From 68cc780f8640153cb17aa84aad9c43a76120d371 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Tue, 23 Jun 2026 19:40:05 +0200 Subject: [PATCH 01/43] add mocked dashboard app --- apps/dashboard/.gitignore | 7 + apps/dashboard/components.json | 21 + ...registration-and-country-selection-plan.md | 207 ++++++ apps/dashboard/index.html | 18 + apps/dashboard/package.json | 42 ++ apps/dashboard/src/App.css | 148 +++++ .../src/components/auth/AuthOtpStep.tsx | 54 ++ .../src/components/auth/CountrySelectStep.tsx | 71 +++ .../components/auth/RegisterDetailsStep.tsx | 141 +++++ .../src/components/layout/AccountSwitcher.tsx | 55 ++ .../src/components/layout/AppSidebar.tsx | 61 ++ .../components/layout/NotificationsBell.tsx | 56 ++ .../src/components/layout/Topbar.tsx | 19 + .../src/components/layout/UserMenu.tsx | 61 ++ .../src/components/layout/VortexLogo.tsx | 17 + .../onboarding/AddCorridorDropdown.tsx | 41 ++ .../components/onboarding/CorridorCard.tsx | 110 ++++ .../onboarding/DocumentDropzone.tsx | 13 + .../onboarding/OnboardingWizard.tsx | 159 +++++ .../src/components/onboarding/StatusBadge.tsx | 23 + .../onboarding/WizardStepFields.tsx | 60 ++ .../components/recipients/RecipientDialog.tsx | 149 +++++ .../components/recipients/RecipientsTable.tsx | 46 ++ .../transactions/TransactionsTable.tsx | 53 ++ .../src/components/transfer/TransferForm.tsx | 357 +++++++++++ apps/dashboard/src/components/ui/avatar.tsx | 29 + apps/dashboard/src/components/ui/badge.tsx | 36 ++ apps/dashboard/src/components/ui/button.tsx | 44 ++ apps/dashboard/src/components/ui/card.tsx | 53 ++ apps/dashboard/src/components/ui/checkbox.tsx | 26 + apps/dashboard/src/components/ui/dialog.tsx | 112 ++++ .../src/components/ui/dropdown-menu.tsx | 201 ++++++ apps/dashboard/src/components/ui/form.tsx | 128 ++++ .../dashboard/src/components/ui/input-otp.tsx | 57 ++ apps/dashboard/src/components/ui/input.tsx | 20 + apps/dashboard/src/components/ui/label.tsx | 18 + apps/dashboard/src/components/ui/popover.tsx | 39 ++ apps/dashboard/src/components/ui/progress.tsx | 21 + apps/dashboard/src/components/ui/select.tsx | 152 +++++ .../dashboard/src/components/ui/separator.tsx | 25 + apps/dashboard/src/components/ui/sheet.tsx | 92 +++ apps/dashboard/src/components/ui/sidebar.tsx | 596 ++++++++++++++++++ apps/dashboard/src/components/ui/skeleton.tsx | 8 + apps/dashboard/src/components/ui/sonner.tsx | 20 + apps/dashboard/src/components/ui/table.tsx | 70 ++ apps/dashboard/src/components/ui/tabs.tsx | 39 ++ apps/dashboard/src/components/ui/tooltip.tsx | 45 ++ apps/dashboard/src/domain/corridors.ts | 87 +++ apps/dashboard/src/domain/seed.ts | 138 ++++ apps/dashboard/src/domain/status.ts | 22 + apps/dashboard/src/domain/transfer.ts | 35 + apps/dashboard/src/domain/types.ts | 100 +++ apps/dashboard/src/hooks/use-mobile.ts | 17 + apps/dashboard/src/hooks/useActiveAccount.ts | 6 + apps/dashboard/src/lib/cn.ts | 6 + apps/dashboard/src/lib/notify.ts | 56 ++ .../src/machines/brazilKyb.machine.ts | 46 ++ .../src/machines/brazilKyc.machine.ts | 46 ++ .../src/machines/europeKyc.machine.ts | 44 ++ .../src/machines/register.machine.ts | 90 +++ apps/dashboard/src/machines/types.ts | 22 + apps/dashboard/src/main.tsx | 14 + apps/dashboard/src/routeTree.gen.ts | 234 +++++++ apps/dashboard/src/router.tsx | 18 + apps/dashboard/src/routes/__root.tsx | 15 + apps/dashboard/src/routes/_app.tsx | 29 + apps/dashboard/src/routes/_app/overview.tsx | 105 +++ apps/dashboard/src/routes/_app/recipients.tsx | 72 +++ apps/dashboard/src/routes/_app/settings.tsx | 66 ++ .../src/routes/_app/transactions.tsx | 78 +++ apps/dashboard/src/routes/_app/transfer.tsx | 53 ++ apps/dashboard/src/routes/index.tsx | 5 + apps/dashboard/src/routes/login.tsx | 90 +++ apps/dashboard/src/routes/register.tsx | 115 ++++ apps/dashboard/src/stores/auth.store.ts | 33 + apps/dashboard/src/stores/dashboard.store.ts | 151 +++++ .../src/stores/notifications.store.ts | 29 + apps/dashboard/tsconfig.json | 27 + apps/dashboard/vite.config.ts | 29 + apps/frontend/_redirects | 3 + bun.lock | 121 +++- package.json | 4 +- 82 files changed, 5819 insertions(+), 7 deletions(-) create mode 100644 apps/dashboard/.gitignore create mode 100644 apps/dashboard/components.json create mode 100644 apps/dashboard/docs/registration-and-country-selection-plan.md create mode 100644 apps/dashboard/index.html create mode 100644 apps/dashboard/package.json create mode 100644 apps/dashboard/src/App.css create mode 100644 apps/dashboard/src/components/auth/AuthOtpStep.tsx create mode 100644 apps/dashboard/src/components/auth/CountrySelectStep.tsx create mode 100644 apps/dashboard/src/components/auth/RegisterDetailsStep.tsx create mode 100644 apps/dashboard/src/components/layout/AccountSwitcher.tsx create mode 100644 apps/dashboard/src/components/layout/AppSidebar.tsx create mode 100644 apps/dashboard/src/components/layout/NotificationsBell.tsx create mode 100644 apps/dashboard/src/components/layout/Topbar.tsx create mode 100644 apps/dashboard/src/components/layout/UserMenu.tsx create mode 100644 apps/dashboard/src/components/layout/VortexLogo.tsx create mode 100644 apps/dashboard/src/components/onboarding/AddCorridorDropdown.tsx create mode 100644 apps/dashboard/src/components/onboarding/CorridorCard.tsx create mode 100644 apps/dashboard/src/components/onboarding/DocumentDropzone.tsx create mode 100644 apps/dashboard/src/components/onboarding/OnboardingWizard.tsx create mode 100644 apps/dashboard/src/components/onboarding/StatusBadge.tsx create mode 100644 apps/dashboard/src/components/onboarding/WizardStepFields.tsx create mode 100644 apps/dashboard/src/components/recipients/RecipientDialog.tsx create mode 100644 apps/dashboard/src/components/recipients/RecipientsTable.tsx create mode 100644 apps/dashboard/src/components/transactions/TransactionsTable.tsx create mode 100644 apps/dashboard/src/components/transfer/TransferForm.tsx create mode 100644 apps/dashboard/src/components/ui/avatar.tsx create mode 100644 apps/dashboard/src/components/ui/badge.tsx create mode 100644 apps/dashboard/src/components/ui/button.tsx create mode 100644 apps/dashboard/src/components/ui/card.tsx create mode 100644 apps/dashboard/src/components/ui/checkbox.tsx create mode 100644 apps/dashboard/src/components/ui/dialog.tsx create mode 100644 apps/dashboard/src/components/ui/dropdown-menu.tsx create mode 100644 apps/dashboard/src/components/ui/form.tsx create mode 100644 apps/dashboard/src/components/ui/input-otp.tsx create mode 100644 apps/dashboard/src/components/ui/input.tsx create mode 100644 apps/dashboard/src/components/ui/label.tsx create mode 100644 apps/dashboard/src/components/ui/popover.tsx create mode 100644 apps/dashboard/src/components/ui/progress.tsx create mode 100644 apps/dashboard/src/components/ui/select.tsx create mode 100644 apps/dashboard/src/components/ui/separator.tsx create mode 100644 apps/dashboard/src/components/ui/sheet.tsx create mode 100644 apps/dashboard/src/components/ui/sidebar.tsx create mode 100644 apps/dashboard/src/components/ui/skeleton.tsx create mode 100644 apps/dashboard/src/components/ui/sonner.tsx create mode 100644 apps/dashboard/src/components/ui/table.tsx create mode 100644 apps/dashboard/src/components/ui/tabs.tsx create mode 100644 apps/dashboard/src/components/ui/tooltip.tsx create mode 100644 apps/dashboard/src/domain/corridors.ts create mode 100644 apps/dashboard/src/domain/seed.ts create mode 100644 apps/dashboard/src/domain/status.ts create mode 100644 apps/dashboard/src/domain/transfer.ts create mode 100644 apps/dashboard/src/domain/types.ts create mode 100644 apps/dashboard/src/hooks/use-mobile.ts create mode 100644 apps/dashboard/src/hooks/useActiveAccount.ts create mode 100644 apps/dashboard/src/lib/cn.ts create mode 100644 apps/dashboard/src/lib/notify.ts create mode 100644 apps/dashboard/src/machines/brazilKyb.machine.ts create mode 100644 apps/dashboard/src/machines/brazilKyc.machine.ts create mode 100644 apps/dashboard/src/machines/europeKyc.machine.ts create mode 100644 apps/dashboard/src/machines/register.machine.ts create mode 100644 apps/dashboard/src/machines/types.ts create mode 100644 apps/dashboard/src/main.tsx create mode 100644 apps/dashboard/src/routeTree.gen.ts create mode 100644 apps/dashboard/src/router.tsx create mode 100644 apps/dashboard/src/routes/__root.tsx create mode 100644 apps/dashboard/src/routes/_app.tsx create mode 100644 apps/dashboard/src/routes/_app/overview.tsx create mode 100644 apps/dashboard/src/routes/_app/recipients.tsx create mode 100644 apps/dashboard/src/routes/_app/settings.tsx create mode 100644 apps/dashboard/src/routes/_app/transactions.tsx create mode 100644 apps/dashboard/src/routes/_app/transfer.tsx create mode 100644 apps/dashboard/src/routes/index.tsx create mode 100644 apps/dashboard/src/routes/login.tsx create mode 100644 apps/dashboard/src/routes/register.tsx create mode 100644 apps/dashboard/src/stores/auth.store.ts create mode 100644 apps/dashboard/src/stores/dashboard.store.ts create mode 100644 apps/dashboard/src/stores/notifications.store.ts create mode 100644 apps/dashboard/tsconfig.json create mode 100644 apps/dashboard/vite.config.ts diff --git a/apps/dashboard/.gitignore b/apps/dashboard/.gitignore new file mode 100644 index 000000000..290bcd476 --- /dev/null +++ b/apps/dashboard/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +.output +.nitro +.tanstack +dev-boot.log +*.log diff --git a/apps/dashboard/components.json b/apps/dashboard/components.json new file mode 100644 index 000000000..3846d54d8 --- /dev/null +++ b/apps/dashboard/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "aliases": { + "components": "@/components", + "hooks": "@/hooks", + "lib": "@/lib", + "ui": "@/components/ui", + "utils": "@/lib/cn" + }, + "iconLibrary": "lucide", + "rsc": false, + "style": "new-york", + "tailwind": { + "baseColor": "neutral", + "config": "", + "css": "src/App.css", + "cssVariables": true, + "prefix": "" + }, + "tsx": true +} diff --git a/apps/dashboard/docs/registration-and-country-selection-plan.md b/apps/dashboard/docs/registration-and-country-selection-plan.md new file mode 100644 index 000000000..235a11be9 --- /dev/null +++ b/apps/dashboard/docs/registration-and-country-selection-plan.md @@ -0,0 +1,207 @@ +# Plan — Registration + Country-of-Interest Selection (Vortex Dashboard) + +> Status: **PLAN ONLY — not implemented.** Scope addition to the existing mocked +> `apps/dashboard`. This document is the spec to implement later. + +## 1. Goal / User story + +> As a new Vortex user, I want to **register for the service** and **choose which +> countries/corridors I'm interested in**. The dashboard then shows only those +> corridors for verification, and I can **add the remaining corridors later from a +> dropdown**. + +This builds on the existing dashboard (fake login, account switcher with 2 seeded +accounts, Brazil/Europe corridor cards, XState KYB/KYC wizards, recipients gated by +approval, notifications). + +## 2. Decisions locked during grilling + +| Decision | Choice | +|---|---| +| Entry routes | Two dedicated routes: **`/register`** and **`/login`** (cross-linked). | +| What register produces | Creates a **new sender account**, added to the switcher and made active. The 2 seeded demo accounts **remain**. | +| Auth UX | **Mirror the Vortex Widget**: email + Terms checkbox → OTP (6-digit) → authenticated. Same for KYB/KYC (already mirrored). | +| Corridor catalog | **Brazil + Europe are the only working corridors.** Alfredpay corridors (Mexico, Colombia, USA, Argentina) appear as **"Coming soon"** (selectable for interest, locked in dashboard). | +| Country selection | Chosen during `/register`; dashboard shows only selected corridors; the rest are addable from an **"Add country" dropdown**. | +| State management | XState v5 for the auth flow (consistent with the widget and existing dashboard wizards); Zustand for stored data. | + +## 3. What "mirror the Vortex Widget" means (reference) + +Source flow in `apps/frontend` (the widget), to replicate **as a mock** (no real +Supabase / no real API — accept any email, any 6-digit code): + +``` +EnterEmail [AuthEmailStep] email + Terms & Conditions checkbox → ENTER_EMAIL + → CheckingEmail (mock: always proceed) + → RequestingOTP (mock: pretend to send code) + → EnterOTP [AuthOTPStep] 6-digit InputOTP, auto-submits on 6th digit → VERIFY_OTP + → VerifyingOTP (mock: any code accepted) + → authenticated (store mock token in localStorage) +``` + +Widget reference files (for UX parity only — do **not** import from the frontend app): +- `apps/frontend/src/components/widget-steps/AuthEmailStep/index.tsx` (email + T&C) +- `apps/frontend/src/components/widget-steps/AuthOTPStep/index.tsx` (6-digit `InputOTP`, auto-submit, "we sent a code to ") +- `apps/frontend/src/components/widget-steps/RegionSelectStep/index.tsx` (region `DropdownSelector`) +- `apps/frontend/src/machines/ramp.machine.ts` (auth states embedded), `src/machines/actors/auth.actor.ts` +- Provider routing: `src/machines/kyc.states.ts` (BRL→Avenia, EURC→Mykobo, ARS/USD/MXN/COP→Alfredpay) + +Mock parity notes: +- Email step requires checking the **Terms & Conditions** box before continuing. +- OTP step uses a **6-digit numeric `InputOTP`** that **auto-submits** when full; shows + the target email; offers "Change email" and "Resend code" (both no-op/mock). +- Tokens stored in `localStorage` (reuse the existing persisted auth store). + +## 4. Corridor catalog change + +Today corridors come from a fixed `CORRIDOR_LIST` of 2 (BR, EU). Expand the **catalog** +to 6, matching the real `FiatToken` set, with an availability flag. + +| Corridor | Country | Currency | Provider | Availability | +|---|---|---|---|---| +| `BR` | Brazil | BRL | Avenia | **live** | +| `EU` | Europe | EURC | Mykobo | **live** | +| `MX` | Mexico | MXN | Alfredpay | coming_soon | +| `CO` | Colombia | COP | Alfredpay | coming_soon | +| `US` | USA | USD | Alfredpay | coming_soon | +| `AR` | Argentina | ARS | Alfredpay | coming_soon | + +- Add `availability: "live" | "coming_soon"` to the `Corridor` type. +- Only `live` corridors run the XState wizards. `coming_soon` corridors render a + locked card (badge "Coming soon", no Start button, disabled in the wizard). +- `coming_soon` corridors **can still be selected** at registration / added later + (the user expresses interest); they simply can't be verified yet. + +## 5. Data model changes + +### `Corridor` (`src/domain/types.ts` + `corridors.ts`) +- Add `availability: "live" | "coming_soon"`. +- Add the 4 Alfredpay corridors to `CORRIDORS` and `CORRIDOR_LIST`. + +### `SenderAccount` (`src/domain/types.ts`) +- Add `selectedCorridors: CorridorId[]` — the corridors the account chose to track. +- `onboardings` becomes **partial**: `Partial>`, + populated only for selected corridors. (Adding a country creates its onboarding.) +- Helper: when a corridor is selected, its onboarding initializes to + `not_started` (live) — coming_soon corridors show a derived "Coming soon" state. + +### Seed (`src/domain/seed.ts`) +- Give the 2 seeded accounts a `selectedCorridors: ["BR", "EU"]` so existing demo + is unchanged. + +### New status surface +- Either add a derived display state `"coming_soon"` in `STATUS_META` / `StatusBadge`, + or compute it from `corridor.availability` at render. Recommended: compute from + `corridor.availability` (don't pollute the onboarding status enum, which mirrors + real provider enums). + +## 6. New routes & flow + +### `/register` (outside the app shell, like `/login`) +A multi-step flow (XState `registerMachine`), mirroring the widget: + +1. **Account details** — name, email, account type (company / individual), + **Terms & Conditions** checkbox. (RHF + Zod.) +2. **OTP** — 6-digit `InputOTP`, auto-submit, mock-accept any code. "Change email" / + "Resend". +3. **Choose countries** — multi-select grid/list over the 6-corridor catalog: + - Brazil & Europe tagged **Available**; the 4 Alfredpay corridors tagged **Coming soon**. + - At least one selection required (recommend defaulting Brazil + Europe checked). +4. **Finish** → `useDashboardStore.createAccount({...})`: + - creates a new `SenderAccount` with `selectedCorridors`, account type, name, identifier; + - initializes onboardings (`not_started` for selected live corridors); + - sets it active; authenticates (auth store `login(email)`); navigates to `/overview`. + +### `/login` (existing, refactored to mirror widget) +- Step 1: email + Terms checkbox. +- Step 2: OTP (6-digit, auto-submit, mock). +- → `/overview` (seeded demo accounts). +- Add a "Don't have an account? **Register**" link; `/register` gets "Already have an + account? **Log in**". + +### Route guards +- `_app` layout already redirects to `/login` when unauthenticated — unchanged. +- `/register` and `/login`: if already authenticated, ``. + +## 7. Dashboard changes (Overview) + +- **Filter corridor cards to `activeAccount.selectedCorridors`** (instead of the full + `CORRIDOR_LIST`). +- **"Add country" dropdown** (top-right of the corridors section): lists catalog + corridors **not** in `selectedCorridors`. Selecting one calls + `dashboardStore.addCorridorToAccount(accountId, corridorId)` → appends to + `selectedCorridors` + creates its onboarding → card appears. + - Live corridors appear startable; coming_soon corridors appear locked. +- **Coming-soon card**: badge "Coming soon", greyed progress, button disabled + ("Available soon"). +- Summary cards: count over selected corridors (Corridors / Approved / In progress); + optionally a 4th "Coming soon" count. +- Recipients gating unchanged (only **approved live** corridors unlock recipients). + +## 8. Store changes (`src/stores/dashboard.store.ts`) +- `createAccount(input: { name; email; type; selectedCorridors })`: builds a + `SenderAccount`, pushes to `accounts`, sets `activeAccountId`, returns id. +- `addCorridorToAccount(accountId, corridorId)`: adds to `selectedCorridors` and seeds + its `Onboarding` (`not_started`). No-op if already present. +- Existing `setOnboardingStatus` / recipient actions unchanged (guard for partial + onboardings). +- Consider persisting accounts to `localStorage` so a registered account survives a + reload (today the dashboard store is in-memory; auth persists but accounts don't — + a full reload currently resets to seeds). **Decision needed** (see open questions). + +## 9. New / changed files (estimate) + +**New** +- `src/routes/register.tsx` — `/register` route hosting the register flow. +- `src/machines/register.machine.ts` — XState v5 (details → otp → countries → done). +- `src/machines/auth.machine.ts` *(optional)* — shared email→OTP sub-flow reused by + `/login` and `/register`. +- `src/components/auth/AuthEmailStep.tsx` — email + T&C (mirrors widget). +- `src/components/auth/AuthOtpStep.tsx` — 6-digit OTP (needs an `InputOTP` ui component). +- `src/components/auth/CountrySelectStep.tsx` — catalog multi-select. +- `src/components/onboarding/AddCorridorDropdown.tsx` — "Add country" dropdown. +- `src/components/ui/input-otp.tsx` — shadcn `InputOTP` (uses `input-otp` dep — add it). + +**Changed** +- `src/domain/types.ts` — `Corridor.availability`, `SenderAccount.selectedCorridors`, partial onboardings. +- `src/domain/corridors.ts` — add MX/CO/US/AR + availability + flags. +- `src/domain/seed.ts` — `selectedCorridors` on seeded accounts. +- `src/domain/status.ts` / `StatusBadge.tsx` — coming-soon rendering. +- `src/stores/dashboard.store.ts` — `createAccount`, `addCorridorToAccount`. +- `src/routes/login.tsx` — refactor to email→OTP, add register link. +- `src/routes/_app/overview.tsx` — filter by selectedCorridors + Add-country dropdown. +- `src/components/onboarding/CorridorCard.tsx` — coming-soon variant. +- `package.json` — add `input-otp` (and `@radix-ui`? no — `input-otp` is standalone). + +## 10. Dependencies +- Add **`input-otp`** (used by the widget for the 6-digit code) — `bun add input-otp -F vortex-dashboard`. + +## 11. Edge cases / rules +- Register requires ≥1 selected country and the Terms box checked. +- OTP mock: accept any 6 digits; "Resend" is a no-op toast. +- Adding a country already selected is a no-op. +- Coming-soon corridors: never startable, never unlock recipients, excluded from + "Approved/In progress" counts (or shown separately). +- Switching accounts shows that account's `selectedCorridors` only. +- A registered account with no live corridors selected → Recipients stays locked. + +## 12. Verification (when implemented) +- `bun typecheck`, `bun lint:fix` clean; `vite build` + prerender pass. +- Live browser walk-through: + 1. `/register` → details + T&C → OTP → select Brazil + Mexico → land on dashboard + showing Brazil (startable) + Mexico (coming soon); new account active in switcher. + 2. "Add country" → add Europe → card appears. + 3. Start Brazil KYC → approve → recipients unlock. + 4. `/login` (email→OTP) → lands on seeded demo accounts. + +## 13. Open questions (resolve before building) +1. **Persist registered accounts to localStorage?** (so they survive reload like auth + does). Recommended: yes, persist `accounts` + `activeAccountId` + `recipients` so the + registered account isn't lost on refresh. Trade-off: seeded demo edits also persist. +2. **`/login` Terms checkbox** — widget shows T&C on the email step always; for a + returning-user login it's arguably redundant. Recommended: show T&C only on + `/register`, plain email on `/login`. +3. **Country selection minimum** — force Brazil+Europe preselected, or start empty? + Recommended: preselect Brazil + Europe (the live corridors), allow deselect. +4. **Coming-soon in summary counts** — separate "Coming soon" stat card, or hide from + counts? Recommended: separate stat. diff --git a/apps/dashboard/index.html b/apps/dashboard/index.html new file mode 100644 index 000000000..283ab6d9f --- /dev/null +++ b/apps/dashboard/index.html @@ -0,0 +1,18 @@ + + + + + + Vortex Dashboard + + + + + +
+ + + diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json new file mode 100644 index 000000000..9cc2d9c4a --- /dev/null +++ b/apps/dashboard/package.json @@ -0,0 +1,42 @@ +{ + "dependencies": { + "@hookform/resolvers": "^3.9.1", + "@tanstack/react-router": "^1.170.16", + "@xstate/react": "^6.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "input-otp": "^1.4.2", + "lucide-react": "^0.562.0", + "radix-ui": "^1.4.3", + "react": "19.2.0", + "react-dom": "19.2.0", + "react-hook-form": "^7.65.0", + "sonner": "^1.7.1", + "tailwind-merge": "^3.4.0", + "xstate": "^5.20.1", + "zod": "^4.3.6", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.3", + "@tanstack/router-plugin": "^1.168.11", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.2.0", + "tailwindcss": "^4.0.3", + "tw-animate-css": "^1.4.0", + "typescript": "catalog:", + "vite": "^7.3.5" + }, + "name": "vortex-dashboard", + "private": true, + "scripts": { + "build": "vite build", + "dev": "vite dev --port 5174 --host", + "preview": "vite preview --port 5174", + "typecheck": "tsc --noEmit", + "verify": "biome check --no-errors-on-unmatched" + }, + "type": "module", + "version": "0.1.0" +} diff --git a/apps/dashboard/src/App.css b/apps/dashboard/src/App.css new file mode 100644 index 000000000..d239214a4 --- /dev/null +++ b/apps/dashboard/src/App.css @@ -0,0 +1,148 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +/* + Vortex design tokens (from apps/frontend/App.css) mapped onto the shadcn + token contract. Values are the exact Vortex OKLCH tokens so shadcn primitives + render in the Vortex palette. Light-only, matching the main app. +*/ +:root { + --radius: 0.625rem; + + --background: oklch(0.98 0.005 210); + --foreground: oklch(0.15 0 0); + + --card: oklch(1 0 0); + --card-foreground: oklch(0.15 0 0); + + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.15 0 0); + + --primary: oklch(0.45 0.2 260); + --primary-foreground: oklch(1 0 0); + + --secondary: oklch(0.97 0.003 260); + --secondary-foreground: oklch(0.4 0.04 250); + + --muted: oklch(0.96 0.005 250); + --muted-foreground: oklch(0.55 0.03 255); + + --accent: oklch(0.97 0.003 260); + --accent-foreground: oklch(0.4 0.04 250); + + --destructive: oklch(0.444 0.177 26.899); + --destructive-foreground: oklch(1 0 0); + + --border: oklch(0.92 0.008 250); + --input: oklch(0.92 0.008 250); + --ring: oklch(0.623 0.214 259); + + /* Vortex brand/status extensions (not part of base shadcn contract) */ + --brand-accent: oklch(0.55 0.2 350); + --brand-accent-foreground: oklch(1 0 0); + --success: oklch(0.448 0.119 151.328); + --success-foreground: oklch(1 0 0); + --warning: oklch(0.77 0.16 83); + --warning-foreground: oklch(0.15 0 0); + --info: oklch(0.623 0.214 259); + --info-foreground: oklch(1 0 0); + + --chart-1: oklch(0.623 0.214 259); + --chart-2: oklch(0.45 0.2 260); + --chart-3: oklch(0.55 0.2 350); + --chart-4: oklch(0.77 0.16 83); + --chart-5: oklch(0.448 0.119 151.328); + + --sidebar: oklch(1 0 0); + --sidebar-foreground: oklch(0.4 0.04 250); + --sidebar-primary: oklch(0.45 0.2 260); + --sidebar-primary-foreground: oklch(1 0 0); + --sidebar-accent: oklch(0.97 0.003 260); + --sidebar-accent-foreground: oklch(0.4 0.04 250); + --sidebar-border: oklch(0.92 0.008 250); + --sidebar-ring: oklch(0.623 0.214 259); +} + +@theme inline { + --font-sans: "Red Hat Display", ui-sans-serif, system-ui, sans-serif; + + --animate-caret-blink: caret-blink 1.25s ease-out infinite; + + @keyframes caret-blink { + 0%, + 70%, + 100% { + opacity: 1; + } + 20%, + 50% { + opacity: 0; + } + } + + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + + --color-brand-accent: var(--brand-accent); + --color-brand-accent-foreground: var(--brand-accent-foreground); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-info: var(--info); + --color-info-foreground: var(--info-foreground); + + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + + html, + body { + font-family: var(--font-sans); + } + + body { + @apply bg-background text-foreground; + } +} diff --git a/apps/dashboard/src/components/auth/AuthOtpStep.tsx b/apps/dashboard/src/components/auth/AuthOtpStep.tsx new file mode 100644 index 000000000..0a7f8d71c --- /dev/null +++ b/apps/dashboard/src/components/auth/AuthOtpStep.tsx @@ -0,0 +1,54 @@ +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"; + +interface AuthOtpStepProps { + email: string; + onVerify: (code: string) => void; + onChangeEmail: () => void; +} + +export function AuthOtpStep({ email, onVerify, onChangeEmail }: AuthOtpStepProps) { + const [code, setCode] = useState(""); + + return ( +
+

+ We sent a 6-digit code to {email}. Enter it below. +

+ +
+ + + + + + + + + + +
+ + + +
+ + +
+ +

Demo — any 6 digits will verify.

+
+ ); +} diff --git a/apps/dashboard/src/components/auth/CountrySelectStep.tsx b/apps/dashboard/src/components/auth/CountrySelectStep.tsx new file mode 100644 index 000000000..c17190583 --- /dev/null +++ b/apps/dashboard/src/components/auth/CountrySelectStep.tsx @@ -0,0 +1,71 @@ +import { Check } from "lucide-react"; +import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { CORRIDOR_LIST, PROVIDER_LABEL } from "@/domain/corridors"; +import type { CorridorId } from "@/domain/types"; +import { cn } from "@/lib/cn"; + +export function CountrySelectStep({ + defaultSelected, + onSubmit +}: { + defaultSelected: CorridorId[]; + onSubmit: (corridors: CorridorId[]) => void; +}) { + const [selected, setSelected] = useState(defaultSelected); + + function toggle(id: CorridorId) { + setSelected(current => (current.includes(id) ? current.filter(c => c !== id) : [...current, id])); + } + + return ( +
+

+ Pick the countries you want to transfer to. You can add more later from the dashboard. +

+ +
+ {CORRIDOR_LIST.map(corridor => { + const isSelected = selected.includes(corridor.id); + return ( + + ); + })} +
+ + +
+ ); +} diff --git a/apps/dashboard/src/components/auth/RegisterDetailsStep.tsx b/apps/dashboard/src/components/auth/RegisterDetailsStep.tsx new file mode 100644 index 000000000..1cc520e2d --- /dev/null +++ b/apps/dashboard/src/components/auth/RegisterDetailsStep.tsx @@ -0,0 +1,141 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Building2, User } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/cn"; +import type { RegisterDetails } from "@/machines/register.machine"; + +const schema = z.object({ + accountType: z.enum(["company", "individual"]), + email: z.string().email("Enter a valid email"), + name: z.string().min(2, "Enter your name or company name"), + terms: z.boolean().refine(value => value, { message: "Accept the terms to continue" }) +}); + +type FormValues = z.infer; + +export function RegisterDetailsStep({ onSubmit }: { onSubmit: (details: RegisterDetails) => void }) { + const form = useForm({ + defaultValues: { accountType: "company", email: "", name: "", terms: false }, + resolver: zodResolver(schema) + }); + + const accountType = form.watch("accountType"); + + function handleSubmit(values: FormValues) { + onSubmit({ accountType: values.accountType, email: values.email, name: values.name }); + } + + return ( +
+ + ( + + Account type +
+ field.onChange("company")} + selected={accountType === "company"} + /> + field.onChange("individual")} + selected={accountType === "individual"} + /> +
+
+ )} + /> + ( + + {accountType === "company" ? "Company name" : "Full name"} + + + + + + )} + /> + ( + + Email + + + + + + )} + /> + ( + +
+ + field.onChange(value === true)} /> + + + I agree to the Terms & Conditions and Privacy Policy. + +
+ +
+ )} + /> + + + + ); +} + +function AccountTypeOption({ + icon: Icon, + label, + hint, + selected, + onSelect +}: { + icon: typeof Building2; + label: string; + hint: string; + selected: boolean; + onSelect: () => void; +}) { + return ( + + ); +} diff --git a/apps/dashboard/src/components/layout/AccountSwitcher.tsx b/apps/dashboard/src/components/layout/AccountSwitcher.tsx new file mode 100644 index 000000000..164df245f --- /dev/null +++ b/apps/dashboard/src/components/layout/AccountSwitcher.tsx @@ -0,0 +1,55 @@ +import { Building2, Check, ChevronsUpDown, User } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/cn"; +import { useDashboardStore } from "@/stores/dashboard.store"; + +export function AccountSwitcher() { + const accounts = useDashboardStore(state => state.accounts); + const activeAccountId = useDashboardStore(state => state.activeAccountId); + const setActiveAccount = useDashboardStore(state => state.setActiveAccount); + const active = accounts.find(account => account.id === activeAccountId) ?? accounts[0]; + + if (!active) { + return null; + } + + return ( + + + + + + Sender accounts + + {accounts.map(account => ( + setActiveAccount(account.id)}> + {account.type === "company" ? : } +
+ {account.name} + + {account.type} · {account.identifier} + +
+ +
+ ))} +
+
+ ); +} diff --git a/apps/dashboard/src/components/layout/AppSidebar.tsx b/apps/dashboard/src/components/layout/AppSidebar.tsx new file mode 100644 index 000000000..95539aeab --- /dev/null +++ b/apps/dashboard/src/components/layout/AppSidebar.tsx @@ -0,0 +1,61 @@ +import { Link, useRouterState } from "@tanstack/react-router"; +import { ArrowLeftRight, LayoutDashboard, Send, Settings, Wallet } from "lucide-react"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarRail +} from "@/components/ui/sidebar"; +import { VortexLogo } from "./VortexLogo"; + +const NAV_ITEMS = [ + { icon: LayoutDashboard, label: "Overview", to: "/overview" }, + { icon: Wallet, label: "New transfer", to: "/transfer" }, + { icon: ArrowLeftRight, label: "Transactions", to: "/transactions" }, + { icon: Send, label: "Recipients", to: "/recipients" }, + { icon: Settings, label: "Settings", to: "/settings" } +] as const; + +export function AppSidebar() { + const pathname = useRouterState({ select: state => state.location.pathname }); + + return ( + + +
+ +
+
+ + + Onboarding + + + {NAV_ITEMS.map(item => ( + + + + + {item.label} + + + + ))} + + + + + +

Cross-border transfers, unlocked.

+
+ +
+ ); +} diff --git a/apps/dashboard/src/components/layout/NotificationsBell.tsx b/apps/dashboard/src/components/layout/NotificationsBell.tsx new file mode 100644 index 000000000..69ffd1343 --- /dev/null +++ b/apps/dashboard/src/components/layout/NotificationsBell.tsx @@ -0,0 +1,56 @@ +import { Bell, MailCheck } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { unreadCount, useNotificationsStore } from "@/stores/notifications.store"; + +export function NotificationsBell() { + const items = useNotificationsStore(state => state.items); + const markAllRead = useNotificationsStore(state => state.markAllRead); + const unread = unreadCount(items); + + return ( + open && unread > 0 && markAllRead()}> + + + + +
+

Email updates

+ {items.length > 0 && {items.length}} +
+ + {items.length === 0 ? ( +
+ +

No updates yet. Completion emails will show up here.

+
+ ) : ( +
    + {items.map(item => ( +
  • +
    + +
    +

    {item.title}

    +

    {item.body}

    +

    + {new Date(item.createdAt).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })} +

    +
    +
    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/components/layout/Topbar.tsx b/apps/dashboard/src/components/layout/Topbar.tsx new file mode 100644 index 000000000..89360c5e3 --- /dev/null +++ b/apps/dashboard/src/components/layout/Topbar.tsx @@ -0,0 +1,19 @@ +import { Separator } from "@/components/ui/separator"; +import { SidebarTrigger } from "@/components/ui/sidebar"; +import { AccountSwitcher } from "./AccountSwitcher"; +import { NotificationsBell } from "./NotificationsBell"; +import { UserMenu } from "./UserMenu"; + +export function Topbar() { + return ( +
+ + + +
+ + +
+
+ ); +} diff --git a/apps/dashboard/src/components/layout/UserMenu.tsx b/apps/dashboard/src/components/layout/UserMenu.tsx new file mode 100644 index 000000000..571e003bb --- /dev/null +++ b/apps/dashboard/src/components/layout/UserMenu.tsx @@ -0,0 +1,61 @@ +import { useNavigate } from "@tanstack/react-router"; +import { LogOut } from "lucide-react"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from "@/components/ui/dropdown-menu"; +import { useAuthStore } from "@/stores/auth.store"; + +export function UserMenu() { + const user = useAuthStore(state => state.user); + const logout = useAuthStore(state => state.logout); + const navigate = useNavigate(); + + if (!user) { + return null; + } + + const initials = user.name + .split(" ") + .map(part => part.charAt(0)) + .slice(0, 2) + .join("") + .toUpperCase(); + + return ( + + + + + + +
+ {user.name} + {user.email} +
+
+ + { + logout(); + navigate({ to: "/login" }); + }} + variant="destructive" + > + + Log out + +
+
+ ); +} diff --git a/apps/dashboard/src/components/layout/VortexLogo.tsx b/apps/dashboard/src/components/layout/VortexLogo.tsx new file mode 100644 index 000000000..93b41fadd --- /dev/null +++ b/apps/dashboard/src/components/layout/VortexLogo.tsx @@ -0,0 +1,17 @@ +import { cn } from "@/lib/cn"; + +export function VortexLogo({ className, showWordmark = true }: { className?: string; showWordmark?: boolean }) { + return ( +
+ + V + + {showWordmark && ( +
+ Vortex + Dashboard +
+ )} +
+ ); +} diff --git a/apps/dashboard/src/components/onboarding/AddCorridorDropdown.tsx b/apps/dashboard/src/components/onboarding/AddCorridorDropdown.tsx new file mode 100644 index 000000000..c3e626b87 --- /dev/null +++ b/apps/dashboard/src/components/onboarding/AddCorridorDropdown.tsx @@ -0,0 +1,41 @@ +import { Plus } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from "@/components/ui/dropdown-menu"; +import { CORRIDOR_LIST } from "@/domain/corridors"; +import type { SenderAccount } from "@/domain/types"; +import { useDashboardStore } from "@/stores/dashboard.store"; + +export function AddCorridorDropdown({ account }: { account: SenderAccount }) { + const addCorridorToAccount = useDashboardStore(state => state.addCorridorToAccount); + const available = CORRIDOR_LIST.filter(corridor => !account.selectedCorridors.includes(corridor.id)); + + return ( + + + + + + Add a corridor + + {available.map(corridor => ( + addCorridorToAccount(account.id, corridor.id)}> + {corridor.flag} + {corridor.name} + {corridor.availability === "coming_soon" && Soon} + + ))} + + + ); +} diff --git a/apps/dashboard/src/components/onboarding/CorridorCard.tsx b/apps/dashboard/src/components/onboarding/CorridorCard.tsx new file mode 100644 index 000000000..1da364644 --- /dev/null +++ b/apps/dashboard/src/components/onboarding/CorridorCard.tsx @@ -0,0 +1,110 @@ +import { ArrowRight, Clock, RotateCcw } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { onboardingKindFor, PROVIDER_LABEL } from "@/domain/corridors"; +import { STATUS_META } from "@/domain/status"; +import type { Corridor, OnboardingStatus, SenderAccount } from "@/domain/types"; +import { StatusBadge } from "./StatusBadge"; + +interface CorridorCardProps { + account: SenderAccount; + corridor: Corridor; + onStart: () => void; +} + +export function CorridorCard({ account, corridor, onStart }: CorridorCardProps) { + const kind = onboardingKindFor(corridor, account.type); + const onboarding = account.onboardings[corridor.id]; + const isComingSoon = corridor.availability === "coming_soon"; + const meta = onboarding ? STATUS_META[onboarding.status] : null; + + return ( + + +
+
+ {corridor.flag} +
+

{corridor.name}

+

+ {kind.toUpperCase()} · {PROVIDER_LABEL[corridor.provider]} · {corridor.currency} +

+
+
+ {isComingSoon || !onboarding ? ( + + + Coming soon + + ) : ( + + )} +
+
+ + + +

+ {isComingSoon || !onboarding + ? `${PROVIDER_LABEL[corridor.provider]} support is launching soon` + : `Updated ${new Date(onboarding.updatedAt).toLocaleDateString(undefined, { + day: "numeric", + month: "short", + year: "numeric" + })}`} +

+
+ + + {isComingSoon || !onboarding ? ( + + ) : ( + + )} + +
+ ); +} + +function CorridorAction({ status, kind, onStart }: { status: OnboardingStatus; kind: "kyb" | "kyc"; onStart: () => void }) { + if (status === "not_started") { + return ( + + ); + } + if (status === "pending") { + return ( + + ); + } + if (status === "rejected") { + return ( + + ); + } + if (status === "in_review") { + return ( + + ); + } + return ( + + ); +} diff --git a/apps/dashboard/src/components/onboarding/DocumentDropzone.tsx b/apps/dashboard/src/components/onboarding/DocumentDropzone.tsx new file mode 100644 index 000000000..8a958b029 --- /dev/null +++ b/apps/dashboard/src/components/onboarding/DocumentDropzone.tsx @@ -0,0 +1,13 @@ +import { UploadCloud } from "lucide-react"; + +export function DocumentDropzone({ label }: { label: string }) { + return ( +
+
+ +
+

{label}

+

Drag & drop or click to upload (demo — no file is sent)

+
+ ); +} diff --git a/apps/dashboard/src/components/onboarding/OnboardingWizard.tsx b/apps/dashboard/src/components/onboarding/OnboardingWizard.tsx new file mode 100644 index 000000000..c2ea07de0 --- /dev/null +++ b/apps/dashboard/src/components/onboarding/OnboardingWizard.tsx @@ -0,0 +1,159 @@ +import { useMachine } from "@xstate/react"; +import { CheckCircle2, Loader2 } from "lucide-react"; +import { useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { onboardingKindFor } from "@/domain/corridors"; +import type { Corridor, OnboardingStatus, SenderAccount } from "@/domain/types"; +import { cn } from "@/lib/cn"; +import { notifyOnboardingStatus } from "@/lib/notify"; +import { BRAZIL_KYB_STEPS, brazilKybMachine } from "@/machines/brazilKyb.machine"; +import { BRAZIL_KYC_STEPS, brazilKycMachine } from "@/machines/brazilKyc.machine"; +import { EUROPE_KYC_STEPS, europeKycMachine } from "@/machines/europeKyc.machine"; +import type { WizardStep } from "@/machines/types"; +import { useDashboardStore } from "@/stores/dashboard.store"; +import { WizardStepFields } from "./WizardStepFields"; + +interface OnboardingWizardProps { + account: SenderAccount; + corridor: Corridor; + onClose: () => void; +} + +function selectFlow(corridor: Corridor, account: SenderAccount) { + const kind = onboardingKindFor(corridor, account.type); + if (corridor.id === "BR") { + return kind === "kyb" + ? { kind, machine: brazilKybMachine, steps: BRAZIL_KYB_STEPS } + : { kind, machine: brazilKycMachine, steps: BRAZIL_KYC_STEPS }; + } + return { kind, machine: europeKycMachine, steps: EUROPE_KYC_STEPS }; +} + +export function OnboardingWizard({ account, corridor, onClose }: OnboardingWizardProps) { + const setOnboardingStatus = useDashboardStore(state => state.setOnboardingStatus); + const { machine, steps, kind } = useMemo(() => selectFlow(corridor, account), [corridor, account]); + + const [state, send] = useMachine(machine, { + input: { + onStatusChange: (status: OnboardingStatus) => { + setOnboardingStatus(account.id, corridor.id, status); + notifyOnboardingStatus(corridor.name, kind, status); + } + } + }); + + const value = String(state.value); + const stepIndex = steps.findIndex(step => step.id === value); + const activeStep: WizardStep | undefined = steps[stepIndex]; + const isLastStep = stepIndex === steps.length - 1; + const isProcessing = value === "verifying" || value === "review"; + const isApproved = value === "approved"; + + return ( + !isOpen && onClose()} open> + + + + {corridor.flag} + {corridor.name} {kind.toUpperCase()} + + + {account.name} · {corridor.provider === "avenia" ? "Avenia" : "Mykobo"} + + + + {activeStep && } + +
+ {activeStep && ( +
+
+

{activeStep.title}

+

{activeStep.description}

+
+ +
+ )} + + {isProcessing && ( +
+ +
+

{value === "verifying" ? "Submitting your application" : "In review"}

+

+ {value === "verifying" + ? "Sending your details to the verification provider…" + : "The provider is reviewing your submission. We'll email you when it's done."} +

+
+
+ )} + + {isApproved && ( +
+ +
+

Approved

+

+ {corridor.name} {kind.toUpperCase()} is complete. You can now register recipients. +

+
+
+ )} +
+ + + {activeStep && ( + <> + + {stepIndex > 0 && ( + + )} + {isLastStep ? ( + + ) : ( + + )} + + )} + {isProcessing && ( + + )} + {isApproved && } + +
+
+ ); +} + +function Stepper({ steps, currentIndex }: { steps: WizardStep[]; currentIndex: number }) { + const effectiveIndex = currentIndex === -1 ? steps.length : currentIndex; + return ( +
+ {steps.map((step, index) => ( +
+
effectiveIndex && "bg-muted text-muted-foreground" + )} + > + {index + 1} +
+ {index < steps.length - 1 && ( +
+ )} +
+ ))} +
+ ); +} diff --git a/apps/dashboard/src/components/onboarding/StatusBadge.tsx b/apps/dashboard/src/components/onboarding/StatusBadge.tsx new file mode 100644 index 000000000..a97a5e1f6 --- /dev/null +++ b/apps/dashboard/src/components/onboarding/StatusBadge.tsx @@ -0,0 +1,23 @@ +import { CheckCircle2, CircleDashed, Clock, Loader2, XCircle } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { STATUS_META } from "@/domain/status"; +import type { OnboardingStatus } from "@/domain/types"; + +const ICONS: Record = { + approved: CheckCircle2, + in_review: Loader2, + not_started: CircleDashed, + pending: Clock, + rejected: XCircle +}; + +export function StatusBadge({ status }: { status: OnboardingStatus }) { + const meta = STATUS_META[status]; + const Icon = ICONS[status]; + return ( + + + {meta.label} + + ); +} diff --git a/apps/dashboard/src/components/onboarding/WizardStepFields.tsx b/apps/dashboard/src/components/onboarding/WizardStepFields.tsx new file mode 100644 index 000000000..d86c0b11e --- /dev/null +++ b/apps/dashboard/src/components/onboarding/WizardStepFields.tsx @@ -0,0 +1,60 @@ +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { DocumentDropzone } from "./DocumentDropzone"; + +/** + * Visual-only mock fields per wizard step. Nothing is submitted — the wizard + * is driven by its state machine, these inputs just make the flow feel real. + */ +export function WizardStepFields({ stepId }: { stepId: string }) { + switch (stepId) { + case "companyInfo": + return ( +
+ + + +
+ ); + case "representative": + return ( +
+ + + +
+ ); + case "personalInfo": + return ( +
+ + + +
+ ); + case "documents": + return ( +
+ + +
+ ); + case "liveness": + return ( +
+ +
+ ); + default: + return null; + } +} + +function Field({ label, placeholder, type = "text" }: { label: string; placeholder?: string; type?: string }) { + return ( +
+ + +
+ ); +} diff --git a/apps/dashboard/src/components/recipients/RecipientDialog.tsx b/apps/dashboard/src/components/recipients/RecipientDialog.tsx new file mode 100644 index 000000000..aa8f711b3 --- /dev/null +++ b/apps/dashboard/src/components/recipients/RecipientDialog.tsx @@ -0,0 +1,149 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Plus } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from "@/components/ui/dialog"; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { CORRIDORS } from "@/domain/corridors"; +import type { Corridor, SenderAccount } from "@/domain/types"; +import { notifyRecipientRegistered } from "@/lib/notify"; +import { useDashboardStore } from "@/stores/dashboard.store"; + +const schema = z.object({ + corridorId: z.enum(["BR", "EU", "MX", "CO", "US", "AR"]), + destination: z.string().min(4, "Enter a valid destination"), + name: z.string().min(2, "Enter the recipient's name") +}); + +type FormValues = z.infer; + +export function RecipientDialog({ account, approvedCorridors }: { account: SenderAccount; approvedCorridors: Corridor[] }) { + const [open, setOpen] = useState(false); + const addRecipient = useDashboardStore(state => state.addRecipient); + const setRecipientStatus = useDashboardStore(state => state.setRecipientStatus); + + const form = useForm({ + defaultValues: { corridorId: approvedCorridors[0]?.id ?? "BR", destination: "", name: "" }, + resolver: zodResolver(schema) + }); + + const selectedCorridor = CORRIDORS[form.watch("corridorId")]; + const disabled = approvedCorridors.length === 0; + + function onSubmit(values: FormValues) { + const id = addRecipient({ + accountId: account.id, + corridorId: values.corridorId, + destination: values.destination, + name: values.name + }); + // Simulate the provider confirming the registration shortly after submission. + setTimeout(() => { + setRecipientStatus(id, "registered"); + notifyRecipientRegistered(values.name, CORRIDORS[values.corridorId].name); + }, 1500); + form.reset({ corridorId: values.corridorId, destination: "", name: "" }); + setOpen(false); + } + + return ( + + + + + + + Register a recipient + + Add a destination account for {account.name}. Only approved corridors are available. + + +
+ + ( + + Corridor + + + + )} + /> + ( + + Recipient name + + + + + + )} + /> + ( + + {selectedCorridor.recipientLabel} + + + + + {selectedCorridor.recipientMethod === "pix" + ? "PIX key — email, phone, CPF/CNPJ or random key." + : "International Bank Account Number."} + + + + )} + /> + + + + + + +
+
+ ); +} diff --git a/apps/dashboard/src/components/recipients/RecipientsTable.tsx b/apps/dashboard/src/components/recipients/RecipientsTable.tsx new file mode 100644 index 000000000..8a9e379d1 --- /dev/null +++ b/apps/dashboard/src/components/recipients/RecipientsTable.tsx @@ -0,0 +1,46 @@ +import { Badge } from "@/components/ui/badge"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { CORRIDORS } from "@/domain/corridors"; +import type { Recipient } from "@/domain/types"; + +export function RecipientsTable({ recipients }: { recipients: Recipient[] }) { + return ( + + + + Recipient + Corridor + Destination + Status + Added + + + + {recipients.map(recipient => { + const corridor = CORRIDORS[recipient.corridorId]; + return ( + + {recipient.name} + + {corridor.flag} {corridor.name} + + + {corridor.recipientLabel}: {recipient.destination} + + + {recipient.status === "registered" ? ( + Registered + ) : ( + Pending + )} + + + {new Date(recipient.createdAt).toLocaleDateString(undefined, { day: "numeric", month: "short" })} + + + ); + })} + +
+ ); +} diff --git a/apps/dashboard/src/components/transactions/TransactionsTable.tsx b/apps/dashboard/src/components/transactions/TransactionsTable.tsx new file mode 100644 index 000000000..64125aa18 --- /dev/null +++ b/apps/dashboard/src/components/transactions/TransactionsTable.tsx @@ -0,0 +1,53 @@ +import { ArrowRight } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { CORRIDORS } from "@/domain/corridors"; +import { TX_STATUS_META } from "@/domain/status"; +import type { Transaction } from "@/domain/types"; + +export function TransactionsTable({ transactions }: { transactions: Transaction[] }) { + return ( + + + + Corridor + Amount + Counterparty + Status + Date + + + + {transactions.map(tx => { + const corridor = CORRIDORS[tx.corridorId]; + const status = TX_STATUS_META[tx.status]; + return ( + + + {corridor.flag} {corridor.name} + + + + + {tx.fromAmount} {tx.fromCurrency} + + + + {tx.toAmount} {tx.toCurrency} + + + + {tx.counterparty} + + {status.label} + + + {new Date(tx.createdAt).toLocaleDateString(undefined, { day: "numeric", month: "short" })} + + + ); + })} + +
+ ); +} diff --git a/apps/dashboard/src/components/transfer/TransferForm.tsx b/apps/dashboard/src/components/transfer/TransferForm.tsx new file mode 100644 index 000000000..9d2a7b0af --- /dev/null +++ b/apps/dashboard/src/components/transfer/TransferForm.tsx @@ -0,0 +1,357 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useNavigate } from "@tanstack/react-router"; +import { ArrowDownToLine, ArrowUpFromLine, Globe2 } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { CORRIDORS } from "@/domain/corridors"; +import { PAYMENT_METHOD_LABEL, shortenAddress, TRANSFER_NETWORKS, USDC_RATES } from "@/domain/transfer"; +import type { Corridor, CorridorId, SenderAccount } from "@/domain/types"; +import { notifyTransferCompleted } from "@/lib/notify"; +import { useDashboardStore } from "@/stores/dashboard.store"; + +const CORRIDOR_IDS = ["BR", "EU", "MX", "CO", "US", "AR"] as const; + +type TransferMode = "onramp" | "offramp" | "international"; + +const schema = z + .object({ + amount: z.string().refine(value => Number(value) > 0, "Enter an amount"), + corridorId: z.enum(CORRIDOR_IDS), + destinationCorridorId: z.enum(CORRIDOR_IDS).optional(), + mode: z.enum(["onramp", "offramp", "international"]), + network: z.string().min(1), + recipientId: z.string().optional(), + walletAddress: z.string().min(6, "Enter a wallet address") + }) + .superRefine((values, ctx) => { + if (values.mode === "onramp") { + return; + } + if (values.mode === "international" && !values.destinationCorridorId) { + ctx.addIssue({ code: "custom", message: "Select a destination country", path: ["destinationCorridorId"] }); + } + if (!values.recipientId) { + ctx.addIssue({ code: "custom", message: "Select a recipient", path: ["recipientId"] }); + } + }); + +type FormValues = z.infer; + +export function TransferForm({ account, approvedCorridors }: { account: SenderAccount; approvedCorridors: Corridor[] }) { + const navigate = useNavigate(); + const recipients = useDashboardStore(state => state.recipients); + const addTransaction = useDashboardStore(state => state.addTransaction); + const setTransactionStatus = useDashboardStore(state => state.setTransactionStatus); + + const firstCorridor = approvedCorridors[0]?.id ?? "BR"; + const form = useForm({ + defaultValues: { + amount: "", + corridorId: firstCorridor, + destinationCorridorId: undefined, + mode: "offramp", + network: TRANSFER_NETWORKS[0].id, + recipientId: undefined, + walletAddress: "" + }, + resolver: zodResolver(schema) + }); + + const mode = form.watch("mode"); + const corridorId = form.watch("corridorId"); + const destinationCorridorId = form.watch("destinationCorridorId"); + const amount = form.watch("amount"); + + const isOnramp = mode === "onramp"; + const isInternational = mode === "international"; + const needsRecipient = mode === "offramp" || mode === "international"; + + // The corridor whose fiat rail, recipient and currency settle the transfer. + const payoutCorridorId: CorridorId = isInternational && destinationCorridorId ? destinationCorridorId : corridorId; + const payoutCorridor = CORRIDORS[payoutCorridorId]; + + // International (cross-corridor payout) requires a KYB'd company verified in 2+ corridors. + const canGoInternational = account.type === "company" && approvedCorridors.length >= 2; + const destinationOptions = approvedCorridors.filter(corridor => corridor.id !== corridorId); + const payoutRecipients = recipients.filter( + recipient => + recipient.accountId === account.id && recipient.corridorId === payoutCorridorId && recipient.status === "registered" + ); + + const rate = USDC_RATES[payoutCorridorId]; + const amountNum = Number(amount) || 0; + const sendCurrency = isOnramp ? CORRIDORS[corridorId].currency : "USDC"; + const receiveCurrency = isOnramp ? "USDC" : payoutCorridor.currency; + const receiveAmount = isOnramp ? amountNum / rate : amountNum * rate; + const missingRecipient = needsRecipient && payoutRecipients.length === 0; + + function changeMode(next: TransferMode) { + form.setValue("mode", next); + form.setValue("destinationCorridorId", undefined); + form.setValue("recipientId", undefined); + } + + function changeCorridor(next: CorridorId) { + form.setValue("corridorId", next); + // Keep the destination distinct and reset the recipient tied to the old payout corridor. + if (next === form.getValues("destinationCorridorId")) { + form.setValue("destinationCorridorId", undefined); + } + form.setValue("recipientId", undefined); + } + + function onSubmit(values: FormValues) { + const recipient = payoutRecipients.find(item => item.id === values.recipientId); + const counterparty = isOnramp ? `Wallet ${shortenAddress(values.walletAddress)}` : (recipient?.name ?? "Recipient"); + + const id = addTransaction({ + accountId: account.id, + corridorId: isOnramp ? values.corridorId : payoutCorridorId, + counterparty, + direction: isOnramp ? "onramp" : "offramp", + fromAmount: amountNum.toFixed(2), + fromCurrency: sendCurrency, + status: "processing", + toAmount: receiveAmount.toFixed(2), + toCurrency: receiveCurrency + }); + + const label = isOnramp ? "On-ramp" : isInternational ? "International transfer" : "Off-ramp"; + const summary = `${label} of ${amountNum.toFixed(2)} ${sendCurrency} → ${receiveAmount.toFixed(2)} ${receiveCurrency}`; + // Simulate the provider settling the transfer shortly after it is triggered. + setTimeout(() => { + setTransactionStatus(id, "completed"); + notifyTransferCompleted(summary); + }, 1800); + + navigate({ to: "/transactions" }); + } + + return ( +
+ + ( + + Type + changeMode(value as TransferMode)} value={field.value}> + + + + On-ramp + + + + Off-ramp + + + + International + + + + {descriptionFor(mode, canGoInternational)} + + )} + /> + + ( + + {isInternational ? "From country" : "Country"} + + + + )} + /> + + {isInternational && ( + ( + + Destination country + + + + )} + /> + )} + + + Payment method + + + {payoutCorridor.name} settles over {PAYMENT_METHOD_LABEL[payoutCorridor.recipientMethod]}. + + + + {needsRecipient && ( + ( + + Recipient + + {missingRecipient ? ( + Register a {payoutCorridor.name} recipient first on the Recipients page. + ) : ( + + )} + + )} + /> + )} + +
+ ( + + Wallet network + + + )} + /> + ( + + {isOnramp ? "Destination wallet" : "Source wallet"} + + + + + + )} + /> +
+ + ( + + You send ({sendCurrency}) + + + + {amountNum > 0 && ( + + Recipient gets ≈ {receiveAmount.toFixed(2)} {receiveCurrency} + + )} + + + )} + /> + + + + + ); +} + +function descriptionFor(mode: TransferMode, canGoInternational: boolean) { + if (mode === "onramp") { + return "Pay with fiat and receive stablecoin in your wallet."; + } + if (mode === "international") { + return "Send stablecoin and pay a recipient in a different country."; + } + return canGoInternational + ? "Send stablecoin from your wallet and pay a recipient in fiat." + : "Send stablecoin and pay a recipient in fiat. International transfers unlock with KYB in 2+ countries."; +} + +function submitLabelFor(mode: TransferMode) { + if (mode === "onramp") { + return "Buy stablecoin"; + } + if (mode === "international") { + return "Send international transfer"; + } + return "Send transfer"; +} diff --git a/apps/dashboard/src/components/ui/avatar.tsx b/apps/dashboard/src/components/ui/avatar.tsx new file mode 100644 index 000000000..f8164b021 --- /dev/null +++ b/apps/dashboard/src/components/ui/avatar.tsx @@ -0,0 +1,29 @@ +import { Avatar as AvatarPrimitive } from "radix-ui"; +import type * as React from "react"; +import { cn } from "@/lib/cn"; + +function Avatar({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AvatarImage({ className, ...props }: React.ComponentProps) { + return ; +} + +function AvatarFallback({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/apps/dashboard/src/components/ui/badge.tsx b/apps/dashboard/src/components/ui/badge.tsx new file mode 100644 index 000000000..a8cf71ef8 --- /dev/null +++ b/apps/dashboard/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { Slot as SlotPrimitive } from "radix-ui"; +import type * as React from "react"; +import { cn } from "@/lib/cn"; + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 font-medium text-xs w-fit whitespace-nowrap shrink-0 gap-1 [&>svg]:size-3 [&>svg]:pointer-events-none transition-[color,box-shadow] overflow-hidden", + { + defaultVariants: { + variant: "default" + }, + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + destructive: "border-transparent bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90", + info: "border-transparent bg-info/10 text-info", + outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + success: "border-transparent bg-success/10 text-success", + warning: "border-transparent bg-warning/15 text-warning-foreground" + } + } + } +); + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? SlotPrimitive.Root : "span"; + return ; +} + +export { Badge, badgeVariants }; diff --git a/apps/dashboard/src/components/ui/button.tsx b/apps/dashboard/src/components/ui/button.tsx new file mode 100644 index 000000000..550266bc6 --- /dev/null +++ b/apps/dashboard/src/components/ui/button.tsx @@ -0,0 +1,44 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { Slot as SlotPrimitive } from "radix-ui"; +import type * as React from "react"; +import { cn } from "@/lib/cn"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 outline-none aria-invalid:border-destructive aria-invalid:ring-destructive/20 shrink-0", + { + defaultVariants: { + size: "default", + variant: "default" + }, + variants: { + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + icon: "size-9", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5" + }, + variant: { + default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + outline: "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", + secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80" + } + } + } +); + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? SlotPrimitive.Root : "button"; + return ; +} + +export { Button, buttonVariants }; diff --git a/apps/dashboard/src/components/ui/card.tsx b/apps/dashboard/src/components/ui/card.tsx new file mode 100644 index 000000000..d19ff8883 --- /dev/null +++ b/apps/dashboard/src/components/ui/card.tsx @@ -0,0 +1,53 @@ +import type * as React from "react"; +import { cn } from "@/lib/cn"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }; diff --git a/apps/dashboard/src/components/ui/checkbox.tsx b/apps/dashboard/src/components/ui/checkbox.tsx new file mode 100644 index 000000000..45fcb05c5 --- /dev/null +++ b/apps/dashboard/src/components/ui/checkbox.tsx @@ -0,0 +1,26 @@ +import { CheckIcon } from "lucide-react"; +import { Checkbox as CheckboxPrimitive } from "radix-ui"; +import type * as React from "react"; +import { cn } from "@/lib/cn"; + +function Checkbox({ className, ...props }: React.ComponentProps) { + return ( + + + + + + ); +} + +export { Checkbox }; diff --git a/apps/dashboard/src/components/ui/dialog.tsx b/apps/dashboard/src/components/ui/dialog.tsx new file mode 100644 index 000000000..6c12de316 --- /dev/null +++ b/apps/dashboard/src/components/ui/dialog.tsx @@ -0,0 +1,112 @@ +import { XIcon } from "lucide-react"; +import { Dialog as DialogPrimitive } from "radix-ui"; +import type * as React from "react"; +import { cn } from "@/lib/cn"; + +function Dialog({ ...props }: React.ComponentProps) { + return ; +} + +function DialogTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function DialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function DialogClose({ ...props }: React.ComponentProps) { + return ; +} + +function DialogOverlay({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { showCloseButton?: boolean }) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger +}; diff --git a/apps/dashboard/src/components/ui/dropdown-menu.tsx b/apps/dashboard/src/components/ui/dropdown-menu.tsx new file mode 100644 index 000000000..41bb96bb7 --- /dev/null +++ b/apps/dashboard/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,201 @@ +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; +import type * as React from "react"; +import { cn } from "@/lib/cn"; + +function DropdownMenu({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuGroup({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { inset?: boolean; variant?: "default" | "destructive" }) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { inset?: boolean }) { + return ( + + ); +} + +function DropdownMenuSeparator({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { + return ( + + ); +} + +function DropdownMenuSub({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { inset?: boolean }) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent +}; diff --git a/apps/dashboard/src/components/ui/form.tsx b/apps/dashboard/src/components/ui/form.tsx new file mode 100644 index 000000000..2c0e1c6d6 --- /dev/null +++ b/apps/dashboard/src/components/ui/form.tsx @@ -0,0 +1,128 @@ +import { Label as LabelPrimitive, Slot as SlotPrimitive } from "radix-ui"; +import * as React from "react"; +import { + Controller, + type ControllerProps, + type FieldPath, + type FieldValues, + FormProvider, + useFormContext, + useFormState +} from "react-hook-form"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/cn"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName; +}; + +const FormFieldContext = React.createContext({} as FormFieldContextValue); + +function FormField< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ ...props }: ControllerProps) { + return ( + + + + ); +} + +function useFormField() { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState } = useFormContext(); + const formState = useFormState({ name: fieldContext.name }); + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + formDescriptionId: `${id}-form-item-description`, + formItemId: `${id}-form-item`, + formMessageId: `${id}-form-item-message`, + id, + name: fieldContext.name, + ...fieldState + }; +} + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext({} as FormItemContextValue); + +function FormItem({ className, ...props }: React.ComponentProps<"div">) { + const id = React.useId(); + return ( + +
+ + ); +} + +function FormLabel({ className, ...props }: React.ComponentProps) { + const { error, formItemId } = useFormField(); + return ( +