From e886db5621378b2baebcb363ef113b6c65c770f3 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 18:45:03 -0300 Subject: [PATCH] feat(ai-templates): choose visual style after the format, on the same screen --- .../components/posts/create/AiPostWizard.vue | 358 ++++++++---------- 1 file changed, 161 insertions(+), 197 deletions(-) diff --git a/resources/js/components/posts/create/AiPostWizard.vue b/resources/js/components/posts/create/AiPostWizard.vue index d4592bf2..e81a2350 100644 --- a/resources/js/components/posts/create/AiPostWizard.vue +++ b/resources/js/components/posts/create/AiPostWizard.vue @@ -55,13 +55,9 @@ const emit = defineEmits<{ const CAROUSEL_FORMAT = 'instagram_carousel' as const; type AiFormat = ContentTypeValue | typeof CAROUSEL_FORMAT; -// Wizard steps -type WizardStep = 'template' | 'configure'; -const wizardStep = ref('template'); - // Selections -const selectedTemplate = ref(props.templates[0]?.key ?? 'image_card'); const selectedFormat = ref(null); +const selectedStyle = ref('image_card'); const selectedAccountId = ref(null); const includeImages = ref(true); const imageCount = ref(2); @@ -92,8 +88,23 @@ const AI_FORMATS: Array<{ value: AiFormat; platforms: string[] }> = [ { value: ContentType.PinterestPin, platforms: ['pinterest'] }, ]; -const activeTemplate = computed(() => - props.templates.find((t) => t.key === selectedTemplate.value) ?? null, +/** Templates with no format restriction — pure visual styles (image_card, tweet_card). */ +const styleTemplates = computed(() => props.templates.filter((t) => t.supported_formats.length === 0)); + +/** The template whose supported_formats includes the currently selected format (e.g. carousel). */ +const formatBoundTemplate = computed(() => + selectedFormat.value + ? props.templates.find((t) => t.supported_formats.includes(selectedFormat.value as string)) ?? null + : null, +); + +/** The template key that will be sent to the backend. */ +const resolvedTemplate = computed(() => + formatBoundTemplate.value ? formatBoundTemplate.value.key : selectedStyle.value, +); + +const resolvedTemplateRecord = computed(() => + props.templates.find((t) => t.key === resolvedTemplate.value) ?? null, ); const connectedPlatforms = computed(() => { @@ -104,12 +115,7 @@ const connectedPlatforms = computed(() => { return Array.from(platforms); }); -// When the active template restricts formats, only show those; otherwise show all. -const availableFormats = computed(() => { - const supported = activeTemplate.value?.supported_formats ?? []; - if (supported.length === 0) return AI_FORMATS; - return AI_FORMATS.filter((f) => supported.includes(f.value)); -}); +const availableFormats = computed(() => AI_FORMATS); const isFormatConnected = (format: typeof AI_FORMATS[number]): boolean => format.platforms.some((p) => connectedPlatforms.value.includes(p)); @@ -136,15 +142,12 @@ const supportsOptionalImages = computed(() => selectedFormat.value === ContentType.ThreadsPost || selectedFormat.value === ContentType.MastodonPost, ); -// Instagram Feed accepts only 1 image (single-image post). Others accept up to 4. const maxOptionalImages = computed(() => selectedFormat.value === ContentType.InstagramFeed ? 1 : 4, ); const showsAccountPicker = computed(() => accountsForFormat.value.length > 1); -// When the template requires an account, we need one even if the format only maps -// to a single account that gets auto-selected — the gating is purely on needsAccount. -const templateNeedsAccount = computed(() => activeTemplate.value?.needs_account ?? false); +const templateNeedsAccount = computed(() => resolvedTemplateRecord.value?.needs_account ?? false); const submittedImageCount = computed(() => { if (isCarousel.value) return imageCount.value; @@ -170,20 +173,8 @@ watch(accountsForFormat, (accounts) => { } }); -// When the template changes, reset format/account selections in case the new -// template's supported_formats set doesn't include the previously-selected one. -watch(selectedTemplate, () => { - const supported = activeTemplate.value?.supported_formats ?? []; - if (supported.length > 0 && selectedFormat.value && !supported.includes(selectedFormat.value)) { - selectedFormat.value = null; - selectedAccountId.value = null; - } -}); - const selectFormat = (format: AiFormat) => { selectedFormat.value = format; - // Sensible default per format. Picking a format always pre-selects an - // image option so the user sees a chip highlighted on arrival. if (format === CAROUSEL_FORMAT) { imageCount.value = 5; } else if (format === ContentType.InstagramFeed) { @@ -195,29 +186,13 @@ const selectFormat = (format: AiFormat) => { } }; -const proceedToConfig = () => { - wizardStep.value = 'configure'; - emit('update:stepHeader', { - title: trans('posts.create.ai_title'), - description: trans('posts.create.ai_configure_description'), - }); -}; - emit('update:stepHeader', { title: trans('posts.create.ai_title'), - description: trans('posts.create.ai_pick_template_description'), + description: trans('posts.create.ai_configure_description'), }); const goBack = () => { - if (wizardStep.value === 'configure') { - wizardStep.value = 'template'; - emit('update:stepHeader', { - title: trans('posts.create.ai_title'), - description: trans('posts.create.ai_pick_template_description'), - }); - } else { - emit('cancel'); - } + emit('cancel'); }; const startGeneration = async () => { @@ -230,7 +205,7 @@ const startGeneration = async () => { httpStart.image_count = submittedImageCount.value; httpStart.prompt = promptText.value.trim(); httpStart.date = props.date; - httpStart.template = selectedTemplate.value; + httpStart.template = resolvedTemplate.value; try { const data = await httpStart.post(startRoute.url()) as { creation_id: string; channel: string }; @@ -255,7 +230,7 @@ const startGeneration = async () => {