Payment Modal
Pay-invoice dialog: amount summary, card/bank method radio cards, method-specific fields, secure note and a pay button that switches to a success state.
neo-corporate/ui/payment-modalSource
"use client";
import { useId, useState, type ReactNode } from "react";
import { Dialog } from "radix-ui";
import { CheckCircle2, CreditCard, Landmark, Lock, X } from "lucide-react";
import { cn } from "@/lib/utils";
export interface PaymentModalProps {
trigger?: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
invoice?: string;
payee?: string;
amount?: number;
currency?: string;
onPay?: (method: "card" | "ach") => void;
da?: string;
}
/** Pay-invoice dialog: amount summary, card/bank method radio cards, method-specific fields, secure note and a pay button that switches to a success state. */
export function PaymentModal({
trigger,
open,
defaultOpen,
onOpenChange,
invoice = "INV-2041",
payee = "Ledgerly Inc.",
amount = 19872,
currency = "USD",
onPay,
da = "neo-corporate",
}: PaymentModalProps) {
const id = useId();
const [method, setMethod] = useState<"card" | "ach">("ach");
const [paid, setPaid] = useState(false);
const fmt = amount.toLocaleString("en-US", { style: "currency", currency });
const fee = method === "card" ? amount * 0.024 + 0.2 : Math.min(amount * 0.008, 5);
const field = "da-stroke h-10 w-full rounded-da-md bg-da-surface px-3 text-sm outline-none focus:border-da-primary focus:ring-3 focus:ring-da-ring/25";
return (
<Dialog.Root
open={open}
defaultOpen={defaultOpen}
onOpenChange={(o) => {
if (!o) setPaid(false);
onOpenChange?.(o);
}}
>
{trigger && <Dialog.Trigger asChild>{trigger}</Dialog.Trigger>}
<Dialog.Portal>
<Dialog.Overlay
data-da={da}
className="fixed inset-0 z-50 bg-da-overlay transition-opacity duration-(--da-duration) starting:opacity-0 motion-reduce:transition-none"
/>
<Dialog.Content
data-da={da}
className="da-stroke fixed top-1/2 left-1/2 z-50 max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-da-lg bg-da-surface text-da-surface-fg shadow-da-lg outline-none transition-[opacity,scale] duration-(--da-duration) starting:scale-[0.98] starting:opacity-0 motion-reduce:transition-none"
>
<Dialog.Close
aria-label="Close"
className="da-focus absolute top-4 right-4 grid size-8 place-items-center rounded-da-md text-da-muted-fg hover:bg-da-muted"
>
<X aria-hidden className="size-4" />
</Dialog.Close>
{paid ? (
<div role="status" className="px-6 py-12 text-center">
<CheckCircle2 aria-hidden className="mx-auto size-12 text-da-success" />
<Dialog.Title className="mt-4 font-da-display text-xl font-bold">Payment submitted</Dialog.Title>
<Dialog.Description className="mt-1 text-sm text-da-muted-fg">
{fmt} to {payee}. A receipt is on its way to your inbox.
</Dialog.Description>
</div>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
onPay?.(method);
setPaid(true);
}}
>
<div className="border-b border-da-border bg-da-surface-2 px-6 pt-6 pb-5">
<Dialog.Title className="text-sm font-medium text-da-muted-fg">
Pay {invoice} · {payee}
</Dialog.Title>
<Dialog.Description className="mt-1 font-da-mono text-3xl font-medium tabular-nums">{fmt}</Dialog.Description>
</div>
<div className="grid gap-4 px-6 py-5">
<fieldset>
<legend className="mb-2 text-sm font-medium">Payment method</legend>
<div className="grid grid-cols-2 gap-2">
{(
[
{ v: "ach", l: "Bank (ACH)", s: "0.8%, max $5", i: Landmark },
{ v: "card", l: "Card", s: "2.4% + 20¢", i: CreditCard },
] as const
).map((m) => (
<label
key={m.v}
className={cn(
"da-stroke flex cursor-pointer flex-col gap-1 rounded-da-md p-3 text-sm has-focus-visible:ring-3 has-focus-visible:ring-da-ring/40",
method === m.v && "border-da-primary bg-da-primary/5 ring-1 ring-da-primary",
)}
>
<input type="radio" name={`${id}-m`} value={m.v} checked={method === m.v} onChange={() => setMethod(m.v)} className="sr-only" />
<m.i aria-hidden className="size-5 text-da-primary" />
<span className="font-semibold">{m.l}</span>
<span className="text-xs text-da-muted-fg">{m.s}</span>
</label>
))}
</div>
</fieldset>
{method === "card" ? (
<label className="grid gap-1.5 text-sm font-medium">
Card number
<input className={cn(field, "font-da-mono")} inputMode="numeric" autoComplete="cc-number" placeholder="4242 4242 4242 4242" required />
</label>
) : (
<div className="grid grid-cols-2 gap-3">
<label className="grid gap-1.5 text-sm font-medium">
Routing number
<input className={cn(field, "font-da-mono")} inputMode="numeric" defaultValue="021000021" required />
</label>
<label className="grid gap-1.5 text-sm font-medium">
Account number
<input className={cn(field, "font-da-mono")} inputMode="numeric" placeholder="••••6612" required />
</label>
</div>
)}
<p className="flex justify-between text-sm text-da-muted-fg">
<span>Processing fee</span>
<span className="font-da-mono">{fee.toLocaleString("en-US", { style: "currency", currency })}</span>
</p>
</div>
<div className="border-t border-da-border px-6 py-4">
<button type="submit" className="da-focus h-11 w-full rounded-da-md bg-da-primary font-semibold text-da-primary-fg hover:bg-da-primary/90">
Pay {fmt}
</button>
<p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-da-muted-fg">
<Lock aria-hidden className="size-3.5" /> Encrypted and processed by Ledgerly Payments
</p>
</div>
</form>
)}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
export default PaymentModal;
modules/neo-corporate/ui/payment-modal/index.tsx
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| trigger | ReactNode | — | Trigger. |
| open | boolean | — | Open. |
| defaultOpen | boolean | — | Default Open. |
| onOpenChange | (open: boolean) => void | — | Callback. |
| invoice | string | — | Invoice. |
| payee | string | — | Payee. |
| amount | number | — | Amount. |
| currency | string | — | Currency. |
| onPay | (method: "card" | "ach") => void | — | Callback. |
| da | string | — | DA scope applied to portalled content. |
Other modal variants in Neo Corporate
Confirm Dialog
Destructive confirmation (Radix AlertDialog): red warning icon, title, consequence text, optional “type VOID to confirm” guard, cancel + red confirm.
Modal
Corporate dialog (Radix): white card with header (title, description, close), divided scrollable body and a tinted footer bar for actions; quick fade + slight scale.
Side Sheet
Detail drawer (Radix Dialog) sliding in from the right: full-height bordered panel with sticky header, scrollable body and footer actions — for record details.
Modal in other art directions
Confirm Dialog
Destructive confirmation (Radix AlertDialog) over a hazard-striped overlay: red 'Danger zone' band, consequences list, and a type-the-name field that arms the delete button; async pending state.
Drawer
Side sheet (Radix Dialog) sliding in from the right or left in stepped motion: yellow title band with close square, scrollable body for forms, ruled footer actions.
Modal
Accessible dialog on Radix Dialog: colored title band (primary or danger), close button, scrollable body, footer action bar. Re-applies the DA scope inside the portal. Stepped drop-in entrance.
Onboarding Modal
Multi-step onboarding dialog: a colored visual panel (giant step number by default) next to title, description, segmented progress bar and Back/Next buttons; resets on open.
Bottom Sheet
Mobile-style bottom sheet (Radix Dialog): heavily frosted panel sliding up with a grab handle, title and description; becomes a floating centered card on wider screens.
Dropdown Menu
Frosted dropdown menu (Radix DropdownMenu) built from an items array: labels, icons, shortcuts, separators, checkbox items, nested submenus and a danger item.
Modal
Frosted glass sheet dialog (Radix): 40px backdrop blur, optional icon bubble, rise-and-pop entrance with overshoot, blurred overlay, round close button and footer actions.
Share Dialog
Glass share dialog: invite-by-email field with role select, people-with-access list, a general access switch (invited only / anyone with link) and a copy-link button with confirmation.
Command Palette
⌘K command palette (Radix Dialog): search input, results filtered as you type and grouped (actions, incidents, schedules), icons and shortcuts, arrow-key highlight, Enter to run, footer hints.
Modal
Radix-based dialog with fade + subtle scale entrance, optional icon, title/description, scrollable body and a tinted footer action bar. Re-applies the DA scope inside the portal.
Popover Card
Anchored popover (Radix Popover) with arrow, optional title + close button and any content — quick forms (snooze), definitions or details. Collision-aware placement.
Sheet
Side sheet (Radix Dialog) sliding in from the right or left over a dimmed overlay: title and description header with close button, scrollable body and footer actions.
Command Dialog
Command palette (Radix Dialog + listbox): a square panel near the top with a large borderless search, grouped results under mono headings, arrow-key navigation with an inverted active row and shortcut hints.
Drawer
Right side panel (Radix Dialog) sliding in over a dimmed page: mono top bar with label and “Close”, large title, scrollable body with ruled rows and a sticky footer.
Lightbox
Full-screen image viewer (Radix Dialog) on ink (stays dark in dark mode): the image frame centered, mono “03 / 04” counter and caption, Prev/Next text buttons and arrow-key navigation, thumbnails strip.
Modal
Square dialog (Radix): a mono label + text “Close” in the top bar, a full-ink rule, a large title, body and right-aligned actions; opens with a quick fade and 8px rise.
Bottom Sheet
Bottom sheet (Radix Dialog): slides up from the bottom with a grab handle and very rounded top corners; becomes a centered card from sm up.
Confirm Dialog
Gentle confirmation (Radix AlertDialog): a wilting-leaf illustration in a clay circle, serif question, kind consequence text, a sage “keep” action and a quiet clay confirm.
Modal
Soft dialog (Radix): large-radius cream card that rises gently with a slight overshoot, serif title, round close button and pill actions.
Welcome Modal
Onboarding dialog: an illustrated tinted header (sprout growing with each step), serif title and copy, leaf-shaped step dots, Back/Next pills and “Let’s begin” on the last step.
Celebrate Modal
“Week complete!” celebration dialog: pastel confetti shapes burst from the center, trophy emoji, friendly copy, three pastel stat tiles and one pill button.
Confirm Sheet
Destructive confirmation shown as a bottom sheet on mobile and a centered card on desktop: big emoji in a red-tint circle, gentle copy, red confirm with pending state and a soft cancel.
Modal
Soft modal (Radix Dialog): big rounded white card popping in with a small bounce, pastel emoji circle, round close button, body and pill footer actions.
Popover Menu
Rounded dropdown menu (Radix DropdownMenu) with emoji items, keyboard hints, soft highlight, separators and a red danger item; pops in with a bounce.