feat(uat-batch-18): interest-berths defaults + a11y loading/hint fixes

- `addInterestBerth` insert-time defaults now match the locked
  multi-berth EOI UX (queue B2):
    is_in_eoi_bundle: true   (was false)
    is_specific_interest: matches `isPrimary` (was always true)
  This means a newly-linked berth is covered by the EOI signature by
  default but the public map only shows the primary as "Under Offer"
  until the rep marks others isSpecificInterest. The two existing
  integration tests pass explicit values so they're unaffected.
- A11y: `set-password` form's password-requirements hint linked via
  aria-describedby so SR users hear the rules on focus.
- A11y: Loading fallbacks on set-password / portal/activate /
  supplemental-info wrapped in role="status" aria-live="polite" with
  sr-only "Loading" copy where only a spinner was visible.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 18:35:52 +02:00
parent 1f8bd47a7b
commit 05e727f462
4 changed files with 28 additions and 7 deletions

View File

@@ -104,7 +104,9 @@ function SetPasswordInner() {
if (token === null) {
return (
<BrandedAuthShell>
<div className="text-center text-sm text-gray-500">Loading</div>
<div role="status" aria-live="polite" className="text-center text-sm text-gray-500">
Loading
</div>
</BrandedAuthShell>
);
}
@@ -141,10 +143,13 @@ function SetPasswordInner() {
type="password"
autoComplete="new-password"
disabled={isLoading}
aria-describedby="password-hint"
className={cn(errors.password && 'border-destructive focus-visible:ring-destructive')}
{...register('password')}
/>
<p className="text-xs text-gray-500">At least {MIN_LENGTH} characters.</p>
<p id="password-hint" className="text-xs text-gray-500">
At least {MIN_LENGTH} characters.
</p>
{errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
</div>

View File

@@ -6,7 +6,11 @@ export default function PortalActivatePage() {
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center bg-gray-50 text-sm text-gray-500">
<div
role="status"
aria-live="polite"
className="min-h-screen flex items-center justify-center bg-gray-50 text-sm text-gray-500"
>
Loading
</div>
}

View File

@@ -140,8 +140,9 @@ export default function SupplementalInfoPage({ params }: PageProps) {
if (loading) {
return (
<BrandedAuthShell>
<div className="flex items-center justify-center py-8">
<div role="status" aria-live="polite" className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="sr-only">Loading</span>
</div>
</BrandedAuthShell>
);

View File

@@ -272,14 +272,25 @@ export async function upsertInterestBerthTx(
}
}
// EOI bundle UX (locked 2026-05-18): a deal's EOI typically covers
// every berth linked to the interest, but only the rep's "main"
// berth (the primary) should show "Under Offer" on the public map.
// The defaults below encode that workflow so reps don't have to
// tick boxes for the common case:
// • `is_in_eoi_bundle` defaults to TRUE for every newly-linked
// berth (rep unticks for the rare carve-out).
// • `is_specific_interest` defaults to TRUE only on the primary;
// non-primary rows default to FALSE so the public map doesn't
// light up extra berths.
const isPrimary = opts.isPrimary ?? false;
const [row] = await tx
.insert(interestBerths)
.values({
interestId,
berthId,
isPrimary: opts.isPrimary ?? false,
isSpecificInterest: opts.isSpecificInterest ?? true,
isInEoiBundle: opts.isInEoiBundle ?? false,
isPrimary,
isSpecificInterest: opts.isSpecificInterest ?? isPrimary,
isInEoiBundle: opts.isInEoiBundle ?? true,
addedBy: opts.addedBy,
notes: opts.notes,
eoiBypassReason: setForUpdate.eoiBypassReason ?? null,