Add site builder service to Docker Compose and remove obsolete scripts
- Introduced a new service `igny8_site_builder` in `docker-compose.app.yml` for site building functionality, including environment variables and volume mappings. - Deleted several outdated scripts: `create_test_users.py`, `test_image_write_access.py`, `update_free_plan.py`, and the database file `db.sqlite3` to clean up the backend. - Updated Django settings and URL configurations to integrate the new site builder module.
This commit is contained in:
56
site-builder/src/pages/dashboard/SiteDashboard.tsx
Normal file
56
site-builder/src/pages/dashboard/SiteDashboard.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { builderApi } from '../../api/builder.api';
|
||||
import type { SiteBlueprint } from '../../types/siteBuilder';
|
||||
import { Card } from '../../components/common/Card';
|
||||
|
||||
export function SiteDashboard() {
|
||||
const [blueprints, setBlueprints] = useState<SiteBlueprint[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await builderApi.listBlueprints();
|
||||
setBlueprints(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to load blueprints');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card title="Blueprint history" description="Every generated structure is stored and can be reopened.">
|
||||
{loading && (
|
||||
<div className="sb-loading">
|
||||
<Loader2 className="spin" size={18} /> Loading blueprints…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="sb-error">{error}</p>}
|
||||
|
||||
{!loading && !blueprints.length && (
|
||||
<p className="sb-muted">You haven’t generated any sites yet. Launch the wizard to create your first one.</p>
|
||||
)}
|
||||
|
||||
<ul className="sb-blueprint-list">
|
||||
{blueprints.map((bp) => (
|
||||
<li key={bp.id}>
|
||||
<div>
|
||||
<strong>{bp.name}</strong>
|
||||
<span>{bp.description}</span>
|
||||
</div>
|
||||
<span className={`status-dot status-${bp.status}`}>{bp.status}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
291
site-builder/src/pages/preview/PreviewCanvas.tsx
Normal file
291
site-builder/src/pages/preview/PreviewCanvas.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
FeatureGridBlock,
|
||||
HeroBlock,
|
||||
MarketingTemplate,
|
||||
StatsPanel,
|
||||
type FeatureGridBlockProps,
|
||||
type StatItem,
|
||||
} from '@shared';
|
||||
import { useSiteDefinitionStore } from '../../state/siteDefinitionStore';
|
||||
import type { PageBlock, PageBlueprint, SiteStructure } from '../../types/siteBuilder';
|
||||
|
||||
type StructuredContent = Record<string, unknown> & {
|
||||
items?: unknown[];
|
||||
eyebrow?: string;
|
||||
ctaLabel?: string;
|
||||
supportingCopy?: string;
|
||||
columns?: number;
|
||||
};
|
||||
|
||||
export function PreviewCanvas() {
|
||||
const { structure, pages, selectedSlug, selectPage } = useSiteDefinitionStore();
|
||||
|
||||
const page = useMemo(() => {
|
||||
if (structure?.pages?.length) {
|
||||
return structure.pages.find((p) => p.slug === selectedSlug) ?? structure.pages[0];
|
||||
}
|
||||
return pages.find((p) => p.slug === selectedSlug) ?? pages[0];
|
||||
}, [structure, pages, selectedSlug]);
|
||||
|
||||
if (!structure && !pages.length) {
|
||||
return (
|
||||
<div className="preview-placeholder">
|
||||
<p>Generate a blueprint to see live previews of every page.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const navItems = structure?.site?.primary_navigation ?? pages.map((p) => p.slug);
|
||||
const blocks = getBlocks(page);
|
||||
const heroBlock = blocks.find((block) => normalizeType(block.type) === 'hero');
|
||||
const contentBlocks = heroBlock ? blocks.filter((block) => block !== heroBlock) : blocks;
|
||||
|
||||
const heroSection =
|
||||
heroBlock || page
|
||||
? renderBlock(heroBlock ?? buildFallbackHero(page, structure))
|
||||
: null;
|
||||
|
||||
const sectionNodes =
|
||||
contentBlocks.length > 0
|
||||
? contentBlocks.map((block, index) => <div key={`${block.type}-${index}`}>{renderBlock(block)}</div>)
|
||||
: buildFallbackSections(page);
|
||||
|
||||
const sidebar = (
|
||||
<div className="preview-sidebar">
|
||||
<p className="preview-label">Page objective</p>
|
||||
<h4>{page?.objective ?? 'Launch a high-converting page'}</h4>
|
||||
<ul className="preview-sidebar__list">
|
||||
{buildSidebarInsights(page, structure).map((insight) => (
|
||||
<li key={insight.label}>
|
||||
<span>{insight.label}</span>
|
||||
<strong>{insight.value}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="preview-canvas">
|
||||
<div className="preview-nav">
|
||||
{navItems?.map((slug) => (
|
||||
<button
|
||||
key={slug}
|
||||
type="button"
|
||||
onClick={() => selectPage(slug)}
|
||||
className={slug === (page?.slug ?? '') ? 'is-active' : ''}
|
||||
>
|
||||
{slug.replace('-', ' ')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<MarketingTemplate hero={heroSection} sections={sectionNodes} sidebar={sidebar} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getBlocks(
|
||||
page: (SiteStructure['pages'][number] & { blocks_json?: PageBlock[] }) | PageBlueprint | undefined,
|
||||
) {
|
||||
if (!page) return [];
|
||||
const fromStructure = (page as { blocks?: PageBlock[] }).blocks;
|
||||
if (Array.isArray(fromStructure)) return fromStructure;
|
||||
const fromBlueprint = (page as PageBlueprint).blocks_json;
|
||||
return Array.isArray(fromBlueprint) ? fromBlueprint : [];
|
||||
}
|
||||
|
||||
function renderBlock(block?: PageBlock) {
|
||||
if (!block) return null;
|
||||
const type = normalizeType(block.type);
|
||||
const structuredContent = extractStructuredContent(block);
|
||||
const listContent = extractListContent(block, structuredContent);
|
||||
|
||||
if (type === 'hero') {
|
||||
return (
|
||||
<HeroBlock
|
||||
eyebrow={structuredContent.eyebrow as string | undefined}
|
||||
title={block.heading ?? 'Untitled hero'}
|
||||
subtitle={block.subheading ?? (structuredContent.supportingCopy as string | undefined)}
|
||||
ctaLabel={(structuredContent.ctaLabel as string | undefined) ?? undefined}
|
||||
supportingContent={
|
||||
listContent.length > 0 ? (
|
||||
<ul>
|
||||
{listContent.map((item) => (
|
||||
<li key={String(item)}>{String(item)}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'feature-grid' || type === 'features' || type === 'value-props') {
|
||||
const features = toFeatureList(listContent, structuredContent.items);
|
||||
const columns = normalizeColumns(structuredContent.columns, features.length);
|
||||
return <FeatureGridBlock heading={block.heading} features={features} columns={columns} />;
|
||||
}
|
||||
|
||||
if (type === 'stats' || type === 'metrics') {
|
||||
const stats = toStatItems(listContent, structuredContent.items, block);
|
||||
if (!stats.length) return defaultBlock(block);
|
||||
return <StatsPanel heading={block.heading} stats={stats} />;
|
||||
}
|
||||
|
||||
return defaultBlock(block);
|
||||
}
|
||||
|
||||
function defaultBlock(block: PageBlock) {
|
||||
return (
|
||||
<div className="preview-block preview-block--legacy">
|
||||
{block.heading && <h4>{block.heading}</h4>}
|
||||
{block.subheading && <p>{block.subheading}</p>}
|
||||
{Array.isArray(block.content) && (
|
||||
<ul>
|
||||
{block.content.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeType(type?: string) {
|
||||
return (type ?? '').toLowerCase();
|
||||
}
|
||||
|
||||
function extractStructuredContent(block: PageBlock): StructuredContent {
|
||||
if (Array.isArray(block.content)) {
|
||||
return {};
|
||||
}
|
||||
return (block.content ?? {}) as StructuredContent;
|
||||
}
|
||||
|
||||
function extractListContent(block: PageBlock, structuredContent: StructuredContent): unknown[] {
|
||||
if (Array.isArray(block.content)) {
|
||||
return block.content;
|
||||
}
|
||||
if (Array.isArray(structuredContent.items)) {
|
||||
return structuredContent.items;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function toFeatureList(listItems: unknown[], structuredItems?: unknown[]): FeatureGridBlockProps['features'] {
|
||||
const source = structuredItems && Array.isArray(structuredItems) && structuredItems.length > 0 ? structuredItems : listItems;
|
||||
return source.map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
return { title: item };
|
||||
}
|
||||
if (typeof item === 'object' && item) {
|
||||
const record = item as Record<string, unknown>;
|
||||
return {
|
||||
title: String(record.title ?? record.heading ?? 'Feature'),
|
||||
description: record.description ? String(record.description) : undefined,
|
||||
icon: record.icon ? String(record.icon) : undefined,
|
||||
};
|
||||
}
|
||||
return { title: String(item) };
|
||||
});
|
||||
}
|
||||
|
||||
function toStatItems(
|
||||
listItems: unknown[],
|
||||
structuredItems: unknown[] | undefined,
|
||||
block: PageBlock,
|
||||
): StatItem[] {
|
||||
const source = structuredItems && Array.isArray(structuredItems) && structuredItems.length > 0 ? structuredItems : listItems;
|
||||
return source
|
||||
.map((item, index) => {
|
||||
if (typeof item === 'string') {
|
||||
return {
|
||||
label: block.heading ?? `Metric ${index + 1}`,
|
||||
value: item,
|
||||
};
|
||||
}
|
||||
if (typeof item === 'object' && item) {
|
||||
const record = item as Record<string, unknown>;
|
||||
const label = record.label ?? record.title ?? `Metric ${index + 1}`;
|
||||
const value = record.value ?? record.metric ?? record.score;
|
||||
if (!value) return null;
|
||||
return {
|
||||
label: String(label),
|
||||
value: String(value),
|
||||
description: record.description ? String(record.description) : undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((stat): stat is StatItem => Boolean(stat));
|
||||
}
|
||||
|
||||
function normalizeColumns(
|
||||
candidate: StructuredContent['columns'],
|
||||
featureCount: number,
|
||||
): FeatureGridBlockProps['columns'] {
|
||||
const inferred = typeof candidate === 'number' ? candidate : featureCount >= 4 ? 4 : featureCount === 2 ? 2 : 3;
|
||||
if (inferred <= 2) return 2;
|
||||
if (inferred >= 4) return 4;
|
||||
return 3;
|
||||
}
|
||||
|
||||
function buildFallbackHero(
|
||||
page: SiteStructure['pages'][number] | PageBlueprint | undefined,
|
||||
structure: SiteStructure | undefined,
|
||||
): PageBlock {
|
||||
return {
|
||||
type: 'hero',
|
||||
heading: page?.title ?? 'Site Builder preview',
|
||||
subheading: structure?.site?.hero_message ?? 'Preview updates as the AI hydrates your blueprint.',
|
||||
content: Array.isArray(structure?.site?.secondary_navigation) ? structure?.site?.secondary_navigation : [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildFallbackSections(page: SiteStructure['pages'][number] | PageBlueprint | undefined) {
|
||||
return [
|
||||
<FeatureGridBlock
|
||||
key="fallback-features"
|
||||
heading="Generated sections"
|
||||
features={[
|
||||
{ title: 'AI messaging kit', description: 'Structured copy generated for each funnel stage.' },
|
||||
{ title: 'Audience resonance', description: 'Language tuned to your target segment.' },
|
||||
{ title: 'Conversion spine', description: 'CTA hierarchy anchored to your objectives.' },
|
||||
]}
|
||||
/>,
|
||||
<StatsPanel
|
||||
key="fallback-stats"
|
||||
heading="Blueprint signals"
|
||||
stats={[
|
||||
{ label: 'Page type', value: page?.type ?? 'Landing' },
|
||||
{ label: 'Status', value: page?.status ?? 'Draft' },
|
||||
{ label: 'Blocks', value: '0' },
|
||||
]}
|
||||
/>,
|
||||
];
|
||||
}
|
||||
|
||||
function buildSidebarInsights(
|
||||
page: SiteStructure['pages'][number] | PageBlueprint | undefined,
|
||||
structure: SiteStructure | undefined,
|
||||
) {
|
||||
return [
|
||||
{
|
||||
label: 'Primary CTA',
|
||||
value: page?.primary_cta ?? 'Book a demo',
|
||||
},
|
||||
{
|
||||
label: 'Tone',
|
||||
value: structure?.site?.tone ?? 'Confident & clear',
|
||||
},
|
||||
{
|
||||
label: 'Status',
|
||||
value: page?.status ?? 'Draft',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
119
site-builder/src/pages/wizard/WizardPage.tsx
Normal file
119
site-builder/src/pages/wizard/WizardPage.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Loader2, PlayCircle, RefreshCw } from 'lucide-react';
|
||||
import { useBuilderStore } from '../../state/builderStore';
|
||||
import { useSiteDefinitionStore } from '../../state/siteDefinitionStore';
|
||||
import { BusinessDetailsStep } from './steps/BusinessDetailsStep';
|
||||
import { BriefStep } from './steps/BriefStep';
|
||||
import { ObjectivesStep } from './steps/ObjectivesStep';
|
||||
import { StyleStep } from './steps/StyleStep';
|
||||
import { Card } from '../../components/common/Card';
|
||||
|
||||
const stepTitles = ['Business', 'Brief', 'Objectives', 'Style'];
|
||||
|
||||
export function WizardPage() {
|
||||
const {
|
||||
form,
|
||||
currentStep,
|
||||
setField,
|
||||
updateStyle,
|
||||
addObjective,
|
||||
removeObjective,
|
||||
nextStep,
|
||||
previousStep,
|
||||
setStep,
|
||||
submitWizard,
|
||||
isSubmitting,
|
||||
error,
|
||||
activeBlueprint,
|
||||
refreshPages,
|
||||
} = useBuilderStore();
|
||||
const { structure } = useSiteDefinitionStore();
|
||||
|
||||
const stepComponents = useMemo(
|
||||
() => [
|
||||
<BusinessDetailsStep key="business" data={form} onChange={setField} />,
|
||||
<BriefStep key="brief" data={form} onChange={setField} />,
|
||||
<ObjectivesStep key="objectives" data={form} addObjective={addObjective} removeObjective={removeObjective} />,
|
||||
<StyleStep key="style" style={form.style} onChange={updateStyle} />,
|
||||
],
|
||||
[form, setField, addObjective, removeObjective, updateStyle],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="wizard-page">
|
||||
<Card
|
||||
title="Site builder wizard"
|
||||
description="Capture your strategy in four lightweight steps. When you hit “Generate structure” we’ll call the Site Builder AI and hydrate the preview canvas."
|
||||
>
|
||||
<div className="wizard-progress">
|
||||
{stepTitles.map((title, idx) => (
|
||||
<button
|
||||
key={title}
|
||||
type="button"
|
||||
className={`wizard-progress__dot ${idx === currentStep ? 'is-active' : ''}`}
|
||||
onClick={() => setStep(idx)}
|
||||
>
|
||||
<span>{idx + 1}</span>
|
||||
<small>{title}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="wizard-step">{stepComponents[currentStep]}</div>
|
||||
|
||||
<div className="wizard-actions">
|
||||
<button type="button" onClick={previousStep} disabled={currentStep === 0 || isSubmitting}>
|
||||
Back
|
||||
</button>
|
||||
{currentStep < stepComponents.length - 1 ? (
|
||||
<button type="button" className="primary" onClick={nextStep}>
|
||||
Next
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="primary" onClick={submitWizard} disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="spin" size={18} />
|
||||
Generating…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlayCircle size={18} />
|
||||
Generate structure
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="sb-error">{error}</p>}
|
||||
</Card>
|
||||
|
||||
{activeBlueprint && (
|
||||
<Card
|
||||
title="Latest blueprint"
|
||||
description="Refresh the preview to fetch the latest AI output."
|
||||
footer={
|
||||
<button type="button" className="ghost" onClick={() => refreshPages(activeBlueprint.id)} disabled={isSubmitting}>
|
||||
<RefreshCw size={16} />
|
||||
Sync pages
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="sb-blueprint-meta">
|
||||
<div>
|
||||
<strong>Status</strong>
|
||||
<span className={`status-dot status-${activeBlueprint.status}`}>{activeBlueprint.status}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Structure</strong>
|
||||
<span>{structure?.pages?.length ?? 0} pages</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
28
site-builder/src/pages/wizard/steps/BriefStep.tsx
Normal file
28
site-builder/src/pages/wizard/steps/BriefStep.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { BuilderFormData } from '../../../types/siteBuilder';
|
||||
import { Card } from '../../../components/common/Card';
|
||||
|
||||
interface Props {
|
||||
data: BuilderFormData;
|
||||
onChange: <K extends keyof BuilderFormData>(key: K, value: BuilderFormData[K]) => void;
|
||||
}
|
||||
|
||||
export function BriefStep({ data, onChange }: Props) {
|
||||
return (
|
||||
<Card
|
||||
title="Business brief"
|
||||
description="Describe the brand, what it sells, and what makes it unique. The more context we have, the more accurate the structure."
|
||||
>
|
||||
<label className="sb-field">
|
||||
<span>Business brief</span>
|
||||
<textarea
|
||||
rows={8}
|
||||
value={data.businessBrief}
|
||||
placeholder="Acme Robotics builds autonomous fulfillment robots that reduce warehouse picking time by 60%..."
|
||||
onChange={(event) => onChange('businessBrief', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
94
site-builder/src/pages/wizard/steps/BusinessDetailsStep.tsx
Normal file
94
site-builder/src/pages/wizard/steps/BusinessDetailsStep.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { BuilderFormData } from '../../../types/siteBuilder';
|
||||
import { Card } from '../../../components/common/Card';
|
||||
|
||||
interface Props {
|
||||
data: BuilderFormData;
|
||||
onChange: <K extends keyof BuilderFormData>(key: K, value: BuilderFormData[K]) => void;
|
||||
}
|
||||
|
||||
export function BusinessDetailsStep({ data, onChange }: Props) {
|
||||
return (
|
||||
<Card
|
||||
title="Business context"
|
||||
description="These details help the AI understand what kind of site we are building."
|
||||
>
|
||||
<div className="sb-grid">
|
||||
<label className="sb-field">
|
||||
<span>Site ID</span>
|
||||
<input
|
||||
type="number"
|
||||
value={data.siteId ?? ''}
|
||||
placeholder="123"
|
||||
onChange={(event) => onChange('siteId', Number(event.target.value) || null)}
|
||||
/>
|
||||
</label>
|
||||
<label className="sb-field">
|
||||
<span>Sector ID</span>
|
||||
<input
|
||||
type="number"
|
||||
value={data.sectorId ?? ''}
|
||||
placeholder="456"
|
||||
onChange={(event) => onChange('sectorId', Number(event.target.value) || null)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="sb-field">
|
||||
<span>Site name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={data.siteName}
|
||||
placeholder="Acme Robotics"
|
||||
onChange={(event) => onChange('siteName', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="sb-grid">
|
||||
<label className="sb-field">
|
||||
<span>Business type</span>
|
||||
<input
|
||||
type="text"
|
||||
value={data.businessType}
|
||||
placeholder="B2B SaaS platform"
|
||||
onChange={(event) => onChange('businessType', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="sb-field">
|
||||
<span>Industry</span>
|
||||
<input
|
||||
type="text"
|
||||
value={data.industry}
|
||||
placeholder="Supply chain automation"
|
||||
onChange={(event) => onChange('industry', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="sb-field">
|
||||
<span>Target audience</span>
|
||||
<input
|
||||
type="text"
|
||||
value={data.targetAudience}
|
||||
placeholder="Operations leaders at fast-scaling eCommerce brands"
|
||||
onChange={(event) => onChange('targetAudience', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="sb-field">
|
||||
<span>Hosting preference</span>
|
||||
<select
|
||||
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>
|
||||
</label>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
52
site-builder/src/pages/wizard/steps/ObjectivesStep.tsx
Normal file
52
site-builder/src/pages/wizard/steps/ObjectivesStep.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useState } from 'react';
|
||||
import type { BuilderFormData } from '../../../types/siteBuilder';
|
||||
import { Card } from '../../../components/common/Card';
|
||||
|
||||
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
|
||||
title="Success metrics & flows"
|
||||
description="List the outcomes the site must accomplish. These become top-level navigation items and hero CTAs."
|
||||
>
|
||||
<div className="sb-pill-list">
|
||||
{data.objectives.map((objective, idx) => (
|
||||
<span className="sb-pill" key={`${objective}-${idx}`}>
|
||||
{objective}
|
||||
<button type="button" onClick={() => removeObjective(idx)} aria-label="Remove objective">
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="sb-objective-input">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder="Offer product tour, capture demo requests, educate on ROI…"
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
/>
|
||||
<button type="button" onClick={handleAdd}>
|
||||
Add objective
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
74
site-builder/src/pages/wizard/steps/StyleStep.tsx
Normal file
74
site-builder/src/pages/wizard/steps/StyleStep.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { StylePreferences } from '../../../types/siteBuilder';
|
||||
import { Card } from '../../../components/common/Card';
|
||||
|
||||
interface Props {
|
||||
style: StylePreferences;
|
||||
onChange: (partial: Partial<StylePreferences>) => void;
|
||||
}
|
||||
|
||||
const palettes = [
|
||||
'Minimal monochrome with bright accent',
|
||||
'Rich jewel tones with high contrast',
|
||||
'Soft gradients and glassmorphism',
|
||||
'Playful pastel palette',
|
||||
];
|
||||
|
||||
const typographyOptions = [
|
||||
'Modern sans-serif for headings, serif body text',
|
||||
'Editorial serif across the site',
|
||||
'Geometric sans with tight tracking',
|
||||
'Rounded fonts with friendly tone',
|
||||
];
|
||||
|
||||
export function StyleStep({ style, onChange }: Props) {
|
||||
return (
|
||||
<Card
|
||||
title="Look & Feel"
|
||||
description="Capture the brand personality so the preview canvas can mirror the right tone."
|
||||
>
|
||||
<div className="sb-grid">
|
||||
<label className="sb-field">
|
||||
<span>Palette direction</span>
|
||||
<select value={style.palette} onChange={(event) => onChange({ palette: event.target.value })}>
|
||||
{palettes.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="sb-field">
|
||||
<span>Typography</span>
|
||||
<select value={style.typography} onChange={(event) => onChange({ typography: event.target.value })}>
|
||||
{typographyOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="sb-field">
|
||||
<span>Brand personality</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={style.personality}
|
||||
onChange={(event) => onChange({ personality: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="sb-field">
|
||||
<span>Hero imagery direction</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={style.heroImagery}
|
||||
onChange={(event) => onChange({ heroImagery: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user