cleanup thorough
This commit is contained in:
@@ -1,254 +0,0 @@
|
|||||||
# CRITICAL BUG FIXES - December 9, 2025
|
|
||||||
|
|
||||||
## Issue #1: User Swapping / Random Logout
|
|
||||||
|
|
||||||
### ROOT CAUSE
|
|
||||||
Django's database-backed session storage combined with in-memory user object caching at the process level caused cross-request contamination. When multiple requests were handled by the same worker process, user objects would leak between sessions.
|
|
||||||
|
|
||||||
### THE PROBLEM
|
|
||||||
1. **Database-Backed Sessions**: Django defaulted to storing sessions in the database, which allowed slow queries and race conditions
|
|
||||||
2. **In-Memory User Caching**: `django.contrib.auth.backends.ModelBackend` cached user objects in thread-local storage
|
|
||||||
3. **Middleware Mutation**: `AccountContextMiddleware` was querying DB again and potentially mutating request.user
|
|
||||||
4. **No Session Integrity Checks**: Sessions didn't verify that user_id/account_id remained consistent
|
|
||||||
|
|
||||||
### THE FIX
|
|
||||||
|
|
||||||
#### 1. Redis-Backed Sessions (`settings.py`)
|
|
||||||
```python
|
|
||||||
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
|
|
||||||
SESSION_CACHE_ALIAS = 'default'
|
|
||||||
|
|
||||||
CACHES = {
|
|
||||||
'default': {
|
|
||||||
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
|
|
||||||
'LOCATION': 'redis://redis:6379/1',
|
|
||||||
'OPTIONS': {
|
|
||||||
'KEY_PREFIX': 'igny8', # Prevent key collisions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why**: Redis provides isolated, fast session storage that doesn't allow cross-process contamination like database sessions do.
|
|
||||||
|
|
||||||
#### 2. Custom Authentication Backend (`auth/backends.py`)
|
|
||||||
```python
|
|
||||||
class NoCacheModelBackend(ModelBackend):
|
|
||||||
def get_user(self, user_id):
|
|
||||||
# ALWAYS query DB fresh - no caching
|
|
||||||
return UserModel.objects.select_related('account', 'account__plan').get(pk=user_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why**: Disables Django's default user object caching that caused cross-request user leakage.
|
|
||||||
|
|
||||||
#### 3. Session Integrity Validation (`auth/middleware.py`)
|
|
||||||
```python
|
|
||||||
# Store account_id and user_id in session
|
|
||||||
request.session['_account_id'] = request.account.id
|
|
||||||
request.session['_user_id'] = request.user.id
|
|
||||||
|
|
||||||
# Verify on every request
|
|
||||||
stored_account_id = request.session.get('_account_id')
|
|
||||||
if stored_account_id and stored_account_id != request.account.id:
|
|
||||||
# Session contamination detected!
|
|
||||||
logout(request)
|
|
||||||
return JsonResponse({'error': 'Session integrity violation'}, status=401)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why**: Detects and prevents session contamination by verifying user/account IDs match on every request.
|
|
||||||
|
|
||||||
#### 4. Never Mutate request.user (`auth/middleware.py`)
|
|
||||||
```python
|
|
||||||
# WRONG (old code):
|
|
||||||
user = User.objects.select_related('account').get(id=user_id)
|
|
||||||
request.user = user # CAUSES CONTAMINATION
|
|
||||||
|
|
||||||
# CORRECT (new code):
|
|
||||||
# Just use request.user as-is from Django's AuthenticationMiddleware
|
|
||||||
request.account = getattr(request.user, 'account', None)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why**: Mutating request.user after Django's AuthenticationMiddleware set it causes the cached object to contaminate other requests.
|
|
||||||
|
|
||||||
### Files Modified
|
|
||||||
- `backend/igny8_core/settings.py` - Added Redis sessions and cache config
|
|
||||||
- `backend/igny8_core/auth/backends.py` - Created custom no-cache backend
|
|
||||||
- `backend/igny8_core/auth/middleware.py` - Added session integrity checks
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Issue #2: useNavigate / useLocation Errors During Development
|
|
||||||
|
|
||||||
### ROOT CAUSE
|
|
||||||
React Router context was lost during Hot Module Replacement (HMR) because every lazy-loaded route had its own Suspense boundary with `fallback={null}`. When Vite performed HMR on modules, the Suspense boundaries would re-render but lose the Router context from `<BrowserRouter>` in `main.tsx`.
|
|
||||||
|
|
||||||
### THE PROBLEM
|
|
||||||
1. **Individual Suspense Boundaries**: Every route had `<Suspense fallback={null}><Component /></Suspense>`
|
|
||||||
2. **HMR Context Loss**: When Vite replaced modules, Suspense boundaries would re-mount but Router context wouldn't propagate
|
|
||||||
3. **Only Affected Active Modules**: Planner, Writer, Sites, Automation were being actively developed, so HMR triggered more frequently
|
|
||||||
4. **Rebuild Fixed It Temporarily**: Full rebuild re-established all contexts, but next code change broke it again
|
|
||||||
|
|
||||||
### THE FIX
|
|
||||||
|
|
||||||
#### Single Top-Level Suspense Boundary (`App.tsx`)
|
|
||||||
```tsx
|
|
||||||
// BEFORE (WRONG):
|
|
||||||
<Routes>
|
|
||||||
<Route path="/planner/keywords" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Keywords />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/writer/tasks" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Tasks />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
{/* 100+ more routes with individual Suspense... */}
|
|
||||||
</Routes>
|
|
||||||
|
|
||||||
// AFTER (CORRECT):
|
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/planner/keywords" element={<Keywords />} />
|
|
||||||
<Route path="/writer/tasks" element={<Tasks />} />
|
|
||||||
{/* All routes without individual Suspense */}
|
|
||||||
</Routes>
|
|
||||||
</Suspense>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why**: A single Suspense boundary around the entire Routes component ensures Router context persists through HMR. When individual lazy components update, they suspend to the top-level boundary without losing Router context.
|
|
||||||
|
|
||||||
### Files Modified
|
|
||||||
- `frontend/src/App.tsx` - Moved Suspense to wrap entire Routes component, removed 100+ individual Suspense wrappers
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why These Fixes Are Permanent
|
|
||||||
|
|
||||||
### Issue #1: User Swapping
|
|
||||||
- **Architectural**: Moved from database to Redis sessions (industry standard)
|
|
||||||
- **Eliminates Root Cause**: Disabled user caching that caused contamination
|
|
||||||
- **Verifiable**: Session integrity checks will detect any future contamination attempts
|
|
||||||
- **No Workarounds Needed**: All previous band-aid fixes (cache clearing, session deletion) can be removed
|
|
||||||
|
|
||||||
### Issue #2: Router Errors
|
|
||||||
- **Follows React Best Practices**: Single Suspense boundary for code-splitting is React's recommended pattern
|
|
||||||
- **HMR-Proof**: Router context now persists through hot reloads
|
|
||||||
- **Cleaner Code**: Removed 200+ lines of repetitive Suspense wrappers
|
|
||||||
- **Future-Proof**: Any new lazy-loaded routes automatically work without Suspense wrappers
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Validation
|
|
||||||
|
|
||||||
### User Swapping Fix
|
|
||||||
```bash
|
|
||||||
# Test 1: Login with multiple users in different tabs
|
|
||||||
# Expected: Each tab maintains its own session without contamination
|
|
||||||
|
|
||||||
# Test 2: Rapid user switching
|
|
||||||
# Expected: Session integrity checks prevent contamination
|
|
||||||
|
|
||||||
# Test 3: High concurrency load test
|
|
||||||
# Expected: No user swapping under load
|
|
||||||
```
|
|
||||||
|
|
||||||
### Router Fix
|
|
||||||
```bash
|
|
||||||
# Test 1: Make code changes to Writer module while on /writer/tasks
|
|
||||||
# Expected: Page hot-reloads without useNavigate errors
|
|
||||||
|
|
||||||
# Test 2: Navigate between Planner → Writer → Sites during active development
|
|
||||||
# Expected: No Router context errors
|
|
||||||
|
|
||||||
# Test 3: Full rebuild no longer required
|
|
||||||
# Expected: HMR works consistently
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration Notes
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
1. **Install Redis** (if not already):
|
|
||||||
```bash
|
|
||||||
# Already in docker-compose.yml, ensure it's running
|
|
||||||
docker-compose up -d redis
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Clear existing sessions** (one-time):
|
|
||||||
```bash
|
|
||||||
docker-compose exec backend python manage.py clearsessions
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **No database migration needed** - session storage location changed but schema unchanged
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
1. **No code changes needed by developers**
|
|
||||||
2. **Clear browser cache** to remove old lazy-load chunks
|
|
||||||
3. **Verify HMR works** by making code changes in active modules
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Removed Code (Can Be Deleted)
|
|
||||||
|
|
||||||
### Backend - Previous Band-Aids
|
|
||||||
These can be removed as they're no longer needed:
|
|
||||||
- Cache clearing logic in logout views
|
|
||||||
- Manual session deletion in middleware
|
|
||||||
- User refresh queries in multiple places
|
|
||||||
- Account validation duplication
|
|
||||||
|
|
||||||
### Frontend - Previous Band-Aids
|
|
||||||
These can be removed:
|
|
||||||
- localStorage.clear() workarounds
|
|
||||||
- Manual cookie deletion loops
|
|
||||||
- Store reset logic
|
|
||||||
- Redundant authentication state syncing
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Performance Impact
|
|
||||||
|
|
||||||
### Issue #1 Fix
|
|
||||||
- **Faster**: Redis sessions are 10-100x faster than database queries
|
|
||||||
- **Lower DB Load**: No more session table queries on every request
|
|
||||||
- **Memory**: Minimal (~1KB per session in Redis)
|
|
||||||
|
|
||||||
### Issue #2 Fix
|
|
||||||
- **Faster HMR**: Single Suspense boundary reduces re-render overhead
|
|
||||||
- **Smaller Bundle**: Removed 200+ lines of Suspense wrapper code
|
|
||||||
- **Better UX**: Cleaner loading states with top-level fallback
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Monitoring
|
|
||||||
|
|
||||||
Add these logs to verify fixes are working:
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
```python
|
|
||||||
# In auth/middleware.py - already added
|
|
||||||
if stored_account_id and stored_account_id != request.account.id:
|
|
||||||
logger.error(f"Session contamination detected: stored={stored_account_id}, actual={request.account.id}")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
```typescript
|
|
||||||
// In App.tsx - add if needed
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('Router context established successfully');
|
|
||||||
}, []);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Both issues were **architectural flaws**, not bugs in business logic:
|
|
||||||
|
|
||||||
1. **User Swapping**: Django's default session/auth caching allowed cross-request contamination
|
|
||||||
2. **Router Errors**: React's Suspense boundaries per route lost Router context during HMR
|
|
||||||
|
|
||||||
Both fixes align with **industry best practices** and are **permanent architectural improvements**.
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
# Documentation Consolidation Report
|
|
||||||
|
|
||||||
**Date:** December 9, 2024
|
|
||||||
**Task:** Consolidate scattered documentation into single navigable structure
|
|
||||||
**Status:** ✅ COMPLETE
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
Successfully consolidated **120+ documentation files** from 2 separate folders (`master-docs/` and `old-docs/`) into a single, well-organized `docs/` folder with **49 active files** and **122 archived files** for reference.
|
|
||||||
|
|
||||||
### Key Achievements
|
|
||||||
|
|
||||||
✅ **Single source of truth** - All docs in `/docs/` folder
|
|
||||||
✅ **Quick navigation** - Find any file in 1-2 steps
|
|
||||||
✅ **No code in docs** - Only file paths, function names, workflows
|
|
||||||
✅ **Hierarchical structure** - Organized by system/backend/API/frontend/workflows/deployment
|
|
||||||
✅ **Safely archived** - Old docs preserved in `/docs/90-ARCHIVED/`
|
|
||||||
✅ **Multi-tenancy untouched** - 5 files remain unchanged ✓
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project Structure Changes
|
|
||||||
|
|
||||||
### Before
|
|
||||||
```
|
|
||||||
/data/app/igny8/
|
|
||||||
├── master-docs/ (100+ markdown files, deep hierarchy)
|
|
||||||
├── old-docs/ (20+ markdown files, various formats)
|
|
||||||
├── multi-tenancy/ (5 files - payment/tenancy specific)
|
|
||||||
├── backend/
|
|
||||||
├── frontend/
|
|
||||||
└── ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### After
|
|
||||||
```
|
|
||||||
/data/app/igny8/
|
|
||||||
├── docs/ (NEW - 49 active docs + 122 archived)
|
|
||||||
│ ├── README.md (Master navigation with Quick Find)
|
|
||||||
│ ├── 00-SYSTEM/ (5 files - architecture, auth, flows)
|
|
||||||
│ ├── 10-BACKEND/ (26 files - all modules organized)
|
|
||||||
│ ├── 20-API/ (7 files - all endpoints)
|
|
||||||
│ ├── 30-FRONTEND/ (8 files - UI components, state)
|
|
||||||
│ ├── 40-WORKFLOWS/ (5 files - complete user journeys)
|
|
||||||
│ ├── 50-DEPLOYMENT/ (3 files - setup, docker, migrations)
|
|
||||||
│ └── 90-ARCHIVED/ (122 files - old docs preserved)
|
|
||||||
│
|
|
||||||
├── multi-tenancy/ (UNTOUCHED - 5 files)
|
|
||||||
├── backend/
|
|
||||||
├── frontend/
|
|
||||||
└── ...
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Documentation Organization
|
|
||||||
|
|
||||||
### 00-SYSTEM (5 files)
|
|
||||||
High-level architecture and cross-cutting concerns
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| ARCHITECTURE-OVERVIEW.md | System design, microservices |
|
|
||||||
| TECH-STACK.md | All technologies used |
|
|
||||||
| MULTITENANCY.md | Account isolation, tenant context |
|
|
||||||
| AUTHENTICATION.md | JWT, sessions, permissions |
|
|
||||||
| DATA-FLOWS.md | Cross-system workflows |
|
|
||||||
|
|
||||||
### 10-BACKEND (26 files across 7 modules)
|
|
||||||
All backend code locations without code snippets
|
|
||||||
|
|
||||||
| Module | Files | Coverage |
|
|
||||||
|--------|-------|----------|
|
|
||||||
| Core | 3 | OVERVIEW.md, MODELS.md, SERVICES.md |
|
|
||||||
| accounts/ | 1 | User, Account, Role models + endpoints |
|
|
||||||
| billing/ | 3 | Plans, Payments, Credits, Payment Methods |
|
|
||||||
| planner/ | 3 | Keywords, Clusters, Ideas pipeline |
|
|
||||||
| writer/ | 4 | Content, Tasks, Publishing, Images |
|
|
||||||
| automation/ | 3 | Pipeline, Stages, Scheduler |
|
|
||||||
| integrations/ | 3 | WordPress, AI Services, Image Generation |
|
|
||||||
| sites/ | 1 | Site & Sector management |
|
|
||||||
|
|
||||||
### 20-API (7 files)
|
|
||||||
All REST endpoints with handler locations
|
|
||||||
|
|
||||||
- API-REFERENCE.md (Complete endpoint list)
|
|
||||||
- AUTHENTICATION-ENDPOINTS.md
|
|
||||||
- PLANNER-ENDPOINTS.md
|
|
||||||
- WRITER-ENDPOINTS.md
|
|
||||||
- AUTOMATION-ENDPOINTS.md
|
|
||||||
- BILLING-ENDPOINTS.md
|
|
||||||
- INTEGRATION-ENDPOINTS.md
|
|
||||||
|
|
||||||
### 30-FRONTEND (8 files across 4 modules)
|
|
||||||
React components and state management
|
|
||||||
|
|
||||||
- FRONTEND-ARCHITECTURE.md
|
|
||||||
- STATE-MANAGEMENT.md
|
|
||||||
- COMPONENTS.md
|
|
||||||
- Module-specific: planner/, writer/, automation/, billing/
|
|
||||||
|
|
||||||
### 40-WORKFLOWS (5 files)
|
|
||||||
Complete user journeys with visual diagrams
|
|
||||||
|
|
||||||
- SIGNUP-TO-ACTIVE.md (User onboarding)
|
|
||||||
- CONTENT-LIFECYCLE.md (Keyword → Published content)
|
|
||||||
- PAYMENT-WORKFLOW.md (Payment approval flow)
|
|
||||||
- AUTOMATION-WORKFLOW.md (Full automation run)
|
|
||||||
- WORDPRESS-SYNC.md (Bidirectional sync)
|
|
||||||
|
|
||||||
### 50-DEPLOYMENT (3 files)
|
|
||||||
Environment and deployment guides
|
|
||||||
|
|
||||||
- ENVIRONMENT-SETUP.md
|
|
||||||
- DOCKER-DEPLOYMENT.md
|
|
||||||
- DATABASE-MIGRATIONS.md
|
|
||||||
|
|
||||||
### 90-ARCHIVED
|
|
||||||
Historical reference (122 files)
|
|
||||||
|
|
||||||
- master-docs-original/ (100+ files)
|
|
||||||
- old-docs-original/ (20+ files)
|
|
||||||
- README.md (Explains archive purpose)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Navigation System
|
|
||||||
|
|
||||||
### Master README "Quick Find" Table
|
|
||||||
|
|
||||||
The `/docs/README.md` file contains a comprehensive "Quick Find" table that allows developers to:
|
|
||||||
|
|
||||||
1. **Search by task** - "I want to add a feature" → Find module → Find file
|
|
||||||
2. **Search by module** - Direct links to backend/frontend modules
|
|
||||||
3. **Search by technology** - Find all docs for Django, React, Celery, etc.
|
|
||||||
4. **Search by code location** - Map directory to documentation
|
|
||||||
|
|
||||||
### Example Usage
|
|
||||||
|
|
||||||
**Scenario:** "I need to add a new payment method"
|
|
||||||
|
|
||||||
```
|
|
||||||
Step 1: Open /docs/README.md
|
|
||||||
Step 2: Find "Billing" in Quick Find table
|
|
||||||
Step 3: Navigate to /docs/10-BACKEND/billing/PAYMENT-METHODS.md
|
|
||||||
Step 4: Read:
|
|
||||||
- File location: backend/igny8_core/business/billing/models.py
|
|
||||||
- Model: PaymentMethodConfig
|
|
||||||
- Admin: PaymentMethodConfigAdmin in admin.py
|
|
||||||
Step 5: Open exact file and implement
|
|
||||||
```
|
|
||||||
|
|
||||||
No guessing. No searching. Direct navigation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Documentation Standards
|
|
||||||
|
|
||||||
### What's IN the docs:
|
|
||||||
|
|
||||||
✅ File paths: `backend/igny8_core/business/billing/services/credit_service.py`
|
|
||||||
✅ Function names: `CreditService.add_credits(account, amount, type)`
|
|
||||||
✅ Model fields: `account.credits`, `invoice.total`, `payment.status`
|
|
||||||
✅ Endpoints: `POST /v1/billing/admin/payments/confirm/`
|
|
||||||
✅ Workflows: ASCII diagrams, state tables, field mappings
|
|
||||||
✅ Cross-references: Links to related documentation
|
|
||||||
|
|
||||||
### What's NOT in the docs:
|
|
||||||
|
|
||||||
❌ Code snippets
|
|
||||||
❌ Implementation details
|
|
||||||
❌ Line-by-line code walkthroughs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files Created/Modified
|
|
||||||
|
|
||||||
### Created
|
|
||||||
- `/docs/README.md` - Master navigation (4,300 lines)
|
|
||||||
- 49 documentation files across 6 main sections
|
|
||||||
- `/docs/90-ARCHIVED/README.md` - Archive explanation
|
|
||||||
|
|
||||||
### Modified
|
|
||||||
- `/docs/CHANGELOG.md` - Copied from multi-tenancy folder
|
|
||||||
|
|
||||||
### Archived
|
|
||||||
- `master-docs/` → `/docs/90-ARCHIVED/master-docs-original/`
|
|
||||||
- `old-docs/` → `/docs/90-ARCHIVED/old-docs-original/`
|
|
||||||
|
|
||||||
### Untouched
|
|
||||||
- `/multi-tenancy/` - 5 files remain unchanged ✓
|
|
||||||
- TENANCY-IMPLEMENTATION-GUIDE.md
|
|
||||||
- TENANCY-DATA-FLOW.md
|
|
||||||
- TENANCY-CHANGE-LOG.md
|
|
||||||
- README.md
|
|
||||||
- DOCUMENTATION-SUMMARY.md
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification Checklist
|
|
||||||
|
|
||||||
### Coverage
|
|
||||||
- [x] All backend modules documented
|
|
||||||
- [x] All API endpoints documented
|
|
||||||
- [x] All frontend modules documented
|
|
||||||
- [x] All major workflows documented
|
|
||||||
- [x] All deployment needs documented
|
|
||||||
|
|
||||||
### Navigation
|
|
||||||
- [x] Master README with Quick Find table
|
|
||||||
- [x] Module-specific references created
|
|
||||||
- [x] Cross-references working
|
|
||||||
- [x] No code in documentation
|
|
||||||
- [x] Exact file locations provided
|
|
||||||
|
|
||||||
### Safety
|
|
||||||
- [x] Old docs archived (not deleted)
|
|
||||||
- [x] Multi-tenancy folder untouched
|
|
||||||
- [x] Archive README created
|
|
||||||
- [x] Clear deprecation notices
|
|
||||||
|
|
||||||
### Quality
|
|
||||||
- [x] Hierarchical organization
|
|
||||||
- [x] 1-2 step navigation maximum
|
|
||||||
- [x] Consistent formatting
|
|
||||||
- [x] Maintenance guidelines included
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Success Metrics
|
|
||||||
|
|
||||||
| Metric | Before | After | Improvement |
|
|
||||||
|--------|--------|-------|-------------|
|
|
||||||
| Active doc files | 120+ (scattered) | 49 (organized) | 60% reduction |
|
|
||||||
| Documentation locations | 3 folders | 1 folder | 67% consolidation |
|
|
||||||
| Navigation steps | 3-5 steps | 1-2 steps | 60% faster |
|
|
||||||
| Code in docs | Yes (scattered) | No (only paths) | 100% cleaner |
|
|
||||||
| Archived safely | No | Yes (122 files) | 100% preserved |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Maintenance Guidelines
|
|
||||||
|
|
||||||
### When adding features:
|
|
||||||
1. Update relevant module reference doc
|
|
||||||
2. Update API doc if endpoint added
|
|
||||||
3. Update workflow doc if flow changed
|
|
||||||
4. Update CHANGELOG.md
|
|
||||||
|
|
||||||
### When fixing bugs:
|
|
||||||
1. Note fix in CHANGELOG.md
|
|
||||||
2. Update relevant doc if behavior changed
|
|
||||||
|
|
||||||
### Documentation standards:
|
|
||||||
- NO code snippets
|
|
||||||
- Only file paths and function names
|
|
||||||
- Visual diagrams welcome
|
|
||||||
- Tables for structured data
|
|
||||||
- Maintain 1-2 step navigation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Recommendations
|
|
||||||
|
|
||||||
### Immediate (Next Sprint)
|
|
||||||
1. Review all 49 docs for accuracy
|
|
||||||
2. Add missing endpoint details in API docs
|
|
||||||
3. Create automation for doc updates
|
|
||||||
|
|
||||||
### Short-term (Next Month)
|
|
||||||
1. Add more visual diagrams to workflows
|
|
||||||
2. Create video walkthroughs using docs
|
|
||||||
3. Set up automated doc testing
|
|
||||||
|
|
||||||
### Long-term (Next Quarter)
|
|
||||||
1. Generate API docs from code annotations
|
|
||||||
2. Create interactive doc navigation
|
|
||||||
3. Build doc search functionality
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
The documentation consolidation project successfully achieved all objectives:
|
|
||||||
|
|
||||||
✅ **Single source of truth** - `/docs/` folder
|
|
||||||
✅ **Quick navigation** - 1-2 step maximum
|
|
||||||
✅ **No code clutter** - Only essential references
|
|
||||||
✅ **Safe archival** - 122 files preserved
|
|
||||||
✅ **Multi-tenancy protected** - Untouched ✓
|
|
||||||
|
|
||||||
The new structure enables both human developers and AI agents to quickly find the exact file and function to modify without any guessing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Report Generated:** December 9, 2024
|
|
||||||
**Status:** Production-Ready
|
|
||||||
**Maintained By:** Development Team
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
# Integration Settings Architecture Analysis
|
|
||||||
|
|
||||||
## Current Setup (As of Dec 10, 2025)
|
|
||||||
|
|
||||||
### 1. How It Works Now
|
|
||||||
|
|
||||||
**System Architecture:**
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ INTEGRATION SETTINGS (System-Wide API Keys) │
|
|
||||||
│ Stored in: IntegrationSettings model │
|
|
||||||
│ Account: AWS Admin (slug: aws-admin) │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
│ Fallback mechanism
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ │
|
|
||||||
↓ ↓
|
|
||||||
┌──────────────────┐ ┌──────────────────┐
|
|
||||||
│ SUPER USER │ │ NORMAL USER │
|
|
||||||
│ dev@igny8.com │ │ paid2@paid.com │
|
|
||||||
│ │ │ │
|
|
||||||
│ Role: developer │ │ Role: owner │
|
|
||||||
│ Superuser: True │ │ Superuser: False│
|
|
||||||
│ Account: │ │ Account: │
|
|
||||||
│ AWS Admin │ │ Paid 2 │
|
|
||||||
└──────────────────┘ └──────────────────┘
|
|
||||||
│ │
|
|
||||||
│ Can access & modify │ Cannot access
|
|
||||||
│ Integration Settings │ Integration Settings
|
|
||||||
│ │
|
|
||||||
↓ ↓
|
|
||||||
┌──────────────────┐ ┌──────────────────┐
|
|
||||||
│ FRONTEND: │ │ FRONTEND: │
|
|
||||||
│ Settings → │ │ (No access to │
|
|
||||||
│ Integration │ │ settings page) │
|
|
||||||
│ Page │ │ │
|
|
||||||
└──────────────────┘ └──────────────────┘
|
|
||||||
│
|
|
||||||
│ Uses AI functions
|
|
||||||
↓
|
|
||||||
┌──────────────────────┐
|
|
||||||
│ BACKEND: │
|
|
||||||
│ get_model_config() │
|
|
||||||
│ Falls back to │
|
|
||||||
│ aws-admin settings │
|
|
||||||
└──────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Current Database State
|
|
||||||
|
|
||||||
**AWS Admin Account Integration Settings:**
|
|
||||||
- **OpenAI**: Active, has API key, model: gpt-4o-mini
|
|
||||||
- **Runware**: Active, has API key
|
|
||||||
- **Image Generation**: Active, no API key (uses OpenAI/Runware settings)
|
|
||||||
|
|
||||||
**Normal User Accounts:**
|
|
||||||
- Have NO integration settings in database
|
|
||||||
- Use aws-admin settings via fallback
|
|
||||||
|
|
||||||
### 3. Permission Architecture
|
|
||||||
|
|
||||||
**IntegrationSettingsViewSet:**
|
|
||||||
```python
|
|
||||||
permission_classes = [
|
|
||||||
IsAuthenticatedAndActive, # Must be logged in and active
|
|
||||||
HasTenantAccess, # Must have account
|
|
||||||
IsSystemAccountOrDeveloper # Must be superuser/developer/system user
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Who Can Access:**
|
|
||||||
- ✅ dev@igny8.com (superuser=True, role=developer)
|
|
||||||
- ❌ paid2@paid.com (superuser=False, role=owner)
|
|
||||||
|
|
||||||
**Exception - task_progress endpoint:**
|
|
||||||
```python
|
|
||||||
@action(..., permission_classes=[IsAuthenticatedAndActive])
|
|
||||||
def task_progress(self, request, task_id=None):
|
|
||||||
```
|
|
||||||
- ✅ All authenticated users can check task progress
|
|
||||||
|
|
||||||
### 4. How Normal Users Use System API Keys
|
|
||||||
|
|
||||||
**Flow:**
|
|
||||||
1. Normal user calls AI function (auto_cluster, generate_ideas, etc.)
|
|
||||||
2. Backend calls `get_model_config(function_name, account)`
|
|
||||||
3. Function checks user's account for IntegrationSettings
|
|
||||||
4. **User account has no settings → Fallback triggered**
|
|
||||||
5. Checks system accounts in order: `aws-admin` → `default-account` → `default`
|
|
||||||
6. Uses aws-admin account's OpenAI settings
|
|
||||||
7. AI function executes with system API key
|
|
||||||
|
|
||||||
**Code Reference:** `backend/igny8_core/ai/settings.py` lines 54-72
|
|
||||||
|
|
||||||
### 5. Your Questions Answered
|
|
||||||
|
|
||||||
**Q: What is super user relation for integration config which are globally used?**
|
|
||||||
|
|
||||||
**A:** Super user (dev@igny8.com) belongs to AWS Admin account. Integration settings are stored per-account in the database:
|
|
||||||
- AWS Admin account has the integration settings
|
|
||||||
- Super user can access/modify these via Integration Settings page in frontend
|
|
||||||
- These settings are "globally used" because ALL normal users fall back to them
|
|
||||||
|
|
||||||
**Q: Do we have to use only backend Django admin to make changes?**
|
|
||||||
|
|
||||||
**A:** No! You have TWO options:
|
|
||||||
|
|
||||||
1. **Frontend Integration Settings Page** (Recommended)
|
|
||||||
- Login as dev@igny8.com
|
|
||||||
- Go to Settings → Integration
|
|
||||||
- Modify OpenAI/Runware settings
|
|
||||||
- Changes saved to IntegrationSettings model for aws-admin account
|
|
||||||
|
|
||||||
2. **Django Admin** (Alternative)
|
|
||||||
- Access: http://api.igny8.com/admin/
|
|
||||||
- Navigate to System → Integration Settings
|
|
||||||
- Find aws-admin account settings
|
|
||||||
- Modify directly
|
|
||||||
|
|
||||||
**Q: Can normal users access integration settings?**
|
|
||||||
|
|
||||||
**A:** Currently: **NO** (by design)
|
|
||||||
|
|
||||||
The permission class `IsSystemAccountOrDeveloper` blocks normal users from:
|
|
||||||
- Viewing integration settings page
|
|
||||||
- Modifying API keys
|
|
||||||
- Testing connections
|
|
||||||
|
|
||||||
Normal users transparently use system API keys via fallback.
|
|
||||||
|
|
||||||
### 6. Current Issues You Mentioned
|
|
||||||
|
|
||||||
**Issue 1: "Super user is now unable to change settings"**
|
|
||||||
|
|
||||||
**Status:** This should work! Let me verify:
|
|
||||||
- dev@igny8.com has is_superuser=True ✓
|
|
||||||
- dev@igny8.com has role=developer ✓
|
|
||||||
- Permission class allows superuser/developer ✓
|
|
||||||
|
|
||||||
**Possible causes if not working:**
|
|
||||||
- Frontend routing issue (Integration page not accessible)
|
|
||||||
- Permission check failing due to HasTenantAccess
|
|
||||||
- Frontend not sending proper Authorization header
|
|
||||||
|
|
||||||
**Issue 2: "There are some wrong configs in aws-admin integration services"**
|
|
||||||
|
|
||||||
**Current Config:**
|
|
||||||
```json
|
|
||||||
OpenAI:
|
|
||||||
- model: "gpt-4o-mini"
|
|
||||||
- active: true
|
|
||||||
- has API key: true
|
|
||||||
|
|
||||||
Runware:
|
|
||||||
- active: true
|
|
||||||
- has API key: true
|
|
||||||
|
|
||||||
Image Generation:
|
|
||||||
- active: true
|
|
||||||
- has API key: false ← WRONG! Should use OpenAI or Runware key
|
|
||||||
```
|
|
||||||
|
|
||||||
**Fix needed:** Image generation settings should reference OpenAI or Runware, not have its own API key field.
|
|
||||||
|
|
||||||
### 7. Recommendations
|
|
||||||
|
|
||||||
**Option A: Keep Current Architecture (Recommended)**
|
|
||||||
- Super user controls all API keys via AWS Admin account
|
|
||||||
- Normal users transparently use system keys
|
|
||||||
- Simple, centralized control
|
|
||||||
- **Action needed:**
|
|
||||||
1. Verify super user can access Integration Settings page
|
|
||||||
2. Fix image_generation config (remove apiKey field, ensure provider is set)
|
|
||||||
3. Test that normal users can use AI functions
|
|
||||||
|
|
||||||
**Option B: Allow Per-Account API Keys**
|
|
||||||
- Each account can configure their own API keys
|
|
||||||
- Fallback to system if not configured
|
|
||||||
- More complex, but gives users control
|
|
||||||
- **Action needed:**
|
|
||||||
1. Remove IsSystemAccountOrDeveloper from viewset
|
|
||||||
2. Add UI to show "using system defaults" vs "custom keys"
|
|
||||||
3. Update get_model_config to prefer user keys over system
|
|
||||||
|
|
||||||
**Option C: Hybrid Approach**
|
|
||||||
- Normal users can VIEW system settings (read-only)
|
|
||||||
- Only super users can MODIFY
|
|
||||||
- Allows transparency without risk
|
|
||||||
- **Action needed:**
|
|
||||||
1. Create separate permission for view vs modify
|
|
||||||
2. Update frontend to show read-only view for normal users
|
|
||||||
|
|
||||||
### 8. Verification Commands
|
|
||||||
|
|
||||||
**Check if super user can access Integration Settings:**
|
|
||||||
```bash
|
|
||||||
# Login as dev@igny8.com in frontend
|
|
||||||
# Navigate to Settings → Integration
|
|
||||||
# Should see OpenAI/Runware/Image Generation tabs
|
|
||||||
```
|
|
||||||
|
|
||||||
**Check integration settings in database:**
|
|
||||||
```bash
|
|
||||||
docker compose exec -T igny8_backend python manage.py shell <<'EOF'
|
|
||||||
from igny8_core.modules.system.models import IntegrationSettings
|
|
||||||
from igny8_core.auth.models import Account
|
|
||||||
|
|
||||||
aws = Account.objects.get(slug='aws-admin')
|
|
||||||
for s in IntegrationSettings.objects.filter(account=aws):
|
|
||||||
print(f"{s.integration_type}: active={s.is_active}, has_key={bool(s.config.get('apiKey'))}")
|
|
||||||
EOF
|
|
||||||
```
|
|
||||||
|
|
||||||
**Test normal user AI function access:**
|
|
||||||
```bash
|
|
||||||
# Login as paid2@paid.com
|
|
||||||
# Try auto_cluster on keywords
|
|
||||||
# Should work using aws-admin OpenAI key
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9. Next Steps
|
|
||||||
|
|
||||||
Based on your needs, tell me:
|
|
||||||
|
|
||||||
1. **Can dev@igny8.com access the Integration Settings page in the frontend?**
|
|
||||||
- If NO: We need to debug permission/routing issue
|
|
||||||
- If YES: Proceed to fix wrong configs
|
|
||||||
|
|
||||||
2. **What wrong configs need to be fixed in aws-admin integration?**
|
|
||||||
- Specific model versions?
|
|
||||||
- API key rotation?
|
|
||||||
- Image generation settings?
|
|
||||||
|
|
||||||
3. **Do you want to keep current architecture (super user only) or allow normal users to configure their own keys?**
|
|
||||||
|
|
||||||
I'm ready to help fix specific issues once you clarify the current state and desired behavior.
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
# CRITICAL MULTI-TENANCY FIXES - December 10, 2025
|
|
||||||
|
|
||||||
## PROBLEM SUMMARY
|
|
||||||
|
|
||||||
Users are unable to use business features (auto_cluster, automation, etc.) despite being authenticated. The error is **"Account is required"** or permission denied.
|
|
||||||
|
|
||||||
## ROOT CAUSE
|
|
||||||
|
|
||||||
The system has **structural** issues in the multi-tenancy implementation:
|
|
||||||
|
|
||||||
### 1. **User Model Allows NULL Accounts**
|
|
||||||
**File**: `backend/igny8_core/auth/models.py` (line 644)
|
|
||||||
```python
|
|
||||||
account = models.ForeignKey('igny8_core_auth.Account',
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
null=True, # ❌ WRONG
|
|
||||||
blank=True, # ❌ WRONG
|
|
||||||
...)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Problem**: Users can exist without accounts (orphaned users). When middleware sets `request.account` from `request.user.account`, it becomes `None` for orphaned users.
|
|
||||||
|
|
||||||
**Impact**: ALL business endpoints that check `request.account` fail.
|
|
||||||
|
|
||||||
### 2. **HasTenantAccess Permission Too Complex**
|
|
||||||
**File**: `backend/igny8_core/api/permissions.py` (lines 24-67)
|
|
||||||
|
|
||||||
**Problem**: The permission class had unnecessary fallback logic and unclear flow:
|
|
||||||
- Checked `request.account`
|
|
||||||
- Fell back to `request.user.account`
|
|
||||||
- Compared if they match
|
|
||||||
- Returned `False` on ANY exception
|
|
||||||
|
|
||||||
**Impact**: Added complexity made debugging hard. If user has no account, access is denied silently.
|
|
||||||
|
|
||||||
### 3. **Business Endpoints Explicitly Check `request.account`**
|
|
||||||
**File**: `backend/igny8_core/modules/planner/views.py` (line 633)
|
|
||||||
```python
|
|
||||||
account = getattr(request, 'account', None)
|
|
||||||
if not account:
|
|
||||||
return error_response(error='Account is required', ...)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Problem**: Direct dependency on `request.account`. If middleware fails to set it, feature breaks.
|
|
||||||
|
|
||||||
**Impact**: Auto-cluster and other AI functions fail with "Account is required".
|
|
||||||
|
|
||||||
## FIXES APPLIED
|
|
||||||
|
|
||||||
### ✅ Fix 1: Simplified HasTenantAccess Permission
|
|
||||||
**File**: `backend/igny8_core/api/permissions.py`
|
|
||||||
|
|
||||||
**Change**: Removed fallback logic. Made it clear: **authenticated users MUST have accounts**.
|
|
||||||
|
|
||||||
```python
|
|
||||||
# SIMPLIFIED LOGIC: Every authenticated user MUST have an account
|
|
||||||
# Middleware already set request.account from request.user.account
|
|
||||||
# Just verify it exists
|
|
||||||
if not hasattr(request.user, 'account'):
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
user_account = request.user.account
|
|
||||||
if not user_account:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
except (AttributeError, Exception):
|
|
||||||
return False
|
|
||||||
```
|
|
||||||
|
|
||||||
### ✅ Fix 2: Task Progress Permission
|
|
||||||
**File**: `backend/igny8_core/modules/system/integration_views.py` (line 900)
|
|
||||||
|
|
||||||
**Change**: Allowed any authenticated user to check task progress (not just system accounts).
|
|
||||||
|
|
||||||
```python
|
|
||||||
@action(..., permission_classes=[IsAuthenticatedAndActive])
|
|
||||||
def task_progress(self, request, task_id=None):
|
|
||||||
```
|
|
||||||
|
|
||||||
### ✅ Fix 3: AI Settings Fallback
|
|
||||||
**File**: `backend/igny8_core/ai/settings.py`
|
|
||||||
|
|
||||||
**Change**: Added fallback to system account (aws-admin) for OpenAI settings when user account doesn't have them configured.
|
|
||||||
|
|
||||||
### ✅ Fix 4: Error Response Parameter
|
|
||||||
**File**: `backend/igny8_core/modules/planner/views.py`
|
|
||||||
|
|
||||||
**Change**: Fixed `error_response()` call - changed invalid `extra_data` parameter to `debug_info`.
|
|
||||||
|
|
||||||
## REQUIRED ACTIONS
|
|
||||||
|
|
||||||
### 🔴 CRITICAL: Fix Orphaned Users
|
|
||||||
|
|
||||||
1. **Run the fix script**:
|
|
||||||
```bash
|
|
||||||
cd /data/app/igny8/backend
|
|
||||||
python3 fix_orphaned_users.py
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **The script will**:
|
|
||||||
- Find users with `account = NULL`
|
|
||||||
- Create accounts for them OR delete them
|
|
||||||
- Report results
|
|
||||||
|
|
||||||
### 🔴 CRITICAL: Make Account Field Required
|
|
||||||
|
|
||||||
After fixing orphaned users, update the User model:
|
|
||||||
|
|
||||||
**File**: `backend/igny8_core/auth/models.py` (line 644)
|
|
||||||
|
|
||||||
**Change**:
|
|
||||||
```python
|
|
||||||
# BEFORE
|
|
||||||
account = models.ForeignKey(..., null=True, blank=True, ...)
|
|
||||||
|
|
||||||
# AFTER
|
|
||||||
account = models.ForeignKey(..., null=False, blank=False, ...)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Then create and run migration**:
|
|
||||||
```bash
|
|
||||||
cd /data/app/igny8/backend
|
|
||||||
python3 manage.py makemigrations
|
|
||||||
python3 manage.py migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
## VERIFICATION
|
|
||||||
|
|
||||||
After fixes, verify:
|
|
||||||
|
|
||||||
1. **Check no orphaned users**:
|
|
||||||
```bash
|
|
||||||
cd /data/app/igny8/backend
|
|
||||||
python3 manage.py shell -c "
|
|
||||||
from igny8_core.auth.models import User
|
|
||||||
orphaned = User.objects.filter(account__isnull=True).count()
|
|
||||||
print(f'Orphaned users: {orphaned}')
|
|
||||||
"
|
|
||||||
```
|
|
||||||
Expected: `Orphaned users: 0`
|
|
||||||
|
|
||||||
2. **Test auto-cluster**:
|
|
||||||
- Login as normal user
|
|
||||||
- Select 5+ keywords
|
|
||||||
- Click "Auto Cluster"
|
|
||||||
- Should work without "Account is required" error
|
|
||||||
|
|
||||||
3. **Test task progress**:
|
|
||||||
- Start any AI function
|
|
||||||
- Progress modal should show real-time updates
|
|
||||||
- No "403 Forbidden" errors
|
|
||||||
|
|
||||||
## ARCHITECTURE PRINCIPLES ESTABLISHED
|
|
||||||
|
|
||||||
1. **NO NULL ACCOUNTS**: Every user MUST have an account. Period.
|
|
||||||
|
|
||||||
2. **NO FALLBACKS**: If `request.user.account` is None, it's a data integrity issue, not a code issue.
|
|
||||||
|
|
||||||
3. **CLEAR FLOW**:
|
|
||||||
- User registers → Account created → User.account set
|
|
||||||
- User logs in → Middleware sets request.account from user.account
|
|
||||||
- Permission checks → Verify request.user.account exists
|
|
||||||
- Business logic → Use request.account directly
|
|
||||||
|
|
||||||
4. **FAIL FAST**: Don't hide errors with fallbacks. If account is missing, raise error.
|
|
||||||
|
|
||||||
## FILES MODIFIED
|
|
||||||
|
|
||||||
1. `backend/igny8_core/api/permissions.py` - Simplified HasTenantAccess
|
|
||||||
2. `backend/igny8_core/modules/system/integration_views.py` - Fixed task_progress permission
|
|
||||||
3. `backend/igny8_core/ai/settings.py` - Added system account fallback for AI settings
|
|
||||||
4. `backend/igny8_core/modules/planner/views.py` - Fixed error_response call
|
|
||||||
|
|
||||||
## FILES CREATED
|
|
||||||
|
|
||||||
1. `backend/fix_orphaned_users.py` - Script to fix orphaned users
|
|
||||||
2. `MULTI-TENANCY-FIXES-DEC-2025.md` - This document
|
|
||||||
|
|
||||||
## NEXT STEPS
|
|
||||||
|
|
||||||
1. ✅ Run orphaned users fix script
|
|
||||||
2. ✅ Make User.account field required (migration)
|
|
||||||
3. ✅ Test all business features
|
|
||||||
4. ✅ Update documentation to reflect "no fallbacks" principle
|
|
||||||
5. ✅ Add database constraints to prevent orphaned users
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Date**: December 10, 2025
|
|
||||||
**Status**: FIXES APPLIED - VERIFICATION PENDING
|
|
||||||
**Priority**: CRITICAL
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
# Router Hook Error - ROOT CAUSE ANALYSIS (SOLVED)
|
|
||||||
|
|
||||||
## Date: December 10, 2025
|
|
||||||
|
|
||||||
## The Errors (FIXED)
|
|
||||||
|
|
||||||
- `/automation` → `useNavigate() may be used only in the context of a <Router> component.` ✅ FIXED
|
|
||||||
- `/planner/keywords` → `useLocation() may be used only in the context of a <Router> component.` ✅ FIXED
|
|
||||||
- `/planner/clusters` → `useLocation() may be used only in the context of a <Router> component.` ✅ FIXED
|
|
||||||
|
|
||||||
## ROOT CAUSE: Vite Bundling Multiple React Router Chunks
|
|
||||||
|
|
||||||
### The Real Problem
|
|
||||||
|
|
||||||
Components imported Router hooks from **TWO different packages**:
|
|
||||||
- Some used `import { useLocation } from 'react-router'`
|
|
||||||
- Some used `import { useLocation } from 'react-router-dom'`
|
|
||||||
- main.tsx used `import { BrowserRouter } from 'react-router-dom'`
|
|
||||||
|
|
||||||
Even though npm showed both packages as v7.9.5 and "deduped", **Vite bundled them into SEPARATE chunks**:
|
|
||||||
|
|
||||||
```
|
|
||||||
chunk-JWK5IZBO.js ← Contains 'react-router' code
|
|
||||||
chunk-U2AIREZK.js ← Contains 'react-router-dom' code
|
|
||||||
```
|
|
||||||
|
|
||||||
### Why This Caused the Error
|
|
||||||
|
|
||||||
1. **BrowserRouter** from `'react-router-dom'` (chunk-U2AIREZK.js) creates a Router context
|
|
||||||
2. **useLocation()** from `'react-router'` (chunk-JWK5IZBO.js) tries to read Router context
|
|
||||||
3. **Different chunks = Different module instances = Different React contexts**
|
|
||||||
4. The context from chunk-U2AIREZK is NOT accessible to hooks in chunk-JWK5IZBO
|
|
||||||
5. Hook can't find context → Error: "useLocation() may be used only in the context of a <Router> component"
|
|
||||||
|
|
||||||
### Evidence from Error Stack
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
ErrorBoundary caught an error: Error: useLocation() may be used only in the context of a <Router> component.
|
|
||||||
at useLocation (chunk-JWK5IZBO.js?v=f560299f:5648:3) ← 'react-router' chunk
|
|
||||||
at TablePageTemplate (TablePageTemplate.tsx:182:20)
|
|
||||||
...
|
|
||||||
at BrowserRouter (chunk-U2AIREZK.js?v=f560299f:9755:3) ← 'react-router-dom' chunk
|
|
||||||
```
|
|
||||||
|
|
||||||
**Two different chunks = Context mismatch!**
|
|
||||||
|
|
||||||
### Component Stack Analysis
|
|
||||||
|
|
||||||
The error showed BrowserRouter WAS in the component tree:
|
|
||||||
|
|
||||||
```
|
|
||||||
TablePageTemplate (useLocation ERROR)
|
|
||||||
↓ Clusters
|
|
||||||
↓ ModuleGuard
|
|
||||||
↓ Routes
|
|
||||||
↓ App
|
|
||||||
↓ BrowserRouter ← Context provided HERE
|
|
||||||
```
|
|
||||||
|
|
||||||
Router context was available, but the hook was looking in the WRONG chunk's context.
|
|
||||||
|
|
||||||
## The Fix Applied
|
|
||||||
|
|
||||||
### 1. Changed ALL imports to use 'react-router-dom'
|
|
||||||
|
|
||||||
**Files updated (16 files):**
|
|
||||||
- `src/templates/TablePageTemplate.tsx`
|
|
||||||
- `src/templates/ContentViewTemplate.tsx`
|
|
||||||
- `src/components/navigation/ModuleNavigationTabs.tsx`
|
|
||||||
- `src/components/common/DebugSiteSelector.tsx`
|
|
||||||
- `src/components/common/SiteSelector.tsx`
|
|
||||||
- `src/components/common/SiteAndSectorSelector.tsx`
|
|
||||||
- `src/components/common/PageTransition.tsx`
|
|
||||||
- `src/pages/Linker/ContentList.tsx`
|
|
||||||
- `src/pages/Linker/Dashboard.tsx`
|
|
||||||
- `src/pages/Writer/ContentView.tsx`
|
|
||||||
- `src/pages/Writer/Content.tsx`
|
|
||||||
- `src/pages/Writer/Published.tsx`
|
|
||||||
- `src/pages/Writer/Review.tsx`
|
|
||||||
- `src/pages/Optimizer/ContentSelector.tsx`
|
|
||||||
- `src/pages/Optimizer/AnalysisPreview.tsx`
|
|
||||||
- `src/pages/Optimizer/Dashboard.tsx`
|
|
||||||
|
|
||||||
**Changed:**
|
|
||||||
```tsx
|
|
||||||
// BEFORE
|
|
||||||
import { useLocation } from 'react-router';
|
|
||||||
import { useNavigate } from 'react-router';
|
|
||||||
|
|
||||||
// AFTER
|
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Removed 'react-router' from package.json
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"dependencies": {
|
|
||||||
"react-router": "^7.1.5",
|
|
||||||
"react-router-dom": "^7.9.5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"dependencies": {
|
|
||||||
"react-router-dom": "^7.9.5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Result
|
|
||||||
|
|
||||||
Now Vite bundles ALL Router code into a SINGLE chunk, ensuring Router context is shared across all components.
|
|
||||||
|
|
||||||
## Why Container Rebuild "Fixed" It Temporarily
|
|
||||||
|
|
||||||
When you rebuild containers, sometimes Vite's chunk splitting algorithm temporarily bundles both packages together, making the error disappear. But on the next HMR or rebuild, it splits them again → error returns.
|
|
||||||
|
|
||||||
## Testing Instructions
|
|
||||||
|
|
||||||
After these changes, test by visiting:
|
|
||||||
- https://app.igny8.com/planner/keywords → Should have NO errors
|
|
||||||
- https://app.igny8.com/planner/clusters → Should have NO errors
|
|
||||||
- https://app.igny8.com/automation → Should have NO errors
|
|
||||||
- https://app.igny8.com/account/plans → Should still have NO errors
|
|
||||||
|
|
||||||
Check browser console for zero Router-related errors.
|
|
||||||
|
|
||||||
## Key Learnings
|
|
||||||
|
|
||||||
1. **npm deduplication ≠ Vite bundling** - Even if npm shows packages as "deduped", Vite may still create separate chunks
|
|
||||||
2. **Module bundler matters** - The error wasn't in React or React Router, it was in how Vite split the code
|
|
||||||
3. **Import source determines chunk** - Importing from different packages creates different chunks with separate module instances
|
|
||||||
4. **React Context is per-module-instance** - Contexts don't cross chunk boundaries
|
|
||||||
5. **Consistency is critical** - ALL imports must use the SAME package to ensure single chunk
|
|
||||||
6. **Component stack traces reveal bundling** - Looking at chunk file names in errors shows the real problem
|
|
||||||
|
|
||||||
## Solution: Use ONE Package Consistently
|
|
||||||
|
|
||||||
For React Router v7 in Vite projects:
|
|
||||||
- ✅ Use `'react-router-dom'` exclusively
|
|
||||||
- ❌ Never mix `'react-router'` and `'react-router-dom'` imports
|
|
||||||
- ✅ Remove unused router packages from package.json
|
|
||||||
- ✅ Verify with: `grep -r "from 'react-router'" src/` (should return nothing)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Status: ✅ RESOLVED
|
|
||||||
|
|
||||||
All imports standardized to `'react-router-dom'`. Error should no longer occur after HMR, container restarts, or cache clears.
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Script to identify and fix orphaned users (users without accounts).
|
|
||||||
|
|
||||||
This script will:
|
|
||||||
1. Find all users with account = NULL
|
|
||||||
2. For each user, either:
|
|
||||||
- Assign them to an existing account if possible
|
|
||||||
- Create a new account for them
|
|
||||||
- Delete them if they're test/invalid users
|
|
||||||
3. Report the results
|
|
||||||
|
|
||||||
Run this from backend directory:
|
|
||||||
python3 fix_orphaned_users.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import django
|
|
||||||
|
|
||||||
# Setup Django
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings')
|
|
||||||
django.setup()
|
|
||||||
|
|
||||||
from igny8_core.auth.models import User, Account, Plan
|
|
||||||
from django.db import transaction
|
|
||||||
|
|
||||||
def main():
|
|
||||||
print("=" * 80)
|
|
||||||
print("ORPHANED USERS FIX SCRIPT")
|
|
||||||
print("=" * 80)
|
|
||||||
|
|
||||||
# Find users without accounts
|
|
||||||
orphaned_users = User.objects.filter(account__isnull=True)
|
|
||||||
count = orphaned_users.count()
|
|
||||||
|
|
||||||
print(f"\nFound {count} user(s) without accounts:\n")
|
|
||||||
|
|
||||||
if count == 0:
|
|
||||||
print("✅ No orphaned users found. System is healthy!")
|
|
||||||
return
|
|
||||||
|
|
||||||
# List them
|
|
||||||
for i, user in enumerate(orphaned_users, 1):
|
|
||||||
print(f"{i}. ID: {user.id}")
|
|
||||||
print(f" Email: {user.email}")
|
|
||||||
print(f" Username: {user.username}")
|
|
||||||
print(f" Role: {user.role}")
|
|
||||||
print(f" Active: {user.is_active}")
|
|
||||||
print(f" Superuser: {user.is_superuser}")
|
|
||||||
print(f" Created: {user.created_at}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Ask what to do
|
|
||||||
print("\nOptions:")
|
|
||||||
print("1. Auto-fix: Create accounts for all orphaned users")
|
|
||||||
print("2. Delete all orphaned users")
|
|
||||||
print("3. Exit without changes")
|
|
||||||
|
|
||||||
choice = input("\nEnter choice (1-3): ").strip()
|
|
||||||
|
|
||||||
if choice == '1':
|
|
||||||
auto_fix_users(orphaned_users)
|
|
||||||
elif choice == '2':
|
|
||||||
delete_users(orphaned_users)
|
|
||||||
else:
|
|
||||||
print("\n❌ No changes made. Exiting.")
|
|
||||||
|
|
||||||
def auto_fix_users(users):
|
|
||||||
"""Create accounts for orphaned users"""
|
|
||||||
print("\n" + "=" * 80)
|
|
||||||
print("AUTO-FIXING ORPHANED USERS")
|
|
||||||
print("=" * 80 + "\n")
|
|
||||||
|
|
||||||
# Get or create free plan
|
|
||||||
try:
|
|
||||||
free_plan = Plan.objects.get(slug='free', is_active=True)
|
|
||||||
except Plan.DoesNotExist:
|
|
||||||
print("❌ ERROR: Free plan not found. Cannot create accounts.")
|
|
||||||
print(" Please create a 'free' plan first or assign users manually.")
|
|
||||||
return
|
|
||||||
|
|
||||||
fixed_count = 0
|
|
||||||
|
|
||||||
with transaction.atomic():
|
|
||||||
for user in users:
|
|
||||||
try:
|
|
||||||
# Generate account name
|
|
||||||
if user.first_name or user.last_name:
|
|
||||||
account_name = f"{user.first_name} {user.last_name}".strip()
|
|
||||||
else:
|
|
||||||
account_name = user.email.split('@')[0]
|
|
||||||
|
|
||||||
# Generate unique slug
|
|
||||||
base_slug = account_name.lower().replace(' ', '-').replace('_', '-')[:50] or 'account'
|
|
||||||
slug = base_slug
|
|
||||||
counter = 1
|
|
||||||
while Account.objects.filter(slug=slug).exists():
|
|
||||||
slug = f"{base_slug}-{counter}"
|
|
||||||
counter += 1
|
|
||||||
|
|
||||||
# Create account
|
|
||||||
account = Account.objects.create(
|
|
||||||
name=account_name,
|
|
||||||
slug=slug,
|
|
||||||
owner=user,
|
|
||||||
plan=free_plan,
|
|
||||||
credits=free_plan.get_effective_credits_per_month(),
|
|
||||||
status='trial',
|
|
||||||
billing_email=user.email,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Assign account to user
|
|
||||||
user.account = account
|
|
||||||
user.save()
|
|
||||||
|
|
||||||
print(f"✅ Fixed user: {user.email}")
|
|
||||||
print(f" Created account: {account.name} (ID: {account.id})")
|
|
||||||
print(f" Credits: {account.credits}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
fixed_count += 1
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ ERROR fixing user {user.email}: {e}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
print("=" * 80)
|
|
||||||
print(f"✅ Successfully fixed {fixed_count} user(s)")
|
|
||||||
print("=" * 80)
|
|
||||||
|
|
||||||
def delete_users(users):
|
|
||||||
"""Delete orphaned users"""
|
|
||||||
print("\n⚠️ WARNING: This will permanently delete the selected users!")
|
|
||||||
confirm = input("Type 'DELETE' to confirm: ").strip()
|
|
||||||
|
|
||||||
if confirm != 'DELETE':
|
|
||||||
print("\n❌ Deletion cancelled.")
|
|
||||||
return
|
|
||||||
|
|
||||||
count = users.count()
|
|
||||||
users.delete()
|
|
||||||
|
|
||||||
print(f"\n✅ Deleted {count} user(s)")
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
3747
frontend/package-lock.json
generated
3747
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
1408
integration_views.py
1408
integration_views.py
File diff suppressed because it is too large
Load Diff
@@ -1,17 +0,0 @@
|
|||||||
# Sites Renderer Dev Image (Node 22 to satisfy Vite requirements)
|
|
||||||
FROM node:22-alpine
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy package manifests first for better caching
|
|
||||||
COPY package*.json ./
|
|
||||||
|
|
||||||
RUN npm install --legacy-peer-deps
|
|
||||||
|
|
||||||
# Copy source (still bind-mounted at runtime, but needed for initial run)
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
EXPOSE 5176
|
|
||||||
|
|
||||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5176"]
|
|
||||||
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import js from '@eslint/js'
|
|
||||||
import globals from 'globals'
|
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
||||||
import tseslint from 'typescript-eslint'
|
|
||||||
|
|
||||||
export default tseslint.config(
|
|
||||||
{ ignores: ['dist'] },
|
|
||||||
{
|
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
languageOptions: {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
globals: globals.browser,
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
'react-hooks': reactHooks,
|
|
||||||
'react-refresh': reactRefresh,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
...reactHooks.configs.recommended.rules,
|
|
||||||
'react-refresh/only-export-components': [
|
|
||||||
'warn',
|
|
||||||
{ allowConstantExport: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>IGNY8 Sites</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
3816
sites/package-lock.json
generated
3816
sites/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,45 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "igny8-sites",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "tsc -b && vite build",
|
|
||||||
"lint": "eslint .",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"test": "vitest",
|
|
||||||
"test:ui": "vitest --ui",
|
|
||||||
"test:coverage": "vitest --coverage"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"axios": "^1.13.2",
|
|
||||||
"lucide-react": "^0.554.0",
|
|
||||||
"react": "^19.2.0",
|
|
||||||
"react-dom": "^19.2.0",
|
|
||||||
"react-hook-form": "^7.66.0",
|
|
||||||
"react-router-dom": "^7.9.6",
|
|
||||||
"zustand": "^5.0.8"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/js": "^9.39.1",
|
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
|
||||||
"@testing-library/react": "^16.2.0",
|
|
||||||
"@testing-library/user-event": "^14.5.1",
|
|
||||||
"@types/node": "^24.10.0",
|
|
||||||
"@types/react": "^19.2.2",
|
|
||||||
"@types/react-dom": "^19.2.2",
|
|
||||||
"@types/react-router-dom": "^5.3.3",
|
|
||||||
"@vitejs/plugin-react": "^5.1.0",
|
|
||||||
"@vitest/ui": "^1.0.4",
|
|
||||||
"eslint": "^9.39.1",
|
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
|
||||||
"globals": "^16.5.0",
|
|
||||||
"jsdom": "^25.0.1",
|
|
||||||
"typescript": "~5.9.3",
|
|
||||||
"typescript-eslint": "^8.46.3",
|
|
||||||
"vite": "^7.2.2",
|
|
||||||
"vitest": "^2.1.5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.666 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,53 +0,0 @@
|
|||||||
import { lazy, Suspense } from 'react';
|
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
|
||||||
import ProtectedRoute from './shared/ProtectedRoute';
|
|
||||||
import BuilderLayout from './builder/components/layout/BuilderLayout';
|
|
||||||
|
|
||||||
// Lazy load builder pages (code-split to avoid loading in public sites)
|
|
||||||
const WizardPage = lazy(() => import('./builder/pages/wizard/WizardPage'));
|
|
||||||
const PreviewCanvas = lazy(() => import('./builder/pages/preview/PreviewCanvas'));
|
|
||||||
const SiteDashboard = lazy(() => import('./builder/pages/dashboard/SiteDashboard'));
|
|
||||||
|
|
||||||
// Renderer pages (load immediately for public sites)
|
|
||||||
const SiteRenderer = lazy(() => import('./pages/SiteRenderer'));
|
|
||||||
|
|
||||||
// Loading component
|
|
||||||
const LoadingFallback = () => (
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
|
||||||
<div>Loading...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<BrowserRouter>
|
|
||||||
<Suspense fallback={<LoadingFallback />}>
|
|
||||||
<Routes>
|
|
||||||
{/* Public Site Renderer Routes (No Auth) */}
|
|
||||||
<Route path="/:siteSlug/:pageSlug?" element={<SiteRenderer />} />
|
|
||||||
<Route path="/:siteSlug" element={<SiteRenderer />} />
|
|
||||||
<Route path="/" element={<div>IGNY8 Sites Renderer</div>} />
|
|
||||||
|
|
||||||
{/* Builder Routes (Auth Required) */}
|
|
||||||
<Route
|
|
||||||
path="/builder/*"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<BuilderLayout>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<WizardPage />} />
|
|
||||||
<Route path="preview" element={<PreviewCanvas />} />
|
|
||||||
<Route path="dashboard" element={<SiteDashboard />} />
|
|
||||||
<Route path="*" element={<Navigate to="/builder" replace />} />
|
|
||||||
</Routes>
|
|
||||||
</BuilderLayout>
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Routes>
|
|
||||||
</Suspense>
|
|
||||||
</BrowserRouter>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for File Access
|
|
||||||
* Phase 5: Sites Renderer & Bulk Generation
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { getFileUrl, getImageUrl } from '../utils/fileAccess';
|
|
||||||
|
|
||||||
// Mock fetch
|
|
||||||
global.fetch = vi.fn();
|
|
||||||
|
|
||||||
describe('File Access', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loads images correctly', async () => {
|
|
||||||
// Test: File access works (images, documents, media)
|
|
||||||
const mockImageUrl = 'https://example.com/images/test.jpg';
|
|
||||||
|
|
||||||
(global.fetch as any).mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
url: mockImageUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
const url = await getImageUrl(1, 'test.jpg', 1);
|
|
||||||
expect(url).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loads documents correctly', async () => {
|
|
||||||
// Test: File access works (images, documents, media)
|
|
||||||
const mockDocUrl = 'https://example.com/documents/test.pdf';
|
|
||||||
|
|
||||||
(global.fetch as any).mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
url: mockDocUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
const url = await getFileUrl(1, 'documents', 'test.pdf', 1);
|
|
||||||
expect(url).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loads media files correctly', async () => {
|
|
||||||
// Test: File access works (images, documents, media)
|
|
||||||
const mockMediaUrl = 'https://example.com/media/test.mp4';
|
|
||||||
|
|
||||||
(global.fetch as any).mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
url: mockMediaUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
const url = await getFileUrl(1, 'media', 'test.mp4', 1);
|
|
||||||
expect(url).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for Layout Renderer
|
|
||||||
* Phase 5: Sites Renderer & Bulk Generation
|
|
||||||
*/
|
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { renderLayout } from '../utils/layoutRenderer';
|
|
||||||
|
|
||||||
describe('Layout Renderer', () => {
|
|
||||||
it('renders default layout correctly', () => {
|
|
||||||
// Test: Multiple layouts work correctly
|
|
||||||
const siteDefinition = {
|
|
||||||
layout: 'default',
|
|
||||||
pages: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = renderLayout(siteDefinition);
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders minimal layout correctly', () => {
|
|
||||||
// Test: Multiple layouts work correctly
|
|
||||||
const siteDefinition = {
|
|
||||||
layout: 'minimal',
|
|
||||||
pages: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = renderLayout(siteDefinition);
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders magazine layout correctly', () => {
|
|
||||||
// Test: Multiple layouts work correctly
|
|
||||||
const siteDefinition = {
|
|
||||||
layout: 'magazine',
|
|
||||||
pages: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = renderLayout(siteDefinition);
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders ecommerce layout correctly', () => {
|
|
||||||
// Test: Multiple layouts work correctly
|
|
||||||
const siteDefinition = {
|
|
||||||
layout: 'ecommerce',
|
|
||||||
pages: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = renderLayout(siteDefinition);
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders portfolio layout correctly', () => {
|
|
||||||
// Test: Multiple layouts work correctly
|
|
||||||
const siteDefinition = {
|
|
||||||
layout: 'portfolio',
|
|
||||||
pages: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = renderLayout(siteDefinition);
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders blog layout correctly', () => {
|
|
||||||
// Test: Multiple layouts work correctly
|
|
||||||
const siteDefinition = {
|
|
||||||
layout: 'blog',
|
|
||||||
pages: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = renderLayout(siteDefinition);
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders corporate layout correctly', () => {
|
|
||||||
// Test: Multiple layouts work correctly
|
|
||||||
const siteDefinition = {
|
|
||||||
layout: 'corporate',
|
|
||||||
pages: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = renderLayout(siteDefinition);
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for Site Definition Loader
|
|
||||||
* Phase 5: Sites Renderer & Bulk Generation
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { loadSiteDefinition } from '../loaders/loadSiteDefinition';
|
|
||||||
|
|
||||||
// Mock fetch
|
|
||||||
global.fetch = vi.fn();
|
|
||||||
|
|
||||||
describe('Site Definition Loader', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loads site definitions from API', async () => {
|
|
||||||
// Test: Sites renderer loads site definitions
|
|
||||||
const mockSiteDefinition = {
|
|
||||||
id: 1,
|
|
||||||
name: 'Test Site',
|
|
||||||
pages: [
|
|
||||||
{ id: 1, slug: 'home', title: 'Home' },
|
|
||||||
{ id: 2, slug: 'about', title: 'About' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
(global.fetch as any).mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ data: mockSiteDefinition }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await loadSiteDefinition(1);
|
|
||||||
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
expect(result.name).toBe('Test Site');
|
|
||||||
expect(result.pages).toHaveLength(2);
|
|
||||||
expect(global.fetch).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('/api/v1/site-builder/blueprints/1/')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles API errors gracefully', async () => {
|
|
||||||
// Test: Sites renderer loads site definitions
|
|
||||||
(global.fetch as any).mockRejectedValueOnce(new Error('API Error'));
|
|
||||||
|
|
||||||
await expect(loadSiteDefinition(1)).rejects.toThrow('API Error');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* Test Setup
|
|
||||||
* Phase 5: Sites Renderer Tests
|
|
||||||
*/
|
|
||||||
import { expect, afterEach } from 'vitest';
|
|
||||||
import { cleanup } from '@testing-library/react';
|
|
||||||
import '@testing-library/jest-dom';
|
|
||||||
|
|
||||||
// Cleanup after each test
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock window.matchMedia
|
|
||||||
Object.defineProperty(window, 'matchMedia', {
|
|
||||||
writable: true,
|
|
||||||
value: (query: string) => ({
|
|
||||||
matches: false,
|
|
||||||
media: query,
|
|
||||||
onchange: null,
|
|
||||||
addListener: () => {},
|
|
||||||
removeListener: () => {},
|
|
||||||
addEventListener: () => {},
|
|
||||||
removeEventListener: () => {},
|
|
||||||
dispatchEvent: () => {},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface BuilderLayoutProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BuilderLayout - Layout wrapper for Site Builder pages
|
|
||||||
* Provides consistent layout for builder routes
|
|
||||||
*/
|
|
||||||
export default function BuilderLayout({ children }: BuilderLayoutProps) {
|
|
||||||
return (
|
|
||||||
<div className="builder-layout" style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
|
||||||
<header style={{ padding: '1rem', borderBottom: '1px solid #e5e7eb', backgroundColor: '#fff' }}>
|
|
||||||
<h1 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 600 }}>IGNY8 Site Builder</h1>
|
|
||||||
</header>
|
|
||||||
<main style={{ flex: 1, padding: '2rem' }}>
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/**
|
|
||||||
* SiteDashboard - Site Builder Dashboard
|
|
||||||
* Placeholder component for builder dashboard functionality
|
|
||||||
*/
|
|
||||||
export default function SiteDashboard() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2>Site Builder Dashboard</h2>
|
|
||||||
<p>This is a placeholder for the Site Builder Dashboard.</p>
|
|
||||||
<p>The builder functionality can be accessed from the main app at <code>/sites/builder/dashboard</code></p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/**
|
|
||||||
* PreviewCanvas - Site Builder Preview
|
|
||||||
* Placeholder component for builder preview functionality
|
|
||||||
*/
|
|
||||||
export default function PreviewCanvas() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2>Site Builder Preview</h2>
|
|
||||||
<p>This is a placeholder for the Site Builder Preview.</p>
|
|
||||||
<p>The builder functionality can be accessed from the main app at <code>/sites/builder/preview</code></p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/**
|
|
||||||
* WizardPage - Site Builder Wizard
|
|
||||||
* Placeholder component for builder wizard functionality
|
|
||||||
*/
|
|
||||||
export default function WizardPage() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2>Site Builder Wizard</h2>
|
|
||||||
<p>This is a placeholder for the Site Builder Wizard.</p>
|
|
||||||
<p>The builder functionality can be accessed from the main app at <code>/sites/builder</code></p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
/* Import shared component styles */
|
|
||||||
/* Note: Using relative path since @shared alias may not work in CSS */
|
|
||||||
/* These will be imported via JavaScript instead */
|
|
||||||
|
|
||||||
:root {
|
|
||||||
font-family: 'Inter', 'Inter var', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
color: #0f172a;
|
|
||||||
background-color: #f5f7fb;
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
background: #f5f7fb;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
/**
|
|
||||||
* Site Definition Loader
|
|
||||||
* Phase 5: Sites Renderer & Publishing
|
|
||||||
*
|
|
||||||
* Loads site definitions from the filesystem or API.
|
|
||||||
*/
|
|
||||||
import axios from 'axios';
|
|
||||||
import type { SiteDefinition } from '../types';
|
|
||||||
|
|
||||||
const SITES_DATA_PATH = import.meta.env.SITES_DATA_PATH || '/sites';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get API base URL - auto-detect based on current origin
|
|
||||||
*/
|
|
||||||
function getApiBaseUrl(): string {
|
|
||||||
// First check environment variables
|
|
||||||
const envUrl = import.meta.env.VITE_API_URL;
|
|
||||||
if (envUrl) {
|
|
||||||
return envUrl.endsWith('/api') ? envUrl : `${envUrl}/api`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-detect based on current origin (browser only)
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const origin = window.location.origin;
|
|
||||||
|
|
||||||
// If accessing via IP address, use direct backend port
|
|
||||||
if (/^\d+\.\d+\.\d+\.\d+/.test(origin) || origin.includes('localhost') || origin.includes('127.0.0.1')) {
|
|
||||||
// Backend is on port 8011 (external) when accessed via IP
|
|
||||||
if (origin.includes(':8024')) {
|
|
||||||
return origin.replace(':8024', ':8011') + '/api';
|
|
||||||
}
|
|
||||||
// Default: try port 8011
|
|
||||||
return origin.split(':')[0] + ':8011/api';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Production: use subdomain
|
|
||||||
return 'https://api.igny8.com/api';
|
|
||||||
}
|
|
||||||
|
|
||||||
const API_URL = getApiBaseUrl();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve site slug to site ID.
|
|
||||||
* Queries the Site API to get the site ID from the slug.
|
|
||||||
*/
|
|
||||||
async function resolveSiteIdFromSlug(siteSlug: string): Promise<number> {
|
|
||||||
try {
|
|
||||||
// Query sites by slug - slug is unique per account, but we need to search across all accounts for public sites
|
|
||||||
const response = await axios.get(`${API_URL}/v1/auth/sites/`, {
|
|
||||||
params: { slug: siteSlug },
|
|
||||||
timeout: 10000,
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const sites = Array.isArray(response.data?.results) ? response.data.results :
|
|
||||||
Array.isArray(response.data) ? response.data : [];
|
|
||||||
|
|
||||||
if (sites.length > 0) {
|
|
||||||
return sites[0].id;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Site with slug "${siteSlug}" not found`);
|
|
||||||
} catch (error) {
|
|
||||||
if (axios.isAxiosError(error)) {
|
|
||||||
if (error.response?.status === 404) {
|
|
||||||
throw new Error(`Site with slug "${siteSlug}" not found`);
|
|
||||||
}
|
|
||||||
throw new Error(`Failed to resolve site slug: ${error.message}`);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load site definition by site slug.
|
|
||||||
* First resolves slug to ID, then tries to load from filesystem (deployed sites),
|
|
||||||
* then falls back to API.
|
|
||||||
*/
|
|
||||||
export async function loadSiteDefinition(siteSlug: string): Promise<SiteDefinition> {
|
|
||||||
// First, resolve slug to site ID
|
|
||||||
let siteId: number;
|
|
||||||
try {
|
|
||||||
siteId = await resolveSiteIdFromSlug(siteSlug);
|
|
||||||
} catch (error) {
|
|
||||||
throw error; // Re-throw slug resolution errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try API endpoint for deployed site definition first
|
|
||||||
try {
|
|
||||||
const response = await axios.get(`${API_URL}/v1/publisher/sites/${siteId}/definition/`, {
|
|
||||||
timeout: 10000, // 10 second timeout
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (response.data) {
|
|
||||||
return response.data as SiteDefinition;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// API load failed, try blueprint endpoint as fallback
|
|
||||||
console.error('Failed to load deployed site definition:', error);
|
|
||||||
if (axios.isAxiosError(error)) {
|
|
||||||
console.error('Error details:', {
|
|
||||||
message: error.message,
|
|
||||||
code: error.code,
|
|
||||||
response: error.response?.status,
|
|
||||||
url: error.config?.url,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to blueprint API (for non-deployed sites)
|
|
||||||
try {
|
|
||||||
const response = await axios.get(`${API_URL}/v1/site-builder/blueprints/?site=${siteId}`);
|
|
||||||
const blueprints = Array.isArray(response.data?.results) ? response.data.results :
|
|
||||||
Array.isArray(response.data) ? response.data : [];
|
|
||||||
|
|
||||||
if (blueprints.length > 0) {
|
|
||||||
const blueprint = blueprints[0]; // Get latest blueprint
|
|
||||||
// Transform blueprint to site definition format
|
|
||||||
return transformBlueprintToSiteDefinition(blueprint);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`No blueprint found for site ${siteSlug}`);
|
|
||||||
} catch (error) {
|
|
||||||
if (axios.isAxiosError(error)) {
|
|
||||||
throw new Error(`Failed to load site: ${error.message}`);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transform SiteBlueprint to SiteDefinition format.
|
|
||||||
*/
|
|
||||||
function transformBlueprintToSiteDefinition(blueprint: any): SiteDefinition {
|
|
||||||
return {
|
|
||||||
id: blueprint.id,
|
|
||||||
name: blueprint.name,
|
|
||||||
description: blueprint.description,
|
|
||||||
version: blueprint.version,
|
|
||||||
layout: blueprint.structure_json?.layout || 'default',
|
|
||||||
theme: blueprint.structure_json?.theme || {},
|
|
||||||
navigation: blueprint.structure_json?.navigation || [],
|
|
||||||
pages: blueprint.pages?.map((page: any) => ({
|
|
||||||
id: page.id,
|
|
||||||
slug: page.slug,
|
|
||||||
title: page.title,
|
|
||||||
type: page.type,
|
|
||||||
blocks: page.blocks_json || [],
|
|
||||||
status: page.status,
|
|
||||||
order: page.order || 0,
|
|
||||||
})) || [],
|
|
||||||
config: blueprint.config_json || {},
|
|
||||||
created_at: blueprint.created_at,
|
|
||||||
updated_at: blueprint.updated_at,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load site definition from a specific version.
|
|
||||||
*/
|
|
||||||
export async function loadSiteDefinitionByVersion(
|
|
||||||
siteId: string,
|
|
||||||
version: number
|
|
||||||
): Promise<SiteDefinition> {
|
|
||||||
try {
|
|
||||||
const fsPath = `${SITES_DATA_PATH}/clients/${siteId}/v${version}/site.json`;
|
|
||||||
const response = await fetch(fsPath);
|
|
||||||
if (response.ok) {
|
|
||||||
const definition = await response.json();
|
|
||||||
return definition;
|
|
||||||
}
|
|
||||||
throw new Error(`Version ${version} not found`);
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to load site version ${version}: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { StrictMode } from 'react';
|
|
||||||
import { createRoot } from 'react-dom/client';
|
|
||||||
import App from './App.tsx';
|
|
||||||
import './index.css';
|
|
||||||
|
|
||||||
// Note: Shared component CSS imports removed - CSS files not accessible in container
|
|
||||||
// Components will work without shared CSS, just without the shared styles
|
|
||||||
// To add shared styles later, either:
|
|
||||||
// 1. Copy CSS files to sites/src/styles/shared/
|
|
||||||
// 2. Or mount frontend directory and use proper path
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
|
||||||
<StrictMode>
|
|
||||||
<App />
|
|
||||||
</StrictMode>,
|
|
||||||
);
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useParams, Link } from 'react-router-dom';
|
|
||||||
import { loadSiteDefinition } from '../loaders/loadSiteDefinition';
|
|
||||||
import { renderLayout } from '../utils/layoutRenderer';
|
|
||||||
import type { SiteDefinition } from '../types';
|
|
||||||
|
|
||||||
function SiteRenderer() {
|
|
||||||
const { siteSlug, pageSlug } = useParams<{ siteSlug: string; pageSlug?: string }>();
|
|
||||||
const [siteDefinition, setSiteDefinition] = useState<SiteDefinition | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!siteSlug) {
|
|
||||||
setError('Site slug is required');
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadSiteDefinition(siteSlug)
|
|
||||||
.then((definition) => {
|
|
||||||
setSiteDefinition(definition);
|
|
||||||
setLoading(false);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
setError(err.message || 'Failed to load site');
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}, [siteSlug, pageSlug]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div>Loading site...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <div>Error: {error}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!siteDefinition) {
|
|
||||||
return <div>Site not found</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build navigation from site definition
|
|
||||||
// Show all published/ready pages (excluding home and draft/generating)
|
|
||||||
// Use explicit navigation if available, otherwise auto-generate from pages
|
|
||||||
const navigation = siteDefinition.navigation && siteDefinition.navigation.length > 0
|
|
||||||
? siteDefinition.navigation
|
|
||||||
: siteDefinition.pages
|
|
||||||
.filter(p =>
|
|
||||||
p.slug !== 'home' &&
|
|
||||||
(p.status === 'published' || p.status === 'ready') &&
|
|
||||||
p.status !== 'draft' &&
|
|
||||||
p.status !== 'generating'
|
|
||||||
)
|
|
||||||
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
|
||||||
.map(page => ({
|
|
||||||
label: page.title,
|
|
||||||
slug: page.slug,
|
|
||||||
order: page.order || 0
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Filter pages based on current route
|
|
||||||
const currentPageSlug = pageSlug || 'home';
|
|
||||||
const currentPage = siteDefinition.pages.find(p => p.slug === currentPageSlug);
|
|
||||||
|
|
||||||
// Show only the current page (home page on home route, specific page on page route)
|
|
||||||
const pagesToRender = currentPage
|
|
||||||
? [currentPage]
|
|
||||||
: []; // Fallback: no page found
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="site-renderer" style={{
|
|
||||||
maxWidth: '1200px',
|
|
||||||
margin: '0 auto',
|
|
||||||
padding: '0 1rem',
|
|
||||||
width: '100%'
|
|
||||||
}}>
|
|
||||||
{/* Navigation Menu */}
|
|
||||||
<nav style={{
|
|
||||||
padding: '1rem 0',
|
|
||||||
borderBottom: '1px solid #e5e7eb',
|
|
||||||
marginBottom: '2rem'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: '1rem'
|
|
||||||
}}>
|
|
||||||
<Link
|
|
||||||
to={`/${siteSlug}`}
|
|
||||||
style={{
|
|
||||||
fontSize: '1.5rem',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
textDecoration: 'none',
|
|
||||||
color: '#0f172a'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{siteDefinition.name}
|
|
||||||
</Link>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: '1.5rem',
|
|
||||||
flexWrap: 'wrap'
|
|
||||||
}}>
|
|
||||||
<Link
|
|
||||||
to={`/${siteSlug}`}
|
|
||||||
style={{
|
|
||||||
textDecoration: 'none',
|
|
||||||
color: currentPageSlug === 'home' ? '#4c1d95' : '#64748b',
|
|
||||||
fontWeight: currentPageSlug === 'home' ? 600 : 400
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Home
|
|
||||||
</Link>
|
|
||||||
{navigation.map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.slug}
|
|
||||||
to={`/${siteSlug}/${item.slug}`}
|
|
||||||
style={{
|
|
||||||
textDecoration: 'none',
|
|
||||||
color: currentPageSlug === item.slug ? '#4c1d95' : '#64748b',
|
|
||||||
fontWeight: currentPageSlug === item.slug ? 600 : 400
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
{renderLayout({ ...siteDefinition, pages: pagesToRender })}
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer style={{
|
|
||||||
marginTop: '4rem',
|
|
||||||
padding: '2rem 0',
|
|
||||||
borderTop: '1px solid #e5e7eb',
|
|
||||||
textAlign: 'center',
|
|
||||||
color: '#64748b',
|
|
||||||
fontSize: '0.875rem'
|
|
||||||
}}>
|
|
||||||
<p style={{ margin: 0 }}>
|
|
||||||
© {new Date().getFullYear()} {siteDefinition.name}. All rights reserved.
|
|
||||||
</p>
|
|
||||||
{siteDefinition.description && (
|
|
||||||
<p style={{ margin: '0.5rem 0 0', fontSize: '0.75rem' }}>
|
|
||||||
{siteDefinition.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SiteRenderer;
|
|
||||||
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { Navigate, useLocation } from 'react-router-dom';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ProtectedRoute component that checks for authentication token.
|
|
||||||
* Redirects to login if not authenticated.
|
|
||||||
*/
|
|
||||||
interface ProtectedRouteProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Check for JWT token in localStorage
|
|
||||||
const token = localStorage.getItem('auth-storage');
|
|
||||||
if (token) {
|
|
||||||
try {
|
|
||||||
const authData = JSON.parse(token);
|
|
||||||
setIsAuthenticated(!!authData?.state?.token);
|
|
||||||
} catch {
|
|
||||||
setIsAuthenticated(false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setIsAuthenticated(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (isAuthenticated === null) {
|
|
||||||
// Still checking authentication
|
|
||||||
return <div>Checking authentication...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
// Redirect to login (or main app login page)
|
|
||||||
// In production, this might redirect to app.igny8.com/login
|
|
||||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
export interface SiteDefinition {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
version: number;
|
|
||||||
layout: string;
|
|
||||||
theme: Record<string, any>;
|
|
||||||
navigation: NavigationItem[];
|
|
||||||
pages: PageDefinition[];
|
|
||||||
config: Record<string, any>;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NavigationItem {
|
|
||||||
label: string;
|
|
||||||
slug: string;
|
|
||||||
order: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PageDefinition {
|
|
||||||
id: number;
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
blocks: Block[];
|
|
||||||
status: string;
|
|
||||||
order?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Block {
|
|
||||||
type: string;
|
|
||||||
data: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
/**
|
|
||||||
* File Access Utility
|
|
||||||
* Phase 5: Sites Renderer & Publishing
|
|
||||||
*
|
|
||||||
* Integrates with Phase 3's SiteBuilderFileService for file access.
|
|
||||||
* Provides utilities to access site assets (images, documents, media).
|
|
||||||
*/
|
|
||||||
|
|
||||||
const SITES_DATA_PATH = import.meta.env.SITES_DATA_PATH || '/sites';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get API base URL - auto-detect based on current origin
|
|
||||||
*/
|
|
||||||
function getApiBaseUrl(): string {
|
|
||||||
const envUrl = import.meta.env.VITE_API_URL;
|
|
||||||
if (envUrl) {
|
|
||||||
return envUrl.endsWith('/api') ? envUrl : `${envUrl}/api`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const origin = window.location.origin;
|
|
||||||
if (/^\d+\.\d+\.\d+\.\d+/.test(origin) || origin.includes('localhost') || origin.includes('127.0.0.1')) {
|
|
||||||
if (origin.includes(':8024')) {
|
|
||||||
return origin.replace(':8024', ':8011') + '/api';
|
|
||||||
}
|
|
||||||
return origin.split(':')[0] + ':8011/api';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'https://api.igny8.com/api';
|
|
||||||
}
|
|
||||||
|
|
||||||
const API_URL = getApiBaseUrl();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get file URL for a site asset.
|
|
||||||
*
|
|
||||||
* @param siteId - Site ID
|
|
||||||
* @param version - Site version (optional, defaults to 'latest')
|
|
||||||
* @param filePath - Relative path to file from assets directory
|
|
||||||
* @returns Full URL to the asset
|
|
||||||
*/
|
|
||||||
export function getSiteAssetUrl(
|
|
||||||
siteId: string | number,
|
|
||||||
filePath: string,
|
|
||||||
version: string | number = 'latest'
|
|
||||||
): string {
|
|
||||||
// Try filesystem first (for deployed sites)
|
|
||||||
const fsPath = `${SITES_DATA_PATH}/clients/${siteId}/v${version}/assets/${filePath}`;
|
|
||||||
|
|
||||||
// In browser, we need to use API endpoint
|
|
||||||
// The backend will serve files from the filesystem
|
|
||||||
return `${API_URL}/v1/site-builder/assets/${siteId}/${version}/${filePath}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get image URL for a site.
|
|
||||||
*/
|
|
||||||
export function getSiteImageUrl(
|
|
||||||
siteId: string | number,
|
|
||||||
imagePath: string,
|
|
||||||
version: string | number = 'latest'
|
|
||||||
): string {
|
|
||||||
return getSiteAssetUrl(siteId, `images/${imagePath}`, version);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get document URL for a site.
|
|
||||||
*/
|
|
||||||
export function getSiteDocumentUrl(
|
|
||||||
siteId: string | number,
|
|
||||||
documentPath: string,
|
|
||||||
version: string | number = 'latest'
|
|
||||||
): string {
|
|
||||||
return getSiteAssetUrl(siteId, `documents/${documentPath}`, version);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get media URL for a site.
|
|
||||||
*/
|
|
||||||
export function getSiteMediaUrl(
|
|
||||||
siteId: string | number,
|
|
||||||
mediaPath: string,
|
|
||||||
version: string | number = 'latest'
|
|
||||||
): string {
|
|
||||||
return getSiteAssetUrl(siteId, `media/${mediaPath}`, version);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a file exists.
|
|
||||||
*
|
|
||||||
* @param siteId - Site ID
|
|
||||||
* @param filePath - Relative path to file
|
|
||||||
* @param version - Site version
|
|
||||||
* @returns Promise that resolves to true if file exists
|
|
||||||
*/
|
|
||||||
export async function fileExists(
|
|
||||||
siteId: string | number,
|
|
||||||
filePath: string,
|
|
||||||
version: string | number = 'latest'
|
|
||||||
): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const url = getSiteAssetUrl(siteId, filePath, version);
|
|
||||||
const response = await fetch(url, { method: 'HEAD' });
|
|
||||||
return response.ok;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load file content as text.
|
|
||||||
*/
|
|
||||||
export async function loadFileAsText(
|
|
||||||
siteId: string | number,
|
|
||||||
filePath: string,
|
|
||||||
version: string | number = 'latest'
|
|
||||||
): Promise<string> {
|
|
||||||
const url = getSiteAssetUrl(siteId, filePath, version);
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to load file: ${filePath}`);
|
|
||||||
}
|
|
||||||
return response.text();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load file content as blob.
|
|
||||||
*/
|
|
||||||
export async function loadFileAsBlob(
|
|
||||||
siteId: string | number,
|
|
||||||
filePath: string,
|
|
||||||
version: string | number = 'latest'
|
|
||||||
): Promise<Blob> {
|
|
||||||
const url = getSiteAssetUrl(siteId, filePath, version);
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to load file: ${filePath}`);
|
|
||||||
}
|
|
||||||
return response.blob();
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
/**
|
|
||||||
* Layout Renderer
|
|
||||||
* Phase 5: Sites Renderer & Publishing
|
|
||||||
*
|
|
||||||
* Renders different layout types for sites using shared components.
|
|
||||||
*/
|
|
||||||
import React from 'react';
|
|
||||||
import type { SiteDefinition } from '../types';
|
|
||||||
import { renderTemplate } from './templateEngine';
|
|
||||||
import { renderPageByType } from './pageTypeRenderer';
|
|
||||||
import {
|
|
||||||
DefaultLayout,
|
|
||||||
MinimalLayout,
|
|
||||||
MagazineLayout,
|
|
||||||
EcommerceLayout,
|
|
||||||
PortfolioLayout,
|
|
||||||
BlogLayout,
|
|
||||||
CorporateLayout,
|
|
||||||
} from '@shared/layouts';
|
|
||||||
|
|
||||||
export type LayoutType =
|
|
||||||
| 'default'
|
|
||||||
| 'minimal'
|
|
||||||
| 'magazine'
|
|
||||||
| 'ecommerce'
|
|
||||||
| 'portfolio'
|
|
||||||
| 'blog'
|
|
||||||
| 'corporate';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render site layout based on site definition.
|
|
||||||
*/
|
|
||||||
export function renderLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
|
||||||
const layoutType = siteDefinition.layout as LayoutType;
|
|
||||||
|
|
||||||
switch (layoutType) {
|
|
||||||
case 'minimal':
|
|
||||||
return renderMinimalLayout(siteDefinition);
|
|
||||||
case 'magazine':
|
|
||||||
return renderMagazineLayout(siteDefinition);
|
|
||||||
case 'ecommerce':
|
|
||||||
return renderEcommerceLayout(siteDefinition);
|
|
||||||
case 'portfolio':
|
|
||||||
return renderPortfolioLayout(siteDefinition);
|
|
||||||
case 'blog':
|
|
||||||
return renderBlogLayout(siteDefinition);
|
|
||||||
case 'corporate':
|
|
||||||
return renderCorporateLayout(siteDefinition);
|
|
||||||
case 'default':
|
|
||||||
default:
|
|
||||||
return renderDefaultLayout(siteDefinition);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default layout: Standard header, content, footer.
|
|
||||||
* Uses shared DefaultLayout component with fully styled modern design.
|
|
||||||
*/
|
|
||||||
function renderDefaultLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
|
||||||
// Find home page for hero (only show hero on home page or when showing all pages)
|
|
||||||
const homePage = siteDefinition.pages.find(p => p.slug === 'home');
|
|
||||||
const heroBlock = homePage?.blocks?.find(b => b.type === 'hero');
|
|
||||||
|
|
||||||
// Only show hero if we're on home page or showing all pages
|
|
||||||
const isHomePage = siteDefinition.pages.length === 1 && siteDefinition.pages[0]?.slug === 'home';
|
|
||||||
const showHero = isHomePage || (homePage && siteDefinition.pages.length > 1);
|
|
||||||
const hero: React.ReactNode = (showHero && heroBlock) ? (renderTemplate(heroBlock) as React.ReactNode) : undefined;
|
|
||||||
|
|
||||||
// Render all pages using page-type-specific templates
|
|
||||||
const sections = siteDefinition.pages
|
|
||||||
.filter((page) => page.status !== 'draft' && page.status !== 'generating')
|
|
||||||
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
|
||||||
.map((page) => {
|
|
||||||
// Use page-type-specific renderer if available
|
|
||||||
const blocksToRender = page.slug === 'home' && heroBlock && showHero
|
|
||||||
? page.blocks?.filter(b => b.type !== 'hero') || []
|
|
||||||
: page.blocks || [];
|
|
||||||
|
|
||||||
// Render using page-type template
|
|
||||||
return (
|
|
||||||
<div key={page.id} className="page-wrapper" data-page-slug={page.slug}>
|
|
||||||
{renderPageByType(page, blocksToRender)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DefaultLayout
|
|
||||||
hero={hero as any}
|
|
||||||
sections={sections}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal layout: Clean, minimal design.
|
|
||||||
* Uses shared MinimalLayout component.
|
|
||||||
*/
|
|
||||||
function renderMinimalLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
|
||||||
const mainContent = (
|
|
||||||
<>
|
|
||||||
{siteDefinition.pages
|
|
||||||
.filter((page) => page.status !== 'draft' && page.status !== 'generating')
|
|
||||||
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
|
||||||
.map((page) => (
|
|
||||||
<div key={page.id} className="page-wrapper" data-page-slug={page.slug}>
|
|
||||||
{renderPageByType(page, page.blocks || [])}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MinimalLayout>
|
|
||||||
{mainContent}
|
|
||||||
</MinimalLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Magazine layout: Editorial, content-focused.
|
|
||||||
* Uses shared MagazineLayout component.
|
|
||||||
*/
|
|
||||||
function renderMagazineLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
|
||||||
const mainContent = (
|
|
||||||
<>
|
|
||||||
{siteDefinition.pages
|
|
||||||
.filter((page) => page.status !== 'draft' && page.status !== 'generating')
|
|
||||||
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
|
||||||
.map((page) => (
|
|
||||||
<div key={page.id} className="page-wrapper" data-page-slug={page.slug}>
|
|
||||||
{renderPageByType(page, page.blocks || [])}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MagazineLayout
|
|
||||||
mainContent={mainContent}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ecommerce layout: Product-focused.
|
|
||||||
* Uses shared EcommerceLayout component.
|
|
||||||
*/
|
|
||||||
function renderEcommerceLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
|
||||||
const mainContent = (
|
|
||||||
<>
|
|
||||||
{siteDefinition.pages
|
|
||||||
.filter((page) => page.status !== 'draft' && page.status !== 'generating')
|
|
||||||
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
|
||||||
.map((page) => (
|
|
||||||
<div key={page.id} className="page-wrapper" data-page-slug={page.slug}>
|
|
||||||
{renderPageByType(page, page.blocks || [])}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EcommerceLayout
|
|
||||||
mainContent={mainContent}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Portfolio layout: Showcase.
|
|
||||||
* Uses shared PortfolioLayout component.
|
|
||||||
*/
|
|
||||||
function renderPortfolioLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
|
||||||
const mainContent = (
|
|
||||||
<>
|
|
||||||
{siteDefinition.pages
|
|
||||||
.filter((page) => page.status !== 'draft' && page.status !== 'generating')
|
|
||||||
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
|
||||||
.map((page) => (
|
|
||||||
<div key={page.id} className="page-wrapper" data-page-slug={page.slug}>
|
|
||||||
{renderPageByType(page, page.blocks || [])}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PortfolioLayout
|
|
||||||
mainContent={mainContent}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Blog layout: Content-first.
|
|
||||||
* Uses shared BlogLayout component.
|
|
||||||
*/
|
|
||||||
function renderBlogLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
|
||||||
const mainContent = (
|
|
||||||
<>
|
|
||||||
{siteDefinition.pages
|
|
||||||
.filter((page) => page.status !== 'draft' && page.status !== 'generating')
|
|
||||||
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
|
||||||
.map((page) => (
|
|
||||||
<div key={page.id} className="page-wrapper" data-page-slug={page.slug}>
|
|
||||||
{renderPageByType(page, page.blocks || [])}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BlogLayout
|
|
||||||
mainContent={mainContent}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Corporate layout: Business.
|
|
||||||
* Uses shared CorporateLayout component.
|
|
||||||
*/
|
|
||||||
function renderCorporateLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
|
||||||
const mainContent = (
|
|
||||||
<>
|
|
||||||
{siteDefinition.pages
|
|
||||||
.filter((page) => page.status !== 'draft' && page.status !== 'generating')
|
|
||||||
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
|
||||||
.map((page) => (
|
|
||||||
<div key={page.id} className="page-wrapper" data-page-slug={page.slug}>
|
|
||||||
{renderPageByType(page, page.blocks || [])}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CorporateLayout
|
|
||||||
mainContent={mainContent}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: Navigation and page rendering are now handled within each layout component
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
/**
|
|
||||||
* Layout Renderer
|
|
||||||
* Phase 5: Sites Renderer & Publishing
|
|
||||||
*
|
|
||||||
* Renders different layout types for sites using shared components.
|
|
||||||
*/
|
|
||||||
import React from 'react';
|
|
||||||
import type { SiteDefinition } from '../types';
|
|
||||||
import { renderTemplate } from './templateEngine';
|
|
||||||
import {
|
|
||||||
DefaultLayout,
|
|
||||||
MinimalLayout,
|
|
||||||
MagazineLayout,
|
|
||||||
EcommerceLayout,
|
|
||||||
PortfolioLayout,
|
|
||||||
BlogLayout,
|
|
||||||
CorporateLayout,
|
|
||||||
} from '@shared/layouts';
|
|
||||||
|
|
||||||
export type LayoutType =
|
|
||||||
| 'default'
|
|
||||||
| 'minimal'
|
|
||||||
| 'magazine'
|
|
||||||
| 'ecommerce'
|
|
||||||
| 'portfolio'
|
|
||||||
| 'blog'
|
|
||||||
| 'corporate';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render site layout based on site definition.
|
|
||||||
*/
|
|
||||||
export function renderLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
|
||||||
const layoutType = siteDefinition.layout as LayoutType;
|
|
||||||
|
|
||||||
switch (layoutType) {
|
|
||||||
case 'minimal':
|
|
||||||
return renderMinimalLayout(siteDefinition);
|
|
||||||
case 'magazine':
|
|
||||||
return renderMagazineLayout(siteDefinition);
|
|
||||||
case 'ecommerce':
|
|
||||||
return renderEcommerceLayout(siteDefinition);
|
|
||||||
case 'portfolio':
|
|
||||||
return renderPortfolioLayout(siteDefinition);
|
|
||||||
case 'blog':
|
|
||||||
return renderBlogLayout(siteDefinition);
|
|
||||||
case 'corporate':
|
|
||||||
return renderCorporateLayout(siteDefinition);
|
|
||||||
case 'default':
|
|
||||||
default:
|
|
||||||
return renderDefaultLayout(siteDefinition);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default layout: Standard header, content, footer.
|
|
||||||
* Uses shared DefaultLayout component with fully styled modern design.
|
|
||||||
*/
|
|
||||||
function renderDefaultLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
|
||||||
// Find home page for hero
|
|
||||||
const homePage = siteDefinition.pages.find(p => p.slug === 'home');
|
|
||||||
const heroBlock = homePage?.blocks?.find(b => b.type === 'hero');
|
|
||||||
const hero: React.ReactNode = heroBlock ? (renderTemplate(heroBlock) as React.ReactNode) : undefined;
|
|
||||||
|
|
||||||
// Render all pages as sections (excluding hero from home page if it exists)
|
|
||||||
const sections = siteDefinition.pages
|
|
||||||
.filter((page) => page.status !== 'draft')
|
|
||||||
.map((page) => {
|
|
||||||
// Filter out hero block if it's the home page (already rendered as hero)
|
|
||||||
const blocksToRender = page.slug === 'home' && heroBlock
|
|
||||||
? page.blocks?.filter(b => b.type !== 'hero') || []
|
|
||||||
: page.blocks || [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={page.id} className="page" data-page-slug={page.slug}>
|
|
||||||
{page.slug !== 'home' && <h2>{page.title}</h2>}
|
|
||||||
{blocksToRender.length > 0 ? (
|
|
||||||
blocksToRender.map((block, index) => (
|
|
||||||
<div key={index} className="block" data-block-type={block.type}>
|
|
||||||
{renderTemplate(block)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : page.slug !== 'home' ? (
|
|
||||||
<p>No content available for this page.</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DefaultLayout
|
|
||||||
hero={hero as any}
|
|
||||||
sections={sections}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal layout: Clean, minimal design.
|
|
||||||
* Uses shared MinimalLayout component.
|
|
||||||
*/
|
|
||||||
function renderMinimalLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
|
||||||
const mainContent = (
|
|
||||||
<>
|
|
||||||
{siteDefinition.pages
|
|
||||||
.filter((page) => page.status !== 'draft')
|
|
||||||
.map((page) => (
|
|
||||||
<div key={page.id} className="page" data-page-slug={page.slug}>
|
|
||||||
<h2>{page.title}</h2>
|
|
||||||
{page.blocks && page.blocks.length > 0 ? (
|
|
||||||
page.blocks.map((block, index) => (
|
|
||||||
<div key={index} className="block" data-block-type={block.type}>
|
|
||||||
{renderTemplate(block)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MinimalLayout>
|
|
||||||
{mainContent}
|
|
||||||
</MinimalLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Magazine layout: Editorial, content-focused.
|
|
||||||
* Uses shared MagazineLayout component.
|
|
||||||
*/
|
|
||||||
function renderMagazineLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
|
||||||
const mainContent = (
|
|
||||||
<>
|
|
||||||
{siteDefinition.pages
|
|
||||||
.filter((page) => page.status !== 'draft')
|
|
||||||
.map((page) => (
|
|
||||||
<div key={page.id} className="page" data-page-slug={page.slug}>
|
|
||||||
{page.blocks && page.blocks.length > 0 ? (
|
|
||||||
page.blocks.map((block, index) => (
|
|
||||||
<div key={index} className="block" data-block-type={block.type}>
|
|
||||||
{renderTemplate(block)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MagazineLayout
|
|
||||||
mainContent={mainContent}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ecommerce layout: Product-focused.
|
|
||||||
* Uses shared EcommerceLayout component.
|
|
||||||
*/
|
|
||||||
function renderEcommerceLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
|
||||||
const mainContent = (
|
|
||||||
<>
|
|
||||||
{siteDefinition.pages
|
|
||||||
.filter((page) => page.status !== 'draft')
|
|
||||||
.map((page) => (
|
|
||||||
<div key={page.id} className="page" data-page-slug={page.slug}>
|
|
||||||
{page.blocks && page.blocks.length > 0 ? (
|
|
||||||
page.blocks.map((block, index) => (
|
|
||||||
<div key={index} className="block" data-block-type={block.type}>
|
|
||||||
{renderTemplate(block)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EcommerceLayout
|
|
||||||
mainContent={mainContent}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Portfolio layout: Showcase.
|
|
||||||
* Uses shared PortfolioLayout component.
|
|
||||||
*/
|
|
||||||
function renderPortfolioLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
|
||||||
const mainContent = (
|
|
||||||
<>
|
|
||||||
{siteDefinition.pages
|
|
||||||
.filter((page) => page.status !== 'draft')
|
|
||||||
.map((page) => (
|
|
||||||
<div key={page.id} className="page" data-page-slug={page.slug}>
|
|
||||||
{page.blocks && page.blocks.length > 0 ? (
|
|
||||||
page.blocks.map((block, index) => (
|
|
||||||
<div key={index} className="block" data-block-type={block.type}>
|
|
||||||
{renderTemplate(block)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PortfolioLayout
|
|
||||||
mainContent={mainContent}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Blog layout: Content-first.
|
|
||||||
* Uses shared BlogLayout component.
|
|
||||||
*/
|
|
||||||
function renderBlogLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
|
||||||
const mainContent = (
|
|
||||||
<>
|
|
||||||
{siteDefinition.pages
|
|
||||||
.filter((page) => page.status !== 'draft')
|
|
||||||
.map((page) => (
|
|
||||||
<div key={page.id} className="page" data-page-slug={page.slug}>
|
|
||||||
{page.blocks && page.blocks.length > 0 ? (
|
|
||||||
page.blocks.map((block, index) => (
|
|
||||||
<div key={index} className="block" data-block-type={block.type}>
|
|
||||||
{renderTemplate(block)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BlogLayout
|
|
||||||
mainContent={mainContent}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Corporate layout: Business.
|
|
||||||
* Uses shared CorporateLayout component.
|
|
||||||
*/
|
|
||||||
function renderCorporateLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
|
||||||
const mainContent = (
|
|
||||||
<>
|
|
||||||
{siteDefinition.pages
|
|
||||||
.filter((page) => page.status !== 'draft')
|
|
||||||
.map((page) => (
|
|
||||||
<div key={page.id} className="page" data-page-slug={page.slug}>
|
|
||||||
{page.blocks && page.blocks.length > 0 ? (
|
|
||||||
page.blocks.map((block, index) => (
|
|
||||||
<div key={index} className="block" data-block-type={block.type}>
|
|
||||||
{renderTemplate(block)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CorporateLayout
|
|
||||||
mainContent={mainContent}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: Navigation and page rendering are now handled within each layout component
|
|
||||||
|
|
||||||
@@ -1,374 +0,0 @@
|
|||||||
/**
|
|
||||||
* Page Type Renderer
|
|
||||||
* Renders page-type-specific templates for different page types.
|
|
||||||
*
|
|
||||||
* Each page type has its own template structure optimized for that content type.
|
|
||||||
*/
|
|
||||||
import React from 'react';
|
|
||||||
import type { PageDefinition, Block } from '../types';
|
|
||||||
import { renderTemplate } from './templateEngine';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render a page based on its type.
|
|
||||||
* Falls back to default rendering if no specific template exists.
|
|
||||||
*/
|
|
||||||
export function renderPageByType(page: PageDefinition, blocks: Block[]): React.ReactElement {
|
|
||||||
// Determine page type - use page.type if available, otherwise infer from slug
|
|
||||||
let pageType = page.type;
|
|
||||||
if (!pageType || pageType === 'custom') {
|
|
||||||
// Infer type from slug
|
|
||||||
if (page.slug === 'home') pageType = 'home';
|
|
||||||
else if (page.slug === 'products') pageType = 'products';
|
|
||||||
else if (page.slug === 'blog') pageType = 'blog';
|
|
||||||
else if (page.slug === 'contact') pageType = 'contact';
|
|
||||||
else if (page.slug === 'about') pageType = 'about';
|
|
||||||
else if (page.slug === 'services') pageType = 'services';
|
|
||||||
else pageType = 'custom';
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (pageType) {
|
|
||||||
case 'home':
|
|
||||||
return renderHomePage(page, blocks);
|
|
||||||
case 'products':
|
|
||||||
return renderProductsPage(page, blocks);
|
|
||||||
case 'blog':
|
|
||||||
return renderBlogPage(page, blocks);
|
|
||||||
case 'contact':
|
|
||||||
return renderContactPage(page, blocks);
|
|
||||||
case 'about':
|
|
||||||
return renderAboutPage(page, blocks);
|
|
||||||
case 'services':
|
|
||||||
return renderServicesPage(page, blocks);
|
|
||||||
case 'custom':
|
|
||||||
default:
|
|
||||||
return renderCustomPage(page, blocks);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Home Page Template
|
|
||||||
* Structure: Hero → Features → Testimonials → CTA
|
|
||||||
*/
|
|
||||||
function renderHomePage(page: PageDefinition, blocks: Block[]): React.ReactElement {
|
|
||||||
// Find specific blocks
|
|
||||||
const heroBlock = blocks.find(b => b.type === 'hero');
|
|
||||||
const featuresBlock = blocks.find(b => b.type === 'features' || b.type === 'grid');
|
|
||||||
const testimonialsBlock = blocks.find(b => b.type === 'testimonials');
|
|
||||||
const ctaBlock = blocks.find(b => b.type === 'cta');
|
|
||||||
const otherBlocks = blocks.filter(b =>
|
|
||||||
!['hero', 'features', 'grid', 'testimonials', 'cta'].includes(b.type)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page-home" data-page-slug={page.slug}>
|
|
||||||
{/* Hero Section */}
|
|
||||||
{heroBlock && (
|
|
||||||
<section className="home-hero">
|
|
||||||
{renderTemplate(heroBlock)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Features Section */}
|
|
||||||
{featuresBlock && (
|
|
||||||
<section className="home-features" style={{ padding: '3rem 2rem', background: '#f9fafb' }}>
|
|
||||||
{renderTemplate(featuresBlock)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Other Content Blocks */}
|
|
||||||
{otherBlocks.length > 0 && (
|
|
||||||
<section className="home-content" style={{ padding: '3rem 2rem' }}>
|
|
||||||
{otherBlocks.map((block, index) => (
|
|
||||||
<div key={index} className="block" data-block-type={block.type}>
|
|
||||||
{renderTemplate(block)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Testimonials Section */}
|
|
||||||
{testimonialsBlock && (
|
|
||||||
<section className="home-testimonials" style={{ padding: '3rem 2rem', background: '#f9fafb' }}>
|
|
||||||
{renderTemplate(testimonialsBlock)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* CTA Section */}
|
|
||||||
{ctaBlock && (
|
|
||||||
<section className="home-cta">
|
|
||||||
{renderTemplate(ctaBlock)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Products Page Template
|
|
||||||
* Structure: Title → Product Grid → Filters (if available)
|
|
||||||
*/
|
|
||||||
function renderProductsPage(page: PageDefinition, blocks: Block[]): React.ReactElement {
|
|
||||||
const productsBlock = blocks.find(b => b.type === 'products' || b.type === 'grid');
|
|
||||||
const otherBlocks = blocks.filter(b => b.type !== 'products' && b.type !== 'grid');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page-products" data-page-slug={page.slug}>
|
|
||||||
<header style={{ marginBottom: '2rem', textAlign: 'center' }}>
|
|
||||||
<h1 style={{ fontSize: '2.5rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
|
||||||
{page.title}
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Product Grid */}
|
|
||||||
{productsBlock && (
|
|
||||||
<section className="products-grid" style={{ padding: '2rem 0' }}>
|
|
||||||
{renderTemplate(productsBlock)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Other Content */}
|
|
||||||
{otherBlocks.length > 0 && (
|
|
||||||
<section className="products-content" style={{ padding: '2rem 0' }}>
|
|
||||||
{otherBlocks.map((block, index) => (
|
|
||||||
<div key={index} className="block" data-block-type={block.type}>
|
|
||||||
{renderTemplate(block)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Blog Page Template
|
|
||||||
* Structure: Title → Post List/Grid → Sidebar (if available)
|
|
||||||
*/
|
|
||||||
function renderBlogPage(page: PageDefinition, blocks: Block[]): React.ReactElement {
|
|
||||||
const heroBlock = blocks.find(b => b.type === 'hero');
|
|
||||||
const contentBlocks = blocks.filter(b => b.type !== 'hero');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page-blog" data-page-slug={page.slug}>
|
|
||||||
{/* Hero/Header */}
|
|
||||||
{heroBlock ? (
|
|
||||||
<section className="blog-hero">
|
|
||||||
{renderTemplate(heroBlock)}
|
|
||||||
</section>
|
|
||||||
) : (
|
|
||||||
<header style={{ marginBottom: '2rem', textAlign: 'center', padding: '2rem 0' }}>
|
|
||||||
<h1 style={{ fontSize: '2.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
|
|
||||||
{page.title}
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Blog Content - Grid Layout for Posts */}
|
|
||||||
<section className="blog-content" style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
|
|
||||||
gap: '2rem',
|
|
||||||
padding: '2rem 0'
|
|
||||||
}}>
|
|
||||||
{contentBlocks.map((block, index) => (
|
|
||||||
<article key={index} className="blog-post" style={{
|
|
||||||
border: '1px solid #e5e7eb',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '1.5rem',
|
|
||||||
background: '#fff'
|
|
||||||
}}>
|
|
||||||
{renderTemplate(block)}
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Contact Page Template
|
|
||||||
* Structure: Title → Contact Info → Form → Map (if available)
|
|
||||||
*/
|
|
||||||
function renderContactPage(page: PageDefinition, blocks: Block[]): React.ReactElement {
|
|
||||||
const formBlock = blocks.find(b => b.type === 'form');
|
|
||||||
const textBlocks = blocks.filter(b => b.type === 'text' || b.type === 'section');
|
|
||||||
const mapBlock = blocks.find(b => b.type === 'image' && b.data?.caption?.toLowerCase().includes('map'));
|
|
||||||
const otherBlocks = blocks.filter(b =>
|
|
||||||
b.type !== 'form' &&
|
|
||||||
b.type !== 'text' &&
|
|
||||||
b.type !== 'section' &&
|
|
||||||
!(b.type === 'image' && b.data?.caption?.toLowerCase().includes('map'))
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page-contact" data-page-slug={page.slug}>
|
|
||||||
<header style={{ marginBottom: '2rem', textAlign: 'center' }}>
|
|
||||||
<h1 style={{ fontSize: '2.5rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
|
||||||
{page.title}
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
|
||||||
gap: '2rem',
|
|
||||||
padding: '2rem 0'
|
|
||||||
}}>
|
|
||||||
{/* Contact Information */}
|
|
||||||
{textBlocks.length > 0 && (
|
|
||||||
<section className="contact-info">
|
|
||||||
{textBlocks.map((block, index) => (
|
|
||||||
<div key={index} className="block" data-block-type={block.type}>
|
|
||||||
{renderTemplate(block)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Contact Form */}
|
|
||||||
{formBlock && (
|
|
||||||
<section className="contact-form">
|
|
||||||
{renderTemplate(formBlock)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Map */}
|
|
||||||
{mapBlock && (
|
|
||||||
<section className="contact-map" style={{ marginTop: '2rem' }}>
|
|
||||||
{renderTemplate(mapBlock)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Other Content */}
|
|
||||||
{otherBlocks.length > 0 && (
|
|
||||||
<section className="contact-other" style={{ marginTop: '2rem' }}>
|
|
||||||
{otherBlocks.map((block, index) => (
|
|
||||||
<div key={index} className="block" data-block-type={block.type}>
|
|
||||||
{renderTemplate(block)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* About Page Template
|
|
||||||
* Structure: Hero → Mission → Team/Values → Stats
|
|
||||||
*/
|
|
||||||
function renderAboutPage(page: PageDefinition, blocks: Block[]): React.ReactElement {
|
|
||||||
const heroBlock = blocks.find(b => b.type === 'hero');
|
|
||||||
const statsBlock = blocks.find(b => b.type === 'stats');
|
|
||||||
const servicesBlock = blocks.find(b => b.type === 'services' || b.type === 'features');
|
|
||||||
const otherBlocks = blocks.filter(b =>
|
|
||||||
!['hero', 'stats', 'services', 'features'].includes(b.type)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page-about" data-page-slug={page.slug}>
|
|
||||||
{/* Hero Section */}
|
|
||||||
{heroBlock && (
|
|
||||||
<section className="about-hero">
|
|
||||||
{renderTemplate(heroBlock)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<section className="about-content" style={{ padding: '3rem 2rem' }}>
|
|
||||||
{otherBlocks.map((block, index) => (
|
|
||||||
<div key={index} className="block" data-block-type={block.type} style={{ marginBottom: '2rem' }}>
|
|
||||||
{renderTemplate(block)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Services/Features Section */}
|
|
||||||
{servicesBlock && (
|
|
||||||
<section className="about-services" style={{ padding: '3rem 2rem', background: '#f9fafb' }}>
|
|
||||||
{renderTemplate(servicesBlock)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stats Section */}
|
|
||||||
{statsBlock && (
|
|
||||||
<section className="about-stats" style={{ padding: '3rem 2rem' }}>
|
|
||||||
{renderTemplate(statsBlock)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Services Page Template
|
|
||||||
* Structure: Title → Service Cards → Details
|
|
||||||
*/
|
|
||||||
function renderServicesPage(page: PageDefinition, blocks: Block[]): React.ReactElement {
|
|
||||||
const heroBlock = blocks.find(b => b.type === 'hero');
|
|
||||||
const servicesBlock = blocks.find(b => b.type === 'services' || b.type === 'features');
|
|
||||||
const otherBlocks = blocks.filter(b =>
|
|
||||||
b.type !== 'hero' && b.type !== 'services' && b.type !== 'features'
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page-services" data-page-slug={page.slug}>
|
|
||||||
{/* Hero/Header */}
|
|
||||||
{heroBlock ? (
|
|
||||||
<section className="services-hero">
|
|
||||||
{renderTemplate(heroBlock)}
|
|
||||||
</section>
|
|
||||||
) : (
|
|
||||||
<header style={{ marginBottom: '2rem', textAlign: 'center', padding: '2rem 0' }}>
|
|
||||||
<h1 style={{ fontSize: '2.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
|
|
||||||
{page.title}
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Services Grid */}
|
|
||||||
{servicesBlock && (
|
|
||||||
<section className="services-grid" style={{ padding: '2rem 0' }}>
|
|
||||||
{renderTemplate(servicesBlock)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Additional Content */}
|
|
||||||
{otherBlocks.length > 0 && (
|
|
||||||
<section className="services-content" style={{ padding: '2rem 0' }}>
|
|
||||||
{otherBlocks.map((block, index) => (
|
|
||||||
<div key={index} className="block" data-block-type={block.type}>
|
|
||||||
{renderTemplate(block)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom Page Template
|
|
||||||
* Default rendering for custom pages - renders all blocks in order
|
|
||||||
*/
|
|
||||||
function renderCustomPage(page: PageDefinition, blocks: Block[]): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="page-custom" data-page-slug={page.slug}>
|
|
||||||
<header style={{ marginBottom: '2rem', textAlign: 'center' }}>
|
|
||||||
<h1 style={{ fontSize: '2.5rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
|
||||||
{page.title}
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="custom-content" style={{ padding: '2rem 0' }}>
|
|
||||||
{blocks.map((block, index) => (
|
|
||||||
<div key={index} className="block" data-block-type={block.type} style={{ marginBottom: '2rem' }}>
|
|
||||||
{renderTemplate(block)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,440 +0,0 @@
|
|||||||
/**
|
|
||||||
* Template Engine
|
|
||||||
* Phase 5: Sites Renderer & Publishing
|
|
||||||
*
|
|
||||||
* Renders blocks using shared components from the component library.
|
|
||||||
* Uses fully styled modern components from @shared.
|
|
||||||
*/
|
|
||||||
import React from 'react';
|
|
||||||
import type { Block } from '../types';
|
|
||||||
import {
|
|
||||||
HeroBlock,
|
|
||||||
FeatureGridBlock,
|
|
||||||
TestimonialsBlock,
|
|
||||||
CTABlock,
|
|
||||||
ServicesBlock,
|
|
||||||
StatsPanel,
|
|
||||||
TextBlock,
|
|
||||||
QuoteBlock,
|
|
||||||
ContactFormBlock,
|
|
||||||
ImageGalleryBlock,
|
|
||||||
VideoBlock,
|
|
||||||
ProductsBlock,
|
|
||||||
} from '@shared/blocks';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render a block using the template engine.
|
|
||||||
* Imports shared components dynamically.
|
|
||||||
*/
|
|
||||||
export function renderTemplate(block: Block | any): React.ReactElement {
|
|
||||||
// Handle both formats: { type, data } or { type, ...properties }
|
|
||||||
let type: string;
|
|
||||||
let data: Record<string, any>;
|
|
||||||
|
|
||||||
if (block.type && block.data) {
|
|
||||||
// Standard format: { type, data }
|
|
||||||
type = block.type;
|
|
||||||
data = block.data;
|
|
||||||
} else {
|
|
||||||
// API format: { type, heading, subheading, content, ... }
|
|
||||||
type = block.type;
|
|
||||||
data = block;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to import shared component
|
|
||||||
// This will be replaced with actual component imports once shared components are available
|
|
||||||
switch (type) {
|
|
||||||
case 'hero':
|
|
||||||
return renderHeroBlock(data);
|
|
||||||
case 'features':
|
|
||||||
return renderFeaturesBlock(data);
|
|
||||||
case 'testimonials':
|
|
||||||
return renderTestimonialsBlock(data);
|
|
||||||
case 'text':
|
|
||||||
return renderTextBlock(data);
|
|
||||||
case 'image':
|
|
||||||
return renderImageBlock(data);
|
|
||||||
case 'button':
|
|
||||||
return renderButtonBlock(data);
|
|
||||||
case 'section':
|
|
||||||
return renderSectionBlock(data);
|
|
||||||
case 'grid':
|
|
||||||
return renderGridBlock(data);
|
|
||||||
case 'card':
|
|
||||||
return renderCardBlock(data);
|
|
||||||
case 'list':
|
|
||||||
return renderListBlock(data);
|
|
||||||
case 'quote':
|
|
||||||
return renderQuoteBlock(data);
|
|
||||||
case 'video':
|
|
||||||
return renderVideoBlock(data);
|
|
||||||
case 'form':
|
|
||||||
return renderFormBlock(data);
|
|
||||||
case 'accordion':
|
|
||||||
return renderAccordionBlock(data);
|
|
||||||
case 'faq':
|
|
||||||
return renderFAQBlock(data);
|
|
||||||
case 'cta':
|
|
||||||
return renderCTABlock(data);
|
|
||||||
case 'services':
|
|
||||||
return renderServicesBlock(data);
|
|
||||||
case 'stats':
|
|
||||||
return renderStatsBlock(data);
|
|
||||||
default:
|
|
||||||
return <div className="block-unknown">Unknown block type: {type}</div>;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error rendering block type ${type}:`, error);
|
|
||||||
return <div className="block-error">Error rendering block: {type}</div>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render hero block using shared HeroBlock component.
|
|
||||||
*/
|
|
||||||
function renderHeroBlock(data: Record<string, any>): React.ReactElement {
|
|
||||||
const title = data.heading || data.title || 'Hero Title';
|
|
||||||
const subtitle = data.subheading || data.subtitle;
|
|
||||||
const content = Array.isArray(data.content) ? data.content.join(' ') : data.content;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HeroBlock
|
|
||||||
title={title}
|
|
||||||
subtitle={subtitle}
|
|
||||||
ctaLabel={data.buttonText || data.ctaLabel}
|
|
||||||
onCtaClick={data.buttonLink || data.ctaLink ? () => window.location.href = (data.buttonLink || data.ctaLink) : undefined}
|
|
||||||
supportingContent={content ? <p>{content}</p> : undefined}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render text block using shared TextBlock component.
|
|
||||||
*/
|
|
||||||
function renderTextBlock(data: Record<string, any>): React.ReactElement {
|
|
||||||
const content = Array.isArray(data.content) ? data.content.join(' ') : data.content;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TextBlock
|
|
||||||
title={data.heading || data.title}
|
|
||||||
content={content || ''}
|
|
||||||
align={data.align || 'left'}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render image block.
|
|
||||||
*/
|
|
||||||
function renderImageBlock(data: Record<string, any>): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="block-image" style={{ padding: '1rem' }}>
|
|
||||||
{data.src && (
|
|
||||||
<img
|
|
||||||
src={data.src}
|
|
||||||
alt={data.alt || ''}
|
|
||||||
style={{ maxWidth: '100%', height: 'auto' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{data.caption && <p style={{ fontSize: '0.9rem', color: '#666' }}>{data.caption}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render button block.
|
|
||||||
*/
|
|
||||||
function renderButtonBlock(data: Record<string, any>): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="block-button" style={{ padding: '1rem', textAlign: data.align || 'center' }}>
|
|
||||||
<a
|
|
||||||
href={data.link || '#'}
|
|
||||||
className="button"
|
|
||||||
style={{
|
|
||||||
display: 'inline-block',
|
|
||||||
padding: '0.75rem 1.5rem',
|
|
||||||
background: data.color || '#007bff',
|
|
||||||
color: 'white',
|
|
||||||
textDecoration: 'none',
|
|
||||||
borderRadius: '4px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{data.text || 'Button'}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render section block.
|
|
||||||
*/
|
|
||||||
function renderSectionBlock(data: Record<string, any>): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<section className="block-section" style={{ padding: '3rem 2rem', background: data.background || 'transparent' }}>
|
|
||||||
{data.title && <h2>{data.title}</h2>}
|
|
||||||
{data.content && <div dangerouslySetInnerHTML={{ __html: data.content }} />}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render grid block.
|
|
||||||
*/
|
|
||||||
function renderGridBlock(data: Record<string, any>): React.ReactElement {
|
|
||||||
const columns = data.columns || 3;
|
|
||||||
return (
|
|
||||||
<div className="block-grid" style={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: '2rem', padding: '2rem' }}>
|
|
||||||
{data.items?.map((item: any, index: number) => (
|
|
||||||
<div key={index} className="grid-item">
|
|
||||||
{item.content && <div dangerouslySetInnerHTML={{ __html: item.content }} />}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render card block.
|
|
||||||
*/
|
|
||||||
function renderCardBlock(data: Record<string, any>): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="block-card" style={{ border: '1px solid #ddd', borderRadius: '8px', padding: '1.5rem', margin: '1rem' }}>
|
|
||||||
{data.title && <h3>{data.title}</h3>}
|
|
||||||
{data.content && <div dangerouslySetInnerHTML={{ __html: data.content }} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render list block.
|
|
||||||
*/
|
|
||||||
function renderListBlock(data: Record<string, any>): React.ReactElement {
|
|
||||||
const listType = data.ordered ? 'ol' : 'ul';
|
|
||||||
return React.createElement(
|
|
||||||
listType,
|
|
||||||
{ className: 'block-list', style: { padding: '1rem 2rem' } },
|
|
||||||
data.items?.map((item: string, index: number) => (
|
|
||||||
<li key={index}>{item}</li>
|
|
||||||
))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render quote block.
|
|
||||||
*/
|
|
||||||
function renderQuoteBlock(data: Record<string, any>): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<blockquote className="block-quote" style={{ padding: '2rem', borderLeft: '4px solid #007bff', margin: '2rem 0', fontStyle: 'italic' }}>
|
|
||||||
{data.quote && <p>{data.quote}</p>}
|
|
||||||
{data.author && <cite>— {data.author}</cite>}
|
|
||||||
</blockquote>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render video block.
|
|
||||||
*/
|
|
||||||
function renderVideoBlock(data: Record<string, any>): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="block-video" style={{ padding: '1rem' }}>
|
|
||||||
{data.src && (
|
|
||||||
<video controls style={{ maxWidth: '100%' }}>
|
|
||||||
<source src={data.src} type={data.type || 'video/mp4'} />
|
|
||||||
</video>
|
|
||||||
)}
|
|
||||||
{data.embed && <div dangerouslySetInnerHTML={{ __html: data.embed }} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render form block.
|
|
||||||
*/
|
|
||||||
function renderFormBlock(data: Record<string, any>): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<form className="block-form" style={{ padding: '2rem', maxWidth: '600px', margin: '0 auto' }}>
|
|
||||||
{data.fields?.map((field: any, index: number) => (
|
|
||||||
<div key={index} style={{ marginBottom: '1rem' }}>
|
|
||||||
<label>{field.label}</label>
|
|
||||||
<input
|
|
||||||
type={field.type || 'text'}
|
|
||||||
name={field.name}
|
|
||||||
placeholder={field.placeholder}
|
|
||||||
style={{ width: '100%', padding: '0.5rem' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button type="submit" style={{ padding: '0.75rem 1.5rem', background: '#007bff', color: 'white', border: 'none', borderRadius: '4px' }}>
|
|
||||||
{data.submitText || 'Submit'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render accordion block.
|
|
||||||
*/
|
|
||||||
function renderAccordionBlock(data: Record<string, any>): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="block-accordion" style={{ padding: '1rem' }}>
|
|
||||||
{data.items?.map((item: any, index: number) => (
|
|
||||||
<details key={index} style={{ marginBottom: '1rem', border: '1px solid #ddd', borderRadius: '4px', padding: '1rem' }}>
|
|
||||||
<summary style={{ cursor: 'pointer', fontWeight: 'bold' }}>{item.title}</summary>
|
|
||||||
<div style={{ marginTop: '1rem' }}>{item.content}</div>
|
|
||||||
</details>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render features block using shared FeatureGridBlock component.
|
|
||||||
*/
|
|
||||||
function renderFeaturesBlock(data: Record<string, any>): React.ReactElement {
|
|
||||||
const heading = data.heading || data.title;
|
|
||||||
const content = Array.isArray(data.content) ? data.content : [];
|
|
||||||
const layout = data.layout || 'two-column';
|
|
||||||
const columns = layout === 'two-column' ? 2 : layout === 'three-column' ? 3 : 3;
|
|
||||||
|
|
||||||
// Convert content array to features array
|
|
||||||
const features = content.map((item: string) => ({
|
|
||||||
title: item.split(':')[0] || item,
|
|
||||||
description: item.includes(':') ? item.split(':').slice(1).join(':').trim() : undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FeatureGridBlock
|
|
||||||
heading={heading}
|
|
||||||
features={features}
|
|
||||||
columns={columns as 2 | 3 | 4}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render testimonials block using shared TestimonialsBlock component.
|
|
||||||
*/
|
|
||||||
function renderTestimonialsBlock(data: Record<string, any>): React.ReactElement {
|
|
||||||
const title = data.heading || data.title;
|
|
||||||
const subtitle = data.subheading || data.subtitle;
|
|
||||||
const content = Array.isArray(data.content) ? data.content : [];
|
|
||||||
const layout = data.layout || 'cards';
|
|
||||||
|
|
||||||
// Convert content array to testimonials array
|
|
||||||
const testimonials = content.map((item: string) => ({
|
|
||||||
quote: item,
|
|
||||||
author: 'Customer', // Default author if not provided
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TestimonialsBlock
|
|
||||||
title={title}
|
|
||||||
subtitle={subtitle}
|
|
||||||
testimonials={testimonials}
|
|
||||||
columns={layout === 'cards' ? 3 : 1}
|
|
||||||
variant={layout === 'cards' ? 'card' : 'default'}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render FAQ block.
|
|
||||||
*/
|
|
||||||
function renderFAQBlock(data: Record<string, any>): React.ReactElement {
|
|
||||||
const heading = data.heading || data.title || 'FAQ';
|
|
||||||
const subheading = data.subheading || data.subtitle;
|
|
||||||
const content = Array.isArray(data.content) ? data.content : [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="block-faq" style={{ padding: '3rem 2rem' }}>
|
|
||||||
{heading && <h2 style={{ marginBottom: '1rem' }}>{heading}</h2>}
|
|
||||||
{subheading && <p style={{ color: '#666', marginBottom: '2rem' }}>{subheading}</p>}
|
|
||||||
<div>
|
|
||||||
{content.map((item: string, index: number) => (
|
|
||||||
<div key={index} style={{ marginBottom: '1rem', padding: '1rem', borderBottom: '1px solid #eee' }}>
|
|
||||||
<p><strong>Q:</strong> {item}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render CTA (Call to Action) block using shared CTABlock component.
|
|
||||||
*/
|
|
||||||
function renderCTABlock(data: Record<string, any>): React.ReactElement {
|
|
||||||
const title = data.heading || data.title || 'Call to Action';
|
|
||||||
const subtitle = data.subheading || data.subtitle;
|
|
||||||
const content = Array.isArray(data.content) ? data.content[0] : data.content;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CTABlock
|
|
||||||
title={title}
|
|
||||||
subtitle={subtitle}
|
|
||||||
primaryCtaLabel={data.buttonText || data.ctaLabel}
|
|
||||||
primaryCtaLink={data.buttonLink || data.ctaLink}
|
|
||||||
backgroundImage={data.backgroundImage}
|
|
||||||
variant={data.layout === 'full-width' ? 'centered' : 'default'}
|
|
||||||
>
|
|
||||||
{content && <p>{content}</p>}
|
|
||||||
</CTABlock>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render services block using shared ServicesBlock component.
|
|
||||||
*/
|
|
||||||
function renderServicesBlock(data: Record<string, any>): React.ReactElement {
|
|
||||||
const title = data.heading || data.title;
|
|
||||||
const subtitle = data.subheading || data.subtitle;
|
|
||||||
const content = Array.isArray(data.content) ? data.content : [];
|
|
||||||
|
|
||||||
// Convert content array to services array
|
|
||||||
const services = content.map((item: string) => ({
|
|
||||||
title: item.split(':')[0] || item,
|
|
||||||
description: item.includes(':') ? item.split(':').slice(1).join(':').trim() : undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ServicesBlock
|
|
||||||
title={title}
|
|
||||||
subtitle={subtitle}
|
|
||||||
services={services}
|
|
||||||
columns={3}
|
|
||||||
variant="card"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render stats block using shared StatsPanel component.
|
|
||||||
*/
|
|
||||||
function renderStatsBlock(data: Record<string, any>): React.ReactElement {
|
|
||||||
const content = Array.isArray(data.content) ? data.content : [];
|
|
||||||
|
|
||||||
// Convert content array to stats array
|
|
||||||
const stats = content.map((item: string) => {
|
|
||||||
// Try to parse "Label: Value" format
|
|
||||||
const parts = item.split(':');
|
|
||||||
if (parts.length >= 2) {
|
|
||||||
return {
|
|
||||||
label: parts[0].trim(),
|
|
||||||
value: parts.slice(1).join(':').trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
label: item,
|
|
||||||
value: '',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StatsPanel
|
|
||||||
heading={data.heading || data.title}
|
|
||||||
stats={stats}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
|
|
||||||
/* Path mapping */
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@shared/*": ["../frontend/src/components/shared/*"]
|
|
||||||
},
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedSideEffectImports": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"files": [],
|
|
||||||
"references": [
|
|
||||||
{ "path": "./tsconfig.app.json" },
|
|
||||||
{ "path": "./tsconfig.node.json" }
|
|
||||||
],
|
|
||||||
"compilerOptions": {
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@shared/*": ["../frontend/src/components/shared/*", "/frontend/src/components/shared/*"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"lib": ["ES2023"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"noFallthroughCasesInSwitch": true
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { defineConfig } from 'vite';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
const sharedPathCandidates = [
|
|
||||||
path.resolve(__dirname, '../frontend/src/components/shared'),
|
|
||||||
path.resolve(__dirname, '../../frontend/src/components/shared'),
|
|
||||||
'/frontend/src/components/shared',
|
|
||||||
path.resolve(__dirname, '../../frontend/src/components/shared'), // Try parent of parent
|
|
||||||
];
|
|
||||||
const sharedComponentsPath = sharedPathCandidates.find((candidate) => fs.existsSync(candidate)) ?? sharedPathCandidates[0];
|
|
||||||
|
|
||||||
// Log for debugging
|
|
||||||
console.log('Shared components path:', sharedComponentsPath);
|
|
||||||
console.log('Path exists:', fs.existsSync(sharedComponentsPath));
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@shared': sharedComponentsPath,
|
|
||||||
// Explicit aliases for CSS files to ensure Vite resolves them
|
|
||||||
'@shared/blocks/blocks.css': path.join(sharedComponentsPath, 'blocks/blocks.css'),
|
|
||||||
'@shared/layouts/layouts.css': path.join(sharedComponentsPath, 'layouts/layouts.css'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
host: '0.0.0.0',
|
|
||||||
port: 5176,
|
|
||||||
allowedHosts: ['sites.igny8.com', 'builder.igny8.com'],
|
|
||||||
fs: {
|
|
||||||
allow: [path.resolve(__dirname, '..'), sharedComponentsPath],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
rollupOptions: {
|
|
||||||
output: {
|
|
||||||
manualChunks: {
|
|
||||||
// Code-split builder routes to avoid loading in public sites
|
|
||||||
'builder': ['./src/builder/pages/wizard/WizardPage', './src/builder/pages/preview/PreviewCanvas', './src/builder/pages/dashboard/SiteDashboard'],
|
|
||||||
// Vendor chunks
|
|
||||||
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
|
|
||||||
'vendor-ui': ['lucide-react', 'zustand'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
test: {
|
|
||||||
environment: 'jsdom',
|
|
||||||
globals: true,
|
|
||||||
setupFiles: ['./src/__tests__/setup.ts'],
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': path.resolve(__dirname, './src'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Script to reproduce useLocation() error on demand
|
|
||||||
# Run this to trigger conditions that cause the error
|
|
||||||
# If error appears = bug exists, If no error = bug is fixed
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "=========================================="
|
|
||||||
echo "useLocation() Error Reproduction Script"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
echo "This script will trigger conditions known to cause:"
|
|
||||||
echo "'useLocation() may be used only in the context of a <Router> component'"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Function to check if error appears in browser console
|
|
||||||
function wait_for_check() {
|
|
||||||
echo ""
|
|
||||||
echo "⚠️ NOW CHECK YOUR BROWSER:"
|
|
||||||
echo " 1. Open: https://app.igny8.com/writer/tasks"
|
|
||||||
echo " 2. Open Developer Console (F12)"
|
|
||||||
echo " 3. Look for: 'useLocation() may be used only in the context of a <Router> component'"
|
|
||||||
echo ""
|
|
||||||
echo " ✅ NO ERROR = Bug is FIXED"
|
|
||||||
echo " ❌ ERROR APPEARS = Bug still EXISTS"
|
|
||||||
echo ""
|
|
||||||
read -p "Press Enter after checking..."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Test 1: Clear Vite cache and restart frontend
|
|
||||||
function test1_clear_cache_restart() {
|
|
||||||
echo ""
|
|
||||||
echo "=========================================="
|
|
||||||
echo "TEST 1: Clear Vite Cache + Restart Frontend"
|
|
||||||
echo "=========================================="
|
|
||||||
echo "This simulates container operation that wipes cache..."
|
|
||||||
|
|
||||||
echo "Clearing Vite cache..."
|
|
||||||
docker compose exec igny8_frontend rm -rf /app/node_modules/.vite || true
|
|
||||||
|
|
||||||
echo "Restarting frontend container..."
|
|
||||||
docker compose restart igny8_frontend
|
|
||||||
|
|
||||||
echo "Waiting for frontend to start (30 seconds)..."
|
|
||||||
sleep 30
|
|
||||||
|
|
||||||
wait_for_check
|
|
||||||
}
|
|
||||||
|
|
||||||
# Test 2: Just restart frontend (no cache clear)
|
|
||||||
function test2_restart_only() {
|
|
||||||
echo ""
|
|
||||||
echo "=========================================="
|
|
||||||
echo "TEST 2: Restart Frontend Only (No Cache Clear)"
|
|
||||||
echo "=========================================="
|
|
||||||
|
|
||||||
echo "Restarting frontend container..."
|
|
||||||
docker compose restart igny8_frontend
|
|
||||||
|
|
||||||
echo "Waiting for frontend to start (30 seconds)..."
|
|
||||||
sleep 30
|
|
||||||
|
|
||||||
wait_for_check
|
|
||||||
}
|
|
||||||
|
|
||||||
# Test 3: Clear cache without restart
|
|
||||||
function test3_cache_only() {
|
|
||||||
echo ""
|
|
||||||
echo "=========================================="
|
|
||||||
echo "TEST 3: Clear Vite Cache Only (No Restart)"
|
|
||||||
echo "=========================================="
|
|
||||||
echo "Checking if HMR triggers the error..."
|
|
||||||
|
|
||||||
echo "Clearing Vite cache..."
|
|
||||||
docker compose exec igny8_frontend rm -rf /app/node_modules/.vite
|
|
||||||
|
|
||||||
echo "Waiting for HMR rebuild (20 seconds)..."
|
|
||||||
sleep 20
|
|
||||||
|
|
||||||
wait_for_check
|
|
||||||
}
|
|
||||||
|
|
||||||
# Test 4: Multiple rapid restarts
|
|
||||||
function test4_rapid_restarts() {
|
|
||||||
echo ""
|
|
||||||
echo "=========================================="
|
|
||||||
echo "TEST 4: Rapid Container Restarts (Stress Test)"
|
|
||||||
echo "=========================================="
|
|
||||||
echo "This simulates multiple deployment cycles..."
|
|
||||||
|
|
||||||
for i in {1..3}; do
|
|
||||||
echo "Restart $i/3..."
|
|
||||||
docker compose restart igny8_frontend
|
|
||||||
sleep 10
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Waiting for final startup (30 seconds)..."
|
|
||||||
sleep 30
|
|
||||||
|
|
||||||
wait_for_check
|
|
||||||
}
|
|
||||||
|
|
||||||
# Main menu
|
|
||||||
echo "Select test to run:"
|
|
||||||
echo "1) Clear cache + restart (most likely to trigger bug)"
|
|
||||||
echo "2) Restart only"
|
|
||||||
echo "3) Clear cache only (HMR test)"
|
|
||||||
echo "4) Rapid restarts (stress test)"
|
|
||||||
echo "5) Run all tests"
|
|
||||||
echo "q) Quit"
|
|
||||||
echo ""
|
|
||||||
read -p "Enter choice: " choice
|
|
||||||
|
|
||||||
case $choice in
|
|
||||||
1) test1_clear_cache_restart ;;
|
|
||||||
2) test2_restart_only ;;
|
|
||||||
3) test3_cache_only ;;
|
|
||||||
4) test4_rapid_restarts ;;
|
|
||||||
5)
|
|
||||||
test1_clear_cache_restart
|
|
||||||
test2_restart_only
|
|
||||||
test3_cache_only
|
|
||||||
test4_rapid_restarts
|
|
||||||
;;
|
|
||||||
q) exit 0 ;;
|
|
||||||
*) echo "Invalid choice"; exit 1 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=========================================="
|
|
||||||
echo "Testing Complete"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
echo "If you saw the useLocation() error:"
|
|
||||||
echo " → Bug still exists, needs investigation"
|
|
||||||
echo ""
|
|
||||||
echo "If NO error appeared in any test:"
|
|
||||||
echo " → Bug is FIXED! ✅"
|
|
||||||
echo ""
|
|
||||||
Reference in New Issue
Block a user