Skip to content

feat(privy-next-cards): cards demo — sign up, manage, and test-spend - #169

Draft
madeleine-c wants to merge 13 commits into
mainfrom
madeleine/add-cards-demo
Draft

feat(privy-next-cards): cards demo — sign up, manage, and test-spend#169
madeleine-c wants to merge 13 commits into
mainfrom
madeleine/add-cards-demo

Conversation

@madeleine-c

@madeleine-c madeleine-c commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds examples/privy-next-cards, a demo of the card issuing flow, wired to the real components from @privy-io/react-auth/ui.

Sign in with Privy → sign up for a card → card summary → simulate a purchase.

  1. Login creates an embedded Ethereum wallet for users without one.
  2. Sign up for a card opens SignUpForCardView in a modal — e-sign disclosure, bank agreements, Bridge terms, KYC, card creation, then the on-chain USDC spend approval.
  3. onCardReady hands the card id straight to CardSummaryView — balance, card face, transactions, card details + reveal, statement downloads.
  4. Simulate a $0.50 purchase creates and captures a Stripe Issuing test authorization so the transaction list has real rows.

Verified end to end against a sandbox app: signup completes, the summary renders, and simulated purchases land on the transaction list.

Before merging

  • The SDK dependency is a beta. Pinned to 3.38.0-beta-20260821205628, the first build containing both components. Pinned exactly, not with a caret, because a caret on a prerelease also matches later prereleases. Move it to the stable release once one ships, and regenerate the lockfile.
  • /api/test-spend should not be deployed as-is. Anyone who can reach it can create authorizations on whatever Stripe account the key points at. It's gated to test-mode keys and capped at $100, but it's a local demo affordance, not a public endpoint. Remove or gate it before deploying anywhere.

Notable choices

  • Sandbox is hardcoded. SignUpForCardView only has published Bridge spender addresses for sandbox testnets; with production it skips the spend approval and still reports the card ready, leaving a card that can't spend. Chain is eip155:84532 (Base Sepolia). Both are constants at the top of cards.tsx, and as const means a stray "production" is a type error.
  • Getting walletId needs a helper. SignUpForCardView takes a Privy wallet id, but useWallets() exposes only addresses, so find-embedded-wallet.ts picks the embedded EVM wallet out of user.linkedAccounts. WalletWithMetadata.id is optional, so it narrows to a required string rather than casting at the call site.
  • Card views load via next/dynamic. Static imports put them and their transitive deps in the initial /page chunk list, which every visitor paid for at first paint even though neither view is reachable without a click. Measured: 3532kB/30 files → 3094kB/24 files, with no card-view chunk in the initial list.
  • Modal, not a drawer, at the 440px width the card views are designed against, capped at 90vh with its own scroll area. Plain Tailwind plus a keydown listener rather than adding @headlessui/react. Mounted conditionally, so neither view fetches until it's on screen.
  • Test spend runs server-side. Stripe Issuing test helpers need a secret key, so it lives in a route handler reading a server-only STRIPE_SECRET_KEY — the case the Next base was chosen for over the Vite starter. It resolves the Privy card id to the Stripe card id via provider_id on GET /api/v1/cards, using a raw authenticated request because the SDK exports no card-list hook.
  • A declined authorization is reported, not captured. Capturing a decline is an API error, and stablecoin-backed cards may decline a synthetic authorization since they check funding. The declined row still appears in the list.
  • Card-exists pill next to the wallet address. It reflects the locally remembered card id, so it's a local view: clearing site data shows "No card yet" even though signup would reuse the existing card.
  • Solana removed from the provider, @solana/kit dropped. The card funds from an EVM chain and the wallet must be ethereum, so Solana only made wallet selection ambiguous.
  • Trimmed base sections (wallet-actions, create-a-wallet, fund-wallet, link-accounts, unlink-accounts, signers, wallet-management, mfa) are opted out under sectionOverrides in .sync-manifest.json. pnpm sync --target=privy-next-cards copies 0 files and pnpm check-drift is clean.

Setup notes worth reading

Two prerequisites cost real debugging time and are now documented in the README:

  • The Bridge sandbox account behind the app's card config needs the cards endorsement. Sandbox and production are separate Bridge accounts with separate capabilities, so enabling cards in production doesn't cover sandbox. Without it signup fails with 'cards' endorsement not allowed, and neither retrying nor KYC clears it.
  • The app's Stripe publishable key must be saved in its cards config. CardSummaryView fetches it itself and has no key prop, so if it's missing Show details is silently inert rather than erroring.

Testing

  • tsc --noEmit, pnpm lint, pnpm build clean.
  • Test-spend guards verified by hand: missing key 500s with guidance, a live key is refused, a bad card id and an over-cap amount 400, and Stripe's own errors pass through with their message intact.
  • Fresh-clone check: git clone the branch → pnpm install --frozen-lockfilepnpm build all succeed. Note the build fails with the placeholder app id (Cannot initialize the Privy provider with an invalid Privy app ID), so a real NEXT_PUBLIC_PRIVY_APP_ID in .env.local is required — same as every other Privy example.

SDK feedback

Papercuts found while integrating, worth fixing in the SDK rather than in every consumer:

  1. walletId isn't reachable from useWallets(), and WalletWithMetadata.id is optional, so consumers hand-roll a filter and a type narrowing. internal-demo hand-rolls the same filter.
  2. The supported-chain list is internal. SANDBOX_SPEND_APPROVAL_CHAINS knows which sandbox chains work, but consumers must hardcode CAIP-2 ids and separately know which are supported — internal-demo copies a 13-entry table.
  3. environment: 'production' fails silently: it skips the spend approval and still reports ready. A dev-mode warning would beat a doc comment.
  4. No public way to list a user's cards, so opening the summary without walking signup means re-implementing GET /api/v1/cards with a raw token fetch — which is exactly what this demo does for test spend.
  5. Prerelease builds can share a version string with a published release, so pnpm install can silently swap in a build missing SignUpForCardView with no resolution error.
  6. CardSummaryView requires developerName while SignUpForCardView dropped appName, leaving no way to brand the signup screen.

Why next rather than react

Both starters pin the same SDK, but 16 of 18 existing examples are privy-next-* (the two react ones are react for platform reasons), CONTRIBUTING.md is written around next paths, and Next route handlers are what make the server-side test-spend call possible.

🤖 Generated with Claude Code

Adds `examples/privy-next-cards` as the scaffold for the Cards demo,
created with `pnpm create-example cards --base=next` so the example is
registered in `.sync-manifest.json` and picks up future base updates.

The demo is trimmed to just the card flow: sign in with Privy, sign up
for a card, then review the card summary. The wallet, funding, linking,
signer, MFA, and wallet-action sections from `privy-next-starter` are
removed and opted out under `sectionOverrides` so base syncs do not
re-add them. `UserObject` is kept as the debug panel.

`src/components/sections/cards.tsx` drives the flow with local state and
renders a placeholder for the signup and summary steps. The real card
components are added on top of this later; the README documents where
they go.

Also drops viem, bs58, and the @solana-program packages, which no longer
have importers after the section removals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
@vercel

vercel Bot commented Aug 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
privy-next-yield-demo Ready Ready Preview Aug 24, 2026 6:42pm
privy-react-farcaster Error Error Aug 24, 2026 6:42pm
privy-react-fiat-onramp Error Error Aug 24, 2026 6:42pm
privy-react-funding Error Error Aug 24, 2026 6:42pm
privy-react-permissionless Error Error Aug 24, 2026 6:42pm
privy-react-smart-wallets Error Error Aug 24, 2026 6:42pm
15 Skipped Deployments
Project Deployment Actions Updated (UTC)
privy-next-cross-app-connect Ignored Ignored Preview Aug 24, 2026 6:42pm
privy-next-cross-app-provider Ignored Ignored Preview Aug 24, 2026 6:42pm
privy-next-farcaster Ignored Ignored Preview Aug 24, 2026 6:42pm
privy-next-farcaster-mini-app Ignored Ignored Preview Aug 24, 2026 6:42pm
privy-next-fiat-onramp Ignored Ignored Preview Aug 24, 2026 6:42pm
privy-next-funding Ignored Ignored Preview Aug 24, 2026 6:42pm
privy-next-permissionless Ignored Ignored Preview Aug 24, 2026 6:42pm
privy-next-session-keys Ignored Ignored Preview Aug 24, 2026 6:42pm
privy-next-smart-wallets Ignored Ignored Preview Aug 24, 2026 6:42pm
privy-next-solana Ignored Ignored Preview Aug 24, 2026 6:42pm
privy-next-starter Ignored Ignored Preview Aug 24, 2026 6:42pm
privy-next-wagmi Ignored Ignored Preview Aug 24, 2026 6:42pm
privy-react-pwa Ignored Ignored Preview Aug 24, 2026 6:42pm
privy-react-starter Ignored Ignored Preview Aug 24, 2026 6:42pm
privy-react-whitelabel-starter Ignored Ignored Preview Aug 24, 2026 6:42pm

Request Review

@socket-security

socket-security Bot commented Aug 21, 2026

Copy link
Copy Markdown

@madeleine-c
madeleine-c marked this pull request as draft August 21, 2026 15:20
Replaces the placeholder card section with the real components from
`@privy-io/react-auth/ui`, so the demo can be tested end to end:

- `SignUpForCardView` runs the pre-card journey (e-sign disclosure, bank
  agreements, Bridge terms, KYC, card creation, USDC spend approval) and
  hands back a card id via `onCardReady`.
- `CardSummaryView` then shows balance, transactions, card details and
  reveal, and statement downloads.

Both render inside a new 440px right-anchored drawer
(`src/components/ui/side-panel.tsx`), matching the width the components
are designed against. Written with plain Tailwind plus a keydown
listener rather than pulling in `@headlessui/react`.

`SignUpForCardView` needs a Privy wallet id, which `useWallets()` does
not expose, so `get-embedded-wallets.ts` picks the embedded Ethereum
wallet out of `user.linkedAccounts` and narrows its optional `id` to a
string. Environment (`sandbox`), chain (`eip155:84532`), and the
disclosure's developer name are constants at the top of `cards.tsx`.

The card id is persisted in `localStorage` per user so a reload reopens
the summary rather than re-walking signup. Signup would reuse the same
card regardless, since there is one card per account.

Providers drop the Solana config and the demo drops `@solana/kit`: the
card funds from an EVM chain and the wallet must be an `ethereum` one,
so Solana only made wallet selection ambiguous. Also adds a funding
block with the wallet address and faucet links, since the approval step
needs Base Sepolia gas and the card needs testnet USDC.

Requires a react-auth release containing both components; the dependency
is provisionally set to ^3.38.0 and `pnpm-lock.yaml` still needs
regenerating once that release exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
Quality pass over the demo now that the flow is complete. No behavior
change except the bundle split.

Sync-safety:

- Move `side-panel.tsx` out of `src/components/ui/`, which is an
  `always`-synced glob in `.sync-manifest.json`. It was the only
  example-owned file under `ui/` in the repo, so a base starter that ever
  added its own `side-panel.tsx` would have silently overwritten it with
  no conflict detection. `sections/` is an explicit filename list, which
  cannot collide, and is where sibling examples keep bespoke components.

Bundle:

- Load both card views with `next/dynamic` (`ssr: false`). Statically
  importing them put the views and their transitive deps in the initial
  `/page` chunk list, which every visitor paid for at first paint even
  though neither view is reachable without a click. Initial `/page` JS
  drops from 3532kB across 30 files to 3094kB across 24, and no
  card-view chunk remains in the initial list. They only render inside a
  client-only drawer, so nothing was server-rendered.

Simplification:

- `SidePanel` loses its `open` prop: mounted *is* open, so the prop
  restated conditional rendering and the same fact appeared in both the
  effect guard and an early return. Callers now mount it conditionally,
  which also folds the inner `wallet ?` / `cardId ?` guards into the same
  condition and drops two `: null` arms.
- Drop the `previousOverflow` capture. Nothing else writes
  `body.style.overflow` and only one panel can be open, so the saved
  value was always `""`. The lock itself stays — the page only hides
  overflow at `md` and up.
- `getEmbeddedWallets` -> `findEmbeddedWallet`: it returned a list whose
  `[0]` was taken immediately at its one call site. Also unexports the
  type alias and drops a redundant length check.
- Collapse the card-id storage to a single derived `storageKey`, removing
  a module-level key helper, two repeated `user?.id` guards, and an
  unreachable reset branch (the section unmounts on logout).
- Drop `useMemo` on the wallet lookup and `useCallback` on
  `onCardReady`; neither consumer is memoized, and the file was teaching
  two conventions at once. `closeDrawer` keeps its `useCallback` — it is
  in the panel effect's deps, so without it every render would tear down
  the Escape listener and rewrite `body.style.overflow`.
- Remove a leftover wrapper `<div>` in `page.tsx` that grouped eight
  sections in the base starter and now wraps one child with no classes.
- Also narrows the `onCardReady` closure to capture a user id string
  rather than the whole user object.

Dependencies:

- `@privy-io/react-auth` was `^3.38.0`, which 404s — nothing that high is
  published, so README step 2 (`pnpm install`) hard-failed for every
  reader before they reached the local-build instructions. Pin the latest
  published `^3.37.4` and let the README carry the unreleased-SDK story.
- Regenerate `pnpm-lock.yaml`, which still recorded the base starter's
  `^3.12.0` specifier plus `@solana/kit` and `@solana-program/*`
  importers this example no longer declares. Any `--frozen-lockfile`
  install would have failed on the mismatch.

Docs:

- Trim the README: drop the Configuration table that restated the
  constants' own doc comments in less precise words, fold the
  sync-manifest note and the unreleased-SDK teaser into existing prose,
  and stop restating the in-app faucet copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
Drops the commented-out `NEXT_PUBLIC_PRIVY_SIGNER_ID` and
`NEXT_PUBLIC_SOLANA_MAINNET_RPC_URL` hints inherited from the base
starter. This example has no signers section, and the Solana provider
config was removed, so neither variable is read anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
Swaps the right-anchored drawer for a centered modal. Same 440px width
the card views are designed against, now with rounded corners, capped at
90vh with its own scroll area so tall content (the transaction list, the
KYC steps) still fits on short viewports.

Escape, backdrop click, and the body scroll lock are unchanged, as is the
conditional mounting — being mounted is still being open, so neither card
view fetches until its modal is on screen.

Renames `SidePanel` to `Modal` and the `Drawer` state union to
`OpenView`, so nothing in the file still reads as a drawer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
$12.40 was needlessly large for a demo that just needs a row on the
transaction list, and it eats more of a sandbox card's funding per click.

Derives the button label from the amount rather than repeating it, so the
copy cannot drift from what is actually charged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
- Adds the two test-spend files to the source map, which had gone stale.
- Adds the simulated purchase as a step in the flow, and mentions the
  card-exists pill.
- Documents the Bridge sandbox `cards` endorsement as a dashboard
  prerequisite. Sandbox and production are separate Bridge accounts with
  separate capabilities, so enabling cards in production does not cover
  sandbox, and the resulting failure ("'cards' endorsement not allowed")
  is not something retrying or KYC can clear. This blocked the demo in
  practice and was the least obvious setup step.

Also reverts the customized page metadata in `src/app/layout.tsx`.
`layout.tsx` is an `always`-synced file, so the custom title showed up as
critical drift and `pnpm sync:apply` would have silently reverted it.
Every sibling example keeps the base title. `pnpm check-drift` is now
clean for this example.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
"Sign up for a card" stayed available after a card was created, which read
as an offer to create a second one. There is one card per account, so the
flow would have walked the disclosure steps and handed back the same card
— misleading rather than harmful, but no reason to show it.

Also swaps the funding-wallet copy from "Before signing up, give it" to
"Keep it topped up with" once a card exists, since the pre-signup framing
was stale at that point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
Removes the `UserObject` debug panel, leaving the card flow as the only
thing on the page.

Deletes the section, opts it out under `sectionOverrides` so base syncs
do not re-add it, and drops the now-pointless row split in `page.tsx` —
`md:flex-row` and `flex-grow` only existed to place the panel beside the
main column.

Verified `pnpm sync --target=privy-next-cards` still copies 0 files and
`pnpm check-drift` is clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
Switches the `environment` prop on both card views, the funding chain
(Base Sepolia vs Base mainnet), and which ledger the card lookup queries.
Defaults to sandbox on every load rather than persisting, so the demo
cannot be left sitting in production.

Production is read-only. `SignUpForCardView` grants the card's USDC
allowance to a Bridge spender, and the shipped beta only contains sandbox
testnet spenders — grepping its bundle for `eip155:` returns testnets
only. In production the approval is skipped and the card is still reported
`ready`, so signup would hand back a card that cannot spend with nothing
in the UI to say so. Signup is therefore hidden in production behind
`ALLOW_PRODUCTION_SIGNUP`, with an on-screen explanation, and the flag can
be flipped once mainnet spenders ship.

Simulated purchases are hidden in production too: Stripe's Issuing test
helpers do not exist for live keys, so a live authorization cannot be
fabricated at all. Live spend has to be a real purchase.

Replaces the `localStorage` card-id cache with a real lookup
(`cards-api.ts`, extracted from the test-spend path). The cache could not
serve the production side — signup is disabled there, so the card has to
be discovered — and it also made the pill lie when site data was cleared.
The lookup is scoped per environment and ignores stale responses if the
toggle moves mid-flight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant