diff --git a/src/lib/shopifySync.ts b/src/lib/shopifySync.ts new file mode 100644 index 0000000..3c6fd2a --- /dev/null +++ b/src/lib/shopifySync.ts @@ -0,0 +1,171 @@ +import type { ShopifyProductFull, SyncProduct } from '@/types/sync' + +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', + }, + } +} + +interface ShopifyVariant { + id: number + price: string + sku: string + inventory_quantity: number +} + +interface ShopifyAPIProduct { + id: number + title: string + handle: string + body_html: string + status: string + variants: ShopifyVariant[] +} + +function toSyncProduct(p: ShopifyAPIProduct): ShopifyProductFull { + return { + shopifyId: String(p.id), + ref: p.variants[0]?.sku || p.handle, + handle: p.handle, + title: p.title, + description: p.body_html ?? '', + price: p.variants[0]?.price ?? '0.00', + stock: p.variants[0]?.inventory_quantity ?? 0, + status: p.status === 'active' ? 'active' : 'draft', + } +} + +function extractNextUrl(linkHeader: string | null): string | null { + if (!linkHeader) return null + const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/) + return match ? match[1] : null +} + +/** + * Récupère tous les produits Shopify (actifs + drafts), en paginant. + * Limite : 250 par page via cursor-based pagination. + */ +export async function fetchAllShopifyProducts(): Promise { + const { baseUrl, headers } = getConfig() + const all: ShopifyProductFull[] = [] + + let url: string | null = + `${baseUrl}/products.json?limit=250&fields=id,title,handle,status,body_html,variants&status=any` + + while (url) { + const res = await fetch(url, { headers }) + if (!res.ok) throw new Error(`Shopify fetch error: ${res.status}`) + const data = await res.json() + all.push(...(data.products as ShopifyAPIProduct[]).map(toSyncProduct)) + url = extractNextUrl(res.headers.get('Link')) + } + + return all +} + +/** + * Crée un nouveau produit Shopify depuis un SyncProduct. + * Retourne l'ID Shopify créé. + */ +export async function createShopifyProduct(product: SyncProduct): Promise { + const { baseUrl, headers } = getConfig() + + const body = { + product: { + title: product.title, + body_html: product.description, + handle: product.handle, + status: product.status, + variants: [{ price: product.price, sku: product.ref }], + }, + } + + const res = await fetch(`${baseUrl}/products.json`, { + method: 'POST', + headers, + body: JSON.stringify(body), + }) + + if (!res.ok) { + const err = await res.text() + throw new Error(`Shopify createProduct error: ${res.status} — ${err}`) + } + + const data = await res.json() + return String(data.product.id) +} + +/** + * Met à jour un produit Shopify existant (titre, description, prix, statut). + */ +export async function updateShopifyProduct( + shopifyId: string, + product: SyncProduct, + variantId: string, +): Promise { + const { baseUrl, headers } = getConfig() + + const body = { + product: { + id: shopifyId, + title: product.title, + body_html: product.description, + status: product.status, + variants: [{ id: variantId, price: product.price }], + }, + } + + const res = await fetch(`${baseUrl}/products/${shopifyId}.json`, { + method: 'PUT', + headers, + body: JSON.stringify(body), + }) + + if (!res.ok) { + const err = await res.text() + throw new Error(`Shopify updateProduct error: ${res.status} — ${err}`) + } +} + +/** + * Passe un produit Shopify en draft (désactivation — jamais de suppression). + */ +export async function deactivateShopifyProduct(shopifyId: string): Promise { + const { baseUrl, headers } = getConfig() + + const res = await fetch(`${baseUrl}/products/${shopifyId}.json`, { + method: 'PUT', + headers, + body: JSON.stringify({ product: { id: shopifyId, status: 'draft' } }), + }) + + if (!res.ok) { + const err = await res.text() + throw new Error(`Shopify deactivateProduct error: ${res.status} — ${err}`) + } +} + +/** + * Récupère l'ID de la première variante d'un produit Shopify. + * Nécessaire pour mettre à jour le prix (update variant). + */ +export async function getFirstVariantId(shopifyId: string): Promise { + const { baseUrl, headers } = getConfig() + const res = await fetch(`${baseUrl}/products/${shopifyId}/variants.json?limit=1`, { headers }) + if (!res.ok) throw new Error(`Shopify getVariants error: ${res.status}`) + const data = await res.json() + const variant = data.variants?.[0] + if (!variant) throw new Error(`Aucune variante pour le produit ${shopifyId}`) + return String(variant.id) +}