Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
113 commits
Select commit Hold shift + click to select a range
83f14a9
fix(auth): scope standalone login lookup to tenant, fail closed on am…
important-new Jul 18, 2026
4947a50
chore(auth): refresh tenant-scope baseline for line shift; full-suite…
important-new Jul 18, 2026
2ef6136
feat(reports): generate reports from sold services, publish and notif…
important-new Aug 3, 2026
5cf55fd
feat(i18n): per-contact language preference
important-new Aug 3, 2026
23fb538
feat(booking): let a client state their language on the public form
important-new Aug 3, 2026
58cf9e4
feat(contacts): let staff set and correct a contact's language
important-new Aug 3, 2026
f2b561f
docs(i18n): make the contact-language demand signal readable, and honest
important-new Aug 3, 2026
d912f46
fix(invoices): stop discarding the balance QuickBooks sends
important-new Aug 3, 2026
e149d42
feat(invoices): say what is still owed on a partially paid invoice
important-new Aug 3, 2026
33d7a62
docs(i18n): es-419 glossary before translating 4,249 keys
important-new Aug 3, 2026
16446a6
i18n(es-419): translate common.json (18 keys)
important-new Aug 3, 2026
a066977
i18n(es-419): translate labels.json (99 keys)
important-new Aug 3, 2026
590991e
i18n(es-419): translate library.json (191 keys)
important-new Aug 3, 2026
2d55fbb
i18n(es-419): translate templates.json (114 keys)
important-new Aug 3, 2026
ebd9df4
feat(agreements): neutral language disclosure, not a contractual clause
important-new Aug 3, 2026
7c883ee
feat(agreements): show the language disclosure beside the agreement, …
important-new Aug 4, 2026
c26c335
fix(i18n): drop the unused export on DEFAULT_CONTACT_LOCALE
important-new Aug 4, 2026
e26e428
feat(agreements): record which language disclosure a signer was shown
important-new Aug 4, 2026
2a532da
docs(agreements): put counsel's reasoning, and what it does NOT settl…
important-new Aug 4, 2026
01b8335
feat(#270): tenant + user date/time format preferences
important-new Aug 4, 2026
d6f11fa
feat(#270): resolve date/time format in the session context
important-new Aug 4, 2026
af73b29
feat(#270): thread the format preference through the shared formatter
important-new Aug 4, 2026
f6493b8
feat(#270): route the five hardcoded date renders through the shared …
important-new Aug 4, 2026
8acba1a
feat(#270): settings UI for the tenant default and the personal override
important-new Aug 4, 2026
55e14eb
fix(reports): bind inspection_results.report_id at creation
important-new Aug 4, 2026
0eec265
feat(reports): per-report list on the order page, with a delete that …
important-new Aug 4, 2026
5e0c7d1
i18n(es-419): translate editor.json (135 keys)
important-new Aug 4, 2026
a55de95
i18n(es-419): translate editor-2.json (205 keys)
important-new Aug 4, 2026
49d7096
i18n(es-419): translate editor-3.json (102 keys)
important-new Aug 4, 2026
b3f5ef3
i18n(es-419): translate editor-4.json (153 keys)
important-new Aug 4, 2026
85ee3a9
i18n(es-419): translate reports.json (258 keys)
important-new Aug 4, 2026
0425efb
i18n(es-419): translate pca-report.json (130 keys)
important-new Aug 4, 2026
41438ae
i18n(es-419): translate settings.json (244 keys)
important-new Aug 4, 2026
f8f5f30
i18n(es-419): translate settings-catalog.json (123 keys)
important-new Aug 4, 2026
1855b69
i18n(es-419): translate settings-components.json (491 keys)
important-new Aug 4, 2026
405679b
i18n(es-419): translate settings-integrations.json (294 keys)
important-new Aug 4, 2026
b6f5f87
i18n(es-419): translate auth.json (126 keys)
important-new Aug 4, 2026
7b35304
i18n(es-419): disambiguate one settings-catalog string from a tu impe…
important-new Aug 4, 2026
3ef6964
i18n(es-419): translate inspections.json (251 keys)
important-new Aug 4, 2026
1b6b7a5
i18n(es-419): translate contacts.json (133 keys)
important-new Aug 4, 2026
0499343
i18n(es-419): translate calendar.json (61 keys)
important-new Aug 4, 2026
a12e795
i18n(es-419): translate booking.json (74 keys)
important-new Aug 4, 2026
02d9c2d
i18n(es-419): translate checkout.json (175 keys)
important-new Aug 4, 2026
abaa363
i18n(es-419): translate public.json (165 keys)
important-new Aug 4, 2026
40f740f
i18n(es-419): translate communication.json (103 keys)
important-new Aug 4, 2026
fe88fd4
i18n(es-419): translate components.json (183 keys)
important-new Aug 4, 2026
a2fff90
i18n(es-419): translate nav.json (30 keys)
important-new Aug 4, 2026
ad964f4
i18n(es-419): translate helpers.json (54 keys)
important-new Aug 4, 2026
40e56b3
i18n(es-419): translate validation.json (28 keys)
important-new Aug 4, 2026
738be69
i18n(es-419): translate metrics.json (47 keys)
important-new Aug 4, 2026
3485796
i18n(es-419): translate media.json (93 keys)
important-new Aug 4, 2026
82c62f0
i18n(es-419): translate misc.json (221 keys)
important-new Aug 4, 2026
6fe51fd
chore(i18n): restore the catalog parity gate
important-new Aug 4, 2026
ae62cf3
fix(i18n): three es-419 layouts that were sized for the English word
important-new Aug 4, 2026
d48ecee
chore(mcp): record the per-report delete route in the OpenAPI snapshot
important-new Aug 4, 2026
503d408
test(collab): stub the session hooks in the second VersionHistoryPane…
important-new Aug 4, 2026
4f58532
test(e2e): scope the People 'Primary' assertions now that reports hav…
important-new Aug 4, 2026
a1627be
feat(events): make results_received notify
important-new Aug 4, 2026
7dac07a
feat(events): make the follow-up delay per event type
important-new Aug 4, 2026
43552d3
feat(events): give the inspection hub the visits that make up the job
important-new Aug 4, 2026
e130c4b
feat(events): give the follow-up delay a control a human can reach
important-new Aug 4, 2026
7b3ae64
fix(calendar): make a calendar item land somewhere real
important-new Aug 4, 2026
e266dfe
feat(events): keep results_received in the office, enforced at the API
important-new Aug 4, 2026
368b7b8
fix(sync): send a field write to the report it belongs to
important-new Aug 4, 2026
35a880c
feat(payments): give a payment a row of its own
important-new Aug 4, 2026
2f6c497
feat(payments): one function that derives an invoice's payment state
important-new Aug 4, 2026
d7184c2
refactor(payments): route every payment write through the ledger
important-new Aug 4, 2026
61c3813
fix(qbo): push the payment that happened, not the invoice total
important-new Aug 4, 2026
22e8da4
feat(qbo): surface a payment disagreement instead of resolving it sil…
important-new Aug 4, 2026
5690747
feat(i18n): give message templates a locale, and a fallback chain tha…
important-new Aug 4, 2026
f7845a8
feat(i18n): render notifications in the recipient's language
important-new Aug 4, 2026
688703f
feat(i18n): let tenants author a template in each language
important-new Aug 4, 2026
993da29
test(i18n): assert the tag reduction that UI activation stands on
important-new Aug 4, 2026
7d479a9
feat(i18n): resolve a real UI locale per request
important-new Aug 4, 2026
1b47bec
feat(i18n): language switcher in the user menu
important-new Aug 4, 2026
df6f997
test(i18n): prove activation end to end, and verify the catalog gate
important-new Aug 4, 2026
cfaa659
fix(ai): make the model configurable instead of pinned in the URL
important-new Aug 4, 2026
8791c7e
feat(ai): provider abstraction with managed and BYO credential sources
important-new Aug 4, 2026
7bdb459
feat(quota): meter managed AI as translate/assist, free tier stays BYOK
important-new Aug 4, 2026
5138113
docs: AI has two credential sources, not one
important-new Aug 4, 2026
416842d
fix(security): scope three by-id writes by tenant instead of baselini…
important-new Aug 4, 2026
e0ef3a8
refactor(events): give the visit lifecycle its own status constant
important-new Aug 4, 2026
7e5edb2
refactor: stop exporting six symbols nothing outside their module uses
important-new Aug 4, 2026
b06afb9
fix(i18n): let the client take the server's locale when there is no c…
important-new Aug 4, 2026
b56956f
feat(scheduling): add booking_conflict_policy tenant config
important-new Aug 4, 2026
b240d5f
feat(scheduling): inspection schedule PATCH endpoint
important-new Aug 4, 2026
d4cb244
feat(dispatch): dispatch route loader
important-new Aug 4, 2026
e61f57c
feat(invoices): record an offline payment against an invoice
important-new Aug 4, 2026
9b1ed39
feat(invoices): correct a mistyped payment without editing the ledger
important-new Aug 4, 2026
ba20f1d
feat(invoices): show the payment ledger and record against it
important-new Aug 5, 2026
44b7d12
fix(gates): stop the capability gate firing on prose, and un-export a…
important-new Aug 5, 2026
c5e2f75
feat(dispatch): read-only dispatch board
important-new Aug 5, 2026
9f3672a
feat(dispatch): drag-drop reschedule and reassign
important-new Aug 5, 2026
027fb37
feat(dispatch): find-a-time modal on new inspection
important-new Aug 5, 2026
acc56d0
feat(dispatch): navigation and calendar cross-link
important-new Aug 5, 2026
6a9ea64
fix(dispatch): read the dropped card from dataTransfer, not from Reac…
important-new Aug 5, 2026
8d373a0
merge: scope standalone login to tenant, fail closed on ambiguity
important-new Aug 5, 2026
ea6d1c2
feat(idempotency): canonical request fingerprinting
important-new Aug 5, 2026
1ca1962
feat(idempotency): key store, with the row as the lock
important-new Aug 5, 2026
cea8da6
feat(idempotency): useGuardedSubmit — one submit, one key, rotated on…
important-new Aug 5, 2026
b5615b2
feat(idempotency): Hono middleware with replay and claim semantics
important-new Aug 5, 2026
f87e9e8
fix(inspections): guard the New Inspection create against double subm…
important-new Aug 5, 2026
f93dd2c
feat(idempotency): protect outbound email and SMS sends
important-new Aug 5, 2026
1b04640
merge: client-side guarded submit for the duplicate-creation path (#105)
important-new Aug 5, 2026
8c736a6
fix(idempotency): forward the wizard's key as Idempotency-Key on crea…
important-new Aug 5, 2026
bbe06a8
fix(e2e): make the seed fixtures actually seed, and the seeded owner …
important-new Aug 5, 2026
fc5728b
test(e2e): dispatch board drag-to-assign and block-policy refusal (Ph…
important-new Aug 5, 2026
d974cda
test(automations): stop the variant-order spec deciding on a coin flip
important-new Aug 5, 2026
b72687c
chore(deps): clear the four Dependabot advisories blocking the release
important-new Aug 5, 2026
ed5b55b
chore(dispatch): drop two exports nothing outside the module uses
important-new Aug 5, 2026
11a57b5
test(e2e): Dispatch is a domain term now, not sci-fi jargon
important-new Aug 5, 2026
2884a2c
test(e2e): probe /status for readiness, and record an undiagnosed fir…
important-new Aug 5, 2026
63fd8d2
fix(agreements): stop the public module carrying private legal material
important-new Aug 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,9 @@ OpenInspection runs as ONE Cloudflare Worker (cloudflare/react-router-hono-fulls
| `APP_BASE_URL` | No | Public URL for OAuth and link generation |
| `APP_BASE_URL` | No | Public origin used when building absolute links (reports, hosted `/legal/:tenant/…` Privacy & Terms). |
| `RESEND_API_KEY`| No | Platform-default email delivery (Resend). Tenants may switch to their OWN Resend key + verified sender via Settings → Communication (per-tenant override; the email pipeline resolves own-vs-platform explicitly). |
| `GEMINI_API_KEY`| No | DEPRECATED as a platform key — AI assistance is strictly bring-your-own-key: `AIService` reads the tenant's own stored key (Settings → Advanced) and ignores this env. AI features stay off until a tenant configures a key. |
| `GEMINI_API_KEY`| No | Not the credential AI features run on. `AIService` resolves credentials per call (`server/lib/ai/resolve-provider.ts`): a tenant's own stored key (Settings → Advanced → AI) always wins, and in `saas` mode a deployment-provided key may be used instead for tenants the deployment grants managed access to. In `standalone` there is no managed path at all — the tenant's key or nothing. This env is still read by the Advanced-settings "Test connection" diagnostic. |
| `AI_MODEL` | No | Model id every AI call uses (e.g. a Gemini model name). **No default is compiled in**: when unset, AI features fail closed with a 503 rather than silently pinning whichever model was current when the code was written. Required for any AI feature to work, in every mode. |
| `AI_MANAGED_API_KEY` | No | Deployment-provided AI key. Used only where `profile.hasManagedAi` is true (`saas`), and only for tenants the deployment grants managed access to; an entitled tenant on a deployment that never provisioned this key gets the feature OFF, not a runtime credential error. Absent in `standalone` by construction rather than disabled by a flag. Usage on this key meters under `ai_translate`/`ai_assist`; usage on a tenant's own key meters under `ai_translate_byo`/`ai_assist_byo` and never counts against a deployment allowance. |
| `APP_MODE` | No | `standalone` (default) or `saas` — controls tenant resolution |
| `APP_NAME` | No | Custom branding name |
| `PRIMARY_COLOR` | No | Custom branding color |
Expand Down
15 changes: 12 additions & 3 deletions app/components/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { Modal } from "@core/shared-ui";
import { m } from "~/paraglide/messages";

/**
* Both button labels are TRANSLATED, and the confirm label defaults rather than
* being hardcoded. They used to be the bare strings "Cancel" and "Delete" —
* which meant this one component silently shipped untranslated chrome to every
* one of its call sites, in the middle of dialogs whose title and message were
* translated. A shared component is the worst place to leave a literal: it does
* not look like ten omissions, it looks like one.
*/
export function ConfirmDialog({
open, title, message, confirmLabel = "Delete", tone = "danger", busy = false, onConfirm, onCancel,
open, title, message, confirmLabel, tone = "danger", busy = false, onConfirm, onCancel,
}: {
open: boolean;
title: string;
Expand Down Expand Up @@ -29,15 +38,15 @@ export function ConfirmDialog({
onClick={onCancel}
className="px-4 py-2 rounded-md border border-ih-border text-[13px] font-bold text-ih-fg-2 hover:bg-ih-bg-muted transition-colors"
>
Cancel
{m.common_cancel()}
</button>
<button
type="button"
onClick={onConfirm}
disabled={busy}
className={`px-4 py-2 rounded-md text-[13px] font-bold transition-opacity disabled:opacity-50 ${confirmClass}`}
>
{confirmLabel}
{confirmLabel ?? m.common_delete()}
</button>
</>
}
Expand Down
100 changes: 100 additions & 0 deletions app/components/LocaleSwitcher.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// @vitest-environment happy-dom
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import { createRoutesStub } from "react-router";
import { LocaleSwitcher } from "./LocaleSwitcher";

/**
* The switcher has to do BOTH writes or it is broken in a way that looks like
* it works. Cookie only: the choice is lost on the next device, and — worse —
* `auth-layout`'s stamp ranks the stored preference above the cookie and
* corrects it straight back on the following navigation. Database only: nothing
* changes until a round trip completes, so the control appears dead.
*
* Rendered through a real router stub rather than bare, so the assertion is on
* what the PROFILE ACTION actually receives — a spy would pass against a
* component that submits to nowhere.
*/
function renderSwitcher(serverLocale: string) {
const submitted: { intent?: string; locale?: string } = {};
const Stub = createRoutesStub([
{
id: "root",
path: "/",
loader: () => ({ locale: serverLocale }),
Component: () => <LocaleSwitcher />,
},
{
path: "/settings/profile",
action: async ({ request }: { request: Request }) => {
const fd = await request.formData();
submitted.intent = String(fd.get("intent"));
submitted.locale = String(fd.get("locale"));
return { success: true };
},
},
]);
render(<Stub initialEntries={["/"]} />);
return submitted;
}

describe("LocaleSwitcher", () => {
beforeEach(() => {
// A cookie surviving between cases would let a test pass on the previous
// test's write.
document.cookie = "PARAGLIDE_LOCALE=; path=/; max-age=0";
});

it("writes the cookie and persists the choice", async () => {
const submitted = renderSwitcher("en");
fireEvent.click(await screen.findByRole("radio", { name: /español/i }));

expect(document.cookie).toContain("PARAGLIDE_LOCALE=es-419");
// Persisted as the tag the settings <select> stores, not the Paraglide tag:
// saving 'es-419' here and 'en' in the other direction would make Profile
// show "Use workspace default" for a preference just set.
await waitFor(() => expect(submitted.intent).toBe("set-locale"));
expect(submitted.locale).toBe("es-419");
});

it("switches back to English, storing the region-qualified tag", async () => {
// The reverse direction, because a switcher that only ever answers Spanish
// passes the test above.
const submitted = renderSwitcher("es-419");
fireEvent.click(await screen.findByRole("radio", { name: /english/i }));

expect(document.cookie).toContain("PARAGLIDE_LOCALE=en");
await waitFor(() => expect(submitted.locale).toBe("en-US"));
});

it("reflects the locale the SERVER rendered, not a local default", async () => {
renderSwitcher("es-419");
expect(await screen.findByRole("radio", { name: /español/i })).toHaveAttribute(
"aria-checked",
"true",
);
expect(screen.getByRole("radio", { name: /english/i })).toHaveAttribute(
"aria-checked",
"false",
);
});

it("does nothing when the current language is re-selected", async () => {
const submitted = renderSwitcher("en");
fireEvent.click(await screen.findByRole("radio", { name: /english/i }));

expect(document.cookie).not.toContain("PARAGLIDE_LOCALE=");
expect(submitted.intent).toBeUndefined();
});

it("understands a stored tag the cookie contract cannot serve", async () => {
// The root loader reports whatever the paraglide scope resolved, but a
// regional tag reaching this control must still select a real segment
// rather than leaving every one of them unchecked.
renderSwitcher("es-MX");
expect(await screen.findByRole("radio", { name: /español/i })).toHaveAttribute(
"aria-checked",
"true",
);
});
});
76 changes: 76 additions & 0 deletions app/components/LocaleSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useFetcher, useRouteLoaderData } from "react-router";
import { SegmentedControl, type SegmentedControlOption } from "@core/shared-ui";
import { SUPPORTED_CONTACT_LOCALES, normalizeLocale } from "../../server/lib/i18n/contact-locale";
import { localeShortLabel, storedLocaleTag } from "~/lib/locales";
import { writeUiLocaleCookie } from "~/lib/ui-prefs";
import { m } from "~/paraglide/messages";

/**
* The always-reachable language control (#269).
*
* Two pickers already persist a language — Settings → Profile writes
* `users.locale`, Settings → Workspace writes `tenant_configs.default_locale`.
* Neither is reachable from the page you are on, and, more to the point,
* neither is reachable by someone who cannot read the English word "Settings".
* This is the one control a person can find when the interface is in a language
* they do not speak, which is why it sits beside the theme control in the user
* menu rather than on a settings page.
*
* Deliberately built on the same `SegmentedControl` as `ThemeSegmentControl`,
* next to which it renders: a bespoke dropdown here would be the third language
* control in the app and the only one shaped unlike its own neighbour.
*
* TWO WRITES, and both are needed:
*
* 1. The COOKIE, written first and synchronously. The worker resolves the
* render locale from it before the router runs (`withResolvedUiLocale`), so
* this is what makes the change take effect on the very next request rather
* than after a database round trip.
* 2. `users.locale`, through the existing profile action, so the choice
* survives a new device — and so `auth-layout`'s stamp, which ranks the
* stored preference ABOVE the cookie, agrees with it instead of correcting
* it back on the next navigation.
*
* The re-render is a consequence of (2), not a separate mechanism: React Router
* revalidates every loader after a fetcher submission, so the root loader re-runs
* server-side inside the new request's paraglide scope and the whole tree — plus
* `<html lang>` — comes back in the new language. No reload, no flash.
*/
export function LocaleSwitcher({ className }: { className?: string }) {
// Root loader data, exactly as ThemeSegmentControl reads the color scheme:
// it is the locale the SERVER rendered this page in, so the control always
// shows what the reader is actually looking at rather than what was last
// clicked. Absent while the error boundary renders — fall back to English.
const rootData = useRouteLoaderData("root") as { locale?: string } | undefined;
const current = normalizeLocale(rootData?.locale) ?? "en";
const fetcher = useFetcher();

// Built at render time (not a module const) so `m.*()` resolves inside the
// paraglide request scope — same reason ThemeSegmentControl builds its own.
//
// The option LABELS come from the locale table, not from message keys: a
// language name is not translated ("Español" is Español in every language),
// and a per-locale key would let the two catalogues disagree about what a
// language is called.
const options: SegmentedControlOption[] = SUPPORTED_CONTACT_LOCALES.map((value) => ({
value,
label: localeShortLabel(value),
}));

return (
<SegmentedControl
options={options}
value={current}
ariaLabel={m.nav_language_aria()}
className={className}
onChange={(next) => {
if (next === current) return;
writeUiLocaleCookie(next);
fetcher.submit(
{ intent: "set-locale", locale: storedLocaleTag(next) },
{ method: "post", action: "/settings/profile" },
);
}}
/>
);
}
32 changes: 31 additions & 1 deletion app/components/NewInspectionWizard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* - string → at cap, billingPortalUrl for the "Subscribe" CTA.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, fireEvent } from '@testing-library/react';
import { render, fireEvent, act } from '@testing-library/react';
import { createElement } from 'react';

const fetcherMocks = {
Expand Down Expand Up @@ -208,5 +208,35 @@ describe('NewInspectionWizard — client + buyer-agent payload', () => {
expect(payload).toHaveProperty('templateId', 'tpl-1');
// Batch C — the wizard sends the zone it displayed, not a bare local time.
expect(payload).toHaveProperty('timeZone', 'UTC');
// Idempotency (portal #105): the create carries a key the server can
// dedupe on. Without it, the guard below only narrows the window.
expect(typeof payload.idempotencyKey).toBe('string');
expect(payload.idempotencyKey.length).toBeGreaterThan(0);
});

/**
* Portal #105, seen in production on 2026-08-05: one tenant created three
* byte-identical inspections seconds apart. Create called `fetcher.submit`
* with nothing guarding it and the button stayed live, so every impatient
* click was another inspection.
*
* Both clicks go inside ONE act(): React batches the handlers and renders
* nothing between them, which is what a real double click looks like and
* why a `fetcher.state` check cannot see the second one.
*/
it('creates one inspection when Create is clicked twice in the same tick (portal #105)', () => {
const { getAllByRole } = walkToConfirm();
const createBtn = (getAllByRole('button') as HTMLButtonElement[])
.find((b) => b.textContent?.includes('Create Inspection'));
expect(createBtn).toBeTruthy();

act(() => {
createBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
createBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});

const createCalls = fetcherMocks.submit.mock.calls
.filter((c) => (c[0] as { intent?: string })?.intent === 'create');
expect(createCalls.length).toBe(1);
});
});
13 changes: 11 additions & 2 deletions app/components/NewInspectionWizard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { useFetcher } from "react-router";
import { useContactSearch } from "~/hooks/useContactSearch";
import { useGuardedSubmit } from "~/hooks/useGuardedSubmit";
import { buildWizardSteps, stepBlockedReason, todayLocalISO, type WizardStepId } from "~/lib/wizard-steps";
import { summariseNewInspection } from "~/lib/wizard-review";
import { buildWizardCreatePayload } from "~/lib/wizard-submit";
Expand Down Expand Up @@ -92,7 +93,11 @@ export function NewInspectionWizard({
*/
quotaExceededAtOpen?: string | null;
}) {
const fetcher = useFetcher();
// portal #105 — the create submit is guarded, not bare: one in-flight submit
// at a time, carrying an idempotency key the server dedupes on. A tenant
// created three byte-identical inspections seconds apart because Create was a
// plain `fetcher.submit` behind a button that stayed live.
const { fetcher, submit: submitCreate, busy: creating } = useGuardedSubmit();
// The zone the Schedule step names, and the zone the typed time is read in.
// Both must be the same value or the inspector is told one thing and the
// booking stores another.
Expand Down Expand Up @@ -390,7 +395,10 @@ export function NewInspectionWizard({
});

function handleSubmit() {
fetcher.submit(
// Returns false and does nothing if a create is already in flight — the
// button below is disabled too, but that only takes effect on the NEXT
// render, which a double click beats.
submitCreate(
buildWizardCreatePayload({
propertyType,
address,
Expand Down Expand Up @@ -435,6 +443,7 @@ export function NewInspectionWizard({
stepIdx={stepIdx}
stepLabel={stepLabel}
blockedReason={blockedReason}
busy={creating}
isLastStep={stepIdx === steps.length - 1}
onBack={() => (stepIdx > 0 ? setStepIdx(stepIdx - 1) : onClose())}
onNext={() => (stepIdx < steps.length - 1 ? setStepIdx(stepIdx + 1) : handleSubmit())}
Expand Down
7 changes: 4 additions & 3 deletions app/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useState, useRef } from "react";
import { NavLink, useRouteLoaderData } from "react-router";
import { useSessionContext, useUnreadMessages } from "~/hooks/useSessionContext";
import { useCapabilities, useSessionContext, useUnreadMessages } from "~/hooks/useSessionContext";
import { writeSidebarCookie, type UiPrefs } from "~/lib/ui-prefs";
import { IC, WORKSPACE_ITEMS } from "~/components/sidebar/nav-items";
import { IC, WORKSPACE_ITEMS, visibleNavItems } from "~/components/sidebar/nav-items";
import { SidebarGroup } from "~/components/sidebar/SidebarGroup";
import { UserMenuPopover } from "~/components/sidebar/UserMenuPopover";
import { MobileHeader } from "~/components/sidebar/MobileHeader";
Expand All @@ -21,6 +21,7 @@ export function Sidebar() {
// flash from the old two-pass localStorage read).
const rootPrefs = useRouteLoaderData("root") as UiPrefs | undefined;
const [collapsed, setCollapsed] = useState(rootPrefs?.sidebarCollapsed ?? false);
const capabilities = useCapabilities();
const [userMenuOpen, setUserMenuOpen] = useState(false);
const userMenuRef = useRef<HTMLDivElement>(null);
const ctx = useSessionContext();
Expand Down Expand Up @@ -98,7 +99,7 @@ export function Sidebar() {

{/* Nav */}
<nav className="flex-1 px-2 py-1 overflow-y-auto">
<SidebarGroup label={m.nav_section_workspace()} items={WORKSPACE_ITEMS.map((i) => (i.to === "/messages" ? { ...i, badge: unreadMessages } : i))} collapsed={collapsed} />
<SidebarGroup label={m.nav_section_workspace()} items={visibleNavItems(WORKSPACE_ITEMS, capabilities).map((i) => (i.to === "/messages" ? { ...i, badge: unreadMessages } : i))} collapsed={collapsed} />
{/* ds-allow: compact sidebar nav rhythm (10/7/14px), no semantic spacing token */}
<div className="mb-[14px]">
<NavLink
Expand Down
39 changes: 39 additions & 0 deletions app/components/agreements/AgreementLanguageDisclosure.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { AgreementLanguageDisclosure } from './AgreementLanguageDisclosure';
import { AGREEMENT_LANGUAGE_DISCLOSURE } from '../../../server/lib/legal/agreement-language-disclosure';

// What this file can and cannot settle:
//
// It can check what a reader sees. It CANNOT check which allow-list the mount
// pass uses — DOMPurify under happy-dom drops the outermost element and applies
// no allow-list at all, so a round trip through it proves nothing about a
// browser (measured; see tests/unit/agreements/language-disclosure.spec.ts).
// Worse, the server-sanitized-then-re-sanitized pattern means the wrong
// component would render identical markup on this first pass, so no synchronous
// DOM assertion here can tell the two apart. The sanitizer choice is asserted
// against source in that spec, and confirmed in a real browser.

describe('AgreementLanguageDisclosure', () => {
it('states the fact, under a heading that says it is not a term', () => {
const { container } = render(<AgreementLanguageDisclosure />);
expect(container.textContent).toContain(AGREEMENT_LANGUAGE_DISCLOSURE.label);
expect(container.textContent).toMatch(/provided in English/i);
expect(container.textContent).toMatch(/translated before signing/i);
});

it('keeps the wrapper that marks it as a note rather than prose', () => {
const { container } = render(<AgreementLanguageDisclosure />);
const note = container.querySelector('section[role="note"]');
expect(note, 'the wrapper is what stops this reading as a loose paragraph').not.toBeNull();
expect(note!.textContent).toMatch(/provided in English/i);
});

it('offers nothing to click and nothing to load', () => {
// A platform note inside a signing flow is the last place to introduce an
// outbound link or a remote asset.
const { container } = render(<AgreementLanguageDisclosure />);
expect(container.querySelector('a, img, iframe, svg, form')).toBeNull();
});
});
Loading
Loading