feat: client Shopify sync (fetch all products + CRUD)
This commit is contained in:
@@ -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<ShopifyProductFull[]> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user