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({
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) => ({

View File

@@ -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 */}
<div className="space-y-3 pt-2 border-t border-gray-200">
<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>
<p className="text-[11px] text-gray-500">
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.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-3">
{(
[
{
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',

View File

@@ -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<string, string> = {
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 {
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;

View File

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