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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user