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