Skip to content
Merged
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
2 changes: 1 addition & 1 deletion next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
13 changes: 13 additions & 0 deletions public/firebase-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// AUTO-GENERATED — do not edit manually.
// Generated by scripts/generate-firebase-config.mjs at build time.
// Contains only PUBLIC Firebase project identifiers (safe for the browser).
// Server secrets must never appear here.
/* eslint-disable */
self.__FIREBASE_CONFIG__ = {
"apiKey": "PLACEHOLDER",
"authDomain": "PLACEHOLDER",
"projectId": "PLACEHOLDER",
"storageBucket": "PLACEHOLDER",
"messagingSenderId": "PLACEHOLDER",
"appId": "PLACEHOLDER"
};
133 changes: 133 additions & 0 deletions src/components/Profile/EmergencyPassOptions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import React, { useState } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { Printer, Wallet } from 'lucide-react';
import { getEmergencyPassStatus, type EmergencyPassRecord } from './emergencyPassStatus';

interface EmergencyPassOptionsProps {
petId: string;
petName: string;
emergencyUrl: string;
/** Public QR record for the pet, or null when none exists or it was denied. */
record: EmergencyPassRecord | null;
/** Requests the wallet pass from the backend, which decides whether to issue it. */
onRequestWalletPass?: () => Promise<Blob>;
}

const STATUS_TEXT = {
active: 'Active',
expired: 'Expired',
revoked: 'Revoked',
unavailable: 'Unavailable',
} as const;

const PRINT_CSS = `
@media print {
body * { visibility: hidden !important; }
#emergency-print-card, #emergency-print-card * { visibility: visible !important; }
#emergency-print-card { position: absolute; left: 0; top: 0; width: 100%; }
}`;

/**
* Print card and wallet pass built only from public fields: pet name, the
* public emergency link, the public message and the code status. Contact
* details are never rendered here.
*/
export const EmergencyPassOptions: React.FC<EmergencyPassOptionsProps> = ({
petId,
petName,
emergencyUrl,
record,
onRequestWalletPass,
}) => {
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const status = getEmergencyPassStatus(record);
const canIssue = status === 'active';
const expiry =
record?.expiresAt && !Number.isNaN(Date.parse(record.expiresAt))
? new Date(record.expiresAt).toLocaleDateString()
: 'No expiration';

const handlePrint = () => {
if (canIssue) window.print();
};

const handleWallet = async () => {
if (!canIssue || !onRequestWalletPass) return;
setBusy(true);
setError(null);
try {
const blob = await onRequestWalletPass();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${petName}-emergency.pkpass`;
link.click();
URL.revokeObjectURL(url);
} catch {
setError('The wallet pass could not be created. Try again later.');
} finally {
setBusy(false);
}
};

return (
<section aria-labelledby={`pass-heading-${petId}`} className="w-full flex flex-col gap-3">
<style>{PRINT_CSS}</style>
<h4 id={`pass-heading-${petId}`} className="text-sm font-semibold text-gray-900">
Print card and wallet pass
</h4>

<div id="emergency-print-card" className="rounded-2xl border border-gray-200 p-4 text-center">
<p className="font-bold text-gray-900">{petName}</p>
{canIssue && (
<QRCodeSVG value={emergencyUrl} size={120} level="H" className="mx-auto my-2" />
)}
{record?.customMessage && canIssue && (
<p className="text-sm text-gray-700">{record.customMessage}</p>
)}
<dl className="text-xs text-gray-600 mt-2">
<dt className="inline font-semibold">Status: </dt>
<dd className="inline" data-testid="pass-status">
{STATUS_TEXT[status]}
</dd>
<br />
<dt className="inline font-semibold">Expires: </dt>
<dd className="inline">{expiry}</dd>
</dl>
</div>

{!canIssue && (
<p role="status" className="text-xs text-red-600">
{status === 'unavailable'
? 'No emergency QR code is available for this pet.'
: `This emergency code is ${STATUS_TEXT[status].toLowerCase()}, so a card or pass cannot be created.`}
</p>
)}
{error && (
<p role="alert" className="text-xs text-red-600">
{error}
</p>
)}

<div className="flex gap-3">
<button
type="button"
onClick={handlePrint}
disabled={!canIssue}
className="flex-1 flex items-center justify-center gap-2 border border-gray-300 rounded-full py-2 text-sm font-semibold disabled:opacity-50"
>
<Printer size={16} aria-hidden="true" /> Print card
</button>
<button
type="button"
onClick={handleWallet}
disabled={!canIssue || busy || !onRequestWalletPass}
className="flex-1 flex items-center justify-center gap-2 border border-gray-300 rounded-full py-2 text-sm font-semibold disabled:opacity-50"
>
<Wallet size={16} aria-hidden="true" /> {busy ? 'Creating pass...' : 'Add to wallet'}
</button>
</div>
</section>
);
};
29 changes: 28 additions & 1 deletion src/components/Profile/EmergencyQR.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,32 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { Download, Share2, ExternalLink } from 'lucide-react';
import { qrcodeAPI, QRCodeRecord } from '@/lib/api/qrcodeAPI';
import { EmergencyPassOptions } from './EmergencyPassOptions';

interface EmergencyQRProps {
petId: string;
petName: string;
}

export const EmergencyQR: React.FC<EmergencyQRProps> = ({ petId, petName }) => {
const [record, setRecord] = useState<QRCodeRecord | null>(null);

useEffect(() => {
let cancelled = false;
qrcodeAPI
.getByPetId(petId)
.then((list) => {
if (!cancelled) setRecord(list.find((r) => r.isActive) ?? list[0] ?? null);
})
.catch(() => {
if (!cancelled) setRecord(null);
});
return () => {
cancelled = true;
};
}, [petId]);

const emergencyUrl = `${typeof window !== 'undefined' ? window.location.origin : ''}/pets/${petId}/emergency`;

const downloadQR = () => {
Expand Down Expand Up @@ -77,6 +96,14 @@ export const EmergencyQR: React.FC<EmergencyQRProps> = ({ petId, petName }) => {
</a>
</div>

<EmergencyPassOptions
petId={petId}
petName={petName}
emergencyUrl={emergencyUrl}
record={record}
onRequestWalletPass={record ? () => qrcodeAPI.getWalletPass(record.qrCodeId) : undefined}
/>

<div className="mt-2 text-xs text-gray-400 flex items-center gap-1">
<Share2 size={12} /> Share this link with pet sitters or family
</div>
Expand Down
107 changes: 107 additions & 0 deletions src/components/Profile/__tests__/EmergencyPassOptions.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { EmergencyPassOptions } from '../EmergencyPassOptions';
import { getEmergencyPassStatus } from '../emergencyPassStatus';

jest.mock('qrcode.react', () => ({
QRCodeSVG: (props: { value: string }) => <svg data-testid="qr" data-value={props.value} />,
}));

const URL_ = 'https://app.test/pets/p1/emergency';
const future = new Date(Date.now() + 86_400_000).toISOString();
const past = new Date(Date.now() - 86_400_000).toISOString();

describe('getEmergencyPassStatus', () => {
it('handles each state', () => {
expect(getEmergencyPassStatus(null)).toBe('unavailable');
expect(getEmergencyPassStatus({ isActive: false })).toBe('revoked');
expect(getEmergencyPassStatus({ isActive: true, expiresAt: past })).toBe('expired');
expect(getEmergencyPassStatus({ isActive: true, expiresAt: 'bad' })).toBe('expired');
expect(getEmergencyPassStatus({ isActive: true, expiresAt: future })).toBe('active');
expect(getEmergencyPassStatus({ isActive: true })).toBe('active');
expect(getEmergencyPassStatus({ isActive: false, expiresAt: past })).toBe('revoked');
});
});

describe('EmergencyPassOptions', () => {
beforeEach(() => {
window.print = jest.fn();
global.URL.createObjectURL = jest.fn(() => 'blob:x');
global.URL.revokeObjectURL = jest.fn();
});

it('shows public fields, status and expiry for an active code, and prints', () => {
render(
<EmergencyPassOptions
petId="p1"
petName="Rex"
emergencyUrl={URL_}
record={{ isActive: true, expiresAt: future, customMessage: 'Needs insulin' }}
onRequestWalletPass={jest.fn()}
/>
);
expect(screen.getByTestId('pass-status')).toHaveTextContent('Active');
expect(screen.getByText('Needs insulin')).toBeTruthy();
expect(screen.getByTestId('qr').getAttribute('data-value')).toBe(URL_);
fireEvent.click(screen.getByRole('button', { name: /print card/i }));
expect(window.print).toHaveBeenCalled();
});

it('requests the wallet pass from the backend', async () => {
const onRequest = jest.fn().mockResolvedValue(new Blob(['x']));
render(
<EmergencyPassOptions
petId="p1"
petName="Rex"
emergencyUrl={URL_}
record={{ isActive: true }}
onRequestWalletPass={onRequest}
/>
);
fireEvent.click(screen.getByRole('button', { name: /add to wallet/i }));
await waitFor(() => expect(onRequest).toHaveBeenCalledTimes(1));
expect(screen.getByText('No expiration')).toBeTruthy();
});

it('shows an error when the backend refuses the wallet pass', async () => {
const onRequest = jest.fn().mockRejectedValue(new Error('403'));
render(
<EmergencyPassOptions
petId="p1"
petName="Rex"
emergencyUrl={URL_}
record={{ isActive: true }}
onRequestWalletPass={onRequest}
/>
);
fireEvent.click(screen.getByRole('button', { name: /add to wallet/i }));
const alert = await screen.findByRole('alert');
expect(alert.textContent).toMatch(/could not be created/i);
});

it.each([
['revoked', { isActive: false, customMessage: 'secret note' }, /revoked/i],
['expired', { isActive: true, expiresAt: past, customMessage: 'secret note' }, /expired/i],
])('disables actions and hides the public data when %s', (_name, record, text) => {
render(
<EmergencyPassOptions
petId="p1"
petName="Rex"
emergencyUrl={URL_}
record={record}
onRequestWalletPass={jest.fn()}
/>
);
expect(screen.getByRole('status').textContent).toMatch(text);
expect((screen.getByRole('button', { name: /print card/i }) as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByRole('button', { name: /add to wallet/i }) as HTMLButtonElement).disabled).toBe(true);
expect(screen.queryByTestId('qr')).toBeNull();
expect(screen.queryByText('secret note')).toBeNull();
});

it('handles a missing record', () => {
render(<EmergencyPassOptions petId="p1" petName="Rex" emergencyUrl={URL_} record={null} />);
expect(screen.getByTestId('pass-status').textContent).toBe('Unavailable');
expect((screen.getByRole('button', { name: /print card/i }) as HTMLButtonElement).disabled).toBe(true);
});
});
20 changes: 20 additions & 0 deletions src/components/Profile/emergencyPassStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { QRCodeRecord } from '@/lib/api/qrcodeAPI';

export type EmergencyPassStatus = 'active' | 'expired' | 'revoked' | 'unavailable';

export type EmergencyPassRecord = Pick<QRCodeRecord, 'isActive'> &
Partial<Pick<QRCodeRecord, 'expiresAt' | 'customMessage'>>;

/** Revocation wins over expiry; a missing record means nothing can be issued. */
export function getEmergencyPassStatus(
record: EmergencyPassRecord | null | undefined,
now: number = Date.now()
): EmergencyPassStatus {
if (!record) return 'unavailable';
if (!record.isActive) return 'revoked';
if (record.expiresAt) {
const expires = Date.parse(record.expiresAt);
if (Number.isNaN(expires) || expires <= now) return 'expired';
}
return 'active';
}
11 changes: 11 additions & 0 deletions src/lib/api/qrcodeAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,17 @@ export class QRCodeAPI {
}
}

/**
* Fetches the wallet pass for a QR code. The backend owns authorization and
* consent and builds the pass from public fields only; it may refuse for
* expired or revoked codes.
*/
async getWalletPass(qrCodeId: string): Promise<Blob> {
const safe = safeQrId(qrCodeId);
const { data } = await this.api.get(`/${safe}/wallet-pass`, { responseType: 'blob' });
return data;
}

getImageUrl(qrCodeId: string): string {
const safe = safeQrId(qrCodeId);
return `${getApiBaseUrl()}/qrcodes/${safe}/image`;
Expand Down