6f4affcd66
- table-fixed avec colgroup pour éviter que la colonne Ref écrase les autres - overflow-x-auto pour permettre le scroll si nécessaire - parseSheetRows : ignore les refs invalides (< 2 chars, caractères spéciaux, mots réservés) - parseSheetRows : ignore les lignes sans prix valide (séparateurs, notes) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
97 lines
3.3 KiB
TypeScript
97 lines
3.3 KiB
TypeScript
import type { SyncProduct } from '@/types/sync'
|
|
|
|
function normalizeHandle(ref: string): string {
|
|
return ref
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
}
|
|
|
|
/**
|
|
* Parse des lignes brutes Google Sheets en SyncProduct[].
|
|
* Colonnes : A=Référence, B=Nom, C=Description, D=Prix, E=Stock, F=Statut
|
|
* Exporté pour les tests unitaires.
|
|
*/
|
|
const HEADER_WORDS = new Set(['id', 'ref', 'sku', 'nom', 'titre', 'reference', 'prix', 'statut'])
|
|
|
|
function isValidRef(ref: string): boolean {
|
|
if (ref.length < 2) return false
|
|
if (/^[^a-zA-Z0-9]+$/.test(ref)) return false // que des caractères spéciaux
|
|
if (HEADER_WORDS.has(ref.toLowerCase())) return false
|
|
return true
|
|
}
|
|
|
|
export function parseSheetRows(rows: string[][]): SyncProduct[] {
|
|
return rows
|
|
.map((row): SyncProduct | null => {
|
|
const ref = row[0]?.trim()
|
|
if (!ref || !isValidRef(ref)) return null
|
|
|
|
const rawPrice = (row[3] ?? '').replace(',', '.').trim()
|
|
const price = parseFloat(rawPrice || '0')
|
|
|
|
// Ligne sans prix valide = séparateur ou note, pas un produit
|
|
if (isNaN(price) || price <= 0) return null
|
|
|
|
return {
|
|
ref,
|
|
handle: normalizeHandle(ref),
|
|
title: row[1]?.trim() || ref,
|
|
description: row[2]?.trim() || '',
|
|
price: price.toFixed(2),
|
|
stock: parseInt(row[4] ?? '0', 10) || 0,
|
|
status: (row[5] ?? '').toLowerCase().includes('inac') ? 'draft' : 'active',
|
|
}
|
|
})
|
|
.filter((p): p is SyncProduct => p !== null)
|
|
}
|
|
|
|
/**
|
|
* Lit tous les produits depuis Google Sheets via l'API v4 (API Key publique).
|
|
* Si GOOGLE_SHEETS_TAB est défini, lit uniquement cet onglet.
|
|
* Sinon, lit tous les onglets du fichier et combine les produits.
|
|
* Le fichier Sheets doit être partagé en lecture ("Toute personne avec le lien").
|
|
*/
|
|
export async function readSheetProducts(): Promise<SyncProduct[]> {
|
|
const apiKey = process.env.GOOGLE_API_KEY
|
|
const sheetId = process.env.GOOGLE_SHEETS_ID
|
|
const singleTab = process.env.GOOGLE_SHEETS_TAB
|
|
|
|
if (!apiKey || !sheetId) {
|
|
throw new Error('Variables GOOGLE_API_KEY et GOOGLE_SHEETS_ID requises')
|
|
}
|
|
|
|
const sheetNames = singleTab ? [singleTab] : await listSheetTabs(sheetId, apiKey)
|
|
|
|
const results = await Promise.all(
|
|
sheetNames.map((name) => readOneTab(sheetId, apiKey, name)),
|
|
)
|
|
|
|
return results.flat()
|
|
}
|
|
|
|
async function listSheetTabs(sheetId: string, apiKey: string): Promise<string[]> {
|
|
const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`
|
|
const res = await fetch(url)
|
|
if (!res.ok) {
|
|
const err = await res.text()
|
|
throw new Error(`Google Sheets API error: ${res.status} — ${err}`)
|
|
}
|
|
const data = await res.json()
|
|
return (data.sheets as Array<{ properties: { title: string } }>).map(
|
|
(s) => s.properties.title,
|
|
)
|
|
}
|
|
|
|
async function readOneTab(sheetId: string, apiKey: string, sheetName: string): Promise<SyncProduct[]> {
|
|
const range = encodeURIComponent(`${sheetName}!A2:F`)
|
|
const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`
|
|
const res = await fetch(url)
|
|
if (!res.ok) {
|
|
const err = await res.text()
|
|
throw new Error(`Google Sheets API error (onglet "${sheetName}"): ${res.status} — ${err}`)
|
|
}
|
|
const data = await res.json()
|
|
return parseSheetRows((data.values ?? []) as string[][])
|
|
}
|