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;
|
updatePayload.default_landing_url = body.default_landing_url.trim() || null;
|
||||||
}
|
}
|
||||||
if (body.canva_templates && typeof body.canva_templates === 'object') {
|
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') {
|
if (body.campaign_audience && typeof body.campaign_audience === 'object') {
|
||||||
updatePayload.campaign_audience = body.campaign_audience;
|
updatePayload.campaign_audience = body.campaign_audience;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
compileClientSkillMarkdown,
|
compileClientSkillMarkdown,
|
||||||
createEmptyCreativeStyle,
|
createEmptyCreativeStyle,
|
||||||
} from '@/lib/creative-style';
|
} from '@/lib/creative-style';
|
||||||
|
import { parseCanvaTemplateId } from '@/lib/canva-templates';
|
||||||
|
|
||||||
type SectionId =
|
type SectionId =
|
||||||
| 'identity'
|
| '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', {
|
const res = await fetch('/api/creatives/style', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -231,7 +241,7 @@ export default function ClientCreativeStylePanel({
|
|||||||
slack_channel_id: slackChannelId,
|
slack_channel_id: slackChannelId,
|
||||||
drive_folder_id: driveFolderId,
|
drive_folder_id: driveFolderId,
|
||||||
default_landing_url: defaultLandingUrl,
|
default_landing_url: defaultLandingUrl,
|
||||||
canva_templates: canvaTemplates,
|
canva_templates: normalizedCanva,
|
||||||
campaign_audience: campaignAudience,
|
campaign_audience: campaignAudience,
|
||||||
campaign_products: campaignProducts,
|
campaign_products: campaignProducts,
|
||||||
style,
|
style,
|
||||||
@@ -1719,109 +1729,88 @@ export default function ClientCreativeStylePanel({
|
|||||||
<h4 className="text-xs font-bold uppercase tracking-wider text-gray-700">
|
<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 WF5 · 4:5 feed + 9:16 Stories + 1.91:1 landscape
|
||||||
</h4>
|
</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 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>
|
key: 'foto-con-franja' as const,
|
||||||
<input
|
label: '4:5 · foto-con-franja',
|
||||||
type="text"
|
placeholder: 'EAHYK-l1SFM o URL Canva',
|
||||||
value={canvaTemplates['foto-con-franja'] || ''}
|
className:
|
||||||
onChange={(e) =>
|
'w-full rounded-lg border border-gray-300 px-2.5 py-1.5 text-xs font-mono',
|
||||||
setCanvaTemplates({
|
labelClass: 'block text-[11px] font-semibold text-gray-600 mb-1',
|
||||||
...canvaTemplates,
|
},
|
||||||
'foto-con-franja': e.target.value.trim(),
|
{
|
||||||
})
|
key: 'tipografico' as const,
|
||||||
}
|
label: '4:5 · tipografico',
|
||||||
placeholder="EAHRb38v_WY"
|
placeholder: 'EAH... o URL Canva',
|
||||||
className="w-full rounded-lg border border-gray-300 px-2.5 py-1.5 text-xs font-mono"
|
className:
|
||||||
/>
|
'w-full rounded-lg border border-gray-300 px-2.5 py-1.5 text-xs font-mono',
|
||||||
</div>
|
labelClass: 'block text-[11px] font-semibold text-gray-600 mb-1',
|
||||||
<div>
|
},
|
||||||
<label className="block text-[11px] font-semibold text-gray-600 mb-1">
|
{
|
||||||
4:5 · tipografico
|
key: 'dato-grande' as const,
|
||||||
</label>
|
label: '4:5 · dato-grande',
|
||||||
<input
|
placeholder: 'EAH... o URL Canva',
|
||||||
type="text"
|
className:
|
||||||
value={canvaTemplates.tipografico || ''}
|
'w-full rounded-lg border border-gray-300 px-2.5 py-1.5 text-xs font-mono',
|
||||||
onChange={(e) =>
|
labelClass: 'block text-[11px] font-semibold text-gray-600 mb-1',
|
||||||
setCanvaTemplates({
|
},
|
||||||
...canvaTemplates,
|
{
|
||||||
tipografico: e.target.value.trim(),
|
key: 'stories_9_16' as const,
|
||||||
})
|
label: '9:16 · Stories / Reels',
|
||||||
}
|
placeholder: 'EAH... (9:16)',
|
||||||
placeholder="EAH..."
|
className:
|
||||||
className="w-full rounded-lg border border-gray-300 px-2.5 py-1.5 text-xs font-mono"
|
'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',
|
||||||
</div>
|
},
|
||||||
<div>
|
{
|
||||||
<label className="block text-[11px] font-semibold text-gray-600 mb-1">
|
key: 'landscape_1_91' as const,
|
||||||
4:5 · dato-grande
|
label: '1.91:1 · Landscape',
|
||||||
</label>
|
placeholder: 'EAH... (1.91:1)',
|
||||||
<input
|
className:
|
||||||
type="text"
|
'w-full rounded-lg border border-sky-300 bg-sky-50/40 px-2.5 py-1.5 text-xs font-mono',
|
||||||
value={canvaTemplates['dato-grande'] || ''}
|
labelClass: 'block text-[11px] font-semibold text-sky-800 mb-1',
|
||||||
onChange={(e) =>
|
},
|
||||||
setCanvaTemplates({
|
{
|
||||||
...canvaTemplates,
|
key: 'default' as const,
|
||||||
'dato-grande': e.target.value.trim(),
|
label: 'Fallback por defecto',
|
||||||
})
|
placeholder: 'EAHYK-l1SFM',
|
||||||
}
|
className:
|
||||||
placeholder="EAH..."
|
'w-full rounded-lg border border-gray-300 px-2.5 py-1.5 text-xs font-mono',
|
||||||
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',
|
||||||
/>
|
},
|
||||||
</div>
|
] as const
|
||||||
<div>
|
).map((field) => (
|
||||||
<label className="block text-[11px] font-semibold text-brand-700 mb-1">
|
<div key={field.key}>
|
||||||
9:16 · Stories / Reels
|
<label className={field.labelClass}>{field.label}</label>
|
||||||
</label>
|
<input
|
||||||
<input
|
type="text"
|
||||||
type="text"
|
value={canvaTemplates[field.key] || ''}
|
||||||
value={canvaTemplates.stories_9_16 || ''}
|
onChange={(e) =>
|
||||||
onChange={(e) =>
|
setCanvaTemplates({
|
||||||
setCanvaTemplates({
|
...canvaTemplates,
|
||||||
...canvaTemplates,
|
[field.key]: e.target.value.trim(),
|
||||||
stories_9_16: e.target.value.trim(),
|
})
|
||||||
})
|
}
|
||||||
}
|
onBlur={(e) => {
|
||||||
placeholder="EAH... (9:16)"
|
const id = parseCanvaTemplateId(e.target.value);
|
||||||
className="w-full rounded-lg border border-brand-300 bg-brand-50/30 px-2.5 py-1.5 text-xs font-mono"
|
if (id && id !== (canvaTemplates[field.key] || '')) {
|
||||||
/>
|
setCanvaTemplates((prev) => ({
|
||||||
</div>
|
...prev,
|
||||||
<div>
|
[field.key]: id,
|
||||||
<label className="block text-[11px] font-semibold text-sky-800 mb-1">
|
}));
|
||||||
1.91:1 · Landscape
|
}
|
||||||
</label>
|
}}
|
||||||
<input
|
placeholder={field.placeholder}
|
||||||
type="text"
|
className={field.className}
|
||||||
value={canvaTemplates.landscape_1_91 || ''}
|
/>
|
||||||
onChange={(e) =>
|
</div>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -48,12 +48,39 @@ function asMap(v: unknown): CanvaTemplateMap {
|
|||||||
return out;
|
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 {
|
export function normalizeCanvaTemplates(raw: unknown): CanvaTemplatesConfig {
|
||||||
if (!raw || typeof raw !== 'object') return {};
|
if (!raw || typeof raw !== 'object') return {};
|
||||||
const obj = raw as Record<string, unknown>;
|
const obj = raw as Record<string, unknown>;
|
||||||
const flat = asMap(obj);
|
const flat = asMap(obj);
|
||||||
const ads = { ...flat, ...asMap(obj.ads) };
|
// Normalize URL values → ids
|
||||||
const rrss = { ...flat, ...asMap(obj.rrss) };
|
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 };
|
return { ...flat, ads, rrss };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,21 +126,41 @@ export function canvaTemplateKeyForLayout(
|
|||||||
* Resolve canva_templates for n8n payloads.
|
* Resolve canva_templates for n8n payloads.
|
||||||
* Prefer nested ads/rrss; fall back to flat keys; BPS gets env default.
|
* 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(
|
export function resolveTenantCanvaTemplates(
|
||||||
raw: unknown,
|
raw: unknown,
|
||||||
opts?: { slug?: string | null; channel?: 'ads' | 'rrss' }
|
opts?: { slug?: string | null; channel?: 'ads' | 'rrss' }
|
||||||
): CanvaTemplateMap {
|
): CanvaTemplateMap {
|
||||||
const channel = opts?.channel || 'ads';
|
const channel = opts?.channel || 'ads';
|
||||||
let map = resolveCanvaTemplatesForChannel(raw, channel);
|
let map = resolveCanvaTemplatesForChannel(raw, channel);
|
||||||
if (
|
const slug = (opts?.slug || '').toLowerCase();
|
||||||
opts?.slug === 'bps' &&
|
const fallbackId = TENANT_DEFAULT_CANVA[slug];
|
||||||
(!map || Object.keys(map).length === 0)
|
if (fallbackId && (!map || Object.keys(map).length === 0)) {
|
||||||
) {
|
map = fillDefaultsFromId(fallbackId, channel);
|
||||||
const defaultBps = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY';
|
} else if (fallbackId && map && !map.default && !map['foto-con-franja']) {
|
||||||
map = {
|
// Partial config: ensure at least default + feed layout
|
||||||
'foto-con-franja': defaultBps,
|
map = { ...fillDefaultsFromId(fallbackId, channel), ...map };
|
||||||
default: defaultBps,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user