feat: NFC-Tags beschreiben mit demselben Deep-Link wie der QR-Code
Neuer Endpunkt GET /api/v1/qr/:entityType/:id/link liefert den vom QR-Dienst bereits genutzten Deep-Link als reine URL statt als SVG, damit die Web-NFC-API (client-seitig, Chrome/Android) ihn per NDEFReader.write() auf einen physischen NFC-Tag schreiben kann. QrService.resolveUrl() ist aus generateSvg() herausgezogen, damit beide Endpunkte exakt denselben Link erzeugen. Der bestehende QrCodeDialog bekommt dadurch für alle sechs QR-fähigen Entitäten (Völker, Bienenstände, Königinnen, Honigchargen, Lagerbehälter, Abfüllungen) automatisch einen "Auf NFC-Tag schreiben"-Button, der nur erscheint, wenn window.NDEFReader tatsächlich existiert – kein Fallback für andere Browser oder iOS, wie in der Lastenheft vorgesehen.
This commit is contained in:
1 parent
f346859121
commit
53b781aa04
8 files changed
+106
-5
No files matched your search
@@ -19,4 +19,13 @@ export class QrController {
|
|||||||
res.setHeader('Content-Type', 'image/svg+xml');
|
res.setHeader('Content-Type', 'image/svg+xml');
|
||||||
res.send(svg);
|
res.send(svg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':entityType/:id/link')
|
||||||
|
async link(
|
||||||
|
@Param('entityType') entityType: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
): Promise<{ url: string }> {
|
||||||
|
const url = await this.qrService.resolveUrl(entityType, id);
|
||||||
|
return { url };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -57,4 +57,23 @@ describe('QrService', () => {
|
|||||||
const svg = await service.generateSvg('bottling-batches', 'bb-1');
|
const svg = await service.generateSvg('bottling-batches', 'bb-1');
|
||||||
expect(svg).toContain('<svg');
|
expect(svg).toContain('<svg');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolveUrl returns the same deep-link that generateSvg encodes, for NFC writing', async () => {
|
||||||
|
prisma.colony.findUnique.mockResolvedValueOnce({ id: 'colony-1' });
|
||||||
|
const url = await service.resolveUrl('colonies', 'colony-1');
|
||||||
|
expect(url).toBe('http://localhost:3000/inspections/new?colonyId=colony-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveUrl rejects an unsupported entity type', async () => {
|
||||||
|
await expect(service.resolveUrl('unknown', 'id-1')).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveUrl throws NotFoundException when the object does not exist', async () => {
|
||||||
|
prisma.colony.findUnique.mockResolvedValueOnce(null);
|
||||||
|
await expect(
|
||||||
|
service.resolveUrl('colonies', 'missing'),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -25,6 +25,11 @@ export class QrService {
|
|||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async generateSvg(entityType: string, id: string): Promise<string> {
|
async generateSvg(entityType: string, id: string): Promise<string> {
|
||||||
|
const url = await this.resolveUrl(entityType, id);
|
||||||
|
return QRCode.toString(url, { type: 'svg', margin: 1, width: 256 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveUrl(entityType: string, id: string): Promise<string> {
|
||||||
if (!isEntityType(entityType)) {
|
if (!isEntityType(entityType)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Unsupported QR entity type: ${entityType}`,
|
`Unsupported QR entity type: ${entityType}`,
|
||||||
@@ -38,8 +43,7 @@ export class QrService {
|
|||||||
|
|
||||||
const frontendOrigin =
|
const frontendOrigin =
|
||||||
process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
|
process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
|
||||||
const url = `${frontendOrigin}${deepLink}`;
|
return `${frontendOrigin}${deepLink}`;
|
||||||
return QRCode.toString(url, { type: 'svg', margin: 1, width: 256 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveDeepLink(
|
private async resolveDeepLink(
|
||||||
|
|||||||
@@ -78,4 +78,13 @@ describe('QR codes (e2e)', () => {
|
|||||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
.expect(400);
|
.expect(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns the plain deep-link URL for NFC writing', async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get(`/api/v1/qr/colonies/${colonyId}/link`)
|
||||||
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
|
.expect(200);
|
||||||
|
const body = response.body as { url: string };
|
||||||
|
expect(body.url).toContain(`colonyId=${colonyId}`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { QrCode } from "lucide-react";
|
import { QrCode, Nfc } from "lucide-react";
|
||||||
import { fetchQrCodeSvg, QrEntityType } from "@/lib/qr/api";
|
import { toast } from "sonner";
|
||||||
|
import { fetchQrCodeSvg, fetchQrLink, QrEntityType } from "@/lib/qr/api";
|
||||||
|
import { isNfcSupported, writeNfcTag } from "@/lib/nfc/write";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -21,6 +23,8 @@ interface QrCodeDialogProps {
|
|||||||
|
|
||||||
export function QrCodeDialog({ entityType, id, label = "QR-Code" }: QrCodeDialogProps) {
|
export function QrCodeDialog({ entityType, id, label = "QR-Code" }: QrCodeDialogProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [isWritingNfc, setIsWritingNfc] = useState(false);
|
||||||
|
const nfcSupported = useMemo(() => isNfcSupported(), []);
|
||||||
|
|
||||||
const { data: blob, isLoading, isError } = useQuery({
|
const { data: blob, isLoading, isError } = useQuery({
|
||||||
queryKey: ["qr", entityType, id],
|
queryKey: ["qr", entityType, id],
|
||||||
@@ -35,6 +39,19 @@ export function QrCodeDialog({ entityType, id, label = "QR-Code" }: QrCodeDialog
|
|||||||
};
|
};
|
||||||
}, [objectUrl]);
|
}, [objectUrl]);
|
||||||
|
|
||||||
|
async function handleWriteNfc() {
|
||||||
|
setIsWritingNfc(true);
|
||||||
|
try {
|
||||||
|
const url = await fetchQrLink(entityType, id);
|
||||||
|
await writeNfcTag(url);
|
||||||
|
toast.success("NFC-Tag beschrieben");
|
||||||
|
} catch {
|
||||||
|
toast.error("NFC-Tag konnte nicht beschrieben werden.");
|
||||||
|
} finally {
|
||||||
|
setIsWritingNfc(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger
|
<DialogTrigger
|
||||||
@@ -59,6 +76,17 @@ export function QrCodeDialog({ entityType, id, label = "QR-Code" }: QrCodeDialog
|
|||||||
// eslint-disable-next-line @next/next/no-img-element -- blob: object URL, next/image cannot handle this
|
// eslint-disable-next-line @next/next/no-img-element -- blob: object URL, next/image cannot handle this
|
||||||
<img src={objectUrl} alt="QR-Code" className="size-64 rounded-lg border bg-white p-3" />
|
<img src={objectUrl} alt="QR-Code" className="size-64 rounded-lg border bg-white p-3" />
|
||||||
) : null}
|
) : null}
|
||||||
|
{nfcSupported ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleWriteNfc()}
|
||||||
|
disabled={isWritingNfc}
|
||||||
|
>
|
||||||
|
<Nfc className="size-4" />
|
||||||
|
{isWritingNfc ? "Tag bereithalten…" : "Auf NFC-Tag schreiben"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export function isNfcSupported(): boolean {
|
||||||
|
return typeof window !== "undefined" && "NDEFReader" in window;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeNfcTag(url: string): Promise<void> {
|
||||||
|
if (!window.NDEFReader) {
|
||||||
|
throw new Error("Web NFC wird von diesem Browser nicht unterstützt.");
|
||||||
|
}
|
||||||
|
const reader = new window.NDEFReader();
|
||||||
|
await reader.write({ records: [{ recordType: "url", data: url }] });
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { apiFetchBlob } from "@/lib/auth/api-client";
|
import { apiFetch, apiFetchBlob } from "@/lib/auth/api-client";
|
||||||
|
|
||||||
export type QrEntityType =
|
export type QrEntityType =
|
||||||
| "colonies"
|
| "colonies"
|
||||||
@@ -12,3 +12,8 @@ export async function fetchQrCodeSvg(entityType: QrEntityType, id: string): Prom
|
|||||||
const { blob } = await apiFetchBlob(`/qr/${entityType}/${id}.svg`, `${entityType}.svg`);
|
const { blob } = await apiFetchBlob(`/qr/${entityType}/${id}.svg`, `${entityType}.svg`);
|
||||||
return blob;
|
return blob;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchQrLink(entityType: QrEntityType, id: string): Promise<string> {
|
||||||
|
const { url } = await apiFetch<{ url: string }>(`/qr/${entityType}/${id}/link`);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
interface NDEFRecordInit {
|
||||||
|
recordType: string;
|
||||||
|
data?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NDEFWriteOptions {
|
||||||
|
records: NDEFRecordInit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NDEFReader {
|
||||||
|
write(options: NDEFWriteOptions): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
NDEFReader?: { new (): NDEFReader };
|
||||||
|
}
|
||||||
Reference in new issue
Block a user