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 && (
{
syncContextFromStores();
- }, [activeSite?.id, activeSite?.name, activeSector?.id]);
+ }, [activeSite?.id, activeSite?.name]);
+
+ useEffect(() => {
+ loadMetadata();
+ }, [loadMetadata]);
+
+ const selectedSectors = useMemo(
+ () =>
+ form.sectorIds.map((id) => ({
+ id,
+ name: sectors.find((sector) => sector.id === id)?.name || `Sector #${id}`,
+ })),
+ [form.sectorIds, sectors],
+ );
const steps = useMemo(
() => [
{
title: "Business context",
component: (
-
+
),
},
{
@@ -79,16 +101,31 @@ export default function SiteBuilderWizard() {
component: (
),
},
],
- [form, setField, updateStyle, addObjective, removeObjective],
+ [
+ form,
+ metadata,
+ selectedSectors,
+ setField,
+ updateStyle,
+ addObjective,
+ removeObjective,
+ ],
);
const isLastStep = currentStep === steps.length - 1;
- const missingContext = !activeSite || !activeSector;
+ const missingContext =
+ !activeSite || !form.sectorIds || form.sectorIds.length === 0;
const handlePrimary = async () => {
if (isLastStep) {
@@ -123,13 +160,28 @@ export default function SiteBuilderWizard() {
-
+
+
+ {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"],
+ )
}
>
IGNY8 Sites
@@ -88,34 +166,194 @@ export function BusinessDetailsStep({ data, onChange }: Props) {
Business type
+
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"
+ >
+
+ {selectedBusinessType?.name ||
+ "Select a business type from the library"}
+
+
+
+
+
+
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("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"
+ }`}
+ >
+
+ {option.name}
+ {option.description && (
+
+ {option.description}
+
+ )}
+
+ {isSelected && }
+
+ );
+ })
+ )}
+
onChange("businessType", event.target.value)}
+ value={data.customBusinessType ?? ""}
+ placeholder="Or describe a custom business model"
+ onChange={(event) =>
+ handleCustomBusinessTypeChange(event.target.value)
+ }
/>
-
Industry
-
onChange("industry", event.target.value)}
- />
+
Industry (from site settings)
+
+ {data.industry || "No industry selected"}
+
+
-
-
Target audience
+
+
+
+
Target audience
+
+ Choose one or more audience profiles from the IGNY8 library. Add your own if needed.
+
+
+
+
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"
+ >
+
+ {selectedAudienceOptions.length > 0
+ ? `${selectedAudienceOptions.length} audience${
+ selectedAudienceOptions.length > 1 ? "s" : ""
+ } selected`
+ : "Select audiences to focus the messaging"}
+
+
+
+
+
+
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 (
+ 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"
+ }`}
+ >
+
+ {option.name}
+ {option.description && (
+
+ {option.description}
+
+ )}
+
+ {isSelected && }
+
+ );
+ })
+ )}
+
+
+
+ {selectedAudienceOptions.map((option) => (
+
+ {option.name}
+ toggleAudience(option.id)}
+ aria-label={`Remove ${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({ palette: event.target.value })}
+ onChange={(event) => onStyleChange({ palette: event.target.value })}
>
{palettes.map((option) => (
@@ -64,7 +115,9 @@ export function StyleStep({ style, onChange }: Props) {
onChange({ typography: event.target.value })}
+ onChange={(event) =>
+ onStyleChange({ typography: event.target.value })
+ }
>
{typography.map((option) => (
@@ -75,26 +128,212 @@ export function StyleStep({ style, onChange }: Props) {
+
+
+
Brand personality profiles
+
+ Select up to three descriptors that define the brand tone.
+
+
+
+
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"
+ >
+
+ {brandPersonalityIds.length > 0
+ ? `${brandPersonalityIds.length} personality${
+ brandPersonalityIds.length > 1 ? " descriptors" : " descriptor"
+ } selected`
+ : "Choose personalities from the IGNY8 library"}
+
+
+
+
+
+
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 (
+ 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"
+ }`}
+ >
+
+ {option.name}
+ {option.description && (
+
+ {option.description}
+
+ )}
+
+ {isSelected && }
+
+ );
+ })
+ )}
+
+
+
+ {selectedBrandOptions.map((option) => (
+
+ {option.name}
+ toggleBrand(option.id)}
+ aria-label={`Remove ${option.name}`}
+ >
+ ×
+
+
+ ))}
+ {customBrandPersonality?.trim() && (
+
+ {customBrandPersonality.trim()}
+
+ )}
+
+
+ onChange("customBrandPersonality", event.target.value)
+ }
+ />
+
+
- Brand personality
+ Brand personality narrative
Hero imagery direction
+
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"
+ >
+
+ {selectedHero?.name ||
+ "Select a hero imagery direction from the library"}
+
+
+
+
+
+
setHeroDropdownOpen(false)}
+ anchorRef={heroButtonRef}
+ placement="bottom-left"
+ className="w-80 max-h-72 overflow-y-auto p-2"
+ >
+ {heroOptions.length === 0 ? (
+
+ No hero imagery directions defined yet.
+
+ ) : (
+ heroOptions.map((option) => {
+ const isSelected = option.id === heroImageryDirectionId;
+ return (
+ {
+ 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"
+ }`}
+ >
+
+ {option.name}
+ {option.description && (
+
+ {option.description}
+
+ )}
+
+ {isSelected && }
+
+ );
+ })
+ )}
+
+
{
+ onChange("customHeroImageryDirection", event.target.value);
+ onChange("heroImageryDirectionId", null);
+ }}
+ />
diff --git a/frontend/src/services/siteBuilder.api.ts b/frontend/src/services/siteBuilder.api.ts
index ae552a0e..84361708 100644
--- a/frontend/src/services/siteBuilder.api.ts
+++ b/frontend/src/services/siteBuilder.api.ts
@@ -8,6 +8,7 @@ import type {
PageBlueprint,
SiteStructure,
BuilderFormData,
+ SiteBuilderMetadata,
} from '../types/siteBuilder';
export interface CreateBlueprintPayload {
@@ -121,5 +122,12 @@ export const siteBuilderApi = {
// Handle unified response format
return response?.data || response;
},
+
+ /**
+ * Load dropdown metadata for wizard fields
+ */
+ async getMetadata(): Promise
{
+ return fetchAPI('/v1/site-builder/metadata/');
+ },
};
diff --git a/frontend/src/store/builderStore.ts b/frontend/src/store/builderStore.ts
index e69dfd52..9fa796e0 100644
--- a/frontend/src/store/builderStore.ts
+++ b/frontend/src/store/builderStore.ts
@@ -1,6 +1,5 @@
import { create } from "zustand";
import { useSiteStore } from "./siteStore";
-import { useSectorStore } from "./sectorStore";
import { useSiteDefinitionStore } from "./siteDefinitionStore";
import { siteBuilderApi } from "../services/siteBuilder.api";
import type {
@@ -8,6 +7,9 @@ import type {
PageBlueprint,
SiteBlueprint,
StylePreferences,
+ SiteStructure,
+ SiteBuilderMetadata,
+ SiteBuilderMetadataOption,
} from "../types/siteBuilder";
const defaultStyle: StylePreferences = {
@@ -19,19 +21,26 @@ const defaultStyle: StylePreferences = {
const buildDefaultForm = (): BuilderFormData => {
const site = useSiteStore.getState().activeSite;
- const sector = useSectorStore.getState().activeSector;
return {
siteId: site?.id ?? null,
- sectorId: sector?.id ?? null,
+ sectorIds: site?.selected_sectors ?? [],
siteName: site?.name ?? "",
+ businessTypeId: null,
businessType: "",
- industry: "",
+ customBusinessType: "",
+ industry: site?.industry_name ?? "",
+ targetAudienceIds: [],
targetAudience: "",
+ customTargetAudience: "",
hostingType: "igny8_sites",
businessBrief: "",
objectives: ["Launch a conversion-focused marketing site"],
- style: defaultStyle,
+ brandPersonalityIds: [],
+ customBrandPersonality: "",
+ heroImageryDirectionId: null,
+ customHeroImageryDirection: "",
+ style: { ...defaultStyle },
};
};
@@ -41,6 +50,9 @@ interface BuilderState {
isSubmitting: boolean;
isGenerating: boolean;
isLoadingBlueprint: boolean;
+ metadata?: SiteBuilderMetadata;
+ isMetadataLoading: boolean;
+ metadataError?: string;
error?: string;
activeBlueprint?: SiteBlueprint;
pages: PageBlueprint[];
@@ -70,6 +82,7 @@ interface BuilderState {
clearPageSelection: () => void;
loadBlueprint: (blueprintId: number) => Promise;
generateAllPages: (blueprintId: number, force?: boolean) => Promise;
+ loadMetadata: () => Promise;
}
export const useBuilderStore = create((set, get) => ({
@@ -78,6 +91,9 @@ export const useBuilderStore = create((set, get) => ({
isSubmitting: false,
isGenerating: false,
isLoadingBlueprint: false,
+ metadata: undefined,
+ isMetadataLoading: false,
+ metadataError: undefined,
pages: [],
selectedPageIds: [],
@@ -130,62 +146,156 @@ export const useBuilderStore = create((set, get) => ({
syncContextFromStores: () => {
const site = useSiteStore.getState().activeSite;
- const sector = useSectorStore.getState().activeSector;
set((state) => ({
form: {
...state.form,
siteId: site?.id ?? state.form.siteId,
siteName: site?.name ?? state.form.siteName,
- sectorId: sector?.id ?? state.form.sectorId,
+ sectorIds: site?.selected_sectors ?? state.form.sectorIds,
+ industry: site?.industry_name ?? state.form.industry,
},
}));
},
submitWizard: async () => {
- const { form } = get();
- if (!form.siteId || !form.sectorId) {
+ const { form, metadata } = get();
+ if (!form.siteId) {
+ set({
+ error: "Select an active site before running the Site Builder wizard.",
+ });
+ return;
+ }
+ if (!form.sectorIds?.length) {
set({
error:
- "Select an active site and sector before running the Site Builder wizard.",
+ "This site has no sectors configured. Add sectors in Sites → All Sites before running the wizard.",
});
return;
}
- set({ isSubmitting: true, error: undefined });
+ const findOptionName = (
+ options: SiteBuilderMetadataOption[] | undefined,
+ id: number | null | undefined,
+ ) => options?.find((option) => option.id === id)?.name;
+
+ const businessTypeName =
+ findOptionName(metadata?.business_types, form.businessTypeId) ||
+ form.customBusinessType?.trim() ||
+ form.businessType ||
+ "General business";
+
+ const selectedAudienceOptions =
+ metadata?.audience_profiles?.filter((option) =>
+ form.targetAudienceIds.includes(option.id),
+ ) ?? [];
+ const audienceNames = selectedAudienceOptions.map((option) => option.name);
+ if (form.customTargetAudience?.trim()) {
+ audienceNames.push(form.customTargetAudience.trim());
+ }
+ const targetAudienceSummary = audienceNames.join(", ");
+
+ const selectedBrandPersonalities =
+ metadata?.brand_personalities?.filter((option) =>
+ form.brandPersonalityIds.includes(option.id),
+ ) ?? [];
+ const brandPersonalityNames = selectedBrandPersonalities.map(
+ (option) => option.name,
+ );
+ if (form.customBrandPersonality?.trim()) {
+ brandPersonalityNames.push(form.customBrandPersonality.trim());
+ }
+ const personalityDescription =
+ brandPersonalityNames.length > 0
+ ? brandPersonalityNames.join(", ")
+ : form.style.personality;
+
+ const heroImageryName =
+ findOptionName(
+ metadata?.hero_imagery_directions,
+ form.heroImageryDirectionId,
+ ) ||
+ form.customHeroImageryDirection?.trim() ||
+ form.style.heroImagery;
+
+ const preparedForm: BuilderFormData = {
+ ...form,
+ businessType: businessTypeName,
+ targetAudience: targetAudienceSummary,
+ };
+
+ const stylePreferences: StylePreferences = {
+ ...preparedForm.style,
+ personality: personalityDescription,
+ heroImagery: heroImageryName,
+ };
+
+ set({
+ form: { ...preparedForm, style: stylePreferences },
+ isSubmitting: true,
+ error: undefined,
+ });
try {
- const payload = {
- name: form.siteName || `Site Blueprint (${form.industry || "New"})`,
- description: form.businessType
- ? `${form.businessType} for ${form.targetAudience}`
- : undefined,
- site_id: form.siteId,
- sector_id: form.sectorId,
- hosting_type: form.hostingType,
- config_json: {
- business_type: form.businessType,
- industry: form.industry,
- target_audience: form.targetAudience,
- },
- };
+ let lastBlueprint: SiteBlueprint | undefined;
+ let lastStructure: SiteStructure | undefined;
+ for (const sectorId of preparedForm.sectorIds) {
+ const payload = {
+ name:
+ preparedForm.siteName ||
+ `Site Blueprint (${preparedForm.industry || "New"})`,
+ description: targetAudienceSummary
+ ? `${businessTypeName} • ${targetAudienceSummary}`
+ : businessTypeName,
+ site_id: preparedForm.siteId!,
+ sector_id: sectorId,
+ hosting_type: preparedForm.hostingType,
+ config_json: {
+ business_type_id: preparedForm.businessTypeId,
+ business_type: businessTypeName,
+ custom_business_type: preparedForm.customBusinessType,
+ industry: preparedForm.industry,
+ target_audience_ids: preparedForm.targetAudienceIds,
+ target_audience: audienceNames,
+ custom_target_audience: preparedForm.customTargetAudience,
+ brand_personality_ids: preparedForm.brandPersonalityIds,
+ brand_personality: brandPersonalityNames,
+ custom_brand_personality: preparedForm.customBrandPersonality,
+ hero_imagery_direction_id: preparedForm.heroImageryDirectionId,
+ hero_imagery_direction: heroImageryName,
+ custom_hero_imagery_direction:
+ preparedForm.customHeroImageryDirection,
+ sector_id: sectorId,
+ },
+ };
- const blueprint = await siteBuilderApi.createBlueprint(payload);
- set({ activeBlueprint: blueprint });
+ const blueprint = await siteBuilderApi.createBlueprint(payload);
+ lastBlueprint = blueprint;
- const generation = await siteBuilderApi.generateStructure(
- blueprint.id,
- {
- business_brief: form.businessBrief,
- objectives: form.objectives,
- style: form.style,
- metadata: { targetAudience: form.targetAudience },
- },
- );
+ const generation = await siteBuilderApi.generateStructure(
+ blueprint.id,
+ {
+ business_brief: preparedForm.businessBrief,
+ objectives: preparedForm.objectives,
+ style: stylePreferences,
+ metadata: {
+ targetAudience: audienceNames,
+ brandPersonality: brandPersonalityNames,
+ sectorId,
+ },
+ },
+ );
- if (generation?.structure) {
- useSiteDefinitionStore.getState().setStructure(generation.structure);
+ if (generation?.structure) {
+ lastStructure = generation.structure;
+ }
}
- await get().refreshPages(blueprint.id);
+ if (lastBlueprint) {
+ set({ activeBlueprint: lastBlueprint });
+ if (lastStructure) {
+ useSiteDefinitionStore.getState().setStructure(lastStructure);
+ }
+ await get().refreshPages(lastBlueprint.id);
+ }
} catch (error: any) {
set({
error: error?.message || "Unexpected error while running wizard",
@@ -285,5 +395,20 @@ export const useBuilderStore = create((set, get) => ({
set({ isGenerating: false });
}
},
+
+ loadMetadata: async () => {
+ if (get().metadata || get().isMetadataLoading) return;
+ set({ isMetadataLoading: true, metadataError: undefined });
+ try {
+ const metadata = await siteBuilderApi.getMetadata();
+ set({ metadata, isMetadataLoading: false });
+ } catch (error: any) {
+ set({
+ metadataError:
+ error?.message || "Unable to load Site Builder metadata",
+ isMetadataLoading: false,
+ });
+ }
+ },
}));
diff --git a/frontend/src/types/siteBuilder.ts b/frontend/src/types/siteBuilder.ts
index 108185d0..146136a4 100644
--- a/frontend/src/types/siteBuilder.ts
+++ b/frontend/src/types/siteBuilder.ts
@@ -9,14 +9,22 @@ export interface StylePreferences {
export interface BuilderFormData {
siteId: number | null;
- sectorId: number | null;
+ sectorIds: number[];
siteName: string;
+ businessTypeId: number | null;
businessType: string;
+ customBusinessType?: string;
industry: string;
+ targetAudienceIds: number[];
targetAudience: string;
+ customTargetAudience?: string;
hostingType: HostingType;
businessBrief: string;
objectives: string[];
+ brandPersonalityIds: number[];
+ customBrandPersonality?: string;
+ heroImageryDirectionId: number | null;
+ customHeroImageryDirection?: string;
style: StylePreferences;
}
@@ -86,3 +94,16 @@ export interface ApiError {
detail?: string;
}
+export interface SiteBuilderMetadataOption {
+ id: number;
+ name: string;
+ description?: string;
+}
+
+export interface SiteBuilderMetadata {
+ business_types: SiteBuilderMetadataOption[];
+ audience_profiles: SiteBuilderMetadataOption[];
+ brand_personalities: SiteBuilderMetadataOption[];
+ hero_imagery_directions: SiteBuilderMetadataOption[];
+}
+