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:
@@ -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) => ({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user