diff --git a/.gitignore b/.gitignore index 612f7c45..38b8f288 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ vite.config.js.timestamp-* vite.config.ts.timestamp-* # USDA bulk data downloads (regenerated by scripts/import-usda-nutrition.ts) data/usda/ +# Loyalty-card barcodes (regenerated by scripts/generate-loyalty-cards.ts from env) +static/shopping/supercard.svg +static/shopping/cumulus.svg src-tauri/target/ src-tauri/*.keystore # Android: ignore build output and caches, track source files diff --git a/package.json b/package.json index c5eeb96f..ba112df8 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "homepage", - "version": "1.46.28", + "version": "1.47.0", "private": true, "type": "module", "scripts": { "dev": "vite dev", - "prebuild": "bash scripts/subset-emoji-font.sh && pnpm exec vite-node scripts/generate-mystery-verses.ts && pnpm exec vite-node scripts/download-models.ts", + "prebuild": "bash scripts/subset-emoji-font.sh && pnpm exec vite-node scripts/generate-mystery-verses.ts && pnpm exec vite-node scripts/download-models.ts && pnpm exec vite-node scripts/generate-loyalty-cards.ts", "build": "vite build", "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", @@ -37,6 +37,7 @@ "@types/node": "^22.12.0", "@types/node-cron": "^3.0.11", "@vitest/ui": "^4.1.2", + "bwip-js": "^4.10.1", "jsdom": "^27.2.0", "svelte": "^5.55.1", "svelte-check": "^4.4.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 940d3934..aa8efce3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: '@vitest/ui': specifier: ^4.1.2 version: 4.1.2(vitest@4.1.2) + bwip-js: + specifier: ^4.10.1 + version: 4.10.1 jsdom: specifier: ^27.2.0 version: 27.2.0 @@ -1194,6 +1197,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + bwip-js@4.10.1: + resolution: {integrity: sha512-I/cEPiXsu7dRCp78PpVY4gdIXmbH752n8dMC+DStM77XPkrzeathdYrjnZ/i/vZPIxXTUWc+JxgJ/MvbodqPLA==} + hasBin: true + cac@7.0.0: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} @@ -2952,6 +2959,8 @@ snapshots: buffer-from@1.1.2: optional: true + bwip-js@4.10.1: {} + cac@7.0.0: {} chai@6.2.2: {} diff --git a/scripts/generate-loyalty-cards.ts b/scripts/generate-loyalty-cards.ts new file mode 100644 index 00000000..28ca0497 --- /dev/null +++ b/scripts/generate-loyalty-cards.ts @@ -0,0 +1,56 @@ +/** + * Build-time generation of loyalty-card barcode SVGs. + * + * Reads card numbers from env vars and writes static/shopping/supercard.svg + * + static/shopping/cumulus.svg. Skips cards whose env var is unset so the + * site still builds in environments without secrets. + * + * SHOPPING_COOP_SUPERCARD_NUMBER → Data Matrix (Coop Supercard) + * SHOPPING_MIGROS_CUMULUS_NUMBER → Code 128 (Migros Cumulus) + * + * Run: pnpm exec vite-node scripts/generate-loyalty-cards.ts + */ +import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { toSVG } from 'bwip-js/node'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const OUT_DIR = resolve(HERE, '..', 'static', 'shopping'); + +type CardSpec = { + envVar: string; + filename: string; + bcid: 'datamatrix' | 'code128'; + scale: number; +}; + +const cards: CardSpec[] = [ + { envVar: 'SHOPPING_COOP_SUPERCARD_NUMBER', filename: 'supercard.svg', bcid: 'datamatrix', scale: 6 }, + { envVar: 'SHOPPING_MIGROS_CUMULUS_NUMBER', filename: 'cumulus.svg', bcid: 'code128', scale: 3 } +]; + +mkdirSync(OUT_DIR, { recursive: true }); + +for (const card of cards) { + const value = process.env[card.envVar]?.trim(); + const outPath = resolve(OUT_DIR, card.filename); + + if (!value) { + try { rmSync(outPath); } catch { /* not present */ } + console.log(`[loyalty-cards] ${card.envVar} not set — skipped ${card.filename}`); + continue; + } + + const svg = toSVG({ + bcid: card.bcid, + text: value, + scale: card.scale, + includetext: false, + paddingwidth: 8, + paddingheight: 8 + }); + + writeFileSync(outPath, svg, 'utf8'); + console.log(`[loyalty-cards] wrote ${card.filename} (${card.bcid})`); +} diff --git a/src/lib/components/shopping/LoyaltyCards.svelte b/src/lib/components/shopping/LoyaltyCards.svelte new file mode 100644 index 00000000..e949f1a6 --- /dev/null +++ b/src/lib/components/shopping/LoyaltyCards.svelte @@ -0,0 +1,162 @@ + + + + +{#if open} + + +
+ +
+{/if} + + diff --git a/src/routes/[cospendRoot=cospendRoot]/list/+page.server.ts b/src/routes/[cospendRoot=cospendRoot]/list/+page.server.ts index 77022c25..99cbf14c 100644 --- a/src/routes/[cospendRoot=cospendRoot]/list/+page.server.ts +++ b/src/routes/[cospendRoot=cospendRoot]/list/+page.server.ts @@ -1,5 +1,7 @@ import type { PageServerLoad } from './$types'; import { redirect } from '@sveltejs/kit'; +import { existsSync } from 'node:fs'; +import { resolveStaticAsset } from '$lib/server/staticAsset'; import { getShoppingUser } from '$lib/server/shoppingAuth'; import { dbConnect } from '$utils/db'; import { ShoppingList, type IShoppingItem } from '$models/ShoppingList'; @@ -12,6 +14,13 @@ function serializeItems(items: IShoppingItem[]): ShoppingItem[] { })); } +function loyaltyCards() { + return { + hasSupercard: existsSync(resolveStaticAsset('shopping/supercard.svg')), + hasCumulus: existsSync(resolveStaticAsset('shopping/cumulus.svg')) + }; +} + export const load: PageServerLoad = async ({ locals, url }) => { const session = locals.session ?? await locals.auth(); const token = url.searchParams.get('token'); @@ -25,7 +34,8 @@ export const load: PageServerLoad = async ({ locals, url }) => { return { session: null, shareToken: token, - initialList: list ? { version: list.version, items: serializeItems(list.items) } : { version: 0, items: [] as ShoppingItem[] } + initialList: list ? { version: list.version, items: serializeItems(list.items) } : { version: 0, items: [] as ShoppingItem[] }, + loyalty: loyaltyCards() }; } } @@ -37,6 +47,7 @@ export const load: PageServerLoad = async ({ locals, url }) => { return { session, shareToken: null, - initialList: list ? { version: list.version, items: serializeItems(list.items) } : { version: 0, items: [] as ShoppingItem[] } + initialList: list ? { version: list.version, items: serializeItems(list.items) } : { version: 0, items: [] as ShoppingItem[] }, + loyalty: loyaltyCards() }; }; diff --git a/src/routes/[cospendRoot=cospendRoot]/list/+page.svelte b/src/routes/[cospendRoot=cospendRoot]/list/+page.svelte index bb30311b..8f2fc8b1 100644 --- a/src/routes/[cospendRoot=cospendRoot]/list/+page.svelte +++ b/src/routes/[cospendRoot=cospendRoot]/list/+page.svelte @@ -26,6 +26,8 @@ import iconCategoriesData from '$lib/data/shoppingIconCategories.json'; import Share2 from '@lucide/svelte/icons/share-2'; + import CreditCard from '@lucide/svelte/icons/credit-card'; + import LoyaltyCards from '$lib/components/shopping/LoyaltyCards.svelte'; import X from '@lucide/svelte/icons/x'; @@ -293,6 +295,12 @@ editSaving = false; } + // --- Loyalty cards --- + let showLoyalty = $state(false); + const hasSupercard = $derived(!!data.loyalty?.hasSupercard); + const hasCumulus = $derived(!!data.loyalty?.hasCumulus); + const hasAnyCard = $derived(hasSupercard || hasCumulus); + // --- Share links --- let showShareModal = $state(false); /** @type {{ id: string, token: string, expiresAt: string, createdBy: string, createdAt: string }[]} */ @@ -421,6 +429,11 @@

{t('shopping_list_title', lang)}

+ {#if hasAnyCard} + + {/if} {#if !isGuest}
+ + {#if editingItem}