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:
@@ -1,43 +1,273 @@
|
||||
/**
|
||||
* Site Builder Wizard
|
||||
* Moved from site-builder container to main app
|
||||
* TODO: Migrate full implementation from site-builder/src/pages/wizard/
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageMeta from '../../../components/common/PageMeta';
|
||||
import { Card } from '../../../components/ui/card';
|
||||
import Button from '../../../components/ui/button/Button';
|
||||
import { Wand2 } from 'lucide-react';
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import Button from "../../../components/ui/button/Button";
|
||||
import PageMeta from "../../../components/common/PageMeta";
|
||||
import SiteAndSectorSelector from "../../../components/common/SiteAndSectorSelector";
|
||||
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() {
|
||||
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 (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Site Builder - IGNY8" />
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Site Builder
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Create a new site using AI-powered wizard
|
||||
</p>
|
||||
<div className="space-y-6 p-6">
|
||||
<PageMeta title="Create Site - IGNY8" />
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<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
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Create a new site using IGNY8’s AI-powered wizard. Align the estate,
|
||||
strategy, and tone before publishing.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate("/sites")}
|
||||
startIcon={<Wand2 size={16} />}
|
||||
>
|
||||
Back to sites
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="p-12 text-center">
|
||||
<Wand2 className="w-16 h-16 mx-auto mb-4 text-gray-400" />
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Site Builder Wizard
|
||||
</h2>
|
||||
<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>
|
||||
</Card>
|
||||
<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>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user