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:
Binary file not shown.
@@ -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),
|
||||||
|
]
|
||||||
|
|
||||||
@@ -166,3 +166,55 @@ class PageBlueprint(SiteSectorBaseModel):
|
|||||||
return f"{self.title} ({self.site_blueprint.name})"
|
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'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
from rest_framework import serializers
|
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):
|
class PageBlueprintSerializer(serializers.ModelSerializer):
|
||||||
@@ -76,3 +83,16 @@ class SiteBlueprintSerializer(serializers.ModelSerializer):
|
|||||||
attrs['sector_id'] = sector_id
|
attrs['sector_id'] = sector_id
|
||||||
return attrs
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from igny8_core.modules.site_builder.views import (
|
|||||||
PageBlueprintViewSet,
|
PageBlueprintViewSet,
|
||||||
SiteAssetView,
|
SiteAssetView,
|
||||||
SiteBlueprintViewSet,
|
SiteBlueprintViewSet,
|
||||||
|
SiteBuilderMetadataView,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
@@ -14,5 +15,6 @@ router.register(r'pages', PageBlueprintViewSet, basename='page_blueprint')
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
path('assets/', SiteAssetView.as_view(), name='site_builder_assets'),
|
path('assets/', SiteAssetView.as_view(), name='site_builder_assets'),
|
||||||
|
path('metadata/', SiteBuilderMetadataView.as_view(), name='site_builder_metadata'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,14 @@ from igny8_core.api.base import SiteSectorModelViewSet
|
|||||||
from igny8_core.api.permissions import IsAuthenticatedAndActive, IsEditorOrAbove
|
from igny8_core.api.permissions import IsAuthenticatedAndActive, IsEditorOrAbove
|
||||||
from igny8_core.api.response import success_response, error_response
|
from igny8_core.api.response import success_response, error_response
|
||||||
from igny8_core.api.throttles import DebugScopedRateThrottle
|
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 (
|
from igny8_core.business.site_building.services import (
|
||||||
PageGenerationService,
|
PageGenerationService,
|
||||||
SiteBuilderFileService,
|
SiteBuilderFileService,
|
||||||
@@ -18,6 +25,7 @@ from igny8_core.business.site_building.services import (
|
|||||||
from igny8_core.modules.site_builder.serializers import (
|
from igny8_core.modules.site_builder.serializers import (
|
||||||
PageBlueprintSerializer,
|
PageBlueprintSerializer,
|
||||||
SiteBlueprintSerializer,
|
SiteBlueprintSerializer,
|
||||||
|
SiteBuilderMetadataSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -205,3 +213,39 @@ class SiteAssetView(APIView):
|
|||||||
return error_response('File not found', status.HTTP_404_NOT_FOUND, request)
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ import { useSiteStore } from '../../store/siteStore';
|
|||||||
import { useSectorStore } from '../../store/sectorStore';
|
import { useSectorStore } from '../../store/sectorStore';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
|
||||||
export default function SiteAndSectorSelector() {
|
interface SiteAndSectorSelectorProps {
|
||||||
|
hideSectorSelector?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SiteAndSectorSelector({
|
||||||
|
hideSectorSelector = false,
|
||||||
|
}: SiteAndSectorSelectorProps) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const { activeSite, setActiveSite, loadActiveSite } = useSiteStore();
|
const { activeSite, setActiveSite, loadActiveSite } = useSiteStore();
|
||||||
const { activeSector, sectors, setActiveSector, loading: sectorsLoading } = useSectorStore();
|
const { activeSector, sectors, setActiveSector, loading: sectorsLoading } = useSectorStore();
|
||||||
@@ -170,7 +176,7 @@ export default function SiteAndSectorSelector() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sector Selector */}
|
{/* Sector Selector */}
|
||||||
{!sectorsLoading && sectors.length > 0 && (
|
{!hideSectorSelector && !sectorsLoading && sectors.length > 0 && (
|
||||||
<div className="relative inline-block">
|
<div className="relative inline-block">
|
||||||
<button
|
<button
|
||||||
ref={sectorButtonRef}
|
ref={sectorButtonRef}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { StyleStep } from "./steps/StyleStep";
|
|||||||
export default function SiteBuilderWizard() {
|
export default function SiteBuilderWizard() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { activeSite } = useSiteStore();
|
const { activeSite } = useSiteStore();
|
||||||
const { activeSector } = useSectorStore();
|
const { sectors } = useSectorStore();
|
||||||
const {
|
const {
|
||||||
form,
|
form,
|
||||||
currentStep,
|
currentStep,
|
||||||
@@ -46,18 +46,40 @@ export default function SiteBuilderWizard() {
|
|||||||
generationProgress,
|
generationProgress,
|
||||||
isGenerating,
|
isGenerating,
|
||||||
syncContextFromStores,
|
syncContextFromStores,
|
||||||
|
metadata,
|
||||||
|
metadataError,
|
||||||
|
isMetadataLoading,
|
||||||
|
loadMetadata,
|
||||||
} = useBuilderStore();
|
} = useBuilderStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
syncContextFromStores();
|
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(
|
const steps = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
title: "Business context",
|
title: "Business context",
|
||||||
component: (
|
component: (
|
||||||
<BusinessDetailsStep data={form} onChange={setField} />
|
<BusinessDetailsStep
|
||||||
|
data={form}
|
||||||
|
onChange={setField}
|
||||||
|
metadata={metadata}
|
||||||
|
selectedSectors={selectedSectors}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -79,16 +101,31 @@ export default function SiteBuilderWizard() {
|
|||||||
component: (
|
component: (
|
||||||
<StyleStep
|
<StyleStep
|
||||||
style={form.style}
|
style={form.style}
|
||||||
onChange={updateStyle}
|
metadata={metadata}
|
||||||
|
brandPersonalityIds={form.brandPersonalityIds}
|
||||||
|
customBrandPersonality={form.customBrandPersonality}
|
||||||
|
heroImageryDirectionId={form.heroImageryDirectionId}
|
||||||
|
customHeroImageryDirection={form.customHeroImageryDirection}
|
||||||
|
onStyleChange={updateStyle}
|
||||||
|
onChange={setField}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[form, setField, updateStyle, addObjective, removeObjective],
|
[
|
||||||
|
form,
|
||||||
|
metadata,
|
||||||
|
selectedSectors,
|
||||||
|
setField,
|
||||||
|
updateStyle,
|
||||||
|
addObjective,
|
||||||
|
removeObjective,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isLastStep = currentStep === steps.length - 1;
|
const isLastStep = currentStep === steps.length - 1;
|
||||||
const missingContext = !activeSite || !activeSector;
|
const missingContext =
|
||||||
|
!activeSite || !form.sectorIds || form.sectorIds.length === 0;
|
||||||
|
|
||||||
const handlePrimary = async () => {
|
const handlePrimary = async () => {
|
||||||
if (isLastStep) {
|
if (isLastStep) {
|
||||||
@@ -123,13 +160,28 @@ export default function SiteBuilderWizard() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SiteAndSectorSelector />
|
<SiteAndSectorSelector hideSectorSelector />
|
||||||
|
|
||||||
|
{metadataError && (
|
||||||
|
<Alert
|
||||||
|
variant="warning"
|
||||||
|
title="Metadata unavailable"
|
||||||
|
message={`${metadataError}. You can still enter custom values.`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isMetadataLoading && !metadata && (
|
||||||
|
<Alert
|
||||||
|
variant="info"
|
||||||
|
title="Loading Site Builder library"
|
||||||
|
message="Fetching business types, audiences, and style presets..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{missingContext && (
|
{missingContext && (
|
||||||
<Alert
|
<Alert
|
||||||
variant="warning"
|
variant="warning"
|
||||||
title="Select site & sector"
|
title="Missing site configuration"
|
||||||
message="Choose an active site and sector using the selector above before running the wizard."
|
message="Choose an active site and ensure it has at least one sector configured (Sites → All Sites) before running the wizard."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { Card } from "../../../../components/ui/card";
|
||||||
import { useSiteStore } from "../../../../store/siteStore";
|
import { useSiteStore } from "../../../../store/siteStore";
|
||||||
import { useSectorStore } from "../../../../store/sectorStore";
|
import { Dropdown } from "../../../../components/ui/dropdown/Dropdown";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
const inputClass =
|
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";
|
"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 {
|
interface Props {
|
||||||
data: BuilderFormData;
|
data: BuilderFormData;
|
||||||
|
metadata?: SiteBuilderMetadata;
|
||||||
|
selectedSectors: Array<{ id: number; name: string }>;
|
||||||
onChange: <K extends keyof BuilderFormData>(
|
onChange: <K extends keyof BuilderFormData>(
|
||||||
key: K,
|
key: K,
|
||||||
value: BuilderFormData[K],
|
value: BuilderFormData[K],
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BusinessDetailsStep({ data, onChange }: Props) {
|
export function BusinessDetailsStep({
|
||||||
|
data,
|
||||||
|
metadata,
|
||||||
|
selectedSectors,
|
||||||
|
onChange,
|
||||||
|
}: Props) {
|
||||||
const { activeSite } = useSiteStore();
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -29,17 +92,16 @@ export function BusinessDetailsStep({ data, onChange }: Props) {
|
|||||||
Context
|
Context
|
||||||
</p>
|
</p>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
Site & Sector
|
Site & sectors
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
The wizard will use your currently active site and sector. Switch
|
IGNY8 will generate a blueprint for every sector configured on this site.
|
||||||
them from the header at any time.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
<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">
|
<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">
|
<p className="text-xs uppercase tracking-wide text-brand-600 dark:text-brand-300">
|
||||||
Active Site
|
Active site
|
||||||
</p>
|
</p>
|
||||||
<p className="text-base font-semibold text-brand-700 dark:text-brand-200">
|
<p className="text-base font-semibold text-brand-700 dark:text-brand-200">
|
||||||
{activeSite?.name ?? "No site selected"}
|
{activeSite?.name ?? "No site selected"}
|
||||||
@@ -47,11 +109,24 @@ export function BusinessDetailsStep({ data, onChange }: Props) {
|
|||||||
</div>
|
</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">
|
<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">
|
<p className="text-xs uppercase tracking-wide text-indigo-600 dark:text-indigo-300">
|
||||||
Active Sector
|
Included sectors
|
||||||
</p>
|
|
||||||
<p className="text-base font-semibold text-indigo-700 dark:text-indigo-200">
|
|
||||||
{activeSector?.name ?? "All sectors"}
|
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -74,7 +149,10 @@ export function BusinessDetailsStep({ data, onChange }: Props) {
|
|||||||
className={inputClass}
|
className={inputClass}
|
||||||
value={data.hostingType}
|
value={data.hostingType}
|
||||||
onChange={(event) =>
|
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>
|
<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 className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className={labelClass}>Business type</label>
|
<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
|
<input
|
||||||
className={inputClass}
|
className={`${inputClass} mt-3`}
|
||||||
type="text"
|
type="text"
|
||||||
value={data.businessType}
|
value={data.customBusinessType ?? ""}
|
||||||
placeholder="B2B SaaS platform"
|
placeholder="Or describe a custom business model"
|
||||||
onChange={(event) => onChange("businessType", event.target.value)}
|
onChange={(event) =>
|
||||||
|
handleCustomBusinessTypeChange(event.target.value)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={labelClass}>Industry</label>
|
<label className={labelClass}>Industry (from site settings)</label>
|
||||||
<input
|
<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">
|
||||||
className={inputClass}
|
{data.industry || "No industry selected"}
|
||||||
type="text"
|
</div>
|
||||||
value={data.industry}
|
|
||||||
placeholder="Supply chain automation"
|
|
||||||
onChange={(event) => onChange("industry", event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="mt-6">
|
<Card variant="surface" padding="lg">
|
||||||
<label className={labelClass}>Target audience</label>
|
<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
|
<input
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
type="text"
|
type="text"
|
||||||
value={data.targetAudience}
|
value={data.customTargetAudience ?? ""}
|
||||||
placeholder="Operations leaders at fast-scaling eCommerce brands"
|
placeholder="Add custom audience (e.g., Healthcare innovators)"
|
||||||
onChange={(event) => onChange("targetAudience", event.target.value)}
|
onChange={(event) => handleCustomAudienceChange(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -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 { Card } from "../../../../components/ui/card";
|
||||||
|
import { Dropdown } from "../../../../components/ui/dropdown/Dropdown";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
const labelClass =
|
const labelClass =
|
||||||
"text-sm font-semibold text-gray-700 dark:text-white/80 mb-2 inline-block";
|
"text-sm font-semibold text-gray-700 dark:text-white/80 mb-2 inline-block";
|
||||||
const selectClass =
|
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";
|
"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 =
|
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";
|
"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 {
|
interface Props {
|
||||||
style: StylePreferences;
|
style: StylePreferences;
|
||||||
onChange: (partial: Partial<StylePreferences>) => void;
|
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, 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<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 (
|
return (
|
||||||
<Card variant="surface" padding="lg">
|
<Card variant="surface" padding="lg">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -39,8 +91,7 @@ export function StyleStep({ style, onChange }: Props) {
|
|||||||
Visual direction
|
Visual direction
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
Capture the brand personality so the preview canvas mirrors the
|
Capture the brand personality so the preview canvas mirrors the right tone.
|
||||||
right tone.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -50,7 +101,7 @@ export function StyleStep({ style, onChange }: Props) {
|
|||||||
<select
|
<select
|
||||||
className={selectClass}
|
className={selectClass}
|
||||||
value={style.palette}
|
value={style.palette}
|
||||||
onChange={(event) => onChange({ palette: event.target.value })}
|
onChange={(event) => onStyleChange({ palette: event.target.value })}
|
||||||
>
|
>
|
||||||
{palettes.map((option) => (
|
{palettes.map((option) => (
|
||||||
<option key={option} value={option}>
|
<option key={option} value={option}>
|
||||||
@@ -64,7 +115,9 @@ export function StyleStep({ style, onChange }: Props) {
|
|||||||
<select
|
<select
|
||||||
className={selectClass}
|
className={selectClass}
|
||||||
value={style.typography}
|
value={style.typography}
|
||||||
onChange={(event) => onChange({ typography: event.target.value })}
|
onChange={(event) =>
|
||||||
|
onStyleChange({ typography: event.target.value })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{typography.map((option) => (
|
{typography.map((option) => (
|
||||||
<option key={option} value={option}>
|
<option key={option} value={option}>
|
||||||
@@ -75,26 +128,212 @@ export function StyleStep({ style, onChange }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</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 && <Check 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 className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className={labelClass}>Brand personality</label>
|
<label className={labelClass}>Brand personality narrative</label>
|
||||||
<textarea
|
<textarea
|
||||||
className={textareaClass}
|
className={textareaClass}
|
||||||
rows={3}
|
rows={4}
|
||||||
value={style.personality}
|
value={style.personality}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
onChange({ personality: event.target.value })
|
onStyleChange({ personality: event.target.value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={labelClass}>Hero imagery direction</label>
|
<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 && <Check 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
|
<textarea
|
||||||
className={textareaClass}
|
className={`${textareaClass} mt-3`}
|
||||||
rows={3}
|
rows={4}
|
||||||
value={style.heroImagery}
|
value={style.heroImagery}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
onChange({ heroImagery: event.target.value })
|
onStyleChange({ heroImagery: event.target.value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
PageBlueprint,
|
PageBlueprint,
|
||||||
SiteStructure,
|
SiteStructure,
|
||||||
BuilderFormData,
|
BuilderFormData,
|
||||||
|
SiteBuilderMetadata,
|
||||||
} from '../types/siteBuilder';
|
} from '../types/siteBuilder';
|
||||||
|
|
||||||
export interface CreateBlueprintPayload {
|
export interface CreateBlueprintPayload {
|
||||||
@@ -121,5 +122,12 @@ export const siteBuilderApi = {
|
|||||||
// Handle unified response format
|
// Handle unified response format
|
||||||
return response?.data || response;
|
return response?.data || response;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load dropdown metadata for wizard fields
|
||||||
|
*/
|
||||||
|
async getMetadata(): Promise<SiteBuilderMetadata> {
|
||||||
|
return fetchAPI('/v1/site-builder/metadata/');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { useSiteStore } from "./siteStore";
|
import { useSiteStore } from "./siteStore";
|
||||||
import { useSectorStore } from "./sectorStore";
|
|
||||||
import { useSiteDefinitionStore } from "./siteDefinitionStore";
|
import { useSiteDefinitionStore } from "./siteDefinitionStore";
|
||||||
import { siteBuilderApi } from "../services/siteBuilder.api";
|
import { siteBuilderApi } from "../services/siteBuilder.api";
|
||||||
import type {
|
import type {
|
||||||
@@ -8,6 +7,9 @@ import type {
|
|||||||
PageBlueprint,
|
PageBlueprint,
|
||||||
SiteBlueprint,
|
SiteBlueprint,
|
||||||
StylePreferences,
|
StylePreferences,
|
||||||
|
SiteStructure,
|
||||||
|
SiteBuilderMetadata,
|
||||||
|
SiteBuilderMetadataOption,
|
||||||
} from "../types/siteBuilder";
|
} from "../types/siteBuilder";
|
||||||
|
|
||||||
const defaultStyle: StylePreferences = {
|
const defaultStyle: StylePreferences = {
|
||||||
@@ -19,19 +21,26 @@ const defaultStyle: StylePreferences = {
|
|||||||
|
|
||||||
const buildDefaultForm = (): BuilderFormData => {
|
const buildDefaultForm = (): BuilderFormData => {
|
||||||
const site = useSiteStore.getState().activeSite;
|
const site = useSiteStore.getState().activeSite;
|
||||||
const sector = useSectorStore.getState().activeSector;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
siteId: site?.id ?? null,
|
siteId: site?.id ?? null,
|
||||||
sectorId: sector?.id ?? null,
|
sectorIds: site?.selected_sectors ?? [],
|
||||||
siteName: site?.name ?? "",
|
siteName: site?.name ?? "",
|
||||||
|
businessTypeId: null,
|
||||||
businessType: "",
|
businessType: "",
|
||||||
industry: "",
|
customBusinessType: "",
|
||||||
|
industry: site?.industry_name ?? "",
|
||||||
|
targetAudienceIds: [],
|
||||||
targetAudience: "",
|
targetAudience: "",
|
||||||
|
customTargetAudience: "",
|
||||||
hostingType: "igny8_sites",
|
hostingType: "igny8_sites",
|
||||||
businessBrief: "",
|
businessBrief: "",
|
||||||
objectives: ["Launch a conversion-focused marketing site"],
|
objectives: ["Launch a conversion-focused marketing site"],
|
||||||
style: defaultStyle,
|
brandPersonalityIds: [],
|
||||||
|
customBrandPersonality: "",
|
||||||
|
heroImageryDirectionId: null,
|
||||||
|
customHeroImageryDirection: "",
|
||||||
|
style: { ...defaultStyle },
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,6 +50,9 @@ interface BuilderState {
|
|||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
isGenerating: boolean;
|
isGenerating: boolean;
|
||||||
isLoadingBlueprint: boolean;
|
isLoadingBlueprint: boolean;
|
||||||
|
metadata?: SiteBuilderMetadata;
|
||||||
|
isMetadataLoading: boolean;
|
||||||
|
metadataError?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
activeBlueprint?: SiteBlueprint;
|
activeBlueprint?: SiteBlueprint;
|
||||||
pages: PageBlueprint[];
|
pages: PageBlueprint[];
|
||||||
@@ -70,6 +82,7 @@ interface BuilderState {
|
|||||||
clearPageSelection: () => void;
|
clearPageSelection: () => void;
|
||||||
loadBlueprint: (blueprintId: number) => Promise<void>;
|
loadBlueprint: (blueprintId: number) => Promise<void>;
|
||||||
generateAllPages: (blueprintId: number, force?: boolean) => Promise<void>;
|
generateAllPages: (blueprintId: number, force?: boolean) => Promise<void>;
|
||||||
|
loadMetadata: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useBuilderStore = create<BuilderState>((set, get) => ({
|
export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||||
@@ -78,6 +91,9 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
|||||||
isSubmitting: false,
|
isSubmitting: false,
|
||||||
isGenerating: false,
|
isGenerating: false,
|
||||||
isLoadingBlueprint: false,
|
isLoadingBlueprint: false,
|
||||||
|
metadata: undefined,
|
||||||
|
isMetadataLoading: false,
|
||||||
|
metadataError: undefined,
|
||||||
pages: [],
|
pages: [],
|
||||||
selectedPageIds: [],
|
selectedPageIds: [],
|
||||||
|
|
||||||
@@ -130,62 +146,156 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
|||||||
|
|
||||||
syncContextFromStores: () => {
|
syncContextFromStores: () => {
|
||||||
const site = useSiteStore.getState().activeSite;
|
const site = useSiteStore.getState().activeSite;
|
||||||
const sector = useSectorStore.getState().activeSector;
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
form: {
|
form: {
|
||||||
...state.form,
|
...state.form,
|
||||||
siteId: site?.id ?? state.form.siteId,
|
siteId: site?.id ?? state.form.siteId,
|
||||||
siteName: site?.name ?? state.form.siteName,
|
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 () => {
|
submitWizard: async () => {
|
||||||
const { form } = get();
|
const { form, metadata } = get();
|
||||||
if (!form.siteId || !form.sectorId) {
|
if (!form.siteId) {
|
||||||
|
set({
|
||||||
|
error: "Select an active site before running the Site Builder wizard.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.sectorIds?.length) {
|
||||||
set({
|
set({
|
||||||
error:
|
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;
|
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 {
|
try {
|
||||||
const payload = {
|
let lastBlueprint: SiteBlueprint | undefined;
|
||||||
name: form.siteName || `Site Blueprint (${form.industry || "New"})`,
|
let lastStructure: SiteStructure | undefined;
|
||||||
description: form.businessType
|
for (const sectorId of preparedForm.sectorIds) {
|
||||||
? `${form.businessType} for ${form.targetAudience}`
|
const payload = {
|
||||||
: undefined,
|
name:
|
||||||
site_id: form.siteId,
|
preparedForm.siteName ||
|
||||||
sector_id: form.sectorId,
|
`Site Blueprint (${preparedForm.industry || "New"})`,
|
||||||
hosting_type: form.hostingType,
|
description: targetAudienceSummary
|
||||||
config_json: {
|
? `${businessTypeName} • ${targetAudienceSummary}`
|
||||||
business_type: form.businessType,
|
: businessTypeName,
|
||||||
industry: form.industry,
|
site_id: preparedForm.siteId!,
|
||||||
target_audience: form.targetAudience,
|
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);
|
const blueprint = await siteBuilderApi.createBlueprint(payload);
|
||||||
set({ activeBlueprint: blueprint });
|
lastBlueprint = blueprint;
|
||||||
|
|
||||||
const generation = await siteBuilderApi.generateStructure(
|
const generation = await siteBuilderApi.generateStructure(
|
||||||
blueprint.id,
|
blueprint.id,
|
||||||
{
|
{
|
||||||
business_brief: form.businessBrief,
|
business_brief: preparedForm.businessBrief,
|
||||||
objectives: form.objectives,
|
objectives: preparedForm.objectives,
|
||||||
style: form.style,
|
style: stylePreferences,
|
||||||
metadata: { targetAudience: form.targetAudience },
|
metadata: {
|
||||||
},
|
targetAudience: audienceNames,
|
||||||
);
|
brandPersonality: brandPersonalityNames,
|
||||||
|
sectorId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (generation?.structure) {
|
if (generation?.structure) {
|
||||||
useSiteDefinitionStore.getState().setStructure(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) {
|
} catch (error: any) {
|
||||||
set({
|
set({
|
||||||
error: error?.message || "Unexpected error while running wizard",
|
error: error?.message || "Unexpected error while running wizard",
|
||||||
@@ -285,5 +395,20 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
|||||||
set({ isGenerating: false });
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,22 @@ export interface StylePreferences {
|
|||||||
|
|
||||||
export interface BuilderFormData {
|
export interface BuilderFormData {
|
||||||
siteId: number | null;
|
siteId: number | null;
|
||||||
sectorId: number | null;
|
sectorIds: number[];
|
||||||
siteName: string;
|
siteName: string;
|
||||||
|
businessTypeId: number | null;
|
||||||
businessType: string;
|
businessType: string;
|
||||||
|
customBusinessType?: string;
|
||||||
industry: string;
|
industry: string;
|
||||||
|
targetAudienceIds: number[];
|
||||||
targetAudience: string;
|
targetAudience: string;
|
||||||
|
customTargetAudience?: string;
|
||||||
hostingType: HostingType;
|
hostingType: HostingType;
|
||||||
businessBrief: string;
|
businessBrief: string;
|
||||||
objectives: string[];
|
objectives: string[];
|
||||||
|
brandPersonalityIds: number[];
|
||||||
|
customBrandPersonality?: string;
|
||||||
|
heroImageryDirectionId: number | null;
|
||||||
|
customHeroImageryDirection?: string;
|
||||||
style: StylePreferences;
|
style: StylePreferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,3 +94,16 @@ export interface ApiError {
|
|||||||
detail?: string;
|
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[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user