feat(creatives): Canva Agua Miia post / story / LinkedIn

Mapea EAHYK-l1SFM, EAHYK9p_MlY y EAHYK_ylNJo por formato y plataforma en RRSS.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-16 14:00:34 +02:00
parent 5de5e3c5e0
commit f127b0a4c4
4 changed files with 139 additions and 44 deletions

View File

@@ -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({ const brief = buildRrssGenerationBrief({
tenantId, tenantId,
clientSlug: tenant?.slug || 'cliente', clientSlug: tenant?.slug || 'cliente',
@@ -202,6 +209,7 @@ export async function POST(req: NextRequest) {
notes: notesBase, notes: notesBase,
pillar, pillar,
month, month,
platform: platformFromBody,
scheduledAt: scheduledAt:
typeof body.scheduled_at === 'string' ? body.scheduled_at : null, typeof body.scheduled_at === 'string' ? body.scheduled_at : null,
calendarPiece: calendarPiece calendarPiece: calendarPiece
@@ -222,9 +230,7 @@ export async function POST(req: NextRequest) {
hashtags: calendarPiece.hashtags hashtags: calendarPiece.hashtags
? String(calendarPiece.hashtags) ? String(calendarPiece.hashtags)
: null, : null,
platform: calendarPiece.platform platform: platformFromBody || 'instagram',
? String(calendarPiece.platform)
: 'instagram',
} }
: null, : null,
recentPipeline: (recentRows || []).map((r) => ({ recentPipeline: (recentRows || []).map((r) => ({

View File

@@ -124,10 +124,13 @@ export default function ClientCreativeStylePanel({
const [driveFolderId, setDriveFolderId] = useState(''); const [driveFolderId, setDriveFolderId] = useState('');
const [defaultLandingUrl, setDefaultLandingUrl] = useState(''); const [defaultLandingUrl, setDefaultLandingUrl] = useState('');
const [canvaTemplates, setCanvaTemplates] = useState<{ const [canvaTemplates, setCanvaTemplates] = useState<{
post?: string;
'foto-con-franja'?: string; 'foto-con-franja'?: string;
tipografico?: string; tipografico?: string;
'dato-grande'?: string; 'dato-grande'?: string;
stories_9_16?: string; stories_9_16?: string;
story?: string;
linkedin?: string;
landscape_1_91?: string; landscape_1_91?: string;
default?: string; default?: string;
}>({}); }>({});
@@ -225,6 +228,16 @@ export default function ClientCreativeStylePanel({
const id = parseCanvaTemplateId(v); const id = parseCanvaTemplateId(v);
if (id) normalizedCanva[k] = id; 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) { if (Object.keys(normalizedCanva).length > 0) {
setCanvaTemplates(normalizedCanva); setCanvaTemplates(normalizedCanva);
} }
@@ -1727,47 +1740,47 @@ export default function ClientCreativeStylePanel({
{/* Plantillas de Canva */} {/* Plantillas de Canva */}
<div className="space-y-3 pt-2 border-t border-gray-200"> <div className="space-y-3 pt-2 border-t border-gray-200">
<h4 className="text-xs font-bold uppercase tracking-wider text-gray-700"> <h4 className="text-xs font-bold uppercase tracking-wider text-gray-700">
Plantillas Canva WF5 · 4:5 feed + 9:16 Stories + 1.91:1 landscape Plantillas Canva · Post + Story + LinkedIn (+ layouts Ads)
</h4> </h4>
<p className="text-[11px] text-gray-500"> <p className="text-[11px] text-gray-500">
Pega el ID Canva (p. ej. EAHYK-l1SFM) o la URL completa de Brand Pega el ID Canva o la URL de Brand Template. Post / Story /
Template. Se usa en Ads y RRSS al generar creatividades. LinkedIn mandan en RRSS; el resto cubre variantes Ads.
</p> </p>
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-3"> <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-3">
{( {(
[ [
{ {
key: 'foto-con-franja' as const, key: 'post' as const,
label: '4:5 · foto-con-franja', label: 'Post (feed)',
placeholder: 'EAHYK-l1SFM o URL Canva', placeholder: 'EAHYK-l1SFM o URL',
className: className:
'w-full rounded-lg border border-gray-300 px-2.5 py-1.5 text-xs font-mono', '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-gray-600 mb-1', labelClass: 'block text-[11px] font-semibold text-emerald-800 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',
}, },
{ {
key: 'stories_9_16' as const, key: 'stories_9_16' as const,
label: '9:16 · Stories / Reels', label: 'Story / Reels 9:16',
placeholder: 'EAH... (9:16)', placeholder: 'EAHYK9p_MlY o URL',
className: className:
'w-full rounded-lg border border-brand-300 bg-brand-50/30 px-2.5 py-1.5 text-xs font-mono', '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', 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, key: 'landscape_1_91' as const,
label: '1.91:1 · Landscape', label: '1.91:1 · Landscape',

View File

@@ -4,7 +4,7 @@
* Recommended structure (1 Brand Kit + 2 template groups): * Recommended structure (1 Brand Kit + 2 template groups):
* canva_templates: { * canva_templates: {
* ads: { "foto-con-franja", "tipografico", "dato-grande", "stories_9_16", "landscape_1_91", "default" }, * 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 * // flat legacy keys still work as fallback for both
* } * }
* *
@@ -31,10 +31,13 @@ const ADS_KEYS = [
] as const; ] as const;
const RRSS_KEYS = [ const RRSS_KEYS = [
'post',
'foto-con-franja', 'foto-con-franja',
'tipografico', 'tipografico',
'dato-grande', 'dato-grande',
'stories_9_16', 'stories_9_16',
'story',
'linkedin',
'default', 'default',
] as const; ] as const;
@@ -111,43 +114,84 @@ export function resolveCanvaTemplatesForChannel(
export function canvaTemplateKeyForLayout( export function canvaTemplateKeyForLayout(
layout: string | null | undefined, layout: string | null | undefined,
channel: 'ads' | 'rrss' channel: 'ads' | 'rrss',
platform?: string | null
): string { ): string {
const l = String(layout || '').toLowerCase(); 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('tipograf')) return 'tipografico';
if (l.includes('dato')) return 'dato-grande'; if (l.includes('dato')) return 'dato-grande';
if (channel === 'rrss' && (l.includes('stor') || l.includes('reel'))) { if (channel === 'rrss' && (l.includes('stor') || l.includes('reel'))) {
return 'stories_9_16'; 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. * Resolve canva_templates for n8n payloads.
* Prefer nested ads/rrss; fall back to flat keys; BPS gets env default. * 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<string, string> = { const TENANT_DEFAULT_CANVA: Record<string, string> = {
bps: process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY', 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<string, CanvaTemplateMap> = {
'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 { function fillDefaultsFromId(templateId: string, channel: 'ads' | 'rrss'): CanvaTemplateMap {
const id = parseCanvaTemplateId(templateId); const id = parseCanvaTemplateId(templateId);
if (!id) return {}; if (!id) return {};
const base: CanvaTemplateMap = { const base: CanvaTemplateMap = {
post: id,
'foto-con-franja': id, 'foto-con-franja': id,
tipografico: id, tipografico: id,
'dato-grande': id, 'dato-grande': id,
stories_9_16: id, stories_9_16: id,
story: id,
default: id, default: id,
}; };
if (channel === 'ads') base.landscape_1_91 = id; if (channel === 'ads') base.landscape_1_91 = id;
return base; 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( export function resolveTenantCanvaTemplates(
raw: unknown, raw: unknown,
opts?: { slug?: string | null; channel?: 'ads' | 'rrss' } opts?: { slug?: string | null; channel?: 'ads' | 'rrss' }
@@ -155,11 +199,20 @@ export function resolveTenantCanvaTemplates(
const channel = opts?.channel || 'ads'; const channel = opts?.channel || 'ads';
let map = resolveCanvaTemplatesForChannel(raw, channel); let map = resolveCanvaTemplatesForChannel(raw, channel);
const slug = (opts?.slug || '').toLowerCase(); const slug = (opts?.slug || '').toLowerCase();
const mapDefaults = TENANT_DEFAULT_CANVA_MAPS[slug];
const fallbackId = TENANT_DEFAULT_CANVA[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); map = fillDefaultsFromId(fallbackId, channel);
} else if (fallbackId && map && !map.default && !map['foto-con-franja']) { } else if (fallbackId && map && !map.default && !map['foto-con-franja'] && !map.post) {
// Partial config: ensure at least default + feed layout
map = { ...fillDefaultsFromId(fallbackId, channel), ...map }; map = { ...fillDefaultsFromId(fallbackId, channel), ...map };
} }
return map; return map;

View File

@@ -14,11 +14,18 @@ export type RrssPlacement = {
}; };
/** Placements per RRSS format (not the Ads triple 4:5+9:16+1.91:1). */ /** 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_ratio: string;
aspect_ratios: string[]; aspect_ratios: string[];
required_placements: RrssPlacement[]; required_placements: RrssPlacement[];
} { } {
const isLinkedIn = String(platform || '')
.toLowerCase()
.includes('linkedin');
if (format === 'story') { if (format === 'story') {
const p: RrssPlacement = { const p: RrssPlacement = {
aspect_ratio: '9:16', 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] }; 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') { if (format === 'carrusel') {
const p: RrssPlacement = { const p: RrssPlacement = {
aspect_ratio: '4:5', aspect_ratio: '4:5',
role: 'carousel_slide', role: 'carousel_slide',
size: '1080x1350', size: '1080x1350',
meta_key: 'asset_url', meta_key: 'asset_url',
canva_template_key: 'foto-con-franja', canva_template_key: 'post',
}; };
return { return {
aspect_ratio: '4:5', aspect_ratio: '4:5',
@@ -53,13 +70,13 @@ export function rrssBriefAspectFields(format: RrssPipelineFormat): {
required_placements: [p], required_placements: [p],
}; };
} }
// post feed // post feed (IG/FB)
const p: RrssPlacement = { const p: RrssPlacement = {
aspect_ratio: '4:5', aspect_ratio: '4:5',
role: 'feed', role: 'feed',
size: '1080x1350', size: '1080x1350',
meta_key: 'asset_url', 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] }; return { aspect_ratio: '4:5', aspect_ratios: ['4:5'], required_placements: [p] };
} }
@@ -99,6 +116,7 @@ export type RrssGenerationBriefInput = {
pillar?: string | null; pillar?: string | null;
month?: string | null; month?: string | null;
scheduledAt?: string | null; scheduledAt?: string | null;
platform?: string | null;
calendarPiece?: RrssCalendarPieceBrief | null; calendarPiece?: RrssCalendarPieceBrief | null;
/** Recent pipeline captions/titles for style continuity */ /** Recent pipeline captions/titles for style continuity */
recentPipeline?: Array<{ title: string; format: string; caption?: string | null }>; recentPipeline?: Array<{ title: string; format: string; caption?: string | null }>;
@@ -113,8 +131,12 @@ export type RrssGenerationBriefInput = {
}; };
export function buildRrssGenerationBrief(input: RrssGenerationBriefInput) { export function buildRrssGenerationBrief(input: RrssGenerationBriefInput) {
const aspects = rrssBriefAspectFields(input.format);
const cal = input.calendarPiece || {}; const cal = input.calendarPiece || {};
const platform =
input.platform ||
(typeof cal.platform === 'string' ? cal.platform : null) ||
null;
const aspects = rrssBriefAspectFields(input.format, platform);
return { return {
channel: 'rrss' as const, channel: 'rrss' as const,
tenant_id: input.tenantId, tenant_id: input.tenantId,
@@ -127,6 +149,7 @@ export function buildRrssGenerationBrief(input: RrssGenerationBriefInput) {
source_pipeline_id: input.pipelineId, source_pipeline_id: input.pipelineId,
calendar_piece_id: input.calendarPieceId || cal.id || null, calendar_piece_id: input.calendarPieceId || cal.id || null,
format: input.format, format: input.format,
platform,
skill_format: mapRrssFormatToSkillFormat(input.format), skill_format: mapRrssFormatToSkillFormat(input.format),
...aspects, ...aspects,
variants_requested: Number(input.variantsRequested || 2), variants_requested: Number(input.variantsRequested || 2),