Files
igny8/frontend/src/pages/Sites/Builder/steps/StyleStep.tsx

346 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 &amp; 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>
);
}