feat(creatives): plantilla Canva Agua Miia EAHYK-l1SFM

Permite pegar URL o ID de Brand Template, default por tenant y normalización al guardar para WF5 Ads/RRSS.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-16 13:51:15 +02:00
parent 0742add9e6
commit 5de5e3c5e0
3 changed files with 152 additions and 115 deletions

View File

@@ -251,7 +251,8 @@ export async function PUT(req: NextRequest) {
updatePayload.default_landing_url = body.default_landing_url.trim() || null;
}
if (body.canva_templates && typeof body.canva_templates === 'object') {
updatePayload.canva_templates = body.canva_templates;
const { normalizeCanvaTemplates } = await import('@/lib/canva-templates');
updatePayload.canva_templates = normalizeCanvaTemplates(body.canva_templates);
}
if (body.campaign_audience && typeof body.campaign_audience === 'object') {
updatePayload.campaign_audience = body.campaign_audience;

View File

@@ -24,6 +24,7 @@ import {
compileClientSkillMarkdown,
createEmptyCreativeStyle,
} from '@/lib/creative-style';
import { parseCanvaTemplateId } from '@/lib/canva-templates';
type SectionId =
| 'identity'
@@ -219,6 +220,15 @@ export default function ClientCreativeStylePanel({
};
}
const normalizedCanva: Record<string, string> = {};
for (const [k, v] of Object.entries(canvaTemplates)) {
const id = parseCanvaTemplateId(v);
if (id) normalizedCanva[k] = id;
}
if (Object.keys(normalizedCanva).length > 0) {
setCanvaTemplates(normalizedCanva);
}
const res = await fetch('/api/creatives/style', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -231,7 +241,7 @@ export default function ClientCreativeStylePanel({
slack_channel_id: slackChannelId,
drive_folder_id: driveFolderId,
default_landing_url: defaultLandingUrl,
canva_templates: canvaTemplates,
canva_templates: normalizedCanva,
campaign_audience: campaignAudience,
campaign_products: campaignProducts,
style,
@@ -1719,109 +1729,88 @@ export default function ClientCreativeStylePanel({
<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
</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.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-3">
<div>
<label className="block text-[11px] font-semibold text-gray-600 mb-1">
4:5 · foto-con-franja
</label>
<input
type="text"
value={canvaTemplates['foto-con-franja'] || ''}
onChange={(e) =>
setCanvaTemplates({
...canvaTemplates,
'foto-con-franja': e.target.value.trim(),
})
}
placeholder="EAHRb38v_WY"
className="w-full rounded-lg border border-gray-300 px-2.5 py-1.5 text-xs font-mono"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-gray-600 mb-1">
4:5 · tipografico
</label>
<input
type="text"
value={canvaTemplates.tipografico || ''}
onChange={(e) =>
setCanvaTemplates({
...canvaTemplates,
tipografico: e.target.value.trim(),
})
}
placeholder="EAH..."
className="w-full rounded-lg border border-gray-300 px-2.5 py-1.5 text-xs font-mono"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-gray-600 mb-1">
4:5 · dato-grande
</label>
<input
type="text"
value={canvaTemplates['dato-grande'] || ''}
onChange={(e) =>
setCanvaTemplates({
...canvaTemplates,
'dato-grande': e.target.value.trim(),
})
}
placeholder="EAH..."
className="w-full rounded-lg border border-gray-300 px-2.5 py-1.5 text-xs font-mono"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-brand-700 mb-1">
9:16 · Stories / Reels
</label>
<input
type="text"
value={canvaTemplates.stories_9_16 || ''}
onChange={(e) =>
setCanvaTemplates({
...canvaTemplates,
stories_9_16: e.target.value.trim(),
})
}
placeholder="EAH... (9:16)"
className="w-full rounded-lg border border-brand-300 bg-brand-50/30 px-2.5 py-1.5 text-xs font-mono"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-sky-800 mb-1">
1.91:1 · Landscape
</label>
<input
type="text"
value={canvaTemplates.landscape_1_91 || ''}
onChange={(e) =>
setCanvaTemplates({
...canvaTemplates,
landscape_1_91: e.target.value.trim(),
})
}
placeholder="EAH... (1.91:1)"
className="w-full rounded-lg border border-sky-300 bg-sky-50/40 px-2.5 py-1.5 text-xs font-mono"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-gray-600 mb-1">
Fallback por defecto
</label>
<input
type="text"
value={canvaTemplates.default || ''}
onChange={(e) =>
setCanvaTemplates({
...canvaTemplates,
default: e.target.value.trim(),
})
}
placeholder="EAHRb38v_WY"
className="w-full rounded-lg border border-gray-300 px-2.5 py-1.5 text-xs font-mono"
/>
</div>
{(
[
{
key: 'foto-con-franja' as const,
label: '4:5 · foto-con-franja',
placeholder: 'EAHYK-l1SFM 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: '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,
label: '9:16 · Stories / Reels',
placeholder: 'EAH... (9:16)',
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: 'landscape_1_91' as const,
label: '1.91:1 · Landscape',
placeholder: 'EAH... (1.91:1)',
className:
'w-full rounded-lg border border-sky-300 bg-sky-50/40 px-2.5 py-1.5 text-xs font-mono',
labelClass: 'block text-[11px] font-semibold text-sky-800 mb-1',
},
{
key: 'default' as const,
label: 'Fallback por defecto',
placeholder: 'EAHYK-l1SFM',
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',
},
] as const
).map((field) => (
<div key={field.key}>
<label className={field.labelClass}>{field.label}</label>
<input
type="text"
value={canvaTemplates[field.key] || ''}
onChange={(e) =>
setCanvaTemplates({
...canvaTemplates,
[field.key]: e.target.value.trim(),
})
}
onBlur={(e) => {
const id = parseCanvaTemplateId(e.target.value);
if (id && id !== (canvaTemplates[field.key] || '')) {
setCanvaTemplates((prev) => ({
...prev,
[field.key]: id,
}));
}
}}
placeholder={field.placeholder}
className={field.className}
/>
</div>
))}
</div>
</div>

View File

@@ -48,12 +48,39 @@ function asMap(v: unknown): CanvaTemplateMap {
return out;
}
/**
* Extract Canva brand-template id from a URL or raw id.
* e.g. https://www.canva.com/brand/brand-templates/EAHYK-l1SFM → EAHYK-l1SFM
*/
export function parseCanvaTemplateId(raw: string | null | undefined): string {
const s = String(raw || '').trim();
if (!s) return '';
const fromUrl = s.match(
/canva\.com\/(?:brand\/)?brand-templates\/([A-Za-z0-9_-]+)/i
);
if (fromUrl?.[1]) return fromUrl[1];
const fromDesign = s.match(/canva\.com\/design\/([A-Za-z0-9_-]+)/i);
if (fromDesign?.[1]) return fromDesign[1];
// bare id
if (/^[A-Za-z0-9_-]{6,}$/.test(s)) return s;
return s;
}
export function normalizeCanvaTemplates(raw: unknown): CanvaTemplatesConfig {
if (!raw || typeof raw !== 'object') return {};
const obj = raw as Record<string, unknown>;
const flat = asMap(obj);
const ads = { ...flat, ...asMap(obj.ads) };
const rrss = { ...flat, ...asMap(obj.rrss) };
// Normalize URL values → ids
for (const [k, v] of Object.entries(flat)) {
flat[k] = parseCanvaTemplateId(v);
}
const adsRaw = asMap(obj.ads);
const rrssRaw = asMap(obj.rrss);
for (const [k, v] of Object.entries(adsRaw)) adsRaw[k] = parseCanvaTemplateId(v);
for (const [k, v] of Object.entries(rrssRaw))
rrssRaw[k] = parseCanvaTemplateId(v);
const ads = { ...flat, ...adsRaw };
const rrss = { ...flat, ...rrssRaw };
return { ...flat, ads, rrss };
}
@@ -99,21 +126,41 @@ export function canvaTemplateKeyForLayout(
* 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). */
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',
};
function fillDefaultsFromId(templateId: string, channel: 'ads' | 'rrss'): CanvaTemplateMap {
const id = parseCanvaTemplateId(templateId);
if (!id) return {};
const base: CanvaTemplateMap = {
'foto-con-franja': id,
tipografico: id,
'dato-grande': id,
stories_9_16: id,
default: id,
};
if (channel === 'ads') base.landscape_1_91 = id;
return base;
}
export function resolveTenantCanvaTemplates(
raw: unknown,
opts?: { slug?: string | null; channel?: 'ads' | 'rrss' }
): CanvaTemplateMap {
const channel = opts?.channel || 'ads';
let map = resolveCanvaTemplatesForChannel(raw, channel);
if (
opts?.slug === 'bps' &&
(!map || Object.keys(map).length === 0)
) {
const defaultBps = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY';
map = {
'foto-con-franja': defaultBps,
default: defaultBps,
};
const slug = (opts?.slug || '').toLowerCase();
const fallbackId = TENANT_DEFAULT_CANVA[slug];
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
map = { ...fillDefaultsFromId(fallbackId, channel), ...map };
}
return map;
}