diff --git a/client/src/lib/spreadsheet/menuSpreadsheet.test.ts b/client/src/lib/spreadsheet/menuSpreadsheet.test.ts new file mode 100644 index 0000000..77141c3 --- /dev/null +++ b/client/src/lib/spreadsheet/menuSpreadsheet.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest"; +import { buildSpreadsheetPreviewEvents } from "./menuSpreadsheet"; + +const MERCHANT_PUBKEY = "npub1merchant000000000000000000000000000000000000000000000000"; + +describe("buildSpreadsheetPreviewEvents", () => { + it("emits two 30402 events with distinct d-tags when same item is in different submenus", () => { + const menus = [ + { Name: "Lunch & Dinner", "Parent Menu": "", "Menu Type": "menu", Description: "Lunch & Dinner Menu" }, + { Name: "Brunch", "Parent Menu": "", "Menu Type": "menu", Description: "Brunch Menu" }, + { Name: "Sandos", "Parent Menu": "Lunch & Dinner", Description: "Sandos section" }, + { Name: "The 'Unch Part", "Parent Menu": "Brunch", Description: "The Unch Part section" }, + ]; + const items = [ + { + Name: "Bigfoot Burger", + Description: "A big burger", + "Part of Menu": "Lunch & Dinner", + "Part of Menu Section": "Sandos", + }, + { + Name: "Bigfoot Burger", + Description: "A big burger", + "Part of Menu": "Brunch", + "Part of Menu Section": "The 'Unch Part", + }, + ]; + + const events = buildSpreadsheetPreviewEvents({ + merchantPubkey: MERCHANT_PUBKEY, + menus, + items, + }); + + const productEvents = events.filter((e) => e.kind === 30402); + expect(productEvents).toHaveLength(2); + + const dTags = productEvents.map((e) => e.tags.find((t) => t[0] === "d")?.[1]).filter(Boolean); + expect(dTags).toHaveLength(2); + expect(dTags[0]).not.toBe(dTags[1]); + expect(new Set(dTags).size).toBe(2); + }); + + it("emits two 30402 events with distinct d-tags when two rows have identical name, menu, and section", () => { + const menus = [ + { Name: "Lunch", "Parent Menu": "", "Menu Type": "menu", Description: "Lunch" }, + { Name: "Mains", "Parent Menu": "Lunch", Description: "Mains section" }, + ]; + const items = [ + { Name: "Caesar Salad", Description: "Salad", "Part of Menu": "Lunch", "Part of Menu Section": "Mains" }, + { Name: "Caesar Salad", Description: "Salad", "Part of Menu": "Lunch", "Part of Menu Section": "Mains" }, + ]; + + const events = buildSpreadsheetPreviewEvents({ + merchantPubkey: MERCHANT_PUBKEY, + menus, + items, + }); + + const productEvents = events.filter((e) => e.kind === 30402); + expect(productEvents).toHaveLength(2); + + const dTags = productEvents.map((e) => e.tags.find((t) => t[0] === "d")?.[1]).filter(Boolean); + expect(dTags).toHaveLength(2); + expect(dTags[0]).not.toBe(dTags[1]); + expect(dTags[1]).toMatch(/-2$/); + }); + + it("emits one 30402 event with slug(name) d-tag when single row has no menu or section", () => { + const menus: Array> = []; + const items = [ + { Name: "House Coffee", Description: "Fresh brew" }, + ]; + + const events = buildSpreadsheetPreviewEvents({ + merchantPubkey: MERCHANT_PUBKEY, + menus, + items, + }); + + const productEvents = events.filter((e) => e.kind === 30402); + expect(productEvents).toHaveLength(1); + + const dTag = productEvents[0].tags.find((t) => t[0] === "d")?.[1]; + expect(dTag).toBe("house-coffee"); + }); +}); diff --git a/client/src/lib/spreadsheet/menuSpreadsheet.ts b/client/src/lib/spreadsheet/menuSpreadsheet.ts index a30f6d5..45c2480 100644 --- a/client/src/lib/spreadsheet/menuSpreadsheet.ts +++ b/client/src/lib/spreadsheet/menuSpreadsheet.ts @@ -49,6 +49,37 @@ function firstNonEmpty(...values: Array): string { return ""; } +/** + * Computes a d-tag for a menu item row so that the same item in different + * menus/sections gets a distinct identifier (supporting one 30402 event per row). + */ +function computeItemDTag( + name: string, + menu: string, + section: string, + fallbackIndex: number +): string { + const context = [section, menu].filter(Boolean).join("-"); + const base = context + ? (slugify(name) && slugify(context) ? `${slugify(name)}-${slugify(context)}` : slugify(name)) + : slugify(name); + return base || `item-${fallbackIndex + 1}`; +} + +/** Ensures each d-tag is unique; duplicates get -2, -3, ... suffix. */ +function uniquifyDTags(baseDTags: string[]): string[] { + const used = new Set(); + return baseDTags.map((d) => { + let s = d; + let c = 1; + while (used.has(s)) { + s = `${d}-${++c}`; + } + used.add(s); + return s; + }); +} + export async function parseMenuSpreadsheetXlsx(file: File): Promise<{ menus: MenuRow[]; items: MenuItemRow[]; @@ -107,19 +138,27 @@ export function buildSpreadsheetPreviewEvents(params: { titleByMenuName.set(name, description); } - // Build 30402 items first and keep a map name -> dTag for collection a-tags. + const baseDTags = items.map((row, i) => + computeItemDTag( + asString(row.Name), + asString((row as any)["Part of Menu"]), + asString((row as any)["Part of Menu Section"]), + i + ) + ); + const finalDTags = uniquifyDTags(baseDTags); + + // Build 30402 items first; each row gets its own d-tag for distinct events per menu/section. const itemEvents: SquareEventTemplate[] = []; - const dTagByItemName = new Map(); - for (const row of items) { + for (let i = 0; i < items.length; i++) { + const row = items[i]; const name = asString(row.Name); if (!name) continue; const description = asString(row.Description); const content = `**${name}**\n\n${description}`.trim(); - const dTag = slugify(name) || `item-${itemEvents.length + 1}`; - dTagByItemName.set(name, dTag); - + const dTag = finalDTags[i]; const tags: string[][] = []; tags.push(["d", dTag]); tags.push(["title", name]); @@ -180,11 +219,11 @@ export function buildSpreadsheetPreviewEvents(params: { // Build membership map: collection name -> item dTags const collectionToProductDTags = new Map(); - for (const row of items) { + for (let i = 0; i < items.length; i++) { + const row = items[i]; const name = asString(row.Name); if (!name) continue; - const dTag = dTagByItemName.get(name); - if (!dTag) continue; + const dTag = finalDTags[i]; const menu = asString((row as any)["Part of Menu"]); const section = asString((row as any)["Part of Menu Section"]);