// Turns the compact $app:configuration JSON (spec/offer-config.schema.json) into engine offers. // Ids in the config are the numeric part of Shopify GIDs; the function compares numeric ids. export function numericId(gid) { if (gid == null) return ''; const s = String(gid); const i = s.lastIndexOf('/'); return i >= 0 ? s.slice(i + 1) : s; } function toScope(sc) { return { variantIds: new Set(sc?.v || []), productIds: new Set(sc?.p || []), collectionIds: new Set(sc?.c || []), }; } /** Money values in the config are in shop currency; cart prices are in presentment currency. */ function convertValue(o, rate) { if (o.dt === 'amount' || o.dt === 'fixed_price') return o.dv * rate; return o.dv; } /** * @param {object} cfg parsed configuration * @param {{rate:number, country?:string, pastGuard?:boolean, guardAt?:string}} ctx */ export function offersFromConfig(cfg, ctx) { const rate = ctx.rate > 0 ? ctx.rate : 1; const out = []; for (const o of cfg.offers || []) { // Safety net for a late scheduler: once the guard time has passed, drop every offer that ends at or // before it. The app republishes the configuration at each boundary; this only covers the gap. if (ctx.pastGuard && o.e && cfg.g && o.e <= cfg.g) continue; if (o.cc && o.cc.length && (!ctx.country || !o.cc.includes(ctx.country))) continue; const base = { id: o.id, type: o.t, maxUses: o.mu || 0 }; if (o.t === 'fixed') { out.push({ ...base, discountType: o.dt, value: convertValue(o, rate), lines: (o.l || []).map((l) => ({ variantId: l.v, productId: l.v ? undefined : l.p, quantity: l.q })) }); } else if (o.t === 'mix') { out.push({ ...base, discountType: o.dt, value: convertValue(o, rate), qty: o.q, scope: toScope(o.sc) }); } else if (o.t === 'bxgy') { out.push({ ...base, buyQty: o.bq, getQty: o.gq, getPercent: o.gp, buyScope: toScope(o.bs), getScope: toScope(o.gs) }); } } return out; }