- 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.
415 lines
12 KiB
TypeScript
415 lines
12 KiB
TypeScript
import { create } from "zustand";
|
|
import { useSiteStore } from "./siteStore";
|
|
import { useSiteDefinitionStore } from "./siteDefinitionStore";
|
|
import { siteBuilderApi } from "../services/siteBuilder.api";
|
|
import type {
|
|
BuilderFormData,
|
|
PageBlueprint,
|
|
SiteBlueprint,
|
|
StylePreferences,
|
|
SiteStructure,
|
|
SiteBuilderMetadata,
|
|
SiteBuilderMetadataOption,
|
|
} from "../types/siteBuilder";
|
|
|
|
const defaultStyle: StylePreferences = {
|
|
palette: "Vibrant modern palette with rich accent color",
|
|
typography: "Sans-serif display for headings, humanist body font",
|
|
personality: "Confident, energetic, optimistic",
|
|
heroImagery: "Real people interacting with the product/service",
|
|
};
|
|
|
|
const buildDefaultForm = (): BuilderFormData => {
|
|
const site = useSiteStore.getState().activeSite;
|
|
|
|
return {
|
|
siteId: site?.id ?? null,
|
|
sectorIds: site?.selected_sectors ?? [],
|
|
siteName: site?.name ?? "",
|
|
businessTypeId: null,
|
|
businessType: "",
|
|
customBusinessType: "",
|
|
industry: site?.industry_name ?? "",
|
|
targetAudienceIds: [],
|
|
targetAudience: "",
|
|
customTargetAudience: "",
|
|
hostingType: "igny8_sites",
|
|
businessBrief: "",
|
|
objectives: ["Launch a conversion-focused marketing site"],
|
|
brandPersonalityIds: [],
|
|
customBrandPersonality: "",
|
|
heroImageryDirectionId: null,
|
|
customHeroImageryDirection: "",
|
|
style: { ...defaultStyle },
|
|
};
|
|
};
|
|
|
|
interface BuilderState {
|
|
form: BuilderFormData;
|
|
currentStep: number;
|
|
isSubmitting: boolean;
|
|
isGenerating: boolean;
|
|
isLoadingBlueprint: boolean;
|
|
metadata?: SiteBuilderMetadata;
|
|
isMetadataLoading: boolean;
|
|
metadataError?: string;
|
|
error?: string;
|
|
activeBlueprint?: SiteBlueprint;
|
|
pages: PageBlueprint[];
|
|
selectedPageIds: number[];
|
|
generationProgress?: {
|
|
pagesQueued: number;
|
|
taskIds: number[];
|
|
celeryTaskId?: string;
|
|
};
|
|
// Actions
|
|
setField: <K extends keyof BuilderFormData>(
|
|
key: K,
|
|
value: BuilderFormData[K],
|
|
) => void;
|
|
updateStyle: (partial: Partial<StylePreferences>) => void;
|
|
addObjective: (value: string) => void;
|
|
removeObjective: (index: number) => void;
|
|
setStep: (step: number) => void;
|
|
nextStep: () => void;
|
|
previousStep: () => void;
|
|
reset: () => void;
|
|
syncContextFromStores: () => void;
|
|
submitWizard: () => Promise<void>;
|
|
refreshPages: (blueprintId: number) => Promise<void>;
|
|
togglePageSelection: (pageId: number) => void;
|
|
selectAllPages: () => void;
|
|
clearPageSelection: () => void;
|
|
loadBlueprint: (blueprintId: number) => Promise<void>;
|
|
generateAllPages: (blueprintId: number, force?: boolean) => Promise<void>;
|
|
loadMetadata: () => Promise<void>;
|
|
}
|
|
|
|
export const useBuilderStore = create<BuilderState>((set, get) => ({
|
|
form: buildDefaultForm(),
|
|
currentStep: 0,
|
|
isSubmitting: false,
|
|
isGenerating: false,
|
|
isLoadingBlueprint: false,
|
|
metadata: undefined,
|
|
isMetadataLoading: false,
|
|
metadataError: undefined,
|
|
pages: [],
|
|
selectedPageIds: [],
|
|
|
|
setField: (key, value) =>
|
|
set((state) => ({
|
|
form: { ...state.form, [key]: value },
|
|
})),
|
|
|
|
updateStyle: (partial) =>
|
|
set((state) => ({
|
|
form: { ...state.form, style: { ...state.form.style, ...partial } },
|
|
})),
|
|
|
|
addObjective: (value) =>
|
|
set((state) => ({
|
|
form: { ...state.form, objectives: [...state.form.objectives, value] },
|
|
})),
|
|
|
|
removeObjective: (index) =>
|
|
set((state) => ({
|
|
form: {
|
|
...state.form,
|
|
objectives: state.form.objectives.filter((_, idx) => idx !== index),
|
|
},
|
|
})),
|
|
|
|
setStep: (step) => set({ currentStep: step }),
|
|
|
|
nextStep: () =>
|
|
set((state) => ({
|
|
currentStep: Math.min(state.currentStep + 1, 3),
|
|
})),
|
|
|
|
previousStep: () =>
|
|
set((state) => ({
|
|
currentStep: Math.max(state.currentStep - 1, 0),
|
|
})),
|
|
|
|
reset: () =>
|
|
set({
|
|
form: buildDefaultForm(),
|
|
currentStep: 0,
|
|
isSubmitting: false,
|
|
error: undefined,
|
|
activeBlueprint: undefined,
|
|
pages: [],
|
|
selectedPageIds: [],
|
|
generationProgress: undefined,
|
|
}),
|
|
|
|
syncContextFromStores: () => {
|
|
const site = useSiteStore.getState().activeSite;
|
|
set((state) => ({
|
|
form: {
|
|
...state.form,
|
|
siteId: site?.id ?? state.form.siteId,
|
|
siteName: site?.name ?? state.form.siteName,
|
|
sectorIds: site?.selected_sectors ?? state.form.sectorIds,
|
|
industry: site?.industry_name ?? state.form.industry,
|
|
},
|
|
}));
|
|
},
|
|
|
|
submitWizard: async () => {
|
|
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:
|
|
"This site has no sectors configured. Add sectors in Sites → All Sites before running the wizard.",
|
|
});
|
|
return;
|
|
}
|
|
|
|
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 {
|
|
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);
|
|
lastBlueprint = blueprint;
|
|
|
|
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) {
|
|
lastStructure = generation.structure;
|
|
}
|
|
}
|
|
|
|
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",
|
|
});
|
|
} finally {
|
|
set({ isSubmitting: false });
|
|
}
|
|
},
|
|
|
|
refreshPages: async (blueprintId: number) => {
|
|
try {
|
|
const pages = await siteBuilderApi.listPages(blueprintId);
|
|
set({ pages });
|
|
useSiteDefinitionStore.getState().setPages(pages);
|
|
} catch (error: any) {
|
|
set({
|
|
error: error?.message || "Unable to load generated pages",
|
|
});
|
|
}
|
|
},
|
|
|
|
togglePageSelection: (pageId: number) =>
|
|
set((state) => {
|
|
const isSelected = state.selectedPageIds.includes(pageId);
|
|
return {
|
|
selectedPageIds: isSelected
|
|
? state.selectedPageIds.filter((id) => id !== pageId)
|
|
: [...state.selectedPageIds, pageId],
|
|
};
|
|
}),
|
|
|
|
selectAllPages: () =>
|
|
set((state) => ({
|
|
selectedPageIds: state.pages.map((page) => page.id),
|
|
})),
|
|
|
|
clearPageSelection: () => set({ selectedPageIds: [] }),
|
|
|
|
loadBlueprint: async (blueprintId: number) => {
|
|
set({ isLoadingBlueprint: true, error: undefined });
|
|
try {
|
|
const [blueprint, pages] = await Promise.all([
|
|
siteBuilderApi.getBlueprint(blueprintId),
|
|
siteBuilderApi.listPages(blueprintId),
|
|
]);
|
|
set({
|
|
activeBlueprint: blueprint,
|
|
pages,
|
|
selectedPageIds: [],
|
|
});
|
|
if (blueprint.structure_json) {
|
|
useSiteDefinitionStore.getState().setStructure(blueprint.structure_json);
|
|
} else {
|
|
useSiteDefinitionStore.getState().setStructure({
|
|
site: undefined,
|
|
pages: pages.map((page) => ({
|
|
slug: page.slug,
|
|
title: page.title,
|
|
type: page.type,
|
|
blocks: page.blocks_json,
|
|
})),
|
|
});
|
|
}
|
|
useSiteDefinitionStore.getState().setPages(pages);
|
|
} catch (error: any) {
|
|
set({
|
|
error: error?.message || "Unable to load blueprint",
|
|
});
|
|
} finally {
|
|
set({ isLoadingBlueprint: false });
|
|
}
|
|
},
|
|
|
|
generateAllPages: async (blueprintId: number, force = false) => {
|
|
const { selectedPageIds } = get();
|
|
set({ isGenerating: true, error: undefined, generationProgress: undefined });
|
|
try {
|
|
const result = await siteBuilderApi.generateAllPages(blueprintId, {
|
|
pageIds: selectedPageIds.length > 0 ? selectedPageIds : undefined,
|
|
force,
|
|
});
|
|
|
|
set({
|
|
generationProgress: {
|
|
pagesQueued: result.pages_queued,
|
|
taskIds: result.task_ids,
|
|
celeryTaskId: result.celery_task_id,
|
|
},
|
|
});
|
|
|
|
await get().refreshPages(blueprintId);
|
|
} catch (error: any) {
|
|
set({
|
|
error: error?.message || "Failed to queue page generation",
|
|
});
|
|
} finally {
|
|
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,
|
|
});
|
|
}
|
|
},
|
|
}));
|
|
|