diff --git a/src/lib/shopifyMetafields.test.ts b/src/lib/shopifyMetafields.test.ts new file mode 100644 index 0000000..d4ab22e --- /dev/null +++ b/src/lib/shopifyMetafields.test.ts @@ -0,0 +1,40 @@ +import { normalizeMetafieldKey, matchMetafields } from './shopifyMetafields' +import type { MetafieldDefinition } from '@/types/creation' + +describe('normalizeMetafieldKey', () => { + it('minuscules + accents retirés', () => { + expect(normalizeMetafieldKey('Éclairage LED')).toBe('eclairage_led') + }) + it('parenthèses et espaces → underscore', () => { + expect(normalizeMetafieldKey('Puissance (AN)')).toBe('puissance_an') + }) + it('limité à 30 caractères', () => { + const long = 'a'.repeat(40) + expect(normalizeMetafieldKey(long)).toHaveLength(30) + }) + it('underscores multiples réduits à un', () => { + expect(normalizeMetafieldKey('Poids par carton')).toBe('poids_par_carton') + }) +}) + +describe('matchMetafields', () => { + const definitions: MetafieldDefinition[] = [ + { id: '1', namespace: 'custom', key: 'puissance_an', name: 'Puissance (AN)', type: 'single_line_text_field' }, + { id: '2', namespace: 'custom', key: 'coloris', name: 'Coloris', type: 'single_line_text_field' }, + ] + + it('trouve une correspondance exacte sur la clé normalisée', () => { + const result = matchMetafields(['Puissance (AN)', 'Batterie (AP)'], definitions) + expect(result.matched['Puissance (AN)'].existingDefinitionId).toBe('1') + }) + + it('met les colonnes sans correspondance dans unmatched', () => { + const result = matchMetafields(['Puissance (AN)', 'Batterie (AP)'], definitions) + expect(result.unmatched).toContain('Batterie (AP)') + }) + + it('ne met pas en unmatched les colonnes qui ont un match', () => { + const result = matchMetafields(['Puissance (AN)'], definitions) + expect(result.unmatched).not.toContain('Puissance (AN)') + }) +}) diff --git a/src/lib/shopifyMetafields.ts b/src/lib/shopifyMetafields.ts new file mode 100644 index 0000000..9c00d4b --- /dev/null +++ b/src/lib/shopifyMetafields.ts @@ -0,0 +1,104 @@ +import type { MetafieldDefinition, MetafieldResolution } from '@/types/creation' + +const API_VERSION = '2024-01' + +function getConfig() { + const domain = process.env.SHOPIFY_STORE_DOMAIN + const token = process.env.SHOPIFY_ADMIN_API_TOKEN + if (!domain || !token) throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis') + return { + baseUrl: `https://${domain}/admin/api/${API_VERSION}`, + headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' }, + } +} + +export function normalizeMetafieldKey(header: string): string { + return header + .toLowerCase() + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .replace(/_+/g, '_') + .substring(0, 30) +} + +export interface MatchResult { + matched: Record + unmatched: string[] +} + +export function matchMetafields(columnHeaders: string[], definitions: MetafieldDefinition[]): MatchResult { + const matched: Record = {} + const unmatched: string[] = [] + for (const header of columnHeaders) { + const key = normalizeMetafieldKey(header) + const def = definitions.find(d => d.key === key) + if (def) { + matched[header] = { columnHeader: header, action: 'map', namespace: def.namespace, key: def.key, existingDefinitionId: def.id } + } else { + unmatched.push(header) + } + } + return { matched, unmatched } +} + +export async function fetchMetafieldDefinitions(): Promise { + const { baseUrl, headers } = getConfig() + const res = await fetch(`${baseUrl}/metafield_definitions.json?owner_type=PRODUCT&limit=250`, { headers }) + if (!res.ok) throw new Error(`Shopify metafield definitions error: ${res.status}`) + const data = await res.json() + return (data.metafield_definitions ?? []).map((d: { + id: string | number; namespace: string; key: string; name: string; type: { name: string } | string + }) => ({ + id: String(d.id), + namespace: d.namespace, + key: d.key, + name: d.name, + type: typeof d.type === 'string' ? d.type : d.type?.name ?? 'single_line_text_field', + })) +} + +export async function ensureMetafieldDefinition(resolution: MetafieldResolution): Promise { + if (resolution.action !== 'create') return + const { baseUrl, headers } = getConfig() + const res = await fetch(`${baseUrl}/metafield_definitions.json`, { + method: 'POST', + headers, + body: JSON.stringify({ + metafield_definition: { + namespace: resolution.namespace, + key: resolution.key, + name: resolution.columnHeader, + type: 'single_line_text_field', + owner_type: 'PRODUCT', + }, + }), + }) + if (!res.ok) { + const err = await res.text() + if (!err.includes('taken') && !err.includes('already')) { + throw new Error(`Shopify create metafield definition error: ${res.status} — ${err}`) + } + } +} + +export async function setProductMetafield( + shopifyProductId: string, + resolution: MetafieldResolution, + value: string, +): Promise { + if (resolution.action === 'skip' || !value.trim()) return + const { baseUrl, headers } = getConfig() + const res = await fetch(`${baseUrl}/products/${shopifyProductId}/metafields.json`, { + method: 'POST', + headers, + body: JSON.stringify({ + metafield: { namespace: resolution.namespace, key: resolution.key, value, type: 'single_line_text_field' }, + }), + }) + if (!res.ok) { + const err = await res.text() + throw new Error(`Shopify set metafield error: ${res.status} — ${err}`) + } +}