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
2 changes: 1 addition & 1 deletion src/app/signup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const metadata = { title: "Sign up — Octo" };

export default function SignupPage() {
return (
<AuthShell>
<AuthShell consent>
<AuthForm mode="signup" />
</AuthShell>
);
Expand Down
37 changes: 21 additions & 16 deletions src/components/auth/AuthShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="relative min-h-screen">
{/* Fixed backdrop: burgundy glow + faint isometric grid. The underwater
Expand Down Expand Up @@ -46,21 +53,19 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
</div>
{children}

<p className="mx-auto mt-8 max-w-sm text-center text-xs text-muted">
By signing up, you agree to our{" "}
<Link href="/terms" className="underline">
Terms
</Link>{" "}
and{" "}
<Link href="/terms" className="underline">
Conditions of use
</Link>{" "}
and{" "}
<Link href="/privacy" className="underline">
Privacy policy
</Link>
.
</p>
{consent && (
<p className="mx-auto mt-8 max-w-sm text-center text-xs text-muted">
By signing up, you agree to our{" "}
<Link href="/terms" className="underline">
Terms of Service
</Link>{" "}
and{" "}
<Link href="/privacy" className="underline">
Privacy policy
</Link>
.
</p>
)}
</div>
</div>
</div>
Expand Down
6 changes: 6 additions & 0 deletions src/components/auth/OtpInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Array<HTMLInputElement | null>>([]);
const digits = Array.from({ length: 6 }, (_, i) => value[i] ?? "");
Expand Down Expand Up @@ -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"
/>
Expand Down
11 changes: 10 additions & 1 deletion src/components/checkout/PayerPrivacyNotice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@
export function PayerPrivacyNotice({ merchantName }: { merchantName: string }) {
return (
<p className="mt-1 text-[11px] text-gray-500">
Shared with {merchantName} and Octo to process this payment. See our Privacy Policy.
Shared with {merchantName} and Octo to process this payment. See our{" "}
<a
href="/privacy"
target="_blank"
rel="noopener noreferrer"
className="underline hover:no-underline"
>
Privacy Policy
</a>
.
</p>
);
}
42 changes: 38 additions & 4 deletions src/components/dashboard/Modal.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -16,16 +39,27 @@ export function Modal({
}) {
const titleId = useId();
const panelRef = useRef<HTMLDivElement>(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 (
<div
Expand Down
22 changes: 13 additions & 9 deletions src/lib/useFocusTrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,21 @@ import { useEffect, useRef, type RefObject } from "react";
const FOCUSABLE =
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';

/** Traps Tab focus inside `ref`, focuses it on mount, and restores prior focus on unmount. */
export function useFocusTrap(ref: RefObject<HTMLElement | null>) {
/**
* 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<HTMLElement | null>, enabled = true) {
const previous = useRef<HTMLElement | null>(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<HTMLElement>(FOCUSABLE));
(items()[0] ?? root).focus();

Expand All @@ -34,9 +41,6 @@ export function useFocusTrap(ref: RefObject<HTMLElement | null>) {
}
}
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("keydown", onKey);
previous.current?.focus?.();
};
}, [ref]);
return () => document.removeEventListener("keydown", onKey);
}, [ref, enabled]);
}