feat(shopping): loyalty-card modal with build-time barcodes
Adds a CreditCard button on the shopping list that opens a modal showing the user's Coop Supercard (Data Matrix) and Migros Cumulus (Code 128). Card numbers come from SHOPPING_COOP_SUPERCARD_NUMBER and SHOPPING_MIGROS_CUMULUS_NUMBER env vars; a prebuild script renders each to an SVG (~1-2 kB) in static/shopping/ so no barcode library ships to the client. Cards missing their env var are silently skipped, and the generated SVGs are gitignored to keep personal numbers out of the repo.
This commit is contained in:
@@ -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
|
||||
|
||||
+3
-2
@@ -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",
|
||||
|
||||
Generated
+9
@@ -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: {}
|
||||
|
||||
@@ -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})`);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
|
||||
let { open = $bindable(false), hasSupercard = false, hasCumulus = false } = $props<{
|
||||
open?: boolean;
|
||||
hasSupercard?: boolean;
|
||||
hasCumulus?: boolean;
|
||||
}>();
|
||||
|
||||
function close() { open = false; }
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="backdrop" onclick={close}>
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-label="Kundenkarten" tabindex="-1" onclick={(e) => e.stopPropagation()}>
|
||||
<button class="close" onclick={close} aria-label="Schliessen">
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
{#if hasSupercard}
|
||||
<article class="card supercard">
|
||||
<header>
|
||||
<span class="brand">SUPERCARD</span>
|
||||
<span class="sub">Coop</span>
|
||||
</header>
|
||||
<div class="barcode barcode-square">
|
||||
<img src="/shopping/supercard.svg" alt="Supercard Data Matrix" />
|
||||
</div>
|
||||
</article>
|
||||
{/if}
|
||||
|
||||
{#if hasCumulus}
|
||||
<article class="card cumulus">
|
||||
<header>
|
||||
<span class="brand">CUMULUS</span>
|
||||
<span class="sub">Migros</span>
|
||||
</header>
|
||||
<div class="barcode barcode-linear">
|
||||
<img src="/shopping/cumulus.svg" alt="Cumulus barcode" />
|
||||
</div>
|
||||
</article>
|
||||
{/if}
|
||||
|
||||
{#if !hasSupercard && !hasCumulus}
|
||||
<p class="empty">Keine Karten konfiguriert.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
.modal {
|
||||
position: relative;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 20px;
|
||||
padding: 1.25rem;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 0.6rem;
|
||||
right: 0.6rem;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
}
|
||||
.close:hover { background: rgba(0, 0, 0, 0.3); }
|
||||
|
||||
.card {
|
||||
border-radius: 16px;
|
||||
padding: 1rem 1.1rem 1.1rem;
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.supercard {
|
||||
background: linear-gradient(135deg, #0a6fc2 0%, #055a9e 100%);
|
||||
}
|
||||
.cumulus {
|
||||
background: linear-gradient(135deg, #ff6a00 0%, #e55300 100%);
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.brand {
|
||||
font-weight: 800;
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.sub {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.barcode {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.barcode-square img {
|
||||
width: min(220px, 60%);
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
.barcode-linear img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 90px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 1rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@@ -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()
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 @@
|
||||
<div class="header-row">
|
||||
<h1 class="sr-only">{t('shopping_list_title', lang)}</h1>
|
||||
<SyncIndicator status={sync.status} />
|
||||
{#if hasAnyCard}
|
||||
<button class="btn-share" onclick={() => showLoyalty = true} title="Kundenkarten" aria-label="Kundenkarten">
|
||||
<CreditCard size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if !isGuest}
|
||||
<button class="btn-share" onclick={openShareModal} title={t('share', lang)}>
|
||||
<Share2 size={16} />
|
||||
@@ -517,6 +530,8 @@
|
||||
|
||||
</div>
|
||||
|
||||
<LoyaltyCards bind:open={showLoyalty} {hasSupercard} {hasCumulus} />
|
||||
|
||||
{#if editingItem}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
|
||||
Reference in New Issue
Block a user