From 799a1513216bbbb9ff441174df7bd49526dc13fd Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 17:42:23 -0300 Subject: [PATCH] feat(ai-templates): template picker in the AI wizard --- app/Http/Controllers/App/PostController.php | 13 ++ lang/en/posts.php | 2 + lang/es/posts.php | 2 + lang/pt-BR/posts.php | 2 + .../components/posts/create/AiPostWizard.vue | 116 ++++++++++++++++-- resources/js/pages/posts/Create.vue | 11 ++ 6 files changed, 137 insertions(+), 9 deletions(-) diff --git a/app/Http/Controllers/App/PostController.php b/app/Http/Controllers/App/PostController.php index 8635b4e5..5c7f9c7f 100644 --- a/app/Http/Controllers/App/PostController.php +++ b/app/Http/Controllers/App/PostController.php @@ -9,6 +9,7 @@ use App\Actions\Post\DuplicatePost; use App\Actions\Post\SyncPostPlatforms; use App\Actions\Post\UpdatePost; +use App\Ai\Templates\AiTemplateRegistry; use App\Enums\Post\Action as PostAction; use App\Enums\Post\Status as PostStatus; use App\Enums\SocialAccount\Platform; @@ -142,11 +143,23 @@ public function create(Request $request): Response $this->authorize('createPost', $workspace); + $registry = app(AiTemplateRegistry::class); + + $templates = array_map(fn ($t) => [ + 'key' => $t->key(), + 'name' => trans($t->name()), + 'description' => trans($t->description()), + 'preview' => $t->previewAsset(), + 'needs_account' => $t->needsAccount(), + 'supported_formats' => $t->supportedFormats(), + ], $registry->all()); + return Inertia::render('posts/Create', [ 'date' => $request->query('date'), 'socialAccounts' => SocialAccountResource::collection( $workspace->socialAccounts()->active()->get() ), + 'templates' => $templates, ]); } diff --git a/lang/en/posts.php b/lang/en/posts.php index 96a13447..b966ee45 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -572,6 +572,7 @@ 'ai_title' => 'Generate with AI', 'ai_description' => 'Describe what you want and AI generates the content for you.', 'ai_configure_description' => 'Pick a format and describe the post you want to create.', + 'ai_pick_template_description' => 'Choose a style for your AI-generated post.', 'template_title' => 'Use a template', 'template_description' => 'Pick from our curated templates and customize.', 'coming_soon' => 'Coming soon', @@ -582,6 +583,7 @@ ], 'steps' => [ + 'template_picker_title' => 'Choose a style', 'format_title' => 'Choose a format', 'format_description' => 'Select the type of post you want to create.', 'account_title' => 'Choose an account', diff --git a/lang/es/posts.php b/lang/es/posts.php index dad61d9a..6926f152 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -572,6 +572,7 @@ 'ai_title' => 'Generar con IA', 'ai_description' => 'Describe lo que quieres y la IA genera el contenido por ti.', 'ai_configure_description' => 'Elige un formato y describe el post que quieres crear.', + 'ai_pick_template_description' => 'Elige un estilo para tu post generado por IA.', 'template_title' => 'Usar una plantilla', 'template_description' => 'Elige una de nuestras plantillas y personalízala.', @@ -583,6 +584,7 @@ 'coming_soon' => 'Próximamente', 'steps' => [ + 'template_picker_title' => 'Elige un estilo', 'format_title' => 'Elige un formato', 'format_description' => 'Selecciona el tipo de post que quieres crear.', 'account_title' => 'Elige una cuenta', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index cd6b1906..c19e0b2a 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -572,6 +572,7 @@ 'ai_title' => 'Gerar com IA', 'ai_description' => 'Descreva o que quer e a IA gera o conteúdo pra você.', 'ai_configure_description' => 'Escolha o formato e descreva o post que quer criar.', + 'ai_pick_template_description' => 'Escolha um estilo para o seu post gerado por IA.', 'template_title' => 'Usar um template', 'template_description' => 'Escolha um dos nossos templates e personalize.', 'coming_soon' => 'Em breve', @@ -582,6 +583,7 @@ ], 'steps' => [ + 'template_picker_title' => 'Escolha um estilo', 'format_title' => 'Escolha um formato', 'format_description' => 'Selecione o tipo de post que deseja criar.', 'account_title' => 'Escolha uma conta', diff --git a/resources/js/components/posts/create/AiPostWizard.vue b/resources/js/components/posts/create/AiPostWizard.vue index 8d5bb037..566e0229 100644 --- a/resources/js/components/posts/create/AiPostWizard.vue +++ b/resources/js/components/posts/create/AiPostWizard.vue @@ -14,8 +14,8 @@ import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { getPlatformLogo } from '@/composables/usePlatformLogo'; -import { ContentType, type ContentTypeValue } from '@/types/content-type'; import { loading as loadingRoute } from '@/routes/app/posts/ai'; +import { ContentType, type ContentTypeValue } from '@/types/content-type'; interface SocialAccount { id: string; @@ -25,8 +25,18 @@ interface SocialAccount { avatar_url: string | null; } +interface AiTemplate { + key: string; + name: string; + description: string; + preview: string; + needs_account: boolean; + supported_formats: string[]; +} + interface Props { socialAccounts: SocialAccount[]; + templates: AiTemplate[]; /** ISO date (YYYY-MM-DD) carried over from the calendar's per-day "+" button. */ date?: string | null; } @@ -45,7 +55,12 @@ 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 selectedAccountId = ref(null); const includeImages = ref(true); @@ -60,7 +75,8 @@ const httpStart = useHttp<{ image_count: number; prompt: string; date: string | null; -}>({ format: null, social_account_id: null, image_count: 0, prompt: '', date: null }); + template: string; +}>({ format: null, social_account_id: null, image_count: 0, prompt: '', date: null, template: 'image_card' }); const AI_FORMATS: Array<{ value: AiFormat; platforms: string[] }> = [ { value: ContentType.InstagramFeed, platforms: ['instagram', 'instagram-facebook'] }, @@ -76,6 +92,10 @@ 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, +); + const connectedPlatforms = computed(() => { const platforms = new Set(); for (const account of props.socialAccounts) { @@ -84,9 +104,12 @@ const connectedPlatforms = computed(() => { return Array.from(platforms); }); -// Show ALL formats — disabled when the workspace has no connected account -// for that platform. Filtering them out hides the catalog from the user. -const availableFormats = computed(() => AI_FORMATS); +// 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 isFormatConnected = (format: typeof AI_FORMATS[number]): boolean => format.platforms.some((p) => connectedPlatforms.value.includes(p)); @@ -119,6 +142,10 @@ const maxOptionalImages = computed(() => ); 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 submittedImageCount = computed(() => { if (isCarousel.value) return imageCount.value; if (requiresImage.value) return 1; @@ -143,6 +170,16 @@ 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 @@ -158,12 +195,30 @@ 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_configure_description'), + description: trans('posts.create.ai_pick_template_description'), }); -const goBack = () => emit('cancel'); +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'); + } +}; const startGeneration = async () => { if (!canSubmit.value || submitting.value) return; @@ -175,6 +230,7 @@ const startGeneration = async () => { httpStart.image_count = submittedImageCount.value; httpStart.prompt = promptText.value.trim(); httpStart.date = props.date; + httpStart.template = selectedTemplate.value; try { const data = await httpStart.post(startRoute.url()) as { creation_id: string; channel: string }; @@ -210,6 +266,47 @@ const startGeneration = async () => { {{ $t('posts.create.steps.back') }} + + + + + + diff --git a/resources/js/pages/posts/Create.vue b/resources/js/pages/posts/Create.vue index 32b2dda3..be61608a 100644 --- a/resources/js/pages/posts/Create.vue +++ b/resources/js/pages/posts/Create.vue @@ -18,11 +18,21 @@ interface SocialAccount { avatar_url: string | null; } +interface AiTemplate { + key: string; + name: string; + description: string; + preview: string; + needs_account: boolean; + supported_formats: string[]; +} + interface Props { /** ISO date (YYYY-MM-DD). When set, the manual "start from scratch" path * pre-schedules the new post on this date. */ date?: string | null; socialAccounts: SocialAccount[]; + templates: AiTemplate[]; } const props = withDefaults(defineProps(), { @@ -131,6 +141,7 @@ const stepHeader = computed(() => {