346 lines
14 KiB
TypeScript
346 lines
14 KiB
TypeScript
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 { CheckLineIcon } from "../../../../icons";
|
||
|
||
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";
|
||
|
||
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;
|
||
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,
|
||
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">
|
||
<div>
|
||
<p className="text-xs uppercase tracking-wider text-gray-500 dark:text-white/50">
|
||
Look & 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) => onStyleChange({ 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) =>
|
||
onStyleChange({ typography: event.target.value })
|
||
}
|
||
>
|
||
{typography.map((option) => (
|
||
<option key={option} value={option}>
|
||
{option}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</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 && <CheckLineIcon 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 narrative</label>
|
||
<textarea
|
||
className={textareaClass}
|
||
rows={4}
|
||
value={style.personality}
|
||
onChange={(event) =>
|
||
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 && <CheckLineIcon 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} mt-3`}
|
||
rows={4}
|
||
value={style.heroImagery}
|
||
onChange={(event) =>
|
||
onStyleChange({ heroImagery: event.target.value })
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
);
|
||
}
|
||
|