From 812e456d4ed7a67008b309c23574315baba5c2c2 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:01:31 -0300 Subject: [PATCH 01/10] feat(frontend): rework the vesting dApp UI and restructure the app Replace the table-and-card dashboard with a top-bar layout: navigation and the ledger status move out of the sidebar, every page carries its own header, grants are served at the root, and the grant filter and the role lens become dropdowns scoped to the page that uses them. Grant creation moves into a modal stepper built on a native dialog, and a toast now stays until dismissed and can carry a link. Restructure the source tree to match the layout rules the app now spells out in its own CLAUDE.md. features becomes pages, lib becomes utils, the icon collection is promoted, dialog components drop their Dialog suffix, and anything with a single consumer moves beside it. --- CLAUDE.md | 17 +- canton-dappbooster/CLAUDE.md | 2 +- dapp/frontend/AGENTS.md | 3 + dapp/frontend/CLAUDE.md | 29 ++ dapp/frontend/README.md | 2 +- dapp/frontend/architecture.md | 40 ++- dapp/frontend/src/App.tsx | 10 +- dapp/frontend/src/backend/LiteBackend.test.ts | 2 +- dapp/frontend/src/backend/VestingBackend.ts | 20 +- dapp/frontend/src/backend/commands.test.ts | 2 +- dapp/frontend/src/backend/commands.ts | 10 +- dapp/frontend/src/backend/config.ts | 4 +- dapp/frontend/src/backend/wallet.ts | 2 +- .../frontend/src/components/AmountDisplay.tsx | 8 +- dapp/frontend/src/components/AppShell.tsx | 32 +- dapp/frontend/src/components/Button.tsx | 10 +- ...{CancelGrantDialog.tsx => CancelGrant.tsx} | 22 +- dapp/frontend/src/components/Card.tsx | 4 +- .../components/{ClaimDialog.tsx => Claim.tsx} | 24 +- .../src/components/CounterpartyId.tsx | 6 +- dapp/frontend/src/components/FieldError.tsx | 4 +- dapp/frontend/src/components/GrantStatus.tsx | 34 ++ dapp/frontend/src/components/GrantTable.tsx | 119 ------- dapp/frontend/src/components/InfoTip.tsx | 42 +++ dapp/frontend/src/components/Modal.tsx | 155 ++++----- dapp/frontend/src/components/PageTitle.tsx | 14 + dapp/frontend/src/components/PrivacyNote.tsx | 7 - dapp/frontend/src/components/ProposalCard.tsx | 77 ----- dapp/frontend/src/components/RoleSelect.tsx | 19 + dapp/frontend/src/components/RoleToggle.tsx | 35 -- dapp/frontend/src/components/ScheduleBar.tsx | 6 +- .../frontend/src/components/ScheduleCurve.tsx | 6 +- dapp/frontend/src/components/Select.tsx | 33 ++ dapp/frontend/src/components/Sidebar.tsx | 69 ---- dapp/frontend/src/components/StatusPill.tsx | 4 +- .../src/components/Toaster/ToastRow.tsx | 53 +++ .../frontend/src/components/Toaster/index.tsx | 24 ++ dapp/frontend/src/components/TopBar.tsx | 31 -- dapp/frontend/src/components/TopBar/Logo.tsx | 34 ++ .../components/{ => TopBar}/PartyAvatar.tsx | 0 .../components/{ => TopBar}/ThemeToggle.tsx | 2 +- dapp/frontend/src/components/TopBar/index.tsx | 56 +++ dapp/frontend/src/components/icons.tsx | 78 ----- dapp/frontend/src/components/toast.tsx | 87 ----- .../src/hooks/useConnectErrorToast.ts | 4 +- dapp/frontend/src/hooks/useRoleLens.ts | 10 + dapp/frontend/src/hooks/useToken.ts | 2 +- dapp/frontend/src/icons/ArrowLeftIcon.tsx | 7 + dapp/frontend/src/icons/CheckIcon.tsx | 7 + dapp/frontend/src/icons/LockIcon.tsx | 8 + dapp/frontend/src/icons/MoonIcon.tsx | 7 + dapp/frontend/src/icons/PlusIcon.tsx | 7 + dapp/frontend/src/icons/SunIcon.tsx | 8 + dapp/frontend/src/icons/Svg.tsx | 24 ++ dapp/frontend/src/icons/TrashIcon.tsx | 7 + dapp/frontend/src/icons/index.ts | 7 + .../pages/Dashboard/CreateGrant/DateField.tsx | 30 ++ .../CreateGrant/LiveScheduleCurve.tsx | 11 + .../src/pages/Dashboard/CreateGrant/fields.ts | 8 + .../Dashboard/CreateGrant/index.tsx} | 326 +++++++++--------- .../Dashboard/GrantCard}/Legend.tsx | 6 +- .../Dashboard/GrantCard/index.tsx} | 65 ++-- .../Dashboard}/KpiCard.tsx | 17 +- .../Dashboard/index.tsx} | 166 ++++----- .../GrantDetail}/MilestoneTimeline.tsx | 10 +- .../GrantDetail/index.tsx} | 46 ++- .../src/pages/Proposals/ProposalCard.tsx | 82 +++++ .../Proposals/index.tsx} | 57 +-- .../{BackendProvider.tsx => Backend.tsx} | 8 +- dapp/frontend/src/routes.tsx | 18 +- dapp/frontend/src/store/types.ts | 32 +- dapp/frontend/src/store/useUiStore.ts | 18 - .../src/store/useVestingStore.test.ts | 23 +- dapp/frontend/src/store/useVestingStore.ts | 57 +-- dapp/frontend/src/styles/index.css | 20 ++ .../src/{lib => utils}/amount.test.ts | 2 +- dapp/frontend/src/{lib => utils}/amount.ts | 0 .../src/{lib => utils}/amountErrorText.ts | 0 dapp/frontend/src/{lib => utils}/clock.ts | 0 dapp/frontend/src/{lib => utils}/cn.ts | 0 dapp/frontend/src/{lib => utils}/config.ts | 0 dapp/frontend/src/{lib => utils}/env.test.ts | 2 +- dapp/frontend/src/{lib => utils}/env.ts | 0 dapp/frontend/src/{lib => utils}/errorText.ts | 0 .../src/{lib => utils}/format.test.ts | 4 +- dapp/frontend/src/{lib => utils}/format.ts | 10 +- .../src/{lib => utils}/schedule.test.ts | 4 +- dapp/frontend/src/{lib => utils}/schedule.ts | 28 +- dapp/frontend/src/utils/toast.ts | 50 +++ dapp/frontend/src/{lib => utils}/tokens.ts | 0 dapp/frontend/src/utils/topLayer.ts | 14 + dapp/frontend/src/vite-env.d.ts | 2 +- dapp/frontend/tsconfig.node.json | 2 +- dapp/frontend/vite.config.ts | 2 +- 94 files changed, 1302 insertions(+), 1154 deletions(-) create mode 100644 dapp/frontend/AGENTS.md create mode 100644 dapp/frontend/CLAUDE.md rename dapp/frontend/src/components/{CancelGrantDialog.tsx => CancelGrant.tsx} (89%) rename dapp/frontend/src/components/{ClaimDialog.tsx => Claim.tsx} (88%) create mode 100644 dapp/frontend/src/components/GrantStatus.tsx delete mode 100644 dapp/frontend/src/components/GrantTable.tsx create mode 100644 dapp/frontend/src/components/InfoTip.tsx create mode 100644 dapp/frontend/src/components/PageTitle.tsx delete mode 100644 dapp/frontend/src/components/PrivacyNote.tsx delete mode 100644 dapp/frontend/src/components/ProposalCard.tsx create mode 100644 dapp/frontend/src/components/RoleSelect.tsx delete mode 100644 dapp/frontend/src/components/RoleToggle.tsx create mode 100644 dapp/frontend/src/components/Select.tsx delete mode 100644 dapp/frontend/src/components/Sidebar.tsx create mode 100644 dapp/frontend/src/components/Toaster/ToastRow.tsx create mode 100644 dapp/frontend/src/components/Toaster/index.tsx delete mode 100644 dapp/frontend/src/components/TopBar.tsx create mode 100644 dapp/frontend/src/components/TopBar/Logo.tsx rename dapp/frontend/src/components/{ => TopBar}/PartyAvatar.tsx (100%) rename dapp/frontend/src/components/{ => TopBar}/ThemeToggle.tsx (92%) create mode 100644 dapp/frontend/src/components/TopBar/index.tsx delete mode 100644 dapp/frontend/src/components/icons.tsx delete mode 100644 dapp/frontend/src/components/toast.tsx create mode 100644 dapp/frontend/src/hooks/useRoleLens.ts create mode 100644 dapp/frontend/src/icons/ArrowLeftIcon.tsx create mode 100644 dapp/frontend/src/icons/CheckIcon.tsx create mode 100644 dapp/frontend/src/icons/LockIcon.tsx create mode 100644 dapp/frontend/src/icons/MoonIcon.tsx create mode 100644 dapp/frontend/src/icons/PlusIcon.tsx create mode 100644 dapp/frontend/src/icons/SunIcon.tsx create mode 100644 dapp/frontend/src/icons/Svg.tsx create mode 100644 dapp/frontend/src/icons/TrashIcon.tsx create mode 100644 dapp/frontend/src/icons/index.ts create mode 100644 dapp/frontend/src/pages/Dashboard/CreateGrant/DateField.tsx create mode 100644 dapp/frontend/src/pages/Dashboard/CreateGrant/LiveScheduleCurve.tsx create mode 100644 dapp/frontend/src/pages/Dashboard/CreateGrant/fields.ts rename dapp/frontend/src/{features/CreateGrantPage.tsx => pages/Dashboard/CreateGrant/index.tsx} (68%) rename dapp/frontend/src/{components => pages/Dashboard/GrantCard}/Legend.tsx (90%) rename dapp/frontend/src/{components/GrantCard.tsx => pages/Dashboard/GrantCard/index.tsx} (73%) rename dapp/frontend/src/{components => pages/Dashboard}/KpiCard.tsx (60%) rename dapp/frontend/src/{features/DashboardPage.tsx => pages/Dashboard/index.tsx} (70%) rename dapp/frontend/src/{components => pages/GrantDetail}/MilestoneTimeline.tsx (91%) rename dapp/frontend/src/{features/GrantDetailPage.tsx => pages/GrantDetail/index.tsx} (87%) create mode 100644 dapp/frontend/src/pages/Proposals/ProposalCard.tsx rename dapp/frontend/src/{features/ProposalsPage.tsx => pages/Proposals/index.tsx} (64%) rename dapp/frontend/src/providers/{BackendProvider.tsx => Backend.tsx} (92%) delete mode 100644 dapp/frontend/src/store/useUiStore.ts rename dapp/frontend/src/{lib => utils}/amount.test.ts (99%) rename dapp/frontend/src/{lib => utils}/amount.ts (100%) rename dapp/frontend/src/{lib => utils}/amountErrorText.ts (100%) rename dapp/frontend/src/{lib => utils}/clock.ts (100%) rename dapp/frontend/src/{lib => utils}/cn.ts (100%) rename dapp/frontend/src/{lib => utils}/config.ts (100%) rename dapp/frontend/src/{lib => utils}/env.test.ts (97%) rename dapp/frontend/src/{lib => utils}/env.ts (100%) rename dapp/frontend/src/{lib => utils}/errorText.ts (100%) rename dapp/frontend/src/{lib => utils}/format.test.ts (78%) rename dapp/frontend/src/{lib => utils}/format.ts (80%) rename dapp/frontend/src/{lib => utils}/schedule.test.ts (99%) rename dapp/frontend/src/{lib => utils}/schedule.ts (88%) create mode 100644 dapp/frontend/src/utils/toast.ts rename dapp/frontend/src/{lib => utils}/tokens.ts (100%) create mode 100644 dapp/frontend/src/utils/topLayer.ts diff --git a/CLAUDE.md b/CLAUDE.md index 46eabc08..dad747f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,10 +10,8 @@ Each subproject can layer its own `CLAUDE.md` for stack-specific deltas: - [`canton-dappbooster/CLAUDE.md`](canton-dappbooster/CLAUDE.md) — L2 component authoring and file layout - [`canton-theme/CLAUDE.md`](canton-theme/CLAUDE.md) — L3 `--cnc-*` token naming convention - [`canton-barebones/wallet-service/CLAUDE.md`](canton-barebones/wallet-service/CLAUDE.md) — wallet-service bridge rules -- `canton-barebones/`, `dapp/daml/vesting-lite/`, `dapp/frontend/` — see each subproject's `README.md` - -`dapp/frontend/` has no `CLAUDE.md`; its seams are in -[`dapp/frontend/architecture.md`](dapp/frontend/architecture.md). +- [`dapp/frontend/CLAUDE.md`](dapp/frontend/CLAUDE.md) — app layout and naming deltas; its seams are in [`dapp/frontend/architecture.md`](dapp/frontend/architecture.md) +- `canton-barebones/`, `dapp/daml/vesting-lite/` — see each subproject's `README.md` The Carpincho wallet (CIP-0103 browser wallet) lives in its own repository at [github.com/BootNodeDev/carpincho-wallet](https://github.com/BootNodeDev/carpincho-wallet); it is no longer part of this monorepo. @@ -41,7 +39,7 @@ Current distribution: | root | yes | shim | yes | yes | Canonical repo rules and cross-component seams. | | `canton-connect/` | yes | shim | yes | yes | Public hook API, the facade's adapter/picker seams, provider event wiring. | | `canton-barebones/wallet-service/` | yes | shim | yes | no | Local bridge rules are useful; README API boundary is enough architecture for now. | -| `dapp/frontend/` | yes | no | no | yes | Canton Coin vesting dApp; root rules suffice for authoring, but its internal seams outgrew the README. Carries a `PROVENANCE.md` recording the vendored source. | +| `dapp/frontend/` | yes | shim | yes | yes | Canton Coin vesting dApp; `CLAUDE.md` carries the page-owns-its-components layout and the naming rules an agent would otherwise get wrong, architecture.md its internal seams. Carries a `PROVENANCE.md` recording the vendored source. | | `dapp/daml/` | yes | no | no | no | Single DAML package (`vesting-lite`) plus its `daml-test` scenarios. Carries a `PROVENANCE.md` recording the vendored source. | | `canton-barebones/` | yes | no | no | no | Docker/Bash local participant wrapper. | | `canton-dappbooster/` | yes | shim | yes | yes | L2 headless components; `CLAUDE.md` carries the folder-per-component layout an agent would otherwise get wrong, architecture.md the authoring seam (anatomy contract, L2/L3 split, Zag boundary). | @@ -149,8 +147,9 @@ Placement: never rejects a file, so each of its modules is named for what it holds (`partyId.ts`, `cx.ts`), never `helpers.ts` or an `index.ts` barrel. - Components live in `components/`, which is a kind folder like the rest and gets no special case. - Routed pages are the one thing kept apart, in `features/`, because the router enters them rather - than a parent composing them. + Routed pages are the one thing kept apart, in `pages/`, because the router enters them rather + than a parent composing them. A page is a consumer like any other, so what only one page renders + lives beside it. - A component whose job is to supply context rather than render markup lives in `providers/`, named `Provider`, so what wraps the tree is one place to look instead of a hunt through feature folders. @@ -165,8 +164,8 @@ Placement: where a caller could reasonably pick a different export, when to reach for it. Do not restate the type. How much prose, and whether an `@example` is required at all, follows the tier table under Doc blocks below. -- **A module has one legal spelling, and it is never relative.** `./components/toast` and - `@/components/toast` both resolved, so which one landed was down to who or what wrote the file. +- **A module has one legal spelling, and it is never relative.** `./utils/toast` and + `@/utils/toast` both resolved, so which one landed was down to who or what wrote the file. Relative specifiers (`.`, `..`, `./*`, `../*`) are now a Biome error in `dapp/frontend`, `canton-dappbooster`, and `canton-connect`, in all four positions: `import … from`, `export … from`, `export *`, and dynamic `import()`. diff --git a/canton-dappbooster/CLAUDE.md b/canton-dappbooster/CLAUDE.md index 7a2b1753..33321312 100644 --- a/canton-dappbooster/CLAUDE.md +++ b/canton-dappbooster/CLAUDE.md @@ -91,7 +91,7 @@ prints the two it will not edit for you, 3 and 5. It decides nothing below; it o from an earlier build is sitting there. - Keep this package app-agnostic: do not import from `dapp/` or `canton-barebones/`. - React 19 only, peer and dev alike. -- `dapp/frontend` keeps its own copy/check icons in `components/icons.tsx`. Leave them: the kit's +- `dapp/frontend` keeps its own copy/check icons in its `src/icons/`. Leave them: the kit's icons are internal, and exporting them is a public-API decision, not a deduplication chore. ## Testing diff --git a/dapp/frontend/AGENTS.md b/dapp/frontend/AGENTS.md new file mode 100644 index 00000000..a7afa29b --- /dev/null +++ b/dapp/frontend/AGENTS.md @@ -0,0 +1,3 @@ +# Agent Configuration +This project's canonical agent configuration lives in [`CLAUDE.md`](./CLAUDE.md). +Agents read `AGENTS.md` natively; load `CLAUDE.md` as the single source of truth. diff --git a/dapp/frontend/CLAUDE.md b/dapp/frontend/CLAUDE.md new file mode 100644 index 00000000..6c49a109 --- /dev/null +++ b/dapp/frontend/CLAUDE.md @@ -0,0 +1,29 @@ +# Agent Configuration — dapp/frontend + +This file applies only to `dapp/frontend/`. For monorepo-wide rules, including the filename casing +table and the colocate-then-promote placement rules it layers on, see [`../../CLAUDE.md`](../../CLAUDE.md). +The internal seams are in [`architecture.md`](architecture.md). Deltas for this app only are below. + +## Layout + +- Routed pages live in `pages/`, not `features/`. A page is a folder: `index.tsx` is the route, and + whatever only that page composes sits beside it. +- **One consumer means colocate, and a page counts as a consumer.** `components/` holds what two or + more places use; everything else lives beside the single thing that renders it, so + `GrantCard` is `pages/Dashboard/GrantCard/`, not a flat file two folders away. A second consumer + appearing is what promotes a module to `components/`, and that move is the whole change. +- A component earns a folder when it outgrows one file, never before: entry `index.tsx`, + subcomponents PascalCase beside it, and anything both the entry and a subcomponent need in a + camelCase leaf module (`CreateGrant/fields.ts`). +- A module that is both store and view splits along that seam: the store and its imperative API in + `utils/`, the viewport in `components/` (`utils/toast.ts` and `components/Toaster/`). +- `providers/` names what it provides, not the role the folder already states: `Backend`, not + `BackendProvider`. + +## Naming + +- No name repeats what its folder, its parent, or its own markup already says. `Claim`, not + `ClaimDialog`; `pages/Dashboard`, not `DashboardPage`. Where the word is the meaning rather than + the mechanism it stays: `PageTitle` is a page's title wherever it is rendered from. +- Type and interface members are alphabetical, as in `canton-dappbooster`. Blank-line groups inside + a declaration are sorted within the group. diff --git a/dapp/frontend/README.md b/dapp/frontend/README.md index 6eef84c7..0b3bbe6f 100644 --- a/dapp/frontend/README.md +++ b/dapp/frontend/README.md @@ -2,7 +2,7 @@ dApp for **Canton Coin vesting**: propose a grant, the beneficiary accepts, claim as it vests, or cancel into a residual claim. Grants render live vested/claimable figures from -the pure schedule math in [`src/lib/schedule.ts`](src/lib/schedule.ts). +the pure schedule math in [`src/utils/schedule.ts`](src/utils/schedule.ts). Every read and every write goes through the connected CIP-0103 wallet, so the app acts as the wallet's primary account and each write raises a real approval prompt. There is no diff --git a/dapp/frontend/architecture.md b/dapp/frontend/architecture.md index bc45793f..5bf5d867 100644 --- a/dapp/frontend/architecture.md +++ b/dapp/frontend/architecture.md @@ -13,12 +13,13 @@ interfaces carry that, and every other decision hangs off them. | Path | Role | |------|------| | `src/backend/` | The `VestingBackend` interface, `LiteBackend` (its one implementation), the pure ACS→domain mappers, the command builders, the `WalletFns` seam, and `config.ts`, which loads the deployment. | -| `src/providers/` | `BackendProvider`: builds the backend from the deployment plus the wallet session, and nothing else. The theme and token-list providers come from the kit, the session provider from `canton-connect`. | +| `src/providers/` | `Backend`: builds the backend from the deployment plus the wallet session, and nothing else. The theme and token-list providers come from the kit, the session provider from `canton-connect`. | | `src/hooks/` | `useParty` narrows the `canton-connect` session to what the UI needs, `useConnectErrorToast` gives a rejected connection somewhere to surface, and `useToken` holds one field's token selection. | | `src/store/useVestingStore.ts` | Backend-backed zustand store; actions submit then refresh. | -| `src/lib/` | Pure helpers, `schedule.ts` chief among them, plus `env.ts`, the environment contract `vite.config.ts` validates against, `config.ts`, which reads the literals that validation left behind, and `tokens.ts`, the one instrument this deployment knows. | -| `src/components/` | The shell, the top bar and sidebar, and the cards, dialogs, table, and charts they compose. | -| `src/features/` | Dashboard, proposals, create, grant detail. | +| `src/utils/` | Pure helpers, `schedule.ts` chief among them, plus `env.ts`, the environment contract `vite.config.ts` validates against, `config.ts`, which reads the literals that validation left behind, and `tokens.ts`, the one instrument this deployment knows. The two state modules whose view lives elsewhere are here too: `toast.ts` and `topLayer.ts`. | +| `src/components/` | What two or more places render: the shell, the top bar, the dialogs, and the primitives the pages compose. | +| `src/icons/` | One inline icon per file over a shared `Svg` wrapper, re-exported from `index.ts`. | +| `src/pages/` | Dashboard, proposals and grant detail, each a folder whose `index.tsx` is the route and whose siblings are what only that page renders. | | `src/styles/` | The single stylesheet entry and the app's own tokens. | ## The two seams @@ -46,12 +47,12 @@ nothing to query and without the blob there is no factory to disclose. ``` config.ts ────────────┐ - ├─▶ BackendProvider ──▶ useBackend ──▶ useVestingStore ──▶ components + ├─▶ Backend ──────────▶ useBackend ──▶ useVestingStore ──▶ components CantonConnectProvider ┤ ▲ └─▶ useParty ───────────────────────────────────────────────┘ ``` -`BackendProvider` ([`src/providers/BackendProvider.tsx`](src/providers/BackendProvider.tsx)) is the +`Backend` ([`src/providers/Backend.tsx`](src/providers/Backend.tsx)) is the only place the two seams above meet. Its backend is `undefined` until both a deployment and a wallet *party* exist, because neither half alone can reach the ledger, and a page with no backend renders `ConnectPrompt` where its data would be. The party rather than the connection status is the gate: a @@ -88,17 +89,17 @@ archives for good, and the page navigates away instead. `deriveGrant` in [`src/store/useVestingStore.ts`](src/store/useVestingStore.ts) is a pure projection of a grant at a moment in time — vested, claimable, claimed, status. It and -[`src/lib/schedule.ts`](src/lib/schedule.ts) are the single source of every per-grant figure, and +[`src/utils/schedule.ts`](src/utils/schedule.ts) are the single source of every per-grant figure, and they mirror the on-ledger math deliberately, so a preview and the choice that follows it agree — the contracts recompute `vestedAmount` themselves and reject anything above it. A component that derives a grant's own vesting figures is a bug. `claimAvailable`, beside it, is the same rule for a residual claim, which carries no schedule and so has no projection of its own: what the dashboard shows, sums and submits for one is a single subtraction in a single place. Two components legitimately compute on top of that projection rather than beside it: `MilestoneTimeline` splits a -total across milestone steps for display, and `DashboardPage` sums `deriveGrant`'s output into the +total across milestone steps for display, and `Dashboard` sums `deriveGrant`'s output into the KPI row. Both take the projection as their input; neither re-derives it. -Under both sits [`src/lib/amount.ts`](src/lib/amount.ts), the arithmetic floor. Every add, +Under both sits [`src/utils/amount.ts`](src/utils/amount.ts), the arithmetic floor. Every add, subtract, floor-at-zero, fraction scale, and round in the app goes through it, on scaled `bigint`s, and `schedule.ts` builds on it too. Nothing computes an amount any other way. @@ -116,7 +117,7 @@ downstream. Party ids come from `@bootnodedev/canton-dappbooster`, styled by `@bootnodedev/canton-theme`. The app holds no truncation or copy-to-clipboard logic of its own. -Entry is the other half. [`CreateGrantPage`](src/features/CreateGrantPage.tsx)'s receiver field is +Entry is the other half. [`CreateGrant`](src/pages/Dashboard/CreateGrant/index.tsx)'s receiver field is the kit's ``, and the submit gate calls the same `validatePartyId` the field does, so the two can never disagree about what a party id is. Party ids are exact strings here: nothing trims, so a stray space is invalid rather than silently stripped on the way to the ledger. @@ -128,14 +129,14 @@ rendering nothing, and the red state is a Tailwind `aria-invalid:` variant rathe `canton-theme`'s, because the app's utilities sit above the `cnc` layer (see [`src/styles/index.css`](src/styles/index.css)). -Amounts run that same split twice more. [`CreateGrantPage`](src/features/CreateGrantPage.tsx)'s -total and [`ClaimDialog`](src/components/ClaimDialog.tsx)'s withdrawal are both the kit's +Amounts run that same split twice more. [`CreateGrant`](src/pages/Dashboard/CreateGrant/index.tsx)'s +total and [`Claim`](src/components/Claim.tsx)'s withdrawal are both the kit's ``: the field sets `aria-invalid` and reports an error *code*, and this app words it -in [`src/lib/amountErrorText.ts`](src/lib/amountErrorText.ts), again an exhaustive `Record` so a +in [`src/utils/amountErrorText.ts`](src/utils/amountErrorText.ts), again an exhaustive `Record` so a code added upstream fails the build here. Both fields also open the kit's token picker, and the list it shows is one entry: -[`src/lib/tokens.ts`](src/lib/tokens.ts) holds `CC` and nothing else, because that is the only +[`src/utils/tokens.ts`](src/utils/tokens.ts) holds `CC` and nothing else, because that is the only instrument this deployment knows. The pick is display-only — it relabels the field, and the re-lock floor's wording, the claim toast, and the grant that gets created are all still Canton Coin. The picker is wired ahead of a second instrument on purpose. @@ -150,7 +151,7 @@ The division of labour underneath is the part neither side announces. The kit ow one precision: parse, format, sanitize a keystroke, validate against the ledger's own limits and a `max`. It knows nothing about a second amount, so everything that combines two of them is this app's, in -[`src/lib/amount.ts`](src/lib/amount.ts), built on the kit's `parseAmount` / `formatScaled` pair and +[`src/utils/amount.ts`](src/utils/amount.ts), built on the kit's `parseAmount` / `formatScaled` pair and on nothing else of the kit's. So the field's `balance` is the ceiling, while both floors are the app's: the create form's `MIN_GRANT_AMOUNT`, and the claim dialog's re-lock floor, which is a rule about the *remainder* and so about two amounts at once. @@ -171,7 +172,7 @@ or a sentence it uses the pure `truncateIdentifier` / `partyHint` formatters ins element. The explorer those ids link to is the app's to supply: Canton has no canonical one, so the kit -composes URLs only from an `ExplorerConfig`. [`src/lib/config.ts`](src/lib/config.ts) holds that +composes URLs only from an `ExplorerConfig`. [`src/utils/config.ts`](src/utils/config.ts) holds that config as a literal baked in at build time from `VITE_EXPLORER_URL`, not parsed at startup, and the kit's `useExplorerLink` turns it into hrefs. Counterparty ids go through one component: [`src/components/CounterpartyId.tsx`](src/components/CounterpartyId.tsx) binds the from/to prefix, @@ -179,12 +180,15 @@ the direction-specific label, and the copy toast, and `GrantCard` and `ProposalC href stays a per-call-site decision, the way the kit's own `href` is optional: linking an id to an explorer is a choice each surface makes, not something the app does everywhere. Every `` the app renders passes `announce={false}`: the `Toaster` is the app's live region, so the kit's own -would double-announce. +would double-announce. That one region has to move: `Modal` opens a native `` with +`showModal()`, which inerts everything outside the dialog's subtree, so a toast raised over an open +dialog — every failed submit — would be neither clickable nor announced. `utils/topLayer.ts` carries +the open dialog element from `Modal` to the `Toaster`, which portals into it. That literal is the build's doing. [`vite.config.ts`](vite.config.ts) runs `parseEnv(loadEnv(...))` and `define`s the parsed values back onto `import.meta.env`, so a bad `VITE_EXPLORER_URL` fails the build rather than the page load and the client ships no validator at -all. [`src/lib/env.ts`](src/lib/env.ts) holds that contract, and is the only module under `src/` +all. [`src/utils/env.ts`](src/utils/env.ts) holds that contract, and is the only module under `src/` that runs outside the browser. The connect button's copy is the kit's: passed no `children` it renders its own label and swaps it diff --git a/dapp/frontend/src/App.tsx b/dapp/frontend/src/App.tsx index 3f6028b7..9eb14691 100644 --- a/dapp/frontend/src/App.tsx +++ b/dapp/frontend/src/App.tsx @@ -1,10 +1,9 @@ import { type CantonConnectConfig, CantonConnectProvider } from '@bootnodedev/canton-connect' import { ThemeProvider, TokenListProvider } from '@bootnodedev/canton-dappbooster' import { createBrowserRouter, RouterProvider } from 'react-router-dom' -import { Toaster } from '@/components/toast' -import { TOKENS } from '@/lib/tokens' -import { BackendProvider } from '@/providers/BackendProvider' +import { Backend } from '@/providers/Backend' import { routes } from '@/routes' +import { TOKENS } from '@/utils/tokens' const router = createBrowserRouter(routes) @@ -15,10 +14,9 @@ export const App = (): React.JSX.Element => ( - + - - + diff --git a/dapp/frontend/src/backend/LiteBackend.test.ts b/dapp/frontend/src/backend/LiteBackend.test.ts index 1902ca3d..6a459a52 100644 --- a/dapp/frontend/src/backend/LiteBackend.test.ts +++ b/dapp/frontend/src/backend/LiteBackend.test.ts @@ -29,8 +29,8 @@ type PartyFilter = { } type AcsQuery = { - filter?: { filtersByParty?: Record } activeAtOffset?: unknown + filter?: { filtersByParty?: Record } } type Read = { requestMethod: string; resource: string; body?: AcsQuery } diff --git a/dapp/frontend/src/backend/VestingBackend.ts b/dapp/frontend/src/backend/VestingBackend.ts index 2b39ec0b..73e1cf7e 100644 --- a/dapp/frontend/src/backend/VestingBackend.ts +++ b/dapp/frontend/src/backend/VestingBackend.ts @@ -2,32 +2,32 @@ // transport details. The mappers below turn active-contract rows into those domain types. import { decodeSchedule } from '@/backend/commands' -import { isAmount } from '@/lib/amount' -import type { VestingSchedule } from '@/lib/schedule' import type { Grant, PartyId, Proposal, VestedClaim } from '@/store/types' +import { isAmount } from '@/utils/amount' +import type { VestingSchedule } from '@/utils/schedule' export interface VestingView { + claims: VestedClaim[] grants: Grant[] proposals: Proposal[] - claims: VestedClaim[] } export interface CreateVestInput { + note?: string proposer: string receiver: string - totalAmount: string schedule: VestingSchedule title: string - note?: string + totalAmount: string } export interface VestingBackend { - viewAs(partyId: string): Promise - createVesting(args: CreateVestInput): Promise<{ disclosedBytes: number }> accept(args: { receiver: string; proposalCid: string }): Promise - withdraw(args: { receiver: string; contractCid: string; amount: string }): Promise cancel(args: { creator: string; contractCid: string }): Promise claimResidual(args: { receiver: string; claimCid: string; amount: string }): Promise + createVesting(args: CreateVestInput): Promise<{ disclosedBytes: number }> + viewAs(partyId: string): Promise + withdraw(args: { receiver: string; contractCid: string; amount: string }): Promise } // ── Domain-mapping convention ────────────────────────────────────────────────── @@ -78,12 +78,12 @@ export const splitNote = ( // The fields every template carries alike; each mapper layers its own on top. type DecodedBase = { arg: Record + funder: PartyId id: string - title: string note?: string provider: PartyId - funder: PartyId receiver: PartyId + title: string } const decodeBase = (row: AcsRow): DecodedBase | undefined => { diff --git a/dapp/frontend/src/backend/commands.test.ts b/dapp/frontend/src/backend/commands.test.ts index 54069a66..22445131 100644 --- a/dapp/frontend/src/backend/commands.test.ts +++ b/dapp/frontend/src/backend/commands.test.ts @@ -8,7 +8,7 @@ import { decodeSchedule, encodeSchedule, } from '@/backend/commands' -import type { VestingSchedule } from '@/lib/schedule' +import type { VestingSchedule } from '@/utils/schedule' const linear: VestingSchedule = { cliff: '2026-01-01T00:00:00Z', diff --git a/dapp/frontend/src/backend/commands.ts b/dapp/frontend/src/backend/commands.ts index c7991bd7..7aadd3e7 100644 --- a/dapp/frontend/src/backend/commands.ts +++ b/dapp/frontend/src/backend/commands.ts @@ -1,8 +1,8 @@ // JSON-Ledger-API v2 command builders and the one curve encode/decode pair. No I/O, so it is // unit-tested directly in commands.test.ts. -import { canonicalAmount } from '@/lib/amount' -import type { VestingSchedule } from '@/lib/schedule' +import { canonicalAmount } from '@/utils/amount' +import type { VestingSchedule } from '@/utils/schedule' // ── Curve variant encoding ──────────────────────────────────────────────────── // The one place the JSON-LF convention lives, mirrored by decodeSchedule: a variant is @@ -68,11 +68,11 @@ export const decodeSchedule = (raw: unknown): VestingSchedule => { // ── Command builders ──────────────────────────────────────────────────────── type CreateVestingArgs = { - proposer: string beneficiary: string - total: string - schedule: VestingSchedule note?: string + proposer: string + schedule: VestingSchedule + total: string } export const buildCreateVestingCommand = ( diff --git a/dapp/frontend/src/backend/config.ts b/dapp/frontend/src/backend/config.ts index 83e2490a..565df9d6 100644 --- a/dapp/frontend/src/backend/config.ts +++ b/dapp/frontend/src/backend/config.ts @@ -2,9 +2,9 @@ // package id and no factory to disclose, so nothing it does can reach the ledger. export type Deployment = { - pkg: string - factoryCid: string factoryBlob: string + factoryCid: string + pkg: string synchronizerId?: string } diff --git a/dapp/frontend/src/backend/wallet.ts b/dapp/frontend/src/backend/wallet.ts index c198bb43..39851941 100644 --- a/dapp/frontend/src/backend/wallet.ts +++ b/dapp/frontend/src/backend/wallet.ts @@ -15,10 +15,10 @@ export type LedgerCommand = { // Explicitly-disclosed contract (JSON Ledger API v2 disclosedContracts entry). export type DisclosedContract = { - templateId: string contractId: string createdEventBlob: string synchronizerId?: string + templateId: string } export type WalletFns = { diff --git a/dapp/frontend/src/components/AmountDisplay.tsx b/dapp/frontend/src/components/AmountDisplay.tsx index ff521b69..6e39fd95 100644 --- a/dapp/frontend/src/components/AmountDisplay.tsx +++ b/dapp/frontend/src/components/AmountDisplay.tsx @@ -1,11 +1,11 @@ -import { cn } from '@/lib/cn' -import { formatCC } from '@/lib/format' +import { cn } from '@/utils/cn' +import { formatCC } from '@/utils/format' interface AmountDisplayProps { - value: string - unit?: string className?: string gradient?: boolean + unit?: string + value: string } // Mono numeral + a muted unit suffix. The canonical way amounts appear. diff --git a/dapp/frontend/src/components/AppShell.tsx b/dapp/frontend/src/components/AppShell.tsx index 5e8c5b61..1090f06a 100644 --- a/dapp/frontend/src/components/AppShell.tsx +++ b/dapp/frontend/src/components/AppShell.tsx @@ -1,37 +1,19 @@ -import { Outlet, useLocation } from 'react-router-dom' +import { Outlet } from 'react-router-dom' import { Card } from '@/components/Card' -import { Sidebar } from '@/components/Sidebar' +import { Toaster } from '@/components/Toaster' import { TopBar } from '@/components/TopBar' import { useConnectErrorToast } from '@/hooks/useConnectErrorToast' -import { useBackend } from '@/providers/BackendProvider' -import { useUiStore } from '@/store/useUiStore' - -const titleFor = (pathname: string, role: string): { title: string; crumb: string } => { - if (pathname.startsWith('/proposals')) { - return { title: 'Proposals', crumb: role } - } - if (pathname.startsWith('/create')) { - return { title: 'Create grant', crumb: 'Funder' } - } - if (pathname.startsWith('/grants/')) { - return { title: 'Grant detail', crumb: role } - } - return { title: role === 'funder' ? 'Granted by me' : 'Dashboard', crumb: role } -} +import { useBackend } from '@/providers/Backend' export const AppShell = (): React.JSX.Element => { - const role = useUiStore((s) => s.role) - const location = useLocation() const { configPending, configError } = useBackend() useConnectErrorToast() - const { title, crumb } = titleFor(location.pathname, role) return (
-
- +
{/* The card swaps in asynchronously with no focus move, so `role="alert"` is what carries it to a reader. */} @@ -45,7 +27,13 @@ export const AppShell = (): React.JSX.Element => { page without one renders a connect placeholder a connected user does not need. */} {configError === undefined && !configPending && }
+
+ + Canton · direct ledger +
+ {/* Inside the router: a toast can carry a ``, which throws without router context. */} +
) } diff --git a/dapp/frontend/src/components/Button.tsx b/dapp/frontend/src/components/Button.tsx index 9b02d7c6..65f23706 100644 --- a/dapp/frontend/src/components/Button.tsx +++ b/dapp/frontend/src/components/Button.tsx @@ -1,14 +1,14 @@ import type { ButtonHTMLAttributes } from 'react' import { Link } from 'react-router-dom' -import { cn } from '@/lib/cn' +import { cn } from '@/utils/cn' type Variant = 'primary' | 'secondary' | 'ghost' | 'danger' type Size = 'sm' | 'md' interface BaseProps { - variant?: Variant - size?: Size className?: string + size?: Size + variant?: Variant } interface ButtonAsButton extends BaseProps, ButtonHTMLAttributes { @@ -17,8 +17,8 @@ interface ButtonAsButton extends BaseProps, ButtonHTMLAttributes = { const classesFor = (variant: Variant, size: Size, className?: string): string => cn( - 'inline-flex items-center justify-center gap-2 rounded-xl font-semibold transition-colors', + 'inline-flex items-center justify-center gap-2 rounded-[8px] font-semibold transition-colors', 'focus-visible:outline-none focus-visible:shadow-[var(--ring)]', 'disabled:cursor-not-allowed disabled:opacity-45', sizes[size], diff --git a/dapp/frontend/src/components/CancelGrantDialog.tsx b/dapp/frontend/src/components/CancelGrant.tsx similarity index 89% rename from dapp/frontend/src/components/CancelGrantDialog.tsx rename to dapp/frontend/src/components/CancelGrant.tsx index 36ae2ee9..82ea60c9 100644 --- a/dapp/frontend/src/components/CancelGrantDialog.tsx +++ b/dapp/frontend/src/components/CancelGrant.tsx @@ -3,25 +3,25 @@ import { AmountDisplay } from '@/components/AmountDisplay' import { Button } from '@/components/Button' import { FieldError } from '@/components/FieldError' import { Modal } from '@/components/Modal' -import { toast } from '@/components/toast' -import { errorText } from '@/lib/errorText' -import { MIN_GRANT_AMOUNT, residualMeetsFloor } from '@/lib/schedule' import type { Grant } from '@/store/types' import { deriveGrant } from '@/store/useVestingStore' +import { errorText } from '@/utils/errorText' +import { MIN_GRANT_AMOUNT, residualMeetsFloor } from '@/utils/schedule' +import { toast } from '@/utils/toast' -interface CancelGrantDialogProps { - open: boolean - onClose: () => void +interface CancelGrantProps { + description: string grant: Grant nowMs: number - description: string - successMessage: string + onClose: () => void onConfirm: () => Promise + open: boolean + successMessage: string } -// Cancel-grant confirmation shared by the dashboard and grant-detail pages. Like ClaimDialog, it +// Cancel-grant confirmation shared by the dashboard and grant-detail pages. Like Claim, it // owns the submit, toast, error and submitting lifecycle so the pages do not. -export const CancelGrantDialog = ({ +export const CancelGrant = ({ open, onClose, grant, @@ -29,7 +29,7 @@ export const CancelGrantDialog = ({ description, successMessage, onConfirm, -}: CancelGrantDialogProps): React.JSX.Element => { +}: CancelGrantProps): React.JSX.Element => { const [submitting, setSubmitting] = useState(false) const derived = deriveGrant(grant, nowMs) // Recomputed each tick with `nowMs`, so a residual growing past the floor re-enables the button diff --git a/dapp/frontend/src/components/Card.tsx b/dapp/frontend/src/components/Card.tsx index d27fb6bd..a3d7b30e 100644 --- a/dapp/frontend/src/components/Card.tsx +++ b/dapp/frontend/src/components/Card.tsx @@ -1,11 +1,11 @@ import type { HTMLAttributes } from 'react' -import { cn } from '@/lib/cn' +import { cn } from '@/utils/cn' // Surface card with the standard border + soft elevation. export const Card = ({ className, ...rest }: HTMLAttributes): React.JSX.Element => (
void - title: string +interface ClaimProps { available: string + onClose: () => void onConfirm: (amount: string) => Promise + open: boolean + title: string } // Amount-entry dialog shared by grant withdraw and residual claim. Enforces the re-lock floor: the // remainder must be zero or at least MIN_GRANT_AMOUNT. -export const ClaimDialog = ({ +export const Claim = ({ open, onClose, title, available, onConfirm, -}: ClaimDialogProps): React.JSX.Element => { +}: ClaimProps): React.JSX.Element => { const [token, setToken] = useToken() const [raw, setRaw] = useState('') const [submitting, setSubmitting] = useState(false) diff --git a/dapp/frontend/src/components/CounterpartyId.tsx b/dapp/frontend/src/components/CounterpartyId.tsx index c086ce63..2e540213 100644 --- a/dapp/frontend/src/components/CounterpartyId.tsx +++ b/dapp/frontend/src/components/CounterpartyId.tsx @@ -1,10 +1,10 @@ import { Identifier } from '@bootnodedev/canton-dappbooster' -import { copyToast } from '@/components/toast' +import { copyToast } from '@/utils/toast' interface CounterpartyIdProps { - party: string - incoming: boolean href?: string + incoming: boolean + party: string } // The other party on a grant or proposal: a from/to prefix and the id, copyable. Renders inline, so diff --git a/dapp/frontend/src/components/FieldError.tsx b/dapp/frontend/src/components/FieldError.tsx index 67a3223c..bd0844ef 100644 --- a/dapp/frontend/src/components/FieldError.tsx +++ b/dapp/frontend/src/components/FieldError.tsx @@ -1,10 +1,10 @@ -import { cn } from '@/lib/cn' +import { cn } from '@/utils/cn' interface FieldErrorProps { + className?: string // Referenced by the field's `aria-describedby`, so the two have to agree. id: string message: string - className?: string } // One place for the error handshake every field repeats: the id the field points at, and the live diff --git a/dapp/frontend/src/components/GrantStatus.tsx b/dapp/frontend/src/components/GrantStatus.tsx new file mode 100644 index 00000000..db102076 --- /dev/null +++ b/dapp/frontend/src/components/GrantStatus.tsx @@ -0,0 +1,34 @@ +import { StatusPill } from '@/components/StatusPill' +import { LockIcon } from '@/icons' +import type { GrantStatus } from '@/store/useVestingStore' + +const LABEL: Record = { + in_cliff: 'In cliff', + not_started: 'Not started', + vesting: 'Vesting', + fully_vested: 'Fully vested', +} + +const WAITING_ON: Record<'in_cliff' | 'not_started', string> = { + in_cliff: 'Locked until cliff', + not_started: 'Nothing vested yet', +} + +export const GrantStatusPill = ({ status }: { status: GrantStatus }): React.JSX.Element => ( + + {LABEL[status]} + +) + +// Stands in for the claim button while nothing is claimable, saying which wait it is. +export const GrantLock = ({ + status, + className, +}: { + status: 'in_cliff' | 'not_started' + className?: string +}): React.JSX.Element => ( + + {WAITING_ON[status]} + +) diff --git a/dapp/frontend/src/components/GrantTable.tsx b/dapp/frontend/src/components/GrantTable.tsx deleted file mode 100644 index a44924c9..00000000 --- a/dapp/frontend/src/components/GrantTable.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { truncateIdentifier } from '@bootnodedev/canton-dappbooster' -import { Link } from 'react-router-dom' -import { Button } from '@/components/Button' -import { Card } from '@/components/Card' -import { ScheduleBar } from '@/components/ScheduleBar' -import { formatCC, formatPct } from '@/lib/format' -import type { Grant, Role } from '@/store/types' -import type { GrantDerived } from '@/store/useVestingStore' - -export interface GrantRow { - grant: Grant - derived: GrantDerived -} - -interface GrantTableProps { - rows: GrantRow[] - role: Role - onClaim?: (grant: Grant) => void - onCancel?: (grant: Grant) => void -} - -const statusText = (d: GrantDerived): string => - d.status === 'in_cliff' ? 'in cliff' : d.status === 'fully_vested' ? 'vested' : 'vesting' - -// Dense, table-driven view for users tracking many grants. -export const GrantTable = ({ - rows, - role, - onClaim, - onCancel, -}: GrantTableProps): React.JSX.Element => ( - -
- - - - - - - - - - - - - {rows.map(({ grant, derived }) => { - const milestones = - grant.schedule.curve.kind === 'milestone' - ? grant.schedule.curve.points.map((p) => p.fraction) - : undefined - return ( - - - - - - - - - - ) - })} - -
GrantStatusProgressTotalVested - {role === 'receiver' ? 'Claimable' : 'Unvested'} - -
- - {grant.title} - -
- {truncateIdentifier(role === 'receiver' ? grant.creator : grant.receiver)} -
-
- - - {statusText(derived)} - - -
- - - {formatPct(derived.fraction)} - -
-
{formatCC(grant.totalAmount)} - {formatCC(derived.vested)} - - {role === 'receiver' ? ( - - {formatCC(derived.claimable)} - - ) : ( - {formatCC(derived.unvested)} - )} - - {role === 'receiver' ? ( - - ) : ( - - )} -
-
-
-) diff --git a/dapp/frontend/src/components/InfoTip.tsx b/dapp/frontend/src/components/InfoTip.tsx new file mode 100644 index 00000000..e410fde4 --- /dev/null +++ b/dapp/frontend/src/components/InfoTip.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from 'react' +import { useId } from 'react' +import { cn } from '@/utils/cn' + +// Wraps the words the tooltip explains, dash-underlined; with no children it is a `?` badge for +// where there is no phrase to underline. Described-by rather than labelled, so the trigger keeps +// its own reading and the note comes after it. +export const InfoTip = ({ + label, + children, + className, +}: { + label: string + children?: ReactNode + className?: string +}): React.JSX.Element => { + const id = useId() + return ( + + + + {label} + + + + ) +} diff --git a/dapp/frontend/src/components/Modal.tsx b/dapp/frontend/src/components/Modal.tsx index 108b1293..b863381c 100644 --- a/dapp/frontend/src/components/Modal.tsx +++ b/dapp/frontend/src/components/Modal.tsx @@ -1,19 +1,18 @@ import { type ReactNode, useEffect, useId, useRef } from 'react' -import { cn } from '@/lib/cn' - -const FOCUSABLE = - 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])' +import { cn } from '@/utils/cn' +import { setTopLayerHost } from '@/utils/topLayer' interface ModalProps { - open: boolean - onClose: () => void - title: string - description?: string children: ReactNode className?: string + description?: string + onClose: () => void + open: boolean + title: string } -// Lightweight centered dialog: scrim + Escape-to-close + scroll lock. +// Centered dialog over a native ``: the top layer carries the scrim, the focus trap, the +// focus restore, the inert background and Escape, so none of it is reimplemented here. export const Modal = ({ open, onClose, @@ -21,92 +20,84 @@ export const Modal = ({ description, children, className, -}: ModalProps): React.JSX.Element | null => { - const dialogRef = useRef(null) +}: ModalProps): React.JSX.Element => { + const dialogRef = useRef(null) const titleId = useId() const descId = useId() - // Held in a ref so a new onClose identity does not re-run the focus-trap effect, which would - // steal focus back to the first focusable on every clock tick. - const onCloseRef = useRef(onClose) - onCloseRef.current = onClose useEffect(() => { - if (!open) { + const dialog = dialogRef.current + if (dialog === null) { return } - const previouslyFocused = document.activeElement as HTMLElement | null - const onKey = (e: KeyboardEvent): void => { - // A nested dismissable layer (the kit's token select) marks the Escape it consumed. - if (e.key === 'Escape' && !e.defaultPrevented) { - onCloseRef.current() - return - } - if (e.key !== 'Tab') { - return - } - const focusable = dialogRef.current?.querySelectorAll(FOCUSABLE) - if (focusable === undefined || focusable.length === 0) { - e.preventDefault() - return - } - const first = focusable[0] - const last = focusable[focusable.length - 1] - const active = document.activeElement - if (e.shiftKey && active === first) { - e.preventDefault() - last.focus() - } else if (!e.shiftKey && active === last) { - e.preventDefault() - first.focus() + if (!open) { + if (dialog.open) { + dialog.close() } + return } - document.addEventListener('keydown', onKey) - const prev = document.body.style.overflow + // Only showModal() reaches the top layer; the `open` attribute renders the dialog inline with + // none of the modal behaviour. Guarded because StrictMode re-runs the effect on mount and a + // second call on an open dialog throws. + if (!dialog.open) { + dialog.showModal() + } + // The top layer inerts the page behind but does not stop it scrolling. + const previousOverflow = document.body.style.overflow document.body.style.overflow = 'hidden' - // Move focus into the dialog so Tab is trapped and screen readers land here. - const firstFocusable = dialogRef.current?.querySelector(FOCUSABLE) - ;(firstFocusable ?? dialogRef.current)?.focus() + setTopLayerHost(dialog) return () => { - document.removeEventListener('keydown', onKey) - document.body.style.overflow = prev - previouslyFocused?.focus() + document.body.style.overflow = previousOverflow + setTopLayerHost(null) } }, [open]) - if (!open) { - return null - } - return ( -
-
+ { + event.preventDefault() + onClose() + }} + // Dismissed on press, not click: a text selection released outside the panel reports the + // dialog as its click target and would close it. + onMouseDown={(event) => { + if (event.target === event.currentTarget) { + onClose() + } + }} + className="fixed inset-0 h-full max-h-none w-full max-w-none place-items-center bg-transparent p-4 backdrop:bg-[var(--scrim)] backdrop:backdrop-blur-sm open:grid" + > + {open && ( +
+

+ {title} +

+ {description !== undefined && ( +

+ {description} +

+ )} +
{children}
+ +
+ )} +
) } diff --git a/dapp/frontend/src/components/PageTitle.tsx b/dapp/frontend/src/components/PageTitle.tsx new file mode 100644 index 00000000..178797e8 --- /dev/null +++ b/dapp/frontend/src/components/PageTitle.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react' + +export const PageTitle = ({ + title, + action, +}: { + title: string + action?: ReactNode +}): React.JSX.Element => ( +
+

{title}

+
{action}
+
+) diff --git a/dapp/frontend/src/components/PrivacyNote.tsx b/dapp/frontend/src/components/PrivacyNote.tsx deleted file mode 100644 index b8bf524a..00000000 --- a/dapp/frontend/src/components/PrivacyNote.tsx +++ /dev/null @@ -1,7 +0,0 @@ -// Shown when the acting party sees nothing, because the ledger only discloses what you are a -// stakeholder of. The privacy is real, not simulated. -export const PrivacyNote = (): React.JSX.Element => ( -

- privacy holds — you see only what you're party to -

-) diff --git a/dapp/frontend/src/components/ProposalCard.tsx b/dapp/frontend/src/components/ProposalCard.tsx deleted file mode 100644 index 3882098a..00000000 --- a/dapp/frontend/src/components/ProposalCard.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { Button } from '@/components/Button' -import { Card } from '@/components/Card' -import { CounterpartyId } from '@/components/CounterpartyId' -import { StatusPill } from '@/components/StatusPill' -import { formatCC, formatDate, relativeTime } from '@/lib/format' -import { vestedFraction } from '@/lib/schedule' -import type { Proposal } from '@/store/types' - -// `direction` incoming means the acting party is the receiver and can accept; outgoing was sent as -// funder. -interface ProposalCardProps { - proposal: Proposal - direction: 'incoming' | 'outgoing' - nowMs: number - onAccept?: (proposal: Proposal) => void -} - -export const ProposalCard = ({ - proposal, - direction, - nowMs, - onAccept, -}: ProposalCardProps): React.JSX.Element => { - const isMilestone = proposal.schedule.curve.kind === 'milestone' - const startFraction = vestedFraction(proposal.schedule, nowMs) - const counterparty = direction === 'incoming' ? proposal.proposer : proposal.receiver - - return ( - -
-
-

{proposal.title}

-
- -
-
- - {direction === 'incoming' ? 'Action needed' : 'Awaiting acceptance'} - -
- -
-
-
Total
-
- {formatCC(proposal.totalAmount)} CC -
-
-
-
Type
-
{isMilestone ? 'Milestone' : 'Linear'}
-
-
-
Cliff
-
- {startFraction > 0 ? 'Passed' : relativeTime(proposal.schedule.cliff, nowMs)} -
-
-
- - {proposal.note !== undefined &&

{proposal.note}

} - -
- - Cliff {formatDate(proposal.schedule.cliff)} - - {direction === 'incoming' ? ( - - ) : ( - awaiting acceptance - )} -
-
- ) -} diff --git a/dapp/frontend/src/components/RoleSelect.tsx b/dapp/frontend/src/components/RoleSelect.tsx new file mode 100644 index 00000000..e2e63ad8 --- /dev/null +++ b/dapp/frontend/src/components/RoleSelect.tsx @@ -0,0 +1,19 @@ +import { Select } from '@/components/Select' +import type { Role } from '@/store/types' + +const roles: { value: Role; label: string }[] = [ + { value: 'receiver', label: 'Receiver' }, + { value: 'funder', label: 'Funder' }, +] + +// The connected party is fixed; this lens chooses whether to view grants where +// the party is receiver or creator. +export const RoleSelect = ({ + value, + onChange, +}: { + value: Role + onChange: (role: Role) => void +}): React.JSX.Element => ( + onChange(e.target.value as T)} + className={cn( + 'rounded-[8px] border border-border bg-surface py-1.5 pl-3 pr-9 text-xs font-semibold text-fg', + className, + )} + > + {options.map((o) => ( + + ))} + +) diff --git a/dapp/frontend/src/components/Sidebar.tsx b/dapp/frontend/src/components/Sidebar.tsx deleted file mode 100644 index fecc6ab0..00000000 --- a/dapp/frontend/src/components/Sidebar.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import type { ComponentType, SVGProps } from 'react' -import { NavLink, type NavLinkRenderProps } from 'react-router-dom' -import { DashboardIcon, InboxIcon, PlusCircleIcon } from '@/components/icons' -import { useParty } from '@/hooks/useParty' -import { cn } from '@/lib/cn' -import { useVestingStore } from '@/store/useVestingStore' - -interface NavItem { - to: string - label: string - Icon: ComponentType> -} - -const items: NavItem[] = [ - { to: '/dashboard', label: 'Dashboard', Icon: DashboardIcon }, - { to: '/proposals', label: 'Proposals', Icon: InboxIcon }, - { to: '/create', label: 'Create grant', Icon: PlusCircleIcon }, -] - -export const Sidebar = (): React.JSX.Element => { - const { party } = useParty() - const proposals = useVestingStore((s) => s.proposals) - const incoming = - party === undefined ? 0 : proposals.filter((p) => p.receiver === party.partyId).length - - return ( - - ) -} diff --git a/dapp/frontend/src/components/StatusPill.tsx b/dapp/frontend/src/components/StatusPill.tsx index 44697da4..4458a6fe 100644 --- a/dapp/frontend/src/components/StatusPill.tsx +++ b/dapp/frontend/src/components/StatusPill.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react' -import { cn } from '@/lib/cn' +import { cn } from '@/utils/cn' type Tone = 'linear' | 'milestone' | 'neutral' | 'success' | 'warning' | 'danger' @@ -13,9 +13,9 @@ const tones: Record = { } interface StatusPillProps { - tone?: Tone children: ReactNode className?: string + tone?: Tone } export const StatusPill = ({ diff --git a/dapp/frontend/src/components/Toaster/ToastRow.tsx b/dapp/frontend/src/components/Toaster/ToastRow.tsx new file mode 100644 index 00000000..7aa782c8 --- /dev/null +++ b/dapp/frontend/src/components/Toaster/ToastRow.tsx @@ -0,0 +1,53 @@ +import { useEffect } from 'react' +import { Link } from 'react-router-dom' +import { CheckIcon } from '@/icons' +import { cn } from '@/utils/cn' +import { type ToastItem, type ToastTone, useToastStore } from '@/utils/toast' + +const toneStyles: Record = { + success: 'border-success/40 text-success', + error: 'border-danger/40 text-danger', + info: 'border-accent/40 text-accent', +} + +export const ToastRow = ({ item }: { item: ToastItem }): React.JSX.Element => { + const dismiss = useToastStore((s) => s.dismiss) + const stays = item.sticky === true || item.action !== undefined + useEffect(() => { + if (stays) { + return + } + const timer = setTimeout(() => dismiss(item.id), 3200) + return () => clearTimeout(timer) + }, [item.id, dismiss, stays]) + return ( +
+ {item.tone === 'success' && } +
+ {item.message} + {item.action !== undefined && ( + dismiss(item.id)} + className="mt-1 block text-xs font-bold text-primary hover:underline" + > + {item.action.label} + + )} +
+ +
+ ) +} diff --git a/dapp/frontend/src/components/Toaster/index.tsx b/dapp/frontend/src/components/Toaster/index.tsx new file mode 100644 index 00000000..1fc22303 --- /dev/null +++ b/dapp/frontend/src/components/Toaster/index.tsx @@ -0,0 +1,24 @@ +import { createPortal } from 'react-dom' +import { ToastRow } from '@/components/Toaster/ToastRow' +import { useToastStore } from '@/utils/toast' +import { useTopLayerHost } from '@/utils/topLayer' + +export const Toaster = (): React.JSX.Element => { + const toasts = useToastStore((s) => s.toasts) + const host = useTopLayerHost() + const viewport = ( +
+ {toasts.map((item) => ( + + ))} +
+ ) + // An open modal dialog inerts the rest of the document, so a toast raised over one (every failed + // submit) is only dismissable and only announced from inside it. + return host === null ? viewport : createPortal(viewport, host) +} diff --git a/dapp/frontend/src/components/TopBar.tsx b/dapp/frontend/src/components/TopBar.tsx deleted file mode 100644 index ecd0adf8..00000000 --- a/dapp/frontend/src/components/TopBar.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { ConnectButton } from '@bootnodedev/canton-dappbooster/connect' -import { PartyAvatar } from '@/components/PartyAvatar' -import { RoleToggle } from '@/components/RoleToggle' -import { ThemeToggle } from '@/components/ThemeToggle' - -interface TopBarProps { - title: string - crumb?: string -} - -export const TopBar = ({ title, crumb }: TopBarProps): React.JSX.Element => { - return ( -
-
-
- {crumb !== undefined && ( -
- {crumb} -
- )} -

{title}

-
-
- - - } /> -
-
-
- ) -} diff --git a/dapp/frontend/src/components/TopBar/Logo.tsx b/dapp/frontend/src/components/TopBar/Logo.tsx new file mode 100644 index 00000000..b59a6193 --- /dev/null +++ b/dapp/frontend/src/components/TopBar/Logo.tsx @@ -0,0 +1,34 @@ +import { Link } from 'react-router-dom' + +// The mark is the product's own mechanic rather than a tile: a flat cliff, then risers, climbing +// from the accent into the pink so the gradient runs along the unlock instead of decorating a box. +export const Logo = (): React.JSX.Element => ( + + + + Canton Vesting + + +) diff --git a/dapp/frontend/src/components/PartyAvatar.tsx b/dapp/frontend/src/components/TopBar/PartyAvatar.tsx similarity index 100% rename from dapp/frontend/src/components/PartyAvatar.tsx rename to dapp/frontend/src/components/TopBar/PartyAvatar.tsx diff --git a/dapp/frontend/src/components/ThemeToggle.tsx b/dapp/frontend/src/components/TopBar/ThemeToggle.tsx similarity index 92% rename from dapp/frontend/src/components/ThemeToggle.tsx rename to dapp/frontend/src/components/TopBar/ThemeToggle.tsx index bdba2977..85cbaed1 100644 --- a/dapp/frontend/src/components/ThemeToggle.tsx +++ b/dapp/frontend/src/components/TopBar/ThemeToggle.tsx @@ -1,5 +1,5 @@ import { useTheme } from '@bootnodedev/canton-dappbooster' -import { MoonIcon, SunIcon } from '@/components/icons' +import { MoonIcon, SunIcon } from '@/icons' export const ThemeToggle = (): React.JSX.Element => { const { resolved, toggle } = useTheme() diff --git a/dapp/frontend/src/components/TopBar/index.tsx b/dapp/frontend/src/components/TopBar/index.tsx new file mode 100644 index 00000000..04907621 --- /dev/null +++ b/dapp/frontend/src/components/TopBar/index.tsx @@ -0,0 +1,56 @@ +import { ConnectButton } from '@bootnodedev/canton-dappbooster/connect' +import { NavLink, type NavLinkRenderProps } from 'react-router-dom' +import { Logo } from '@/components/TopBar/Logo' +import { PartyAvatar } from '@/components/TopBar/PartyAvatar' +import { ThemeToggle } from '@/components/TopBar/ThemeToggle' +import { useParty } from '@/hooks/useParty' +import { useVestingStore } from '@/store/useVestingStore' +import { cn } from '@/utils/cn' + +const items = [ + { to: '/', label: 'Grants' }, + { to: '/proposals', label: 'Proposals' }, +] + +export const TopBar = (): React.JSX.Element => { + const { party } = useParty() + const proposals = useVestingStore((s) => s.proposals) + const incoming = + party === undefined ? 0 : proposals.filter((p) => p.receiver === party.partyId).length + + return ( +
+
+ + + + +
+ } /> + +
+
+
+ ) +} diff --git a/dapp/frontend/src/components/icons.tsx b/dapp/frontend/src/components/icons.tsx deleted file mode 100644 index 47604f3d..00000000 --- a/dapp/frontend/src/components/icons.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import type { ReactNode, SVGProps } from 'react' - -// Inline stroked icons (currentColor). Decorative — rendered through a single Svg -// wrapper that carries aria-hidden so they are skipped by assistive tech. - -type IconProps = SVGProps - -const Svg = ({ children, ...props }: IconProps & { children: ReactNode }): React.JSX.Element => ( - -) - -export const DashboardIcon = (p: IconProps) => ( - - - - - - -) - -export const InboxIcon = (p: IconProps) => ( - - - - -) - -export const PlusCircleIcon = (p: IconProps) => ( - - - - -) - -export const SunIcon = (p: IconProps) => ( - - - - -) - -export const MoonIcon = (p: IconProps) => ( - - - -) - -export const LockIcon = (p: IconProps) => ( - - - - -) - -export const ArrowLeftIcon = (p: IconProps) => ( - - - -) - -export const CheckIcon = (p: IconProps) => ( - - - -) diff --git a/dapp/frontend/src/components/toast.tsx b/dapp/frontend/src/components/toast.tsx deleted file mode 100644 index 7ab7e042..00000000 --- a/dapp/frontend/src/components/toast.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { useEffect } from 'react' -import { create } from 'zustand' -import { CheckIcon } from '@/components/icons' -import { cn } from '@/lib/cn' - -type ToastTone = 'success' | 'error' | 'info' - -interface ToastItem { - id: string - tone: ToastTone - message: string -} - -interface ToastState { - toasts: ToastItem[] - push: (tone: ToastTone, message: string) => void - dismiss: (id: string) => void -} - -const useToastStore = create((set) => ({ - toasts: [], - push: (tone, message) => - set((state) => ({ - toasts: [...state.toasts, { id: crypto.randomUUID(), tone, message }], - })), - dismiss: (id) => set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })), -})) - -export const toast = { - success: (message: string): void => useToastStore.getState().push('success', message), - error: (message: string): void => useToastStore.getState().push('error', message), - info: (message: string): void => useToastStore.getState().push('info', message), -} - -// The Toaster is the app's live region, so kit ``s using this pass `announce={false}`. -export const copyToast = - (noun: string) => - (outcome: { ok: boolean }): void => { - if (outcome.ok) { - toast.success(`${noun} copied`) - } else { - toast.error(`Could not copy ${noun.toLowerCase()}`) - } - } - -const toneStyles: Record = { - success: 'border-success/40 text-success', - error: 'border-danger/40 text-danger', - info: 'border-accent/40 text-accent', -} - -const ToastRow = ({ item }: { item: ToastItem }): React.JSX.Element => { - const dismiss = useToastStore((s) => s.dismiss) - useEffect(() => { - const timer = setTimeout(() => dismiss(item.id), 3200) - return () => clearTimeout(timer) - }, [item.id, dismiss]) - return ( - - ) -} - -export const Toaster = (): React.JSX.Element => { - const toasts = useToastStore((s) => s.toasts) - return ( -
- {toasts.map((item) => ( - - ))} -
- ) -} diff --git a/dapp/frontend/src/hooks/useConnectErrorToast.ts b/dapp/frontend/src/hooks/useConnectErrorToast.ts index c35d59e5..56354c1e 100644 --- a/dapp/frontend/src/hooks/useConnectErrorToast.ts +++ b/dapp/frontend/src/hooks/useConnectErrorToast.ts @@ -1,7 +1,7 @@ import { ConnectCancelledError, useConnect } from '@bootnodedev/canton-connect' import { useEffect, useRef } from 'react' -import { toast } from '@/components/toast' -import { errorText } from '@/lib/errorText' +import { errorText } from '@/utils/errorText' +import { toast } from '@/utils/toast' /** False for a cancelled connect, which is a choice rather than a failure. */ export const isReportableConnectError = (error: Error): boolean => diff --git a/dapp/frontend/src/hooks/useRoleLens.ts b/dapp/frontend/src/hooks/useRoleLens.ts new file mode 100644 index 00000000..b6de2deb --- /dev/null +++ b/dapp/frontend/src/hooks/useRoleLens.ts @@ -0,0 +1,10 @@ +import { useState } from 'react' +import { useSearchParams } from 'react-router-dom' +import type { Role } from '@/store/types' + +// Page-local so switching sections resets it; the initial value comes from the URL, which is how a +// link can land on the lens that shows what the user just created. +export const useRoleLens = (): [Role, (role: Role) => void] => { + const [params] = useSearchParams() + return useState(params.get('role') === 'funder' ? 'funder' : 'receiver') +} diff --git a/dapp/frontend/src/hooks/useToken.ts b/dapp/frontend/src/hooks/useToken.ts index 30460f81..2d4947f3 100644 --- a/dapp/frontend/src/hooks/useToken.ts +++ b/dapp/frontend/src/hooks/useToken.ts @@ -1,6 +1,6 @@ import type { TokenMeta } from '@bootnodedev/canton-dappbooster' import { useState } from 'react' -import { CC } from '@/lib/tokens' +import { CC } from '@/utils/tokens' // The field's own selection, seeded with CC. Per field on purpose: nothing yet says two amounts on // one page share a token. diff --git a/dapp/frontend/src/icons/ArrowLeftIcon.tsx b/dapp/frontend/src/icons/ArrowLeftIcon.tsx new file mode 100644 index 00000000..384d2548 --- /dev/null +++ b/dapp/frontend/src/icons/ArrowLeftIcon.tsx @@ -0,0 +1,7 @@ +import { type IconProps, Svg } from '@/icons/Svg' + +export const ArrowLeftIcon = (p: IconProps): React.JSX.Element => ( + + + +) diff --git a/dapp/frontend/src/icons/CheckIcon.tsx b/dapp/frontend/src/icons/CheckIcon.tsx new file mode 100644 index 00000000..aea09ede --- /dev/null +++ b/dapp/frontend/src/icons/CheckIcon.tsx @@ -0,0 +1,7 @@ +import { type IconProps, Svg } from '@/icons/Svg' + +export const CheckIcon = (p: IconProps): React.JSX.Element => ( + + + +) diff --git a/dapp/frontend/src/icons/LockIcon.tsx b/dapp/frontend/src/icons/LockIcon.tsx new file mode 100644 index 00000000..17f9404e --- /dev/null +++ b/dapp/frontend/src/icons/LockIcon.tsx @@ -0,0 +1,8 @@ +import { type IconProps, Svg } from '@/icons/Svg' + +export const LockIcon = (p: IconProps): React.JSX.Element => ( + + + + +) diff --git a/dapp/frontend/src/icons/MoonIcon.tsx b/dapp/frontend/src/icons/MoonIcon.tsx new file mode 100644 index 00000000..ddf9af2a --- /dev/null +++ b/dapp/frontend/src/icons/MoonIcon.tsx @@ -0,0 +1,7 @@ +import { type IconProps, Svg } from '@/icons/Svg' + +export const MoonIcon = (p: IconProps): React.JSX.Element => ( + + + +) diff --git a/dapp/frontend/src/icons/PlusIcon.tsx b/dapp/frontend/src/icons/PlusIcon.tsx new file mode 100644 index 00000000..455d9c8d --- /dev/null +++ b/dapp/frontend/src/icons/PlusIcon.tsx @@ -0,0 +1,7 @@ +import { type IconProps, Svg } from '@/icons/Svg' + +export const PlusIcon = (p: IconProps): React.JSX.Element => ( + + + +) diff --git a/dapp/frontend/src/icons/SunIcon.tsx b/dapp/frontend/src/icons/SunIcon.tsx new file mode 100644 index 00000000..abc4b7c9 --- /dev/null +++ b/dapp/frontend/src/icons/SunIcon.tsx @@ -0,0 +1,8 @@ +import { type IconProps, Svg } from '@/icons/Svg' + +export const SunIcon = (p: IconProps): React.JSX.Element => ( + + + + +) diff --git a/dapp/frontend/src/icons/Svg.tsx b/dapp/frontend/src/icons/Svg.tsx new file mode 100644 index 00000000..39b9265a --- /dev/null +++ b/dapp/frontend/src/icons/Svg.tsx @@ -0,0 +1,24 @@ +import type { ReactNode, SVGProps } from 'react' + +export type IconProps = SVGProps + +// Decorative by default: aria-hidden here so no icon has to remember it. +export const Svg = ({ + children, + ...props +}: IconProps & { children: ReactNode }): React.JSX.Element => ( + +) diff --git a/dapp/frontend/src/icons/TrashIcon.tsx b/dapp/frontend/src/icons/TrashIcon.tsx new file mode 100644 index 00000000..a3532668 --- /dev/null +++ b/dapp/frontend/src/icons/TrashIcon.tsx @@ -0,0 +1,7 @@ +import { type IconProps, Svg } from '@/icons/Svg' + +export const TrashIcon = (p: IconProps): React.JSX.Element => ( + + + +) diff --git a/dapp/frontend/src/icons/index.ts b/dapp/frontend/src/icons/index.ts new file mode 100644 index 00000000..c3205884 --- /dev/null +++ b/dapp/frontend/src/icons/index.ts @@ -0,0 +1,7 @@ +export { ArrowLeftIcon } from '@/icons/ArrowLeftIcon' +export { CheckIcon } from '@/icons/CheckIcon' +export { LockIcon } from '@/icons/LockIcon' +export { MoonIcon } from '@/icons/MoonIcon' +export { PlusIcon } from '@/icons/PlusIcon' +export { SunIcon } from '@/icons/SunIcon' +export { TrashIcon } from '@/icons/TrashIcon' diff --git a/dapp/frontend/src/pages/Dashboard/CreateGrant/DateField.tsx b/dapp/frontend/src/pages/Dashboard/CreateGrant/DateField.tsx new file mode 100644 index 00000000..d3d98679 --- /dev/null +++ b/dapp/frontend/src/pages/Dashboard/CreateGrant/DateField.tsx @@ -0,0 +1,30 @@ +import { atMidnight, dateOf, inputClass, labelClass } from '@/pages/Dashboard/CreateGrant/fields' + +// One of the schedule's three labelled dates. It hands back a full ISO string, so the caller never +// sees the calendar-day form the input binds to. +export const DateField = ({ + id, + label, + value, + onChange, + className, +}: { + id: string + label: string + value: string + onChange: (iso: string) => void + className?: string +}): React.JSX.Element => ( +
+ + onChange(atMidnight(e.target.value))} + className={inputClass} + /> +
+) diff --git a/dapp/frontend/src/pages/Dashboard/CreateGrant/LiveScheduleCurve.tsx b/dapp/frontend/src/pages/Dashboard/CreateGrant/LiveScheduleCurve.tsx new file mode 100644 index 00000000..f9de3d99 --- /dev/null +++ b/dapp/frontend/src/pages/Dashboard/CreateGrant/LiveScheduleCurve.tsx @@ -0,0 +1,11 @@ +import { ScheduleCurve } from '@/components/ScheduleCurve' +import { useNow } from '@/utils/clock' +import type { VestingSchedule } from '@/utils/schedule' + +// Isolates the clock to the preview marker so typing in the form does not reconcile the whole page +// each tick. +export const LiveScheduleCurve = ({ + schedule, +}: { + schedule: VestingSchedule +}): React.JSX.Element => diff --git a/dapp/frontend/src/pages/Dashboard/CreateGrant/fields.ts b/dapp/frontend/src/pages/Dashboard/CreateGrant/fields.ts new file mode 100644 index 00000000..dc30f83a --- /dev/null +++ b/dapp/frontend/src/pages/Dashboard/CreateGrant/fields.ts @@ -0,0 +1,8 @@ +// Schedule timestamps are full ISO so demo presets can build sub-day windows; the date inputs bind +// to the calendar-day part and reset the time to midnight when edited. +export const dateOf = (iso: string): string => iso.slice(0, 10) +export const atMidnight = (date: string): string => `${date}T00:00:00.000Z` + +export const labelClass = 'block text-xs font-bold uppercase tracking-[0.06em] text-fg-muted' +export const inputClass = + 'mt-1.5 h-11 w-full rounded-[8px] border border-border bg-bg px-3 text-fg outline-none focus:shadow-[var(--ring)]' diff --git a/dapp/frontend/src/features/CreateGrantPage.tsx b/dapp/frontend/src/pages/Dashboard/CreateGrant/index.tsx similarity index 68% rename from dapp/frontend/src/features/CreateGrantPage.tsx rename to dapp/frontend/src/pages/Dashboard/CreateGrant/index.tsx index a4274d49..1905175f 100644 --- a/dapp/frontend/src/features/CreateGrantPage.tsx +++ b/dapp/frontend/src/pages/Dashboard/CreateGrant/index.tsx @@ -1,59 +1,61 @@ import { + Identifier, isValidPartyId, type PartyIdError, PartyIdInput, - partyHint, TokenInput, + useExplorerLink, validateAmount, } from '@bootnodedev/canton-dappbooster' import { useMemo, useState } from 'react' -import { useNavigate } from 'react-router-dom' import { AmountDisplay } from '@/components/AmountDisplay' import { Button } from '@/components/Button' -import { Card } from '@/components/Card' import { ConnectPrompt } from '@/components/ConnectPrompt' import { FieldError } from '@/components/FieldError' -import { ScheduleCurve } from '@/components/ScheduleCurve' -import { toast } from '@/components/toast' +import { InfoTip } from '@/components/InfoTip' +import { Modal } from '@/components/Modal' +import { Select } from '@/components/Select' import { useParty } from '@/hooks/useParty' import { useToken } from '@/hooks/useToken' -import { compareAmounts } from '@/lib/amount' -import { AMOUNT_ERROR_TEXT } from '@/lib/amountErrorText' -import { now, useNow } from '@/lib/clock' -import { cn } from '@/lib/cn' -import { errorText } from '@/lib/errorText' -import { MIN_GRANT_AMOUNT, type VestingSchedule, validVestingSchedule } from '@/lib/schedule' +import { TrashIcon } from '@/icons' +import { DateField } from '@/pages/Dashboard/CreateGrant/DateField' +import { atMidnight, dateOf, inputClass, labelClass } from '@/pages/Dashboard/CreateGrant/fields' +import { LiveScheduleCurve } from '@/pages/Dashboard/CreateGrant/LiveScheduleCurve' import { useVesting, useVestingStore } from '@/store/useVestingStore' +import { compareAmounts } from '@/utils/amount' +import { AMOUNT_ERROR_TEXT } from '@/utils/amountErrorText' +import { now } from '@/utils/clock' +import { cn } from '@/utils/cn' +import { EXPLORER } from '@/utils/config' +import { errorText } from '@/utils/errorText' +import { MIN_GRANT_AMOUNT, type VestingSchedule, validVestingSchedule } from '@/utils/schedule' +import { copyToast, toast } from '@/utils/toast' type CurveKind = 'linear' | 'milestone' interface MilestoneInput { - id: string date: string + id: string pct: string } // A demo preset; the actual schedule is re-anchored to submit time (see submit()). interface DemoPreset { - kind: CurveKind durationMs: number + kind: CurveKind } // One state for the whole schedule, because no edit touches only one field: every manual one also // has to clear `demo`, and a preset rewrites most of the rest. interface ScheduleForm { - curveKind: CurveKind cliff: string - start: string + curveKind: CurveKind + demo: DemoPreset | null end: string milestones: MilestoneInput[] - demo: DemoPreset | null + start: string } -// Schedule timestamps are full ISO so demo presets can build sub-day windows; the date inputs bind -// to the calendar-day part and reset the time to midnight when edited. -const dateOf = (iso: string): string => iso.slice(0, 10) -const atMidnight = (date: string): string => `${date}T00:00:00.000Z` const relIso = (msFromNow: number): string => new Date(now() + msFromNow).toISOString() const addMonths = (d: Date, m: number): Date => { const copy = new Date(d) @@ -75,12 +77,6 @@ const defaultSchedule = ( ], }) -// Isolates the clock to the preview marker so typing in the form does not reconcile the whole page -// each tick. -const LiveScheduleCurve = ({ schedule }: { schedule: VestingSchedule }): React.JSX.Element => ( - -) - // Build a short demo schedule anchored at `anchorMs` (cliff = anchor, vests over duration). const buildDemoSchedule = (preset: DemoPreset, anchorMs: number): VestingSchedule => { const at = (ms: number): string => new Date(anchorMs + ms).toISOString() @@ -101,38 +97,14 @@ const buildDemoSchedule = (preset: DemoPreset, anchorMs: number): VestingSchedul } } -const labelClass = 'block text-xs font-bold uppercase tracking-[0.06em] text-fg-muted' -const inputClass = - 'mt-1.5 h-11 w-full rounded-xl border border-border bg-bg px-3 text-fg outline-none focus:shadow-[var(--ring)]' -const chipClass = - 'rounded-full border border-border px-3 py-1 text-xs font-semibold text-fg-muted transition-colors hover:border-primary hover:text-primary' - -// One of the schedule's three labelled dates. It hands back a full ISO string, so the caller never -// sees the calendar-day form the input binds to. -const DateField = ({ - id, - label, - value, - onChange, -}: { - id: string - label: string - value: string - onChange: (iso: string) => void -}): React.JSX.Element => ( -
- - onChange(atMidnight(e.target.value))} - className={inputClass} - /> -
-) +const DEMO_DURATION_HINT = 'Schedule will be compressed into the selected amount of time' +const PRESETS = [ + { value: 'none', label: 'Real time' }, + { value: '60000', label: '1 min' }, + { value: '120000', label: '2 min' }, + { value: '300000', label: '5 min' }, + { value: '600000', label: '10 min' }, +] // The kit ships codes, not copy, so the wording is the app's. Exhaustive by construction: a new // code stops this compiling rather than rendering nothing. @@ -142,11 +114,16 @@ const RECEIVER_MESSAGE: Record = { 'invalid-fingerprint': 'The fingerprint after :: must be 68 hex characters.', } -export const CreateGrantPage = (): React.JSX.Element => { - const navigate = useNavigate() +interface CreateGrantProps { + onClose: () => void + open: boolean +} + +export const CreateGrant = ({ open, onClose }: CreateGrantProps): React.JSX.Element => { const { party } = useParty() const { backend, partyId } = useVesting() const createVesting = useVestingStore((s) => s.createVesting) + const explorerLink = useExplorerLink(EXPLORER) const [token, setToken] = useToken() const [receiver, setReceiver] = useState('') @@ -159,19 +136,13 @@ export const CreateGrantPage = (): React.JSX.Element => { demo: null, })) const { curveKind, cliff, start, end, milestones, demo } = scheduleForm - const [note, setNote] = useState('') + const [title, setTitle] = useState('') const [submitting, setSubmitting] = useState(false) - const [disclosedBytes, setDisclosedBytes] = useState(null) + const [step, setStep] = useState(1) - // Editing the grant's identity clears the created panel, restoring the submit button. const editReceiver = (value: string, error: PartyIdError | undefined): void => { setReceiver(value) setReceiverError(error) - setDisclosedBytes(null) - } - const editAmount = (value: string): void => { - setAmount(value) - setDisclosedBytes(null) } const schedule = useMemo(() => { @@ -210,7 +181,8 @@ export const CreateGrantPage = (): React.JSX.Element => { : isSelf ? 'Cannot grant to your own party.' : undefined - const valid = scheduleValid && amountValid && receiverValid && backend !== undefined + const titleValid = title.trim() !== '' + const valid = scheduleValid && amountValid && receiverValid && titleValid && backend !== undefined // Any manual schedule edit drops the demo flag so the entered dates are used verbatim, which is // why every one of them goes through here. @@ -234,21 +206,35 @@ export const CreateGrantPage = (): React.JSX.Element => { end: relIso(durationMs), demo: { kind: 'linear', durationMs }, })) - const demoMilestones = (): void => + const demoMilestones = (durationMs: number): void => setScheduleForm((current) => ({ ...current, curveKind: 'milestone', cliff: relIso(0), milestones: [ - { id: 'd1', date: relIso(30_000), pct: '34' }, - { id: 'd2', date: relIso(60_000), pct: '67' }, - { id: 'd3', date: relIso(90_000), pct: '100' }, + { id: 'd1', date: relIso(durationMs / 3), pct: '34' }, + { id: 'd2', date: relIso((durationMs / 3) * 2), pct: '67' }, + { id: 'd3', date: relIso(durationMs), pct: '100' }, ], - demo: { kind: 'milestone', durationMs: 90_000 }, + demo: { kind: 'milestone', durationMs }, })) // Restore the default months-out schedule (undo a quick-demo preset). const resetSchedule = (): void => - setScheduleForm({ curveKind: 'linear', ...defaultSchedule(new Date(now())), demo: null }) + setScheduleForm((current) => ({ + curveKind: current.curveKind, + ...defaultSchedule(new Date(now())), + demo: null, + })) + + const applyPreset = (value: string): void => { + if (value === 'none') { + resetSchedule() + } else if (curveKind === 'linear') { + demoLinear(Number(value)) + } else { + demoMilestones(Number(value)) + } + } const submit = async (): Promise => { if (!valid || party === undefined || backend === undefined) { @@ -257,11 +243,6 @@ export const CreateGrantPage = (): React.JSX.Element => { // Re-anchor a demo preset to now, or its short window is mostly vested before the receiver // accepts. const finalSchedule = demo === null ? schedule : buildDemoSchedule(demo, now()) - const trimmedNote = note.trim() - const title = - trimmedNote !== '' - ? trimmedNote.split(/[.\n]/)[0].slice(0, 60) - : `Grant to ${partyHint(receiver)}` setSubmitting(true) try { const result = await createVesting(backend, partyId, { @@ -269,25 +250,42 @@ export const CreateGrantPage = (): React.JSX.Element => { receiver, totalAmount: amount, schedule: finalSchedule, - title, - note: trimmedNote === '' ? undefined : trimmedNote, + title: title.trim(), }) - setDisclosedBytes(result.disclosedBytes) + onClose() toast.success( `Proposal created · delivered via explicit disclosure · ${result.disclosedBytes} bytes`, + { action: { label: 'View proposals', to: '/proposals?role=funder' } }, ) } catch (err) { - toast.error(errorText(err)) + toast.error(errorText(err), { sticky: true }) } finally { setSubmitting(false) } } + const stepValid = step === 1 ? titleValid && receiverValid && amountValid : scheduleValid + return ( -
-
- -

Receiver & amount

+ + {step === 1 && ( + <> + + setTitle(e.target.value)} + placeholder="What is this grant for?" + className={inputClass} + />
-
- + + )} + + {step === 2 && ( + <>

Schedule

@@ -347,32 +348,16 @@ export const CreateGrantPage = (): React.JSX.Element => {
- - Quick demo - - - - - + Demo duration + +