Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
"@vercel/speed-insights": "^1.1.0",
"@zenstackhq/runtime": "^2.10.0",
"@zenstackhq/server": "^2.10.0",
"@zxing/library": "^0.21.3",
"browser-image-compression": "^2.0.2",
"croppie": "^2.6.5",
"dayjs": "^1.11.13",
Expand Down
19 changes: 19 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 42 additions & 0 deletions src/lib/components/QRCodeScanner.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<script lang="ts">
import { useQRScanner } from "$lib/hooks/useQRScanner";
import * as m from "$paraglide/messages";

interface Props {
onScan: (text: string) => void;
}

let { onScan }: Props = $props();

let videoElement: HTMLVideoElement | undefined = $state();
let errorMessage = $state("");

const { initialize } = useQRScanner();

$effect(() => {
if (videoElement) {
initialize(videoElement, (text) => {
onScan(text);
}).then((result) => {
if (result.error) {
errorMessage = result.error;
}
});
}
});
</script>

<div class="w-100 flex justify-center p-4">
<div class="w-100 max-w-500px">
{#if errorMessage}
<p class="p-4 text-center text-red-500">
{m.events_camera_init_failure()}<br />
Error: {errorMessage}
</p>
{:else}
<video bind:this={videoElement} class="w-100" playsinline>
<track kind="captions" src="" label="English captions" srclang="en" />
</video>
{/if}
</div>
</div>
4 changes: 2 additions & 2 deletions src/lib/components/shop/inventory/InventoryItemPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import { eventLink } from "$lib/utils/redirect";
import Price from "$lib/components/Price.svelte";
import type { InventoryItemLoadData } from "$lib/server/shop/inventory/getInventory";
import type { page } from "$app/stores";
import { page } from "$app/stores";
Copy link

Copilot AI Oct 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The page import is added but doesn't appear to be used in the visible code changes. Consider removing unused imports unless they're used elsewhere in the file.

Suggested change
import { page } from "$app/stores";

Copilot uses AI. Check for mistakes.
import { getFileUrl } from "$lib/files/client";
import SEO from "$lib/seo/SEO.svelte";

Expand Down Expand Up @@ -107,6 +107,6 @@
</section>
{/if}

<QRCode data={shoppable.title} />
<QRCode data={consumable.id} />
</main>
</div>
61 changes: 61 additions & 0 deletions src/lib/hooks/useQRScanner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { BrowserMultiFormatReader } from "@zxing/library";
import { onDestroy } from "svelte";

export function useQRScanner() {
const codeReader = new BrowserMultiFormatReader();

const initialize = async (
videoElement: HTMLVideoElement,
onResult?: (text: string) => void,
): Promise<{ error?: string }> => {
try {
const videoInputDevices = await codeReader.listVideoInputDevices();
console.log("Available devices:", videoInputDevices);

if (videoInputDevices.length === 0) {
throw new Error("No camera devices found");
}

// Try to get the back camera first, then front camera, then first available camera
const selectedDevice =
videoInputDevices.find((device) =>
device.label.toLowerCase().includes("back"),
) ||
videoInputDevices.find((device) =>
device.label.toLowerCase().includes("front"),
) ||
videoInputDevices[0];

if (!selectedDevice) throw new Error("No suitable camera device found");

await codeReader.decodeFromVideoDevice(
selectedDevice.deviceId,
videoElement,
(result) => {
if (result && onResult) {
onResult(result.getText());
}
},
);

return {};
} catch (error) {
console.error("Camera initialization error:", error);
return { error: `Camera error: ${error}` };
}
};

const reset = () => {
codeReader.reset();
};

// Cleanup when component is destroyed
onDestroy(() => {
reset();
});

return {
initialize,
reset,
};
}
31 changes: 31 additions & 0 deletions src/lib/server/shop/consumable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { PrismaClient } from "@prisma/client";

export const consumeConsumable = async (
prisma: PrismaClient,
consumableId: string,
): Promise<Message> => {
Copy link

Copilot AI Oct 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Message type is not imported or defined in this file. This will cause a TypeScript compilation error.

Copilot uses AI. Check for mistakes.
try {
await prisma.consumable.update({
where: {
id: consumableId,
},
data: {
consumedAt: new Date(),
},
});
} catch (e) {
if (e instanceof Error)
return {
message: e.message,
type: "error",
};
return {
message: "Kunde inte konsumera biljetten.",
type: "error",
};
}
return {
message: "Biljetten har konsumerats.",
type: "success",
};
};
93 changes: 93 additions & 0 deletions src/routes/(app)/admin/qr/+page.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { BASIC_EVENT_FILTER } from "$lib/events/events";
import apiNames from "$lib/utils/apiNames";
import { authorize } from "$lib/utils/authorization";
import { redirect } from "$lib/utils/redirect";
import type { PageServerLoad } from "./$types";

export const load: PageServerLoad = async ({ locals, url }) => {
const { prisma, user } = locals;
authorize(apiNames.EVENT.READ, user);

// Get page number from URL query params (default to 1)
const page = parseInt(url.searchParams.get("page") || "1");
const pageSize = 12;

// Calculate pagination values
const skip = (page - 1) * pageSize;

// Get total count for pagination
const totalEvents = await prisma.event.count({
where: {
...BASIC_EVENT_FILTER(),
// Only include events with tickets available
tickets: {
some: {
stock: {
gt: 0,
},
},
},
},
});

// Calculate total pages
const totalPages = Math.ceil(totalEvents / pageSize);

// Fetch paginated events
const events = await prisma.event.findMany({
where: {
...BASIC_EVENT_FILTER(),
tickets: {
some: {
stock: {
gt: 0,
},
},
},
},
orderBy: {
startDatetime: "desc",
},
skip,
take: pageSize,
include: {
tags: true,
going: {
select: {
id: true,
},
},
interested: {
select: {
id: true,
},
},
author: true,
},
});

return {
events,
pagination: {
currentPage: page,
totalPages,
totalEvents,
hasNextPage: page < totalPages,
hasPrevPage: page > 1,
},
};
};

export const actions = {
async selectEvent({ request }) {
const formData = await request.formData();
const eventSlug = formData.get("eventSlug")?.toString();

if (!eventSlug) {
return { success: false, message: "No event slug provided" };
}

// Redirect to the event's QR page
throw redirect(303, `/events/${eventSlug}/scan`);
},
};
Loading
Loading