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,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 &amp; Sector
Site &amp; 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>