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