Skip to content
Closed
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
28 changes: 13 additions & 15 deletions src/app/services/[serviceId]/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
import { useEffect, useState, use } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { apiGet, apiPatch } from "@/lib/apiClient";
import { apiGet } from "@/lib/apiClient";
import { PageShell } from "@/components/PageShell";
import { TextField } from "@/components/TextField";
import { Spinner } from "@/components/Spinner";
import { useToast } from "@/components/ToastProvider";
import { parseNonNegativeInt } from "@/lib/validateNumber";
import { runServiceMutation } from "@/lib/serviceMutationQueue";

type Service = { serviceId: string; priceStroops: number };

Expand All @@ -27,11 +28,6 @@ export default function EditServicePage({
const [prefillError, setPrefillError] = useState<string | null>(null);
const [originalPrice, setOriginalPrice] = useState<string | null>(null);

/**
* Whether the price field differs from the originally-fetched value.
* Derived during render (not stored in state) so it stays in sync without a
* setState-in-effect. Used by the unsaved-changes guard.
*/
const dirty = originalPrice !== null && price !== originalPrice;

useEffect(() => {
Expand All @@ -54,11 +50,6 @@ export default function EditServicePage({
void load();
}, [serviceId]);

/*
* beforeunload guard: registers a `beforeunload` event on the window
* whenever the form is dirty so the browser prompts the operator before
* closing or navigating away.
*/
useEffect(() => {
if (!dirty) return;

Expand Down Expand Up @@ -86,11 +77,18 @@ export default function EditServicePage({

setSaving(true);
try {
await apiPatch(
`/api/v1/services/${encodeURIComponent(serviceId)}/price`,
{ priceStroops: parsed.value }
);
const result = await runServiceMutation<unknown>({
kind: "service.price.update",
serviceId,
path: `/api/v1/services/${encodeURIComponent(serviceId)}/price`,
body: { priceStroops: parsed.value },
basePriceStroops: Number(originalPrice),
});
setOriginalPrice(price);
if (result.queued) {
toast.push("Price change queued. It will sync when you reconnect.", "info");
return;
}
toast.push("Price updated.", "info");
router.push(`/services/${encodeURIComponent(serviceId)}`);
} catch (err) {
Expand Down
85 changes: 85 additions & 0 deletions src/app/services/new/offline-queue.integration.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import NewServicePage from "./page";
import { apiGet, apiPatch, apiPost } from "@/lib/apiClient";
import { SERVICE_MUTATION_QUEUE_STORAGE_KEY } from "@/lib/serviceMutationQueue";

jest.mock("@/lib/apiClient", () => ({
apiGet: jest.fn(),
apiPatch: jest.fn(),
apiPost: jest.fn(),
}));

const mockPush = jest.fn();

jest.mock("next/navigation", () => ({
useRouter() {
return {
push: mockPush,
replace: jest.fn(),
prefetch: jest.fn(),
};
},
}));

const apiGetMock = apiGet as jest.MockedFunction<typeof apiGet>;
const apiPatchMock = apiPatch as jest.MockedFunction<typeof apiPatch>;
const apiPostMock = apiPost as jest.MockedFunction<typeof apiPost>;

function setOnline(value: boolean) {
Object.defineProperty(window.navigator, "onLine", {
value,
configurable: true,
});
}

describe("NewServicePage offline mutation integration", () => {
beforeEach(() => {
localStorage.clear();
mockPush.mockReset();
apiGetMock.mockReset();
apiPatchMock.mockReset();
apiPostMock.mockReset();
setOnline(false);
});

afterEach(() => {
setOnline(true);
localStorage.clear();
});

it("persists a submitted service while offline and surfaces queued UI state", async () => {
render(<NewServicePage />);

fireEvent.change(screen.getByLabelText("Service ID"), {
target: { value: "offline-svc" },
});
fireEvent.change(screen.getByLabelText("Price (stroops / request)"), {
target: { value: "42" },
});
fireEvent.submit(screen.getByRole("button", { name: /register service/i }));

await waitFor(() => {
expect(
screen.getByRole("button", { name: /queued for sync/i }),
).toBeDisabled();
});

expect(screen.getByRole("status")).toHaveTextContent(
/saved on this device and will sync when connectivity returns/i,
);
expect(apiPostMock).not.toHaveBeenCalled();
expect(mockPush).not.toHaveBeenCalled();

const stored = JSON.parse(
localStorage.getItem(SERVICE_MUTATION_QUEUE_STORAGE_KEY)!,
);
expect(stored).toHaveLength(1);
expect(stored[0]).toMatchObject({
kind: "service.create",
serviceId: "offline-svc",
path: "/api/v1/services",
body: { serviceId: "offline-svc", priceStroops: 42 },
state: "pending",
});
});
});
29 changes: 24 additions & 5 deletions src/app/services/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

import { useRouter } from "next/navigation";
import { useState } from "react";
import { apiPost } from "@/lib/apiClient";
import { PageShell } from "@/components/PageShell";
import { TextField } from "@/components/TextField";
import { Button } from "@/components/Button";
import { parseNonNegativeInt } from "@/lib/validateNumber";
import { useApiMutation } from "@/lib/useApiMutation";
import { runServiceMutation } from "@/lib/serviceMutationQueue";

type CreateServiceBody = {
serviceId: string;
Expand All @@ -19,17 +19,27 @@ export default function NewServicePage() {
const [serviceId, setServiceId] = useState("");
const [priceStroops, setPriceStroops] = useState("");
const [priceError, setPriceError] = useState<string | null>(null);
const [queued, setQueued] = useState(false);

const { mutate, status, error, reset } = useApiMutation(
(body: CreateServiceBody, { signal }) =>
apiPost("/api/v1/services", body, { signal }),
runServiceMutation<unknown>(
{
kind: "service.create",
serviceId: body.serviceId,
path: "/api/v1/services",
body,
},
{ signal },
),
);

const loading = status === "pending";

const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
reset();
setQueued(false);
setPriceError(null);

const parsed = parseNonNegativeInt(priceStroops);
Expand All @@ -39,10 +49,14 @@ export default function NewServicePage() {
}

try {
await mutate({
const result = await mutate({
serviceId,
priceStroops: parsed.value,
});
if (result.queued) {
setQueued(true);
return;
}
router.push("/services");
} catch {
// Error message is already mirrored on the mutation `error` state.
Expand Down Expand Up @@ -74,12 +88,17 @@ export default function NewServicePage() {
<Button
type="submit"
loading={loading}
disabled={loading}
disabled={loading || queued}
className="self-start"
>
{loading ? "Saving…" : "Register service"}
{loading ? "Saving…" : queued ? "Queued for sync" : "Register service"}
</Button>

{queued && (
<p role="status" className="text-sm text-amber-700 dark:text-amber-300">
Service registration is saved on this device and will sync when connectivity returns.
</p>
)}
{error && (
<p role="alert" className="text-sm text-rose-600">
{error}
Expand Down
84 changes: 72 additions & 12 deletions src/components/OfflineBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,63 @@
"use client";

import { useCallback, useState } from "react";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useOnlineStatus } from "@/lib/useOnlineStatus";
import { useServiceMutationQueue } from "@/lib/useServiceMutationQueue";
import {
discardServiceMutationConflicts,
flushServiceMutationQueue,
retryServiceMutationConflicts,
} from "@/lib/serviceMutationQueue";

/**
* A dismissible banner that appears when the browser goes offline.
* Global connectivity + queued-mutation status surface.
*
* Accessibility:
* - The banner uses `role="alert"` so assistive technology announces it
* immediately when the connectivity state changes.
* - The dismiss button carries an `aria-label` for screen-reader users.
*
* Satisfies WCAG 4.1.3 Status Messages.
* Queued service mutations live in localStorage, so this banner can flush them
* after a reload or navigation. Conflicts remain visible until the operator
* explicitly retries or discards them.
*/
export function OfflineBanner() {
const { isOnline } = useOnlineStatus();
const queue = useServiceMutationQueue();
const [dismissed, setDismissed] = useState(false);

const dismiss = useCallback(() => setDismissed(true), []);

if (isOnline || dismissed) return null;
useEffect(() => {
setDismissed(false);
if (isOnline) {
void flushServiceMutationQueue();
}
}, [isOnline]);

const retry = useCallback(() => {
setDismissed(false);
if (queue.conflicts > 0) {
void retryServiceMutationConflicts();
} else {
void flushServiceMutationQueue();
}
}, [queue.conflicts]);

if (dismissed) return null;
if (isOnline && queue.total === 0 && !queue.isFlushing) return null;

let message = "You are offline. Some features may be unavailable.";
if (queue.conflicts > 0) {
message = `${queue.conflicts} queued service change${queue.conflicts === 1 ? " has" : "s have"} a server conflict. Review the affected service, then retry or discard the change.`;
} else if (!isOnline && queue.total > 0) {
message = `You are offline. ${queue.total} service change${queue.total === 1 ? " is" : "s are"} queued and will sync when you reconnect.`;
} else if (queue.isFlushing) {
message = `Syncing ${queue.pending} queued service change${queue.pending === 1 ? "" : "s"}…`;
} else if (isOnline && queue.pending > 0) {
message = `${queue.pending} service change${queue.pending === 1 ? " is" : "s are"} waiting to sync. Retry when the connection is available.`;
}

return (
<div
role="alert"
className="sticky top-0 z-50 flex items-center justify-center gap-3 bg-amber-50 px-4 py-2 text-sm text-amber-900 shadow-sm dark:bg-amber-950 dark:text-amber-200"
className="sticky top-0 z-50 flex flex-wrap items-center justify-center gap-3 bg-amber-50 px-4 py-2 text-sm text-amber-900 shadow-sm dark:bg-amber-950 dark:text-amber-200"
>
<svg
xmlns="http://www.w3.org/2000/svg"
Expand All @@ -39,12 +72,39 @@ export function OfflineBanner() {
clipRule="evenodd"
/>
</svg>
<span>You are offline. Some features may be unavailable.</span>
<span>{message}</span>
{queue.conflictItems.slice(0, 3).map(({ serviceId, errorCode }) => (
<Link
key={serviceId}
href={`/services/${encodeURIComponent(serviceId)}`}
className="underline underline-offset-2"
>
{serviceId} ({errorCode})
</Link>
))}
{isOnline && queue.total > 0 && !queue.isFlushing && (
<button
type="button"
onClick={retry}
className="rounded border border-current px-2 py-1 text-xs font-medium focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
>
Retry sync
</button>
)}
{queue.conflicts > 0 && (
<button
type="button"
onClick={() => void discardServiceMutationConflicts()}
className="rounded border border-current px-2 py-1 text-xs font-medium focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
>
Discard conflicted changes
</button>
)}
<button
type="button"
onClick={dismiss}
aria-label="Dismiss offline notification"
className="-ml-1 ml-auto rounded p-0.5 text-lg leading-none opacity-70 transition-opacity hover:opacity-100 focus-visible:opacity-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
className="ml-auto rounded p-0.5 text-lg leading-none opacity-70 transition-opacity hover:opacity-100 focus-visible:opacity-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
>
<span aria-hidden="true">&times;</span>
</button>
Expand Down
Loading
Loading