diff --git a/src/app/api/creative-pipeline/generate/route.ts b/src/app/api/creative-pipeline/generate/route.ts index d3ada77..f52a102 100644 --- a/src/app/api/creative-pipeline/generate/route.ts +++ b/src/app/api/creative-pipeline/generate/route.ts @@ -190,6 +190,13 @@ export async function POST(req: NextRequest) { ); } + const platformFromBody = + typeof body.platform === 'string' + ? body.platform + : calendarPiece?.platform + ? String(calendarPiece.platform) + : null; + const brief = buildRrssGenerationBrief({ tenantId, clientSlug: tenant?.slug || 'cliente', @@ -202,6 +209,7 @@ export async function POST(req: NextRequest) { notes: notesBase, pillar, month, + platform: platformFromBody, scheduledAt: typeof body.scheduled_at === 'string' ? body.scheduled_at : null, calendarPiece: calendarPiece @@ -222,9 +230,7 @@ export async function POST(req: NextRequest) { hashtags: calendarPiece.hashtags ? String(calendarPiece.hashtags) : null, - platform: calendarPiece.platform - ? String(calendarPiece.platform) - : 'instagram', + platform: platformFromBody || 'instagram', } : null, recentPipeline: (recentRows || []).map((r) => ({ diff --git a/src/components/ClientCreativeStylePanel.tsx b/src/components/ClientCreativeStylePanel.tsx index f4a1299..1ecaf41 100644 --- a/src/components/ClientCreativeStylePanel.tsx +++ b/src/components/ClientCreativeStylePanel.tsx @@ -124,10 +124,13 @@ export default function ClientCreativeStylePanel({ const [driveFolderId, setDriveFolderId] = useState(''); const [defaultLandingUrl, setDefaultLandingUrl] = useState(''); const [canvaTemplates, setCanvaTemplates] = useState<{ + post?: string; 'foto-con-franja'?: string; tipografico?: string; 'dato-grande'?: string; stories_9_16?: string; + story?: string; + linkedin?: string; landscape_1_91?: string; default?: string; }>({}); @@ -225,6 +228,16 @@ export default function ClientCreativeStylePanel({ const id = parseCanvaTemplateId(v); if (id) normalizedCanva[k] = id; } + // Keep legacy layout keys aligned with Post / Story slots + if (normalizedCanva.post) { + if (!normalizedCanva['foto-con-franja']) { + normalizedCanva['foto-con-franja'] = normalizedCanva.post; + } + if (!normalizedCanva.default) normalizedCanva.default = normalizedCanva.post; + } + if (normalizedCanva.stories_9_16 && !normalizedCanva.story) { + normalizedCanva.story = normalizedCanva.stories_9_16; + } if (Object.keys(normalizedCanva).length > 0) { setCanvaTemplates(normalizedCanva); } @@ -1727,47 +1740,47 @@ export default function ClientCreativeStylePanel({ {/* Plantillas de Canva */}

- Plantillas Canva WF5 · 4:5 feed + 9:16 Stories + 1.91:1 landscape + Plantillas Canva · Post + Story + LinkedIn (+ layouts Ads)

- Pega el ID Canva (p. ej. EAHYK-l1SFM) o la URL completa de Brand - Template. Se usa en Ads y RRSS al generar creatividades. + Pega el ID Canva o la URL de Brand Template. Post / Story / + LinkedIn mandan en RRSS; el resto cubre variantes Ads.

{( [ { - key: 'foto-con-franja' as const, - label: '4:5 · foto-con-franja', - placeholder: 'EAHYK-l1SFM o URL Canva', + key: 'post' as const, + label: 'Post (feed)', + placeholder: 'EAHYK-l1SFM o URL', className: - 'w-full rounded-lg border border-gray-300 px-2.5 py-1.5 text-xs font-mono', - labelClass: 'block text-[11px] font-semibold text-gray-600 mb-1', - }, - { - key: 'tipografico' as const, - label: '4:5 · tipografico', - placeholder: 'EAH... o URL Canva', - className: - 'w-full rounded-lg border border-gray-300 px-2.5 py-1.5 text-xs font-mono', - labelClass: 'block text-[11px] font-semibold text-gray-600 mb-1', - }, - { - key: 'dato-grande' as const, - label: '4:5 · dato-grande', - placeholder: 'EAH... o URL Canva', - className: - 'w-full rounded-lg border border-gray-300 px-2.5 py-1.5 text-xs font-mono', - labelClass: 'block text-[11px] font-semibold text-gray-600 mb-1', + 'w-full rounded-lg border border-emerald-300 bg-emerald-50/40 px-2.5 py-1.5 text-xs font-mono', + labelClass: 'block text-[11px] font-semibold text-emerald-800 mb-1', }, { key: 'stories_9_16' as const, - label: '9:16 · Stories / Reels', - placeholder: 'EAH... (9:16)', + label: 'Story / Reels 9:16', + placeholder: 'EAHYK9p_MlY o URL', className: 'w-full rounded-lg border border-brand-300 bg-brand-50/30 px-2.5 py-1.5 text-xs font-mono', labelClass: 'block text-[11px] font-semibold text-brand-700 mb-1', }, + { + key: 'linkedin' as const, + label: 'LinkedIn', + placeholder: 'EAHYK_ylNJo o URL', + className: + 'w-full rounded-lg border border-violet-300 bg-violet-50/40 px-2.5 py-1.5 text-xs font-mono', + labelClass: 'block text-[11px] font-semibold text-violet-800 mb-1', + }, + { + key: 'foto-con-franja' as const, + label: '4:5 · foto-con-franja', + placeholder: 'EAH... (Ads)', + className: + 'w-full rounded-lg border border-gray-300 px-2.5 py-1.5 text-xs font-mono', + labelClass: 'block text-[11px] font-semibold text-gray-600 mb-1', + }, { key: 'landscape_1_91' as const, label: '1.91:1 · Landscape', diff --git a/src/lib/canva-templates.ts b/src/lib/canva-templates.ts index 54e4c57..35e7bd4 100644 --- a/src/lib/canva-templates.ts +++ b/src/lib/canva-templates.ts @@ -4,7 +4,7 @@ * Recommended structure (1 Brand Kit + 2 template groups): * canva_templates: { * ads: { "foto-con-franja", "tipografico", "dato-grande", "stories_9_16", "landscape_1_91", "default" }, - * rrss: { "foto-con-franja", "tipografico", "dato-grande", "stories_9_16", "default" }, + * rrss: { "post", "foto-con-franja", "tipografico", "dato-grande", "stories_9_16", "story", "linkedin", "default" }, * // flat legacy keys still work as fallback for both * } * @@ -31,10 +31,13 @@ const ADS_KEYS = [ ] as const; const RRSS_KEYS = [ + 'post', 'foto-con-franja', 'tipografico', 'dato-grande', 'stories_9_16', + 'story', + 'linkedin', 'default', ] as const; @@ -111,43 +114,84 @@ export function resolveCanvaTemplatesForChannel( export function canvaTemplateKeyForLayout( layout: string | null | undefined, - channel: 'ads' | 'rrss' + channel: 'ads' | 'rrss', + platform?: string | null ): string { const l = String(layout || '').toLowerCase(); + const p = String(platform || '').toLowerCase(); + if (channel === 'rrss' && p.includes('linkedin')) return 'linkedin'; if (l.includes('tipograf')) return 'tipografico'; if (l.includes('dato')) return 'dato-grande'; if (channel === 'rrss' && (l.includes('stor') || l.includes('reel'))) { return 'stories_9_16'; } - return 'foto-con-franja'; + if (channel === 'rrss' && (l.includes('post') || l.includes('feed') || !l)) { + return 'post'; + } + return channel === 'rrss' ? 'post' : 'foto-con-franja'; } /** * Resolve canva_templates for n8n payloads. * Prefer nested ads/rrss; fall back to flat keys; BPS gets env default. */ -/** Known Brand Template defaults per client (Canva Brand Kit → Brand Templates). */ +/** Single-ID fallback (all slots share one Brand Template). */ const TENANT_DEFAULT_CANVA: Record = { bps: process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY', - /** Agua Miia · https://www.canva.com/brand/brand-templates/EAHYK-l1SFM */ - 'agua-miia': - process.env.AGUA_MIIA_CANVA_TEMPLATE_ID || 'EAHYK-l1SFM', +}; + +/** + * Multi-template defaults per client (post / story / LinkedIn). + * Agua Miia: + * post https://www.canva.com/brand/brand-templates/EAHYK-l1SFM + * linkedin https://www.canva.com/brand/brand-templates/EAHYK_ylNJo + * story https://www.canva.com/brand/brand-templates/EAHYK9p_MlY + */ +const TENANT_DEFAULT_CANVA_MAPS: Record = { + 'agua-miia': { + post: process.env.AGUA_MIIA_CANVA_POST_ID || 'EAHYK-l1SFM', + 'foto-con-franja': process.env.AGUA_MIIA_CANVA_POST_ID || 'EAHYK-l1SFM', + tipografico: process.env.AGUA_MIIA_CANVA_POST_ID || 'EAHYK-l1SFM', + 'dato-grande': process.env.AGUA_MIIA_CANVA_POST_ID || 'EAHYK-l1SFM', + default: process.env.AGUA_MIIA_CANVA_POST_ID || 'EAHYK-l1SFM', + landscape_1_91: process.env.AGUA_MIIA_CANVA_POST_ID || 'EAHYK-l1SFM', + stories_9_16: process.env.AGUA_MIIA_CANVA_STORY_ID || 'EAHYK9p_MlY', + story: process.env.AGUA_MIIA_CANVA_STORY_ID || 'EAHYK9p_MlY', + linkedin: process.env.AGUA_MIIA_CANVA_LINKEDIN_ID || 'EAHYK_ylNJo', + }, }; function fillDefaultsFromId(templateId: string, channel: 'ads' | 'rrss'): CanvaTemplateMap { const id = parseCanvaTemplateId(templateId); if (!id) return {}; const base: CanvaTemplateMap = { + post: id, 'foto-con-franja': id, tipografico: id, 'dato-grande': id, stories_9_16: id, + story: id, default: id, }; if (channel === 'ads') base.landscape_1_91 = id; return base; } +function fillDefaultsFromMap( + defaults: CanvaTemplateMap, + channel: 'ads' | 'rrss' +): CanvaTemplateMap { + const out: CanvaTemplateMap = {}; + for (const [k, v] of Object.entries(defaults)) { + const id = parseCanvaTemplateId(v); + if (id) out[k] = id; + } + if (channel === 'rrss') { + delete out.landscape_1_91; + } + return out; +} + export function resolveTenantCanvaTemplates( raw: unknown, opts?: { slug?: string | null; channel?: 'ads' | 'rrss' } @@ -155,11 +199,20 @@ export function resolveTenantCanvaTemplates( const channel = opts?.channel || 'ads'; let map = resolveCanvaTemplatesForChannel(raw, channel); const slug = (opts?.slug || '').toLowerCase(); + const mapDefaults = TENANT_DEFAULT_CANVA_MAPS[slug]; const fallbackId = TENANT_DEFAULT_CANVA[slug]; - if (fallbackId && (!map || Object.keys(map).length === 0)) { + + if (mapDefaults) { + const filled = fillDefaultsFromMap(mapDefaults, channel); + if (!map || Object.keys(map).length === 0) { + map = filled; + } else { + // Fill any missing slots (e.g. linkedin/story) from tenant defaults + map = { ...filled, ...map }; + } + } else if (fallbackId && (!map || Object.keys(map).length === 0)) { map = fillDefaultsFromId(fallbackId, channel); - } else if (fallbackId && map && !map.default && !map['foto-con-franja']) { - // Partial config: ensure at least default + feed layout + } else if (fallbackId && map && !map.default && !map['foto-con-franja'] && !map.post) { map = { ...fillDefaultsFromId(fallbackId, channel), ...map }; } return map; diff --git a/src/lib/rrss-generation.ts b/src/lib/rrss-generation.ts index 6e1fc27..abb1b57 100644 --- a/src/lib/rrss-generation.ts +++ b/src/lib/rrss-generation.ts @@ -14,11 +14,18 @@ export type RrssPlacement = { }; /** Placements per RRSS format (not the Ads triple 4:5+9:16+1.91:1). */ -export function rrssBriefAspectFields(format: RrssPipelineFormat): { +export function rrssBriefAspectFields( + format: RrssPipelineFormat, + platform?: string | null +): { aspect_ratio: string; aspect_ratios: string[]; required_placements: RrssPlacement[]; } { + const isLinkedIn = String(platform || '') + .toLowerCase() + .includes('linkedin'); + if (format === 'story') { const p: RrssPlacement = { aspect_ratio: '9:16', @@ -39,13 +46,23 @@ export function rrssBriefAspectFields(format: RrssPipelineFormat): { }; return { aspect_ratio: '9:16', aspect_ratios: ['9:16'], required_placements: [p] }; } + if (isLinkedIn) { + const p: RrssPlacement = { + aspect_ratio: '1:1', + role: 'linkedin', + size: '1080x1080', + meta_key: 'asset_url', + canva_template_key: 'linkedin', + }; + return { aspect_ratio: '1:1', aspect_ratios: ['1:1'], required_placements: [p] }; + } if (format === 'carrusel') { const p: RrssPlacement = { aspect_ratio: '4:5', role: 'carousel_slide', size: '1080x1350', meta_key: 'asset_url', - canva_template_key: 'foto-con-franja', + canva_template_key: 'post', }; return { aspect_ratio: '4:5', @@ -53,13 +70,13 @@ export function rrssBriefAspectFields(format: RrssPipelineFormat): { required_placements: [p], }; } - // post feed + // post feed (IG/FB) const p: RrssPlacement = { aspect_ratio: '4:5', role: 'feed', size: '1080x1350', meta_key: 'asset_url', - canva_template_key: 'foto-con-franja', + canva_template_key: 'post', }; return { aspect_ratio: '4:5', aspect_ratios: ['4:5'], required_placements: [p] }; } @@ -99,6 +116,7 @@ export type RrssGenerationBriefInput = { pillar?: string | null; month?: string | null; scheduledAt?: string | null; + platform?: string | null; calendarPiece?: RrssCalendarPieceBrief | null; /** Recent pipeline captions/titles for style continuity */ recentPipeline?: Array<{ title: string; format: string; caption?: string | null }>; @@ -113,8 +131,12 @@ export type RrssGenerationBriefInput = { }; export function buildRrssGenerationBrief(input: RrssGenerationBriefInput) { - const aspects = rrssBriefAspectFields(input.format); const cal = input.calendarPiece || {}; + const platform = + input.platform || + (typeof cal.platform === 'string' ? cal.platform : null) || + null; + const aspects = rrssBriefAspectFields(input.format, platform); return { channel: 'rrss' as const, tenant_id: input.tenantId, @@ -127,6 +149,7 @@ export function buildRrssGenerationBrief(input: RrssGenerationBriefInput) { source_pipeline_id: input.pipelineId, calendar_piece_id: input.calendarPieceId || cal.id || null, format: input.format, + platform, skill_format: mapRrssFormatToSkillFormat(input.format), ...aspects, variants_requested: Number(input.variantsRequested || 2),