Implement Site Builder Metadata and Enhance Wizard Functionality
- Introduced new models for Site Builder options, including BusinessType, AudienceProfile, BrandPersonality, and HeroImageryDirection. - Added serializers and views to handle metadata for dropdowns in the Site Builder wizard. - Updated the SiteBuilderWizard component to load and display metadata, improving user experience with dynamic options. - Enhanced BusinessDetailsStep and StyleStep components to utilize new metadata for business types and brand personalities. - Refactored state management in builderStore to include metadata loading and error handling. - Updated API service to fetch Site Builder metadata, ensuring seamless integration with the frontend.
This commit is contained in:
@@ -26,7 +26,7 @@ import { StyleStep } from "./steps/StyleStep";
|
||||
export default function SiteBuilderWizard() {
|
||||
const navigate = useNavigate();
|
||||
const { activeSite } = useSiteStore();
|
||||
const { activeSector } = useSectorStore();
|
||||
const { sectors } = useSectorStore();
|
||||
const {
|
||||
form,
|
||||
currentStep,
|
||||
@@ -46,18 +46,40 @@ export default function SiteBuilderWizard() {
|
||||
generationProgress,
|
||||
isGenerating,
|
||||
syncContextFromStores,
|
||||
metadata,
|
||||
metadataError,
|
||||
isMetadataLoading,
|
||||
loadMetadata,
|
||||
} = useBuilderStore();
|
||||
|
||||
useEffect(() => {
|
||||
syncContextFromStores();
|
||||
}, [activeSite?.id, activeSite?.name, activeSector?.id]);
|
||||
}, [activeSite?.id, activeSite?.name]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMetadata();
|
||||
}, [loadMetadata]);
|
||||
|
||||
const selectedSectors = useMemo(
|
||||
() =>
|
||||
form.sectorIds.map((id) => ({
|
||||
id,
|
||||
name: sectors.find((sector) => sector.id === id)?.name || `Sector #${id}`,
|
||||
})),
|
||||
[form.sectorIds, sectors],
|
||||
);
|
||||
|
||||
const steps = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: "Business context",
|
||||
component: (
|
||||
<BusinessDetailsStep data={form} onChange={setField} />
|
||||
<BusinessDetailsStep
|
||||
data={form}
|
||||
onChange={setField}
|
||||
metadata={metadata}
|
||||
selectedSectors={selectedSectors}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -79,16 +101,31 @@ export default function SiteBuilderWizard() {
|
||||
component: (
|
||||
<StyleStep
|
||||
style={form.style}
|
||||
onChange={updateStyle}
|
||||
metadata={metadata}
|
||||
brandPersonalityIds={form.brandPersonalityIds}
|
||||
customBrandPersonality={form.customBrandPersonality}
|
||||
heroImageryDirectionId={form.heroImageryDirectionId}
|
||||
customHeroImageryDirection={form.customHeroImageryDirection}
|
||||
onStyleChange={updateStyle}
|
||||
onChange={setField}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[form, setField, updateStyle, addObjective, removeObjective],
|
||||
[
|
||||
form,
|
||||
metadata,
|
||||
selectedSectors,
|
||||
setField,
|
||||
updateStyle,
|
||||
addObjective,
|
||||
removeObjective,
|
||||
],
|
||||
);
|
||||
|
||||
const isLastStep = currentStep === steps.length - 1;
|
||||
const missingContext = !activeSite || !activeSector;
|
||||
const missingContext =
|
||||
!activeSite || !form.sectorIds || form.sectorIds.length === 0;
|
||||
|
||||
const handlePrimary = async () => {
|
||||
if (isLastStep) {
|
||||
@@ -123,13 +160,28 @@ export default function SiteBuilderWizard() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SiteAndSectorSelector />
|
||||
<SiteAndSectorSelector hideSectorSelector />
|
||||
|
||||
{metadataError && (
|
||||
<Alert
|
||||
variant="warning"
|
||||
title="Metadata unavailable"
|
||||
message={`${metadataError}. You can still enter custom values.`}
|
||||
/>
|
||||
)}
|
||||
{isMetadataLoading && !metadata && (
|
||||
<Alert
|
||||
variant="info"
|
||||
title="Loading Site Builder library"
|
||||
message="Fetching business types, audiences, and style presets..."
|
||||
/>
|
||||
)}
|
||||
|
||||
{missingContext && (
|
||||
<Alert
|
||||
variant="warning"
|
||||
title="Select site & sector"
|
||||
message="Choose an active site and sector using the selector above before running the wizard."
|
||||
title="Missing site configuration"
|
||||
message="Choose an active site and ensure it has at least one sector configured (Sites → All Sites) before running the wizard."
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { BuilderFormData } from "../../../../types/siteBuilder";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import type {
|
||||
BuilderFormData,
|
||||
SiteBuilderMetadata,
|
||||
} from "../../../../types/siteBuilder";
|
||||
import { Card } from "../../../../components/ui/card";
|
||||
import { useSiteStore } from "../../../../store/siteStore";
|
||||
import { useSectorStore } from "../../../../store/sectorStore";
|
||||
import { Dropdown } from "../../../../components/ui/dropdown/Dropdown";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
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";
|
||||
@@ -11,15 +16,73 @@ const labelClass =
|
||||
|
||||
interface Props {
|
||||
data: BuilderFormData;
|
||||
metadata?: SiteBuilderMetadata;
|
||||
selectedSectors: Array<{ id: number; name: string }>;
|
||||
onChange: <K extends keyof BuilderFormData>(
|
||||
key: K,
|
||||
value: BuilderFormData[K],
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function BusinessDetailsStep({ data, onChange }: Props) {
|
||||
export function BusinessDetailsStep({
|
||||
data,
|
||||
metadata,
|
||||
selectedSectors,
|
||||
onChange,
|
||||
}: Props) {
|
||||
const { activeSite } = useSiteStore();
|
||||
const { activeSector } = useSectorStore();
|
||||
const [businessDropdownOpen, setBusinessDropdownOpen] = useState(false);
|
||||
const businessButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [audienceDropdownOpen, setAudienceDropdownOpen] = useState(false);
|
||||
const audienceButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const businessOptions = metadata?.business_types ?? [];
|
||||
const audienceOptions = metadata?.audience_profiles ?? [];
|
||||
|
||||
const selectedBusinessType = businessOptions.find(
|
||||
(option) => option.id === data.businessTypeId,
|
||||
);
|
||||
|
||||
const selectedAudienceOptions = useMemo(
|
||||
() =>
|
||||
audienceOptions.filter((option) =>
|
||||
data.targetAudienceIds.includes(option.id),
|
||||
),
|
||||
[audienceOptions, data.targetAudienceIds],
|
||||
);
|
||||
|
||||
const computeAudienceSummary = (
|
||||
ids: number[],
|
||||
custom?: string,
|
||||
): string => {
|
||||
const names = audienceOptions
|
||||
.filter((option) => ids.includes(option.id))
|
||||
.map((option) => option.name);
|
||||
if (custom?.trim()) {
|
||||
names.push(custom.trim());
|
||||
}
|
||||
return names.join(", ");
|
||||
};
|
||||
|
||||
const toggleAudience = (audienceId: number) => {
|
||||
const isSelected = data.targetAudienceIds.includes(audienceId);
|
||||
const next = isSelected
|
||||
? data.targetAudienceIds.filter((id) => id !== audienceId)
|
||||
: [...data.targetAudienceIds, audienceId];
|
||||
onChange("targetAudienceIds", next);
|
||||
onChange("targetAudience", computeAudienceSummary(next, data.customTargetAudience));
|
||||
};
|
||||
|
||||
const handleCustomAudienceChange = (value: string) => {
|
||||
onChange("customTargetAudience", value);
|
||||
onChange("targetAudience", computeAudienceSummary(data.targetAudienceIds, value));
|
||||
};
|
||||
|
||||
const handleCustomBusinessTypeChange = (value: string) => {
|
||||
onChange("customBusinessType", value);
|
||||
onChange("businessTypeId", null);
|
||||
onChange("businessType", value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -29,17 +92,16 @@ export function BusinessDetailsStep({ data, onChange }: Props) {
|
||||
Context
|
||||
</p>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Site & Sector
|
||||
Site & sectors
|
||||
</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.
|
||||
IGNY8 will generate a blueprint for every sector configured on this site.
|
||||
</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
|
||||
Active site
|
||||
</p>
|
||||
<p className="text-base font-semibold text-brand-700 dark:text-brand-200">
|
||||
{activeSite?.name ?? "No site selected"}
|
||||
@@ -47,11 +109,24 @@ export function BusinessDetailsStep({ data, onChange }: Props) {
|
||||
</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"}
|
||||
Included sectors
|
||||
</p>
|
||||
{selectedSectors.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{selectedSectors.map((sector) => (
|
||||
<span
|
||||
key={sector.id}
|
||||
className="inline-flex items-center rounded-full bg-white px-3 py-1 text-xs font-semibold text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-100"
|
||||
>
|
||||
{sector.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm font-semibold text-indigo-700 dark:text-indigo-200">
|
||||
No sectors configured for this site
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -74,7 +149,10 @@ export function BusinessDetailsStep({ data, onChange }: Props) {
|
||||
className={inputClass}
|
||||
value={data.hostingType}
|
||||
onChange={(event) =>
|
||||
onChange("hostingType", event.target.value as BuilderFormData["hostingType"])
|
||||
onChange(
|
||||
"hostingType",
|
||||
event.target.value as BuilderFormData["hostingType"],
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="igny8_sites">IGNY8 Sites</option>
|
||||
@@ -88,34 +166,194 @@ export function BusinessDetailsStep({ data, onChange }: Props) {
|
||||
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label className={labelClass}>Business type</label>
|
||||
<button
|
||||
ref={businessButtonRef}
|
||||
type="button"
|
||||
onClick={() => setBusinessDropdownOpen((open) => !open)}
|
||||
className="dropdown-toggle flex w-full items-center justify-between rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-900 transition hover:border-brand-200 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/90"
|
||||
>
|
||||
<span>
|
||||
{selectedBusinessType?.name ||
|
||||
"Select a business type from the library"}
|
||||
</span>
|
||||
<svg
|
||||
className="h-4 w-4 text-gray-500 dark:text-gray-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.23 7.21a.75.75 0 011.06.02L10 10.94l3.71-3.71a.75.75 0 111.08 1.04l-4.25 4.25a.75.75 0 01-1.08 0L5.21 8.27a.75.75 0 01.02-1.06z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<Dropdown
|
||||
isOpen={businessDropdownOpen}
|
||||
onClose={() => setBusinessDropdownOpen(false)}
|
||||
anchorRef={businessButtonRef}
|
||||
placement="bottom-left"
|
||||
className="w-72 max-h-72 overflow-y-auto p-2"
|
||||
>
|
||||
{businessOptions.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-gray-500">
|
||||
No business types defined yet.
|
||||
</div>
|
||||
) : (
|
||||
businessOptions.map((option) => {
|
||||
const isSelected = option.id === data.businessTypeId;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange("businessTypeId", option.id);
|
||||
onChange("businessType", option.name);
|
||||
onChange("customBusinessType", "");
|
||||
setBusinessDropdownOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-start gap-3 rounded-xl px-3 py-2 text-left text-sm ${
|
||||
isSelected
|
||||
? "bg-brand-50 text-brand-700 dark:bg-brand-500/10 dark:text-brand-100"
|
||||
: "text-gray-700 hover:bg-gray-100 dark:text-white/80 dark:hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1">
|
||||
<span className="font-semibold">{option.name}</span>
|
||||
{option.description && (
|
||||
<span className="block text-xs text-gray-500 dark:text-gray-400">
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{isSelected && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Dropdown>
|
||||
<input
|
||||
className={inputClass}
|
||||
className={`${inputClass} mt-3`}
|
||||
type="text"
|
||||
value={data.businessType}
|
||||
placeholder="B2B SaaS platform"
|
||||
onChange={(event) => onChange("businessType", event.target.value)}
|
||||
value={data.customBusinessType ?? ""}
|
||||
placeholder="Or describe a custom business model"
|
||||
onChange={(event) =>
|
||||
handleCustomBusinessTypeChange(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)}
|
||||
/>
|
||||
<label className={labelClass}>Industry (from site settings)</label>
|
||||
<div className="flex h-11 items-center rounded-xl border border-gray-100 bg-gray-50 px-4 text-sm font-semibold text-gray-700 dark:border-white/10 dark:bg-white/[0.05] dark:text-white/80">
|
||||
{data.industry || "No industry selected"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mt-6">
|
||||
<label className={labelClass}>Target audience</label>
|
||||
<Card variant="surface" padding="lg">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className={labelClass}>Target audience</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Choose one or more audience profiles from the IGNY8 library. Add your own if needed.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
ref={audienceButtonRef}
|
||||
type="button"
|
||||
onClick={() => setAudienceDropdownOpen((open) => !open)}
|
||||
className="dropdown-toggle flex w-full items-center justify-between rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-900 transition hover:border-brand-200 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/90"
|
||||
>
|
||||
<span>
|
||||
{selectedAudienceOptions.length > 0
|
||||
? `${selectedAudienceOptions.length} audience${
|
||||
selectedAudienceOptions.length > 1 ? "s" : ""
|
||||
} selected`
|
||||
: "Select audiences to focus the messaging"}
|
||||
</span>
|
||||
<svg
|
||||
className="h-4 w-4 text-gray-500 dark:text-gray-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.23 7.21a.75.75 0 011.06.02L10 10.94l3.71-3.71a.75.75 0 111.08 1.04l-4.25 4.25a.75.75 0 01-1.08 0L5.21 8.27a.75.75 0 01.02-1.06z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<Dropdown
|
||||
isOpen={audienceDropdownOpen}
|
||||
onClose={() => setAudienceDropdownOpen(false)}
|
||||
anchorRef={audienceButtonRef}
|
||||
placement="bottom-left"
|
||||
className="w-80 max-h-80 overflow-y-auto p-2"
|
||||
>
|
||||
{audienceOptions.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-gray-500">
|
||||
No audience profiles defined yet.
|
||||
</div>
|
||||
) : (
|
||||
audienceOptions.map((option) => {
|
||||
const isSelected = data.targetAudienceIds.includes(option.id);
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => toggleAudience(option.id)}
|
||||
className={`flex w-full items-start gap-3 rounded-xl px-3 py-2 text-left text-sm ${
|
||||
isSelected
|
||||
? "bg-brand-50 text-brand-700 dark:bg-brand-500/10 dark:text-brand-100"
|
||||
: "text-gray-700 hover:bg-gray-100 dark:text-white/80 dark:hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1">
|
||||
<span className="font-semibold">{option.name}</span>
|
||||
{option.description && (
|
||||
<span className="block text-xs text-gray-500 dark:text-gray-400">
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{isSelected && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedAudienceOptions.map((option) => (
|
||||
<span
|
||||
key={option.id}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-brand-50 px-3 py-1 text-xs font-semibold text-brand-700 dark:bg-brand-500/10 dark:text-brand-100"
|
||||
>
|
||||
{option.name}
|
||||
<button
|
||||
type="button"
|
||||
className="text-brand-600 hover:text-brand-800 dark:text-brand-200 dark:hover:text-brand-50"
|
||||
onClick={() => toggleAudience(option.id)}
|
||||
aria-label={`Remove ${option.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{data.customTargetAudience?.trim() && (
|
||||
<span className="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-semibold text-gray-700 dark:bg-white/10 dark:text-white/80">
|
||||
{data.customTargetAudience.trim()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
className={inputClass}
|
||||
type="text"
|
||||
value={data.targetAudience}
|
||||
placeholder="Operations leaders at fast-scaling eCommerce brands"
|
||||
onChange={(event) => onChange("targetAudience", event.target.value)}
|
||||
value={data.customTargetAudience ?? ""}
|
||||
placeholder="Add custom audience (e.g., Healthcare innovators)"
|
||||
onChange={(event) => handleCustomAudienceChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import type { StylePreferences } from "../../../../types/siteBuilder";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import type {
|
||||
BuilderFormData,
|
||||
SiteBuilderMetadata,
|
||||
StylePreferences,
|
||||
} from "../../../../types/siteBuilder";
|
||||
import { Card } from "../../../../components/ui/card";
|
||||
import { Dropdown } from "../../../../components/ui/dropdown/Dropdown";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
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 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 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";
|
||||
|
||||
@@ -24,10 +33,53 @@ const typography = [
|
||||
|
||||
interface Props {
|
||||
style: StylePreferences;
|
||||
onChange: (partial: Partial<StylePreferences>) => void;
|
||||
metadata?: SiteBuilderMetadata;
|
||||
brandPersonalityIds: number[];
|
||||
customBrandPersonality?: string;
|
||||
heroImageryDirectionId: number | null;
|
||||
customHeroImageryDirection?: string;
|
||||
onStyleChange: (partial: Partial<StylePreferences>) => void;
|
||||
onChange: <K extends keyof BuilderFormData>(
|
||||
key: K,
|
||||
value: BuilderFormData[K],
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function StyleStep({ style, onChange }: Props) {
|
||||
export function StyleStep({
|
||||
style,
|
||||
metadata,
|
||||
brandPersonalityIds,
|
||||
customBrandPersonality,
|
||||
heroImageryDirectionId,
|
||||
customHeroImageryDirection,
|
||||
onStyleChange,
|
||||
onChange,
|
||||
}: Props) {
|
||||
const brandOptions = metadata?.brand_personalities ?? [];
|
||||
const heroOptions = metadata?.hero_imagery_directions ?? [];
|
||||
|
||||
const [heroDropdownOpen, setHeroDropdownOpen] = useState(false);
|
||||
const heroButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [brandDropdownOpen, setBrandDropdownOpen] = useState(false);
|
||||
const brandButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const selectedBrandOptions = useMemo(
|
||||
() => brandOptions.filter((option) => brandPersonalityIds.includes(option.id)),
|
||||
[brandOptions, brandPersonalityIds],
|
||||
);
|
||||
|
||||
const selectedHero = heroOptions.find(
|
||||
(option) => option.id === heroImageryDirectionId,
|
||||
);
|
||||
|
||||
const toggleBrand = (id: number) => {
|
||||
const isSelected = brandPersonalityIds.includes(id);
|
||||
const next = isSelected
|
||||
? brandPersonalityIds.filter((value) => value !== id)
|
||||
: [...brandPersonalityIds, id];
|
||||
onChange("brandPersonalityIds", next);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card variant="surface" padding="lg">
|
||||
<div className="space-y-6">
|
||||
@@ -39,8 +91,7 @@ export function StyleStep({ style, onChange }: Props) {
|
||||
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.
|
||||
Capture the brand personality so the preview canvas mirrors the right tone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -50,7 +101,7 @@ export function StyleStep({ style, onChange }: Props) {
|
||||
<select
|
||||
className={selectClass}
|
||||
value={style.palette}
|
||||
onChange={(event) => onChange({ palette: event.target.value })}
|
||||
onChange={(event) => onStyleChange({ palette: event.target.value })}
|
||||
>
|
||||
{palettes.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
@@ -64,7 +115,9 @@ export function StyleStep({ style, onChange }: Props) {
|
||||
<select
|
||||
className={selectClass}
|
||||
value={style.typography}
|
||||
onChange={(event) => onChange({ typography: event.target.value })}
|
||||
onChange={(event) =>
|
||||
onStyleChange({ typography: event.target.value })
|
||||
}
|
||||
>
|
||||
{typography.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
@@ -75,26 +128,212 @@ export function StyleStep({ style, onChange }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className={labelClass}>Brand personality profiles</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Select up to three descriptors that define the brand tone.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
ref={brandButtonRef}
|
||||
type="button"
|
||||
onClick={() => setBrandDropdownOpen((open) => !open)}
|
||||
className="dropdown-toggle flex w-full items-center justify-between rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-900 transition hover:border-brand-200 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/90"
|
||||
>
|
||||
<span>
|
||||
{brandPersonalityIds.length > 0
|
||||
? `${brandPersonalityIds.length} personality${
|
||||
brandPersonalityIds.length > 1 ? " descriptors" : " descriptor"
|
||||
} selected`
|
||||
: "Choose personalities from the IGNY8 library"}
|
||||
</span>
|
||||
<svg
|
||||
className="h-4 w-4 text-gray-500 dark:text-gray-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.23 7.21a.75.75 0 011.06.02L10 10.94l3.71-3.71a.75.75 0 111.08 1.04l-4.25 4.25a.75.75 0 01-1.08 0L5.21 8.27a.75.75 0 01.02-1.06z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<Dropdown
|
||||
isOpen={brandDropdownOpen}
|
||||
onClose={() => setBrandDropdownOpen(false)}
|
||||
anchorRef={brandButtonRef}
|
||||
placement="bottom-left"
|
||||
className="w-80 max-h-80 overflow-y-auto p-2"
|
||||
>
|
||||
{brandOptions.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-gray-500">
|
||||
No brand personalities defined yet. Use the custom field below.
|
||||
</div>
|
||||
) : (
|
||||
brandOptions.map((option) => {
|
||||
const isSelected = brandPersonalityIds.includes(option.id);
|
||||
const disabled =
|
||||
!isSelected && brandPersonalityIds.length >= 3;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => toggleBrand(option.id)}
|
||||
className={`flex w-full items-start gap-3 rounded-xl px-3 py-2 text-left text-sm ${
|
||||
isSelected
|
||||
? "bg-brand-50 text-brand-700 dark:bg-brand-500/10 dark:text-brand-100"
|
||||
: "text-gray-700 hover:bg-gray-100 disabled:opacity-50 dark:text-white/80 dark:hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1">
|
||||
<span className="font-semibold">{option.name}</span>
|
||||
{option.description && (
|
||||
<span className="block text-xs text-gray-500 dark:text-gray-400">
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{isSelected && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedBrandOptions.map((option) => (
|
||||
<span
|
||||
key={option.id}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-brand-50 px-3 py-1 text-xs font-semibold text-brand-700 dark:bg-brand-500/10 dark:text-brand-100"
|
||||
>
|
||||
{option.name}
|
||||
<button
|
||||
type="button"
|
||||
className="text-brand-600 hover:text-brand-800 dark:text-brand-100 dark:hover:text-brand-50"
|
||||
onClick={() => toggleBrand(option.id)}
|
||||
aria-label={`Remove ${option.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{customBrandPersonality?.trim() && (
|
||||
<span className="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-semibold text-gray-700 dark:bg-white/10 dark:text-white/80">
|
||||
{customBrandPersonality.trim()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
className={inputClass}
|
||||
type="text"
|
||||
value={customBrandPersonality ?? ""}
|
||||
placeholder="Add custom personality descriptor"
|
||||
onChange={(event) =>
|
||||
onChange("customBrandPersonality", event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label className={labelClass}>Brand personality</label>
|
||||
<label className={labelClass}>Brand personality narrative</label>
|
||||
<textarea
|
||||
className={textareaClass}
|
||||
rows={3}
|
||||
rows={4}
|
||||
value={style.personality}
|
||||
onChange={(event) =>
|
||||
onChange({ personality: event.target.value })
|
||||
onStyleChange({ personality: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Hero imagery direction</label>
|
||||
<button
|
||||
ref={heroButtonRef}
|
||||
type="button"
|
||||
onClick={() => setHeroDropdownOpen((open) => !open)}
|
||||
className="dropdown-toggle flex w-full items-center justify-between rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-900 transition hover:border-brand-200 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/90"
|
||||
>
|
||||
<span>
|
||||
{selectedHero?.name ||
|
||||
"Select a hero imagery direction from the library"}
|
||||
</span>
|
||||
<svg
|
||||
className="h-4 w-4 text-gray-500 dark:text-gray-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.23 7.21a.75.75 0 011.06.02L10 10.94l3.71-3.71a.75.75 0 111.08 1.04l-4.25 4.25a.75.75 0 01-1.08 0L5.21 8.27a.75.75 0 01.02-1.06z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<Dropdown
|
||||
isOpen={heroDropdownOpen}
|
||||
onClose={() => setHeroDropdownOpen(false)}
|
||||
anchorRef={heroButtonRef}
|
||||
placement="bottom-left"
|
||||
className="w-80 max-h-72 overflow-y-auto p-2"
|
||||
>
|
||||
{heroOptions.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-gray-500">
|
||||
No hero imagery directions defined yet.
|
||||
</div>
|
||||
) : (
|
||||
heroOptions.map((option) => {
|
||||
const isSelected = option.id === heroImageryDirectionId;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange("heroImageryDirectionId", option.id);
|
||||
onChange("customHeroImageryDirection", "");
|
||||
onStyleChange({ heroImagery: option.name });
|
||||
setHeroDropdownOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-start gap-3 rounded-xl px-3 py-2 text-left text-sm ${
|
||||
isSelected
|
||||
? "bg-brand-50 text-brand-700 dark:bg-brand-500/10 dark:text-brand-100"
|
||||
: "text-gray-700 hover:bg-gray-100 dark:text-white/80 dark:hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1">
|
||||
<span className="font-semibold">{option.name}</span>
|
||||
{option.description && (
|
||||
<span className="block text-xs text-gray-500 dark:text-gray-400">
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{isSelected && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Dropdown>
|
||||
<input
|
||||
className={`${inputClass} mt-3`}
|
||||
type="text"
|
||||
value={customHeroImageryDirection ?? ""}
|
||||
placeholder="Or describe a custom hero imagery direction"
|
||||
onChange={(event) => {
|
||||
onChange("customHeroImageryDirection", event.target.value);
|
||||
onChange("heroImageryDirectionId", null);
|
||||
}}
|
||||
/>
|
||||
<textarea
|
||||
className={textareaClass}
|
||||
rows={3}
|
||||
className={`${textareaClass} mt-3`}
|
||||
rows={4}
|
||||
value={style.heroImagery}
|
||||
onChange={(event) =>
|
||||
onChange({ heroImagery: event.target.value })
|
||||
onStyleChange({ heroImagery: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user