diff --git a/src/app/api/creatives/style/route.ts b/src/app/api/creatives/style/route.ts index d03e5df..6560be4 100644 --- a/src/app/api/creatives/style/route.ts +++ b/src/app/api/creatives/style/route.ts @@ -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; diff --git a/src/components/ClientCreativeStylePanel.tsx b/src/components/ClientCreativeStylePanel.tsx index 57e3def..f4a1299 100644 --- a/src/components/ClientCreativeStylePanel.tsx +++ b/src/components/ClientCreativeStylePanel.tsx @@ -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 = {}; + 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({

Plantillas Canva WF5 · 4:5 feed + 9:16 Stories + 1.91:1 landscape

+

+ Pega el ID Canva (p. ej. EAHYK-l1SFM) o la URL completa de Brand + Template. Se usa en Ads y RRSS al generar creatividades. +

-
- - - 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" - /> -
-
- - - 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" - /> -
-
- - - 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" - /> -
-
- - - 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" - /> -
-
- - - 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" - /> -
-
- - - 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" - /> -
+ {( + [ + { + 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) => ( +
+ + + 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} + /> +
+ ))}
diff --git a/src/lib/canva-templates.ts b/src/lib/canva-templates.ts index 11f329d..54e4c57 100644 --- a/src/lib/canva-templates.ts +++ b/src/lib/canva-templates.ts @@ -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; 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 = { + 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; }