feat(ai-templates): template picker in the AI wizard

This commit is contained in:
Paulo Castellano 2026-06-17 17:42:23 -03:00
parent 4656cb52c6
commit 799a151321
6 changed files with 137 additions and 9 deletions

View file

@ -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,
]);
}

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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<WizardStep>('template');
// Selections
const selectedTemplate = ref<string>(props.templates[0]?.key ?? 'image_card');
const selectedFormat = ref<AiFormat | null>(null);
const selectedAccountId = ref<string | null>(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<string>();
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 () => {
</span>
{{ $t('posts.create.steps.back') }}
</button>
<!-- Step 1: Template picker -->
<template v-if="wizardStep === 'template'">
<div class="space-y-2">
<Label class="text-sm font-bold">{{ $t('posts.create.steps.template_picker_title') }}</Label>
<div class="grid gap-3 sm:grid-cols-3">
<button
v-for="template in templates"
:key="template.key"
type="button"
class="relative flex cursor-pointer flex-col overflow-hidden rounded-xl border-2 border-foreground bg-card text-left shadow-2xs transition-all hover:bg-foreground/5"
:class="{ '!bg-violet-100 shadow-md': selectedTemplate === template.key }"
@click="selectedTemplate = template.key"
>
<div class="aspect-video w-full overflow-hidden bg-muted">
<img
:src="template.preview"
:alt="template.name"
class="size-full object-cover"
/>
</div>
<div class="flex items-start gap-2 p-3">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-bold text-foreground">{{ template.name }}</p>
<p class="mt-0.5 text-xs leading-snug text-foreground/60">{{ template.description }}</p>
</div>
<IconCheck v-if="selectedTemplate === template.key" class="mt-0.5 size-4 shrink-0 text-foreground" stroke-width="3" />
</div>
</button>
</div>
</div>
<div class="flex justify-end pt-1">
<Button @click="proceedToConfig">
{{ $t('posts.create.steps.next') }}
</Button>
</div>
</template>
<!-- Step 2: Configure (format / account / images / prompt) -->
<template v-else-if="wizardStep === 'configure'">
<!-- Format -->
<div class="space-y-2">
<Label class="text-sm font-bold">{{ $t('posts.create.steps.format_title') }}</Label>
@ -237,8 +334,8 @@ const startGeneration = async () => {
</div>
</div>
<!-- Account (only when there's a choice to make) -->
<div v-if="selectedFormat && showsAccountPicker" class="space-y-2">
<!-- Account (when template needs_account OR there's a choice to make) -->
<div v-if="selectedFormat && (templateNeedsAccount || showsAccountPicker)" class="space-y-2">
<Label class="text-sm font-bold">{{ $t('posts.create.steps.account_title') }}</Label>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
<button
@ -324,5 +421,6 @@ const startGeneration = async () => {
{{ $t('posts.ai.generate.start') }}
</Button>
</div>
</template>
</div>
</template>

View file

@ -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<Props>(), {
@ -131,6 +141,7 @@ const stepHeader = computed(() => {
<AiPostWizard
v-else-if="view === 'ai'"
:social-accounts="socialAccounts"
:templates="templates"
:date="props.date"
@update:step-header="aiHeader = $event"
@cancel="view = 'choice'; aiHeader = null"