diff --git a/backend/celerybeat-schedule b/backend/celerybeat-schedule
new file mode 100644
index 00000000..9276c3ea
Binary files /dev/null and b/backend/celerybeat-schedule differ
diff --git a/backend/igny8_core/modules/system/utils.py b/backend/igny8_core/modules/system/utils.py
index 19eb10cd..613dff80 100644
--- a/backend/igny8_core/modules/system/utils.py
+++ b/backend/igny8_core/modules/system/utils.py
@@ -253,6 +253,70 @@ Make sure each prompt is detailed enough for image generation, describing the vi
'image_prompt_template': 'Create a high-quality {image_type} image to use as a featured photo for a blog post titled "{post_title}". The image should visually represent the theme, mood, and subject implied by the image prompt: {image_prompt}. Focus on a realistic, well-composed scene that naturally communicates the topic without text or logos. Use balanced lighting, pleasing composition, and photographic detail suitable for lifestyle or editorial web content. Avoid adding any visible or readable text, brand names, or illustrative effects. **And make sure image is not blurry.**',
'negative_prompt': 'text, watermark, logo, overlay, title, caption, writing on walls, writing on objects, UI, infographic elements, post title',
+
+ 'site_structure_generation': """You are the lead IA and conversion-focused strategist for a new marketing website. Use the inputs from the site builder wizard to craft an SEO-rich, user-journey friendly site architecture that a design and content team can build immediately.
+
+INPUT DATA
+----------
+BUSINESS BRIEF:
+[IGNY8_BUSINESS_BRIEF]
+
+PRIMARY OBJECTIVES (rank by impact):
+[IGNY8_OBJECTIVES]
+
+DESIGN & STORY STYLE NOTES:
+[IGNY8_STYLE]
+
+SITE INFO (platform, audience, product/service category, any technical requirements):
+[IGNY8_SITE_INFO]
+
+TASK
+----
+1. Interpret the brief to define the core narrative arc (problem → solution → proof → conversion).
+2. Output a JSON object that matches the SiteStructure schema:
+{
+ "site": {
+ "name": "[Site or campaign name]",
+ "primary_navigation": ["..."],
+ "secondary_navigation": ["..."],
+ "hero_message": "[Top-level positioning statement]",
+ "tone": "[Voice guidance derived from style input]"
+ },
+ "pages": [
+ {
+ "slug": "kebab-case-slug",
+ "title": "SEO-friendly page title",
+ "type": "[page role e.g. hero, solution, proof, pricing, conversion]",
+ "status": "draft",
+ "objective": "Single measurable goal for this page aligned to objectives",
+ "primary_cta": "Primary call to action text",
+ "blocks": [
+ {
+ "type": "[block archetype e.g. hero, feature_grid, testimonial]",
+ "heading": "Section headline optimized for intent",
+ "subheading": "Support copy that clarifies value or context",
+ "layout": "Suggested layout pattern (full-bleed, two-column, cards, stats, etc.)",
+ "content": [
+ "Key talking points, proof, FAQs, offers, or data points"
+ ]
+ }
+ ]
+ }
+ ]
+}
+
+3. Produce 6–10 total pages covering the full funnel (awareness, consideration, evaluation, conversion, post-conversion/support) unless the objectives explicitly demand fewer.
+4. Every page must include at least three blocks ordered to tell a story, with clear internal logic (hook → build trust → guide action).
+5. Craft navigation labels that mirror user language, avoid jargon, and reinforce topical authority.
+6. Emphasize SEO signals: use keyword-rich yet natural titles, include topical coverage (solutions, use cases, proof, resources), and highlight schema-worthy elements (stats, FAQs, testimonials).
+
+RESPONSE RULES
+--------------
+- Return ONLY the JSON object described above. Do not wrap it in markdown.
+- Keep text human and specific; never say "Lorem ipsum" or "Example".
+- When objectives mention specific offers, personas, or industries, reflect them in page titles, CTAs, and block content.
+- If data is missing, infer the most logical assumption and note it inline with phrasing like "(assumed: ...)".
+""",
}
return defaults.get(prompt_type, '')
diff --git a/frontend/src/components/integration/IntegrationStatus.tsx b/frontend/src/components/integration/IntegrationStatus.tsx
index fa11b88a..77daedb6 100644
--- a/frontend/src/components/integration/IntegrationStatus.tsx
+++ b/frontend/src/components/integration/IntegrationStatus.tsx
@@ -3,7 +3,7 @@
* Phase 6: Site Integration & Multi-Destination Publishing
*/
import React from 'react';
-import { CheckCircleIcon, XCircleIcon, ClockIcon, SyncIcon } from 'lucide-react';
+import { CheckCircleIcon, XCircleIcon, ClockIcon, RefreshCw } from 'lucide-react';
interface IntegrationStatusProps {
syncEnabled: boolean;
@@ -25,7 +25,7 @@ export default function IntegrationStatus({
case 'failed':
return ;
case 'syncing':
- return ;
+ return ;
default:
return ;
}
diff --git a/frontend/src/components/integration/SiteIntegrationsSection.tsx b/frontend/src/components/integration/SiteIntegrationsSection.tsx
index 9c89189c..7da5a944 100644
--- a/frontend/src/components/integration/SiteIntegrationsSection.tsx
+++ b/frontend/src/components/integration/SiteIntegrationsSection.tsx
@@ -3,7 +3,7 @@
* Phase 6: Site Integration & Multi-Destination Publishing
*/
import React, { useState, useEffect } from 'react';
-import { PlusIcon, TrashIcon, TestTubeIcon, SyncIcon } from 'lucide-react';
+import { PlusIcon, TrashIcon, TestTubeIcon, RefreshCw } from 'lucide-react';
import Button from '../ui/button/Button';
import { Modal } from '../ui/modal';
import FormModal, { FormField } from '../common/FormModal';
@@ -303,7 +303,7 @@ export default function SiteIntegrationsSection({ siteId }: SiteIntegrationsSect
onClick={() => handleSync(integration)}
className="flex-1"
>
-
+
Sync
)}
diff --git a/site-builder/src/api/builder.api.ts b/site-builder/src/api/builder.api.ts
index 59caa5aa..ec4f55f8 100644
--- a/site-builder/src/api/builder.api.ts
+++ b/site-builder/src/api/builder.api.ts
@@ -31,6 +31,10 @@ export interface GenerateStructurePayload {
}
export const builderApi = {
+ async getBlueprint(blueprintId: number): Promise {
+ const res = await client.get(`/blueprints/${blueprintId}/`);
+ return res.data;
+ },
async listBlueprints(): Promise {
const res = await client.get('/blueprints/');
if (Array.isArray(res.data?.results)) {
diff --git a/site-builder/src/api/system.api.ts b/site-builder/src/api/system.api.ts
new file mode 100644
index 00000000..715efb6b
--- /dev/null
+++ b/site-builder/src/api/system.api.ts
@@ -0,0 +1,27 @@
+import axios from 'axios';
+
+const API_ROOT = import.meta.env.VITE_API_URL ?? 'http://localhost:8010/api';
+
+const client = axios.create({
+ baseURL: API_ROOT,
+ withCredentials: true,
+});
+
+export interface TaskProgressResponse {
+ state: 'PENDING' | 'PROGRESS' | 'SUCCESS' | 'FAILURE';
+ meta?: {
+ phase?: string;
+ percentage?: number;
+ message?: string;
+ error?: string;
+ };
+}
+
+export const systemApi = {
+ async getTaskProgress(taskId: string): Promise {
+ const res = await client.get(`/v1/system/settings/task_progress/${taskId}/`);
+ return res.data;
+ },
+};
+
+
diff --git a/site-builder/src/hooks/useTaskProgress.ts b/site-builder/src/hooks/useTaskProgress.ts
new file mode 100644
index 00000000..1f2c6b35
--- /dev/null
+++ b/site-builder/src/hooks/useTaskProgress.ts
@@ -0,0 +1,102 @@
+import { useEffect, useState } from 'react';
+import { systemApi, type TaskProgressResponse } from '../api/system.api';
+
+type TaskStatus = 'idle' | 'pending' | 'processing' | 'completed' | 'error';
+
+interface UseTaskProgressOptions {
+ onComplete?: () => void;
+ onError?: (message: string) => void;
+ onUpdate?: (meta: TaskProgressResponse['meta']) => void;
+}
+
+interface TaskProgressState {
+ status: TaskStatus;
+ percentage: number;
+ message?: string;
+ phase?: string;
+}
+
+export function useTaskProgress(taskId: string | null, options: UseTaskProgressOptions = {}) {
+ const { onComplete, onError, onUpdate } = options;
+ const [state, setState] = useState({
+ status: 'idle',
+ percentage: 0,
+ });
+
+ useEffect(() => {
+ if (!taskId) {
+ setState({ status: 'idle', percentage: 0 });
+ return;
+ }
+
+ let isActive = true;
+ let interval: ReturnType | null = null;
+
+ const mapResponseToState = (response: TaskProgressResponse): TaskProgressState => {
+ const { state: taskState, meta } = response;
+ const percentage = typeof meta?.percentage === 'number' ? meta.percentage : 0;
+ const message = meta?.message;
+ const phase = meta?.phase;
+
+ if (taskState === 'SUCCESS') {
+ return { status: 'completed', percentage: 100, message: message ?? 'Site structure ready.', phase };
+ }
+ if (taskState === 'FAILURE') {
+ return { status: 'error', percentage, message: meta?.error ?? message ?? 'Task failed.', phase };
+ }
+ if (taskState === 'PROGRESS') {
+ return { status: 'processing', percentage: Math.min(percentage || 10, 95), message, phase };
+ }
+ return { status: 'pending', percentage, message, phase };
+ };
+
+ const poll = async () => {
+ try {
+ const response = await systemApi.getTaskProgress(taskId);
+
+ if (!isActive) return;
+
+ const nextState = mapResponseToState(response);
+ setState(nextState);
+ onUpdate?.(response.meta);
+
+ if (nextState.status === 'completed') {
+ onComplete?.();
+ if (interval) {
+ clearInterval(interval);
+ interval = null;
+ }
+ } else if (nextState.status === 'error') {
+ onError?.(nextState.message ?? 'Task failed');
+ if (interval) {
+ clearInterval(interval);
+ interval = null;
+ }
+ }
+ } catch (error) {
+ if (!isActive) return;
+ const message = error instanceof Error ? error.message : 'Unable to load task progress';
+ setState({ status: 'error', percentage: 0, message });
+ onError?.(message);
+ if (interval) {
+ clearInterval(interval);
+ interval = null;
+ }
+ }
+ };
+
+ poll();
+ interval = setInterval(poll, 2000);
+
+ return () => {
+ isActive = false;
+ if (interval) {
+ clearInterval(interval);
+ }
+ };
+ }, [taskId, onComplete, onError, onUpdate]);
+
+ return state;
+}
+
+
diff --git a/site-builder/src/pages/wizard/WizardPage.tsx b/site-builder/src/pages/wizard/WizardPage.tsx
index 56787162..7c444076 100644
--- a/site-builder/src/pages/wizard/WizardPage.tsx
+++ b/site-builder/src/pages/wizard/WizardPage.tsx
@@ -1,4 +1,4 @@
-import { useMemo } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
import { Loader2, PlayCircle, RefreshCw } from 'lucide-react';
import { useBuilderStore } from '../../state/builderStore';
import { useSiteDefinitionStore } from '../../state/siteDefinitionStore';
@@ -7,6 +7,9 @@ import { BriefStep } from './steps/BriefStep';
import { ObjectivesStep } from './steps/ObjectivesStep';
import { StyleStep } from './steps/StyleStep';
import { Card } from '../../components/common/Card';
+import { ProgressModal } from '../../components/common/ProgressModal';
+import { useTaskProgress } from '../../hooks/useTaskProgress';
+import { builderApi } from '../../api/builder.api';
const stepTitles = ['Business', 'Brief', 'Objectives', 'Style'];
@@ -26,8 +29,54 @@ export function WizardPage() {
error,
activeBlueprint,
refreshPages,
+ structureTaskId,
+ setStructureTaskId,
} = useBuilderStore();
- const { structure } = useSiteDefinitionStore();
+ const structure = useSiteDefinitionStore((state) => state.structure);
+ const setStructure = useSiteDefinitionStore((state) => state.setStructure);
+ const [showProgressModal, setShowProgressModal] = useState(false);
+ const [progressMessage, setProgressMessage] = useState('Initializing Site Builder AI…');
+
+ const syncLatestStructure = useCallback(async () => {
+ if (!activeBlueprint) return;
+ try {
+ const latestBlueprint = await builderApi.getBlueprint(activeBlueprint.id);
+ if (latestBlueprint.structure_json) {
+ setStructure(latestBlueprint.structure_json);
+ }
+ await refreshPages(activeBlueprint.id);
+ } catch (syncError) {
+ const message = syncError instanceof Error ? syncError.message : 'Unable to sync blueprint';
+ useBuilderStore.setState({ error: message });
+ } finally {
+ setStructureTaskId(null);
+ }
+ }, [activeBlueprint, refreshPages, setStructure, setStructureTaskId]);
+
+ const taskProgress = useTaskProgress(structureTaskId, {
+ onUpdate: (meta) => {
+ if (meta?.message) {
+ setProgressMessage(meta.message);
+ }
+ },
+ onComplete: async () => {
+ setProgressMessage('Site structure ready!');
+ await syncLatestStructure();
+ setTimeout(() => setShowProgressModal(false), 500);
+ },
+ onError: (message) => {
+ useBuilderStore.setState({ error: message });
+ setShowProgressModal(false);
+ setStructureTaskId(null);
+ },
+ });
+
+ useEffect(() => {
+ if (structureTaskId) {
+ setShowProgressModal(true);
+ setProgressMessage('Sending request to Site Builder AI…');
+ }
+ }, [structureTaskId]);
const stepComponents = useMemo(
() => [
@@ -112,6 +161,23 @@ export function WizardPage() {
)}
+ {
+ if (taskProgress.status === 'processing') {
+ return;
+ }
+ setShowProgressModal(false);
+ setStructureTaskId(null);
+ }}
+ title="Generating site structure"
+ message={progressMessage}
+ progress={{
+ current: Math.round(taskProgress.percentage),
+ total: 100,
+ }}
+ taskId={structureTaskId || undefined}
+ />
);
}
diff --git a/site-builder/src/pages/wizard/__tests__/WizardPage.test.tsx b/site-builder/src/pages/wizard/__tests__/WizardPage.test.tsx
index a15b8f50..d5d7b664 100644
--- a/site-builder/src/pages/wizard/__tests__/WizardPage.test.tsx
+++ b/site-builder/src/pages/wizard/__tests__/WizardPage.test.tsx
@@ -17,6 +17,9 @@ describe('WizardPage', () => {
const mockAddObjective = vi.fn();
const mockRemoveObjective = vi.fn();
const mockSubmitWizard = vi.fn();
+ const mockSetStructureTaskId = vi.fn();
+ const mockRefreshPages = vi.fn();
+ const mockSetStructure = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
@@ -37,6 +40,7 @@ describe('WizardPage', () => {
isSubmitting: false,
error: undefined,
activeBlueprint: undefined,
+ structureTaskId: null,
setField: mockSetField,
nextStep: mockNextStep,
previousStep: mockPreviousStep,
@@ -45,10 +49,15 @@ describe('WizardPage', () => {
addObjective: mockAddObjective,
removeObjective: mockRemoveObjective,
submitWizard: mockSubmitWizard,
- refreshPages: vi.fn(),
+ refreshPages: mockRefreshPages,
+ setStructureTaskId: mockSetStructureTaskId,
});
- (useSiteDefinitionStore as any).mockReturnValue({
- structure: undefined,
+ (useSiteDefinitionStore as any).mockImplementation((selector?: (state: any) => any) => {
+ const state = {
+ structure: undefined,
+ setStructure: mockSetStructure,
+ };
+ return selector ? selector(state) : state;
});
});
@@ -85,6 +94,7 @@ describe('WizardPage', () => {
isSubmitting: false,
error: undefined,
activeBlueprint: undefined,
+ structureTaskId: null,
setField: mockSetField,
nextStep: mockNextStep,
previousStep: mockPreviousStep,
@@ -93,7 +103,8 @@ describe('WizardPage', () => {
addObjective: mockAddObjective,
removeObjective: mockRemoveObjective,
submitWizard: mockSubmitWizard,
- refreshPages: vi.fn(),
+ refreshPages: mockRefreshPages,
+ setStructureTaskId: mockSetStructureTaskId,
});
render();
@@ -112,6 +123,7 @@ describe('WizardPage', () => {
isSubmitting: false,
error: 'Test error message',
activeBlueprint: undefined,
+ structureTaskId: null,
setField: mockSetField,
nextStep: mockNextStep,
previousStep: mockPreviousStep,
@@ -120,7 +132,8 @@ describe('WizardPage', () => {
addObjective: mockAddObjective,
removeObjective: mockRemoveObjective,
submitWizard: mockSubmitWizard,
- refreshPages: vi.fn(),
+ refreshPages: mockRefreshPages,
+ setStructureTaskId: mockSetStructureTaskId,
});
render();
@@ -151,6 +164,7 @@ describe('WizardPage', () => {
isSubmitting: true,
error: undefined,
activeBlueprint: undefined,
+ structureTaskId: null,
setField: mockSetField,
nextStep: mockNextStep,
previousStep: mockPreviousStep,
@@ -159,7 +173,8 @@ describe('WizardPage', () => {
addObjective: mockAddObjective,
removeObjective: mockRemoveObjective,
submitWizard: mockSubmitWizard,
- refreshPages: vi.fn(),
+ refreshPages: mockRefreshPages,
+ setStructureTaskId: mockSetStructureTaskId,
});
render();
diff --git a/site-builder/src/state/builderStore.ts b/site-builder/src/state/builderStore.ts
index 7b448200..3b5be2e2 100644
--- a/site-builder/src/state/builderStore.ts
+++ b/site-builder/src/state/builderStore.ts
@@ -34,6 +34,7 @@ interface BuilderState {
isSubmitting: boolean;
error?: string;
activeBlueprint?: SiteBlueprint;
+ structureTaskId: string | null;
pages: PageBlueprint[];
selectedPageIds: number[];
isGenerating: boolean;
@@ -51,6 +52,7 @@ interface BuilderState {
previousStep: () => void;
reset: () => void;
submitWizard: () => Promise;
+ setStructureTaskId: (taskId: string | null) => void;
refreshPages: (blueprintId: number) => Promise;
togglePageSelection: (pageId: number) => void;
selectAllPages: () => void;
@@ -62,7 +64,9 @@ export const useBuilderStore = create((set, get) => ({
form: defaultForm,
currentStep: 0,
isSubmitting: false,
+ structureTaskId: null,
pages: [],
+ setStructureTaskId: (taskId) => set({ structureTaskId: taskId }),
selectedPageIds: [],
isGenerating: false,
@@ -118,7 +122,7 @@ export const useBuilderStore = create((set, get) => ({
return;
}
- set({ isSubmitting: true, error: undefined });
+ set({ isSubmitting: true, error: undefined, structureTaskId: null });
try {
const payload = {
name: form.siteName || `Site Blueprint (${form.industry || 'New'})`,
@@ -143,11 +147,17 @@ export const useBuilderStore = create((set, get) => ({
metadata: { targetAudience: form.targetAudience },
});
+ if (generation?.task_id) {
+ set({ structureTaskId: generation.task_id });
+ }
+
if (generation?.structure) {
useSiteDefinitionStore.getState().setStructure(generation.structure);
}
- await get().refreshPages(blueprint.id);
+ if (!generation?.task_id) {
+ await get().refreshPages(blueprint.id);
+ }
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Unexpected error' });
} finally {