diff --git a/backend/celerybeat-schedule b/backend/celerybeat-schedule index b7fd6992..0075704a 100644 Binary files a/backend/celerybeat-schedule and b/backend/celerybeat-schedule differ diff --git a/backend/igny8_core/business/site_building/migrations/0002_sitebuilder_metadata.py b/backend/igny8_core/business/site_building/migrations/0002_sitebuilder_metadata.py new file mode 100644 index 00000000..f944b4c0 --- /dev/null +++ b/backend/igny8_core/business/site_building/migrations/0002_sitebuilder_metadata.py @@ -0,0 +1,159 @@ +from django.db import migrations, models + + +def seed_site_builder_metadata(apps, schema_editor): + BusinessType = apps.get_model('site_building', 'BusinessType') + AudienceProfile = apps.get_model('site_building', 'AudienceProfile') + BrandPersonality = apps.get_model('site_building', 'BrandPersonality') + HeroImageryDirection = apps.get_model('site_building', 'HeroImageryDirection') + + business_types = [ + ("Productized Services", "Standardized service offering with clear deliverables."), + ("B2B SaaS Platform", "Subscription software platform targeting business teams."), + ("eCommerce Brand", "Direct-to-consumer catalog with premium merchandising."), + ("Marketplace / Platform", "Two-sided marketplace connecting buyers and sellers."), + ("Advisory / Consulting", "Expert advisory firm or boutique consultancy."), + ("Education / Training", "Learning platform, cohort, or academy."), + ("Community / Membership", "Member-driven experience with gated content."), + ("Mission-Driven / Nonprofit", "Impact-focused organization or foundation."), + ] + + audience_profiles = [ + ("Enterprise Operations Leaders", "COO / Ops executives at scale-ups."), + ("Marketing Directors & CMOs", "Growth and brand owners across industries."), + ("Founders & Executive Teams", "Visionaries leading fast-moving companies."), + ("Revenue & Sales Leaders", "CROs, VPs of Sales, and GTM owners."), + ("Product & Innovation Teams", "Product managers and innovation leaders."), + ("IT & Engineering Teams", "Technical buyers evaluating new platforms."), + ("HR & People Leaders", "People ops and talent professionals."), + ("Healthcare Administrators", "Clinical and operational healthcare leads."), + ("Financial Services Professionals", "Banking, fintech, and investment teams."), + ("Consumers / Prospects", "End-user or prospect-focused experience."), + ] + + brand_personalities = [ + ("Bold Visionary", "Decisive, future-forward, and thought-leading."), + ("Trusted Advisor", "Calm, credible, and risk-aware guidance."), + ("Analytical Expert", "Data-backed, precise, and rigorous."), + ("Friendly Guide", "Welcoming, warm, and supportive tone."), + ("Luxe & Premium", "High-touch, elevated, and detail-obsessed."), + ("Playful Creative", "Vibrant, unexpected, and energetic."), + ("Minimalist Modern", "Clean, refined, and confident."), + ("Fearless Innovator", "Experimental, edgy, and fast-moving."), + ] + + hero_imagery = [ + ("Real team collaboration photography", "Documentary-style shots of real teams."), + ("Product close-ups with UI overlays", "Focus on interfaces and feature highlights."), + ("Lifestyle scenes featuring customers", "Story-driven photography of real scenarios."), + ("Abstract gradients & motion graphics", "Modern, colorful abstract compositions."), + ("Illustration + iconography blend", "Custom illustrations paired with icon systems."), + ] + + for order, (name, description) in enumerate(business_types): + BusinessType.objects.update_or_create( + name=name, + defaults={'description': description, 'order': order, 'is_active': True}, + ) + + for order, (name, description) in enumerate(audience_profiles): + AudienceProfile.objects.update_or_create( + name=name, + defaults={'description': description, 'order': order, 'is_active': True}, + ) + + for order, (name, description) in enumerate(brand_personalities): + BrandPersonality.objects.update_or_create( + name=name, + defaults={'description': description, 'order': order, 'is_active': True}, + ) + + for order, (name, description) in enumerate(hero_imagery): + HeroImageryDirection.objects.update_or_create( + name=name, + defaults={'description': description, 'order': order, 'is_active': True}, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('site_building', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='AudienceProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=120, unique=True)), + ('description', models.CharField(blank=True, max_length=255)), + ('is_active', models.BooleanField(default=True)), + ('order', models.PositiveIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Audience Profile', + 'verbose_name_plural': 'Audience Profiles', + 'db_table': 'igny8_site_builder_audience_profiles', + 'ordering': ['order', 'name'], + }, + ), + migrations.CreateModel( + name='BrandPersonality', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=120, unique=True)), + ('description', models.CharField(blank=True, max_length=255)), + ('is_active', models.BooleanField(default=True)), + ('order', models.PositiveIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Brand Personality', + 'verbose_name_plural': 'Brand Personalities', + 'db_table': 'igny8_site_builder_brand_personalities', + 'ordering': ['order', 'name'], + }, + ), + migrations.CreateModel( + name='BusinessType', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=120, unique=True)), + ('description', models.CharField(blank=True, max_length=255)), + ('is_active', models.BooleanField(default=True)), + ('order', models.PositiveIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Business Type', + 'verbose_name_plural': 'Business Types', + 'db_table': 'igny8_site_builder_business_types', + 'ordering': ['order', 'name'], + }, + ), + migrations.CreateModel( + name='HeroImageryDirection', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=120, unique=True)), + ('description', models.CharField(blank=True, max_length=255)), + ('is_active', models.BooleanField(default=True)), + ('order', models.PositiveIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Hero Imagery Direction', + 'verbose_name_plural': 'Hero Imagery Directions', + 'db_table': 'igny8_site_builder_hero_imagery', + 'ordering': ['order', 'name'], + }, + ), + migrations.RunPython(seed_site_builder_metadata, migrations.RunPython.noop), + ] + diff --git a/backend/igny8_core/business/site_building/models.py b/backend/igny8_core/business/site_building/models.py index 8e760d74..b3a921b9 100644 --- a/backend/igny8_core/business/site_building/models.py +++ b/backend/igny8_core/business/site_building/models.py @@ -166,3 +166,55 @@ class PageBlueprint(SiteSectorBaseModel): return f"{self.title} ({self.site_blueprint.name})" +class SiteBuilderOption(models.Model): + """ + Base model for Site Builder dropdown metadata. + """ + + name = models.CharField(max_length=120, unique=True) + description = models.CharField(max_length=255, blank=True) + is_active = models.BooleanField(default=True) + order = models.PositiveIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + ordering = ['order', 'name'] + + def __str__(self): + return self.name + + +class BusinessType(SiteBuilderOption): + class Meta(SiteBuilderOption.Meta): + app_label = 'site_building' + db_table = 'igny8_site_builder_business_types' + verbose_name = 'Business Type' + verbose_name_plural = 'Business Types' + + +class AudienceProfile(SiteBuilderOption): + class Meta(SiteBuilderOption.Meta): + app_label = 'site_building' + db_table = 'igny8_site_builder_audience_profiles' + verbose_name = 'Audience Profile' + verbose_name_plural = 'Audience Profiles' + + +class BrandPersonality(SiteBuilderOption): + class Meta(SiteBuilderOption.Meta): + app_label = 'site_building' + db_table = 'igny8_site_builder_brand_personalities' + verbose_name = 'Brand Personality' + verbose_name_plural = 'Brand Personalities' + + +class HeroImageryDirection(SiteBuilderOption): + class Meta(SiteBuilderOption.Meta): + app_label = 'site_building' + db_table = 'igny8_site_builder_hero_imagery' + verbose_name = 'Hero Imagery Direction' + verbose_name_plural = 'Hero Imagery Directions' + + diff --git a/backend/igny8_core/modules/site_builder/serializers.py b/backend/igny8_core/modules/site_builder/serializers.py index 0e1715d4..ac21530f 100644 --- a/backend/igny8_core/modules/site_builder/serializers.py +++ b/backend/igny8_core/modules/site_builder/serializers.py @@ -1,6 +1,13 @@ from rest_framework import serializers -from igny8_core.business.site_building.models import SiteBlueprint, PageBlueprint +from igny8_core.business.site_building.models import ( + AudienceProfile, + BrandPersonality, + BusinessType, + HeroImageryDirection, + PageBlueprint, + SiteBlueprint, +) class PageBlueprintSerializer(serializers.ModelSerializer): @@ -76,3 +83,16 @@ class SiteBlueprintSerializer(serializers.ModelSerializer): attrs['sector_id'] = sector_id return attrs + +class MetadataOptionSerializer(serializers.Serializer): + id = serializers.IntegerField() + name = serializers.CharField() + description = serializers.CharField(required=False, allow_blank=True) + + +class SiteBuilderMetadataSerializer(serializers.Serializer): + business_types = MetadataOptionSerializer(many=True) + audience_profiles = MetadataOptionSerializer(many=True) + brand_personalities = MetadataOptionSerializer(many=True) + hero_imagery_directions = MetadataOptionSerializer(many=True) + diff --git a/backend/igny8_core/modules/site_builder/urls.py b/backend/igny8_core/modules/site_builder/urls.py index 8487babc..f9b0ee91 100644 --- a/backend/igny8_core/modules/site_builder/urls.py +++ b/backend/igny8_core/modules/site_builder/urls.py @@ -5,6 +5,7 @@ from igny8_core.modules.site_builder.views import ( PageBlueprintViewSet, SiteAssetView, SiteBlueprintViewSet, + SiteBuilderMetadataView, ) router = DefaultRouter() @@ -14,5 +15,6 @@ router.register(r'pages', PageBlueprintViewSet, basename='page_blueprint') urlpatterns = [ path('', include(router.urls)), path('assets/', SiteAssetView.as_view(), name='site_builder_assets'), + path('metadata/', SiteBuilderMetadataView.as_view(), name='site_builder_metadata'), ] diff --git a/backend/igny8_core/modules/site_builder/views.py b/backend/igny8_core/modules/site_builder/views.py index 4bfdc10b..e12ae8c1 100644 --- a/backend/igny8_core/modules/site_builder/views.py +++ b/backend/igny8_core/modules/site_builder/views.py @@ -9,7 +9,14 @@ from igny8_core.api.base import SiteSectorModelViewSet from igny8_core.api.permissions import IsAuthenticatedAndActive, IsEditorOrAbove from igny8_core.api.response import success_response, error_response from igny8_core.api.throttles import DebugScopedRateThrottle -from igny8_core.business.site_building.models import SiteBlueprint, PageBlueprint +from igny8_core.business.site_building.models import ( + AudienceProfile, + BrandPersonality, + BusinessType, + HeroImageryDirection, + PageBlueprint, + SiteBlueprint, +) from igny8_core.business.site_building.services import ( PageGenerationService, SiteBuilderFileService, @@ -18,6 +25,7 @@ from igny8_core.business.site_building.services import ( from igny8_core.modules.site_builder.serializers import ( PageBlueprintSerializer, SiteBlueprintSerializer, + SiteBuilderMetadataSerializer, ) @@ -205,3 +213,39 @@ class SiteAssetView(APIView): return error_response('File not found', status.HTTP_404_NOT_FOUND, request) +class SiteBuilderMetadataView(APIView): + """ + Read-only metadata for Site Builder dropdowns. + """ + + permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove] + + def get(self, request, *args, **kwargs): + def serialize_queryset(qs): + return [ + { + 'id': item.id, + 'name': item.name, + 'description': item.description or '', + } + for item in qs + ] + + data = { + 'business_types': serialize_queryset( + BusinessType.objects.filter(is_active=True).order_by('order', 'name') + ), + 'audience_profiles': serialize_queryset( + AudienceProfile.objects.filter(is_active=True).order_by('order', 'name') + ), + 'brand_personalities': serialize_queryset( + BrandPersonality.objects.filter(is_active=True).order_by('order', 'name') + ), + 'hero_imagery_directions': serialize_queryset( + HeroImageryDirection.objects.filter(is_active=True).order_by('order', 'name') + ), + } + + serializer = SiteBuilderMetadataSerializer(data) + return Response(serializer.data) + diff --git a/frontend/src/components/common/SiteAndSectorSelector.tsx b/frontend/src/components/common/SiteAndSectorSelector.tsx index 3a245ff8..2d7f8c86 100644 --- a/frontend/src/components/common/SiteAndSectorSelector.tsx +++ b/frontend/src/components/common/SiteAndSectorSelector.tsx @@ -11,7 +11,13 @@ import { useSiteStore } from '../../store/siteStore'; import { useSectorStore } from '../../store/sectorStore'; import { useAuthStore } from '../../store/authStore'; -export default function SiteAndSectorSelector() { +interface SiteAndSectorSelectorProps { + hideSectorSelector?: boolean; +} + +export default function SiteAndSectorSelector({ + hideSectorSelector = false, +}: SiteAndSectorSelectorProps) { const toast = useToast(); const { activeSite, setActiveSite, loadActiveSite } = useSiteStore(); const { activeSector, sectors, setActiveSector, loading: sectorsLoading } = useSectorStore(); @@ -170,7 +176,7 @@ export default function SiteAndSectorSelector() { {/* Sector Selector */} - {!sectorsLoading && sectors.length > 0 && ( + {!hideSectorSelector && !sectorsLoading && sectors.length > 0 && (
- + + + {metadataError && ( + + )} + {isMetadataLoading && !metadata && ( + + )} {missingContext && ( )} diff --git a/frontend/src/pages/Sites/Builder/steps/BusinessDetailsStep.tsx b/frontend/src/pages/Sites/Builder/steps/BusinessDetailsStep.tsx index a6f7a369..9b74c6cf 100644 --- a/frontend/src/pages/Sites/Builder/steps/BusinessDetailsStep.tsx +++ b/frontend/src/pages/Sites/Builder/steps/BusinessDetailsStep.tsx @@ -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: ( 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(null); + const [audienceDropdownOpen, setAudienceDropdownOpen] = useState(false); + const audienceButtonRef = useRef(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 (
@@ -29,17 +92,16 @@ export function BusinessDetailsStep({ data, onChange }: Props) { Context

- Site & Sector + Site & sectors

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

- Active Site + Active site

{activeSite?.name ?? "No site selected"} @@ -47,11 +109,24 @@ export function BusinessDetailsStep({ data, onChange }: Props) {

- Active Sector -

-

- {activeSector?.name ?? "All sectors"} + Included sectors

+ {selectedSectors.length > 0 ? ( +
+ {selectedSectors.map((sector) => ( + + {sector.name} + + ))} +
+ ) : ( +

+ No sectors configured for this site +

+ )}
@@ -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"], + ) } > @@ -88,34 +166,194 @@ export function BusinessDetailsStep({ data, onChange }: Props) {
+ + setBusinessDropdownOpen(false)} + anchorRef={businessButtonRef} + placement="bottom-left" + className="w-72 max-h-72 overflow-y-auto p-2" + > + {businessOptions.length === 0 ? ( +
+ No business types defined yet. +
+ ) : ( + businessOptions.map((option) => { + const isSelected = option.id === data.businessTypeId; + return ( + + ); + }) + )} +
onChange("businessType", event.target.value)} + value={data.customBusinessType ?? ""} + placeholder="Or describe a custom business model" + onChange={(event) => + handleCustomBusinessTypeChange(event.target.value) + } />
- - onChange("industry", event.target.value)} - /> + +
+ {data.industry || "No industry selected"} +
+ -
- + +
+
+ +

+ Choose one or more audience profiles from the IGNY8 library. Add your own if needed. +

+
+
+ + setAudienceDropdownOpen(false)} + anchorRef={audienceButtonRef} + placement="bottom-left" + className="w-80 max-h-80 overflow-y-auto p-2" + > + {audienceOptions.length === 0 ? ( +
+ No audience profiles defined yet. +
+ ) : ( + audienceOptions.map((option) => { + const isSelected = data.targetAudienceIds.includes(option.id); + return ( + + ); + }) + )} +
+
+
+ {selectedAudienceOptions.map((option) => ( + + {option.name} + + + ))} + {data.customTargetAudience?.trim() && ( + + {data.customTargetAudience.trim()} + + )} +
onChange("targetAudience", event.target.value)} + value={data.customTargetAudience ?? ""} + placeholder="Add custom audience (e.g., Healthcare innovators)" + onChange={(event) => handleCustomAudienceChange(event.target.value)} />
diff --git a/frontend/src/pages/Sites/Builder/steps/StyleStep.tsx b/frontend/src/pages/Sites/Builder/steps/StyleStep.tsx index 90875f53..24b7a461 100644 --- a/frontend/src/pages/Sites/Builder/steps/StyleStep.tsx +++ b/frontend/src/pages/Sites/Builder/steps/StyleStep.tsx @@ -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) => void; + metadata?: SiteBuilderMetadata; + brandPersonalityIds: number[]; + customBrandPersonality?: string; + heroImageryDirectionId: number | null; + customHeroImageryDirection?: string; + onStyleChange: (partial: Partial) => void; + onChange: ( + 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(null); + const [brandDropdownOpen, setBrandDropdownOpen] = useState(false); + const brandButtonRef = useRef(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 (
@@ -39,8 +91,7 @@ export function StyleStep({ style, onChange }: Props) { Visual direction

- Capture the brand personality so the preview canvas mirrors the - right tone. + Capture the brand personality so the preview canvas mirrors the right tone.

@@ -50,7 +101,7 @@ export function StyleStep({ style, onChange }: Props) { onChange({ typography: event.target.value })} + onChange={(event) => + onStyleChange({ typography: event.target.value }) + } > {typography.map((option) => (
+
+
+ +

+ Select up to three descriptors that define the brand tone. +

+
+
+ + setBrandDropdownOpen(false)} + anchorRef={brandButtonRef} + placement="bottom-left" + className="w-80 max-h-80 overflow-y-auto p-2" + > + {brandOptions.length === 0 ? ( +
+ No brand personalities defined yet. Use the custom field below. +
+ ) : ( + brandOptions.map((option) => { + const isSelected = brandPersonalityIds.includes(option.id); + const disabled = + !isSelected && brandPersonalityIds.length >= 3; + return ( + + ); + }) + )} +
+
+
+ {selectedBrandOptions.map((option) => ( + + {option.name} + + + ))} + {customBrandPersonality?.trim() && ( + + {customBrandPersonality.trim()} + + )} +
+ + onChange("customBrandPersonality", event.target.value) + } + /> +
+
- +