Skip to content

Commit 22634df

Browse files
ekvanoxCopilot
andauthored
Consume endpoint (#895)
* feat: Add QR code scanning for ticket consumption Implements a complete QR code scanning system for validating and consuming event tickets: Features: - QR code scanner component using @zxing/library - Event-specific scan pages at /events/[slug]/scan - Consumable detail view with validation status - Admin QR code management page at /admin/qr - Ticket consumption tracking and history - Support for scanning from inventory page Changes: - Add QRCodeScanner.svelte component with camera access - Add useQRScanner hook for QR code detection - Add consumable.ts server utilities for ticket consumption - Add scan routes for events with ticket validation - Add consume action to mark tickets as used - Add QR code admin page to routes - Filter events with tickets in event listing - Update translations for scanning UI - Add @zxing/library dependency The system validates tickets against the event, checks consumption status, displays owner information, and shows question responses if available. * fix: resolve linting and formatting issues * feat: add visual indication of recent consumption * fix: missing translations * chore: remove snaplet config files * chore: reset config.json to default * feat: minor improvements * Update src/routes/(app)/events/[slug]/scan/[consumable]/+page.svelte Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent ba54b5a commit 22634df

21 files changed

Lines changed: 1008 additions & 29 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@
111111
"@vercel/speed-insights": "^1.1.0",
112112
"@zenstackhq/runtime": "^2.10.0",
113113
"@zenstackhq/server": "^2.10.0",
114+
"@zxing/library": "^0.21.3",
114115
"browser-image-compression": "^2.0.2",
115116
"croppie": "^2.6.5",
116117
"dayjs": "^1.11.13",

pnpm-lock.yaml

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<script lang="ts">
2+
import { useQRScanner } from "$lib/hooks/useQRScanner";
3+
import * as m from "$paraglide/messages";
4+
5+
interface Props {
6+
onScan: (text: string) => void;
7+
}
8+
9+
let { onScan }: Props = $props();
10+
11+
let videoElement: HTMLVideoElement | undefined = $state();
12+
let errorMessage = $state("");
13+
14+
const { initialize } = useQRScanner();
15+
16+
$effect(() => {
17+
if (videoElement) {
18+
initialize(videoElement, (text) => {
19+
onScan(text);
20+
}).then((result) => {
21+
if (result.error) {
22+
errorMessage = result.error;
23+
}
24+
});
25+
}
26+
});
27+
</script>
28+
29+
<div class="w-100 flex justify-center p-4">
30+
<div class="w-100 max-w-500px">
31+
{#if errorMessage}
32+
<p class="p-4 text-center text-red-500">
33+
{m.events_camera_init_failure()}<br />
34+
Error: {errorMessage}
35+
</p>
36+
{:else}
37+
<video bind:this={videoElement} class="w-100" playsinline>
38+
<track kind="captions" src="" label="English captions" srclang="en" />
39+
</video>
40+
{/if}
41+
</div>
42+
</div>

src/lib/components/shop/inventory/InventoryItemPage.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import { eventLink } from "$lib/utils/redirect";
88
import Price from "$lib/components/Price.svelte";
99
import type { InventoryItemLoadData } from "$lib/server/shop/inventory/getInventory";
10-
import type { page } from "$app/stores";
10+
import { page } from "$app/stores";
1111
import { getFileUrl } from "$lib/files/client";
1212
import SEO from "$lib/seo/SEO.svelte";
1313
@@ -107,6 +107,6 @@
107107
</section>
108108
{/if}
109109

110-
<QRCode data={shoppable.title} />
110+
<QRCode data={consumable.id} />
111111
</main>
112112
</div>

src/lib/hooks/useQRScanner.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { BrowserMultiFormatReader } from "@zxing/library";
2+
import { onDestroy } from "svelte";
3+
4+
export function useQRScanner() {
5+
const codeReader = new BrowserMultiFormatReader();
6+
7+
const initialize = async (
8+
videoElement: HTMLVideoElement,
9+
onResult?: (text: string) => void,
10+
): Promise<{ error?: string }> => {
11+
try {
12+
const videoInputDevices = await codeReader.listVideoInputDevices();
13+
console.log("Available devices:", videoInputDevices);
14+
15+
if (videoInputDevices.length === 0) {
16+
throw new Error("No camera devices found");
17+
}
18+
19+
// Try to get the back camera first, then front camera, then first available camera
20+
const selectedDevice =
21+
videoInputDevices.find((device) =>
22+
device.label.toLowerCase().includes("back"),
23+
) ||
24+
videoInputDevices.find((device) =>
25+
device.label.toLowerCase().includes("front"),
26+
) ||
27+
videoInputDevices[0];
28+
29+
if (!selectedDevice) throw new Error("No suitable camera device found");
30+
31+
await codeReader.decodeFromVideoDevice(
32+
selectedDevice.deviceId,
33+
videoElement,
34+
(result) => {
35+
if (result && onResult) {
36+
onResult(result.getText());
37+
}
38+
},
39+
);
40+
41+
return {};
42+
} catch (error) {
43+
console.error("Camera initialization error:", error);
44+
return { error: `Camera error: ${error}` };
45+
}
46+
};
47+
48+
const reset = () => {
49+
codeReader.reset();
50+
};
51+
52+
// Cleanup when component is destroyed
53+
onDestroy(() => {
54+
reset();
55+
});
56+
57+
return {
58+
initialize,
59+
reset,
60+
};
61+
}

src/lib/server/shop/consumable.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { ExtendedPrisma } from "$lib/server/extendedPrisma";
2+
3+
export const consumeConsumable = async (
4+
prisma: ExtendedPrisma,
5+
consumableId: string,
6+
): Promise<Message> => {
7+
try {
8+
await prisma.consumable.update({
9+
where: {
10+
id: consumableId,
11+
},
12+
data: {
13+
consumedAt: new Date(),
14+
},
15+
});
16+
} catch (e) {
17+
if (e instanceof Error)
18+
return {
19+
message: e.message,
20+
type: "error",
21+
};
22+
return {
23+
message: "Kunde inte konsumera biljetten.",
24+
type: "error",
25+
};
26+
}
27+
return {
28+
message: "Biljetten har konsumerats.",
29+
type: "success",
30+
};
31+
};
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { BASIC_EVENT_FILTER } from "$lib/events/events";
2+
import apiNames from "$lib/utils/apiNames";
3+
import { authorize } from "$lib/utils/authorization";
4+
import { redirect } from "$lib/utils/redirect";
5+
import type { PageServerLoad } from "./$types";
6+
7+
export const load: PageServerLoad = async ({ locals, url }) => {
8+
const { prisma, user } = locals;
9+
authorize(apiNames.EVENT.READ, user);
10+
11+
// Get page number from URL query params (default to 1)
12+
const page = parseInt(url.searchParams.get("page") || "1");
13+
const pageSize = 12;
14+
15+
// Calculate pagination values
16+
const skip = (page - 1) * pageSize;
17+
18+
// Get total count for pagination
19+
const totalEvents = await prisma.event.count({
20+
where: {
21+
...BASIC_EVENT_FILTER(),
22+
// Only include events with tickets available
23+
tickets: {
24+
some: {
25+
stock: {
26+
gt: 0,
27+
},
28+
},
29+
},
30+
},
31+
});
32+
33+
// Calculate total pages
34+
const totalPages = Math.ceil(totalEvents / pageSize);
35+
36+
// Fetch paginated events
37+
const events = await prisma.event.findMany({
38+
where: {
39+
...BASIC_EVENT_FILTER(),
40+
tickets: {
41+
some: {
42+
stock: {
43+
gt: 0,
44+
},
45+
},
46+
},
47+
},
48+
orderBy: {
49+
startDatetime: "desc",
50+
},
51+
skip,
52+
take: pageSize,
53+
include: {
54+
tags: true,
55+
going: {
56+
select: {
57+
id: true,
58+
},
59+
},
60+
interested: {
61+
select: {
62+
id: true,
63+
},
64+
},
65+
author: true,
66+
},
67+
});
68+
69+
return {
70+
events,
71+
pagination: {
72+
currentPage: page,
73+
totalPages,
74+
totalEvents,
75+
hasNextPage: page < totalPages,
76+
hasPrevPage: page > 1,
77+
},
78+
};
79+
};
80+
81+
export const actions = {
82+
async selectEvent({ request }) {
83+
const formData = await request.formData();
84+
const eventSlug = formData.get("eventSlug")?.toString();
85+
86+
if (!eventSlug) {
87+
return { success: false, message: "No event slug provided" };
88+
}
89+
90+
// Redirect to the event's QR page
91+
throw redirect(303, `/events/${eventSlug}/scan`);
92+
},
93+
};

0 commit comments

Comments
 (0)