diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index 87a86c9..165e2cb 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -9,7 +9,7 @@ export const metadata = { title: "Sign up — Octo" }; export default function SignupPage() { return ( - + ); diff --git a/src/components/auth/AuthShell.tsx b/src/components/auth/AuthShell.tsx index 56d3152..afde3b1 100644 --- a/src/components/auth/AuthShell.tsx +++ b/src/components/auth/AuthShell.tsx @@ -5,7 +5,14 @@ import { AuthWalletScene } from "@/components/auth/AuthWalletScene"; /** Split-screen auth layout: glass form card on the left, brand copy on the * right, both floating over a full-screen underwater "secure deposits" scene. */ -export function AuthShell({ children }: { children: React.ReactNode }) { +export function AuthShell({ + children, + consent = false, +}: { + children: React.ReactNode; + /** Show the signup consent line. Only the signup page sets it. */ + consent?: boolean; +}) { return (
{/* Fixed backdrop: burgundy glow + faint isometric grid. The underwater @@ -46,21 +53,19 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
{children} -

- By signing up, you agree to our{" "} - - Terms - {" "} - and{" "} - - Conditions of use - {" "} - and{" "} - - Privacy policy - - . -

+ {consent && ( +

+ By signing up, you agree to our{" "} + + Terms of Service + {" "} + and{" "} + + Privacy policy + + . +

+ )} diff --git a/src/components/auth/OtpInput.tsx b/src/components/auth/OtpInput.tsx index 99f9cf9..42026d7 100644 --- a/src/components/auth/OtpInput.tsx +++ b/src/components/auth/OtpInput.tsx @@ -7,10 +7,14 @@ export function OtpInput({ value, onChange, disabled, + "aria-describedby": describedBy, + "aria-invalid": invalid, }: { value: string; onChange: (code: string) => void; disabled?: boolean; + "aria-describedby"?: string; + "aria-invalid"?: "true" | "false"; }) { const inputs = useRef>([]); const digits = Array.from({ length: 6 }, (_, i) => value[i] ?? ""); @@ -50,6 +54,8 @@ export function OtpInput({ inputMode="numeric" autoComplete={i === 0 ? "one-time-code" : "off"} aria-label={`Digit ${i + 1} of 6`} + aria-describedby={describedBy} + aria-invalid={invalid} maxLength={1} className="h-12 w-10 rounded-lg border border-border bg-surface-sunken text-center text-lg font-semibold text-foreground focus:border-burgundy-bright focus:outline-none disabled:opacity-60" /> diff --git a/src/components/checkout/PayerPrivacyNotice.tsx b/src/components/checkout/PayerPrivacyNotice.tsx index f1ed486..b0b43de 100644 --- a/src/components/checkout/PayerPrivacyNotice.tsx +++ b/src/components/checkout/PayerPrivacyNotice.tsx @@ -2,7 +2,16 @@ export function PayerPrivacyNotice({ merchantName }: { merchantName: string }) { return (

- Shared with {merchantName} and Octo to process this payment. See our Privacy Policy. + Shared with {merchantName} and Octo to process this payment. See our{" "} + + Privacy Policy + + .

); } diff --git a/src/components/dashboard/Modal.tsx b/src/components/dashboard/Modal.tsx index 9f40e30..f3f42da 100644 --- a/src/components/dashboard/Modal.tsx +++ b/src/components/dashboard/Modal.tsx @@ -1,10 +1,33 @@ "use client"; -import { useEffect, useId, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react"; import { QRCodeSVG } from "qrcode.react"; import { CopyButton } from "@/components/CopyButton"; import { useFocusTrap } from "@/lib/useFocusTrap"; +// Open modals, outermost first. Only the top one handles Escape and traps Tab. +const modalStack: string[] = []; +const stackListeners = new Set<() => void>(); + +function notifyStack() { + stackListeners.forEach((listener) => listener()); +} + +function subscribeStack(listener: () => void) { + stackListeners.add(listener); + return () => { + stackListeners.delete(listener); + }; +} + +function useIsTopModal(id: string) { + return useSyncExternalStore( + subscribeStack, + () => modalStack[modalStack.length - 1] === id, + () => false, + ); +} + export function Modal({ title, onClose, @@ -16,16 +39,27 @@ export function Modal({ }) { const titleId = useId(); const panelRef = useRef(null); - useFocusTrap(panelRef); + const isTop = useIsTopModal(titleId); + useFocusTrap(panelRef, isTop); + + useEffect(() => { + modalStack.push(titleId); + notifyStack(); + return () => { + modalStack.splice(modalStack.indexOf(titleId), 1); + notifyStack(); + }; + }, [titleId]); - // Escape closes, matching native dialog behaviour for keyboard users. + // Escape closes, matching native dialog behaviour for keyboard users. Only the top modal reacts. useEffect(() => { + if (!isTop) return; function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [onClose]); + }, [isTop, onClose]); return (
) { +/** + * Traps Tab focus inside `ref` while `enabled`, focusing it when enabled. Prior focus is + * restored on unmount. Nested modals pass `enabled` only for the top one. + */ +export function useFocusTrap(ref: RefObject, enabled = true) { const previous = useRef(null); useEffect(() => { - const root = ref.current; - if (!root) return; previous.current = document.activeElement as HTMLElement | null; + return () => previous.current?.focus?.(); + }, []); + + useEffect(() => { + const root = ref.current; + if (!root || !enabled) return; const items = () => Array.from(root.querySelectorAll(FOCUSABLE)); (items()[0] ?? root).focus(); @@ -34,9 +41,6 @@ export function useFocusTrap(ref: RefObject) { } } document.addEventListener("keydown", onKey); - return () => { - document.removeEventListener("keydown", onKey); - previous.current?.focus?.(); - }; - }, [ref]); + return () => document.removeEventListener("keydown", onKey); + }, [ref, enabled]); }