cleanup thorough

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-10 15:50:09 +00:00
parent aba2c7da01
commit 87d1392b4c
40 changed files with 3646 additions and 9281 deletions

View File

@@ -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**.

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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"]

View File

@@ -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 },
],
},
},
)

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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');
});
});

View File

@@ -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: () => {},
}),
});

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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}`);
}
}

View File

@@ -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>,
);

View File

@@ -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;

View File

@@ -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}</>;
}

View File

@@ -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>;
}

View File

@@ -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();
}

View File

@@ -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

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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"]
}

View File

@@ -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/*"]
}
}
}

View File

@@ -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"]
}

View File

@@ -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'],
},
},
},
},
});

View File

@@ -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'),
},
},
});

View File

@@ -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 ""