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
124 changes: 124 additions & 0 deletions src/components/EmergencyResponderFeedback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import React, { useState } from 'react';
import type { FormEvent } from 'react';

import {
qrcodeAPI,
type EmergencyResponderFeedbackType,
} from '@/lib/api/qrcodeAPI';

interface EmergencyResponderFeedbackProps {
qrCodeId: string;
}

function getResponseStatus(error: unknown): number | undefined {
if (typeof error !== 'object' || error === null) return undefined;

const responseStatus = (error as { response?: { status?: unknown } }).response?.status;
if (typeof responseStatus === 'number') return responseStatus;

const status = (error as { status?: unknown }).status;
return typeof status === 'number' ? status : undefined;
}

export default function EmergencyResponderFeedback({
qrCodeId,
}: EmergencyResponderFeedbackProps) {
const [feedbackType, setFeedbackType] =
useState<EmergencyResponderFeedbackType | ''>('');
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [errorMessage, setErrorMessage] = useState('');

async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!feedbackType || submitting || submitted) return;

setSubmitting(true);
setErrorMessage('');
try {
await qrcodeAPI.submitEmergencyFeedback(qrCodeId, feedbackType);
setSubmitted(true);
} catch (error: unknown) {
const status = getResponseStatus(error);
if (status === 429) {
setErrorMessage('Too many reports have been sent. Please wait before trying again.');
} else if (status === 403 || status === 404 || status === 410) {
setErrorMessage('Feedback is not available for this emergency profile.');
} else {
setErrorMessage('Your report could not be sent. Please try again later.');
}
} finally {
setSubmitting(false);
}
}

return (
<section
aria-labelledby="responder-feedback-title"
className="rounded-3xl border border-red-100 bg-white p-6 shadow-lg"
>
<h2 id="responder-feedback-title" className="text-base font-black text-gray-900">
Update the pet&apos;s owner
</h2>
<p id="responder-feedback-description" className="mt-2 text-sm text-gray-600">
Let them know if these contact details are out of date or if the pet is safe.
This report sends no message or personal details.
</p>

<form className="mt-4 space-y-3" onSubmit={handleSubmit}>
<fieldset disabled={submitting || submitted}>
<legend className="mb-2 text-sm font-semibold text-gray-800">
Choose one update
</legend>
<div className="space-y-2">
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-gray-200 p-3">
<input
type="radio"
name="responder-feedback"
value="contact_details_stale"
checked={feedbackType === 'contact_details_stale'}
onChange={() => setFeedbackType('contact_details_stale')}
aria-describedby="responder-feedback-description"
className="mt-1"
/>
<span className="text-sm text-gray-800">
The contact details are out of date
</span>
</label>
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-gray-200 p-3">
<input
type="radio"
name="responder-feedback"
value="pet_safe"
checked={feedbackType === 'pet_safe'}
onChange={() => setFeedbackType('pet_safe')}
aria-describedby="responder-feedback-description"
className="mt-1"
/>
<span className="text-sm text-gray-800">The pet is safe</span>
</label>
</div>
</fieldset>

<button
type="submit"
disabled={!feedbackType || submitting || submitted}
className="w-full rounded-full bg-gray-900 px-5 py-3 font-bold text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? 'Sending report…' : submitted ? 'Report sent' : 'Send report'}
</button>
</form>

{submitted ? (
<p className="mt-3 text-sm text-green-800" role="status" aria-live="polite">
Thank you. Your report was sent to the pet&apos;s owner.
</p>
) : null}
{errorMessage ? (
<p className="mt-3 text-sm text-red-700" role="alert">
{errorMessage}
</p>
) : null}
</section>
);
}
69 changes: 69 additions & 0 deletions src/components/__tests__/EmergencyResponderFeedback.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';

import EmergencyResponderFeedback from '@/components/EmergencyResponderFeedback';
import { qrcodeAPI } from '@/lib/api/qrcodeAPI';

jest.mock('@/lib/api/qrcodeAPI', () => ({
qrcodeAPI: {
submitEmergencyFeedback: jest.fn(),
},
}));

describe('EmergencyResponderFeedback', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('requires a choice and sends the selected feedback for the scanned tag', async () => {
(qrcodeAPI.submitEmergencyFeedback as jest.Mock).mockResolvedValue(undefined);
render(<EmergencyResponderFeedback qrCodeId="tag-1" />);

expect(screen.getByRole('button', { name: 'Send report' })).toBeDisabled();
fireEvent.click(screen.getByRole('radio', { name: 'The pet is safe' }));
fireEvent.click(screen.getByRole('button', { name: 'Send report' }));

await waitFor(() => {
expect(qrcodeAPI.submitEmergencyFeedback).toHaveBeenCalledWith('tag-1', 'pet_safe');
});
expect(await screen.findByRole('status')).toHaveTextContent(/report was sent/i);
expect(screen.getByRole('button', { name: 'Report sent' })).toBeDisabled();
});

it('does not submit until a feedback option is selected', () => {
render(<EmergencyResponderFeedback qrCodeId="tag-1" />);

expect(screen.getByRole('radio', { name: /contact details are out of date/i })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'The pet is safe' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Send report' })).toBeDisabled();
expect(qrcodeAPI.submitEmergencyFeedback).not.toHaveBeenCalled();
});

it('shows a denied message when the backend declines the report', async () => {
(qrcodeAPI.submitEmergencyFeedback as jest.Mock).mockRejectedValue({
response: { status: 403 },
});
render(<EmergencyResponderFeedback qrCodeId="tag-1" />);

fireEvent.click(screen.getByRole('radio', { name: 'The pet is safe' }));
fireEvent.click(screen.getByRole('button', { name: 'Send report' }));

expect(await screen.findByRole('alert')).toHaveTextContent(
/feedback is not available for this emergency profile/i,
);
});

it('shows the backend rate-limit state without losing the selected option', async () => {
(qrcodeAPI.submitEmergencyFeedback as jest.Mock).mockRejectedValue({
response: { status: 429 },
});
render(<EmergencyResponderFeedback qrCodeId="tag-1" />);

const safeOption = screen.getByRole('radio', { name: 'The pet is safe' });
fireEvent.click(safeOption);
fireEvent.click(screen.getByRole('button', { name: 'Send report' }));

expect(await screen.findByRole('alert')).toHaveTextContent(/please wait/i);
expect(safeOption).toBeChecked();
});
});
10 changes: 10 additions & 0 deletions src/lib/api/qrcodeAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export interface ScanEventMeta {
emergencyContact?: string;
}

export type EmergencyResponderFeedbackType = 'contact_details_stale' | 'pet_safe';

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const QRID_RE = /^[a-zA-Z0-9_-]{1,128}$/;
const ALLOWED_DEVICE_TYPES = new Set(['mobile', 'desktop', 'tablet', 'unknown']);
Expand Down Expand Up @@ -127,6 +129,14 @@ export class QRCodeAPI {
}
}

async submitEmergencyFeedback(
qrCodeId: string,
feedbackType: EmergencyResponderFeedbackType
): Promise<void> {
const safe = safeQrId(qrCodeId);
await this.api.post(`/${safe}/feedback`, { feedbackType });
}

async update(
qrCodeId: string,
patch: Partial<Pick<QRCodeRecord, 'isActive' | 'emergencyContact' | 'customMessage'>>
Expand Down
3 changes: 3 additions & 0 deletions src/pages/scan/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Head from 'next/head';
import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react';

import EmergencyResponderFeedback from '@/components/EmergencyResponderFeedback';
import { petAPI } from '@/lib/api/petAPI';
import { qrcodeAPI } from '@/lib/api/qrcodeAPI';
import type { PetEmergencyInfo, EmergencyContact } from '@/types/pet';
Expand Down Expand Up @@ -296,6 +297,8 @@ export default function ScanPage({
</div>
)}

<EmergencyResponderFeedback qrCodeId={profile.qrCodeId} />

<div className="text-center pt-8">
<div className="flex items-center justify-center gap-2 text-gray-400 mb-2">
<Dna size={16} />
Expand Down
Loading