Refactor Site Builder Integration and Update Docker Configuration
- Merged the site builder functionality into the main app, enhancing the SiteBuilderWizard component with new steps and improved UI. - Updated the Docker Compose configuration by removing the separate site builder service and integrating its functionality into the igny8_sites service. - Enhanced Vite configuration to support code-splitting for builder routes, optimizing loading times. - Updated package dependencies to include new libraries for state management and form handling.
This commit is contained in:
264
SITE_BUILDER_INTEGRATION_PLAN.md
Normal file
264
SITE_BUILDER_INTEGRATION_PLAN.md
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
# Site Builder Wizard Integration Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Integrate the Site Builder wizard directly into the main frontend app (`frontend/src/pages/Sites/Builder/`), using the same UI kit, state stores, and API helpers as the rest of the dashboard. The legacy `sites/src/builder` + `sites/src/renderer` code has been removed, so the only viable implementation path is the unified Sites module.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
### ✅ What's Done
|
||||||
|
- Legacy builder/renderer folders removed from Sites container (no more parallel UI)
|
||||||
|
- Type definitions created in `frontend/src/types/siteBuilder.ts`
|
||||||
|
- API helper created in `frontend/src/services/siteBuilder.api.ts`
|
||||||
|
|
||||||
|
### ⚠️ What's Missing
|
||||||
|
- Builder store not yet created in the main app
|
||||||
|
- Wizard steps/page still placeholder in `frontend/src/pages/Sites/Builder/`
|
||||||
|
- No Tailwind/CX styling hooked into shared UI kit
|
||||||
|
- Routes/menu point to placeholder
|
||||||
|
- Tests/docs still reference old structure
|
||||||
|
- Sites container still contains stale references (needs cleanup after integration)
|
||||||
|
|
||||||
|
## Integration Plan
|
||||||
|
|
||||||
|
### Phase 1: Create API Service Layer ✅
|
||||||
|
**Location**: `frontend/src/services/siteBuilder.api.ts`
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
1. Create `siteBuilderApi` using `fetchAPI` pattern (not axios)
|
||||||
|
2. Functions needed:
|
||||||
|
- `listBlueprints()`
|
||||||
|
- `createBlueprint(payload)`
|
||||||
|
- `generateStructure(blueprintId, payload)`
|
||||||
|
- `listPages(blueprintId)`
|
||||||
|
- `generateAllPages(blueprintId, options)`
|
||||||
|
- `createTasksForPages(blueprintId, pageIds)`
|
||||||
|
|
||||||
|
**API Endpoints** (already exist in backend):
|
||||||
|
- `GET /api/v1/site-builder/blueprints/`
|
||||||
|
- `POST /api/v1/site-builder/blueprints/`
|
||||||
|
- `POST /api/v1/site-builder/blueprints/{id}/generate_structure/`
|
||||||
|
- `GET /api/v1/site-builder/pages/?site_blueprint={id}`
|
||||||
|
- `POST /api/v1/site-builder/blueprints/{id}/generate_all_pages/`
|
||||||
|
- `POST /api/v1/site-builder/blueprints/{id}/create_tasks/`
|
||||||
|
|
||||||
|
### Phase 2: Create Zustand Store ⏳
|
||||||
|
**Location**: `frontend/src/store/builderStore.ts`
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
1. Copy `builderStore.ts` from `sites/src/builder/state/`
|
||||||
|
2. Adapt to use `siteBuilderApi` instead of `builderApi`
|
||||||
|
3. Integrate with `useSiteStore` and `useSectorStore`:
|
||||||
|
- Auto-populate `siteId` from `useSiteStore().activeSite`
|
||||||
|
- Auto-populate `sectorId` from `useSectorStore().activeSector`
|
||||||
|
- Show site/sector selector if not set
|
||||||
|
|
||||||
|
**Store State**:
|
||||||
|
- `form: BuilderFormData` - Wizard form data
|
||||||
|
- `currentStep: number` - Current wizard step (0-3)
|
||||||
|
- `isSubmitting: boolean` - Generation in progress
|
||||||
|
- `activeBlueprint: SiteBlueprint | null` - Latest blueprint
|
||||||
|
- `pages: PageBlueprint[]` - Generated pages
|
||||||
|
- `error: string | null` - Error message
|
||||||
|
|
||||||
|
### Phase 3: Create Type Definitions ✅
|
||||||
|
**Location**: `frontend/src/types/siteBuilder.ts`
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
1. Copy types from `sites/src/builder/types/siteBuilder.ts`
|
||||||
|
2. Ensure compatibility with frontend's existing types
|
||||||
|
|
||||||
|
**Types Needed**:
|
||||||
|
- `HostingType`
|
||||||
|
- `StylePreferences`
|
||||||
|
- `BuilderFormData`
|
||||||
|
- `SiteBlueprint`
|
||||||
|
- `PageBlueprint`
|
||||||
|
- `PageBlock`
|
||||||
|
- `SiteStructure`
|
||||||
|
|
||||||
|
### Phase 4: Create Wizard Step Components ⏳
|
||||||
|
**Location**: `frontend/src/pages/Sites/Builder/steps/`
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
1. Copy step components from `sites/src/builder/pages/wizard/steps/`
|
||||||
|
2. Adapt to use frontend's UI components:
|
||||||
|
- Replace `Card` with `frontend/src/components/ui/card/Card`
|
||||||
|
- Replace custom inputs with Tailwind-styled inputs
|
||||||
|
- Use frontend's `Button` component
|
||||||
|
3. Adapt styles to Tailwind CSS:
|
||||||
|
- Remove `.sb-field`, `.sb-grid`, `.sb-pill` classes
|
||||||
|
- Use Tailwind utility classes instead
|
||||||
|
|
||||||
|
**Step Components**:
|
||||||
|
- `BusinessDetailsStep.tsx` - Site/sector selection, business info
|
||||||
|
- `BriefStep.tsx` - Business brief textarea
|
||||||
|
- `ObjectivesStep.tsx` - Objectives list with add/remove
|
||||||
|
- `StyleStep.tsx` - Style preferences (palette, typography, personality)
|
||||||
|
|
||||||
|
### Phase 5: Create Main Wizard Page ⏳
|
||||||
|
**Location**: `frontend/src/pages/Sites/Builder/Wizard.tsx`
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
1. Copy `WizardPage.tsx` from `sites/src/builder/pages/wizard/`
|
||||||
|
2. Adapt to frontend patterns:
|
||||||
|
- Use `PageMeta` component
|
||||||
|
- Use frontend's `Card` component
|
||||||
|
- Use frontend's `Button` component
|
||||||
|
- Use Tailwind CSS for styling
|
||||||
|
3. Integrate with stores:
|
||||||
|
- Auto-load active site/sector
|
||||||
|
- Show site/sector selector if needed
|
||||||
|
- Navigate to sites list on completion
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- 4-step wizard with progress indicators
|
||||||
|
- Step navigation (Back/Next buttons)
|
||||||
|
- Form validation
|
||||||
|
- Blueprint generation on submit
|
||||||
|
- Error handling
|
||||||
|
- Loading states
|
||||||
|
|
||||||
|
### Phase 6: Create Site Definition Store (Optional) ⏳
|
||||||
|
**Location**: `frontend/src/store/siteDefinitionStore.ts`
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
1. Copy `siteDefinitionStore.ts` from `sites/src/builder/state/`
|
||||||
|
2. Use for preview functionality (if needed)
|
||||||
|
|
||||||
|
### Phase 7: Update Routing & Navigation ⏳
|
||||||
|
**Location**: `frontend/src/App.tsx`
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
1. Ensure `/sites/builder` route points to new `Wizard.tsx`
|
||||||
|
2. Update navigation to show wizard in Sites section
|
||||||
|
|
||||||
|
### Phase 8: Fix Test File ⏳
|
||||||
|
**Location**: `frontend/src/__tests__/sites/BulkGeneration.test.tsx`
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
1. Update import path from `site-builder/src/api/builder.api` to `services/siteBuilder.api`
|
||||||
|
2. Update mock path accordingly
|
||||||
|
|
||||||
|
### Phase 9: Testing ⏳
|
||||||
|
**Tasks**:
|
||||||
|
1. Test wizard flow:
|
||||||
|
- Site selection
|
||||||
|
- Sector selection
|
||||||
|
- All 4 wizard steps
|
||||||
|
- Blueprint generation
|
||||||
|
- Error handling
|
||||||
|
2. Test integration:
|
||||||
|
- Site/sector auto-population
|
||||||
|
- Navigation
|
||||||
|
- API calls
|
||||||
|
|
||||||
|
### Phase 10: Cleanup ⏳
|
||||||
|
**Tasks**:
|
||||||
|
1. Stop `igny8_site_builder` container
|
||||||
|
2. Remove Docker image
|
||||||
|
3. Remove `/site-builder` folder
|
||||||
|
4. Update documentation
|
||||||
|
|
||||||
|
## File Structure After Integration
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
├── pages/Sites/Builder/
|
||||||
|
│ ├── Wizard.tsx # Main wizard page (UPDATED)
|
||||||
|
│ ├── Preview.tsx # Preview page (keep placeholder for now)
|
||||||
|
│ ├── Blueprints.tsx # Blueprints list (already exists)
|
||||||
|
│ └── steps/ # NEW
|
||||||
|
│ ├── BusinessDetailsStep.tsx
|
||||||
|
│ ├── BriefStep.tsx
|
||||||
|
│ ├── ObjectivesStep.tsx
|
||||||
|
│ └── StyleStep.tsx
|
||||||
|
├── services/
|
||||||
|
│ └── siteBuilder.api.ts # NEW - API service
|
||||||
|
├── store/
|
||||||
|
│ ├── builderStore.ts # NEW - Builder state
|
||||||
|
│ └── siteDefinitionStore.ts # NEW - Site definition state (optional)
|
||||||
|
└── types/
|
||||||
|
└── siteBuilder.ts # NEW - Type definitions
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Adaptations Needed
|
||||||
|
|
||||||
|
### 1. API Client Pattern
|
||||||
|
**From** (sites container):
|
||||||
|
```typescript
|
||||||
|
import axios from 'axios';
|
||||||
|
const client = axios.create({ baseURL: BASE_PATH });
|
||||||
|
```
|
||||||
|
|
||||||
|
**To** (frontend):
|
||||||
|
```typescript
|
||||||
|
import { fetchAPI } from '../services/api';
|
||||||
|
// Use fetchAPI directly, no axios
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Component Library
|
||||||
|
**From** (sites container):
|
||||||
|
```typescript
|
||||||
|
import { Card } from '../../components/common/Card';
|
||||||
|
```
|
||||||
|
|
||||||
|
**To** (frontend):
|
||||||
|
```typescript
|
||||||
|
import { Card } from '../../../components/ui/card/Card';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Styling
|
||||||
|
**From** (sites container):
|
||||||
|
```css
|
||||||
|
.sb-field { ... }
|
||||||
|
.sb-grid { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**To** (frontend):
|
||||||
|
```tsx
|
||||||
|
className="flex flex-col gap-2"
|
||||||
|
className="grid grid-cols-2 gap-4"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Store Integration
|
||||||
|
**From** (sites container):
|
||||||
|
```typescript
|
||||||
|
// Manual siteId/sectorId input
|
||||||
|
```
|
||||||
|
|
||||||
|
**To** (frontend):
|
||||||
|
```typescript
|
||||||
|
import { useSiteStore } from '../../../store/siteStore';
|
||||||
|
import { useSectorStore } from '../../../store/sectorStore';
|
||||||
|
// Auto-populate from stores
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. ✅ Create types (`types/siteBuilder.ts`)
|
||||||
|
2. ✅ Create API service (`services/siteBuilder.api.ts`)
|
||||||
|
3. ⏳ Create builder store (`store/builderStore.ts`)
|
||||||
|
4. ⏳ Create step components (`pages/Sites/Builder/steps/`)
|
||||||
|
5. ⏳ Create main wizard page (`pages/Sites/Builder/Wizard.tsx`)
|
||||||
|
6. ⏳ Fix test file(s)
|
||||||
|
7. ⏳ Test integration
|
||||||
|
8. ⏳ Cleanup site-builder container/image/docs
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- ✅ Wizard loads in main app at `/sites/builder`
|
||||||
|
- ✅ Site/sector auto-populated from stores
|
||||||
|
- ✅ All 4 steps work correctly
|
||||||
|
- ✅ Blueprint generation works
|
||||||
|
- ✅ Error handling works
|
||||||
|
- ✅ Navigation works
|
||||||
|
- ✅ No references to `site-builder/` folder in code
|
||||||
|
- ✅ Test file updated
|
||||||
|
- ✅ Sites container removed or marked deprecated in compose
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Sites container will be deprecated once the wizard lives entirely inside the main app.
|
||||||
|
- Only integrate wizard into main frontend app (no parallel codepaths).
|
||||||
|
- Use frontend's existing patterns/components/stores for absolute consistency.
|
||||||
|
|
||||||
Binary file not shown.
@@ -101,33 +101,14 @@ services:
|
|||||||
- "com.docker.compose.project=igny8-app"
|
- "com.docker.compose.project=igny8-app"
|
||||||
- "com.docker.compose.service=igny8_marketing_dev"
|
- "com.docker.compose.service=igny8_marketing_dev"
|
||||||
|
|
||||||
igny8_site_builder:
|
|
||||||
image: igny8-site-builder-dev:latest
|
|
||||||
container_name: igny8_site_builder
|
|
||||||
restart: always
|
|
||||||
ports:
|
|
||||||
- "0.0.0.0:8025:5175"
|
|
||||||
environment:
|
|
||||||
VITE_API_URL: "https://api.igny8.com/api"
|
|
||||||
volumes:
|
|
||||||
- /data/app/igny8/site-builder:/app:rw
|
|
||||||
- /data/app/igny8/frontend:/frontend:ro
|
|
||||||
depends_on:
|
|
||||||
igny8_backend:
|
|
||||||
condition: service_healthy
|
|
||||||
networks: [igny8_net]
|
|
||||||
labels:
|
|
||||||
- "com.docker.compose.project=igny8-app"
|
|
||||||
- "com.docker.compose.service=igny8_site_builder"
|
|
||||||
|
|
||||||
igny8_sites:
|
igny8_sites:
|
||||||
# Sites renderer for hosting public sites
|
# Sites container: Public site renderer + Site Builder (merged)
|
||||||
# Build separately: docker build -t igny8-sites-dev:latest -f Dockerfile.dev .
|
# Build separately: docker build -t igny8-sites-dev:latest -f Dockerfile.dev .
|
||||||
image: igny8-sites-dev:latest
|
image: igny8-sites-dev:latest
|
||||||
container_name: igny8_sites
|
container_name: igny8_sites
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "0.0.0.0:8024:5176" # Sites renderer dev server port
|
- "0.0.0.0:8024:5176" # Sites renderer + Builder dev server port
|
||||||
environment:
|
environment:
|
||||||
VITE_API_URL: "https://api.igny8.com/api"
|
VITE_API_URL: "https://api.igny8.com/api"
|
||||||
SITES_DATA_PATH: "/sites"
|
SITES_DATA_PATH: "/sites"
|
||||||
|
|||||||
@@ -1,43 +1,273 @@
|
|||||||
/**
|
import { useEffect, useMemo } from "react";
|
||||||
* Site Builder Wizard
|
import { useNavigate } from "react-router-dom";
|
||||||
* Moved from site-builder container to main app
|
import {
|
||||||
* TODO: Migrate full implementation from site-builder/src/pages/wizard/
|
Card,
|
||||||
*/
|
CardDescription,
|
||||||
import React from 'react';
|
CardTitle,
|
||||||
import { useNavigate } from 'react-router-dom';
|
} from "../../../components/ui/card";
|
||||||
import PageMeta from '../../../components/common/PageMeta';
|
import Button from "../../../components/ui/button/Button";
|
||||||
import { Card } from '../../../components/ui/card';
|
import PageMeta from "../../../components/common/PageMeta";
|
||||||
import Button from '../../../components/ui/button/Button';
|
import SiteAndSectorSelector from "../../../components/common/SiteAndSectorSelector";
|
||||||
import { Wand2 } from 'lucide-react';
|
import Alert from "../../../components/ui/alert/Alert";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
PlayCircle,
|
||||||
|
RefreshCw,
|
||||||
|
Wand2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useSiteStore } from "../../../store/siteStore";
|
||||||
|
import { useSectorStore } from "../../../store/sectorStore";
|
||||||
|
import { useBuilderStore } from "../../../store/builderStore";
|
||||||
|
import { BusinessDetailsStep } from "./steps/BusinessDetailsStep";
|
||||||
|
import { BriefStep } from "./steps/BriefStep";
|
||||||
|
import { ObjectivesStep } from "./steps/ObjectivesStep";
|
||||||
|
import { StyleStep } from "./steps/StyleStep";
|
||||||
|
|
||||||
export default function SiteBuilderWizard() {
|
export default function SiteBuilderWizard() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { activeSite } = useSiteStore();
|
||||||
|
const { activeSector } = useSectorStore();
|
||||||
|
const {
|
||||||
|
form,
|
||||||
|
currentStep,
|
||||||
|
setStep,
|
||||||
|
setField,
|
||||||
|
updateStyle,
|
||||||
|
addObjective,
|
||||||
|
removeObjective,
|
||||||
|
nextStep,
|
||||||
|
previousStep,
|
||||||
|
submitWizard,
|
||||||
|
isSubmitting,
|
||||||
|
error,
|
||||||
|
activeBlueprint,
|
||||||
|
refreshPages,
|
||||||
|
pages,
|
||||||
|
generationProgress,
|
||||||
|
isGenerating,
|
||||||
|
syncContextFromStores,
|
||||||
|
} = useBuilderStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
syncContextFromStores();
|
||||||
|
}, [activeSite?.id, activeSite?.name, activeSector?.id]);
|
||||||
|
|
||||||
|
const steps = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
title: "Business context",
|
||||||
|
component: (
|
||||||
|
<BusinessDetailsStep data={form} onChange={setField} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Brand brief",
|
||||||
|
component: <BriefStep data={form} onChange={setField} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Objectives",
|
||||||
|
component: (
|
||||||
|
<ObjectivesStep
|
||||||
|
data={form}
|
||||||
|
addObjective={addObjective}
|
||||||
|
removeObjective={removeObjective}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Look & feel",
|
||||||
|
component: (
|
||||||
|
<StyleStep
|
||||||
|
style={form.style}
|
||||||
|
onChange={updateStyle}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[form, setField, updateStyle, addObjective, removeObjective],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLastStep = currentStep === steps.length - 1;
|
||||||
|
const missingContext = !activeSite || !activeSector;
|
||||||
|
|
||||||
|
const handlePrimary = async () => {
|
||||||
|
if (isLastStep) {
|
||||||
|
await submitWizard();
|
||||||
|
} else {
|
||||||
|
nextStep();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="space-y-6 p-6">
|
||||||
<PageMeta title="Site Builder - IGNY8" />
|
<PageMeta title="Create Site - IGNY8" />
|
||||||
<div className="mb-6">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wider text-gray-500 dark:text-white/50">
|
||||||
|
Sites / Create Site
|
||||||
|
</p>
|
||||||
|
<h1 className="text-3xl font-semibold text-gray-900 dark:text-white">
|
||||||
Site Builder
|
Site Builder
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
Create a new site using AI-powered wizard
|
Create a new site using IGNY8’s AI-powered wizard. Align the estate,
|
||||||
|
strategy, and tone before publishing.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
<Card className="p-12 text-center">
|
variant="outline"
|
||||||
<Wand2 className="w-16 h-16 mx-auto mb-4 text-gray-400" />
|
onClick={() => navigate("/sites")}
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
startIcon={<Wand2 size={16} />}
|
||||||
Site Builder Wizard
|
>
|
||||||
</h2>
|
Back to sites
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
|
||||||
The Site Builder wizard is being integrated into the main app.
|
|
||||||
Full implementation coming soon.
|
|
||||||
</p>
|
|
||||||
<Button onClick={() => navigate('/sites')} variant="outline">
|
|
||||||
Back to Sites
|
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SiteAndSectorSelector />
|
||||||
|
|
||||||
|
{missingContext && (
|
||||||
|
<Alert
|
||||||
|
variant="warning"
|
||||||
|
title="Select site & sector"
|
||||||
|
message="Choose an active site and sector using the selector above before running the wizard."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[2fr,1fr]">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card variant="panel" padding="lg">
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<button
|
||||||
|
key={step.title}
|
||||||
|
type="button"
|
||||||
|
onClick={() => index < currentStep + 1 && setStep(index)}
|
||||||
|
className={`flex flex-col items-start rounded-2xl border px-4 py-3 text-left transition ${
|
||||||
|
index === currentStep
|
||||||
|
? "border-brand-300 bg-brand-50 dark:border-brand-500/40 dark:bg-brand-500/10"
|
||||||
|
: "border-gray-200 bg-white dark:border-white/10 dark:bg-white/[0.02]"
|
||||||
|
}`}
|
||||||
|
disabled={index > currentStep}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||||
|
Step {index + 1}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{step.title}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{steps[currentStep].component}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="error" title="Something went wrong" message={error} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 border-t border-gray-100 pt-4 dark:border-white/10 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Step {currentStep + 1} of {steps.length}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
tone="neutral"
|
||||||
|
disabled={currentStep === 0 || isSubmitting}
|
||||||
|
onClick={previousStep}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
tone="brand"
|
||||||
|
disabled={missingContext || isSubmitting}
|
||||||
|
onClick={handlePrimary}
|
||||||
|
startIcon={
|
||||||
|
isSubmitting ? (
|
||||||
|
<Loader2 className="animate-spin" size={16} />
|
||||||
|
) : isLastStep ? (
|
||||||
|
<PlayCircle size={16} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isLastStep ? "Generate structure" : "Next"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card variant="surface" padding="lg">
|
||||||
|
<CardTitle>Latest blueprint</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Once the wizard finishes, the most recent blueprint appears here.
|
||||||
|
</CardDescription>
|
||||||
|
{activeBlueprint ? (
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<div className="flex items-center justify-between rounded-2xl bg-gray-50 px-4 py-3 text-sm font-semibold text-gray-700 dark:bg-white/[0.04] dark:text-white/80">
|
||||||
|
<span>Status</span>
|
||||||
|
<span className="capitalize">{activeBlueprint.status}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-2xl bg-gray-50 px-4 py-3 text-sm font-semibold text-gray-700 dark:bg-white/[0.04] dark:text-white/80">
|
||||||
|
<span>Pages generated</span>
|
||||||
|
<span>{pages.length}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
tone="brand"
|
||||||
|
fullWidth
|
||||||
|
startIcon={<RefreshCw size={16} />}
|
||||||
|
onClick={() => refreshPages(activeBlueprint.id)}
|
||||||
|
>
|
||||||
|
Sync pages
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 rounded-2xl border border-dashed border-gray-200 bg-gray-50/60 p-6 text-center text-sm text-gray-500 dark:border-white/10 dark:bg-white/[0.03] dark:text-white/60">
|
||||||
|
Run the wizard to create your first blueprint.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{generationProgress && (
|
||||||
|
<Card variant="panel" padding="lg">
|
||||||
|
<CardTitle>Generation progress</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Tracking background tasks queued for this blueprint.
|
||||||
|
</CardDescription>
|
||||||
|
<div className="mt-4 space-y-3 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<div className="flex items-center justify-between rounded-xl bg-white/70 px-3 py-2 dark:bg-white/[0.04]">
|
||||||
|
<span>Pages queued</span>
|
||||||
|
<span>{generationProgress.pagesQueued}</span>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-white/70 px-3 py-2 text-xs dark:bg-white/[0.04]">
|
||||||
|
<p className="font-semibold text-gray-800 dark:text-white/90">
|
||||||
|
Task IDs
|
||||||
|
</p>
|
||||||
|
<p className="break-all">
|
||||||
|
{generationProgress.taskIds.join(", ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{generationProgress.celeryTaskId && (
|
||||||
|
<p className="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Celery task ID: {generationProgress.celeryTaskId}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isGenerating && (
|
||||||
|
<Alert
|
||||||
|
variant="info"
|
||||||
|
title="Background generation running"
|
||||||
|
message="You can leave this page safely. We’ll keep processing and update the blueprint when tasks finish."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
48
frontend/src/pages/Sites/Builder/steps/BriefStep.tsx
Normal file
48
frontend/src/pages/Sites/Builder/steps/BriefStep.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import type { BuilderFormData } from "../../../../types/siteBuilder";
|
||||||
|
import { Card } from "../../../../components/ui/card";
|
||||||
|
|
||||||
|
const labelClass =
|
||||||
|
"text-sm font-semibold text-gray-700 dark:text-white/80 mb-2 inline-block";
|
||||||
|
const textareaClass =
|
||||||
|
"w-full rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-900 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-white/10 dark:bg-white/[0.03] dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: BuilderFormData;
|
||||||
|
onChange: <K extends keyof BuilderFormData>(
|
||||||
|
key: K,
|
||||||
|
value: BuilderFormData[K],
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BriefStep({ data, onChange }: Props) {
|
||||||
|
return (
|
||||||
|
<Card variant="surface" padding="lg">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wider text-gray-500 dark:text-white/50">
|
||||||
|
Brand narrative
|
||||||
|
</p>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Business brief
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Describe the brand, the offer, and what makes it unique. The more
|
||||||
|
context we provide, the more precise the structure.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>What's the story?</label>
|
||||||
|
<textarea
|
||||||
|
rows={10}
|
||||||
|
className={textareaClass}
|
||||||
|
value={data.businessBrief}
|
||||||
|
placeholder="Acme Robotics builds autonomous fulfillment robots that reduce warehouse picking time by 60%..."
|
||||||
|
onChange={(event) => onChange("businessBrief", event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
125
frontend/src/pages/Sites/Builder/steps/BusinessDetailsStep.tsx
Normal file
125
frontend/src/pages/Sites/Builder/steps/BusinessDetailsStep.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import type { BuilderFormData } from "../../../../types/siteBuilder";
|
||||||
|
import { Card } from "../../../../components/ui/card";
|
||||||
|
import { useSiteStore } from "../../../../store/siteStore";
|
||||||
|
import { useSectorStore } from "../../../../store/sectorStore";
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"h-11 w-full rounded-xl border border-gray-200 bg-white px-4 text-sm font-medium text-gray-900 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-white/10 dark:bg-white/[0.03] dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800";
|
||||||
|
|
||||||
|
const labelClass =
|
||||||
|
"text-sm font-semibold text-gray-700 dark:text-white/80 mb-2 inline-block";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: BuilderFormData;
|
||||||
|
onChange: <K extends keyof BuilderFormData>(
|
||||||
|
key: K,
|
||||||
|
value: BuilderFormData[K],
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BusinessDetailsStep({ data, onChange }: Props) {
|
||||||
|
const { activeSite } = useSiteStore();
|
||||||
|
const { activeSector } = useSectorStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card variant="panel" padding="lg">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-white/50">
|
||||||
|
Context
|
||||||
|
</p>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Site & Sector
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
The wizard will use your currently active site and sector. Switch
|
||||||
|
them from the header at any time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="rounded-2xl border border-brand-100 bg-brand-50/60 p-4 dark:border-brand-500/40 dark:bg-brand-500/10">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-brand-600 dark:text-brand-300">
|
||||||
|
Active Site
|
||||||
|
</p>
|
||||||
|
<p className="text-base font-semibold text-brand-700 dark:text-brand-200">
|
||||||
|
{activeSite?.name ?? "No site selected"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-indigo-100 bg-indigo-50/60 p-4 dark:border-indigo-500/40 dark:bg-indigo-500/10">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-indigo-600 dark:text-indigo-300">
|
||||||
|
Active Sector
|
||||||
|
</p>
|
||||||
|
<p className="text-base font-semibold text-indigo-700 dark:text-indigo-200">
|
||||||
|
{activeSector?.name ?? "All sectors"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card variant="surface" padding="lg">
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Site name</label>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="text"
|
||||||
|
value={data.siteName}
|
||||||
|
placeholder="Acme Robotics"
|
||||||
|
onChange={(event) => onChange("siteName", event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Hosting preference</label>
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
value={data.hostingType}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange("hostingType", event.target.value as BuilderFormData["hostingType"])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="igny8_sites">IGNY8 Sites</option>
|
||||||
|
<option value="wordpress">WordPress</option>
|
||||||
|
<option value="shopify">Shopify</option>
|
||||||
|
<option value="multi">Multiple destinations</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Business type</label>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="text"
|
||||||
|
value={data.businessType}
|
||||||
|
placeholder="B2B SaaS platform"
|
||||||
|
onChange={(event) => onChange("businessType", event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Industry</label>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="text"
|
||||||
|
value={data.industry}
|
||||||
|
placeholder="Supply chain automation"
|
||||||
|
onChange={(event) => onChange("industry", event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<label className={labelClass}>Target audience</label>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="text"
|
||||||
|
value={data.targetAudience}
|
||||||
|
placeholder="Operations leaders at fast-scaling eCommerce brands"
|
||||||
|
onChange={(event) => onChange("targetAudience", event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
90
frontend/src/pages/Sites/Builder/steps/ObjectivesStep.tsx
Normal file
90
frontend/src/pages/Sites/Builder/steps/ObjectivesStep.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { BuilderFormData } from "../../../../types/siteBuilder";
|
||||||
|
import { Card } from "../../../../components/ui/card/Card";
|
||||||
|
import Button from "../../../../components/ui/button/Button";
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"h-11 flex-1 rounded-xl border border-gray-200 bg-white px-4 text-sm font-medium text-gray-900 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-white/10 dark:bg-white/[0.03] dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: BuilderFormData;
|
||||||
|
addObjective: (value: string) => void;
|
||||||
|
removeObjective: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ObjectivesStep({
|
||||||
|
data,
|
||||||
|
addObjective,
|
||||||
|
removeObjective,
|
||||||
|
}: Props) {
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
addObjective(trimmed);
|
||||||
|
setValue("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card variant="surface" padding="lg">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wider text-gray-500 dark:text-white/50">
|
||||||
|
Conversion goals
|
||||||
|
</p>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
What should the site accomplish?
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Each objective becomes navigation, hero CTAs, and supporting
|
||||||
|
sections. Add as many as you need.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{data.objectives.map((objective, idx) => (
|
||||||
|
<span
|
||||||
|
key={`${objective}-${idx}`}
|
||||||
|
className="inline-flex items-center gap-3 rounded-full bg-brand-50 px-4 py-2 text-sm font-medium text-brand-700 dark:bg-brand-500/15 dark:text-brand-200"
|
||||||
|
>
|
||||||
|
{objective}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-brand-600 hover:text-brand-800 dark:text-brand-200 dark:hover:text-brand-50"
|
||||||
|
onClick={() => removeObjective(idx)}
|
||||||
|
aria-label="Remove objective"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{data.objectives.length === 0 && (
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
No objectives yet. Add one below.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
placeholder="Offer product tour, capture demo requests, educate on ROI…"
|
||||||
|
onChange={(event) => setValue(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="solid"
|
||||||
|
tone="brand"
|
||||||
|
onClick={handleAdd}
|
||||||
|
>
|
||||||
|
Add objective
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
106
frontend/src/pages/Sites/Builder/steps/StyleStep.tsx
Normal file
106
frontend/src/pages/Sites/Builder/steps/StyleStep.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import type { StylePreferences } from "../../../../types/siteBuilder";
|
||||||
|
import { Card } from "../../../../components/ui/card";
|
||||||
|
|
||||||
|
const labelClass =
|
||||||
|
"text-sm font-semibold text-gray-700 dark:text-white/80 mb-2 inline-block";
|
||||||
|
const selectClass =
|
||||||
|
"h-11 w-full rounded-xl border border-gray-200 bg-white px-4 text-sm font-medium text-gray-900 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-white/10 dark:bg-white/[0.03] dark:text-white/90 dark:focus:border-brand-800";
|
||||||
|
const textareaClass =
|
||||||
|
"w-full rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-900 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-white/10 dark:bg-white/[0.03] dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800";
|
||||||
|
|
||||||
|
const palettes = [
|
||||||
|
"Minimal monochrome with bright accent",
|
||||||
|
"Rich jewel tones with high contrast",
|
||||||
|
"Soft gradients and glassmorphism",
|
||||||
|
"Playful pastel palette",
|
||||||
|
];
|
||||||
|
|
||||||
|
const typography = [
|
||||||
|
"Modern sans-serif for headings, serif body text",
|
||||||
|
"Editorial serif across the site",
|
||||||
|
"Geometric sans with tight tracking",
|
||||||
|
"Rounded fonts with friendly tone",
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
style: StylePreferences;
|
||||||
|
onChange: (partial: Partial<StylePreferences>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StyleStep({ style, onChange }: Props) {
|
||||||
|
return (
|
||||||
|
<Card variant="surface" padding="lg">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wider text-gray-500 dark:text-white/50">
|
||||||
|
Look & feel
|
||||||
|
</p>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Visual direction
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Capture the brand personality so the preview canvas mirrors the
|
||||||
|
right tone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Palette direction</label>
|
||||||
|
<select
|
||||||
|
className={selectClass}
|
||||||
|
value={style.palette}
|
||||||
|
onChange={(event) => onChange({ palette: event.target.value })}
|
||||||
|
>
|
||||||
|
{palettes.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Typography</label>
|
||||||
|
<select
|
||||||
|
className={selectClass}
|
||||||
|
value={style.typography}
|
||||||
|
onChange={(event) => onChange({ typography: event.target.value })}
|
||||||
|
>
|
||||||
|
{typography.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Brand personality</label>
|
||||||
|
<textarea
|
||||||
|
className={textareaClass}
|
||||||
|
rows={3}
|
||||||
|
value={style.personality}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange({ personality: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Hero imagery direction</label>
|
||||||
|
<textarea
|
||||||
|
className={textareaClass}
|
||||||
|
rows={3}
|
||||||
|
value={style.heroImagery}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange({ heroImagery: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
125
frontend/src/services/siteBuilder.api.ts
Normal file
125
frontend/src/services/siteBuilder.api.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Site Builder API Service
|
||||||
|
* Uses fetchAPI pattern (not axios) - handles authentication automatically
|
||||||
|
*/
|
||||||
|
import { fetchAPI } from './api';
|
||||||
|
import type {
|
||||||
|
SiteBlueprint,
|
||||||
|
PageBlueprint,
|
||||||
|
SiteStructure,
|
||||||
|
BuilderFormData,
|
||||||
|
} from '../types/siteBuilder';
|
||||||
|
|
||||||
|
export interface CreateBlueprintPayload {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
site_id: number;
|
||||||
|
sector_id: number;
|
||||||
|
hosting_type: BuilderFormData['hostingType'];
|
||||||
|
config_json: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateStructurePayload {
|
||||||
|
business_brief: string;
|
||||||
|
objectives: string[];
|
||||||
|
style: BuilderFormData['style'];
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Site Builder API functions
|
||||||
|
*/
|
||||||
|
export const siteBuilderApi = {
|
||||||
|
/**
|
||||||
|
* List all site blueprints
|
||||||
|
*/
|
||||||
|
async listBlueprints(siteId?: number): Promise<SiteBlueprint[]> {
|
||||||
|
const params = siteId ? `?site=${siteId}` : '';
|
||||||
|
const response = await fetchAPI(`/v1/site-builder/blueprints/${params}`);
|
||||||
|
// Handle paginated response
|
||||||
|
if (response?.results) {
|
||||||
|
return response.results as SiteBlueprint[];
|
||||||
|
}
|
||||||
|
// Handle direct array response
|
||||||
|
return Array.isArray(response) ? response : [];
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single blueprint by ID
|
||||||
|
*/
|
||||||
|
async getBlueprint(id: number): Promise<SiteBlueprint> {
|
||||||
|
return fetchAPI(`/v1/site-builder/blueprints/${id}/`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new site blueprint
|
||||||
|
*/
|
||||||
|
async createBlueprint(payload: CreateBlueprintPayload): Promise<SiteBlueprint> {
|
||||||
|
return fetchAPI('/v1/site-builder/blueprints/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate site structure for a blueprint
|
||||||
|
*/
|
||||||
|
async generateStructure(
|
||||||
|
blueprintId: number,
|
||||||
|
payload: GenerateStructurePayload,
|
||||||
|
): Promise<{ task_id?: string; success?: boolean; structure?: SiteStructure }> {
|
||||||
|
return fetchAPI(`/v1/site-builder/blueprints/${blueprintId}/generate_structure/`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List pages for a blueprint
|
||||||
|
*/
|
||||||
|
async listPages(blueprintId: number): Promise<PageBlueprint[]> {
|
||||||
|
const response = await fetchAPI(`/v1/site-builder/pages/?site_blueprint=${blueprintId}`);
|
||||||
|
// Handle paginated response
|
||||||
|
if (response?.results) {
|
||||||
|
return response.results as PageBlueprint[];
|
||||||
|
}
|
||||||
|
// Handle direct array response
|
||||||
|
return Array.isArray(response) ? response : [];
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate all pages for a blueprint
|
||||||
|
*/
|
||||||
|
async generateAllPages(
|
||||||
|
blueprintId: number,
|
||||||
|
options?: { pageIds?: number[]; force?: boolean },
|
||||||
|
): Promise<{ success: boolean; pages_queued: number; task_ids: number[]; celery_task_id?: string }> {
|
||||||
|
const response = await fetchAPI(`/v1/site-builder/blueprints/${blueprintId}/generate_all_pages/`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
page_ids: options?.pageIds,
|
||||||
|
force: options?.force || false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
// Handle unified response format
|
||||||
|
return response?.data || response;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create tasks for pages
|
||||||
|
*/
|
||||||
|
async createTasksForPages(
|
||||||
|
blueprintId: number,
|
||||||
|
pageIds?: number[],
|
||||||
|
): Promise<{ tasks: unknown[]; count: number }> {
|
||||||
|
const response = await fetchAPI(`/v1/site-builder/blueprints/${blueprintId}/create_tasks/`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
page_ids: pageIds,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
// Handle unified response format
|
||||||
|
return response?.data || response;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
251
frontend/src/store/builderStore.ts
Normal file
251
frontend/src/store/builderStore.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { useSiteStore } from "./siteStore";
|
||||||
|
import { useSectorStore } from "./sectorStore";
|
||||||
|
import { useSiteDefinitionStore } from "./siteDefinitionStore";
|
||||||
|
import { siteBuilderApi } from "../services/siteBuilder.api";
|
||||||
|
import type {
|
||||||
|
BuilderFormData,
|
||||||
|
PageBlueprint,
|
||||||
|
SiteBlueprint,
|
||||||
|
StylePreferences,
|
||||||
|
} from "../types/siteBuilder";
|
||||||
|
|
||||||
|
const defaultStyle: StylePreferences = {
|
||||||
|
palette: "Vibrant modern palette with rich accent color",
|
||||||
|
typography: "Sans-serif display for headings, humanist body font",
|
||||||
|
personality: "Confident, energetic, optimistic",
|
||||||
|
heroImagery: "Real people interacting with the product/service",
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildDefaultForm = (): BuilderFormData => {
|
||||||
|
const site = useSiteStore.getState().activeSite;
|
||||||
|
const sector = useSectorStore.getState().activeSector;
|
||||||
|
|
||||||
|
return {
|
||||||
|
siteId: site?.id ?? null,
|
||||||
|
sectorId: sector?.id ?? null,
|
||||||
|
siteName: site?.name ?? "",
|
||||||
|
businessType: "",
|
||||||
|
industry: "",
|
||||||
|
targetAudience: "",
|
||||||
|
hostingType: "igny8_sites",
|
||||||
|
businessBrief: "",
|
||||||
|
objectives: ["Launch a conversion-focused marketing site"],
|
||||||
|
style: defaultStyle,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BuilderState {
|
||||||
|
form: BuilderFormData;
|
||||||
|
currentStep: number;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
isGenerating: boolean;
|
||||||
|
error?: string;
|
||||||
|
activeBlueprint?: SiteBlueprint;
|
||||||
|
pages: PageBlueprint[];
|
||||||
|
selectedPageIds: number[];
|
||||||
|
generationProgress?: {
|
||||||
|
pagesQueued: number;
|
||||||
|
taskIds: number[];
|
||||||
|
celeryTaskId?: string;
|
||||||
|
};
|
||||||
|
// Actions
|
||||||
|
setField: <K extends keyof BuilderFormData>(
|
||||||
|
key: K,
|
||||||
|
value: BuilderFormData[K],
|
||||||
|
) => void;
|
||||||
|
updateStyle: (partial: Partial<StylePreferences>) => void;
|
||||||
|
addObjective: (value: string) => void;
|
||||||
|
removeObjective: (index: number) => void;
|
||||||
|
setStep: (step: number) => void;
|
||||||
|
nextStep: () => void;
|
||||||
|
previousStep: () => void;
|
||||||
|
reset: () => void;
|
||||||
|
syncContextFromStores: () => void;
|
||||||
|
submitWizard: () => Promise<void>;
|
||||||
|
refreshPages: (blueprintId: number) => Promise<void>;
|
||||||
|
togglePageSelection: (pageId: number) => void;
|
||||||
|
selectAllPages: () => void;
|
||||||
|
clearPageSelection: () => void;
|
||||||
|
generateAllPages: (blueprintId: number, force?: boolean) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||||
|
form: buildDefaultForm(),
|
||||||
|
currentStep: 0,
|
||||||
|
isSubmitting: false,
|
||||||
|
isGenerating: false,
|
||||||
|
pages: [],
|
||||||
|
selectedPageIds: [],
|
||||||
|
|
||||||
|
setField: (key, value) =>
|
||||||
|
set((state) => ({
|
||||||
|
form: { ...state.form, [key]: value },
|
||||||
|
})),
|
||||||
|
|
||||||
|
updateStyle: (partial) =>
|
||||||
|
set((state) => ({
|
||||||
|
form: { ...state.form, style: { ...state.form.style, ...partial } },
|
||||||
|
})),
|
||||||
|
|
||||||
|
addObjective: (value) =>
|
||||||
|
set((state) => ({
|
||||||
|
form: { ...state.form, objectives: [...state.form.objectives, value] },
|
||||||
|
})),
|
||||||
|
|
||||||
|
removeObjective: (index) =>
|
||||||
|
set((state) => ({
|
||||||
|
form: {
|
||||||
|
...state.form,
|
||||||
|
objectives: state.form.objectives.filter((_, idx) => idx !== index),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
|
||||||
|
setStep: (step) => set({ currentStep: step }),
|
||||||
|
|
||||||
|
nextStep: () =>
|
||||||
|
set((state) => ({
|
||||||
|
currentStep: Math.min(state.currentStep + 1, 3),
|
||||||
|
})),
|
||||||
|
|
||||||
|
previousStep: () =>
|
||||||
|
set((state) => ({
|
||||||
|
currentStep: Math.max(state.currentStep - 1, 0),
|
||||||
|
})),
|
||||||
|
|
||||||
|
reset: () =>
|
||||||
|
set({
|
||||||
|
form: buildDefaultForm(),
|
||||||
|
currentStep: 0,
|
||||||
|
isSubmitting: false,
|
||||||
|
error: undefined,
|
||||||
|
activeBlueprint: undefined,
|
||||||
|
pages: [],
|
||||||
|
selectedPageIds: [],
|
||||||
|
generationProgress: undefined,
|
||||||
|
}),
|
||||||
|
|
||||||
|
syncContextFromStores: () => {
|
||||||
|
const site = useSiteStore.getState().activeSite;
|
||||||
|
const sector = useSectorStore.getState().activeSector;
|
||||||
|
set((state) => ({
|
||||||
|
form: {
|
||||||
|
...state.form,
|
||||||
|
siteId: site?.id ?? state.form.siteId,
|
||||||
|
siteName: site?.name ?? state.form.siteName,
|
||||||
|
sectorId: sector?.id ?? state.form.sectorId,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
submitWizard: async () => {
|
||||||
|
const { form } = get();
|
||||||
|
if (!form.siteId || !form.sectorId) {
|
||||||
|
set({
|
||||||
|
error:
|
||||||
|
"Select an active site and sector before running the Site Builder wizard.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isSubmitting: true, error: undefined });
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: form.siteName || `Site Blueprint (${form.industry || "New"})`,
|
||||||
|
description: form.businessType
|
||||||
|
? `${form.businessType} for ${form.targetAudience}`
|
||||||
|
: undefined,
|
||||||
|
site_id: form.siteId,
|
||||||
|
sector_id: form.sectorId,
|
||||||
|
hosting_type: form.hostingType,
|
||||||
|
config_json: {
|
||||||
|
business_type: form.businessType,
|
||||||
|
industry: form.industry,
|
||||||
|
target_audience: form.targetAudience,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const blueprint = await siteBuilderApi.createBlueprint(payload);
|
||||||
|
set({ activeBlueprint: blueprint });
|
||||||
|
|
||||||
|
const generation = await siteBuilderApi.generateStructure(
|
||||||
|
blueprint.id,
|
||||||
|
{
|
||||||
|
business_brief: form.businessBrief,
|
||||||
|
objectives: form.objectives,
|
||||||
|
style: form.style,
|
||||||
|
metadata: { targetAudience: form.targetAudience },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (generation?.structure) {
|
||||||
|
useSiteDefinitionStore.getState().setStructure(generation.structure);
|
||||||
|
}
|
||||||
|
|
||||||
|
await get().refreshPages(blueprint.id);
|
||||||
|
} catch (error: any) {
|
||||||
|
set({
|
||||||
|
error: error?.message || "Unexpected error while running wizard",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
set({ isSubmitting: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshPages: async (blueprintId: number) => {
|
||||||
|
try {
|
||||||
|
const pages = await siteBuilderApi.listPages(blueprintId);
|
||||||
|
set({ pages });
|
||||||
|
useSiteDefinitionStore.getState().setPages(pages);
|
||||||
|
} catch (error: any) {
|
||||||
|
set({
|
||||||
|
error: error?.message || "Unable to load generated pages",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
togglePageSelection: (pageId: number) =>
|
||||||
|
set((state) => {
|
||||||
|
const isSelected = state.selectedPageIds.includes(pageId);
|
||||||
|
return {
|
||||||
|
selectedPageIds: isSelected
|
||||||
|
? state.selectedPageIds.filter((id) => id !== pageId)
|
||||||
|
: [...state.selectedPageIds, pageId],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
selectAllPages: () =>
|
||||||
|
set((state) => ({
|
||||||
|
selectedPageIds: state.pages.map((page) => page.id),
|
||||||
|
})),
|
||||||
|
|
||||||
|
clearPageSelection: () => set({ selectedPageIds: [] }),
|
||||||
|
|
||||||
|
generateAllPages: async (blueprintId: number, force = false) => {
|
||||||
|
const { selectedPageIds } = get();
|
||||||
|
set({ isGenerating: true, error: undefined, generationProgress: undefined });
|
||||||
|
try {
|
||||||
|
const result = await siteBuilderApi.generateAllPages(blueprintId, {
|
||||||
|
pageIds: selectedPageIds.length > 0 ? selectedPageIds : undefined,
|
||||||
|
force,
|
||||||
|
});
|
||||||
|
|
||||||
|
set({
|
||||||
|
generationProgress: {
|
||||||
|
pagesQueued: result.pages_queued,
|
||||||
|
taskIds: result.task_ids,
|
||||||
|
celeryTaskId: result.celery_task_id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await get().refreshPages(blueprintId);
|
||||||
|
} catch (error: any) {
|
||||||
|
set({
|
||||||
|
error: error?.message || "Failed to queue page generation",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
set({ isGenerating: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
30
frontend/src/store/siteDefinitionStore.ts
Normal file
30
frontend/src/store/siteDefinitionStore.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type {
|
||||||
|
PageBlueprint,
|
||||||
|
SiteStructure,
|
||||||
|
} from "../types/siteBuilder";
|
||||||
|
|
||||||
|
interface SiteDefinitionState {
|
||||||
|
structure?: SiteStructure;
|
||||||
|
pages: PageBlueprint[];
|
||||||
|
selectedSlug?: string;
|
||||||
|
setStructure: (structure: SiteStructure) => void;
|
||||||
|
setPages: (pages: PageBlueprint[]) => void;
|
||||||
|
selectPage: (slug: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSiteDefinitionStore = create<SiteDefinitionState>((set) => ({
|
||||||
|
pages: [],
|
||||||
|
setStructure: (structure) =>
|
||||||
|
set({
|
||||||
|
structure,
|
||||||
|
selectedSlug: structure.pages?.[0]?.slug,
|
||||||
|
}),
|
||||||
|
setPages: (pages) =>
|
||||||
|
set((state) => ({
|
||||||
|
pages,
|
||||||
|
selectedSlug: state.selectedSlug ?? pages[0]?.slug,
|
||||||
|
})),
|
||||||
|
selectPage: (slug) => set({ selectedSlug: slug }),
|
||||||
|
}));
|
||||||
|
|
||||||
88
frontend/src/types/siteBuilder.ts
Normal file
88
frontend/src/types/siteBuilder.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
export type HostingType = 'igny8_sites' | 'wordpress' | 'shopify' | 'multi';
|
||||||
|
|
||||||
|
export interface StylePreferences {
|
||||||
|
palette: string;
|
||||||
|
typography: string;
|
||||||
|
personality: string;
|
||||||
|
heroImagery: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuilderFormData {
|
||||||
|
siteId: number | null;
|
||||||
|
sectorId: number | null;
|
||||||
|
siteName: string;
|
||||||
|
businessType: string;
|
||||||
|
industry: string;
|
||||||
|
targetAudience: string;
|
||||||
|
hostingType: HostingType;
|
||||||
|
businessBrief: string;
|
||||||
|
objectives: string[];
|
||||||
|
style: StylePreferences;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteBlueprint {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
status: 'draft' | 'generating' | 'ready' | 'deployed';
|
||||||
|
hosting_type: HostingType;
|
||||||
|
config_json: Record<string, unknown>;
|
||||||
|
structure_json: SiteStructure | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
site?: number;
|
||||||
|
sector?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageBlueprint {
|
||||||
|
id: number;
|
||||||
|
site_blueprint: number;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
order: number;
|
||||||
|
blocks_json: PageBlock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageBlock {
|
||||||
|
type: string;
|
||||||
|
heading?: string;
|
||||||
|
subheading?: string;
|
||||||
|
layout?: string;
|
||||||
|
content?: string[] | Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteStructure {
|
||||||
|
site?: {
|
||||||
|
name?: string;
|
||||||
|
primary_navigation?: string[];
|
||||||
|
secondary_navigation?: string[];
|
||||||
|
hero_message?: string;
|
||||||
|
tone?: string;
|
||||||
|
};
|
||||||
|
pages: Array<{
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
status?: string;
|
||||||
|
objective?: string;
|
||||||
|
primary_cta?: string;
|
||||||
|
blocks?: PageBlock[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiListResponse<T> {
|
||||||
|
count?: number;
|
||||||
|
next?: string | null;
|
||||||
|
previous?: string | null;
|
||||||
|
results?: T[];
|
||||||
|
data?: T[] | T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
116
sites/MIGRATION_SUMMARY.md
Normal file
116
sites/MIGRATION_SUMMARY.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Site Builder → Sites Container Migration Summary
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Successfully merged Site Builder container into Sites container. Sites is now the primary container hosting both:
|
||||||
|
- **Public Site Renderer** (no auth required)
|
||||||
|
- **Site Builder** (auth required)
|
||||||
|
|
||||||
|
## Structure Changes
|
||||||
|
|
||||||
|
### New Directory Structure
|
||||||
|
```
|
||||||
|
sites/src/
|
||||||
|
├── builder/ # Site Builder (from site-builder/src/)
|
||||||
|
│ ├── pages/ # Wizard, Preview, Dashboard
|
||||||
|
│ ├── components/ # Builder-specific components
|
||||||
|
│ ├── state/ # Zustand stores
|
||||||
|
│ ├── api/ # API client with conditional auth
|
||||||
|
│ ├── types/ # TypeScript types
|
||||||
|
│ └── App.css # Builder styles
|
||||||
|
├── renderer/ # Sites Renderer (existing)
|
||||||
|
│ ├── pages/ # SiteRenderer component
|
||||||
|
│ ├── loaders/ # Site definition loaders
|
||||||
|
│ ├── utils/ # Layout renderer utilities
|
||||||
|
│ └── types/ # Renderer types
|
||||||
|
├── shared/ # Shared components
|
||||||
|
│ └── ProtectedRoute.tsx
|
||||||
|
├── App.tsx # Unified router
|
||||||
|
└── main.tsx # Entry point
|
||||||
|
```
|
||||||
|
|
||||||
|
## Routing Structure
|
||||||
|
|
||||||
|
### Public Routes (No Auth)
|
||||||
|
- `/:siteId/*` - Site renderer (public sites)
|
||||||
|
- `/` - Root page
|
||||||
|
|
||||||
|
### Builder Routes (Auth Required)
|
||||||
|
- `/builder` - Wizard page
|
||||||
|
- `/builder/preview` - Preview canvas
|
||||||
|
- `/builder/dashboard` - Blueprint history
|
||||||
|
|
||||||
|
## Key Changes
|
||||||
|
|
||||||
|
### 1. Unified Router (`App.tsx`)
|
||||||
|
- Single router handles both builder and renderer routes
|
||||||
|
- Builder routes wrapped in `ProtectedRoute` component
|
||||||
|
- Builder routes wrapped in `BuilderLayout` component
|
||||||
|
- Code-splitting for builder routes (lazy loading)
|
||||||
|
|
||||||
|
### 2. Authentication
|
||||||
|
- `ProtectedRoute` component checks for JWT token in localStorage
|
||||||
|
- Builder API client conditionally includes auth headers
|
||||||
|
- Public renderer routes have no authentication
|
||||||
|
|
||||||
|
### 3. API Clients
|
||||||
|
- Builder API (`builder/api/builder.api.ts`): Includes auth token if available
|
||||||
|
- Renderer API (`renderer/loaders/loadSiteDefinition.ts`): No auth required
|
||||||
|
|
||||||
|
### 4. Build Configuration
|
||||||
|
- Updated `vite.config.ts` with code-splitting
|
||||||
|
- Builder routes split into separate chunk
|
||||||
|
- Supports both `sites.igny8.com` and `builder.igny8.com` domains
|
||||||
|
|
||||||
|
### 5. Docker Configuration
|
||||||
|
- Removed `igny8_site_builder` service from docker-compose
|
||||||
|
- Updated `igny8_sites` service comment
|
||||||
|
- Single container now handles both functions
|
||||||
|
|
||||||
|
## Backend (No Changes)
|
||||||
|
- Backend Django views remain unchanged
|
||||||
|
- API endpoints: `/api/v1/site-builder/` (same as before)
|
||||||
|
- Database tables: No changes
|
||||||
|
- Authentication: Same JWT-based auth
|
||||||
|
|
||||||
|
## Migration Checklist
|
||||||
|
|
||||||
|
✅ Created merged directory structure
|
||||||
|
✅ Moved site-builder files to sites/src/builder/
|
||||||
|
✅ Created unified App.tsx with routing
|
||||||
|
✅ Created ProtectedRoute component
|
||||||
|
✅ Created BuilderLayout component
|
||||||
|
✅ Merged package.json dependencies
|
||||||
|
✅ Updated vite.config.ts with code-splitting
|
||||||
|
✅ Updated main.tsx entry point
|
||||||
|
✅ Updated docker-compose.app.yml (removed site_builder service)
|
||||||
|
✅ Updated API clients with conditional authentication
|
||||||
|
✅ Updated index.css with builder styles
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Test Builder Functionality**
|
||||||
|
- Navigate to `/builder` (should require auth)
|
||||||
|
- Test wizard flow
|
||||||
|
- Test preview
|
||||||
|
- Test dashboard
|
||||||
|
|
||||||
|
2. **Test Renderer Functionality**
|
||||||
|
- Navigate to `/:siteId/*` (should work without auth)
|
||||||
|
- Test site loading from filesystem
|
||||||
|
- Test site loading from API
|
||||||
|
|
||||||
|
3. **Build & Deploy**
|
||||||
|
- Build Docker image: `docker build -t igny8-sites-dev:latest -f Dockerfile.dev .`
|
||||||
|
- Update docker-compose: `docker compose -f docker-compose.app.yml up -d igny8_sites`
|
||||||
|
|
||||||
|
4. **Cleanup** (Optional)
|
||||||
|
- Remove `/site-builder` directory after verification
|
||||||
|
- Update documentation
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **No Backward Compatibility**: Old site-builder container is removed
|
||||||
|
- **Backend Unchanged**: All Django views and models remain the same
|
||||||
|
- **Code-Splitting**: Builder code is lazy-loaded, so public sites don't load builder code
|
||||||
|
- **Route Conflicts**: Builder routes are namespaced under `/builder/*` to avoid conflicts
|
||||||
|
|
||||||
@@ -17,27 +17,29 @@
|
|||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.9.6"
|
"react-hook-form": "^7.66.0",
|
||||||
|
"react-router-dom": "^7.9.6",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@testing-library/jest-dom": "^6.1.5",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^14.1.2",
|
"@testing-library/react": "^16.2.0",
|
||||||
"@testing-library/user-event": "^14.5.1",
|
"@testing-library/user-event": "^14.5.1",
|
||||||
"@types/node": "^24.10.0",
|
"@types/node": "^24.10.0",
|
||||||
"@types/react": "^19.2.2",
|
"@types/react": "^19.2.2",
|
||||||
"@types/react-dom": "^19.2.2",
|
"@types/react-dom": "^19.2.2",
|
||||||
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@vitejs/plugin-react": "^5.1.0",
|
"@vitejs/plugin-react": "^5.1.0",
|
||||||
"@vitest/ui": "^1.0.4",
|
"@vitest/ui": "^1.0.4",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"jsdom": "^23.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.46.3",
|
"typescript-eslint": "^8.46.3",
|
||||||
"vite": "^7.2.2",
|
"vite": "^7.2.2",
|
||||||
"vitest": "^1.0.4"
|
"vitest": "^2.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,52 @@
|
|||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
import { lazy, Suspense } from 'react';
|
||||||
import SiteRenderer from './pages/SiteRenderer';
|
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('./renderer/pages/SiteRenderer'));
|
||||||
|
|
||||||
|
// Loading component
|
||||||
|
const LoadingFallback = () => (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||||
|
<div>Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
{/* Public Site Renderer Routes (No Auth) */}
|
||||||
<Route path="/:siteId/*" element={<SiteRenderer />} />
|
<Route path="/:siteId/*" element={<SiteRenderer />} />
|
||||||
<Route path="/" element={<div>IGNY8 Sites Renderer</div>} />
|
<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>
|
</Routes>
|
||||||
|
</BuilderLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,28 @@
|
|||||||
:root {
|
:root {
|
||||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
font-family: 'Inter', 'Inter var', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
line-height: 1.5;
|
color: #0f172a;
|
||||||
font-weight: 400;
|
background-color: #f5f7fb;
|
||||||
|
|
||||||
color-scheme: light dark;
|
|
||||||
color: rgba(255, 255, 255, 0.87);
|
|
||||||
background-color: #242424;
|
|
||||||
|
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
background: #f5f7fb;
|
||||||
place-items: center;
|
|
||||||
min-width: 320px;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
button {
|
||||||
width: 100%;
|
font-family: inherit;
|
||||||
margin: 0 auto;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client';
|
||||||
import App from './App.tsx'
|
import App from './App.tsx';
|
||||||
import './index.css'
|
import './index.css';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
);
|
||||||
|
|
||||||
|
|||||||
44
sites/src/shared/ProtectedRoute.tsx
Normal file
44
sites/src/shared/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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}</>;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -23,10 +23,22 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 5176,
|
port: 5176,
|
||||||
allowedHosts: ['sites.igny8.com'],
|
allowedHosts: ['sites.igny8.com', 'builder.igny8.com'],
|
||||||
fs: {
|
fs: {
|
||||||
allow: [path.resolve(__dirname, '..'), sharedComponentsPath],
|
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'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user