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:
IGNY8 VPS (Salman)
2025-11-18 12:31:59 +00:00
parent 5d97ab6e49
commit 26ec2ae03e
13 changed files with 1062 additions and 96 deletions

View File

@@ -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>