mirror of
https://github.com/SamyRai/turash.git
synced 2025-12-26 23:01:33 +00:00
WIP: commit local changes
This commit is contained in:
parent
02fad6713c
commit
7f1beb9d7f
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.
@ -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)
|
||||
}
|
||||
|
||||
301
bugulma/backend/cmd/cli/cmd/heritage/import_timeline.go
Normal file
301
bugulma/backend/cmd/cli/cmd/heritage/import_timeline.go
Normal 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"
|
||||
}
|
||||
37
bugulma/backend/docs/PUBLIC_TRANSPORT.md
Normal file
37
bugulma/backend/docs/PUBLIC_TRANSPORT.md
Normal 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`
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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{},
|
||||
|
||||
52
bugulma/backend/internal/domain/public_transport.go
Normal file
52
bugulma/backend/internal/domain/public_transport.go
Normal 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)
|
||||
}
|
||||
54
bugulma/backend/internal/domain/public_transport_gtfs.go
Normal file
54
bugulma/backend/internal/domain/public_transport_gtfs.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
52
bugulma/backend/internal/handler/admin_handler_test.go
Normal file
52
bugulma/backend/internal/handler/admin_handler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
314
bugulma/backend/internal/handler/i18n_data_handler_test.go
Normal file
314
bugulma/backend/internal/handler/i18n_data_handler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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"])
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
96
bugulma/backend/internal/handler/public_transport_handler.go
Normal file
96
bugulma/backend/internal/handler/public_transport_handler.go
Normal 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})
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
53
bugulma/backend/internal/handler/settings_admin_handler.go
Normal file
53
bugulma/backend/internal/handler/settings_admin_handler.go
Normal 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"})
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 ""
|
||||
}
|
||||
|
||||
|
||||
62
bugulma/backend/internal/middleware/maintenance.go
Normal file
62
bugulma/backend/internal/middleware/maintenance.go
Normal 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})
|
||||
}
|
||||
}
|
||||
114
bugulma/backend/internal/middleware/maintenance_test.go
Normal file
114
bugulma/backend/internal/middleware/maintenance_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
24
bugulma/backend/internal/routes/public_transport.go
Normal file
24
bugulma/backend/internal/routes/public_transport.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
146
bugulma/backend/internal/service/i18n_service_bulk_test.go
Normal file
146
bugulma/backend/internal/service/i18n_service_bulk_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
116
bugulma/backend/internal/service/public_transport_importer.go
Normal file
116
bugulma/backend/internal/service/public_transport_importer.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
162
bugulma/backend/internal/service/public_transport_service.go
Normal file
162
bugulma/backend/internal/service/public_transport_service.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
96
bugulma/backend/internal/service/settings_service.go
Normal file
96
bugulma/backend/internal/service/settings_service.go
Normal 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
|
||||
}
|
||||
59
bugulma/backend/internal/service/settings_service_test.go
Normal file
59
bugulma/backend/internal/service/settings_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
-- Migration: 019_create_system_settings_table.down.sql
|
||||
DROP TABLE IF EXISTS system_settings;
|
||||
10
bugulma/backend/migrations/postgres/019_create_system_settings_table.up.sql
Executable file
10
bugulma/backend/migrations/postgres/019_create_system_settings_table.up.sql
Executable 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);
|
||||
Binary file not shown.
@ -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']);
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -69,4 +69,3 @@ export const ChartCard = ({
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -49,4 +49,3 @@ export const FormSection = ({
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -31,4 +31,3 @@ export const SearchAndFilter = ({ search, filters, className }: SearchAndFilterP
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -39,4 +39,3 @@ export const SettingsSection = ({
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
38
bugulma/frontend/components/analytics/SimpleBarChart.tsx
Normal file
38
bugulma/frontend/components/analytics/SimpleBarChart.tsx
Normal 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;
|
||||
@ -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;
|
||||
@ -72,4 +72,3 @@ export const AdminRoute = ({
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
|
||||
@ -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}</>;
|
||||
};
|
||||
|
||||
|
||||
@ -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}</>;
|
||||
};
|
||||
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
25
bugulma/frontend/components/dashboard/MetricsGrid.tsx
Normal file
25
bugulma/frontend/components/dashboard/MetricsGrid.tsx
Normal 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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
@ -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;
|
||||
106
bugulma/frontend/components/dashboard/RecentActivitySection.tsx
Normal file
106
bugulma/frontend/components/dashboard/RecentActivitySection.tsx
Normal 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;
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
8
bugulma/frontend/components/dashboard/index.ts
Normal file
8
bugulma/frontend/components/dashboard/index.ts
Normal 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';
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -86,4 +86,3 @@ export const MatchCardPricing: React.FC<MatchCardPricingProps> = (props) => {
|
||||
};
|
||||
|
||||
export default React.memo(MatchCardPricing);
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -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':
|
||||
|
||||
124
bugulma/frontend/components/heritage/HeroSection.tsx
Normal file
124
bugulma/frontend/components/heritage/HeroSection.tsx
Normal 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;
|
||||
77
bugulma/frontend/components/heritage/SourcesSection.tsx
Normal file
77
bugulma/frontend/components/heritage/SourcesSection.tsx
Normal 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;
|
||||
@ -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',
|
||||
})}
|
||||
|
||||
198
bugulma/frontend/components/heritage/TimelineSection.tsx
Normal file
198
bugulma/frontend/components/heritage/TimelineSection.tsx
Normal 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;
|
||||
@ -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>
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user