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:
IGNY8 VPS (Salman)
2025-11-18 10:35:30 +00:00
parent 8508af37c7
commit 3ea519483d
19 changed files with 1637 additions and 91 deletions

View File

@@ -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 IGNY8s 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. Well keep processing and update the blueprint when tasks finish."
/>
)}
</div>
</div>
</div>
);
}

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

View 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 &amp; 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>
);
}

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

View 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 &amp; 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>
);
}