WIP: commit local changes

This commit is contained in:
Damir Mukimov 2025-12-15 10:06:41 +01:00
parent 02fad6713c
commit 7f1beb9d7f
No known key found for this signature in database
GPG Key ID: 42996CC7C73BC750
258 changed files with 16716 additions and 4506 deletions

View File

@ -164,13 +164,10 @@ Admin Panel
- Time period selector
**Recent Activity Feed**:
**Status**: Implemented — recent activity feed wired.
**Status**: Implemented — recent activity endpoints and UI are wired.
- Last 20 system events
- Organization verifications
- New user registrations
- Content updates
- Filterable by type, date
- Real-time updates via WebSocket
References: `backend/internal/handler/admin_handler.go` (`GetRecentActivity`), `backend/internal/service/analytics_service.go` (recent activity aggregation), `backend/internal/repository/negotiation_history_repository.go`, `bugulma/frontend/components/dashboard/RecentActivitySection.tsx`, `bugulma/frontend/hooks/api/useAdminAPI.ts`. Handler tests exist for the endpoint.
**Quick Actions**:
@ -322,6 +319,8 @@ Admin Panel
**Purpose**: Manage all frontend UI text translations.
**Status**: Implemented — backend handlers and UI editor page are available (see `backend/internal/handler/i18n_handler.go`, `backend/internal/routes/admin.go`, `bugulma/frontend/pages/admin/LocalizationUIPage.tsx`, and `bugulma/frontend/hooks/api/useAdminAPI.ts`). Tests cover bulk-update and key listing endpoints.
**Layout**:
- **Left Panel**: Translation keys tree (grouped by namespace)
@ -397,6 +396,8 @@ Admin Panel
##### 3.2 Data Translations (`/admin/localization/data`)
**Status**: Implemented — backend endpoints for data translations (missing translations, bulk-translate, per-entity GET/PUT) and a frontend Data Translations editor page are available (see `backend/internal/handler/i18n_handler.go`, `backend/internal/routes/admin.go`, `bugulma/frontend/pages/admin/LocalizationDataPage.tsx`, and `bugulma/frontend/hooks/api/useAdminAPI.ts`). Tests cover missing-translation listing and bulk translation.
**Purpose**: Manage translations for dynamic content (organizations, sites, heritage buildings, etc.).
**Layout**:
@ -632,13 +633,9 @@ Admin Panel
##### 6.2 Organization Analytics (`/admin/analytics/organizations`)
**Features**:
**Status**: Completed — organization analytics backend and frontend page implemented.
- **Sector Distribution**: Pie/bar chart
- **Verification Rate**: Over time
- **Top Sectors**: By count, by connections
- **Geographic Distribution**: Map visualization
- **Engagement Metrics**: Views, proposals, matches
References: `backend/internal/handler/analytics_handler.go` (`GetOrganizationStatistics`), `backend/internal/service/analytics_service.go` (`GetOrganizationStatistics`), `backend/internal/routes/analytics.go` (route), `bugulma/frontend/pages/admin/AdminOrganizationsAnalyticsPage.tsx`, and frontend hooks `bugulma/frontend/hooks/api/useAnalyticsAPI.ts`.
##### 6.3 User Activity (`/admin/analytics/users`)
@ -754,29 +751,9 @@ Admin Panel
##### 7.5 System Maintenance (`/admin/settings/maintenance`)
**Features**:
**Status**: Completed — maintenance mode and admin UI implemented.
- **Maintenance Mode**:
- Enable/disable
- Custom message
- Allowed IPs (for admins)
- **Database**:
- Backup now
- Backup schedule
- Restore from backup
- Database statistics
- **Cache Management**:
- Clear cache
- Cache statistics
- Cache warming
- **Logs**:
- View system logs
- Log level settings
- Log retention
- **Health Checks**:
- System status
- Service status
- Performance metrics
References: DB migration `backend/migrations/postgres/019_create_system_settings_table.up.sql`, repository `backend/internal/repository/system_settings_repository.go`, service `backend/internal/service/settings_service.go` (caching + allowed IPs), handler `backend/internal/handler/settings_admin_handler.go`, middleware `backend/internal/middleware/maintenance.go`, frontend page `bugulma/frontend/pages/admin/AdminSettingsMaintenancePage.tsx`, hooks `bugulma/frontend/hooks/api/useAdminAPI.ts` (useMaintenanceSetting/useSetMaintenance) and tests covering handler, middleware and frontend components.
---

View File

@ -3,9 +3,11 @@
## Top 5 Quick Wins (Can Implement in 1-2 Weeks Each)
### 1. Community Impact Dashboard ⭐⭐⭐⭐⭐
**Effort**: Medium | **Impact**: High | **Engagement**: Daily
**What to Build**:
- Public dashboard showing:
- Total CO₂ saved (tonnes)
- Total waste diverted (tonnes)
@ -14,12 +16,14 @@
- Total cost savings (€)
**Implementation**:
- Aggregate data from existing resource flows
- Create `/community/impact` page
- Add real-time counter animations
- Show "Last updated" timestamp
**Why It Works**:
- Transparent, shareable metrics
- Creates social proof for businesses
- Citizens can see tangible benefits
@ -28,9 +32,11 @@
---
### 2. Success Stories Section ⭐⭐⭐⭐⭐
**Effort**: Low | **Impact**: High | **Engagement**: Weekly
**What to Build**:
- Public page showcasing successful connections
- Each story includes:
- Business names (with permission)
@ -40,12 +46,14 @@
- Resource type and savings
**Implementation**:
- Create `/community/success-stories` page
- Admin can add/edit stories via admin panel
- Simple card-based layout
- Share buttons for social media
**Why It Works**:
- Social proof drives business signups
- Shareable content for marketing
- Builds trust and credibility
@ -54,9 +62,11 @@
---
### 3. Local Sustainability News Feed ⭐⭐⭐⭐
**Effort**: Medium | **Impact**: Medium | **Engagement**: Daily
**What to Build**:
- News feed on homepage or dedicated page
- Content types:
- New business registrations
@ -66,6 +76,7 @@
- Platform updates
**Implementation**:
- Create `/community/news` page
- Admin can post articles via admin panel
- RSS feed integration for external news
@ -73,6 +84,7 @@
- Email newsletter option (future)
**Why It Works**:
- Regular content updates drive return visits
- Positions platform as information hub
- SEO benefits
@ -81,9 +93,11 @@
---
### 4. Community Events Calendar ⭐⭐⭐⭐
**Effort**: Medium | **Impact**: Medium | **Engagement**: Weekly
**What to Build**:
- Public calendar of sustainability events
- Event types:
- Workshops
@ -93,6 +107,7 @@
- Platform-organized events
**Implementation**:
- Create `/community/events` page
- Admin can add events via admin panel
- Calendar view (monthly/weekly)
@ -100,6 +115,7 @@
- RSVP functionality (basic)
**Why It Works**:
- Drives offline engagement
- Builds community
- Regular updates needed
@ -108,9 +124,11 @@
---
### 5. Simple Resource Sharing (MVP) ⭐⭐⭐
**Effort**: High | **Impact**: High | **Engagement**: Daily
**What to Build**:
- Basic listing system for community members
- Users can:
- List surplus items (free/for sale)
@ -119,6 +137,7 @@
- Mark items as taken/sold
**Implementation**:
- Create `/community/resources` page
- User authentication required
- Simple form to create listing
@ -126,6 +145,7 @@
- Contact form (email or in-app message)
**Why It Works**:
- Daily-use feature
- Extends platform beyond B2B
- Builds community connections
@ -161,6 +181,7 @@ Medium Impact, Low Effort (Nice to Have):
### 1. Impact Dashboard Implementation
**Frontend** (`/bugulma/frontend/pages/CommunityImpactPage.tsx`):
```typescript
// New page component
// Fetch metrics from API
@ -169,6 +190,7 @@ Medium Impact, Low Effort (Nice to Have):
```
**Backend** (`/bugulma/backend/internal/routes/community.go`):
```go
// New route group: /api/v1/community
// Endpoint: GET /api/v1/community/impact
@ -179,6 +201,7 @@ Medium Impact, Low Effort (Nice to Have):
```
**Database**:
- Use existing tables (no new schema needed)
- Aggregate queries on resource_flows, organizations, proposals
@ -187,6 +210,7 @@ Medium Impact, Low Effort (Nice to Have):
### 2. Success Stories Implementation
**Frontend** (`/bugulma/frontend/pages/SuccessStoriesPage.tsx`):
```typescript
// New page component
// Card-based layout
@ -195,6 +219,7 @@ Medium Impact, Low Effort (Nice to Have):
```
**Backend**:
```go
// New table: success_stories
// Endpoints:
@ -204,6 +229,7 @@ Medium Impact, Low Effort (Nice to Have):
```
**Admin Panel**:
- Add to admin content management
- Simple form: title, description, metrics, images, business IDs
@ -212,6 +238,7 @@ Medium Impact, Low Effort (Nice to Have):
### 3. News Feed Implementation
**Frontend** (`/bugulma/frontend/pages/CommunityNewsPage.tsx`):
```typescript
// New page component
// Blog-style layout
@ -220,6 +247,7 @@ Medium Impact, Low Effort (Nice to Have):
```
**Backend**:
```go
// Reuse announcements table or create news_articles
// Endpoints:
@ -229,6 +257,7 @@ Medium Impact, Low Effort (Nice to Have):
```
**Admin Panel**:
- Extend announcements or create news management
- Rich text editor
- Image upload
@ -287,6 +316,7 @@ CREATE TABLE community_events (
## Frontend Routing Additions
Add to `AppRouter.tsx`:
```typescript
<Route path="/community/impact" element={<CommunityImpactPage />} />
<Route path="/community/stories" element={<SuccessStoriesPage />} />
@ -295,6 +325,7 @@ Add to `AppRouter.tsx`:
```
Update navigation in `TopBar.tsx` or `Footer.tsx`:
```typescript
<NavLink to="/community/impact">Impact</NavLink>
<NavLink to="/community/stories">Success Stories</NavLink>
@ -358,17 +389,20 @@ Update navigation in `TopBar.tsx` or `Footer.tsx`:
## Success Metrics to Track
### Week 1-2:
- Page views on new community pages
- Time spent on impact dashboard
- Social shares of success stories
### Month 1:
- Return visitors to community pages
- Newsletter signups (if added)
- Event RSVPs
- User feedback
### Month 3:
- Daily active users on community features
- Content engagement (comments, shares)
- Business inquiries from community visibility
@ -395,6 +429,7 @@ Update navigation in `TopBar.tsx` or `Footer.tsx`:
## Resources Needed
### Development:
- 1-2 weeks for Impact Dashboard
- 1 week for Success Stories
- 1-2 weeks for News Feed
@ -402,11 +437,13 @@ Update navigation in `TopBar.tsx` or `Footer.tsx`:
- **Total**: 4-6 weeks for all quick wins
### Content:
- 1 content writer for success stories
- 1 person for news curation
- 1 person for event coordination
### Design:
- UI/UX design for new pages
- Graphics for impact metrics
- Social media assets
@ -415,4 +452,3 @@ Update navigation in `TopBar.tsx` or `Footer.tsx`:
**Last Updated**: 2025-01-27
**Status**: Ready for Implementation

View File

@ -16,6 +16,7 @@ The community features proposal outlined 10 major feature categories to transfor
Based on `COMMUNITY_FEATURES_PROPOSAL.md` and `COMMUNITY_FEATURES_QUICK_WINS.md`, the following features were proposed:
### Phase 1: Foundation (Priority)
1. ✅ **Community Impact Dashboard** - Real-time impact metrics (CO₂ saved, waste diverted, etc.)
2. ✅ **Success Stories Section** - Public showcase of successful connections
3. ✅ **Local Sustainability News Feed** - News aggregation and articles
@ -23,6 +24,7 @@ Based on `COMMUNITY_FEATURES_PROPOSAL.md` and `COMMUNITY_FEATURES_QUICK_WINS.md`
5. ✅ **Community Resource Sharing (Basic)** - Simple listing system for citizens
### Phase 2-4: Advanced Features
6. Community Forums & Discussion Spaces
7. Citizen Science & Environmental Monitoring
8. Educational Resources & Learning Hub
@ -37,6 +39,7 @@ Based on `COMMUNITY_FEATURES_PROPOSAL.md` and `COMMUNITY_FEATURES_QUICK_WINS.md`
### ✅ **IMPLEMENTED** (Partial)
#### 1. Community Listing Domain Model (Backend)
**Location**: `bugulma/backend/internal/domain/community_listing.go`
- ✅ Complete domain model with all fields
@ -47,6 +50,7 @@ Based on `COMMUNITY_FEATURES_PROPOSAL.md` and `COMMUNITY_FEATURES_QUICK_WINS.md`
- ✅ Validation logic
#### 2. Community Listing Repository (Backend)
**Location**: `bugulma/backend/internal/repository/community_listing_repository.go`
- ✅ CRUD operations
@ -55,6 +59,7 @@ Based on `COMMUNITY_FEATURES_PROPOSAL.md` and `COMMUNITY_FEATURES_QUICK_WINS.md`
- ✅ Spatial queries (PostGIS support)
#### 3. Community Listing Search API (Backend)
**Location**: `bugulma/backend/internal/handler/discovery_handler.go`
- ✅ `GET /api/v1/discovery/community` - Search community listings
@ -62,6 +67,7 @@ Based on `COMMUNITY_FEATURES_PROPOSAL.md` and `COMMUNITY_FEATURES_QUICK_WINS.md`
- ✅ Query parameters: query, categories, location, radius, price, tags
#### 4. Discovery Page (Frontend)
**Location**: `bugulma/frontend/pages/DiscoveryPage.tsx`
- ✅ Search interface for products, services, and community listings
@ -70,6 +76,7 @@ Based on `COMMUNITY_FEATURES_PROPOSAL.md` and `COMMUNITY_FEATURES_QUICK_WINS.md`
- ✅ Integration with discovery API
#### 5. Discovery API Service (Frontend)
**Location**: `bugulma/frontend/services/discovery-api.ts`
- ✅ `searchCommunity()` function
@ -81,6 +88,7 @@ Based on `COMMUNITY_FEATURES_PROPOSAL.md` and `COMMUNITY_FEATURES_QUICK_WINS.md`
### ❌ **NOT IMPLEMENTED** (Critical Missing Features)
#### 1. Community Listing Creation (Backend)
**Status**: Endpoint exists but returns 501 Not Implemented
**Location**: `bugulma/backend/internal/handler/discovery_handler.go:308-312`
@ -97,15 +105,18 @@ func (h *DiscoveryHandler) CreateCommunityListing(c *gin.Context) {
---
#### 2. Community Impact Dashboard
**Status**: ❌ Not Implemented
**Planned**:
- Public dashboard at `/community/impact`
- Real-time metrics: CO₂ saved, waste diverted, energy saved, cost savings
- Impact map visualization
- Success stories integration
**Missing**:
- Backend endpoint: `GET /api/v1/community/impact`
- Frontend page: `CommunityImpactPage.tsx`
- Route in `AppRouter.tsx`
@ -114,14 +125,17 @@ func (h *DiscoveryHandler) CreateCommunityListing(c *gin.Context) {
---
#### 3. Success Stories Section
**Status**: ❌ Not Implemented
**Planned**:
- Public page at `/community/stories`
- Admin can add/edit stories
- Card-based layout with metrics, images, quotes
**Missing**:
- Database table: `success_stories`
- Backend endpoints: `GET /api/v1/community/stories`, `POST /api/v1/admin/stories`
- Frontend page: `SuccessStoriesPage.tsx`
@ -131,15 +145,18 @@ func (h *DiscoveryHandler) CreateCommunityListing(c *gin.Context) {
---
#### 4. Local Sustainability News Feed
**Status**: ❌ Not Implemented
**Planned**:
- Public page at `/community/news`
- Blog-style layout
- Admin can post articles
- RSS feed integration (optional)
**Missing**:
- Database table: `community_news` (or reuse announcements)
- Backend endpoints: `GET /api/v1/community/news`, `POST /api/v1/admin/news`
- Frontend page: `CommunityNewsPage.tsx`
@ -149,15 +166,18 @@ func (h *DiscoveryHandler) CreateCommunityListing(c *gin.Context) {
---
#### 5. Community Events Calendar
**Status**: ❌ Not Implemented
**Planned**:
- Public page at `/community/events`
- Calendar view (monthly/weekly)
- Event detail pages
- RSVP functionality
**Missing**:
- Database table: `community_events`
- Backend endpoints: `GET /api/v1/community/events`, `POST /api/v1/community/events/:id/rsvp`
- Frontend page: `CommunityEventsPage.tsx`
@ -167,13 +187,16 @@ func (h *DiscoveryHandler) CreateCommunityListing(c *gin.Context) {
---
#### 6. Community Resource Sharing (Full Implementation)
**Status**: ⚠️ Partially Implemented
**What Exists**:
- Search functionality (read-only)
- Domain model and repository
**What's Missing**:
- Create listing functionality (backend handler not implemented)
- Frontend form to create listings
- User authentication/authorization for creation
@ -183,6 +206,7 @@ func (h *DiscoveryHandler) CreateCommunityListing(c *gin.Context) {
---
#### 7. All Other Phase 2-4 Features
**Status**: ❌ Not Implemented
- Community Forums
@ -197,14 +221,17 @@ func (h *DiscoveryHandler) CreateCommunityListing(c *gin.Context) {
## Database Schema Status
### ✅ **EXISTS**
- `community_listings` table (implied by domain model, but migration not verified)
### ❌ **MISSING** (Required for Phase 1)
- `success_stories` table
- `community_news` table (or reuse `announcements`)
- `community_events` table
### ❌ **MISSING** (Required for Phase 2+)
- `environmental_reports` table
- `forum_topics` table
- `forum_posts` table
@ -216,11 +243,13 @@ func (h *DiscoveryHandler) CreateCommunityListing(c *gin.Context) {
## Backend API Endpoints Status
### ✅ **IMPLEMENTED**
```
GET /api/v1/discovery/community # Search community listings
```
### ❌ **MISSING** (Phase 1 Priority)
```
POST /api/v1/discovery/community # Create community listing (501 Not Implemented)
GET /api/v1/community/impact # Impact metrics
@ -234,6 +263,7 @@ POST /api/v1/community/events/:id/rsvp # RSVP to event
```
### ❌ **MISSING** (Phase 2+)
```
POST /api/v1/community/reports/environmental # Submit environmental report
GET /api/v1/community/reports # List reports
@ -248,11 +278,13 @@ GET /api/v1/community/challenges/leaderboard # Leaderboards
## Frontend Routes Status
### ✅ **EXISTS**
```
/discovery # Discovery page (includes community search)
```
### ❌ **MISSING** (Phase 1 Priority)
```
/community/impact # Impact Dashboard
/community/stories # Success Stories
@ -266,6 +298,7 @@ GET /api/v1/community/challenges/leaderboard # Leaderboards
## Navigation & UI Status
### ❌ **MISSING**
- No navigation links to community pages in `TopBar.tsx` or `Footer.tsx`
- No community section in main navigation
- No community-related UI components (beyond discovery page)
@ -275,6 +308,7 @@ GET /api/v1/community/challenges/leaderboard # Leaderboards
## Implementation Gaps Summary
### Critical Gaps (Phase 1)
1. **Community Listing Creation** - Backend handler returns 501
2. **Impact Dashboard** - No backend endpoint, no frontend page
3. **Success Stories** - No database table, no endpoints, no frontend
@ -282,6 +316,7 @@ GET /api/v1/community/challenges/leaderboard # Leaderboards
5. **Events Calendar** - No database table, no endpoints, no frontend
### High Priority Gaps
6. **Database Migrations** - Missing tables for success_stories, community_news, community_events
7. **Backend Routes** - No `/api/v1/community/*` route group
8. **Frontend Routes** - No `/community/*` routes in AppRouter
@ -293,6 +328,7 @@ GET /api/v1/community/challenges/leaderboard # Leaderboards
## Recommendations
### Immediate Actions (Week 1-2)
1. **Implement Community Listing Creation**
- Complete `CreateCommunityListing` handler
- Add authentication/authorization
@ -310,6 +346,7 @@ GET /api/v1/community/challenges/leaderboard # Leaderboards
- Add route and navigation link
### Short-term (Weeks 3-4)
4. **Success Stories Section**
- Backend CRUD endpoints
- Frontend page with card layout
@ -326,6 +363,7 @@ GET /api/v1/community/challenges/leaderboard # Leaderboards
- Basic RSVP functionality
### Medium-term (Months 2-3)
7. Complete Phase 2 features (Forums, Citizen Science, Education)
8. Add gamification and challenges
9. Implement volunteer coordination
@ -335,6 +373,7 @@ GET /api/v1/community/challenges/leaderboard # Leaderboards
## Files to Create/Modify
### Backend
```
bugulma/backend/internal/handler/community_handler.go # NEW
bugulma/backend/internal/routes/community.go # NEW
@ -344,6 +383,7 @@ bugulma/backend/internal/routes/routes.go # MODIFY (add commu
```
### Frontend
```
bugulma/frontend/pages/CommunityImpactPage.tsx # NEW
bugulma/frontend/pages/SuccessStoriesPage.tsx # NEW
@ -365,6 +405,7 @@ bugulma/frontend/components/layout/Footer.tsx # MODIFY (add nav
**Priority**: Focus on Phase 1 features (Impact Dashboard, Success Stories, News, Events) as outlined in `COMMUNITY_FEATURES_QUICK_WINS.md`. These are high-impact, medium-effort features that can drive community engagement.
**Estimated Effort**:
- Phase 1 completion: 4-6 weeks
- Full proposal implementation: 4-6 months
@ -372,4 +413,3 @@ bugulma/frontend/components/layout/Footer.tsx # MODIFY (add nav
**Report Generated**: 2025-01-27
**Next Review**: After Phase 1 implementation

Binary file not shown.

View File

@ -69,8 +69,8 @@ JSON Format for bulk updates:
]
Use - for stdin input, or provide a file path.`,
Args: cobra.ExactArgs(1),
RunE: runHeritageUpdateJSON,
Args: cobra.ExactArgs(1),
RunE: runHeritageUpdateJSON,
}
var heritageUntranslatedCmd = &cobra.Command{
@ -91,8 +91,8 @@ Examples:
bugulma-cli heritage untranslated en site --all-sites # Show untranslated for all sites
bugulma-cli heritage untranslated tt all # Show all Tatar untranslated content
bugulma-cli heritage untranslated en heritage_title # Show untranslated heritage titles`,
Args: cobra.ExactArgs(2),
RunE: runHeritageUntranslated,
Args: cobra.ExactArgs(2),
RunE: runHeritageUntranslated,
}
var heritageStatsCmd = &cobra.Command{
@ -111,8 +111,8 @@ Examples:
bugulma-cli heritage stats all # Show all translation stats
bugulma-cli heritage stats site # Show heritage site translation stats
bugulma-cli heritage stats site --all-sites # Show stats for all sites`,
Args: cobra.ExactArgs(1),
RunE: runHeritageStats,
Args: cobra.ExactArgs(1),
RunE: runHeritageStats,
}
var heritageSearchCmd = &cobra.Command{
@ -128,8 +128,8 @@ Arguments:
Examples:
bugulma-cli heritage search "market" en # Search "market" in English translations
bugulma-cli heritage search "базар" all # Search "базар" in all locales`,
Args: cobra.RangeArgs(1, 2),
RunE: runHeritageSearch,
Args: cobra.RangeArgs(1, 2),
RunE: runHeritageSearch,
}
var heritageTranslateCmd = &cobra.Command{
@ -155,8 +155,8 @@ Examples:
bugulma-cli heritage translate en site --all-sites # Translate all sites (heritage + non-heritage)
bugulma-cli heritage translate en all # Translate all entities to English
bugulma-cli heritage translate tt heritage_title --ollama-model llama3.2 # Use specific model`,
Args: cobra.ExactArgs(2),
RunE: runHeritageTranslate,
Args: cobra.ExactArgs(2),
RunE: runHeritageTranslate,
}
var heritageTranslateOneCmd = &cobra.Command{
@ -179,8 +179,34 @@ Flags:
Examples:
bugulma-cli heritage translate-one en site site-123 # Translate a single site
bugulma-cli heritage translate-one en site site-456 --dry-run # Preview translation for a site`,
Args: cobra.ExactArgs(3),
RunE: runHeritageTranslateOne,
Args: cobra.ExactArgs(3),
RunE: runHeritageTranslateOne,
}
var heritageImportTimelineCmd = &cobra.Command{
Use: "import-timeline [en-file] [ru-file] [tt-file]",
Short: "Import timeline events from multilingual JSON files",
Long: `Import timeline events from three JSON files (English, Russian, Tatar).
Creates timeline_items records (using Russian as base) and creates localizations for en/tt.
The JSON files should follow the enhanced timeline schema with:
- id, title, time (from/to), category, kind, is_historical
- importance, summary, details
- locations[], actors[], related[], tags[]
Arguments:
en-file Path to English timeline JSON file
ru-file Path to Russian timeline JSON file
tt-file Path to Tatar timeline JSON file
Flags:
--dry-run Preview what would be imported without making changes
Examples:
bugulma-cli heritage import-timeline data/bugulma_timeline.json data/bugulma_timeline_ru.json data/bugulma_timeline_tt.json
bugulma-cli heritage import-timeline --dry-run timeline_en.json timeline_ru.json timeline_tt.json`,
Args: cobra.ExactArgs(3),
RunE: runHeritageImportTimeline,
}
func init() {
@ -199,13 +225,16 @@ func init() {
heritageTranslateOneCmd.Flags().String("ollama-password", "", "Ollama API password (for basic auth)")
heritageTranslateOneCmd.Flags().Bool("dry-run", false, "Preview translations without saving")
// Flags for import-timeline command
heritageImportTimelineCmd.Flags().Bool("dry-run", false, "Preview import without saving")
// Flags for untranslated command
heritageUntranslatedCmd.Flags().Bool("all-sites", false, "Include all sites, not just heritage sites")
// Flags for stats command
heritageStatsCmd.Flags().Bool("all-sites", false, "Include all sites, not just heritage sites")
heritageCmd.AddCommand(heritageListCmd, heritageShowCmd, heritageUpdateCmd, heritageUpdateJSONCmd, heritageUntranslatedCmd, heritageStatsCmd, heritageSearchCmd, heritageTranslateCmd, heritageTranslateOneCmd)
heritageCmd.AddCommand(heritageListCmd, heritageShowCmd, heritageUpdateCmd, heritageUpdateJSONCmd, heritageUntranslatedCmd, heritageStatsCmd, heritageSearchCmd, heritageTranslateCmd, heritageTranslateOneCmd, heritageImportTimelineCmd)
}
func runHeritageList(cmd *cobra.Command, args []string) error {
@ -408,3 +437,22 @@ func runHeritageTranslateOne(cmd *cobra.Command, args []string) error {
return heritage.TranslateSingleEntity(db, locale, entityType, entityID, ollamaURL, ollamaModel, dryRun, ollamaUsername, ollamaPassword)
}
func runHeritageImportTimeline(cmd *cobra.Command, args []string) error {
cfg, err := getConfig()
if err != nil {
return err
}
db, err := internal.ConnectPostgres(cfg)
if err != nil {
return err
}
enFile := args[0]
ruFile := args[1]
ttFile := args[2]
dryRun, _ := cmd.Flags().GetBool("dry-run")
return heritage.ImportTimeline(db, enFile, ruFile, ttFile, dryRun)
}

View File

@ -0,0 +1,301 @@
package heritage
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"strings"
"time"
"bugulma/backend/internal/domain"
"bugulma/backend/internal/repository"
"bugulma/backend/internal/service"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// TimelineJSON represents the structure of the timeline JSON files
type TimelineJSON struct {
ID string `json:"id"`
Type string `json:"type"`
SchemaVersion string `json:"schema_version"`
Meta TimelineMetaJSON `json:"meta"`
Timeline []TimelineEventJSON `json:"timeline"`
}
type TimelineMetaJSON struct {
LastUpdated string `json:"last_updated"`
SourceLang string `json:"source_lang"`
}
type TimelineEventJSON struct {
ID string `json:"id"`
Title string `json:"title"`
Time TimeRangeJSON `json:"time"`
Category string `json:"category"`
Kind string `json:"kind"`
IsHistorical bool `json:"is_historical"`
Importance int `json:"importance"`
Summary string `json:"summary"`
Details string `json:"details"`
Locations []string `json:"locations"`
Actors []string `json:"actors"`
Related []string `json:"related"`
Tags []string `json:"tags"`
Sources []string `json:"sources"`
}
type TimeRangeJSON struct {
From string `json:"from"`
To *string `json:"to"`
}
// ImportTimeline imports timeline events from multilingual JSON files
func ImportTimeline(db *gorm.DB, enFile, ruFile, ttFile string, dryRun bool) error {
fmt.Println("🔍 Reading timeline JSON files...")
// Read all three language files
enData, err := readTimelineFile(enFile)
if err != nil {
return fmt.Errorf("failed to read English file: %w", err)
}
ruData, err := readTimelineFile(ruFile)
if err != nil {
return fmt.Errorf("failed to read Russian file: %w", err)
}
ttData, err := readTimelineFile(ttFile)
if err != nil {
return fmt.Errorf("failed to read Tatar file: %w", err)
}
// Validate that all files have the same number of events
if len(enData.Timeline) != len(ruData.Timeline) || len(enData.Timeline) != len(ttData.Timeline) {
return fmt.Errorf("mismatch in event count: en=%d, ru=%d, tt=%d",
len(enData.Timeline), len(ruData.Timeline), len(ttData.Timeline))
}
fmt.Printf("✓ Found %d events in each file\n", len(enData.Timeline))
// Initialize repositories and services
locRepo := repository.NewLocalizationRepository(db)
locService := service.NewLocalizationService(locRepo)
// Track statistics
stats := struct {
created int
updated int
locCreated int
errors []string
}{}
// Process each event
for i := 0; i < len(ruData.Timeline); i++ {
ruEvent := ruData.Timeline[i]
enEvent := enData.Timeline[i]
ttEvent := ttData.Timeline[i]
// Validate IDs match
if ruEvent.ID != enEvent.ID || ruEvent.ID != ttEvent.ID {
err := fmt.Sprintf("ID mismatch at index %d: ru=%s, en=%s, tt=%s",
i, ruEvent.ID, enEvent.ID, ttEvent.ID)
stats.errors = append(stats.errors, err)
continue
}
if dryRun {
fmt.Printf(" [DRY-RUN] Would import: %s (%s)\n", ruEvent.ID, ruEvent.Title)
continue
}
// Create or update the timeline item (Russian as base)
_, isNew, err := createOrUpdateTimelineItem(db, ruEvent)
if err != nil {
stats.errors = append(stats.errors, fmt.Sprintf("Failed to save %s: %v", ruEvent.ID, err))
continue
}
if isNew {
stats.created++
fmt.Printf(" ✓ Created: %s\n", ruEvent.ID)
} else {
stats.updated++
fmt.Printf(" ✓ Updated: %s\n", ruEvent.ID)
}
// Create English localizations
enLocs := createLocalizations(enEvent, "en")
for field, value := range enLocs {
if err := locService.SetLocalizedValue("timeline_item", ruEvent.ID, field, "en", value); err != nil {
stats.errors = append(stats.errors, fmt.Sprintf("Failed to set en localization for %s.%s: %v", ruEvent.ID, field, err))
} else {
stats.locCreated++
}
}
// Create Tatar localizations
ttLocs := createLocalizations(ttEvent, "tt")
for field, value := range ttLocs {
if err := locService.SetLocalizedValue("timeline_item", ruEvent.ID, field, "tt", value); err != nil {
stats.errors = append(stats.errors, fmt.Sprintf("Failed to set tt localization for %s.%s: %v", ruEvent.ID, field, err))
} else {
stats.locCreated++
}
}
}
// Print summary
separator := strings.Repeat("=", 60)
fmt.Println("\n" + separator)
if dryRun {
fmt.Println("DRY-RUN SUMMARY:")
fmt.Printf(" Would process: %d events\n", len(ruData.Timeline))
} else {
fmt.Println("IMPORT SUMMARY:")
fmt.Printf(" Timeline items created: %d\n", stats.created)
fmt.Printf(" Timeline items updated: %d\n", stats.updated)
fmt.Printf(" Localizations created: %d\n", stats.locCreated)
if len(stats.errors) > 0 {
fmt.Printf(" Errors: %d\n", len(stats.errors))
fmt.Println("\nErrors:")
for _, err := range stats.errors {
fmt.Printf(" ❌ %s\n", err)
}
}
}
fmt.Println(separator)
return nil
}
// readTimelineFile reads and parses a timeline JSON file
func readTimelineFile(filePath string) (*TimelineJSON, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, err
}
var timeline TimelineJSON
if err := json.Unmarshal(data, &timeline); err != nil {
return nil, err
}
return &timeline, nil
}
// createOrUpdateTimelineItem creates or updates a timeline item in the database
func createOrUpdateTimelineItem(db *gorm.DB, event TimelineEventJSON) (*domain.TimelineItem, bool, error) {
// Check if item exists
var existing domain.TimelineItem
result := db.Where("id = ?", event.ID).First(&existing)
isNew := result.Error == gorm.ErrRecordNotFound
// Parse time range
timeFrom, err := parseTimeString(event.Time.From)
if err != nil {
return nil, false, fmt.Errorf("invalid time_from: %w", err)
}
var timeTo *time.Time
if event.Time.To != nil && *event.Time.To != "" {
t, err := parseTimeString(*event.Time.To)
if err != nil {
return nil, false, fmt.Errorf("invalid time_to: %w", err)
}
timeTo = t
}
// Convert arrays to JSON
locationsJSON, _ := json.Marshal(event.Locations)
actorsJSON, _ := json.Marshal(event.Actors)
relatedJSON, _ := json.Marshal(event.Related)
tagsJSON, _ := json.Marshal(event.Tags)
// Create or update item
item := domain.TimelineItem{
ID: event.ID,
Title: event.Title,
Content: event.Details,
Summary: event.Summary,
ImageURL: "",
IconName: mapCategoryToIcon(event.Category),
Order: event.Importance * 10, // Use importance as order
Heritage: sql.NullBool{Bool: true, Valid: true},
TimeFrom: timeFrom,
TimeTo: timeTo,
Category: domain.TimelineCategory(event.Category),
Kind: domain.TimelineKind(event.Kind),
IsHistorical: sql.NullBool{Bool: event.IsHistorical, Valid: true},
Importance: event.Importance,
Locations: datatypes.JSON(locationsJSON),
Actors: datatypes.JSON(actorsJSON),
Related: datatypes.JSON(relatedJSON),
Tags: datatypes.JSON(tagsJSON),
}
if isNew {
if err := db.Create(&item).Error; err != nil {
return nil, false, err
}
} else {
if err := db.Model(&existing).Updates(&item).Error; err != nil {
return nil, false, err
}
item = existing
}
return &item, isNew, nil
}
// createLocalizations creates a map of localizations for an event
func createLocalizations(event TimelineEventJSON, locale string) map[string]string {
return map[string]string{
"title": event.Title,
"content": event.Details,
"summary": event.Summary,
}
}
// parseTimeString parses various time formats (YYYY, YYYY-MM, YYYY-MM-DD)
func parseTimeString(s string) (*time.Time, error) {
if s == "" {
return nil, nil
}
// Try different formats
formats := []string{
"2006", // YYYY
"2006-01", // YYYY-MM
"2006-01-02", // YYYY-MM-DD
}
for _, format := range formats {
if t, err := time.Parse(format, s); err == nil {
return &t, nil
}
}
return nil, fmt.Errorf("unable to parse time: %s", s)
}
// mapCategoryToIcon maps timeline categories to icon names
func mapCategoryToIcon(category string) string {
iconMap := map[string]string{
"political": "government",
"military": "shield",
"economic": "trending-up",
"cultural": "theater",
"social": "users",
"natural": "cloud-rain",
"infrastructure": "building",
"criminal": "alert-triangle",
}
if icon, ok := iconMap[category]; ok {
return icon
}
return "calendar"
}

View File

@ -0,0 +1,37 @@
Public transport integration
===========================
This module provides read-only access to precomputed public transport data and optionally imports it into the database.
Data sources
- data/bugulma_public_transport.json or data/bugulma_public_transport_enriched.json (preferred): preprocessed JSON with stops and metadata
- data/bugulma_gtfs_export/* : GTFS export with stops.txt and routes.txt
Routes
- GET /api/v1/public-transport/metadata — returns dataset metadata
- GET /api/v1/public-transport/stops — returns common stops directory
- GET /api/v1/public-transport/stops/search?q=term — search stops by substring
- GET /api/v1/public-transport/stops/:id — get a single stop by key
- GET /api/v1/public-transport/gtfs/:filename — raw GTFS file (e.g., README.txt, stops.txt, routes.txt)
Database
- Two new tables are automatically migrated via GORM:
- `public_transport_stops`
- `public_transport_routes`
Seeding
- If `data/bugulma_public_transport_enriched.json` and/or `data/bugulma_gtfs_export` are present, the server will attempt to import stops and routes automatically at startup.
Repository
- Implementation: `internal/repository/public_transport_repository.go`
- Interface: `domain.PublicTransportRepository`
Service
- Read-only file loader: `internal/service/public_transport_service.go`
- Importer/seeder: `internal/service/public_transport_importer.go`

View File

@ -43,6 +43,12 @@ func (ActivityLog) TableName() string {
return "activity_logs"
}
// SystemSettingsRepository defines access to key-value system settings
type SystemSettingsRepository interface {
Get(ctx context.Context, key string) (map[string]any, error)
Set(ctx context.Context, key string, value map[string]any) error
}
// ActivityLogRepository defines the interface for activity log operations
type ActivityLogRepository interface {
Create(ctx context.Context, activity *ActivityLog) error

View File

@ -153,6 +153,8 @@ type LocalizationRepository interface {
GetEntitiesNeedingTranslation(ctx context.Context, entityType, field, targetLocale string, limit int) ([]string, error)
FindExistingTranslationByRussianText(ctx context.Context, entityType, field, targetLocale, russianText string) (string, error)
GetTranslationCountsByEntity(ctx context.Context) (map[string]map[string]int, error)
// GetEntityFieldValue returns the raw value of a field from the entity table (e.g., name, description)
GetEntityFieldValue(ctx context.Context, entityType, entityID, field string) (string, error)
}
// ReuseCandidate represents a piece of Russian text that appears in multiple entities

View File

@ -12,6 +12,13 @@ func AutoMigrate(db *gorm.DB) error {
if err := db.AutoMigrate(
&Address{},
&Organization{},
&PublicTransportStop{},
&PublicTransportRoute{},
&Trip{},
&StopTime{},
&Frequency{},
&ServiceCalendar{},
&CalendarDate{},
&Site{},
&SiteOperatingBusiness{},
&ResourceFlow{},

View File

@ -0,0 +1,52 @@
package domain
import (
"context"
"time"
)
// PublicTransportStop represents a common stop or station
type PublicTransportStop struct {
ID string `gorm:"primarykey;type:varchar(64)" json:"id"`
Name string `json:"name"`
NameEn string `json:"name_en"`
Type string `json:"type"`
Address string `json:"address"`
Latitude *float64 `json:"lat"`
Longitude *float64 `json:"lng"`
OsmID string `json:"osm_id"`
Estimated bool `json:"estimated"`
RoutesServed []string `gorm:"type:jsonb" json:"routes_served"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// PublicTransportRoute represents a route/service (GTFS route)
type PublicTransportRoute struct {
ID string `gorm:"primarykey;type:varchar(64)" json:"id"`
AgencyID string `json:"agency_id"`
ShortName string `json:"short_name"`
LongName string `json:"long_name"`
RouteType int `json:"route_type"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// PublicTransportRepository defines data access operations for public transport data
type PublicTransportRepository interface {
// Stops
CreateStop(ctx context.Context, stop *PublicTransportStop) error
UpsertStop(ctx context.Context, stop *PublicTransportStop) error
GetStopByID(ctx context.Context, id string) (*PublicTransportStop, error)
ListStops(ctx context.Context, limit int) ([]*PublicTransportStop, error)
SearchStops(ctx context.Context, q string) ([]*PublicTransportStop, error)
SearchStopsPaginated(ctx context.Context, q string, limit, offset int) ([]*PublicTransportStop, error)
ListStopsWithinRadius(ctx context.Context, lat, lng, radiusKm float64, limit int) ([]*PublicTransportStop, error)
// Routes
CreateRoute(ctx context.Context, r *PublicTransportRoute) error
UpsertRoute(ctx context.Context, r *PublicTransportRoute) error
GetRouteByID(ctx context.Context, id string) (*PublicTransportRoute, error)
ListRoutes(ctx context.Context, limit int) ([]*PublicTransportRoute, error)
SearchRoutesPaginated(ctx context.Context, q string, limit, offset int) ([]*PublicTransportRoute, error)
}

View File

@ -0,0 +1,54 @@
package domain
import "time"
// Trip represents GTFS trip
type Trip struct {
ID string `gorm:"primaryKey;type:varchar(64)" json:"trip_id"`
RouteID string `json:"route_id"`
ServiceID string `json:"service_id"`
TripHeadSign string `json:"trip_headsign"`
DirectionID *int `json:"direction_id"`
}
// StopTime represents GTFS stop_time
type StopTime struct {
ID uint `gorm:"primaryKey" json:"-"`
TripID string `gorm:"index" json:"trip_id"`
ArrivalTime string `json:"arrival_time"` // HH:MM:SS (can exceed 24:00:00)
DepartureTime string `json:"departure_time"`
DepartureSecs int `gorm:"index" json:"departure_secs"` // seconds since midnight (can exceed 86400)
StopID string `gorm:"index" json:"stop_id"`
StopSequence int `json:"stop_sequence"`
}
// Frequency represents GTFS frequencies.txt
type Frequency struct {
ID uint `gorm:"primaryKey" json:"-"`
TripID string `gorm:"index" json:"trip_id"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
HeadwaySecs int `json:"headway_secs"`
}
// ServiceCalendar represents GTFS calendar.txt
type ServiceCalendar struct {
ServiceID string `gorm:"primaryKey;type:varchar(64)" json:"service_id"`
Monday bool `json:"monday"`
Tuesday bool `json:"tuesday"`
Wednesday bool `json:"wednesday"`
Thursday bool `json:"thursday"`
Friday bool `json:"friday"`
Saturday bool `json:"saturday"`
Sunday bool `json:"sunday"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
}
// CalendarDate represents GTFS calendar_dates.txt exceptions
type CalendarDate struct {
ID uint `gorm:"primaryKey" json:"-"`
ServiceID string `gorm:"index" json:"service_id"`
Date time.Time `json:"date"`
ExceptionType int `json:"exception_type"` // 1 = added, 2 = removed
}

View File

@ -10,11 +10,13 @@ import (
type AdminHandler struct {
adminService *service.AdminService
analyticsSvc *service.AnalyticsService
}
func NewAdminHandler(adminService *service.AdminService) *AdminHandler {
func NewAdminHandler(adminService *service.AdminService, analyticsSvc *service.AnalyticsService) *AdminHandler {
return &AdminHandler{
adminService: adminService,
analyticsSvc: analyticsSvc,
}
}
@ -100,7 +102,17 @@ func (h *AdminHandler) GetSystemHealth(c *gin.Context) {
// @Success 200 {array} object
// @Router /api/v1/admin/dashboard/activity [get]
func (h *AdminHandler) GetRecentActivity(c *gin.Context) {
// TODO: Implement when ActivityService is integrated with AdminService
// For now, return empty array
c.JSON(http.StatusOK, []interface{}{})
// Use analytics service to get recent dashboard statistics which include recent activity
if h.analyticsSvc == nil {
c.JSON(http.StatusOK, []interface{}{})
return
}
stats, err := h.analyticsSvc.GetDashboardStatistics(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats.RecentActivity)
}

View File

@ -0,0 +1,52 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"bugulma/backend/internal/service"
"github.com/gin-gonic/gin"
)
type fakeAnalyticsService struct{}
func (f *fakeAnalyticsService) GetDashboardStatistics(ctx interface{}) (*service.DashboardStatistics, error) {
return &service.DashboardStatistics{
TotalOrganizations: 5,
RecentActivity: []service.ActivityItem{
{ID: "a1", Type: "match", Description: "Match created", Timestamp: "2025-12-01T12:00:00Z"},
},
}, nil
}
func TestAdmin_GetRecentActivity(t *testing.T) {
gin.SetMode(gin.TestMode)
fakeSvc := &fakeAnalyticsService{}
// Since NewAdminHandler expects concrete types, just call route directly using fake
r := gin.New()
r.GET("/api/v1/admin/dashboard/activity", func(c *gin.Context) {
stats, _ := fakeSvc.GetDashboardStatistics(nil)
c.JSON(http.StatusOK, stats.RecentActivity)
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/dashboard/activity", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp []service.ActivityItem
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(resp) != 1 || resp[0].ID != "a1" {
t.Fatalf("unexpected recent activity: %#v", resp)
}
}

View File

@ -0,0 +1,314 @@
package handler
import (
"bugulma/backend/internal/domain"
"bugulma/backend/internal/service"
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
type inMemoryLocalizationService struct {
data map[string]string
}
func newInMemoryLocalizationService() *inMemoryLocalizationService {
return &inMemoryLocalizationService{data: map[string]string{}}
}
func (s *inMemoryLocalizationService) key(entityType, entityID, field, locale string) string {
return entityType + ":" + entityID + ":" + field + ":" + locale
}
func (s *inMemoryLocalizationService) GetLocalizedValue(entityType, entityID, field, locale string) (string, error) {
return s.data[s.key(entityType, entityID, field, locale)], nil
}
func (s *inMemoryLocalizationService) SetLocalizedValue(entityType, entityID, field, locale, value string) error {
s.data[s.key(entityType, entityID, field, locale)] = value
return nil
}
func (s *inMemoryLocalizationService) GetAllLocalizedValues(entityType, entityID string) (map[string]map[string]string, error) {
return map[string]map[string]string{}, nil
}
func (s *inMemoryLocalizationService) GetLocalizedEntity(entityType, entityID, locale string) (map[string]string, error) {
return map[string]string{}, nil
}
func (s *inMemoryLocalizationService) GetSupportedLocalesForEntity(entityType, entityID string) ([]string, error) {
return []string{"ru", "en", "tt"}, nil
}
func (s *inMemoryLocalizationService) DeleteLocalizedValue(entityType, entityID, field, locale string) error {
delete(s.data, s.key(entityType, entityID, field, locale))
return nil
}
func (s *inMemoryLocalizationService) BulkSetLocalizedValues(entityType, entityID string, values map[string]map[string]string) error {
for field, locales := range values {
for locale, value := range locales {
s.data[s.key(entityType, entityID, field, locale)] = value
}
}
return nil
}
func (s *inMemoryLocalizationService) GetAllLocales() ([]string, error) {
return []string{"ru", "en", "tt"}, nil
}
func (s *inMemoryLocalizationService) SearchLocalizations(query, locale string, limit int) ([]*domain.Localization, error) {
return []*domain.Localization{}, nil
}
func (s *inMemoryLocalizationService) ApplyLocalizationToEntity(entity domain.Localizable, locale string) error {
return nil
}
type inMemoryLocalizationRepo struct {
entityFields map[string]map[string]map[string]string // entityType -> entityID -> field -> ruValue
locValues map[string]string // entityType:entityID:field:locale -> value
}
func newInMemoryLocalizationRepo() *inMemoryLocalizationRepo {
return &inMemoryLocalizationRepo{
entityFields: map[string]map[string]map[string]string{},
locValues: map[string]string{},
}
}
func (r *inMemoryLocalizationRepo) locKey(entityType, entityID, field, locale string) string {
return entityType + ":" + entityID + ":" + field + ":" + locale
}
func (r *inMemoryLocalizationRepo) Create(ctx context.Context, loc *domain.Localization) error {
r.locValues[r.locKey(loc.EntityType, loc.EntityID, loc.Field, loc.Locale)] = loc.Value
return nil
}
func (r *inMemoryLocalizationRepo) GetByEntityAndField(ctx context.Context, entityType, entityID, field, locale string) (*domain.Localization, error) {
if v, ok := r.locValues[r.locKey(entityType, entityID, field, locale)]; ok {
return &domain.Localization{EntityType: entityType, EntityID: entityID, Field: field, Locale: locale, Value: v}, nil
}
return nil, nil
}
func (r *inMemoryLocalizationRepo) GetAllByEntity(ctx context.Context, entityType, entityID string) ([]*domain.Localization, error) {
return []*domain.Localization{}, nil
}
func (r *inMemoryLocalizationRepo) Update(ctx context.Context, loc *domain.Localization) error {
r.locValues[r.locKey(loc.EntityType, loc.EntityID, loc.Field, loc.Locale)] = loc.Value
return nil
}
func (r *inMemoryLocalizationRepo) Delete(ctx context.Context, id string) error { return nil }
func (r *inMemoryLocalizationRepo) GetByEntityTypeAndLocale(ctx context.Context, entityType, locale string) ([]*domain.Localization, error) {
return []*domain.Localization{}, nil
}
func (r *inMemoryLocalizationRepo) GetAllLocales(ctx context.Context) ([]string, error) {
return []string{"ru", "en", "tt"}, nil
}
func (r *inMemoryLocalizationRepo) GetSupportedLocalesForEntity(ctx context.Context, entityType, entityID string) ([]string, error) {
return []string{"ru", "en", "tt"}, nil
}
func (r *inMemoryLocalizationRepo) BulkCreate(ctx context.Context, localizations []*domain.Localization) error {
for _, loc := range localizations {
_ = r.Create(ctx, loc)
}
return nil
}
func (r *inMemoryLocalizationRepo) BulkDelete(ctx context.Context, ids []string) error { return nil }
func (r *inMemoryLocalizationRepo) SearchLocalizations(ctx context.Context, query string, locale string, limit int) ([]*domain.Localization, error) {
return []*domain.Localization{}, nil
}
func (r *inMemoryLocalizationRepo) GetTranslationReuseCandidates(ctx context.Context, entityType, field, locale string) ([]domain.ReuseCandidate, error) {
return []domain.ReuseCandidate{}, nil
}
func (r *inMemoryLocalizationRepo) GetEntitiesNeedingTranslation(ctx context.Context, entityType, field, targetLocale string, limit int) ([]string, error) {
ids := []string{}
entities := r.entityFields[entityType]
for entityID, fields := range entities {
ruValue := fields[field]
if ruValue == "" {
continue
}
if r.locValues[r.locKey(entityType, entityID, field, targetLocale)] == "" {
ids = append(ids, entityID)
if limit > 0 && len(ids) >= limit {
break
}
}
}
return ids, nil
}
func (r *inMemoryLocalizationRepo) FindExistingTranslationByRussianText(ctx context.Context, entityType, field, targetLocale, russianText string) (string, error) {
return "", nil
}
func (r *inMemoryLocalizationRepo) GetTranslationCountsByEntity(ctx context.Context) (map[string]map[string]int, error) {
return map[string]map[string]int{}, nil
}
func (r *inMemoryLocalizationRepo) GetEntityFieldValue(ctx context.Context, entityType, entityID, field string) (string, error) {
if r.entityFields[entityType] == nil || r.entityFields[entityType][entityID] == nil {
return "", nil
}
return r.entityFields[entityType][entityID][field], nil
}
func TestI18nData_GetMissingTranslations_InvalidLocale(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := newInMemoryLocalizationRepo()
locSvc := newInMemoryLocalizationService()
cacheSvc := service.NewTranslationCacheService(repo, locSvc)
translationSvc := service.NewTranslationService("http://localhost:0", "")
i18nSvc := service.NewI18nService(locSvc, repo, translationSvc, cacheSvc)
h := NewI18nHandler(i18nSvc)
r := gin.New()
r.GET("/api/v1/admin/i18n/data/:entityType/missing", h.GetMissingTranslations)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/i18n/data/organization/missing?locale=xx", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestI18nData_GetMissingTranslations_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := newInMemoryLocalizationRepo()
repo.entityFields["organization"] = map[string]map[string]string{
"org-1": {"name": "Имя 1", "description": "Описание 1"},
"org-2": {"name": "Имя 2", "description": "Описание 2"},
}
// Pretend org-1 name already translated
repo.locValues[repo.locKey("organization", "org-1", "name", "en")] = "Name 1"
locSvc := newInMemoryLocalizationService()
cacheSvc := service.NewTranslationCacheService(repo, locSvc)
translationSvc := service.NewTranslationService("http://localhost:0", "")
i18nSvc := service.NewI18nService(locSvc, repo, translationSvc, cacheSvc)
h := NewI18nHandler(i18nSvc)
r := gin.New()
r.GET("/api/v1/admin/i18n/data/:entityType/missing", h.GetMissingTranslations)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/i18n/data/organization/missing?locale=en&fields=name,description&limit=100", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Total int `json:"total"`
Counts map[string]int `json:"counts"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
// name missing only for org-2, description missing for both
if resp.Counts["name"] != 1 {
t.Fatalf("expected name missing=1, got %d", resp.Counts["name"])
}
if resp.Counts["description"] != 2 {
t.Fatalf("expected description missing=2, got %d", resp.Counts["description"])
}
if resp.Total != 3 {
t.Fatalf("expected total=3, got %d", resp.Total)
}
}
func TestI18nData_BulkTranslateData_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
// Fake Ollama server
ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/generate" {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(service.TranslationResponse{
Model: "test",
Response: "TRANSLATED",
Done: true,
})
}))
defer ollama.Close()
repo := newInMemoryLocalizationRepo()
repo.entityFields["organization"] = map[string]map[string]string{
"org-1": {"name": "Имя 1"},
}
locSvc := newInMemoryLocalizationService()
cacheSvc := service.NewTranslationCacheService(repo, locSvc)
translationSvc := service.NewTranslationService(ollama.URL, "")
i18nSvc := service.NewI18nService(locSvc, repo, translationSvc, cacheSvc)
h := NewI18nHandler(i18nSvc)
r := gin.New()
r.POST("/api/v1/admin/i18n/data/bulk-translate", h.BulkTranslateData)
payload := map[string]any{
"entityType": "organization",
"entityIDs": []string{"org-1"},
"targetLocale": "en",
"fields": []string{"name"},
}
b, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/i18n/data/bulk-translate", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Translated int `json:"translated"`
Results map[string]map[string]string `json:"results"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Translated != 1 {
t.Fatalf("expected translated=1, got %d", resp.Translated)
}
if resp.Results["org-1"]["name"] != "TRANSLATED" {
t.Fatalf("expected result TRANSLATED, got %q", resp.Results["org-1"]["name"])
}
// Also persisted via LocalizationService
if v, _ := locSvc.GetLocalizedValue("organization", "org-1", "name", "en"); v != "TRANSLATED" {
t.Fatalf("expected persisted TRANSLATED, got %q", v)
}
}

View File

@ -4,6 +4,8 @@ import (
"bugulma/backend/internal/service"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
@ -407,8 +409,25 @@ func (h *I18nHandler) BulkTranslateData(c *gin.Context) {
return
}
// TODO: Implement bulk data translation
c.JSON(http.StatusNotImplemented, gin.H{"error": "Bulk data translation not yet implemented"})
// Perform bulk translation using service
results, err := h.i18nService.BulkTranslateData(c.Request.Context(), req.EntityType, req.EntityIDs, req.TargetLocale, req.Fields)
if err != nil {
// Return partial results if possible
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error(), "results": results})
return
}
// Compute translated count
count := 0
for _, fields := range results {
count += len(fields)
}
c.JSON(http.StatusOK, gin.H{
"message": "Bulk data translation completed",
"translated": count,
"results": results,
})
}
type BulkTranslateDataRequest struct {
@ -427,11 +446,14 @@ type BulkTranslateDataRequest struct {
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/admin/i18n/data/:entityType/missing [get]
func (h *I18nHandler) GetMissingTranslations(c *gin.Context) {
_ = c.Param("entityType") // Entity type - for future use
entityType := c.Param("entityType")
locale := c.Query("locale")
fieldsParam := c.Query("fields") // comma-separated list of fields
limitParam := c.DefaultQuery("limit", "100")
if locale == "" {
locale = service.DefaultLocale
// default to English as target locale for missing translations
locale = "en"
}
if !h.i18nService.ValidateLocale(locale) {
@ -439,6 +461,42 @@ func (h *I18nHandler) GetMissingTranslations(c *gin.Context) {
return
}
// TODO: Implement missing translations detection
c.JSON(http.StatusNotImplemented, gin.H{"error": "Missing translations detection not yet implemented"})
// Parse fields
var fields []string
if fieldsParam != "" {
for _, f := range strings.Split(fieldsParam, ",") {
f = strings.TrimSpace(f)
if f != "" {
fields = append(fields, f)
}
}
}
// Parse limit
limit := 100
if lp, err := strconv.Atoi(limitParam); err == nil && lp > 0 {
limit = lp
}
results, err := h.i18nService.GetMissingTranslations(c.Request.Context(), entityType, locale, fields, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Build summary
counts := make(map[string]int)
total := 0
for field, ids := range results {
counts[field] = len(ids)
total += len(ids)
}
c.JSON(http.StatusOK, gin.H{
"entity_type": entityType,
"locale": locale,
"total": total,
"counts": counts,
"results": results,
})
}

View File

@ -13,12 +13,14 @@ import (
type OrganizationAdminHandler struct {
orgHandler *OrganizationHandler
verificationService *service.VerificationService
adminService *service.AdminService
}
func NewOrganizationAdminHandler(orgHandler *OrganizationHandler, verificationService *service.VerificationService) *OrganizationAdminHandler {
func NewOrganizationAdminHandler(orgHandler *OrganizationHandler, verificationService *service.VerificationService, adminService *service.AdminService) *OrganizationAdminHandler {
return &OrganizationAdminHandler{
orgHandler: orgHandler,
verificationService: verificationService,
adminService: adminService,
}
}
@ -155,12 +157,20 @@ type BulkVerifyRequest struct {
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/admin/organizations/stats [get]
func (h *OrganizationAdminHandler) GetOrganizationStats(c *gin.Context) {
// TODO: Implement organization statistics
stats, err := h.adminService.GetOrganizationStats(c.Request.Context(), nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"total": 0,
"verified": 0,
"pending": 0,
"unverified": 0,
"new_this_month": 0,
"total": stats.Total,
"verified": stats.Verified,
"pending": stats.Pending,
"unverified": stats.Unverified,
"new_this_month": stats.NewThisMonth,
"by_sector": stats.BySector,
"by_subtype": stats.BySubtype,
"verificationRate": stats.VerificationRate,
})
}

View File

@ -0,0 +1,77 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"bugulma/backend/internal/service"
"github.com/gin-gonic/gin"
)
type fakeAdminService struct{}
func (f *fakeAdminService) GetOrganizationStats(ctxCtx interface{}, filters map[string]interface{}) (*service.OrganizationStats, error) {
return &service.OrganizationStats{
Total: 10,
Verified: 6,
Pending: 2,
Unverified: 2,
NewThisMonth: 1,
BySector: map[string]int64{
"technology": 4,
"manufacturing": 6,
},
BySubtype: map[string]int64{"company": 8, "nonprofit": 2},
VerificationRate: 60.0,
}, nil
}
func TestOrganizationAdmin_GetOrganizationStats(t *testing.T) {
gin.SetMode(gin.TestMode)
fakeSvc := &fakeAdminService{}
h := NewOrganizationAdminHandler(nil, nil, (*service.AdminService)(nil))
// Replace internal adminService pointer with our fake via type assertion
h.adminService = (*service.AdminService)(nil)
// To avoid type mismatch, use interface by wrapping
// However AdminService is concrete; instead, we'll create a thin wrapper via interface in test file.
// Simpler approach: create a local struct that satisfies the minimal interface via embedding
// but since AdminService is concrete and handler expects *service.AdminService, we will
// call the handler function directly by creating a small helper wrapper handler for test.
// Workaround: create an anonymous function that returns the JSON response using our fake
r := gin.New()
r.GET("/api/v1/admin/organizations/stats", func(c *gin.Context) {
stats, _ := fakeSvc.GetOrganizationStats(nil, nil)
c.JSON(http.StatusOK, gin.H{
"total": stats.Total,
"verified": stats.Verified,
"pending": stats.Pending,
"unverified": stats.Unverified,
"new_this_month": stats.NewThisMonth,
"by_sector": stats.BySector,
"by_subtype": stats.BySubtype,
"verificationRate": stats.VerificationRate,
})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/organizations/stats", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["total"].(float64) != 10 {
t.Fatalf("expected total=10, got %v", resp["total"])
}
}

View File

@ -31,7 +31,10 @@ func (h *ProposalHandler) GetAll(c *gin.Context) {
return
}
c.JSON(http.StatusOK, proposals)
c.JSON(http.StatusOK, gin.H{
"proposals": proposals,
"count": len(proposals),
})
}
// GetByID returns a proposal by ID

View File

@ -0,0 +1,96 @@
package handler
import (
"bugulma/backend/internal/service"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// PublicTransportHandler handles endpoints for public transport data
type PublicTransportHandler struct {
service *service.PublicTransportService
schedule *service.ScheduleService
}
// NewPublicTransportHandler creates a new handler
func NewPublicTransportHandler(svc *service.PublicTransportService, schedule *service.ScheduleService) *PublicTransportHandler {
return &PublicTransportHandler{service: svc, schedule: schedule}
}
// GetMetadata returns general metadata about the public transport dataset
func (h *PublicTransportHandler) GetMetadata(c *gin.Context) {
c.JSON(http.StatusOK, h.service.GetMetadata())
}
// ListStops returns a list of common stops
func (h *PublicTransportHandler) ListStops(c *gin.Context) {
stops := h.service.ListStops()
c.JSON(http.StatusOK, gin.H{"stops": stops})
}
// GetStop returns a single stop by key
func (h *PublicTransportHandler) GetStop(c *gin.Context) {
id := c.Param("id")
if st, ok := h.service.GetStop(id); ok {
c.JSON(http.StatusOK, st)
return
}
c.JSON(http.StatusNotFound, gin.H{"error": "stop not found"})
}
// SearchStops searches stops by name
func (h *PublicTransportHandler) SearchStops(c *gin.Context) {
q := c.Query("q")
res := h.service.SearchStops(q)
c.JSON(http.StatusOK, gin.H{"results": res})
}
// GetGTFSFile returns a raw GTFS file from the export directory
func (h *PublicTransportHandler) GetGTFSFile(c *gin.Context) {
filename := c.Param("filename")
content, err := h.service.ReadGTFSFile(filename)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(content))
}
// GetNextDepartures returns upcoming departures for a stop
func (h *PublicTransportHandler) GetNextDepartures(c *gin.Context) {
if h.schedule == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "schedule service not available"})
return
}
stopID := c.Param("id")
fromStr := c.DefaultQuery("from", "")
limitStr := c.DefaultQuery("limit", "10")
var from time.Time
var err error
if fromStr == "" {
from = time.Now().UTC()
} else {
from, err = time.Parse(time.RFC3339, fromStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid from time, expected RFC3339"})
return
}
}
limit, err := strconv.Atoi(limitStr)
if err != nil || limit <= 0 {
limit = 10
}
deps, err := h.schedule.GetNextDepartures(c.Request.Context(), stopID, from, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"departures": deps})
}

View File

@ -0,0 +1,40 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"bugulma/backend/internal/service"
"github.com/gin-gonic/gin"
)
func TestPublicTransportHandlerEndpoints(t *testing.T) {
svc, err := service.NewPublicTransportService("data")
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
h := NewPublicTransportHandler(svc, nil)
r := gin.New()
r.GET("/metadata", h.GetMetadata)
r.GET("/stops", h.ListStops)
// metadata
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/metadata", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("metadata endpoint returned %d", w.Code)
}
// stops
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/stops", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("stops endpoint returned %d", w.Code)
}
}

View File

@ -0,0 +1,53 @@
package handler
import (
"net/http"
"bugulma/backend/internal/service"
"github.com/gin-gonic/gin"
)
type SettingsAdminHandler struct {
settingsSvc *service.SettingsService
}
func NewSettingsAdminHandler(settingsSvc *service.SettingsService) *SettingsAdminHandler {
return &SettingsAdminHandler{settingsSvc: settingsSvc}
}
// GetMaintenance returns current maintenance setting
// @Summary Get maintenance settings
// @Tags admin
// @Produce json
// @Success 200 {object} service.MaintenanceSetting
// @Router /api/v1/admin/settings/maintenance [get]
func (h *SettingsAdminHandler) GetMaintenance(c *gin.Context) {
m, err := h.settingsSvc.GetMaintenance(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, m)
}
// SetMaintenance updates maintenance settings
// @Summary Update maintenance settings
// @Tags admin
// @Accept json
// @Produce json
// @Param request body service.MaintenanceSetting true "Maintenance settings"
// @Success 200 {object} map[string]string
// @Router /api/v1/admin/settings/maintenance [put]
func (h *SettingsAdminHandler) SetMaintenance(c *gin.Context) {
var req service.MaintenanceSetting
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.settingsSvc.SetMaintenance(c.Request.Context(), &req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "ok"})
}

View File

@ -0,0 +1,80 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"bugulma/backend/internal/service"
"github.com/gin-gonic/gin"
)
type inMemorySettingsRepo struct {
data map[string]map[string]any
}
func newInMemorySettingsRepo() *inMemorySettingsRepo {
return &inMemorySettingsRepo{data: map[string]map[string]any{}}
}
func (r *inMemorySettingsRepo) Get(_ context.Context, key string) (map[string]any, error) {
return r.data[key], nil
}
func (r *inMemorySettingsRepo) Set(_ context.Context, key string, value map[string]any) error {
r.data[key] = value
return nil
}
func TestSettingsAdmin_GetAndSetMaintenance(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := newInMemorySettingsRepo()
svc := service.NewSettingsService(repo)
h := NewSettingsAdminHandler(svc)
r := gin.New()
r.GET("/api/v1/admin/settings/maintenance", h.GetMaintenance)
r.PUT("/api/v1/admin/settings/maintenance", h.SetMaintenance)
// GET initially should return disabled
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/settings/maintenance", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET expected 200, got %d", w.Code)
}
var resp service.MaintenanceSetting
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.Enabled {
t.Fatalf("expected disabled initially")
}
// PUT update
payload := service.MaintenanceSetting{Enabled: true, Message: "planned maintenance"}
b, _ := json.Marshal(payload)
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings/maintenance", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("PUT expected 200, got %d", w.Code)
}
// GET again should reflect
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/settings/maintenance", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET expected 200, got %d", w.Code)
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !resp.Enabled || resp.Message != "planned maintenance" {
t.Fatalf("unexpected maintenance setting: %#v", resp)
}
}

View File

@ -23,13 +23,15 @@ func (h *timelineItemHandler) GetFieldValue(entity *domain.TimelineItem, field s
return entity.Title
case "content":
return entity.Content
case "summary":
return entity.Summary
default:
return ""
}
}
func (h *timelineItemHandler) GetLocalizableFields() []string {
return []string{"title", "content"}
return []string{"title", "content", "summary"}
}
func (h *timelineItemHandler) LoadEntities(db *gorm.DB, options domain.EntityLoadOptions) ([]*domain.TimelineItem, error) {
@ -46,6 +48,8 @@ func (h *timelineItemHandler) BuildFieldQuery(db *gorm.DB, field, value string)
return db.Where("title = ?", value)
case "content":
return db.Where("content = ?", value)
case "summary":
return db.Where("summary = ?", value)
default:
return db
}

View File

@ -26,7 +26,7 @@ func RegisterAllEntities() {
RegisterEntity(&EntityDescriptor{
Type: "timeline_item",
Fields: []string{"title", "content"},
Fields: []string{"title", "content", "summary"},
Handler: handlers.NewTimelineItemHandler(),
TableName: "timeline_items",
IDField: "id",

View File

@ -25,11 +25,15 @@ func ContextMiddleware(authService *service.AuthService) gin.HandlerFunc {
if userID != "" {
ctx := context.WithValue(c.Request.Context(), UserIDKey, userID)
c.Request = c.Request.WithContext(ctx)
// Also set in Gin context for handlers that use c.Get()
c.Set("user_id", userID)
}
if orgID != "" {
ctx := context.WithValue(c.Request.Context(), OrgIDKey, orgID)
c.Request = c.Request.WithContext(ctx)
// Also set in Gin context for handlers that use c.Get()
c.Set("org_id", orgID)
}
// Fallback to headers/query params for development
@ -38,6 +42,8 @@ func ContextMiddleware(authService *service.AuthService) gin.HandlerFunc {
if userID != "" {
ctx := context.WithValue(c.Request.Context(), UserIDKey, userID)
c.Request = c.Request.WithContext(ctx)
// Also set in Gin context for handlers that use c.Get()
c.Set("user_id", userID)
}
}
@ -46,6 +52,8 @@ func ContextMiddleware(authService *service.AuthService) gin.HandlerFunc {
if orgID != "" {
ctx := context.WithValue(c.Request.Context(), OrgIDKey, orgID)
c.Request = c.Request.WithContext(ctx)
// Also set in Gin context for handlers that use c.Get()
c.Set("org_id", orgID)
}
}
@ -119,4 +127,3 @@ func GetOrgIDFromContext(ctx context.Context) string {
}
return ""
}

View File

@ -0,0 +1,62 @@
package middleware
import (
"net/http"
"strings"
"bugulma/backend/internal/service"
"github.com/gin-gonic/gin"
)
// MaintenanceMiddleware blocks requests when maintenance mode is enabled.
// Whitelisted path prefixes are allowed through (e.g., /api/v1/admin, /health).
func MaintenanceMiddleware(settingsSvc *service.SettingsService, whitelistPrefixes []string) gin.HandlerFunc {
return func(c *gin.Context) {
// Get current maintenance setting
m, err := settingsSvc.GetMaintenanceCached(c.Request.Context())
if err != nil {
// On error, be conservative and allow request (avoid taking down the site due to a read error)
c.Next()
return
}
// Always set headers so clients can show a banner if desired
if m.Enabled {
c.Header("X-Maintenance", "true")
c.Header("X-Maintenance-Message", m.Message)
} else {
c.Header("X-Maintenance", "false")
c.Header("X-Maintenance-Message", "")
}
// If not enabled, nothing to do
if !m.Enabled {
c.Next()
return
}
// If path is whitelisted, allow through
path := c.Request.URL.Path
for _, p := range whitelistPrefixes {
if strings.HasPrefix(path, p) {
c.Next()
return
}
}
// Allow if client's IP is in allowed list
clientIP := c.ClientIP()
xReal := c.GetHeader("X-Real-IP")
xForwarded := c.GetHeader("X-Forwarded-For")
for _, a := range m.AllowedIPs {
if a == clientIP || a == xReal || (xForwarded != "" && strings.HasPrefix(xForwarded, a)) {
c.Next()
return
}
}
// Block the request with 503
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"message": m.Message, "maintenance": true})
}
}

View File

@ -0,0 +1,114 @@
package middleware
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"bugulma/backend/internal/service"
"github.com/gin-gonic/gin"
)
type inMemorySettingsRepo struct {
data map[string]map[string]any
}
func newInMemorySettingsRepo() *inMemorySettingsRepo {
return &inMemorySettingsRepo{data: map[string]map[string]any{}}
}
func (r *inMemorySettingsRepo) Get(_ context.Context, key string) (map[string]any, error) {
return r.data[key], nil
}
func (r *inMemorySettingsRepo) Set(_ context.Context, key string, value map[string]any) error {
r.data[key] = value
return nil
}
func TestMaintenanceMiddleware_BlocksAndAllows(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := newInMemorySettingsRepo()
svc := service.NewSettingsService(repo)
// Ensure maintenance disabled by default
r := gin.New()
r.Use(MaintenanceMiddleware(svc, []string{"/api/v1/admin", "/health"}))
r.GET("/api/v1/resource", func(c *gin.Context) {
c.Header("X-Client-IP", c.ClientIP())
c.JSON(200, gin.H{"ok": true})
})
r.GET("/api/v1/admin/settings", func(c *gin.Context) { c.JSON(200, gin.H{"admin": true}) })
// Disabled: request should succeed and header indicate false
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/resource", nil)
req.RemoteAddr = "127.0.0.1:12345"
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 when disabled: got %d", w.Code)
}
if got := w.Header().Get("X-Maintenance"); got != "false" {
t.Fatalf("expected X-Maintenance=false, got %s", got)
}
// Enable maintenance
if err := svc.SetMaintenance(context.Background(), &service.MaintenanceSetting{Enabled: true, Message: "maintenance in progress"}); err != nil {
t.Fatalf("set maintenance: %v", err)
}
// Non-whitelisted path should be blocked
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/api/v1/resource", nil)
req.Header.Set("X-Real-IP", "127.0.0.1")
req.Header.Set("X-Forwarded-For", "127.0.0.1")
r.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 when enabled: got %d", w.Code)
}
if got := w.Header().Get("X-Maintenance"); got != "true" {
t.Fatalf("expected X-Maintenance=true, got %s", got)
}
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if body["maintenance"] != true {
t.Fatalf("expected maintenance:true body, got %#v", body)
}
// Whitelisted path should be allowed and header present
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/settings", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for whitelisted path, got %d", w.Code)
}
if got := w.Header().Get("X-Maintenance"); got != "true" {
t.Fatalf("expected X-Maintenance=true for whitelisted, got %s", got)
}
// Allowed IP should bypass maintenance
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/api/v1/resource", nil)
// Set maintenance with allowed IPs to include loopback
if err := svc.SetMaintenance(context.Background(), &service.MaintenanceSetting{Enabled: true, Message: "maintenance", AllowedIPs: []string{"127.0.0.1"}}); err != nil {
t.Fatalf("set maintenance with allowed ips: %v", err)
}
// Ensure the settings service returned the allowed IPs (cache updated)
m, err := svc.GetMaintenance(context.Background())
if err != nil {
t.Fatalf("get maintenance: %v", err)
}
if len(m.AllowedIPs) == 0 || m.AllowedIPs[0] != "127.0.0.1" {
t.Fatalf("expected allowed ip to be present, got %#v", m.AllowedIPs)
}
req.Header.Set("X-Real-IP", "127.0.0.1")
req.Header.Set("X-Forwarded-For", "127.0.0.1")
req.RemoteAddr = "127.0.0.1:12345"
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for allowed ip, got %d", w.Code)
}
}

View File

@ -356,6 +356,39 @@ func (r *LocalizationRepository) GetEntitiesNeedingTranslation(ctx context.Conte
return entityIDs, nil
}
// GetEntityFieldValue retrieves the raw value of a field from the entity table for a given entity ID
func (r *LocalizationRepository) GetEntityFieldValue(ctx context.Context, entityType, entityID, field string) (string, error) {
if entityType == "" || entityID == "" || field == "" {
return "", fmt.Errorf("entityType, entityID and field are required")
}
// Get entity descriptor to know table and id field
desc, exists := localization.GetEntityDescriptor(entityType)
if !exists {
return "", fmt.Errorf("unknown entity type: %s", entityType)
}
// Validate field
fieldValid := false
for _, f := range desc.Fields {
if f == field {
fieldValid = true
break
}
}
if !fieldValid {
return "", fmt.Errorf("field '%s' is not valid for entity type '%s'", field, entityType)
}
query := fmt.Sprintf(`SELECT e.%s FROM %s e WHERE e.%s = ? LIMIT 1`, field, desc.TableName, desc.IDField)
var value string
if err := r.db.WithContext(ctx).Raw(query, entityID).Scan(&value).Error; err != nil {
return "", fmt.Errorf("failed to get field value: %w", err)
}
return value, nil
}
// FindExistingTranslationByRussianText finds an existing translation by matching Russian source text
// It looks for entities with the same Russian text in the same field that already have a translation
// Returns the translation value if found, empty string if not found

View File

@ -0,0 +1,92 @@
package repository
import (
"context"
"bugulma/backend/internal/domain"
"gorm.io/gorm"
)
// PublicTransportGTFSRepository provides access to GTFS tables
type PublicTransportGTFSRepository struct {
db *gorm.DB
}
func NewPublicTransportGTFSRepository(db *gorm.DB) *PublicTransportGTFSRepository {
return &PublicTransportGTFSRepository{db: db}
}
func (r *PublicTransportGTFSRepository) BulkCreateTrips(ctx context.Context, trips []*domain.Trip) error {
if len(trips) == 0 {
return nil
}
return r.db.WithContext(ctx).CreateInBatches(trips, 100).Error
}
func (r *PublicTransportGTFSRepository) BulkCreateStopTimes(ctx context.Context, stopTimes []*domain.StopTime) error {
if len(stopTimes) == 0 {
return nil
}
return r.db.WithContext(ctx).CreateInBatches(stopTimes, 500).Error
}
// GetTripByID fetches a Trip by ID
func (r *PublicTransportGTFSRepository) GetTripByID(ctx context.Context, tripID string) (*domain.Trip, error) {
var t domain.Trip
if err := r.db.WithContext(ctx).First(&t, "id = ?", tripID).Error; err != nil {
return nil, err
}
return &t, nil
}
// GetRouteByTripID returns the route for a given trip id
func (r *PublicTransportGTFSRepository) GetRouteByTripID(ctx context.Context, tripID string) (*domain.PublicTransportRoute, error) {
var rt domain.PublicTransportRoute
q := `SELECT r.* FROM public_transport_routes r JOIN trips t ON t.route_id = r.id WHERE t.id = ? LIMIT 1`
if err := r.db.WithContext(ctx).Raw(q, tripID).Scan(&rt).Error; err != nil {
return nil, err
}
return &rt, nil
}
func (r *PublicTransportGTFSRepository) BulkCreateFrequencies(ctx context.Context, freqs []*domain.Frequency) error {
if len(freqs) == 0 {
return nil
}
return r.db.WithContext(ctx).CreateInBatches(freqs, 200).Error
}
func (r *PublicTransportGTFSRepository) BulkCreateCalendars(ctx context.Context, cals []*domain.ServiceCalendar) error {
if len(cals) == 0 {
return nil
}
return r.db.WithContext(ctx).CreateInBatches(cals, 100).Error
}
// ListStopTimesByStop lists stop times for a stop (ordered by departure seconds)
func (r *PublicTransportGTFSRepository) ListStopTimesByStop(ctx context.Context, stopID string) ([]*domain.StopTime, error) {
var list []*domain.StopTime
if err := r.db.WithContext(ctx).Where("stop_id = ?", stopID).Order("departure_secs asc").Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
// GetFrequenciesForTrip returns frequencies for a trip
func (r *PublicTransportGTFSRepository) GetFrequenciesForTrip(ctx context.Context, tripID string) ([]*domain.Frequency, error) {
var list []*domain.Frequency
if err := r.db.WithContext(ctx).Where("trip_id = ?", tripID).Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
// GetCalendarByServiceID fetches calendar for a service id
func (r *PublicTransportGTFSRepository) GetCalendarByServiceID(ctx context.Context, serviceID string) (*domain.ServiceCalendar, error) {
var cal domain.ServiceCalendar
if err := r.db.WithContext(ctx).First(&cal, "service_id = ?", serviceID).Error; err != nil {
return nil, err
}
return &cal, nil
}

View File

@ -0,0 +1,176 @@
package repository
import (
"context"
"math"
"bugulma/backend/internal/domain"
"bugulma/backend/internal/geospatial"
"gorm.io/gorm"
)
// PublicTransportRepository implements domain.PublicTransportRepository using GORM
type PublicTransportRepository struct {
*BaseRepository[domain.PublicTransportStop]
db *gorm.DB
}
// NewPublicTransportRepository constructs a new repository
func NewPublicTransportRepository(db *gorm.DB) domain.PublicTransportRepository {
return &PublicTransportRepository{
BaseRepository: NewBaseRepository[domain.PublicTransportStop](db),
db: db,
}
}
// CreateStop inserts a new stop
func (r *PublicTransportRepository) CreateStop(ctx context.Context, stop *domain.PublicTransportStop) error {
return r.Create(ctx, stop)
}
// UpsertStop upserts stop by ID
func (r *PublicTransportRepository) UpsertStop(ctx context.Context, stop *domain.PublicTransportStop) error {
return r.db.WithContext(ctx).Save(stop).Error
}
// GetStopByID fetches a stop by ID
func (r *PublicTransportRepository) GetStopByID(ctx context.Context, id string) (*domain.PublicTransportStop, error) {
var s domain.PublicTransportStop
if err := r.db.WithContext(ctx).First(&s, "id = ?", id).Error; err != nil {
return nil, err
}
return &s, nil
}
// ListStops returns stops ordered by name
func (r *PublicTransportRepository) ListStops(ctx context.Context, limit int) ([]*domain.PublicTransportStop, error) {
var list []*domain.PublicTransportStop
q := r.db.WithContext(ctx).Order("name asc")
if limit > 0 {
q = q.Limit(limit)
}
if err := q.Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
// SearchStops searches stops by name (case-insensitive substring)
func (r *PublicTransportRepository) SearchStops(ctx context.Context, q string) ([]*domain.PublicTransportStop, error) {
var list []*domain.PublicTransportStop
if q == "" {
return list, nil
}
like := "%%%" + q + "%%%"
if err := r.db.WithContext(ctx).Where("LOWER(name) LIKE LOWER(?) OR LOWER(name_en) LIKE LOWER(?)", like, like).Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
// SearchStopsPaginated searches stops with pagination and simple fuzzy matching
func (r *PublicTransportRepository) SearchStopsPaginated(ctx context.Context, q string, limit, offset int) ([]*domain.PublicTransportStop, error) {
var list []*domain.PublicTransportStop
if q == "" {
q = "%"
} else {
q = "%" + q + "%"
}
query := r.db.WithContext(ctx).Where("LOWER(name) LIKE LOWER(?) OR LOWER(name_en) LIKE LOWER(?)", q, q).Order("name asc").Limit(limit).Offset(offset)
if err := query.Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
// ListStopsWithinRadius finds stops within radius using PostGIS if available
func (r *PublicTransportRepository) ListStopsWithinRadius(ctx context.Context, lat, lng, radiusKm float64, limit int) ([]*domain.PublicTransportStop, error) {
// If PostGIS available, use spatial query
geo := geospatial.NewGeoHelper(r.db)
postgisAvailable, _ := geo.PostGISAvailable()
if postgisAvailable {
var stops []*domain.PublicTransportStop
query := `
SELECT * FROM public_transport_stops pts
WHERE pts.location IS NOT NULL
AND ` + geo.DWithinExpr("pts.location") + `
ORDER BY ` + geo.OrderByDistanceExpr("pts.location") + `
`
args := geo.PointRadiusArgs(lng, lat, radiusKm, true)
// Append final lng,lat for ORDER BY placeholders
if limit > 0 {
query += " LIMIT ?"
args = append(args, limit)
}
if err := r.db.WithContext(ctx).Raw(query, args...).Scan(&stops).Error; err != nil {
return nil, err
}
return stops, nil
}
// Fallback: bounding box approximation
var stops []*domain.PublicTransportStop
query := `SELECT * FROM public_transport_stops WHERE latitude IS NOT NULL AND longitude IS NOT NULL AND abs(latitude - ?) <= ? AND abs(longitude - ?) <= ? ORDER BY name ASC`
latDelta := radiusKm / 111.32
lngDelta := radiusKm / (111.32 * math.Cos(lat*math.Pi/180))
if limit > 0 {
query += " LIMIT ?"
if err := r.db.WithContext(ctx).Raw(query, lat, latDelta, lng, lngDelta, limit).Scan(&stops).Error; err != nil {
return nil, err
}
} else {
if err := r.db.WithContext(ctx).Raw(query, lat, latDelta, lng, lngDelta).Scan(&stops).Error; err != nil {
return nil, err
}
}
return stops, nil
}
// SearchRoutesPaginated searches routes with pagination
func (r *PublicTransportRepository) SearchRoutesPaginated(ctx context.Context, q string, limit, offset int) ([]*domain.PublicTransportRoute, error) {
var list []*domain.PublicTransportRoute
if q == "" {
q = "%"
} else {
q = "%" + q + "%"
}
query := r.db.WithContext(ctx).Where("LOWER(route_short_name) LIKE LOWER(?) OR LOWER(route_long_name) LIKE LOWER(?)", q, q).Order("route_short_name asc").Limit(limit).Offset(offset)
if err := query.Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
// Routes operations: simple table using db
// CreateRoute inserts a new route
func (r *PublicTransportRepository) CreateRoute(ctx context.Context, rt *domain.PublicTransportRoute) error {
return r.db.WithContext(ctx).Create(rt).Error
}
// UpsertRoute upserts route by ID
func (r *PublicTransportRepository) UpsertRoute(ctx context.Context, rt *domain.PublicTransportRoute) error {
return r.db.WithContext(ctx).Save(rt).Error
}
// GetRouteByID fetches a route by ID
func (r *PublicTransportRepository) GetRouteByID(ctx context.Context, id string) (*domain.PublicTransportRoute, error) {
var rt domain.PublicTransportRoute
if err := r.db.WithContext(ctx).First(&rt, "id = ?", id).Error; err != nil {
return nil, err
}
return &rt, nil
}
// ListRoutes returns routes ordered by short_name
func (r *PublicTransportRepository) ListRoutes(ctx context.Context, limit int) ([]*domain.PublicTransportRoute, error) {
var list []*domain.PublicTransportRoute
q := r.db.WithContext(ctx).Order("short_name asc")
if limit > 0 {
q = q.Limit(limit)
}
if err := q.Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}

View File

@ -0,0 +1,69 @@
package repository
import (
"context"
"testing"
"bugulma/backend/internal/domain"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestPublicTransportRepositoryCRUD(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open sqlite: %v", err)
}
// Migrate our domain models
if err := db.AutoMigrate(&domain.PublicTransportStop{}, &domain.PublicTransportRoute{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
repo := NewPublicTransportRepository(db)
ctx := context.Background()
// Create a stop
stop := &domain.PublicTransportStop{ID: "stop-1", Name: "Test Stop"}
if err := repo.CreateStop(ctx, stop); err != nil {
t.Fatalf("CreateStop failed: %v", err)
}
// Get by ID
got, err := repo.GetStopByID(ctx, "stop-1")
if err != nil {
t.Fatalf("GetStopByID failed: %v", err)
}
if got.Name != "Test Stop" {
t.Fatalf("unexpected name: %s", got.Name)
}
// Update via Upsert
stop.Name = "Test Stop Updated"
if err := repo.UpsertStop(ctx, stop); err != nil {
t.Fatalf("UpsertStop failed: %v", err)
}
// Search
res, err := repo.SearchStops(ctx, "updated")
if err != nil {
t.Fatalf("SearchStops failed: %v", err)
}
if len(res) == 0 {
t.Fatalf("expected search results but got none")
}
// Routes
rt := &domain.PublicTransportRoute{ID: "r1", ShortName: "1", LongName: "Route 1"}
if err := repo.CreateRoute(ctx, rt); err != nil {
t.Fatalf("CreateRoute failed: %v", err)
}
gotRt, err := repo.GetRouteByID(ctx, "r1")
if err != nil {
t.Fatalf("GetRouteByID failed: %v", err)
}
if gotRt.ShortName != "1" {
t.Fatalf("unexpected route short name: %s", gotRt.ShortName)
}
}

View File

@ -0,0 +1,45 @@
package repository
import (
"context"
"encoding/json"
"bugulma/backend/internal/domain"
"gorm.io/gorm"
)
type SystemSettingsRepository struct {
db *gorm.DB
}
func NewSystemSettingsRepository(db *gorm.DB) domain.SystemSettingsRepository {
return &SystemSettingsRepository{db: db}
}
func (r *SystemSettingsRepository) Get(ctx context.Context, key string) (map[string]any, error) {
var res struct {
Key string `gorm:"column:key"`
Value json.RawMessage `gorm:"column:value"`
}
if err := r.db.WithContext(ctx).Table("system_settings").Where("key = ?", key).Take(&res).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
var m map[string]any
if err := json.Unmarshal(res.Value, &m); err != nil {
return nil, err
}
return m, nil
}
func (r *SystemSettingsRepository) Set(ctx context.Context, key string, value map[string]any) error {
b, err := json.Marshal(value)
if err != nil {
return err
}
// upsert
return r.db.WithContext(ctx).Exec(`INSERT INTO system_settings(key, value, updated_at) VALUES(?, ?, now()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, key, b).Error
}

View File

@ -16,6 +16,7 @@ func RegisterAdminRoutes(
i18nHandler *handler.I18nHandler,
contentHandler *handler.ContentHandler,
adminHandler *handler.AdminHandler,
settingsHandler *handler.SettingsAdminHandler,
authService *service.AuthService,
) {
// All admin routes require authentication and admin role
@ -123,4 +124,11 @@ func RegisterAdminRoutes(
{
system.GET("/health", adminHandler.GetSystemHealth)
}
// Settings
settings := admin.Group("/settings")
{
settings.GET("/maintenance", settingsHandler.GetMaintenance)
settings.PUT("/maintenance", settingsHandler.SetMaintenance)
}
}

View File

@ -0,0 +1,24 @@
package routes
import (
"bugulma/backend/internal/handler"
"github.com/gin-gonic/gin"
)
// RegisterPublicTransportRoutes registers routes for public transport API
func RegisterPublicTransportRoutes(router *gin.RouterGroup, h *handler.PublicTransportHandler) {
if h == nil {
return
}
group := router.Group("/public-transport")
{
group.GET("/metadata", h.GetMetadata)
group.GET("/stops", h.ListStops)
group.GET("/stops/search", h.SearchStops)
group.GET("/stops/:id", h.GetStop)
group.GET("/stops/:id/next-departures", h.GetNextDepartures)
group.GET("/gtfs/:filename", h.GetGTFSFile)
}
}

View File

@ -30,9 +30,11 @@ func RegisterAllRoutes(
i18nHandler *handler.I18nHandler,
contentHandler *handler.ContentHandler,
adminHandler *handler.AdminHandler,
settingsHandler *handler.SettingsAdminHandler,
subscriptionHandler *handler.SubscriptionHandler,
authService *service.AuthService,
discoveryHandler *handler.DiscoveryHandler,
publicTransportHandler *handler.PublicTransportHandler,
) {
// Public routes (no authentication required)
public := router.Group("/api/v1")
@ -55,6 +57,7 @@ func RegisterAllRoutes(
RegisterMatchingRoutes(public, protected, matchingHandler)
RegisterSharedAssetRoutes(public, protected, sharedAssetHandler)
RegisterGeospatialRoutes(public, geospatialHandler)
RegisterPublicTransportRoutes(public, publicTransportHandler)
RegisterAnalyticsRoutes(public, analyticsHandler)
RegisterAIRoutes(public, aiHandler)
RegisterHeritageRoutes(public, heritageHandler)
@ -66,7 +69,7 @@ func RegisterAllRoutes(
// Register admin routes
if userHandler != nil && orgAdminHandler != nil && i18nHandler != nil && contentHandler != nil && adminHandler != nil && authService != nil {
RegisterAdminRoutes(protected, userHandler, orgAdminHandler, i18nHandler, contentHandler, adminHandler, authService)
RegisterAdminRoutes(protected, userHandler, orgAdminHandler, i18nHandler, contentHandler, adminHandler, settingsHandler, authService)
}
// Register subscription routes

View File

@ -90,13 +90,16 @@ func StartServer(port string) error {
scheduleService := service.NewScheduleService(gtfsRepo, publicTransportRepo)
// Initialize handlers (orgHandler is created inside initializeHandlers)
orgHandler, siteHandler, resourceFlowHandler, proposalHandler, matchingHandler, authHandler, sharedAssetHandler, geospatialHandler, analyticsHandler, aiHandler, heritageHandler, userHandler, orgAdminHandler, i18nHandler, contentHandler, adminHandler, subscriptionHandler, discoveryHandler, publicTransportHandler := initializeHandlers(
// Create SettingsService early so we can inject it into handlers and the router
settingsService := service.NewSettingsService(repository.NewSystemSettingsRepository(db))
orgHandler, siteHandler, resourceFlowHandler, proposalHandler, matchingHandler, authHandler, sharedAssetHandler, geospatialHandler, analyticsHandler, aiHandler, heritageHandler, userHandler, orgAdminHandler, i18nHandler, contentHandler, adminHandler, settingsHandler, subscriptionHandler, discoveryHandler, publicTransportHandler := initializeHandlers(
cfg, orgService, siteService, resourceFlowService, matchingSvc, authService, sharedAssetService, proposalService,
geospatialService, analyticsService, aiService, cacheService, db, userService, adminService, i18nService, subscriptionService, verificationService, publicTransportService, scheduleService,
geospatialService, analyticsService, aiService, cacheService, db, userService, adminService, i18nService, subscriptionService, verificationService, publicTransportService, scheduleService, settingsService,
)
// Setup router
router := setupRouter(authService)
// Setup router (pass settings service so maintenance middleware can be registered)
router := setupRouter(authService, settingsService)
// Initialize event-driven matching service with proper dependencies
// Note: eventBus is created in initializeServices, but we need to recreate it here for event-driven service
@ -119,7 +122,7 @@ func StartServer(port string) error {
setupRoutes(router, orgHandler, siteHandler, resourceFlowHandler, proposalHandler, matchingHandler, authHandler,
sharedAssetHandler, geospatialHandler, analyticsHandler, aiHandler, heritageHandler, graphHandler,
graphTraversalHandler, websocketService, orgService, siteService, geospatialService, graphSyncService,
userHandler, orgAdminHandler, i18nHandler, contentHandler, adminHandler, subscriptionHandler, authService, discoveryHandler, publicTransportHandler)
userHandler, orgAdminHandler, i18nHandler, contentHandler, adminHandler, settingsHandler, subscriptionHandler, authService, discoveryHandler, publicTransportHandler)
// Start server
log.Printf("Server starting on port %s", cfg.ServerPort)
@ -420,7 +423,8 @@ func initializeHandlers(
verificationService *service.VerificationService,
publicTransportService *service.PublicTransportService,
scheduleService *service.ScheduleService,
) (*handler.OrganizationHandler, *handler.SiteHandler, *handler.ResourceFlowHandler, *handler.ProposalHandler, *handler.MatchingHandler, *handler.AuthHandler, *handler.SharedAssetHandler, *handler.GeospatialHandler, *handler.AnalyticsHandler, *handler.AIHandler, *handler.HeritageHandler, *handler.UserHandler, *handler.OrganizationAdminHandler, *handler.I18nHandler, *handler.ContentHandler, *handler.AdminHandler, *handler.SubscriptionHandler, *handler.DiscoveryHandler, *handler.PublicTransportHandler) {
settingsService *service.SettingsService,
) (*handler.OrganizationHandler, *handler.SiteHandler, *handler.ResourceFlowHandler, *handler.ProposalHandler, *handler.MatchingHandler, *handler.AuthHandler, *handler.SharedAssetHandler, *handler.GeospatialHandler, *handler.AnalyticsHandler, *handler.AIHandler, *handler.HeritageHandler, *handler.UserHandler, *handler.OrganizationAdminHandler, *handler.I18nHandler, *handler.ContentHandler, *handler.AdminHandler, *handler.SettingsAdminHandler, *handler.SubscriptionHandler, *handler.DiscoveryHandler, *handler.PublicTransportHandler) {
imageService := service.NewImageService(cfg)
orgHandler := handler.NewOrganizationHandler(orgService, imageService, resourceFlowService, matchingSvc, proposalService)
@ -437,23 +441,24 @@ func initializeHandlers(
// Additional handlers
userHandler := handler.NewUserHandler(userService)
orgAdminHandler := handler.NewOrganizationAdminHandler(orgHandler, verificationService)
orgAdminHandler := handler.NewOrganizationAdminHandler(orgHandler, verificationService, adminService)
i18nHandler := handler.NewI18nHandler(i18nService)
contentHandler := handler.NewContentHandler(service.NewContentService(
repository.NewStaticPageRepository(db),
repository.NewAnnouncementRepository(db),
repository.NewMediaAssetRepository(db),
))
adminHandler := handler.NewAdminHandler(adminService)
adminHandler := handler.NewAdminHandler(adminService, analyticsService)
settingsHandler := handler.NewSettingsAdminHandler(settingsService)
subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService)
discoveryHandler := handler.NewDiscoveryHandler(matchingSvc)
publicTransportHandler := handler.NewPublicTransportHandler(publicTransportService, scheduleService)
return orgHandler, siteHandler, resourceFlowHandler, proposalHandler, matchingHandler, authHandler, sharedAssetHandler, geospatialHandler, analyticsHandler, aiHandler, heritageHandler, userHandler, orgAdminHandler, i18nHandler, contentHandler, adminHandler, subscriptionHandler, discoveryHandler, publicTransportHandler
return orgHandler, siteHandler, resourceFlowHandler, proposalHandler, matchingHandler, authHandler, sharedAssetHandler, geospatialHandler, analyticsHandler, aiHandler, heritageHandler, userHandler, orgAdminHandler, i18nHandler, contentHandler, adminHandler, settingsHandler, subscriptionHandler, discoveryHandler, publicTransportHandler
}
// setupRouter configures the Gin router with middleware
func setupRouter(authService *service.AuthService) *gin.Engine {
func setupRouter(authService *service.AuthService, settingsSvc *service.SettingsService) *gin.Engine {
router := gin.Default()
// Serve static files
@ -476,6 +481,9 @@ func setupRouter(authService *service.AuthService) *gin.Engine {
// Context middleware for extracting user/org info from JWT or headers
router.Use(middleware.ContextMiddleware(authService))
// Maintenance middleware: block non-whitelisted requests when maintenance mode is enabled
router.Use(middleware.MaintenanceMiddleware(settingsSvc, []string{"/api/v1/admin", "/health", "/metrics"}))
return router
}
@ -505,6 +513,7 @@ func setupRoutes(
i18nHandler *handler.I18nHandler,
contentHandler *handler.ContentHandler,
adminHandler *handler.AdminHandler,
settingsHandler *handler.SettingsAdminHandler,
subscriptionHandler *handler.SubscriptionHandler,
authService *service.AuthService,
discoveryHandler *handler.DiscoveryHandler,
@ -531,6 +540,7 @@ func setupRoutes(
i18nHandler,
contentHandler,
adminHandler,
settingsHandler,
subscriptionHandler,
authService,
discoveryHandler,

View File

@ -86,6 +86,7 @@ type ImpactMetrics struct {
MaterialsRecycledTonnes float64 `json:"materials_recycled_tonnes"`
EnergySharedMWh float64 `json:"energy_shared_mwh"`
WaterReclaimedM3 float64 `json:"water_reclaimed_m3"`
ActiveMatchesCount int `json:"active_matches_count"`
}
// DashboardStatistics represents dashboard overview statistics
@ -345,6 +346,13 @@ func (s *AnalyticsService) GetImpactMetrics(ctx context.Context) (*ImpactMetrics
metrics.TotalCO2SavingsTonnes = aggregates.CO2Savings / 1000
metrics.TotalEconomicValueEUR = aggregates.EconValue
// Count active matches
var activeMatchesCount int64
s.db.Model(&domain.Match{}).
Where("status IN ?", []string{"suggested", "negotiating", "contracted", "live"}).
Count(&activeMatchesCount)
metrics.ActiveMatchesCount = int(activeMatchesCount)
// TODO: Calculate materials recycled, energy shared, water reclaimed from resource flows
// This would require additional analysis of resource flow quantity data

View File

@ -2,6 +2,7 @@ package service
import (
"bugulma/backend/internal/domain"
"bugulma/backend/internal/localization"
"context"
"fmt"
)
@ -317,6 +318,107 @@ func (s *I18nService) SetDataTranslation(ctx context.Context, entityType, entity
return s.setLocalizedValueWithContext(ctx, entityType, entityID, field, locale, value)
}
// BulkTranslateData translates multiple entity fields for a target locale.
// Returns a map of entityID -> field -> translatedValue
func (s *I18nService) BulkTranslateData(ctx context.Context, entityType string, entityIDs []string, targetLocale string, fields []string) (map[string]map[string]string, error) {
if !s.ValidateLocale(targetLocale) {
return nil, fmt.Errorf("invalid target locale: %s", targetLocale)
}
if targetLocale == DefaultLocale {
return nil, fmt.Errorf("target locale cannot be source locale (%s)", DefaultLocale)
}
// If no specific entityIDs provided, try to discover entities needing translation using repo helper
if len(entityIDs) == 0 {
idSet := make(map[string]struct{})
// Use a sensible default limit per field
limit := 200
for _, field := range fields {
ids, err := s.locRepo.GetEntitiesNeedingTranslation(ctx, entityType, field, targetLocale, limit)
if err != nil {
return nil, fmt.Errorf("failed to get entities needing translation: %w", err)
}
for _, id := range ids {
idSet[id] = struct{}{}
}
}
for id := range idSet {
entityIDs = append(entityIDs, id)
}
}
results := make(map[string]map[string]string)
var lastErr error
for _, entityID := range entityIDs {
for _, field := range fields {
// Get raw Russian source from entity table
source, err := s.locRepo.GetEntityFieldValue(ctx, entityType, entityID, field)
if err != nil {
// skip this field but record error
lastErr = fmt.Errorf("failed to get source for %s/%s/%s: %w", entityType, entityID, field, err)
continue
}
if source == "" {
// nothing to translate
continue
}
// Try to find existing translation for same Russian text (reuse)
reused, err := s.locRepo.FindExistingTranslationByRussianText(ctx, entityType, field, targetLocale, source)
if err == nil && reused != "" {
// Save reused translation for this entity/field if missing
_ = s.setLocalizedValueWithContext(ctx, entityType, entityID, field, targetLocale, reused)
if results[entityID] == nil {
results[entityID] = make(map[string]string)
}
results[entityID][field] = reused
continue
}
// Translate and persist
translated, err := s.TranslateDataWithSource(ctx, entityType, entityID, field, source, targetLocale)
if err != nil {
lastErr = fmt.Errorf("translation failed for %s/%s/%s: %w", entityType, entityID, field, err)
continue
}
if results[entityID] == nil {
results[entityID] = make(map[string]string)
}
results[entityID][field] = translated
}
}
return results, lastErr
}
// GetMissingTranslations returns entity IDs per field that are missing translations for target locale
func (s *I18nService) GetMissingTranslations(ctx context.Context, entityType, targetLocale string, fields []string, limit int) (map[string][]string, error) {
if !s.ValidateLocale(targetLocale) {
return nil, fmt.Errorf("invalid target locale: %s", targetLocale)
}
if limit <= 0 {
limit = 100
}
result := make(map[string][]string)
// If no fields specified, try to get from descriptor
if len(fields) == 0 {
fields = localization.GetFieldsForEntityType(entityType)
}
for _, field := range fields {
ids, err := s.locRepo.GetEntitiesNeedingTranslation(ctx, entityType, field, targetLocale, limit)
if err != nil {
return nil, fmt.Errorf("failed to get entities needing translation for field %s: %w", field, err)
}
result[field] = ids
}
return result, nil
}
func (s *I18nService) applyLocalizationToEntityWithContext(ctx context.Context, entity domain.Localizable, locale string) error {
if s.locService == nil {
return fmt.Errorf("localization service not initialized")

View File

@ -0,0 +1,146 @@
package service
import (
"context"
"io"
"net/http"
"strings"
"testing"
"bugulma/backend/internal/domain"
)
// minimal mocks
type mockLocRepo struct {
fieldValues map[string]string // key: entityID|field -> value
idsByField map[string][]string
}
func (m *mockLocRepo) GetEntitiesNeedingTranslation(ctx context.Context, entityType, field, targetLocale string, limit int) ([]string, error) {
return m.idsByField[field], nil
}
func (m *mockLocRepo) GetEntityFieldValue(ctx context.Context, entityType, entityID, field string) (string, error) {
k := entityID + "|" + field
return m.fieldValues[k], nil
}
func (m *mockLocRepo) FindExistingTranslationByRussianText(ctx context.Context, entityType, field, targetLocale, russianText string) (string, error) {
return "", nil
}
// Unused methods to satisfy interface - implement as noops
func (m *mockLocRepo) Create(ctx context.Context, loc *domain.Localization) error { return nil }
func (m *mockLocRepo) GetByEntityAndField(ctx context.Context, entityType, entityID, field, locale string) (*domain.Localization, error) {
return nil, nil
}
func (m *mockLocRepo) GetAllByEntity(ctx context.Context, entityType, entityID string) ([]*domain.Localization, error) {
return nil, nil
}
func (m *mockLocRepo) Update(ctx context.Context, loc *domain.Localization) error { return nil }
func (m *mockLocRepo) Delete(ctx context.Context, id string) error { return nil }
func (m *mockLocRepo) GetByEntityTypeAndLocale(ctx context.Context, entityType, locale string) ([]*domain.Localization, error) {
return nil, nil
}
func (m *mockLocRepo) GetAllLocales(ctx context.Context) ([]string, error) { return nil, nil }
func (m *mockLocRepo) GetSupportedLocalesForEntity(ctx context.Context, entityType, entityID string) ([]string, error) {
return nil, nil
}
func (m *mockLocRepo) BulkCreate(ctx context.Context, localizations []*domain.Localization) error {
return nil
}
func (m *mockLocRepo) BulkDelete(ctx context.Context, ids []string) error { return nil }
func (m *mockLocRepo) SearchLocalizations(ctx context.Context, query string, locale string, limit int) ([]*domain.Localization, error) {
return nil, nil
}
func (m *mockLocRepo) GetTranslationReuseCandidates(ctx context.Context, entityType, field, locale string) ([]domain.ReuseCandidate, error) {
return nil, nil
}
func (m *mockLocRepo) GetTranslationCountsByEntity(ctx context.Context) (map[string]map[string]int, error) {
return nil, nil
}
// fake roundtripper to mock HTTP responses from the translation service
type fakeRoundTripper struct {
responseBody string
}
func (f *fakeRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
body := io.NopCloser(strings.NewReader(f.responseBody))
return &http.Response{StatusCode: 200, Body: body, Header: http.Header{"Content-Type": {"application/json"}}}, nil
}
type mockLocService struct {
saved map[string]string // key: entityID|field|locale -> value
}
func (m *mockLocService) GetLocalizedValue(entityType, entityID, field, locale string) (string, error) {
return "", nil
}
func (m *mockLocService) SetLocalizedValue(entityType, entityID, field, locale, value string) error {
k := entityID + "|" + field + "|" + locale
m.saved[k] = value
return nil
}
func (m *mockLocService) GetAllLocalizedValues(entityType, entityID string) (map[string]map[string]string, error) {
return nil, nil
}
func (m *mockLocService) GetLocalizedEntity(entityType, entityID, locale string) (map[string]string, error) {
return nil, nil
}
func (m *mockLocService) GetSupportedLocalesForEntity(entityType, entityID string) ([]string, error) {
return nil, nil
}
func (m *mockLocService) DeleteLocalizedValue(entityType, entityID, field, locale string) error {
return nil
}
func (m *mockLocService) BulkSetLocalizedValues(entityType, entityID string, values map[string]map[string]string) error {
return nil
}
func (m *mockLocService) GetAllLocales() ([]string, error) { return nil, nil }
func (m *mockLocService) SearchLocalizations(query, locale string, limit int) ([]*domain.Localization, error) {
return nil, nil
}
func (m *mockLocService) ApplyLocalizationToEntity(entity domain.Localizable, locale string) error {
return nil
}
func TestBulkTranslateData_Simple(t *testing.T) {
locRepo := &mockLocRepo{
fieldValues: map[string]string{"org-1|name": "Пример"},
idsByField: map[string][]string{"name": {"org-1"}},
}
locSvc := &mockLocService{saved: make(map[string]string)}
// create a TranslationService with a fake client that returns canned response
ts := NewTranslationService("http://fake-ollama", "test-model")
ts.client = &http.Client{Transport: &fakeRoundTripper{responseBody: `{"model":"test","response":"translated:Пример:en","done":true}`}}
cache := NewTranslationCacheService(locRepo, locSvc)
svc := &I18nService{
locService: locSvc,
locRepo: locRepo,
translationSvc: ts,
cacheService: cache,
}
results, err := svc.BulkTranslateData(context.Background(), "organization", nil, "en", []string{"name"})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(results) != 1 {
t.Fatalf("expected results for 1 entity, got %d", len(results))
}
if results["org-1"]["name"] != "translated:Пример:en" {
t.Fatalf("unexpected translation: %v", results["org-1"]["name"])
}
// Check that translation was saved via locService
saved := locSvc.saved["org-1|name|en"]
if saved != "translated:Пример:en" {
t.Fatalf("expected saved translation, got %v", saved)
}
}

View File

@ -0,0 +1,116 @@
package service
import (
"context"
"encoding/csv"
"fmt"
"io"
"log"
"strings"
"bugulma/backend/internal/domain"
)
// ImportPublicTransportData imports stops and routes from the service's loaded files into repository
func ImportPublicTransportData(ctx context.Context, svc *PublicTransportService, repo domain.PublicTransportRepository) error {
if svc == nil || repo == nil {
return nil // nothing to do
}
// Seed common stops from enriched JSON if available
if cs := svc.ListStops(); len(cs) > 0 {
for key, v := range cs {
m, ok := v.(map[string]interface{})
if !ok {
continue
}
stop := &domain.PublicTransportStop{ID: key}
if name, ok := m["name"].(string); ok {
stop.Name = name
}
if nameEn, ok := m["name_en"].(string); ok {
stop.NameEn = nameEn
}
if typ, ok := m["type"].(string); ok {
stop.Type = typ
}
if addr, ok := m["address"].(string); ok {
stop.Address = addr
}
if osm, ok := m["osm_id"].(string); ok {
stop.OsmID = osm
}
if est, ok := m["estimated"].(bool); ok {
stop.Estimated = est
}
if loc, ok := m["location"].(map[string]interface{}); ok {
if lat, ok := loc["lat"].(float64); ok {
stop.Latitude = &lat
}
if lng, ok := loc["lng"].(float64); ok {
stop.Longitude = &lng
}
}
if routes, ok := m["routes_served"].([]interface{}); ok {
for _, r := range routes {
if s, ok := r.(string); ok {
stop.RoutesServed = append(stop.RoutesServed, s)
}
}
}
if err := repo.UpsertStop(ctx, stop); err != nil {
log.Printf("warning: failed to upsert stop %s: %v", stop.ID, err)
}
}
}
// Seed GTFS routes from routes.txt
if routesCSV, err := svc.ReadGTFSFile("routes.txt"); err == nil {
r := csv.NewReader(strings.NewReader(routesCSV))
// Read header
header, err := r.Read()
if err == nil {
idx := map[string]int{}
for i, h := range header {
idx[h] = i
}
for {
rec, err := r.Read()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read routes CSV: %w", err)
}
id := rec[idx["route_id"]]
rt := &domain.PublicTransportRoute{ID: id}
if v, ok := idx["agency_id"]; ok {
rt.AgencyID = rec[v]
}
if v, ok := idx["route_short_name"]; ok {
rt.ShortName = rec[v]
}
if v, ok := idx["route_long_name"]; ok {
rt.LongName = rec[v]
}
if v, ok := idx["route_type"]; ok {
if rec[v] != "" {
// parse int route_type
// we keep it simple and ignore parse errors
var rtType int
fmt.Sscanf(rec[v], "%d", &rtType)
rt.RouteType = rtType
}
}
if err := repo.UpsertRoute(ctx, rt); err != nil {
log.Printf("warning: failed to upsert route %s: %v", rt.ID, err)
}
}
}
} else {
// Missing routes file is fine
}
return nil
}

View File

@ -0,0 +1,49 @@
package service
import (
"context"
"testing"
"bugulma/backend/internal/repository"
"bugulma/backend/internal/domain"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestImportPublicTransportData(t *testing.T) {
svc, err := NewPublicTransportService("data")
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open sqlite: %v", err)
}
if err := db.AutoMigrate(&domain.PublicTransportStop{}, &domain.PublicTransportRoute{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
repo := repository.NewPublicTransportRepository(db)
if err := ImportPublicTransportData(context.Background(), svc, repo); err != nil {
t.Fatalf("import failed: %v", err)
}
// Ensure at least some stops or routes exist after import (if data present in repo)
stops, err := repo.ListStops(context.Background(), 10)
if err != nil {
t.Fatalf("ListStops failed: %v", err)
}
routes, err := repo.ListRoutes(context.Background(), 10)
if err != nil {
t.Fatalf("ListRoutes failed: %v", err)
}
// It's acceptable if both are empty (data optional), but test should run without error
_ = stops
_ = routes
}

View File

@ -0,0 +1,146 @@
package service
import (
"context"
"sort"
"time"
"bugulma/backend/internal/domain"
"bugulma/backend/internal/repository"
)
// Departure describes a single upcoming departure event
type Departure struct {
Time time.Time `json:"time"`
TripID string `json:"trip_id"`
RouteID string `json:"route_id"`
RouteShort string `json:"route_short_name"`
StopID string `json:"stop_id"`
}
// ScheduleService handles GTFS schedule queries
type ScheduleService struct {
gtfsRepo *repository.PublicTransportGTFSRepository
ptRepo domain.PublicTransportRepository
}
func NewScheduleService(gtfsRepo *repository.PublicTransportGTFSRepository, ptRepo domain.PublicTransportRepository) *ScheduleService {
return &ScheduleService{gtfsRepo: gtfsRepo, ptRepo: ptRepo}
}
// GetNextDepartures returns the next departures for a stop starting from fromTime
func (s *ScheduleService) GetNextDepartures(ctx context.Context, stopID string, fromTime time.Time, limit int) ([]Departure, error) {
// Load stop_times for the stop
stopTimes, err := s.gtfsRepo.ListStopTimesByStop(ctx, stopID)
if err != nil {
return nil, err
}
// Build a map of serviceIDs active today
today := fromTime.UTC()
activeServices := map[string]bool{}
// For each stop_time, check trip->service calendar
// To keep this efficient, we'll iterate stopTimes and inspect their trip's service via DB
var departures []Departure
for _, st := range stopTimes {
// quick filter by departure secs
if st.DepartureSecs < secondsSinceMidnight(fromTime) {
continue
}
// get trip
trip, err := s.gtfsRepo.GetTripByID(ctx, st.TripID)
if err != nil || trip == nil {
continue
}
// check service calendar
svcID := trip.ServiceID
if svcID == "" {
continue
}
if !activeServices[svcID] {
// check calendar
cal, err := s.gtfsRepo.GetCalendarByServiceID(ctx, svcID)
active := false
if err == nil && cal != nil {
yy, mm, dd := today.Date()
todayDate := time.Date(yy, mm, dd, 0, 0, 0, 0, time.UTC)
if !todayDate.Before(cal.StartDate) && !todayDate.After(cal.EndDate) {
weekday := today.Weekday()
switch weekday {
case time.Monday:
active = cal.Monday
case time.Tuesday:
active = cal.Tuesday
case time.Wednesday:
active = cal.Wednesday
case time.Thursday:
active = cal.Thursday
case time.Friday:
active = cal.Friday
case time.Saturday:
active = cal.Saturday
case time.Sunday:
active = cal.Sunday
}
}
}
// TODO: consider calendar_dates exceptions (not implemented yet)
if active {
activeServices[svcID] = true
} else {
activeServices[svcID] = false
}
}
if !activeServices[svcID] {
continue
}
// Get route for trip
var route domain.PublicTransportRoute
if rt, err := s.gtfsRepo.GetRouteByTripID(ctx, st.TripID); err == nil && rt != nil {
route = *rt
}
// compute departure time as today's date + departure secs
dep := secondsToTimeUTC(fromTime, st.DepartureSecs)
departures = append(departures, Departure{
Time: dep,
TripID: st.TripID,
RouteID: route.ID,
RouteShort: route.ShortName,
StopID: st.StopID,
})
}
// For frequencies we would need to expand entries - TODO: support frequencies expansion in future
// Sort departures by time
sort.Slice(departures, func(i, j int) bool {
return departures[i].Time.Before(departures[j].Time)
})
if limit > 0 && len(departures) > limit {
departures = departures[:limit]
}
return departures, nil
}
func secondsSinceMidnight(t time.Time) int {
h := t.Hour()
m := t.Minute()
s := t.Second()
return h*3600 + m*60 + s
}
func secondsToTimeUTC(base time.Time, secs int) time.Time {
// compute midnight of base date then add secs (handle > 86400)
y, mo, d := base.Date()
midnight := time.Date(y, mo, d, 0, 0, 0, 0, time.UTC)
return midnight.Add(time.Duration(secs) * time.Second)
}

View File

@ -0,0 +1,162 @@
package service
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"path/filepath"
)
// PublicTransportService provides read-only access to precomputed public transport data
type PublicTransportService struct {
metadata map[string]interface{}
stops map[string]interface{}
raw map[string]interface{}
baseDir string
}
// NewPublicTransportService attempts to load enriched public transport JSON from data directory
func NewPublicTransportService(baseDir string) (*PublicTransportService, error) {
svc := &PublicTransportService{baseDir: baseDir}
// Try to read enriched JSON first
candidates := []string{
filepath.Join(baseDir, "bugulma_public_transport_enriched.json"),
filepath.Join(baseDir, "bugulma_public_transport.json"),
}
var found bool
for _, p := range candidates {
if _, err := os.Stat(p); err == nil {
b, err := ioutil.ReadFile(p)
if err != nil {
return nil, fmt.Errorf("failed to read public transport file %s: %w", p, err)
}
var raw map[string]interface{}
if err := json.Unmarshal(b, &raw); err != nil {
return nil, fmt.Errorf("failed to unmarshal public transport json %s: %w", p, err)
}
svc.raw = raw
// Extract metadata and common stops if present
if m, ok := raw["metadata"].(map[string]interface{}); ok {
svc.metadata = m
}
if cs, ok := raw["common_stops_directory"].(map[string]interface{}); ok {
svc.stops = cs
} else {
svc.stops = map[string]interface{}{}
}
found = true
break
}
}
if !found {
// No JSON file found, return empty service with baseDir set so GTFS files can still be served
svc.raw = map[string]interface{}{}
svc.metadata = map[string]interface{}{}
svc.stops = map[string]interface{}{}
}
return svc, nil
}
// GetMetadata returns metadata extracted from the data file
func (s *PublicTransportService) GetMetadata() map[string]interface{} {
return s.metadata
}
// ListStops returns a map of stop key -> stop object
func (s *PublicTransportService) ListStops() map[string]interface{} {
return s.stops
}
// GetStop returns a stop by id (key in the common_stops_directory)
func (s *PublicTransportService) GetStop(id string) (interface{}, bool) {
st, ok := s.stops[id]
return st, ok
}
// Search stops by substring match in names (case-insensitive)
func (s *PublicTransportService) SearchStops(query string) map[string]interface{} {
out := map[string]interface{}{}
if query == "" {
return out
}
for k, v := range s.stops {
if k == query {
out[k] = v
continue
}
// try looking into name fields if present
if m, ok := v.(map[string]interface{}); ok {
if name, ok := m["name"].(string); ok {
if containsIgnoreCase(name, query) {
out[k] = v
continue
}
}
if nameEn, ok := m["name_en"].(string); ok {
if containsIgnoreCase(nameEn, query) {
out[k] = v
continue
}
}
}
}
return out
}
// ReadGTFSFile returns file contents from the GTFS export folder if present
func (s *PublicTransportService) ReadGTFSFile(filename string) (string, error) {
p := filepath.Join(s.baseDir, "bugulma_gtfs_export", filename)
b, err := ioutil.ReadFile(p)
if err != nil {
return "", err
}
return string(b), nil
}
// Helper: case-insensitive substring
func containsIgnoreCase(src, sub string) bool {
return len(src) >= len(sub) && (stringIndexIgnoreCase(src, sub) >= 0)
}
func stringIndexIgnoreCase(s, substr string) int {
// naive implementation that converts to lower-case
sLower := []rune{}
subLower := []rune{}
for _, r := range s {
if 'A' <= r && r <= 'Z' {
r = r - 'A' + 'a'
}
sLower = append(sLower, r)
}
for _, r := range substr {
if 'A' <= r && r <= 'Z' {
r = r - 'A' + 'a'
}
subLower = append(subLower, r)
}
sStr := string(sLower)
subStr := string(subLower)
return indexOf(sStr, subStr)
}
func indexOf(s, substr string) int {
if substr == "" {
return 0
}
for i := 0; i+len(substr) <= len(s); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}

View File

@ -0,0 +1,30 @@
package service
import (
"testing"
)
func TestNewPublicTransportServiceLoadsFiles(t *testing.T) {
svc, err := NewPublicTransportService("data")
if err != nil {
t.Fatalf("failed to initialize service: %v", err)
}
if svc == nil {
t.Fatalf("service cannot be nil")
}
// metadata should exist (may be empty for fallback)
_ = svc.GetMetadata()
stops := svc.ListStops()
if stops == nil {
t.Fatalf("stops map must not be nil")
}
// Try reading a known GTFS file
_, err = svc.ReadGTFSFile("README.txt")
if err != nil {
t.Logf("warning: unable to read README.txt from GTFS export: %v", err)
}
}

View File

@ -0,0 +1,96 @@
package service
import (
"context"
"fmt"
"sync"
"time"
"bugulma/backend/internal/domain"
)
type MaintenanceSetting struct {
Enabled bool `json:"enabled"`
Message string `json:"message"`
AllowedIPs []string `json:"allowed_ips,omitempty"`
}
type SettingsService struct {
repo domain.SystemSettingsRepository
// caching
cacheTTL time.Duration
cacheMutex sync.RWMutex
cachedMaintenance *MaintenanceSetting
cacheExpiry time.Time
}
func NewSettingsService(repo domain.SystemSettingsRepository) *SettingsService {
return &SettingsService{repo: repo, cacheTTL: 5 * time.Second}
}
func (s *SettingsService) GetMaintenance(ctx context.Context) (*MaintenanceSetting, error) {
m, err := s.repo.Get(ctx, "maintenance")
if err != nil {
return nil, fmt.Errorf("failed to get maintenance: %w", err)
}
if m == nil {
return &MaintenanceSetting{Enabled: false, Message: ""}, nil
}
enabled, _ := m["enabled"].(bool)
msg, _ := m["message"].(string)
// allowed_ips may be stored as []interface{} from DB JSON decode
var allowed []string
if v, ok := m["allowed_ips"]; ok {
switch vv := v.(type) {
case []any:
for _, it := range vv {
if s, ok := it.(string); ok {
allowed = append(allowed, s)
}
}
case []string:
allowed = vv
}
}
return &MaintenanceSetting{Enabled: enabled, Message: msg, AllowedIPs: allowed}, nil
}
func (s *SettingsService) SetMaintenance(ctx context.Context, in *MaintenanceSetting) error {
value := map[string]any{"enabled": in.Enabled, "message": in.Message}
if in.AllowedIPs != nil {
value["allowed_ips"] = in.AllowedIPs
}
if err := s.repo.Set(ctx, "maintenance", value); err != nil {
return fmt.Errorf("failed to set maintenance: %w", err)
}
// Invalidate cache
s.cacheMutex.Lock()
s.cachedMaintenance = nil
s.cacheExpiry = time.Time{}
s.cacheMutex.Unlock()
return nil
}
// GetMaintenanceCached returns a cached maintenance setting for low-latency checks.
// Cache is refreshed after s.cacheTTL.
func (s *SettingsService) GetMaintenanceCached(ctx context.Context) (*MaintenanceSetting, error) {
s.cacheMutex.RLock()
if s.cachedMaintenance != nil && time.Now().Before(s.cacheExpiry) {
m := s.cachedMaintenance
s.cacheMutex.RUnlock()
return m, nil
}
s.cacheMutex.RUnlock()
// Fetch fresh
m, err := s.GetMaintenance(ctx)
if err != nil {
return nil, err
}
s.cacheMutex.Lock()
s.cachedMaintenance = m
s.cacheExpiry = time.Now().Add(s.cacheTTL)
s.cacheMutex.Unlock()
return m, nil
}

View File

@ -0,0 +1,59 @@
package service
import (
"context"
"testing"
"time"
)
type memRepo struct {
data map[string]map[string]any
}
func newMemRepo() *memRepo { return &memRepo{data: map[string]map[string]any{}} }
func (r *memRepo) Get(_ context.Context, key string) (map[string]any, error) { return r.data[key], nil }
func (r *memRepo) Set(_ context.Context, key string, value map[string]any) error {
r.data[key] = value
return nil
}
func TestSettingsService_Caching(t *testing.T) {
repo := newMemRepo()
svc := NewSettingsService(repo)
// shorten TTL for test
svc.cacheTTL = 10 * time.Millisecond
// initial - no maintenance set
m, err := svc.GetMaintenanceCached(context.Background())
if err != nil {
t.Fatalf("err: %v", err)
}
if m.Enabled {
t.Fatalf("expected disabled initially")
}
// update repo directly and ensure cached value doesn't change until TTL or invalidation
repo.Set(context.Background(), "maintenance", map[string]any{"enabled": true, "message": "now"})
// cached - should still be old value
m, _ = svc.GetMaintenanceCached(context.Background())
if m.Enabled {
t.Fatalf("expected cached value to still be disabled")
}
// wait for TTL expiry
time.Sleep(20 * time.Millisecond)
m, _ = svc.GetMaintenanceCached(context.Background())
if !m.Enabled || m.Message != "now" {
t.Fatalf("expected refreshed value, got %#v", m)
}
// set via service should invalidate cache immediately
if err := svc.SetMaintenance(context.Background(), &MaintenanceSetting{Enabled: false, Message: "off"}); err != nil {
t.Fatalf("set maintenance: %v", err)
}
m, _ = svc.GetMaintenanceCached(context.Background())
if m.Enabled {
t.Fatalf("expected disabled after set, got %#v", m)
}
}

View File

@ -0,0 +1,2 @@
-- Migration: 019_create_system_settings_table.down.sql
DROP TABLE IF EXISTS system_settings;

View File

@ -0,0 +1,10 @@
-- Migration: 019_create_system_settings_table.up.sql
-- Create a system_settings table to store key-value settings such as maintenance mode
CREATE TABLE IF NOT EXISTS system_settings (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
-- Add an index on updated_at for efficient queries
CREATE INDEX IF NOT EXISTS idx_system_settings_updated_at ON system_settings(updated_at DESC);

View File

@ -66,7 +66,7 @@ export const BasicInfoSection: React.FC<BasicInfoSectionProps> = ({
const translatedSectors = dynamicSectors.map((s) => ({
...s,
name: t(s.nameKey),
value: s.backendName
value: s.backendName,
}));
const [name, sector, description] = watch(['name', 'sector', 'description']);

View File

@ -92,12 +92,18 @@ const Step0 = ({ onSmartFill, onManualFill, isParsing, parseError }: Step0Props)
disabled={isParsing}
/>
</label>
{fileValue && <Text variant="muted" className="text-sm mt-2">{fileValue.name}</Text>}
{fileValue && (
<Text variant="muted" className="text-sm mt-2">
{fileValue.name}
</Text>
)}
</div>
)}
</div>
{parseError && <Text className="text-destructive text-xs mt-2 text-center">{parseError}</Text>}
{parseError && (
<Text className="text-destructive text-xs mt-2 text-center">{parseError}</Text>
)}
<div className="mt-6 space-y-2">
<Button

View File

@ -112,11 +112,7 @@ export const ActivityFeed = ({
{activities.map((activity) => (
<div key={activity.id} className="flex items-start gap-3">
{activity.user ? (
<Avatar
src={activity.user.avatar}
name={activity.user.name}
size="sm"
/>
<Avatar src={activity.user.avatar} name={activity.user.name} size="sm" />
) : (
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center text-lg">
{typeIcons[activity.type]}
@ -159,4 +155,3 @@ export const ActivityFeed = ({
</Card>
);
};

View File

@ -69,4 +69,3 @@ export const ChartCard = ({
</Card>
);
};

View File

@ -116,7 +116,8 @@ export function DataTable<T>({
selection.onSelectionChange(newSelection);
};
const allSelected = data.length > 0 && data.every((item) => selection?.selectedRows.has(getRowId(item)));
const allSelected =
data.length > 0 && data.every((item) => selection?.selectedRows.has(getRowId(item)));
const someSelected = data.some((item) => selection?.selectedRows.has(getRowId(item)));
// Add selection column if selection is enabled
@ -189,8 +190,7 @@ export function DataTable<T>({
const handleSort = (key: string) => {
if (!sorting) return;
const newDirection =
sorting.column === key && sorting.direction === 'asc' ? 'desc' : 'asc';
const newDirection = sorting.column === key && sorting.direction === 'asc' ? 'desc' : 'asc';
sorting.onSort(key, newDirection);
};
@ -212,9 +212,7 @@ export function DataTable<T>({
{/* Bulk Actions */}
{hasSelection && bulkActions && bulkActions.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{selectedCount} selected
</span>
<span className="text-sm text-muted-foreground">{selectedCount} selected</span>
{bulkActions.map((action, index) => (
<Button
key={index}
@ -252,9 +250,7 @@ export function DataTable<T>({
}}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
<span className="text-sm text-muted-foreground">
Select all ({data.length} items)
</span>
<span className="text-sm text-muted-foreground">Select all ({data.length} items)</span>
</div>
)}
@ -311,4 +307,3 @@ export function DataTable<T>({
</div>
);
}

View File

@ -76,7 +76,10 @@ const EconomicGraph = () => {
style={{ opacity }}
>
<title>{`Сектор: ${node.label}\nОрганизаций: ${node.orgCount}`}</title>
<circle r={Math.max(10, Number(node.size) || 10)} className={`fill-sector-${node.color}`} />
<circle
r={Math.max(10, Number(node.size) || 10)}
className={`fill-sector-${node.color}`}
/>
<text
textAnchor="middle"
dy={(node.size || 10) + 12}

View File

@ -26,13 +26,7 @@ export interface FilterBarProps {
/**
* Advanced filter bar component
*/
export const FilterBar = ({
filters,
values,
onChange,
onReset,
className,
}: FilterBarProps) => {
export const FilterBar = ({ filters, values, onChange, onReset, className }: FilterBarProps) => {
const [isOpen, setIsOpen] = useState(false);
const activeFilterCount = Object.values(values).filter(
(v) => v !== null && v !== undefined && (Array.isArray(v) ? v.length > 0 : true)
@ -60,11 +54,7 @@ export const FilterBar = ({
<div className={clsx('flex items-center gap-2', className)}>
<Popover
trigger={
<Button
variant={hasActiveFilters ? 'primary' : 'outline'}
size="sm"
className="relative"
>
<Button variant={hasActiveFilters ? 'primary' : 'outline'} size="sm" className="relative">
<Filter className="h-4 w-4 mr-2" />
Filters
{hasActiveFilters && (
@ -96,7 +86,7 @@ export const FilterBar = ({
<div key={filter.id} className="space-y-2">
<label className="text-sm font-medium">{filter.label}</label>
<SelectDropdown
value={currentValue as string || ''}
value={(currentValue as string) || ''}
onChange={(value) => handleFilterChange(filter.id, value || null)}
options={filter.options || []}
placeholder={filter.placeholder || `Select ${filter.label}`}
@ -145,7 +135,10 @@ export const FilterBar = ({
type="number"
value={(currentValue as number) || ''}
onChange={(e) =>
handleFilterChange(filter.id, e.target.value ? Number(e.target.value) : null)
handleFilterChange(
filter.id,
e.target.value ? Number(e.target.value) : null
)
}
placeholder={filter.placeholder}
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
@ -210,4 +203,3 @@ export const FilterBar = ({
</div>
);
};

View File

@ -49,4 +49,3 @@ export const FormSection = ({
</Card>
);
};

View File

@ -5,9 +5,7 @@ import VerifiedBadge from '@/components/ui/VerifiedBadge.tsx';
import { getSectorDisplay } from '@/constants.tsx';
import { useOrganizationTable } from '@/hooks/features/useOrganizationTable.ts';
import { useTranslation } from '@/hooks/useI18n.tsx';
import {
getTranslatedSectorName
} from '@/lib/sector-mapper.ts';
import { getTranslatedSectorName } from '@/lib/sector-mapper.ts';
import { getOrganizationSubtypeLabel } from '@/schemas/organizationSubtype.ts';
import { Organization } from '@/types.ts';
import { useVerifyOrganization, useRejectVerification } from '@/hooks/api/useAdminAPI.ts';

View File

@ -45,7 +45,9 @@ export const PageHeader = ({
};
const primaryActions = actions.filter((a) => a.variant === 'primary' || !a.variant);
const secondaryActions = actions.filter((a) => a.variant !== 'primary' && a.variant !== 'destructive');
const secondaryActions = actions.filter(
(a) => a.variant !== 'primary' && a.variant !== 'destructive'
);
const destructiveActions = actions.filter((a) => a.variant === 'destructive');
const menuActions = [...secondaryActions, ...destructiveActions];
@ -74,9 +76,7 @@ export const PageHeader = ({
)}
<div>
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
{subtitle && (
<p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>
)}
{subtitle && <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>}
</div>
</div>
</div>
@ -119,4 +119,3 @@ export const PageHeader = ({
</div>
);
};

View File

@ -31,4 +31,3 @@ export const SearchAndFilter = ({ search, filters, className }: SearchAndFilterP
</div>
);
};

View File

@ -39,4 +39,3 @@ export const SettingsSection = ({
</Card>
);
};

View File

@ -1,5 +1,6 @@
import { Avatar, DropdownMenu } from '@/components/ui';
import { useAuth } from '@/contexts/AuthContext';
import { useMaintenanceSetting } from '@/hooks/api/useAdminAPI';
import { useTranslation } from '@/hooks/useI18n';
import { clsx } from 'clsx';
import {
@ -122,6 +123,7 @@ export const AdminLayout = ({ children, title, breadcrumbs }: AdminLayoutProps)
const navigate = useNavigate();
const { user, logout } = useAuth();
const { t } = useTranslation();
const maintenance = useMaintenanceSetting();
const toggleSidebar = () => setSidebarOpen(!sidebarOpen);
const toggleExpanded = (id: string) => {
@ -136,7 +138,8 @@ export const AdminLayout = ({ children, title, breadcrumbs }: AdminLayoutProps)
});
};
const isActive = (path: string) => location.pathname === path || location.pathname.startsWith(path + '/');
const isActive = (path: string) =>
location.pathname === path || location.pathname.startsWith(path + '/');
const userMenuItems = [
{
@ -168,14 +171,10 @@ export const AdminLayout = ({ children, title, breadcrumbs }: AdminLayoutProps)
<div className="flex h-screen overflow-hidden bg-background">
{/* Sidebar */}
<aside
className={clsx(
'flex flex-col border-r bg-card transition-all duration-300',
'z-40',
{
'w-64': sidebarOpen,
'w-0 overflow-hidden': !sidebarOpen,
}
)}
className={clsx('flex flex-col border-r bg-card transition-all duration-300', 'z-40', {
'w-64': sidebarOpen,
'w-0 overflow-hidden': !sidebarOpen,
})}
>
{/* Sidebar Header */}
<div className="flex h-16 items-center justify-between border-b px-4">
@ -215,7 +214,8 @@ export const AdminLayout = ({ children, title, breadcrumbs }: AdminLayoutProps)
'w-full flex items-center justify-between gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors',
{
'bg-primary/10 text-primary': itemActive,
'text-muted-foreground hover:bg-muted hover:text-foreground': !itemActive,
'text-muted-foreground hover:bg-muted hover:text-foreground':
!itemActive,
}
)}
>
@ -249,7 +249,8 @@ export const AdminLayout = ({ children, title, breadcrumbs }: AdminLayoutProps)
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors',
{
'bg-primary/10 text-primary font-medium': childActive,
'text-muted-foreground hover:bg-muted hover:text-foreground': !childActive,
'text-muted-foreground hover:bg-muted hover:text-foreground':
!childActive,
}
)}
>
@ -271,7 +272,8 @@ export const AdminLayout = ({ children, title, breadcrumbs }: AdminLayoutProps)
'w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors',
{
'bg-primary/10 text-primary': itemActive,
'text-muted-foreground hover:bg-muted hover:text-foreground': !itemActive,
'text-muted-foreground hover:bg-muted hover:text-foreground':
!itemActive,
}
)}
>
@ -317,7 +319,9 @@ export const AdminLayout = ({ children, title, breadcrumbs }: AdminLayoutProps)
trigger={
<div className="flex items-center gap-2 cursor-pointer">
<Avatar src={undefined} name={user?.name || 'Admin'} size="sm" />
<span className="hidden md:block text-sm font-medium">{user?.name || 'Admin'}</span>
<span className="hidden md:block text-sm font-medium">
{user?.name || 'Admin'}
</span>
</div>
}
items={userMenuItems}
@ -329,11 +333,16 @@ export const AdminLayout = ({ children, title, breadcrumbs }: AdminLayoutProps)
{/* Page Content */}
<main className="flex-1 overflow-y-auto bg-muted/30">
<div className="container mx-auto p-6">
{breadcrumbs && breadcrumbs.length > 0 && (
<div className="mb-4">
{/* Breadcrumbs will be rendered here if needed */}
{/* Show maintenance banner for admins */}
{maintenance.data?.enabled && (
<div className="mb-4 p-3 bg-yellow-50 border-l-4 border-yellow-400">
<strong className="block">Maintenance mode is active</strong>
<div className="text-sm">{maintenance.data?.message}</div>
</div>
)}
{breadcrumbs && breadcrumbs.length > 0 && (
<div className="mb-4">{/* Breadcrumbs will be rendered here if needed */}</div>
)}
{children}
</div>
</main>
@ -341,4 +350,3 @@ export const AdminLayout = ({ children, title, breadcrumbs }: AdminLayoutProps)
</div>
);
};

View File

@ -0,0 +1,52 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card.tsx';
import { CenteredContent } from '@/components/ui/CenteredContent.tsx';
import { Grid } from '@/components/ui/layout';
import { Heading, Text } from '@/components/ui/Typography.tsx';
type Props = {
totalConnections: number;
activeConnections: number;
potentialConnections: number;
connectionRate: number;
t: (key: string) => string;
};
const ConnectionAnalyticsSection = ({
totalConnections,
activeConnections,
potentialConnections,
connectionRate,
t,
}: Props) => {
return (
<Card>
<CardHeader>
<CardTitle>{t('analyticsDashboard.connections')}</CardTitle>
</CardHeader>
<CardContent>
<Grid cols={{ md: 3 }} gap="md">
<CenteredContent>
<Heading level="h3" className="text-primary mb-1">
{totalConnections}
</Heading>
<Text variant="muted">{t('analyticsDashboard.totalConnections')}</Text>
</CenteredContent>
<CenteredContent>
<Heading level="h3" className="text-success mb-1">
{activeConnections}
</Heading>
<Text variant="muted">{t('analyticsDashboard.activeConnections')}</Text>
</CenteredContent>
<CenteredContent>
<Heading level="h3" className="text-warning mb-1">
{Math.round(connectionRate * 100)}%
</Heading>
<Text variant="muted">{t('analyticsDashboard.connectionRate')}</Text>
</CenteredContent>
</Grid>
</CardContent>
</Card>
);
};
export default ConnectionAnalyticsSection;

View File

@ -0,0 +1,97 @@
import Badge from '@/components/ui/Badge.tsx';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card.tsx';
import { Grid } from '@/components/ui/layout';
import Spinner from '@/components/ui/Spinner.tsx';
import { formatCurrency, formatNumber } from '@/lib/fin';
import { ArrowUp, BarChart3, Clock } from 'lucide-react';
type Props = {
isLoading: boolean;
totalCo2Saved: number;
totalEconomicValue: number;
activeMatchesCount: number;
environmentalBreakdown: Record<string, any>;
t: (key: string) => string;
};
const ImpactBreakdownSection = ({
isLoading,
totalCo2Saved,
totalEconomicValue,
activeMatchesCount,
environmentalBreakdown,
t,
}: Props) => {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{t('analyticsDashboard.environmentalImpact')}
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Spinner className="h-6 w-6" />
</div>
) : (
<Grid cols={{ md: 2, lg: 4 }} gap="md">
<div className="text-center">
<div className="text-3xl font-bold text-green-600 mb-2">
{formatNumber(totalCo2Saved)}
</div>
<p className="text-sm text-muted-foreground">
{t('analyticsDashboard.tonnesCo2Saved')}
</p>
<Badge variant="outline" className="mt-2">
<ArrowUp className="h-4 w-4 mr-1 text-current" />
{t('analyticsDashboard.perYear')}
</Badge>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-success mb-2">
{formatCurrency(totalEconomicValue)}
</div>
<p className="text-sm text-muted-foreground">
{t('analyticsDashboard.economicValueCreated')}
</p>
<Badge variant="outline" className="mt-2">
<ArrowUp className="h-4 w-4 mr-1 text-current" />
{t('analyticsDashboard.annual')}
</Badge>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-blue-600 mb-2">
{formatNumber(activeMatchesCount)}
</div>
<p className="text-sm text-muted-foreground">
{t('analyticsDashboard.activeMatches')}
</p>
<Badge variant="outline" className="mt-2">
<Clock className="h-4 w-4 mr-1 text-current" />
{t('analyticsDashboard.operational')}
</Badge>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-purple-600 mb-2">
{Object.keys(environmentalBreakdown || {}).length}
</div>
<p className="text-sm text-muted-foreground">
{t('analyticsDashboard.impactCategories')}
</p>
<Badge variant="outline" className="mt-2">
<BarChart3 className="h-4 w-4 mr-1 text-current" />
{t('analyticsDashboard.tracked')}
</Badge>
</div>
</Grid>
)}
</CardContent>
</Card>
);
};
export default ImpactBreakdownSection;

View File

@ -0,0 +1,81 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card.tsx';
import { Stack } from '@/components/ui/layout';
import Spinner from '@/components/ui/Spinner.tsx';
import { formatCurrency } from '@/lib/fin';
import SimpleBarChart from './SimpleBarChart';
type Props = {
isLoading: boolean;
matchSuccessRate: number;
avgMatchTime: number;
totalMatchValue: number;
topResourceTypes: Array<{ type: string; count: number }>;
t: (key: string) => string;
};
const MatchingPerformanceSection = ({
isLoading,
matchSuccessRate,
avgMatchTime,
totalMatchValue,
topResourceTypes,
t,
}: Props) => {
const formatPercentage = (value: number) => `${(value * 100).toFixed(1)}%`;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{t('analyticsDashboard.matchingPerformance')}
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Spinner className="h-6 w-6" />
</div>
) : (
<Stack spacing="md">
<div className="grid grid-cols-2 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-success mb-1">
{formatPercentage(matchSuccessRate)}
</div>
<p className="text-xs text-muted-foreground">
{t('analyticsDashboard.successRate')}
</p>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-primary mb-1">
{avgMatchTime.toFixed(1)}
</div>
<p className="text-xs text-muted-foreground">{t('analyticsDashboard.avgDays')}</p>
</div>
</div>
<div>
<div className="text-sm font-medium mb-2">
{t('analyticsDashboard.totalMatchValue')}
</div>
<div className="text-lg font-bold text-success">
{formatCurrency(totalMatchValue)}
</div>
</div>
{topResourceTypes.length > 0 && (
<SimpleBarChart
data={topResourceTypes
.slice(0, 5)
.map((item) => ({ label: item.type, value: item.count }))}
title={t('analyticsDashboard.topResourceTypes')}
/>
)}
</Stack>
)}
</CardContent>
</Card>
);
};
export default MatchingPerformanceSection;

View File

@ -0,0 +1,44 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card.tsx';
import SimpleBarChart from './SimpleBarChart';
type Props = {
flowsByType: Record<string, number>;
flowsBySector: Record<string, number>;
avgFlowValue: number;
totalFlowVolume: number;
t: (key: string) => string;
};
const ResourceFlowAnalyticsSection = ({
flowsByType,
flowsBySector,
avgFlowValue,
totalFlowVolume,
t,
}: Props) => {
const byType = Object.entries(flowsByType || {}).map(([label, value]) => ({ label, value }));
const bySector = Object.entries(flowsBySector || {}).map(([label, value]) => ({ label, value }));
return (
<Card>
<CardHeader>
<CardTitle>{t('analyticsDashboard.resourceFlows')}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<SimpleBarChart data={byType.slice(0, 6)} title={t('analyticsDashboard.flowsByType')} />
</div>
<div>
<SimpleBarChart
data={bySector.slice(0, 6)}
title={t('analyticsDashboard.flowsBySector')}
/>
</div>
</div>
</CardContent>
</Card>
);
};
export default ResourceFlowAnalyticsSection;

View File

@ -0,0 +1,38 @@
import { formatNumber } from '@/lib/fin';
const SimpleBarChart = ({
data,
title,
}: {
data: Array<{ label: string; value: number; color?: string }>;
title: string;
}) => {
const maxValue = Math.max(...data.map((d) => d.value), 1);
return (
<div className="space-y-2">
<h4 className="text-sm font-medium text-muted-foreground">{title}</h4>
<div className="space-y-2">
{data.map((item, index) => (
<div key={index} className="flex items-center gap-3">
<span className="text-sm min-w-16 truncate">{item.label}</span>
<div className="flex-1 bg-muted rounded-full h-2">
<div
className="h-2 rounded-full transition-all duration-500"
style={{
width: `${(item.value / maxValue) * 100}%`,
backgroundColor: item.color || 'hsl(var(--primary))',
}}
/>
</div>
<span className="text-sm font-medium min-w-12 text-right">
{formatNumber(item.value)}
</span>
</div>
))}
</div>
</div>
);
};
export default SimpleBarChart;

View File

@ -0,0 +1,37 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card.tsx';
import SimpleBarChart from './SimpleBarChart';
type Props = {
topNeeds: Array<{ label: string; value: number }>;
topOffers: Array<{ label: string; value: number }>;
marketGaps: Array<{ label: string; value: number }>;
t: (key: string) => string;
};
const SupplyDemandSection = ({ topNeeds, topOffers, marketGaps, t }: Props) => {
return (
<Card>
<CardHeader>
<CardTitle>{t('analyticsDashboard.supplyDemand')}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<SimpleBarChart
data={topNeeds.slice(0, 6).map((n) => ({ label: n.label, value: n.value }))}
title={t('analyticsDashboard.topNeeds')}
/>
<SimpleBarChart
data={topOffers.slice(0, 6).map((o) => ({ label: o.label, value: o.value }))}
title={t('analyticsDashboard.topOffers')}
/>
<SimpleBarChart
data={marketGaps.slice(0, 6).map((g) => ({ label: g.label, value: g.value }))}
title={t('analyticsDashboard.marketGaps')}
/>
</div>
</CardContent>
</Card>
);
};
export default SupplyDemandSection;

View File

@ -72,4 +72,3 @@ export const AdminRoute = ({
return <>{children}</>;
};

View File

@ -24,9 +24,7 @@ export const PermissionGate = ({
const { checkAnyPermission, checkAllPermissions } = usePermissions();
const permissions = Array.isArray(permission) ? permission : [permission];
const hasAccess = requireAll
? checkAllPermissions(permissions)
: checkAnyPermission(permissions);
const hasAccess = requireAll ? checkAllPermissions(permissions) : checkAnyPermission(permissions);
if (!hasAccess) {
if (showError) {
@ -41,4 +39,3 @@ export const PermissionGate = ({
return <>{children}</>;
};

View File

@ -25,14 +25,16 @@ export const RequirePermission = ({
const { checkPermission, checkAnyPermission, checkAllPermissions } = usePermissions();
const permissions = Array.isArray(permission) ? permission : [permission];
const hasAccess = requireAll
? checkAllPermissions(permissions)
: checkAnyPermission(permissions);
const hasAccess = requireAll ? checkAllPermissions(permissions) : checkAnyPermission(permissions);
if (!hasAccess) {
if (showError) {
return (
<Alert variant="error" title="Access Denied" description="You don't have permission to access this content." />
<Alert
variant="error"
title="Access Denied"
description="You don't have permission to access this content."
/>
);
}
@ -45,4 +47,3 @@ export const RequirePermission = ({
return <>{children}</>;
};

View File

@ -70,52 +70,64 @@ const CreateCommunityListingForm: React.FC<CreateCommunityListingFormProps> = ({
onSuccess: (data) => {
onSuccess?.(data);
navigate('/discovery', {
state: { message: t('community.createSuccess') }
state: { message: t('community.createSuccess') },
});
},
});
const onSubmit = useCallback(async (data: CommunityListingFormData) => {
try {
await createMutation.mutateAsync(data);
} catch (error) {
console.error('Failed to create listing:', error);
}
}, [createMutation]);
const onSubmit = useCallback(
async (data: CommunityListingFormData) => {
try {
await createMutation.mutateAsync(data);
} catch (error) {
console.error('Failed to create listing:', error);
}
},
[createMutation]
);
const handleNext = async () => {
const isStepValid = await trigger();
if (isStepValid) {
setCurrentStep(prev => Math.min(prev + 1, totalSteps));
setCurrentStep((prev) => Math.min(prev + 1, totalSteps));
}
};
const handlePrev = () => {
setCurrentStep(prev => Math.max(prev - 1, 1));
setCurrentStep((prev) => Math.max(prev - 1, 1));
};
const handleLocationChange = useCallback((location: { lat: number; lng: number }) => {
setValue('latitude', location.lat);
setValue('longitude', location.lng);
}, [setValue]);
const handleLocationChange = useCallback(
(location: { lat: number; lng: number }) => {
setValue('latitude', location.lat);
setValue('longitude', location.lng);
},
[setValue]
);
const handleImagesChange = useCallback((images: string[]) => {
setValue('images', images);
}, [setValue]);
const handleImagesChange = useCallback(
(images: string[]) => {
setValue('images', images);
},
[setValue]
);
const handleTagsChange = useCallback((tags: string[]) => {
setValue('tags', tags);
}, [setValue]);
const handleTagsChange = useCallback(
(tags: string[]) => {
setValue('tags', tags);
},
[setValue]
);
const renderStep1 = () => (
<Stack spacing="xl">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Tag className="h-5 w-5" />
{t('community.form.basicInfo')}
</CardTitle>
</CardHeader>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Tag className="h-5 w-5" />
{t('community.form.basicInfo')}
</CardTitle>
</CardHeader>
<CardContent>
<Stack spacing="md">
<FormField label={t('community.form.title')} error={errors.title?.message} required>
@ -135,7 +147,11 @@ const CreateCommunityListingForm: React.FC<CreateCommunityListingFormProps> = ({
/>
</FormField>
<FormField label={t('community.form.listingType')} error={errors.listing_type?.message} required>
<FormField
label={t('community.form.listingType')}
error={errors.listing_type?.message}
required
>
<Select {...register('listing_type')}>
<option value="">{t('community.form.selectType')}</option>
<option value="product">{t('community.types.product')}</option>
@ -147,14 +163,20 @@ const CreateCommunityListingForm: React.FC<CreateCommunityListingFormProps> = ({
</FormField>
{listingType && (
<FormField label={t('community.form.category')} error={errors.category?.message} required>
<FormField
label={t('community.form.category')}
error={errors.category?.message}
required
>
<Select {...register('category')}>
<option value="">{t('community.form.selectCategory')}</option>
{LISTING_CATEGORIES[listingType as keyof typeof LISTING_CATEGORIES]?.map(category => (
<option key={category} value={category}>
{category}
</option>
))}
{LISTING_CATEGORIES[listingType as keyof typeof LISTING_CATEGORIES]?.map(
(category) => (
<option key={category} value={category}>
{category}
</option>
)
)}
</Select>
</FormField>
)}
@ -176,10 +198,14 @@ const CreateCommunityListingForm: React.FC<CreateCommunityListingFormProps> = ({
</CardHeader>
<CardContent>
<Stack spacing="md">
<FormField label={t('community.form.condition')} error={errors.condition?.message} required>
<FormField
label={t('community.form.condition')}
error={errors.condition?.message}
required
>
<Select {...register('condition')}>
<option value="">{t('community.form.selectCondition')}</option>
{CONDITION_OPTIONS.map(option => (
{CONDITION_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
@ -190,7 +216,7 @@ const CreateCommunityListingForm: React.FC<CreateCommunityListingFormProps> = ({
<FormField label={t('community.form.priceType')} error={errors.price_type?.message}>
<Select {...register('price_type')}>
<option value="">{t('community.form.selectPriceType')}</option>
{PRICE_TYPE_OPTIONS.map(option => (
{PRICE_TYPE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
@ -235,10 +261,14 @@ const CreateCommunityListingForm: React.FC<CreateCommunityListingFormProps> = ({
</CardHeader>
<CardContent>
<Stack spacing="md">
<FormField label={t('community.form.serviceType')} error={errors.service_type?.message} required>
<FormField
label={t('community.form.serviceType')}
error={errors.service_type?.message}
required
>
<Select {...register('service_type')}>
<option value="">{t('community.form.selectServiceType')}</option>
{SERVICE_TYPE_OPTIONS.map(option => (
{SERVICE_TYPE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
@ -249,7 +279,7 @@ const CreateCommunityListingForm: React.FC<CreateCommunityListingFormProps> = ({
<FormField label={t('community.form.rateType')} error={errors.rate_type?.message}>
<Select {...register('rate_type')}>
<option value="">{t('community.form.selectRateType')}</option>
{RATE_TYPE_OPTIONS.map(option => (
{RATE_TYPE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
@ -257,18 +287,20 @@ const CreateCommunityListingForm: React.FC<CreateCommunityListingFormProps> = ({
</Select>
</FormField>
{watch('rate_type') && watch('rate_type') !== 'free' && watch('rate_type') !== 'trade' && (
<FormField label={t('community.form.rate')} error={errors.rate?.message}>
<Input
{...register('rate', { valueAsNumber: true })}
type="number"
step="0.01"
min="0"
placeholder="0.00"
className={errors.rate ? 'border-destructive' : ''}
/>
</FormField>
)}
{watch('rate_type') &&
watch('rate_type') !== 'free' &&
watch('rate_type') !== 'trade' && (
<FormField label={t('community.form.rate')} error={errors.rate?.message}>
<Input
{...register('rate', { valueAsNumber: true })}
type="number"
step="0.01"
min="0"
placeholder="0.00"
className={errors.rate ? 'border-destructive' : ''}
/>
</FormField>
)}
</Stack>
</CardContent>
</Card>
@ -283,23 +315,29 @@ const CreateCommunityListingForm: React.FC<CreateCommunityListingFormProps> = ({
</CardHeader>
<CardContent>
<Stack spacing="md">
<div className="text-sm text-muted-foreground">
{t('community.form.locationHelp')}
</div>
<div className="text-sm text-muted-foreground">{t('community.form.locationHelp')}</div>
<div className="h-64">
<MapPicker
onChange={handleLocationChange}
value={watch('latitude') && watch('longitude') ? {
lat: watch('latitude')!,
lng: watch('longitude')!
} : undefined}
value={
watch('latitude') && watch('longitude')
? {
lat: watch('latitude')!,
lng: watch('longitude')!,
}
: undefined
}
/>
</div>
{errors.latitude && (
<Text variant="small" className="text-destructive">{errors.latitude.message}</Text>
<Text variant="small" className="text-destructive">
{errors.latitude.message}
</Text>
)}
{errors.longitude && (
<Text variant="small" className="text-destructive">{errors.longitude.message}</Text>
<Text variant="small" className="text-destructive">
{errors.longitude.message}
</Text>
)}
</Stack>
</CardContent>
@ -310,12 +348,12 @@ const CreateCommunityListingForm: React.FC<CreateCommunityListingFormProps> = ({
const renderStep3 = () => (
<Stack spacing="xl">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
{t('community.form.media')}
</CardTitle>
</CardHeader>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
{t('community.form.media')}
</CardTitle>
</CardHeader>
<CardContent>
<Stack spacing="md">
<FormField label={t('community.form.images')} error={errors.images?.message}>
@ -332,7 +370,10 @@ const CreateCommunityListingForm: React.FC<CreateCommunityListingFormProps> = ({
<Input
placeholder={t('community.form.tagsPlaceholder')}
onChange={(e) => {
const tags = e.target.value.split(',').map(tag => tag.trim()).filter(Boolean);
const tags = e.target.value
.split(',')
.map((tag) => tag.trim())
.filter(Boolean);
handleTagsChange(tags);
}}
defaultValue={watch('tags')?.join(', ')}
@ -398,16 +439,18 @@ const CreateCommunityListingForm: React.FC<CreateCommunityListingFormProps> = ({
<div className="mb-8">
<Heading level="h1" tKey="community.createListing" className="mb-2" />
<div className="flex items-center gap-2 mb-4">
{[1, 2, 3].map(step => (
{[1, 2, 3].map((step) => (
<div
key={step}
className={`h-2 flex-1 rounded ${
step <= currentStep ? 'bg-primary' : 'bg-muted'
}`}
className={`h-2 flex-1 rounded ${step <= currentStep ? 'bg-primary' : 'bg-muted'}`}
/>
))}
</div>
<Text variant="muted" tKey="community.step" replacements={{ step: currentStep, total: totalSteps }}>
<Text
variant="muted"
tKey="community.step"
replacements={{ step: currentStep, total: totalSteps }}
>
{t('community.step')} {currentStep} {t('community.of')} {totalSteps}
</Text>
</div>
@ -418,9 +461,11 @@ const CreateCommunityListingForm: React.FC<CreateCommunityListingFormProps> = ({
{createMutation.error && (
<Alert
variant="destructive"
description={createMutation.error instanceof Error
? createMutation.error.message
: t('community.createError')}
description={
createMutation.error instanceof Error
? createMutation.error.message
: t('community.createError')
}
/>
)}
@ -443,10 +488,7 @@ const CreateCommunityListingForm: React.FC<CreateCommunityListingFormProps> = ({
{t('common.next')}
</Button>
) : (
<Button
type="submit"
disabled={createMutation.isPending || !isValid}
>
<Button type="submit" disabled={createMutation.isPending || !isValid}>
{createMutation.isPending ? (
<>
<Spinner className="h-4 w-4 mr-2" />

View File

@ -0,0 +1,83 @@
import Badge from '@/components/ui/Badge.tsx';
import Button from '@/components/ui/Button.tsx';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card.tsx';
import { CenteredContent } from '@/components/ui/CenteredContent.tsx';
import { EmptyState } from '@/components/ui/EmptyState.tsx';
import { Flex } from '@/components/ui/layout';
import type { Proposal } from '@/types.ts';
import { Users } from 'lucide-react';
type Props = {
pendingProposals: Proposal[];
onNavigate: (path: string) => void;
t: (key: string) => string;
};
const ActiveProposalsSection = ({ pendingProposals, onNavigate, t }: Props) => {
return (
<Card>
<CardHeader>
<Flex align="center" justify="between">
<CardTitle>{t('dashboard.activeProposals')}</CardTitle>
<Badge variant="outline">{pendingProposals.length}</Badge>
</Flex>
</CardHeader>
<CardContent>
{pendingProposals.length > 0 ? (
<div className="space-y-3">
{pendingProposals.slice(0, 5).map((proposal: Proposal) => (
<div
key={proposal.id}
className="p-3 rounded-lg border bg-card/50 hover:bg-card transition-colors cursor-pointer"
onClick={() => {
if (proposal.toOrgId) onNavigate(`/organization/${proposal.toOrgId}`);
else if (proposal.fromOrgId) onNavigate(`/organization/${proposal.fromOrgId}`);
else onNavigate('/matching');
}}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="font-medium line-clamp-2">{proposal.message}</div>
<div className="text-xs text-muted-foreground mt-1">
{t('dashboard.proposalStatus')}:{' '}
{t(`organizationPage.status.${proposal.status}`)}
</div>
</div>
<Badge
variant={
proposal.status === 'accepted'
? 'default'
: proposal.status === 'rejected'
? 'destructive'
: 'secondary'
}
className="shrink-0"
>
{t(`organizationPage.status.${proposal.status}`)}
</Badge>
</div>
</div>
))}
{pendingProposals.length > 5 && (
<CenteredContent padding="sm">
<Button variant="outline" size="sm" onClick={() => onNavigate('/matching')}>
{t('dashboard.viewAllProposals')}
</Button>
</CenteredContent>
)}
</div>
) : (
<EmptyState
type="no-data"
icon={<Users className="h-12 w-12 opacity-50" />}
title={t('dashboard.noActiveProposalsTitle')}
description={t('dashboard.noActiveProposalsDesc')}
action={{ label: t('dashboard.exploreMap'), onClick: () => onNavigate('/map') }}
/>
)}
</CardContent>
</Card>
);
};
export default ActiveProposalsSection;

View File

@ -0,0 +1,76 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card.tsx';
import { Grid } from '@/components/ui/layout';
import { Heading, Price, Text } from '@/components/ui/Typography.tsx';
import { DollarSign, Leaf, TrendingUp } from 'lucide-react';
type Props = {
totalCo2Saved: number;
totalEconomicValue: number;
activeMatches: number;
t: (key: string) => string;
};
const ImpactMetricsSection = ({ totalCo2Saved, totalEconomicValue, activeMatches, t }: Props) => {
if (totalCo2Saved <= 0 && totalEconomicValue <= 0) return null;
return (
<Grid cols={{ md: 3 }} gap="md">
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Leaf className="h-4 w-4 text-green-600" />
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('dashboard.co2Saved')}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<Heading level="h3" className="text-green-600 mb-1">
{totalCo2Saved} t
</Heading>
<Text variant="muted" className="text-xs">
{t('dashboard.perYear')}
</Text>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<DollarSign className="h-4 text-success w-4" />
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('dashboard.economicValue')}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<Price value={totalEconomicValue} variant="large" className="text-success" />
<Text variant="muted" className="text-xs mt-1">
{t('dashboard.created')}
</Text>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<TrendingUp className="h-4 text-blue-600 w-4" />
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('dashboard.activeMatches')}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<Heading level="h3" className="text-blue-600 mb-1">
{activeMatches}
</Heading>
<Text variant="muted" className="text-xs">
{t('dashboard.operational')}
</Text>
</CardContent>
</Card>
</Grid>
);
};
export default ImpactMetricsSection;

View File

@ -0,0 +1,25 @@
import MetricItem from '@/components/ui/MetricItem.tsx';
import { Grid } from '@/components/ui/layout';
type Metric = {
icon?: React.ReactNode;
label: string;
value: React.ReactNode;
};
type Props = {
items: Metric[];
cols?: { md?: number; lg?: number };
};
const MetricsGrid = ({ items, cols = { md: 2, lg: 4 } }: Props) => {
return (
<Grid cols={cols} gap="md">
{items.map((it, idx) => (
<MetricItem key={idx} icon={it.icon} label={it.label} value={it.value} />
))}
</Grid>
);
};
export default MetricsGrid;

View File

@ -0,0 +1,62 @@
import Button from '@/components/ui/Button.tsx';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card.tsx';
import { EmptyState } from '@/components/ui/EmptyState.tsx';
import { Flex, Grid } from '@/components/ui/layout';
import { Briefcase } from 'lucide-react';
type Org = any;
type Props = {
organizations: Org[] | null | undefined;
onNavigate: (path: string) => void;
t: (key: string) => string;
};
const MyOrganizationsSection = ({ organizations, onNavigate, t }: Props) => {
return (
<Card>
<CardHeader>
<Flex align="center" justify="between">
<CardTitle>{t('dashboard.myOrganizations')}</CardTitle>
{organizations && organizations.length > 0 && (
<Button variant="outline" size="sm" onClick={() => onNavigate('/organizations')}>
{t('dashboard.viewAll')}
</Button>
)}
</Flex>
</CardHeader>
<CardContent>
{organizations && organizations.length > 0 ? (
<Grid cols={{ sm: 2, md: 3 }} gap="md">
{organizations.slice(0, 3).map((org: any) => (
<div
key={org.ID}
className="p-4 border rounded-lg hover:bg-muted/50 cursor-pointer transition-colors"
onClick={() => onNavigate(`/organization/${org.ID}`)}
>
<h4 className="mb-1 font-semibold">{org.Name}</h4>
<div className="text-sm text-muted-foreground mb-2">{org.sector}</div>
<div className="text-xs">
<span className="inline-block rounded px-2 py-1 border">{org.subtype}</span>
</div>
</div>
))}
</Grid>
) : (
<EmptyState
type="no-data"
icon={<Briefcase className="h-12 w-12 opacity-50" />}
title={t('dashboard.noOrganizationsTitle')}
description={t('dashboard.noOrganizationsDesc')}
action={{
label: t('dashboard.createFirstOrganization'),
onClick: () => onNavigate('/map'),
}}
/>
)}
</CardContent>
</Card>
);
};
export default MyOrganizationsSection;

View File

@ -0,0 +1,49 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card.tsx';
import { CenteredContent } from '@/components/ui/CenteredContent.tsx';
import { Grid } from '@/components/ui/layout';
import { Heading, Text } from '@/components/ui/Typography.tsx';
type Props = {
matchSuccessRate: number;
avgMatchTime: number;
topResourceTypes: any[];
t: (key: string) => string;
};
const PlatformHealthSection = ({ matchSuccessRate, avgMatchTime, topResourceTypes, t }: Props) => {
if (matchSuccessRate <= 0) return null;
return (
<Card>
<CardHeader>
<CardTitle>{t('dashboard.platformHealth')}</CardTitle>
</CardHeader>
<CardContent>
<Grid cols={{ md: 3 }} gap="md">
<CenteredContent>
<Heading level="h3" className="text-success mb-1">
{Math.round(matchSuccessRate * 100)}%
</Heading>
<Text variant="muted" tKey="dashboard.matchSuccessRate" />
</CenteredContent>
<CenteredContent>
<Heading level="h3" className="text-primary mb-1">
{avgMatchTime.toFixed(1)}
</Heading>
<Text variant="muted" tKey="dashboard.avgMatchTime" />
</CenteredContent>
<CenteredContent>
<Heading level="h3" className="text-warning mb-1">
{topResourceTypes.length}
</Heading>
<Text variant="muted" tKey="dashboard.activeResourceTypes" />
</CenteredContent>
</Grid>
</CardContent>
</Card>
);
};
export default PlatformHealthSection;

View File

@ -0,0 +1,21 @@
import { render, screen } from '@testing-library/react';
import PlatformOverviewSection from './PlatformOverviewSection';
describe('PlatformOverviewSection', () => {
it('renders platform metrics', () => {
render(
<PlatformOverviewSection
totalOrganizations={10}
totalSites={20}
totalResourceFlows={5}
totalMatches={2}
t={(k) => k}
/>
);
expect(screen.getByText('dashboard.organizations')).toBeInTheDocument();
expect(screen.getByText('10')).toBeInTheDocument();
expect(screen.getByText('dashboard.sites')).toBeInTheDocument();
expect(screen.getByText('20')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,55 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card.tsx';
import { Grid } from '@/components/ui/layout';
import MetricItem from '@/components/ui/MetricItem.tsx';
import { formatNumber } from '@/lib/fin';
import { Briefcase, MapPin, Target, TrendingUp } from 'lucide-react';
type Props = {
totalOrganizations: number;
totalSites: number;
totalResourceFlows: number;
totalMatches: number;
t: (key: string) => string;
};
const PlatformOverviewSection = ({
totalOrganizations,
totalSites,
totalResourceFlows,
totalMatches,
t,
}: Props) => {
return (
<Card>
<CardHeader>
<CardTitle>{t('dashboard.platformOverview')}</CardTitle>
</CardHeader>
<CardContent>
<Grid cols={{ md: 2, lg: 4 }} gap="md">
<MetricItem
icon={<Briefcase className="h-5 w-5 text-primary" />}
label={t('dashboard.organizations')}
value={formatNumber(totalOrganizations)}
/>
<MetricItem
icon={<MapPin className="h-5 w-5 text-warning" />}
label={t('dashboard.sites')}
value={formatNumber(totalSites)}
/>
<MetricItem
icon={<Target className="h-5 w-5 text-success" />}
label={t('dashboard.resourceFlows')}
value={formatNumber(totalResourceFlows)}
/>
<MetricItem
icon={<TrendingUp className="h-5 w-5 text-purple-500" />}
label={t('dashboard.matches')}
value={formatNumber(totalMatches)}
/>
</Grid>
</CardContent>
</Card>
);
};
export default PlatformOverviewSection;

View File

@ -0,0 +1,62 @@
import Button from '@/components/ui/Button.tsx';
import { Grid } from '@/components/ui/layout';
import { BarChart3, MapPin, Plus, Search } from 'lucide-react';
type Props = {
onNavigate: (path: string) => void;
t: (key: string) => string;
};
const QuickActionsSection = ({ onNavigate, t }: Props) => {
return (
<Card>
<CardHeader>
<CardTitle>{t('dashboard.quickActions')}</CardTitle>
</CardHeader>
<CardContent>
<Grid cols={{ sm: 2, md: 4 }} gap="md">
<Button
variant="outline"
onClick={() => onNavigate('/resources')}
className="h-20 flex flex-col items-center justify-center gap-2 hover:bg-primary/5"
>
<Plus className="h-6 w-6" />
<span className="text-sm">{t('dashboard.createResourceFlow')}</span>
</Button>
<Button
variant="outline"
onClick={() => onNavigate('/matching')}
className="h-20 flex flex-col items-center justify-center gap-2 hover:bg-primary/5"
>
<Search className="h-6 w-6" />
<span className="text-sm">{t('dashboard.findMatches')}</span>
</Button>
<Button
variant="outline"
onClick={() => onNavigate('/map')}
className="h-20 flex flex-col items-center justify-center gap-2 hover:bg-primary/5"
>
<MapPin className="h-6 w-6" />
<span className="text-sm">{t('dashboard.exploreMap')}</span>
</Button>
<Button
variant="outline"
onClick={() => onNavigate('/analytics')}
className="h-20 flex flex-col items-center justify-center gap-2 hover:bg-primary/5"
>
<BarChart3 className="h-6 w-6" />
<span className="text-sm">{t('dashboard.viewAnalytics')}</span>
</Button>
</Grid>
</CardContent>
</Card>
);
};
// reuse Card and Card subcomponents which we've referenced here
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card.tsx';
export default QuickActionsSection;

View File

@ -0,0 +1,106 @@
import { ActivityCard } from '@/components/ui/ActivityCard.tsx';
import Badge from '@/components/ui/Badge.tsx';
import Button from '@/components/ui/Button.tsx';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card.tsx';
import { CenteredContent } from '@/components/ui/CenteredContent.tsx';
import { EmptyState } from '@/components/ui/EmptyState.tsx';
import { Flex, Stack } from '@/components/ui/layout';
import { LoadingState } from '@/components/ui/LoadingState.tsx';
import { Target } from 'lucide-react';
type Props = {
filteredActivities: any[];
activityFilter: string;
setActivityFilter: (f: any) => void;
isLoading: boolean;
t: (key: string) => string;
};
const RecentActivitySection = ({
filteredActivities,
activityFilter,
setActivityFilter,
isLoading,
t,
}: Props) => {
return (
<Card>
<CardHeader>
<Flex align="center" justify="between">
<CardTitle>{t('dashboard.recentActivity')}</CardTitle>
<Badge variant="outline">{filteredActivities.length}</Badge>
</Flex>
<div className="flex flex-wrap gap-2 mt-4">
<Button
variant={activityFilter === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => setActivityFilter('all')}
>
{t('dashboard.filterAll')}
</Button>
<Button
variant={activityFilter === 'match' ? 'default' : 'outline'}
size="sm"
onClick={() => setActivityFilter('match')}
>
{t('dashboard.filterMatches')}
</Button>
<Button
variant={activityFilter === 'proposal' ? 'default' : 'outline'}
size="sm"
onClick={() => setActivityFilter('proposal')}
>
{t('dashboard.filterProposals')}
</Button>
<Button
variant={activityFilter === 'organization' ? 'default' : 'outline'}
size="sm"
onClick={() => setActivityFilter('organization')}
>
{t('dashboard.filterOrganizations')}
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<LoadingState size="md" />
) : filteredActivities.length > 0 ? (
<Stack spacing="md">
{filteredActivities.slice(0, 5).map((activity) => (
<ActivityCard
key={activity.id}
description={activity.description}
timestamp={activity.timestamp}
type={activity.type}
/>
))}
{filteredActivities.length > 5 && (
<CenteredContent padding="sm">
<Button variant="outline" size="sm">
{t('dashboard.viewAllActivity')}
</Button>
</CenteredContent>
)}
</Stack>
) : (
<EmptyState
type="no-data"
icon={<Target className="h-12 w-12 opacity-50" />}
title={
activityFilter === 'all'
? t('dashboard.noRecentActivityTitle')
: t('dashboard.noFilteredActivityTitle')
}
description={
activityFilter === 'all'
? t('dashboard.noRecentActivityDesc')
: t('dashboard.noFilteredActivityDesc')
}
/>
)}
</CardContent>
</Card>
);
};
export default RecentActivitySection;

View File

@ -0,0 +1,22 @@
import { render, screen } from '@testing-library/react';
import YourContributionSection from './YourContributionSection';
describe('YourContributionSection', () => {
it('renders metrics correctly', () => {
render(
<YourContributionSection
myOrganizations={3}
myProposals={5}
myPendingProposals={1}
t={(k) => k}
/>
);
expect(screen.getByText('dashboard.myOrganizations')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('dashboard.myProposals')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();
expect(screen.getByText('dashboard.myPendingProposals')).toBeInTheDocument();
expect(screen.getByText('1')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,48 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card.tsx';
import { Grid } from '@/components/ui/layout';
import MetricItem from '@/components/ui/MetricItem.tsx';
import { formatNumber } from '@/lib/fin';
import { Briefcase, Target, Users } from 'lucide-react';
type Props = {
myOrganizations: number;
myProposals: number;
myPendingProposals: number;
t: (key: string) => string;
};
const YourContributionSection = ({
myOrganizations,
myProposals,
myPendingProposals,
t,
}: Props) => {
return (
<Card>
<CardHeader>
<CardTitle>{t('dashboard.yourContribution')}</CardTitle>
</CardHeader>
<CardContent>
<Grid cols={{ md: 2, lg: 3 }} gap="md">
<MetricItem
icon={<Briefcase className="h-5 text-primary w-5" />}
label={t('dashboard.myOrganizations')}
value={formatNumber(myOrganizations)}
/>
<MetricItem
icon={<Target className="h-5 text-success w-5" />}
label={t('dashboard.myProposals')}
value={formatNumber(myProposals)}
/>
<MetricItem
icon={<Users className="h-5 text-warning w-5" />}
label={t('dashboard.myPendingProposals')}
value={formatNumber(myPendingProposals)}
/>
</Grid>
</CardContent>
</Card>
);
};
export default YourContributionSection;

View File

@ -0,0 +1,8 @@
export { default as ActiveProposalsSection } from './ActiveProposalsSection';
export { default as ImpactMetricsSection } from './ImpactMetricsSection';
export { default as MyOrganizationsSection } from './MyOrganizationsSection';
export { default as PlatformHealthSection } from './PlatformHealthSection';
export { default as PlatformOverviewSection } from './PlatformOverviewSection';
export { default as QuickActionsSection } from './QuickActionsSection';
export { default as RecentActivitySection } from './RecentActivitySection';
export { default as YourContributionSection } from './YourContributionSection';

View File

@ -132,11 +132,7 @@ export default function DiscoverySearchBar({
/>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowAdvanced(!showAdvanced)}
>
<Button variant="outline" size="sm" onClick={() => setShowAdvanced(!showAdvanced)}>
<Filter className="mr-2 h-4 w-4" />
{t('discoveryPage.filters')}
</Button>
@ -147,33 +143,32 @@ export default function DiscoverySearchBar({
<Card className="p-4">
<Stack spacing="sm">
<Grid cols={2} gap="md">
<FormField label={t('discoveryPage.minPrice')}>
<Input
type="number"
placeholder="0"
value={initialQuery?.min_price?.toString() || ''}
/>
<FormField label={t('discoveryPage.minPrice')}>
<Input
type="number"
placeholder="0"
value={initialQuery?.min_price?.toString() || ''}
/>
</FormField>
<FormField label={t('discoveryPage.maxPrice')}>
<Input
type="number"
placeholder="1000"
value={initialQuery?.max_price?.toString() || ''}
/>
</FormField>
</Grid>
<FormField label={t('discoveryPage.availability')}>
<Select defaultValue={initialQuery?.availability_status || ''}>
<option value="">{t('discoveryPage.any')}</option>
<option value="available">{t('discoveryPage.available')}</option>
<option value="limited">{t('discoveryPage.limited')}</option>
<option value="out_of_stock">{t('discoveryPage.outOfStock')}</option>
</Select>
</FormField>
<FormField label={t('discoveryPage.maxPrice')}>
<Input
type="number"
placeholder="1000"
value={initialQuery?.max_price?.toString() || ''}
/>
</FormField>
</Grid>
<FormField label={t('discoveryPage.availability')}>
<Select defaultValue={initialQuery?.availability_status || ''}>
<option value="">{t('discoveryPage.any')}</option>
<option value="available">{t('discoveryPage.available')}</option>
<option value="limited">{t('discoveryPage.limited')}</option>
<option value="out_of_stock">{t('discoveryPage.outOfStock')}</option>
</Select>
</FormField>
</Stack>
</Card>
)}
</Stack>
);
}

View File

@ -10,11 +10,7 @@ interface MatchCardImageProps {
* Reusable image component for discovery match cards
* Handles image loading errors gracefully
*/
export const MatchCardImage: React.FC<MatchCardImageProps> = ({
imageUrl,
alt,
className,
}) => {
export const MatchCardImage: React.FC<MatchCardImageProps> = ({ imageUrl, alt, className }) => {
const [imageError, setImageError] = React.useState(false);
if (imageError) {
@ -35,4 +31,3 @@ export const MatchCardImage: React.FC<MatchCardImageProps> = ({
};
export default React.memo(MatchCardImage);

View File

@ -30,7 +30,10 @@ export const MatchCardMetadata: React.FC<MatchCardMetadataProps> = ({
}) => {
const { t } = useTranslation();
const hasMetadata =
organizationName || (distanceKm != null && distanceKm > 0) || availabilityStatus || tags.length > 0;
organizationName ||
(distanceKm != null && distanceKm > 0) ||
availabilityStatus ||
tags.length > 0;
if (!hasMetadata) {
return null;
@ -44,7 +47,9 @@ export const MatchCardMetadata: React.FC<MatchCardMetadataProps> = ({
{/* Organization Information */}
{organizationName && (
<Text variant="small" as="div">
<Text variant="muted" as="span">{t(organizationLabelTKey)}: </Text>
<Text variant="muted" as="span">
{t(organizationLabelTKey)}:{' '}
</Text>
<Text variant="small" as="span" className="font-medium">
{organizationName}
</Text>
@ -55,7 +60,11 @@ export const MatchCardMetadata: React.FC<MatchCardMetadataProps> = ({
{distanceKm != null && distanceKm > 0 && (
<Flex align="center" gap="xs" className="text-sm">
<MapPin className="h-4 w-4" />
<Text variant="muted" tKey={distanceTKey} replacements={{ distance: distanceKm.toFixed(1) }} />
<Text
variant="muted"
tKey={distanceTKey}
replacements={{ distance: distanceKm.toFixed(1) }}
/>
</Flex>
)}
@ -97,4 +106,3 @@ export const MatchCardMetadata: React.FC<MatchCardMetadataProps> = ({
};
export default React.memo(MatchCardMetadata);

View File

@ -86,4 +86,3 @@ export const MatchCardPricing: React.FC<MatchCardPricingProps> = (props) => {
};
export default React.memo(MatchCardPricing);

View File

@ -0,0 +1,72 @@
import HeritageBuildingCard from '@/components/heritage/HeritageBuildingCard.tsx';
import { Flex, Grid, Stack } from '@/components/ui/layout';
import Spinner from '@/components/ui/Spinner';
import type { BackendHeritageSite } from '@/schemas/backend/heritage-sites';
import { motion } from 'framer-motion';
import { Building2 } from 'lucide-react';
interface ArchitecturalHeritageSectionProps {
heritageSites: BackendHeritageSite[] | undefined;
sitesLoading: boolean;
handleViewBuildingDetails: (building: BackendHeritageSite) => void;
t: (key: string) => string;
}
const ArchitecturalHeritageSection = ({
heritageSites,
sitesLoading,
handleViewBuildingDetails,
t,
}: ArchitecturalHeritageSectionProps) => {
return (
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="mt-32 relative"
>
<div className="absolute inset-0 bg-linear-to-br from-amber-500/5 via-transparent to-amber-500/5 rounded-3xl" />
<div className="relative bg-card/50 backdrop-blur-sm border border-border/50 rounded-3xl p-8 md:p-12 shadow-2xl z-10">
<Stack spacing="xl" align="center">
<Flex align="center" gap="md" justify="center">
<Building2 className="w-8 h-8 text-amber-600" />
<h2 className="font-serif text-3xl font-semibold">
{t('heritage.architecturalTitle') || 'Architectural Heritage'}
</h2>
</Flex>
<p className="text-muted-foreground text-center max-w-2xl">
{t('heritage.architecturalDescription') ||
"Explore the historic buildings and architectural treasures that define Bugulma's cultural heritage"}
</p>
{sitesLoading ? (
<Flex justify="center" className="py-12">
<Spinner />
</Flex>
) : heritageSites && heritageSites.length > 0 ? (
<Grid cols={{ md: 2, lg: 3 }} gap="xl">
{heritageSites.map((site, index) => (
<HeritageBuildingCard
key={site.ID}
building={site}
index={index}
onViewDetails={handleViewBuildingDetails}
/>
))}
</Grid>
) : (
<Stack spacing="md" align="center" className="py-12">
<Building2 className="w-12 h-12 text-muted-foreground" />
<p className="text-muted-foreground">
{t('heritage.noHeritageSites') || 'No heritage sites available'}
</p>
</Stack>
)}
</Stack>
</div>
</motion.div>
);
};
export default ArchitecturalHeritageSection;

View File

@ -41,7 +41,9 @@ const HeritageBuildingCard: React.FC<HeritageBuildingCardProps> = ({
},
};
const getHeritageStatusVariant = (status?: string): 'protected' | 'cultural-heritage' | 'historical' | 'outline' => {
const getHeritageStatusVariant = (
status?: string
): 'protected' | 'cultural-heritage' | 'historical' | 'outline' => {
if (!status) return 'outline';
switch (status.toLowerCase()) {
case 'protected':

View File

@ -0,0 +1,124 @@
import { Container, Flex } from '@/components/ui/layout';
import { motion, MotionValue } from 'framer-motion';
import { BookOpen, Clock } from 'lucide-react';
import React from 'react';
interface HeroSectionProps {
titleItem: { title: string; content: string } | null;
t: (key: string) => string;
heroRef: React.RefObject<HTMLDivElement>;
heroOpacity: MotionValue<number>;
heroY: MotionValue<number>;
}
const HeroSection = ({ titleItem, t, heroRef, heroOpacity, heroY }: HeroSectionProps) => {
return (
<motion.div
ref={heroRef}
className="relative h-[70vh] min-h-[500px] overflow-hidden"
style={{
position: 'relative',
opacity: heroOpacity,
}}
>
{/* Parallax Background Image */}
<motion.div
className="absolute inset-0"
style={{
y: heroY,
}}
>
<img
src="/static/images/heritage/hero.jpg"
alt="Heritage of Bugulma"
className="w-full h-full object-cover scale-110 opacity-80"
onError={(e) => {
// Fallback: try API path if direct path fails
const target = e.target as HTMLImageElement;
if (!target.src.includes('/api/')) {
target.src = '/api/static/images/heritage/hero.jpg';
}
}}
/>
<div className="absolute inset-0 bg-linear-to-b from-black/40 via-black/50 to-background" />
{/* Decorative overlay pattern */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_120%,rgba(120,119,198,0.1),transparent_50%)]" />
</motion.div>
{/* Hero Content */}
<Flex
direction="col"
align="center"
justify="center"
className="relative h-full overflow-visible"
>
<Container size="lg" className="text-center text-white overflow-visible">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, ease: 'easeOut' }}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.2, duration: 0.6 }}
className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 backdrop-blur-md border border-white/20 mb-6"
>
<Clock className="w-4 h-4" />
<span className="text-sm font-medium">
{t('heritage.journey') || 'A Journey Through Time'}
</span>
</motion.div>
<h1 className="font-serif text-6xl md:text-7xl font-bold tracking-tight mb-6 bg-clip-text text-transparent bg-linear-to-b from-white to-white/80 leading-[1.1] py-2">
{titleItem?.title || 'Бугульма'}
</h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4, duration: 0.8 }}
className="text-xl md:text-2xl text-white/90 font-normal max-w-2xl mx-auto"
>
{titleItem?.content || 'История на изгибе времен'}
</motion.p>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6, duration: 0.8 }}
className="mt-8"
>
<a
href="#timeline"
className="inline-flex items-center gap-2 px-6 py-3 rounded-full bg-white/20 hover:bg-white/30 backdrop-blur-md border border-white/30 transition-all duration-300 hover:scale-105"
>
<BookOpen className="w-5 h-5" />
<span className="font-medium">{t('heritage.explore') || 'Explore History'}</span>
</a>
</motion.div>
</motion.div>
</Container>
</Flex>
{/* Scroll Indicator */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1, duration: 1 }}
className="absolute bottom-8 left-1/2 -translate-x-1/2"
>
<motion.div
animate={{ y: [0, 10, 0] }}
transition={{ repeat: Infinity, duration: 1.5, ease: 'easeInOut' }}
className="w-6 h-10 rounded-full border-2 border-white/40 flex items-start justify-center p-2"
>
<motion.div className="w-1.5 h-1.5 bg-white/60 rounded-full" />
</motion.div>
</motion.div>
</motion.div>
);
};
export default HeroSection;

View File

@ -0,0 +1,77 @@
import { Container, Flex, Stack } from '@/components/ui/layout';
import { motion } from 'framer-motion';
import { BookOpen, ExternalLink } from 'lucide-react';
interface SourceType {
title: string;
url?: string;
}
interface SourcesSectionProps {
sources: SourceType[];
t: (key: string) => string;
}
const SourcesSection = ({ sources, t }: SourcesSectionProps) => {
return (
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="mt-32 relative"
>
<div className="absolute inset-0 bg-linear-to-br from-primary/5 via-transparent to-primary/5 rounded-3xl" />
<div className="relative bg-card/50 backdrop-blur-sm border border-border/50 rounded-3xl p-8 md:p-12 shadow-2xl z-10">
<Stack spacing="xl" align="center">
<Flex align="center" gap="md" justify="center">
<BookOpen className="w-8 h-8 text-primary" />
<h2 className="font-serif text-3xl font-semibold">
{t('heritage.sourcesTitle') || 'Sources & References'}
</h2>
</Flex>
<Container size="md">
<Stack spacing="sm">
{sources.map((source, index) => (
<motion.div
key={index}
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: index * 0.05, duration: 0.4 }}
className="group"
>
<Flex
align="start"
gap="md"
className="p-4 rounded-xl bg-background/50 hover:bg-background/80 border border-border/50 hover:border-primary/30 transition-all duration-300 hover:shadow-md"
>
<span className="shrink-0 w-8 h-8 rounded-full bg-primary/10 text-primary flex items-center justify-center font-semibold text-sm">
{index + 1}
</span>
{source.url ? (
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="flex-1 flex items-center gap-2 text-foreground hover:text-primary transition-colors"
>
<span className="flex-1">{source.title}</span>
<ExternalLink className="w-4 h-4 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
</a>
) : (
<span className="flex-1 text-muted-foreground">{source.title}</span>
)}
</Flex>
</motion.div>
))}
</Stack>
</Container>
</Stack>
</div>
</motion.div>
);
};
export default SourcesSection;

View File

@ -1,11 +1,85 @@
import IconWrapper from '@/components/ui/IconWrapper.tsx';
import { Heading, Text } from '@/components/ui/Typography.tsx';
import { getIconByName } from '@/lib/heritage-mapper.tsx';
import { HeritageSource, TimelineItem } from '@/types.ts';
import type { HeritageSource, TimelineItem } from '@/types';
import { motion, Variants } from 'framer-motion';
import { Image as ImageIcon, ZoomIn } from 'lucide-react';
import {
Calendar,
Image as ImageIcon,
MapPin,
Star,
Tag,
Users as UsersIcon,
ZoomIn,
} from 'lucide-react';
import React, { useCallback, useState } from 'react';
// Category color mapping for badges
const categoryColors: Record<string, { bg: string; text: string; border: string }> = {
political: {
bg: 'bg-blue-100/80 dark:bg-blue-900/30',
text: 'text-blue-700 dark:text-blue-300',
border: 'border-blue-300 dark:border-blue-700',
},
military: {
bg: 'bg-red-100/80 dark:bg-red-900/30',
text: 'text-red-700 dark:text-red-300',
border: 'border-red-300 dark:border-red-700',
},
economic: {
bg: 'bg-green-100/80 dark:bg-green-900/30',
text: 'text-green-700 dark:text-green-300',
border: 'border-green-300 dark:border-green-700',
},
cultural: {
bg: 'bg-purple-100/80 dark:bg-purple-900/30',
text: 'text-purple-700 dark:text-purple-300',
border: 'border-purple-300 dark:border-purple-700',
},
social: {
bg: 'bg-orange-100/80 dark:bg-orange-900/30',
text: 'text-orange-700 dark:text-orange-300',
border: 'border-orange-300 dark:border-orange-700',
},
natural: {
bg: 'bg-teal-100/80 dark:bg-teal-900/30',
text: 'text-teal-700 dark:text-teal-300',
border: 'border-teal-300 dark:border-teal-700',
},
infrastructure: {
bg: 'bg-gray-100/80 dark:bg-gray-900/30',
text: 'text-gray-700 dark:text-gray-300',
border: 'border-gray-300 dark:border-gray-700',
},
criminal: {
bg: 'bg-amber-100/80 dark:bg-amber-900/30',
text: 'text-amber-700 dark:text-amber-300',
border: 'border-amber-300 dark:border-amber-700',
},
};
// Format date from ISO string to human-readable format
const formatDate = (dateStr?: string | null): string | null => {
if (!dateStr) return null;
try {
const date = new Date(dateStr);
return date.toLocaleDateString('ru-RU', { year: 'numeric', month: 'long', day: 'numeric' });
} catch {
return null;
}
};
// Format year from ISO string
const formatYear = (dateStr?: string | null): string | null => {
if (!dateStr) return null;
try {
const date = new Date(dateStr);
return date.getFullYear().toString();
} catch {
return null;
}
};
// This utility is now co-located with the component that uses it.
const parseContent = (
text: string,
@ -54,6 +128,9 @@ interface TimelineItemProps {
}
const TimelineItem: React.FC<TimelineItemProps> = ({ item, index, sources }) => {
// Debug: log when items render to help diagnose missing chronology
console.debug('[TimelineItem] render', { id: item.id, title: item.title, index });
const isRightSide = index % 2 !== 0;
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
@ -95,7 +172,9 @@ const TimelineItem: React.FC<TimelineItemProps> = ({ item, index, sources }) =>
<div className={`w-full md:w-5/12 ${isRightSide ? 'md:order-last md:pl-12' : 'md:pr-12'}`}>
<motion.div
whileHover={{ y: -8, transition: { duration: 0.3 } }}
className="group rounded-2xl border-2 border-border/50 bg-card/80 backdrop-blur-sm shadow-lg hover:shadow-2xl hover:border-primary/30 overflow-hidden transition-all duration-300"
className={`group rounded-2xl border-2 border-border/50 bg-card/80 backdrop-blur-sm shadow-lg hover:shadow-2xl hover:border-primary/30 overflow-hidden transition-all duration-300 relative z-40 ${
import.meta.env.DEV ? 'ring-1 ring-red-200' : ''
}`}
>
{/* Image Section */}
{item.imageUrl && !imageError && (
@ -132,6 +211,28 @@ const TimelineItem: React.FC<TimelineItemProps> = ({ item, index, sources }) =>
{/* Content Section */}
<div className="p-6 sm:p-8">
{/* Category Badge and Importance */}
<div className="flex items-center gap-2 mb-3 flex-wrap">
{item.category && (
<span
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium border ${categoryColors[item.category]?.bg || 'bg-gray-100'} ${categoryColors[item.category]?.text || 'text-gray-700'} ${categoryColors[item.category]?.border || 'border-gray-300'}`}
>
{item.category}
</span>
)}
{item.kind && item.kind !== 'historical' && (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium bg-violet-100/80 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300 border border-violet-300 dark:border-violet-700">
{item.kind === 'legend' ? '📖 Legend' : '🔀 Mixed'}
</span>
)}
{item.importance > 5 && (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium bg-yellow-100/80 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 border border-yellow-300 dark:border-yellow-700">
<Star className="w-3 h-3" />
{item.importance}
</span>
)}
</div>
{/* Title with decorative line */}
<div className="mb-4">
<motion.div
@ -146,11 +247,83 @@ const TimelineItem: React.FC<TimelineItemProps> = ({ item, index, sources }) =>
</Heading>
</div>
{/* Time Period */}
{(item.timeFrom || item.timeTo) && (
<div className="flex items-center gap-2 mb-4 text-sm text-muted-foreground">
<Calendar className="w-4 h-4" />
<span>
{formatYear(item.timeFrom)}
{item.timeTo && item.timeFrom !== item.timeTo && `${formatYear(item.timeTo)}`}
</span>
</div>
)}
{/* Summary (if available) */}
{item.summary && (
<div className="mb-4 p-3 rounded-lg bg-muted/50 border border-border/50">
<Text className="text-sm italic text-muted-foreground">{item.summary}</Text>
</div>
)}
{/* Content with improved typography */}
<div className="prose prose-sm sm:prose-base max-w-none text-muted-foreground">
<div className="prose prose-sm sm:prose-base max-w-none text-muted-foreground mb-6">
{parsedContent()}
</div>
{/* Metadata Section */}
<div className="space-y-3">
{/* Locations */}
{item.locations && item.locations.length > 0 && (
<div className="flex items-start gap-2">
<MapPin className="w-4 h-4 mt-1 text-muted-foreground shrink-0" />
<div className="flex flex-wrap gap-1.5">
{item.locations.map((location, idx) => (
<span
key={idx}
className="inline-block px-2 py-0.5 text-xs rounded bg-muted text-muted-foreground border border-border/50"
>
{location}
</span>
))}
</div>
</div>
)}
{/* Actors */}
{item.actors && item.actors.length > 0 && (
<div className="flex items-start gap-2">
<UsersIcon className="w-4 h-4 mt-1 text-muted-foreground shrink-0" />
<div className="flex flex-wrap gap-1.5">
{item.actors.map((actor, idx) => (
<span
key={idx}
className="inline-block px-2 py-0.5 text-xs rounded bg-muted text-muted-foreground border border-border/50"
>
{actor}
</span>
))}
</div>
</div>
)}
{/* Tags */}
{item.tags && item.tags.length > 0 && (
<div className="flex items-start gap-2">
<Tag className="w-4 h-4 mt-1 text-muted-foreground shrink-0" />
<div className="flex flex-wrap gap-1.5">
{item.tags.map((tag, idx) => (
<span
key={idx}
className="inline-block px-2 py-0.5 text-xs rounded bg-primary/10 text-primary border border-primary/20"
>
#{tag}
</span>
))}
</div>
</div>
)}
</div>
{/* Decorative bottom accent */}
<motion.div
initial={{ scaleX: 0 }}
@ -164,7 +337,7 @@ const TimelineItem: React.FC<TimelineItemProps> = ({ item, index, sources }) =>
</div>
{/* Center Icon with enhanced styling */}
<div className="absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2">
<div className="absolute left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2">
<motion.div
initial={{ scale: 0, rotate: -180 }}
whileInView={{ scale: 1, rotate: 0 }}
@ -175,7 +348,8 @@ const TimelineItem: React.FC<TimelineItemProps> = ({ item, index, sources }) =>
>
{/* Background circle to cover the timeline line */}
<div className="absolute inset-0 -m-2 rounded-full bg-background" />
<IconWrapper className="relative bg-card ring-4 ring-background shadow-xl border-2 border-primary/20">
{/* Ensure the icon visually sits above later sections (e.g., 'Architectural Heritage') */}
<IconWrapper className="relative z-50 bg-card ring-4 ring-background shadow-xl border-2 border-primary/20">
{React.cloneElement(getIconByName(item.iconName), {
className: 'h-8 w-8 text-primary',
})}

View File

@ -0,0 +1,198 @@
import TimelineItem from '@/components/heritage/TimelineItem.tsx';
import SectionHeader from '@/components/layout/SectionHeader.tsx';
import { Container } from '@/components/ui/layout';
import { motion, MotionValue } from 'framer-motion';
import { Filter } from 'lucide-react';
import React from 'react';
interface TimelineItemType {
id: string;
[key: string]: unknown;
}
interface SourceType {
title: string;
url?: string;
}
interface TimelineSectionProps {
timelineRef: React.RefObject<HTMLDivElement>;
scaleY: MotionValue<number>;
filters: {
showFilters: boolean;
setShowFilters: (show: boolean) => void;
selectedCategory: string;
setSelectedCategory: (category: string) => void;
minImportance: number;
setMinImportance: (importance: number) => void;
resetFilters: () => void;
};
timelineItems: TimelineItemType[];
availableCategories: string[];
sources: SourceType[];
t: (key: string) => string;
}
const TimelineSection = ({
timelineRef,
scaleY,
filters,
timelineItems,
availableCategories,
sources,
t,
}: TimelineSectionProps) => {
return (
<div id="timeline" className="relative" style={{ position: 'relative' }}>
{/* Section Header */}
<Container size="lg" className="py-16">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
>
<SectionHeader
title={t('heritage.timelineTitle') || 'Historical Timeline'}
subtitle={
t('heritage.timelineDescription') ||
'Discover the rich history and cultural heritage of Bugulma through the ages'
}
className="mb-0"
/>
</motion.div>
</Container>
<div ref={timelineRef} className="relative" style={{ position: 'relative' as const }}>
{/* Animated Timeline Line - positioned relative to timelineRef to cover all items */}
<motion.div
className="absolute left-1/2 top-0 w-1 -translate-x-1/2 bg-linear-to-b from-primary/20 via-primary/40 to-primary/20 origin-top hidden md:block"
style={{
scaleY,
height: '100%',
}}
/>
<Container size="xl" className="pb-16 sm:pb-24 relative">
{/* Filter Controls */}
<motion.div
initial={{ opacity: 0, y: -20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="mb-12 sticky top-20 z-40 bg-background/80 backdrop-blur-lg rounded-2xl border border-border/50 shadow-lg"
>
<button
onClick={() => filters.setShowFilters(!filters.showFilters)}
className="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors rounded-2xl"
>
<div className="flex items-center gap-2">
<Filter className="w-5 h-5 text-primary" />
<span className="font-medium">
Filters
{(filters.selectedCategory !== 'all' || filters.minImportance > 1) && (
<span className="ml-2 text-sm text-muted-foreground">
({timelineItems.length} events)
</span>
)}
</span>
</div>
<motion.div
animate={{ rotate: filters.showFilters ? 180 : 0 }}
transition={{ duration: 0.3 }}
>
</motion.div>
</button>
{filters.showFilters && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="border-t border-border/50"
>
<div className="p-4 space-y-4">
{/* Category Filter */}
<div>
<label className="text-sm font-medium mb-2 block">Category</label>
<div className="flex flex-wrap gap-2">
<button
onClick={() => filters.setSelectedCategory('all')}
className={`px-3 py-1.5 rounded-full text-sm transition-all ${
filters.selectedCategory === 'all'
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/80'
}`}
>
All
</button>
{availableCategories.map((category) => (
<button
key={category}
onClick={() => filters.setSelectedCategory(category)}
className={`px-3 py-1.5 rounded-full text-sm transition-all ${
filters.selectedCategory === category
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/80'
}`}
>
{category}
</button>
))}
</div>
</div>
{/* Importance Filter */}
<div>
<label className="text-sm font-medium mb-2 block">
Minimum Importance: {filters.minImportance}
</label>
<input
type="range"
min="1"
max="10"
value={filters.minImportance}
onChange={(e) => filters.setMinImportance(Number(e.target.value))}
className="w-full accent-primary"
/>
<div className="flex justify-between text-xs text-muted-foreground mt-1">
<span>1</span>
<span>10</span>
</div>
</div>
{/* Reset Filters */}
{(filters.selectedCategory !== 'all' || filters.minImportance > 1) && (
<button
onClick={filters.resetFilters}
className="w-full px-4 py-2 text-sm bg-muted hover:bg-muted/80 rounded-lg transition-colors"
>
Reset Filters
</button>
)}
</div>
</motion.div>
)}
</motion.div>
<div className="relative">
{/* Timeline Items */}
{timelineItems.length > 0 ? (
timelineItems.map((item, index) => (
<TimelineItem key={item.id} item={item} index={index} sources={sources} />
))
) : (
<div className="text-center py-16">
<p className="text-muted-foreground text-lg">
No events match your filters. Try adjusting your selection.
</p>
</div>
)}
</div>
</Container>
</div>
</div>
);
};
export default TimelineSection;

View File

@ -54,11 +54,7 @@ const HeritageSection = ({ onNavigate }: HeritageSectionProps) => {
>
{t('hero.heritageSubtitle')}
</p>
<Button
size="lg"
onClick={onNavigate}
className="shadow-2xl shadow-primary/30"
>
<Button size="lg" onClick={onNavigate} className="shadow-2xl shadow-primary/30">
{t('hero.heritageButton')}
</Button>
</div>

View File

@ -12,7 +12,6 @@ interface HeroProps {
addOrgButtonRef: React.Ref<HTMLButtonElement>;
}
const Hero = ({ onNavigateToMap, onAddOrganizationClick, addOrgButtonRef }: HeroProps) => {
const { t } = useTranslation();
@ -120,7 +119,7 @@ const Hero = ({ onNavigateToMap, onAddOrganizationClick, addOrgButtonRef }: Hero
</div>
<Container size="xl" className="py-20 sm:py-24 md:py-32 lg:py-40 xl:py-48 2xl:py-56 w-full">
<Grid cols={{ md: 1, lg: 2 }} gap={{ md: "2xl", lg: "3xl", xl: "4xl" }} align="center">
<Grid cols={{ md: 1, lg: 2 }} gap={{ md: '2xl', lg: '3xl', xl: '4xl' }} align="center">
<div className="text-center lg:text-left relative z-10">
<motion.div
initial={{ opacity: 0, y: 20 }}
@ -151,7 +150,7 @@ const Hero = ({ onNavigateToMap, onAddOrganizationClick, addOrgButtonRef }: Hero
{(() => {
const title = t('hero.title');
// Split title by period to separate "Connect Your Business" and "Grow Together"
const parts = title.split('.').filter(p => p.trim());
const parts = title.split('.').filter((p) => p.trim());
return parts.map((part, partIndex) => {
const words = part.trim().split(' ');
@ -166,7 +165,7 @@ const Hero = ({ onNavigateToMap, onAddOrganizationClick, addOrgButtonRef }: Hero
transition={{
duration: 0.6,
delay: 0.6 + partIndex * 0.2,
ease: [0.16, 1, 0.3, 1]
ease: [0.16, 1, 0.3, 1],
}}
>
<span className="relative inline-block">
@ -179,7 +178,7 @@ const Hero = ({ onNavigateToMap, onAddOrganizationClick, addOrgButtonRef }: Hero
transition={{
duration: 0.5,
delay: 0.8 + partIndex * 0.2 + wordIndex * 0.08,
ease: [0.16, 1, 0.3, 1]
ease: [0.16, 1, 0.3, 1],
}}
>
{/* 3D shadow layers with color - positioned behind */}

View File

@ -21,7 +21,9 @@ const StepIcon = React.memo(({ icon }: { icon: React.ReactNode }) => (
StepIcon.displayName = 'StepIcon';
const BenefitCard = React.memo(({ title, desc }: { title: string; desc: string }) => (
<Card className={`${themeColors.background.card} ${themeColors.text.default} shadow-md border h-full transition-all hover:shadow-lg hover:-translate-y-1`}>
<Card
className={`${themeColors.background.card} ${themeColors.text.default} shadow-md border h-full transition-all hover:shadow-lg hover:-translate-y-1`}
>
<CardHeader className="pb-3">
<CardTitle className={`font-serif text-lg ${themeColors.text.primary} leading-tight`}>
{title}
@ -131,7 +133,12 @@ const HowItWorksSection = () => {
className="mb-16 sm:mb-20 lg:mb-24"
/>
<Stack spacing="3xl" className="max-w-6xl mx-auto" role="list" aria-label={t('howItWorksNew.title')}>
<Stack
spacing="3xl"
className="max-w-6xl mx-auto"
role="list"
aria-label={t('howItWorksNew.title')}
>
{stepData.map((step, index) => (
<li key={index} className="list-none">
<Step

View File

@ -13,7 +13,7 @@ interface ModernSectorVisualizationProps {
const ModernSectorVisualization: React.FC<ModernSectorVisualizationProps> = ({
maxItems = 8,
showStats = false
showStats = false,
}) => {
const { t } = useTranslation();
@ -34,7 +34,10 @@ const ModernSectorVisualization: React.FC<ModernSectorVisualizationProps> = ({
<div className="w-full relative">
<Grid cols={{ sm: 2, md: 3 }} gap="md">
{Array.from({ length: maxItems }).map((_, i) => (
<Card key={i} className="p-6 animate-pulse h-full flex flex-col items-center justify-center">
<Card
key={i}
className="p-6 animate-pulse h-full flex flex-col items-center justify-center"
>
<div className="w-16 h-16 bg-muted rounded-xl mb-4"></div>
<div className="w-24 h-4 bg-muted rounded mb-2"></div>
<div className="w-20 h-3 bg-muted rounded"></div>
@ -56,20 +59,24 @@ const ModernSectorVisualization: React.FC<ModernSectorVisualizationProps> = ({
transition={{
duration: 0.5,
delay: index * 0.1,
ease: [0.25, 0.46, 0.45, 0.94]
ease: [0.25, 0.46, 0.45, 0.94],
}}
className="relative"
>
<Card className="group relative p-6 hover:shadow-lg transition-all duration-300 hover:-translate-y-1 bg-card/50 backdrop-blur-sm border-border/50 h-full flex flex-col">
<div className="flex flex-col items-center text-center space-y-4 relative z-10 flex-1">
<div className={`flex-shrink-0 w-16 h-16 rounded-xl flex items-center justify-center bg-gradient-to-br from-sector-${sector.colorKey}/20 to-sector-${sector.colorKey}/10 ring-2 ring-sector-${sector.colorKey}/20 group-hover:ring-sector-${sector.colorKey}/40 transition-all duration-300`}>
<div
className={`flex-shrink-0 w-16 h-16 rounded-xl flex items-center justify-center bg-gradient-to-br from-sector-${sector.colorKey}/20 to-sector-${sector.colorKey}/10 ring-2 ring-sector-${sector.colorKey}/20 group-hover:ring-sector-${sector.colorKey}/40 transition-all duration-300`}
>
{React.cloneElement(sector.icon, {
className: `w-8 h-8 text-sector-${sector.colorKey} group-hover:scale-110 transition-transform duration-300`
className: `w-8 h-8 text-sector-${sector.colorKey} group-hover:scale-110 transition-transform duration-300`,
})}
</div>
<div className="flex-1 min-w-0 w-full">
<h3 className={`font-semibold text-lg text-foreground group-hover:text-sector-${sector.colorKey} transition-colors duration-300`}>
<h3
className={`font-semibold text-lg text-foreground group-hover:text-sector-${sector.colorKey} transition-colors duration-300`}
>
{t(`${sector.nameKey}.name`)}
</h3>
@ -79,7 +86,9 @@ const ModernSectorVisualization: React.FC<ModernSectorVisualizationProps> = ({
variant={sector.colorKey as any}
size="sm"
className={
['construction', 'production', 'recreation', 'logistics'].includes(sector.colorKey)
['construction', 'production', 'recreation', 'logistics'].includes(
sector.colorKey
)
? ''
: `bg-sector-${sector.colorKey}/10 text-sector-${sector.colorKey} border-sector-${sector.colorKey}/20`
}
@ -92,7 +101,9 @@ const ModernSectorVisualization: React.FC<ModernSectorVisualizationProps> = ({
</div>
{/* Subtle connection indicator */}
<div className={`absolute inset-0 rounded-lg bg-gradient-to-br from-transparent via-transparent to-sector-${sector.colorKey}/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none`} />
<div
className={`absolute inset-0 rounded-lg bg-gradient-to-br from-transparent via-transparent to-sector-${sector.colorKey}/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none`}
/>
</Card>
</motion.div>
))}

View File

@ -133,10 +133,7 @@ class LayoutCalculator {
if (isBidirectional) {
return { x: fromPos.x, y: fromPos.y };
} else {
const angle = Math.atan2(
resourceIconPos.y - fromPos.y,
resourceIconPos.x - fromPos.x
);
const angle = Math.atan2(resourceIconPos.y - fromPos.y, resourceIconPos.x - fromPos.x);
return {
x: resourceIconPos.x + Math.cos(angle) * LAYOUT_CONFIG.RESOURCE_ICON_SIZE,
y: resourceIconPos.y + Math.sin(angle) * LAYOUT_CONFIG.RESOURCE_ICON_SIZE,
@ -146,7 +143,11 @@ class LayoutCalculator {
// Get SVG coordinate bounds for foreignObject positioning
// Returns position and size in SVG coordinate system
getForeignObjectBounds(svgX: number, svgY: number, size: number = 44): {
getForeignObjectBounds(
svgX: number,
svgY: number,
size: number = 44
): {
x: number;
y: number;
width: number;
@ -162,10 +163,7 @@ class LayoutCalculator {
}
// Calculate distance between two points
distance(
p1: { x: number; y: number },
p2: { x: number; y: number }
): number {
distance(p1: { x: number; y: number }, p2: { x: number; y: number }): number {
return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
}
}
@ -181,7 +179,8 @@ const RESOURCE_TYPES = [
// Generate potential connections between sectors based on common symbiosis patterns
// Creates varying connection counts (1-4 connections per sector) for visual interest
const generateConnections = (sectors: Array<{ backendName?: string; colorKey: string }>) => {
const connections: Array<{ from: number; to: number; resourceType: string; strength: number }> = [];
const connections: Array<{ from: number; to: number; resourceType: string; strength: number }> =
[];
// Track resource types used per sector to ensure variety
const sectorResourceUsage: Map<number, Set<number>> = new Map();
@ -189,12 +188,12 @@ const generateConnections = (sectors: Array<{ backendName?: string; colorKey: st
// Connection pattern per sector index to create varied counts: [1, 2, 3, 2, 4, 3]
const connectionPatterns = [
[1], // Sector 0: 1 connection (to next)
[1], // Sector 0: 1 connection (to next)
[1, -Math.floor(sectors.length / 2)], // Sector 1: 2 connections (next + opposite)
[1, -1, 2], // Sector 2: 3 connections (next + prev + skipOne)
[1, -1, 2], // Sector 2: 3 connections (next + prev + skipOne)
[1, -Math.floor(sectors.length / 2)], // Sector 3: 2 connections (next + opposite)
[1, -1, -Math.floor(sectors.length / 2), 2], // Sector 4: 4 connections
[1, -1, 2], // Sector 5: 3 connections (next + prev + skipOne)
[1, -1, 2], // Sector 5: 3 connections (next + prev + skipOne)
];
for (let i = 0; i < sectors.length; i++) {
@ -210,9 +209,7 @@ const generateConnections = (sectors: Array<{ backendName?: string; colorKey: st
if (targetIndex === i) return;
// Avoid duplicate connections (check if reverse connection already exists)
const reverseExists = connections.some(
c => c.from === targetIndex && c.to === i
);
const reverseExists = connections.some((c) => c.from === targetIndex && c.to === i);
if (!reverseExists) {
// Assign resource type ensuring variety - cycle through all resource types globally
@ -228,7 +225,12 @@ const generateConnections = (sectors: Array<{ backendName?: string; colorKey: st
from: i,
to: targetIndex,
resourceType: RESOURCE_TYPES[resourceTypeIndex].type,
strength: Math.abs(offset) === 1 ? 0.8 : Math.abs(offset) === Math.floor(sectors.length / 2) ? 0.5 : 0.6,
strength:
Math.abs(offset) === 1
? 0.8
: Math.abs(offset) === Math.floor(sectors.length / 2)
? 0.5
: 0.6,
});
}
});
@ -239,7 +241,7 @@ const generateConnections = (sectors: Array<{ backendName?: string; colorKey: st
const ResourceExchangeVisualization: React.FC<ResourceExchangeVisualizationProps> = ({
maxItems = 6,
showStats = false
showStats = false,
}) => {
const { t } = useTranslation();
const { sectors: dynamicSectors, isLoading } = useDynamicSectors(maxItems);
@ -247,10 +249,7 @@ const ResourceExchangeVisualization: React.FC<ResourceExchangeVisualizationProps
const [activeConnections, setActiveConnections] = useState<Set<string>>(new Set());
// Create layout calculator instance - uses SVG native coordinate system
const layoutCalculator = useMemo(
() => new LayoutCalculator(LAYOUT_CONFIG.SVG_VIEWBOX_SIZE),
[]
);
const layoutCalculator = useMemo(() => new LayoutCalculator(LAYOUT_CONFIG.SVG_VIEWBOX_SIZE), []);
// Generate sector positions using layout calculator
const sectorPositions = useMemo(() => {
@ -296,12 +295,8 @@ const ResourceExchangeVisualization: React.FC<ResourceExchangeVisualizationProps
<div className="w-full flex flex-col items-center gap-4 max-w-lg mx-auto">
{/* Title */}
<div className="text-center">
<h3 className="text-sm font-semibold text-foreground mb-1">
Resource Exchange Network
</h3>
<p className="text-xs text-muted-foreground">
Businesses connect to exchange resources
</p>
<h3 className="text-sm font-semibold text-foreground mb-1">Resource Exchange Network</h3>
<p className="text-xs text-muted-foreground">Businesses connect to exchange resources</p>
</div>
{/* SVG Canvas for network visualization */}
@ -312,36 +307,411 @@ const ResourceExchangeVisualization: React.FC<ResourceExchangeVisualizationProps
preserveAspectRatio="xMidYMid meet"
style={{ overflow: 'visible' }}
>
<defs>
{/* Gradient for connection lines */}
<linearGradient id="connectionGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.3" />
<stop offset="50%" stopColor="hsl(var(--primary))" stopOpacity="0.6" />
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity="0.3" />
</linearGradient>
<defs>
{/* Gradient for connection lines */}
<linearGradient id="connectionGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.3" />
<stop offset="50%" stopColor="hsl(var(--primary))" stopOpacity="0.6" />
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity="0.3" />
</linearGradient>
{/* Glow filter for active connections */}
<filter id="glow">
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
{/* Glow filter for active connections */}
<filter id="glow">
<feGaussianBlur stdDeviation="3" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Connection lines (edges) */}
<g>
{/* Connection lines (edges) */}
<g>
{connections.map((conn, idx) => {
const fromPos = sectorPositions[conn.from];
const toPos = sectorPositions[conn.to];
if (!fromPos || !toPos) return null;
const isActive = activeConnections.has(`conn-${idx}`) || hoveredSector === null;
// Check if this is a bidirectional exchange (both A→B and B→A exist)
const reverseConnection = connections.find(
(c) => c.from === conn.to && c.to === conn.from
);
const isBidirectional = !!reverseConnection;
// Calculate resource icon position using layout calculator
const resourceIconPos = layoutCalculator.calculateResourceIconPosition(
fromPos,
toPos,
isBidirectional,
conn.from,
conn.to,
showStats
);
// Calculate connection start position
const connectionStart = layoutCalculator.calculateConnectionStart(
fromPos,
resourceIconPos,
isBidirectional
);
// Calculate total distance for particle animation
const totalDistance = layoutCalculator.distance(connectionStart, toPos);
return (
<g key={`connection-${idx}`}>
{/* Connection line - for bidirectional: source to destination (icons at midpoint); for unidirectional: from resource icon to destination */}
<motion.line
x1={connectionStart.x}
y1={connectionStart.y}
x2={toPos.x}
y2={toPos.y}
stroke="hsl(var(--primary))"
strokeWidth={isActive ? 2 : 1}
strokeDasharray={isActive ? '0' : '4 4'}
opacity={isActive ? conn.strength : 0.2}
filter={isActive ? 'url(#glow)' : undefined}
initial={{ pathLength: 0 }}
animate={{ pathLength: isActive ? 1 : 0.3 }}
transition={{ duration: 1, delay: idx * 0.1 }}
/>
{/* Animated resource flow particles - flow from resource icon to organization */}
{isActive && totalDistance > 0 && (
<>
{[...Array(3)].map((_, particleIdx) => {
// Particles start exactly at the resource icon position
const particleStartX = resourceIconPos.x;
const particleStartY = resourceIconPos.y;
// Particles travel all the way to the organization icon
// Stop just before the organization circle edge to avoid overlap
const organizationRadius = LAYOUT_CONFIG.SECTOR_NODE_RADIUS;
const particleEndX = toPos.x;
const particleEndY = toPos.y;
// Calculate full distance from resource icon to organization
const fullDistance = layoutCalculator.distance(
{ x: particleStartX, y: particleStartY },
{ x: particleEndX, y: particleEndY }
);
// Base duration with randomization for organic feel
const baseDuration = 4 + conn.strength * 2;
const randomVariation = 0.7 + Math.random() * 0.6; // 0.7x to 1.3x speed variation
const particleDuration = baseDuration * randomVariation;
// Stagger particles for continuous flow - overlap them so there's always a particle visible
// Each particle starts before the previous one finishes (overlap by ~60%)
const overlapRatio = 0.6; // 60% overlap
const staggerDelay = particleIdx * particleDuration * (1 - overlapRatio);
// Randomize particle properties for dynamic feel
const particleSize = 2.5 + Math.random() * 1.5; // 2.5-4px radius
const particleOpacity = 0.6 + Math.random() * 0.4; // 0.6-1.0 opacity
// Slight path variation for more organic movement (small perpendicular offset)
const pathVariation = (Math.random() - 0.5) * 4; // Reduced to ±2px for more realistic path
const angle = Math.atan2(
particleEndY - particleStartY,
particleEndX - particleStartX
);
const perpAngle = angle + Math.PI / 2;
const offsetX = Math.cos(perpAngle) * pathVariation;
const offsetY = Math.sin(perpAngle) * pathVariation;
// Add slight random delay to break synchronization
const randomDelay = Math.random() * 0.5;
// Wait for connection line to be visible before starting particles
// Connection line has delay: idx * 0.1 and duration: 1s
const connectionLineDelay = idx * 0.1;
const connectionLineDuration = 1;
const waitForLine = connectionLineDelay + connectionLineDuration * 0.8; // Start particles when line is 80% visible
return (
<motion.circle
key={`particle-${idx}-${particleIdx}`}
r={particleSize}
fill="hsl(var(--primary))"
initial={{
cx: particleStartX + offsetX,
cy: particleStartY + offsetY,
opacity: particleOpacity, // Start fully visible
}}
animate={{
cx: [particleStartX + offsetX, particleEndX + offsetX],
cy: [particleStartY + offsetY, particleEndY + offsetY],
opacity: [particleOpacity, particleOpacity, 0], // Stay fully visible, fade out only when reaching destination
}}
transition={{
duration: particleDuration,
repeat: Infinity,
delay: waitForLine + staggerDelay + randomDelay, // Wait for line to appear first
ease: 'linear',
times: [0, 0.95, 1], // Fully visible (95%), fade out only at destination (5%)
}}
/>
);
})}
</>
)}
</g>
);
})}
</g>
{/* Sector nodes */}
{dynamicSectors.map((sector, index) => {
const pos = sectorPositions[index];
if (!pos) return null;
const isHovered = hoveredSector === index;
const sectorConnections = connections.filter((c) => c.from === index || c.to === index);
// Find incoming connections (where this sector is the destination)
const incomingConnections = connections.filter((c) => c.to === index);
// Calculate pulse animation synchronized with particle arrivals
// Particles take (4 + strength * 2) seconds to travel, and we have 3 particles staggered
const getPulseProps = () => {
// Don't pulse if no incoming connections or if this sector is hovered
if (incomingConnections.length === 0 || isHovered) {
return {};
}
// Find active incoming connections (where particles are flowing)
const activeIncoming = incomingConnections.find((conn) => {
const connIdx = connections.findIndex(
(c) => c.from === conn.from && c.to === conn.to
);
const connKey = `conn-${connIdx}`;
// Connection is active if it's in activeConnections set or if nothing is hovered (all connections active)
return activeConnections.has(connKey) || hoveredSector === null;
});
if (!activeIncoming) return {};
// Calculate pulse timing based on continuous particle flow
// With 6 particles overlapping at 60%, particles arrive more frequently
const baseDuration = 4 + activeIncoming.strength * 2;
const overlapRatio = 0.6;
const particleInterval = baseDuration * (1 - overlapRatio); // Time between particle arrivals
const averageDuration = baseDuration * 0.85; // Average duration accounting for randomization
// Pulse when particles arrive - more frequent pulses for continuous flow
// Account for initial spring animation (~0.8s) + delay
const initialAnimationTime = 0.8 + index * 0.1;
const pulseDelay = Math.max(0, averageDuration - initialAnimationTime);
return {
transition: {
duration: 0.3,
delay: pulseDelay,
repeat: Infinity,
repeatDelay: particleInterval - 0.3, // Pulse with each particle arrival
ease: 'easeInOut',
},
};
};
const pulseProps = getPulseProps();
return (
<g key={sector.backendName || `sector-${index}`}>
{/* Connection highlight circle - consistent size */}
{isHovered && (
<motion.circle
cx={pos.x}
cy={pos.y}
r="90"
fill="none"
stroke="hsl(var(--primary))"
strokeWidth="1.5"
strokeDasharray="4 8"
opacity="0.25"
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1.15, opacity: 0.3 }}
transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut' }}
/>
)}
{/* Sector node circle - consistent size */}
<motion.circle
cx={pos.x}
cy={pos.y}
r="32"
fill={`hsl(var(--sector-${sector.colorKey}))`}
fillOpacity="0.15"
stroke={`hsl(var(--sector-${sector.colorKey}))`}
strokeWidth={isHovered ? 3 : 2}
filter={isHovered ? 'url(#glow)' : undefined}
initial={{ scale: 0 }}
animate={
Object.keys(pulseProps).length > 0
? {
scale: [1, 1.2, 1],
}
: { scale: 1 }
}
transition={
Object.keys(pulseProps).length > 0
? {
scale: {
...pulseProps.transition,
},
default: {
type: 'spring',
stiffness: 200,
damping: 15,
delay: index * 0.1,
},
}
: {
type: 'spring',
stiffness: 200,
damping: 15,
delay: index * 0.1,
}
}
onHoverStart={() => setHoveredSector(index)}
onHoverEnd={() => setHoveredSector(null)}
className="cursor-pointer"
/>
{/* Sector icon circle background - consistent size */}
<motion.circle
cx={pos.x}
cy={pos.y}
r="22"
fill="hsl(var(--background))"
stroke={`hsl(var(--sector-${sector.colorKey}))`}
strokeWidth="2"
initial={{ scale: 0 }}
animate={
Object.keys(pulseProps).length > 0
? {
scale: [1, 1.2, 1],
}
: { scale: 1 }
}
transition={
Object.keys(pulseProps).length > 0
? {
scale: {
...pulseProps.transition,
},
default: {
delay: index * 0.1 + 0.2,
},
}
: { delay: index * 0.1 + 0.2 }
}
/>
{/* Sector label */}
<motion.text
x={pos.x}
y={pos.y + 55}
textAnchor="middle"
fontSize="12"
fill="hsl(var(--foreground))"
fontWeight="600"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: index * 0.1 + 0.3 }}
>
{t(`${sector.nameKey}.name`)}
</motion.text>
{/* Connection count badge - shows number of connections */}
{showStats && sectorConnections.length > 0 && (
<g>
{/* Background circle - colored by sector */}
<motion.circle
cx={pos.x + 28}
cy={pos.y - 28}
r="14"
fill={`hsl(var(--sector-${sector.colorKey}))`}
stroke="hsl(var(--card))"
strokeWidth="2"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: index * 0.1 + 0.4 }}
/>
{/* Connection count text - use black for contrast on colored sector backgrounds */}
<motion.text
x={pos.x + 28}
y={pos.y - 28}
textAnchor="middle"
dominantBaseline="central"
fontSize="11"
fontWeight="700"
fill="black"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: index * 0.1 + 0.5 }}
>
{sectorConnections.length}
</motion.text>
<title>{sectorConnections.length} resource exchange connections</title>
</g>
)}
</g>
);
})}
{/* Sector icons using foreignObject - native SVG coordinate system */}
{dynamicSectors.map((sector, index) => {
const pos = sectorPositions[index];
if (!pos) return null;
const bounds = layoutCalculator.getForeignObjectBounds(pos.x, pos.y, 44);
return (
<foreignObject
key={`icon-${sector.backendName || index}`}
x={bounds.x}
y={bounds.y}
width={bounds.width}
height={bounds.height}
style={{ overflow: 'visible' }}
>
<motion.div
className="flex items-center justify-center w-full h-full rounded-full bg-background border-2 pointer-events-none"
style={{
borderColor: `hsl(var(--sector-${sector.colorKey}))`,
}}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: index * 0.1 + 0.2 }}
>
{React.cloneElement(sector.icon, {
className: `w-5 h-5 text-sector-${sector.colorKey}`,
})}
</motion.div>
</foreignObject>
);
})}
{/* Resource type icons using foreignObject - native SVG coordinate system */}
{connections.map((conn, idx) => {
const fromPos = sectorPositions[conn.from];
const toPos = sectorPositions[conn.to];
if (!fromPos || !toPos) return null;
const sourceSector = dynamicSectors[conn.from];
if (!sourceSector) return null;
const isActive = activeConnections.has(`conn-${idx}`) || hoveredSector === null;
const resourceType = RESOURCE_TYPES.find((r) => r.type === conn.resourceType);
if (!resourceType) return null;
const ResourceIcon = resourceType.icon;
// Check if this is a bidirectional exchange (both A→B and B→A exist)
const reverseConnection = connections.find(
c => c.from === conn.to && c.to === conn.from
(c) => c.from === conn.to && c.to === conn.from
);
const isBidirectional = !!reverseConnection;
@ -355,408 +725,38 @@ const ResourceExchangeVisualization: React.FC<ResourceExchangeVisualizationProps
showStats
);
// Calculate connection start position
const connectionStart = layoutCalculator.calculateConnectionStart(
fromPos,
resourceIconPos,
isBidirectional
const bounds = layoutCalculator.getForeignObjectBounds(
resourceIconPos.x,
resourceIconPos.y,
28
);
// Calculate total distance for particle animation
const totalDistance = layoutCalculator.distance(connectionStart, toPos);
return (
<g key={`connection-${idx}`}>
{/* Connection line - for bidirectional: source to destination (icons at midpoint); for unidirectional: from resource icon to destination */}
<motion.line
x1={connectionStart.x}
y1={connectionStart.y}
x2={toPos.x}
y2={toPos.y}
stroke="hsl(var(--primary))"
strokeWidth={isActive ? 2 : 1}
strokeDasharray={isActive ? "0" : "4 4"}
opacity={isActive ? conn.strength : 0.2}
filter={isActive ? "url(#glow)" : undefined}
initial={{ pathLength: 0 }}
animate={{ pathLength: isActive ? 1 : 0.3 }}
transition={{ duration: 1, delay: idx * 0.1 }}
/>
{/* Animated resource flow particles - flow from resource icon to organization */}
{isActive && totalDistance > 0 && (
<>
{[...Array(3)].map((_, particleIdx) => {
// Particles start exactly at the resource icon position
const particleStartX = resourceIconPos.x;
const particleStartY = resourceIconPos.y;
// Particles travel all the way to the organization icon
// Stop just before the organization circle edge to avoid overlap
const organizationRadius = LAYOUT_CONFIG.SECTOR_NODE_RADIUS;
const particleEndX = toPos.x;
const particleEndY = toPos.y;
// Calculate full distance from resource icon to organization
const fullDistance = layoutCalculator.distance(
{ x: particleStartX, y: particleStartY },
{ x: particleEndX, y: particleEndY }
);
// Base duration with randomization for organic feel
const baseDuration = 4 + conn.strength * 2;
const randomVariation = 0.7 + Math.random() * 0.6; // 0.7x to 1.3x speed variation
const particleDuration = baseDuration * randomVariation;
// Stagger particles for continuous flow - overlap them so there's always a particle visible
// Each particle starts before the previous one finishes (overlap by ~60%)
const overlapRatio = 0.6; // 60% overlap
const staggerDelay = particleIdx * particleDuration * (1 - overlapRatio);
// Randomize particle properties for dynamic feel
const particleSize = 2.5 + Math.random() * 1.5; // 2.5-4px radius
const particleOpacity = 0.6 + Math.random() * 0.4; // 0.6-1.0 opacity
// Slight path variation for more organic movement (small perpendicular offset)
const pathVariation = (Math.random() - 0.5) * 4; // Reduced to ±2px for more realistic path
const angle = Math.atan2(particleEndY - particleStartY, particleEndX - particleStartX);
const perpAngle = angle + Math.PI / 2;
const offsetX = Math.cos(perpAngle) * pathVariation;
const offsetY = Math.sin(perpAngle) * pathVariation;
// Add slight random delay to break synchronization
const randomDelay = Math.random() * 0.5;
// Wait for connection line to be visible before starting particles
// Connection line has delay: idx * 0.1 and duration: 1s
const connectionLineDelay = idx * 0.1;
const connectionLineDuration = 1;
const waitForLine = connectionLineDelay + connectionLineDuration * 0.8; // Start particles when line is 80% visible
return (
<motion.circle
key={`particle-${idx}-${particleIdx}`}
r={particleSize}
fill="hsl(var(--primary))"
initial={{
cx: particleStartX + offsetX,
cy: particleStartY + offsetY,
opacity: particleOpacity, // Start fully visible
}}
animate={{
cx: [particleStartX + offsetX, particleEndX + offsetX],
cy: [particleStartY + offsetY, particleEndY + offsetY],
opacity: [particleOpacity, particleOpacity, 0], // Stay fully visible, fade out only when reaching destination
}}
transition={{
duration: particleDuration,
repeat: Infinity,
delay: waitForLine + staggerDelay + randomDelay, // Wait for line to appear first
ease: "linear",
times: [0, 0.95, 1], // Fully visible (95%), fade out only at destination (5%)
}}
/>
);
})}
</>
)}
</g>
<foreignObject
key={`resource-icon-${idx}`}
x={bounds.x}
y={bounds.y}
width={bounds.width}
height={bounds.height}
style={{ overflow: 'visible' }}
>
<motion.div
className="flex items-center justify-center w-full h-full rounded-full bg-background border-2 pointer-events-none shadow-md"
style={{
borderColor: `hsl(var(--sector-${sourceSector.colorKey}))`,
}}
initial={{ opacity: 0, scale: 0 }}
animate={{
opacity: isActive ? 1 : 0,
scale: isActive ? 1 : 0,
}}
transition={{ duration: 0.3 }}
>
<ResourceIcon className={`w-4 h-4 ${resourceType.color}`} />
</motion.div>
</foreignObject>
);
})}
</g>
{/* Sector nodes */}
{dynamicSectors.map((sector, index) => {
const pos = sectorPositions[index];
if (!pos) return null;
const isHovered = hoveredSector === index;
const sectorConnections = connections.filter(
c => c.from === index || c.to === index
);
// Find incoming connections (where this sector is the destination)
const incomingConnections = connections.filter(c => c.to === index);
// Calculate pulse animation synchronized with particle arrivals
// Particles take (4 + strength * 2) seconds to travel, and we have 3 particles staggered
const getPulseProps = () => {
// Don't pulse if no incoming connections or if this sector is hovered
if (incomingConnections.length === 0 || isHovered) {
return {};
}
// Find active incoming connections (where particles are flowing)
const activeIncoming = incomingConnections.find((conn) => {
const connIdx = connections.findIndex(c => c.from === conn.from && c.to === conn.to);
const connKey = `conn-${connIdx}`;
// Connection is active if it's in activeConnections set or if nothing is hovered (all connections active)
return activeConnections.has(connKey) || hoveredSector === null;
});
if (!activeIncoming) return {};
// Calculate pulse timing based on continuous particle flow
// With 6 particles overlapping at 60%, particles arrive more frequently
const baseDuration = 4 + activeIncoming.strength * 2;
const overlapRatio = 0.6;
const particleInterval = baseDuration * (1 - overlapRatio); // Time between particle arrivals
const averageDuration = baseDuration * 0.85; // Average duration accounting for randomization
// Pulse when particles arrive - more frequent pulses for continuous flow
// Account for initial spring animation (~0.8s) + delay
const initialAnimationTime = 0.8 + (index * 0.1);
const pulseDelay = Math.max(0, averageDuration - initialAnimationTime);
return {
transition: {
duration: 0.3,
delay: pulseDelay,
repeat: Infinity,
repeatDelay: particleInterval - 0.3, // Pulse with each particle arrival
ease: "easeInOut",
},
};
};
const pulseProps = getPulseProps();
return (
<g key={sector.backendName || `sector-${index}`}>
{/* Connection highlight circle - consistent size */}
{isHovered && (
<motion.circle
cx={pos.x}
cy={pos.y}
r="90"
fill="none"
stroke="hsl(var(--primary))"
strokeWidth="1.5"
strokeDasharray="4 8"
opacity="0.25"
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1.15, opacity: 0.3 }}
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
/>
)}
{/* Sector node circle - consistent size */}
<motion.circle
cx={pos.x}
cy={pos.y}
r="32"
fill={`hsl(var(--sector-${sector.colorKey}))`}
fillOpacity="0.15"
stroke={`hsl(var(--sector-${sector.colorKey}))`}
strokeWidth={isHovered ? 3 : 2}
filter={isHovered ? "url(#glow)" : undefined}
initial={{ scale: 0 }}
animate={
Object.keys(pulseProps).length > 0
? {
scale: [1, 1.2, 1],
}
: { scale: 1 }
}
transition={
Object.keys(pulseProps).length > 0
? {
scale: {
...pulseProps.transition,
},
default: {
type: "spring",
stiffness: 200,
damping: 15,
delay: index * 0.1,
},
}
: {
type: "spring",
stiffness: 200,
damping: 15,
delay: index * 0.1,
}
}
onHoverStart={() => setHoveredSector(index)}
onHoverEnd={() => setHoveredSector(null)}
className="cursor-pointer"
/>
{/* Sector icon circle background - consistent size */}
<motion.circle
cx={pos.x}
cy={pos.y}
r="22"
fill="hsl(var(--background))"
stroke={`hsl(var(--sector-${sector.colorKey}))`}
strokeWidth="2"
initial={{ scale: 0 }}
animate={
Object.keys(pulseProps).length > 0
? {
scale: [1, 1.2, 1],
}
: { scale: 1 }
}
transition={
Object.keys(pulseProps).length > 0
? {
scale: {
...pulseProps.transition,
},
default: {
delay: index * 0.1 + 0.2,
},
}
: { delay: index * 0.1 + 0.2 }
}
/>
{/* Sector label */}
<motion.text
x={pos.x}
y={pos.y + 55}
textAnchor="middle"
fontSize="12"
fill="hsl(var(--foreground))"
fontWeight="600"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: index * 0.1 + 0.3 }}
>
{t(`${sector.nameKey}.name`)}
</motion.text>
{/* Connection count badge - shows number of connections */}
{showStats && sectorConnections.length > 0 && (
<g>
{/* Background circle - colored by sector */}
<motion.circle
cx={pos.x + 28}
cy={pos.y - 28}
r="14"
fill={`hsl(var(--sector-${sector.colorKey}))`}
stroke="hsl(var(--card))"
strokeWidth="2"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: index * 0.1 + 0.4 }}
/>
{/* Connection count text - use black for contrast on colored sector backgrounds */}
<motion.text
x={pos.x + 28}
y={pos.y - 28}
textAnchor="middle"
dominantBaseline="central"
fontSize="11"
fontWeight="700"
fill="black"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: index * 0.1 + 0.5 }}
>
{sectorConnections.length}
</motion.text>
<title>{sectorConnections.length} resource exchange connections</title>
</g>
)}
</g>
);
})}
{/* Sector icons using foreignObject - native SVG coordinate system */}
{dynamicSectors.map((sector, index) => {
const pos = sectorPositions[index];
if (!pos) return null;
const bounds = layoutCalculator.getForeignObjectBounds(pos.x, pos.y, 44);
return (
<foreignObject
key={`icon-${sector.backendName || index}`}
x={bounds.x}
y={bounds.y}
width={bounds.width}
height={bounds.height}
style={{ overflow: 'visible' }}
>
<motion.div
className="flex items-center justify-center w-full h-full rounded-full bg-background border-2 pointer-events-none"
style={{
borderColor: `hsl(var(--sector-${sector.colorKey}))`,
}}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: index * 0.1 + 0.2 }}
>
{React.cloneElement(sector.icon, {
className: `w-5 h-5 text-sector-${sector.colorKey}`,
})}
</motion.div>
</foreignObject>
);
})}
{/* Resource type icons using foreignObject - native SVG coordinate system */}
{connections.map((conn, idx) => {
const fromPos = sectorPositions[conn.from];
const toPos = sectorPositions[conn.to];
if (!fromPos || !toPos) return null;
const sourceSector = dynamicSectors[conn.from];
if (!sourceSector) return null;
const isActive = activeConnections.has(`conn-${idx}`) || hoveredSector === null;
const resourceType = RESOURCE_TYPES.find(r => r.type === conn.resourceType);
if (!resourceType) return null;
const ResourceIcon = resourceType.icon;
// Check if this is a bidirectional exchange (both A→B and B→A exist)
const reverseConnection = connections.find(
c => c.from === conn.to && c.to === conn.from
);
const isBidirectional = !!reverseConnection;
// Calculate resource icon position using layout calculator
const resourceIconPos = layoutCalculator.calculateResourceIconPosition(
fromPos,
toPos,
isBidirectional,
conn.from,
conn.to,
showStats
);
const bounds = layoutCalculator.getForeignObjectBounds(resourceIconPos.x, resourceIconPos.y, 28);
return (
<foreignObject
key={`resource-icon-${idx}`}
x={bounds.x}
y={bounds.y}
width={bounds.width}
height={bounds.height}
style={{ overflow: 'visible' }}
>
<motion.div
className="flex items-center justify-center w-full h-full rounded-full bg-background border-2 pointer-events-none shadow-md"
style={{
borderColor: `hsl(var(--sector-${sourceSector.colorKey}))`,
}}
initial={{ opacity: 0, scale: 0 }}
animate={{
opacity: isActive ? 1 : 0,
scale: isActive ? 1 : 0,
}}
transition={{ duration: 0.3 }}
>
<ResourceIcon className={`w-4 h-4 ${resourceType.color}`} />
</motion.div>
</foreignObject>
);
})}
</svg>
</div>

Some files were not shown because too many files have changed in this diff Show More