diff --git a/CLAUDE.md b/CLAUDE.md index 7132e6bc0..4d4b618d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,7 @@ Full wayfinding is in [`MAP.md`](MAP.md). This is a **Bun monorepo** using works - **apps/frontend** — React 19 + Vite web app → [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md) - **apps/demo** — minimal browser SDK integration → [`apps/demo/CLAUDE.md`](apps/demo/CLAUDE.md) +- **apps/gold** — "ouro. by Vortex" gold app served at /pt-br/gold → [`apps/gold/CLAUDE.md`](apps/gold/CLAUDE.md) - **apps/api** — Express backend (PostgreSQL + Sequelize) → [`apps/api/CLAUDE.md`](apps/api/CLAUDE.md) - **apps/dashboard** — authenticated React dashboard → [`apps/dashboard/CLAUDE.md`](apps/dashboard/CLAUDE.md) - **apps/rebalancer** — liquidity rebalancing service → [`apps/rebalancer/CLAUDE.md`](apps/rebalancer/CLAUDE.md) diff --git a/MAP.md b/MAP.md index 9e86ae1e5..cf40f66cb 100644 --- a/MAP.md +++ b/MAP.md @@ -10,6 +10,7 @@ workspace; use [`docs/README.md`](docs/README.md) to locate durable project cont | `apps/api` | Express API, PostgreSQL/Sequelize models and migrations, block-flow ramp engine, provider integrations, webhooks, and workers. | | `apps/frontend` | React widget and public web surface. XState ramp/KYC flows, wallets, and partner embedding. | | `apps/demo` | Minimal browser-only React example for a BRL/PIX to BSC USDC onramp through `@vortexfi/sdk`. | +| `apps/gold` | "ouro. by Vortex" consumer app for BRL/PIX to PAXG through `@vortexfi/sdk`, served by the frontend site at `/pt-br/gold`. | | `apps/dashboard` | React account dashboard. Auth, customer entities, onboarding, recipients, history, and self-ramp flows. | | `apps/rebalancer` | Standalone service for cross-chain liquidity correction and profitability-aware rebalancing. | diff --git a/apps/frontend/_redirects b/apps/frontend/_redirects index f5065418d..237010d0f 100644 --- a/apps/frontend/_redirects +++ b/apps/frontend/_redirects @@ -13,6 +13,11 @@ # Support the lowercase widget alias without turning unknown localized paths into soft 404s. /pt-br/widget /_shell.html 200 +# Gold's canonical URL is /pt-br/gold/ (plain files under dist/client/pt-BR/gold/, see +# netlify.toml). The unprefixed path redirects until an international Gold landing exists. +/gold /pt-br/gold/ 301 +/gold/* /pt-br/gold/:splat 301 + # Everything else is a real 404 /* /404.html 404 diff --git a/apps/frontend/netlify.toml b/apps/frontend/netlify.toml index 9f3a5b148..fd989cce8 100644 --- a/apps/frontend/netlify.toml +++ b/apps/frontend/netlify.toml @@ -7,9 +7,21 @@ # Redirect rules live in `_redirects` (copied into dist/client by the build), not here. # Marketing routes are prerendered to static HTML by TanStack Start, so they are plain files # under `dist/client`; only the SSR-disabled widget route needs a rewrite. +# Gold (apps/gold) is a separate Vite app built with base /pt-br/gold/ and copied into the +# published tree next to the prerendered locale pages (the pt-BR directory; Netlify matches +# files case-insensitively), so it is served as plain files before any redirect rule applies. +# Gold's build reads these site variables (set in the Netlify UI for all contexts): +# VITE_PRIVY_APP_ID – the Privy app id (without it Gold renders a config error); the +# original gold.satoshipay.io release used cmtiwhqgf01uu0el8lx3ejr35 +# VITE_DEMO_MODE – must be "false"; any other value ships the no-money demo +# It shares VITE_SIGNING_SERVICE_PATH with the frontend, so API routing follows the context. # The dashboard is a separate Netlify site (apps/dashboard/netlify.toml) served on its # own domain; this build does not include it. [build] - command = "cd ../.. && bun install --frozen-lockfile && bun run build:shared && bun run build:sdk && bun run build:frontend" + command = "cd ../.. && bun install --frozen-lockfile && bun run build:shared && bun run build:sdk && bun run build:frontend && bun run build:gold && mkdir -p apps/frontend/dist/client/pt-BR/gold && cp -R apps/gold/dist/client/. apps/frontend/dist/client/pt-BR/gold/" publish = "dist/client" + # Netlify's default ignore check only diffs the base directory, so a commit touching only + # apps/gold or the shared/sdk packages would be skipped as "no content change". Build when + # any input of this site changed (paths are relative to the base directory). + ignore = "git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF -- . ../gold ../../packages/shared ../../packages/sdk" diff --git a/apps/frontend/src/hooks/ramp/schema.ts b/apps/frontend/src/hooks/ramp/schema.ts index d8b824da1..652a55708 100644 --- a/apps/frontend/src/hooks/ramp/schema.ts +++ b/apps/frontend/src/hooks/ramp/schema.ts @@ -39,7 +39,7 @@ export const createRampFormSchema = ( ) => { return z .object({ - fiatToken: z.string().optional() as z.ZodType, + fiatToken: z.string().optional() as z.ZodOptional>, pixId: z.string().optional(), taxId: z.string().optional(), walletAddress: z.string().optional() diff --git a/apps/frontend/src/pages/alfredpay/FiatAccountRegistration/RegisterFiatAccountScreen.tsx b/apps/frontend/src/pages/alfredpay/FiatAccountRegistration/RegisterFiatAccountScreen.tsx index af4adc8a3..371d9b67d 100644 --- a/apps/frontend/src/pages/alfredpay/FiatAccountRegistration/RegisterFiatAccountScreen.tsx +++ b/apps/frontend/src/pages/alfredpay/FiatAccountRegistration/RegisterFiatAccountScreen.tsx @@ -40,7 +40,7 @@ const FIELD_ATTRS: Record { if (f.field === "accountAlias") { return z.string().max(40, t("components.fiatAccountRegistration.validation.nickname")).optional(); } @@ -75,7 +75,7 @@ function buildZodSchema( fields: FieldDef[], accountType: FiatAccountTypeKey, t: TFunction -): z.ZodObject> { +): z.ZodObject>> { const shape = Object.fromEntries(fields.map(f => [f.field, schemaForField(f, accountType, t)])); const base = z.object(shape); @@ -92,7 +92,7 @@ function buildZodSchema( }); } } - }) as unknown as z.ZodObject>; + }) as unknown as z.ZodObject>>; } return base; diff --git a/apps/frontend/src/tests/netlify-redirects.test.ts b/apps/frontend/src/tests/netlify-redirects.test.ts index eb5ae1b99..144e8831c 100644 --- a/apps/frontend/src/tests/netlify-redirects.test.ts +++ b/apps/frontend/src/tests/netlify-redirects.test.ts @@ -17,6 +17,10 @@ describe("Netlify redirects", () => { ]); }); + it("redirects the unprefixed gold path to its Brazilian edition", () => { + expect(redirects).toContainEqual({ from: "/gold/*", status: "301", to: "/pt-br/gold/:splat" }); + }); + it("keeps unknown paths on the real 404 fallback", () => { expect(redirects).toContainEqual({ from: "/*", status: "404", to: "/404.html" }); }); diff --git a/apps/gold/.env.example b/apps/gold/.env.example new file mode 100644 index 000000000..6320f788a --- /dev/null +++ b/apps/gold/.env.example @@ -0,0 +1,12 @@ +# Local visual QA. No real authentication or money movement. +VITE_DEMO_MODE=true + +# Public Privy App ID. The Privy App Secret must never be exposed to Vite/browser code. +VITE_PRIVY_APP_ID=cmtiwhqgf01uu0el8lx3ejr35 + +# Vortex API base: an absolute URL for local development, or a path such as /api/production +# that resolves against the page origin (the Netlify site proxies /api//* to the API). +# State-changing browser calls use the user's OTP-derived bearer session. pk_* is optional +# for attribution/readiness; sk_* is forbidden here. +VITE_SIGNING_SERVICE_PATH=https://api.vortexfinance.co +VITE_VORTEX_PUBLIC_KEY= diff --git a/apps/gold/.gitignore b/apps/gold/.gitignore new file mode 100644 index 000000000..8344d3b5c --- /dev/null +++ b/apps/gold/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +node_modules +dist/ +.env +.env.* +!.env.example +*.log +.DS_Store diff --git a/apps/gold/CLAUDE.md b/apps/gold/CLAUDE.md new file mode 100644 index 000000000..d51d960f5 --- /dev/null +++ b/apps/gold/CLAUDE.md @@ -0,0 +1,24 @@ +# apps/gold - ouro. gold app + +Brazil-first consumer app for buying and selling PAX Gold with PIX through `@vortexfi/sdk`, +branded "ouro. by Vortex". Plain JavaScript React 19 + Vite app with Privy embedded wallets; +imported as a snapshot from `pendulum-chain/vortexperiments`, which keeps the pilot history. + +- It is served by the frontend Netlify site at `https://www.vortexfinance.co/pt-br/gold/`: + `apps/frontend/netlify.toml` builds it with Vite `base: "/pt-br/gold/"` and copies + `dist/client` into the frontend's `dist/client/pt-BR/gold/`, beside the prerendered locale + pages. The site is locale-first (`/pt-br/business`, `/pt-br/widget`), so future markets go + to `/es-mx/gold/` etc.; the unprefixed `/gold/` 301s to the Brazilian edition until an + international landing exists (`apps/frontend/_redirects`). + Reference public assets through `import.meta.env.BASE_URL`, never as root-relative + `/brand/...` paths. +- The API base comes from `VITE_SIGNING_SERVICE_PATH`, like the frontend: a path such as + `/api/production` resolves against the page origin and goes through the site's `/api/*` + proxy, an absolute URL is used as-is. `VITE_DEMO_MODE` must be `false` for real builds. +- Gold stays on its own tooling: excluded from Biome, tested with `node --test`, no + TypeScript. Do not reformat it to the monorepo style. +- Browser storage keys are namespaced `satoshi:*` so they do not collide with the widget on + the shared origin. + +Run `bun dev:gold`, `bun build:gold`, or `cd apps/gold && bun run test` from the repository +root. The SDK must be built first (`bun build:sdk`). diff --git a/apps/gold/README.md b/apps/gold/README.md new file mode 100644 index 000000000..13aca44aa --- /dev/null +++ b/apps/gold/README.md @@ -0,0 +1,35 @@ +# ouro. + +Brazil-first consumer pilot for buying and selling PAX Gold with PIX while the user keeps control of an embedded Ethereum wallet. + +## What is implemented + +- Portuguese landing page and responsive gold dashboard +- Passwordless Privy login with Google first and e-mail under “Outras formas de entrar”, plus automatic embedded Ethereum wallet creation +- Vortex e-mail OTP, refresh-token rotation and concurrent refresh coalescing +- Live Vortex BRL/PIX → PAXG (Ethereum) quote, purchase registration, PIX display, ramp start, status polling and reload recovery +- Brazilian Avenia onboarding: subaccount recovery/creation, ID upload, hosted liveness, KYC submission and provider-status polling +- Explicit fee/slippage disclosure and real on-chain PAXG balance +- PAXG/BRL indicative price chart from CoinGecko; no simulated prices in production +- Encrypted, device-local recovery storage for Vortex ephemeral ramp keys +- Full no-money demo mode for product and mobile QA +- PAXG → BRL/PIX sell flow, explicit ETH-gas funding, wallet confirmations, receipt sequencing and transaction-hash checkpoints for interrupted sessions +- Native Ethereum PAXG availability checked against Vortex's network-specific token list and canonical contract address + +## Local run + +```bash +cp .env.example .env.local +bun install +bun run dev -- --host 0.0.0.0 --port 4173 +``` + +The checked-in defaults run the safe demo. Use OTP `123456` in demo mode. + +## Production configuration + +Set `VITE_DEMO_MODE=false` and build with `bun run build`. The browser integration authenticates state-changing Vortex requests with the user's bearer session obtained through e-mail OTP. A `pk_live_*` public value is optional for partner attribution, discount eligibility and public-key-only readiness reads; it is not a substitute for the user's bearer session. Never add the Privy App Secret or a Vortex `sk_*` key to any `VITE_*` variable. + +The frontend never receives the Privy App Secret or a Vortex server secret. Vortex session tokens remain in session storage; CPF and identity documents are not persisted by the app. + +Live deployment: [https://www.vortexfinance.co/pt-br/gold/](https://www.vortexfinance.co/pt-br/gold/), built and served by the frontend Netlify site (see `apps/frontend/netlify.toml` and `CLAUDE.md` here). The pilot evidence, deployment notes and production checklist from the standalone `gold.satoshipay.io` release stay in `pendulum-chain/vortexperiments`. diff --git a/apps/gold/index.html b/apps/gold/index.html new file mode 100644 index 000000000..ac7dc3879 --- /dev/null +++ b/apps/gold/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + ouro. by Vortex — Compre ouro com PIX + + +
+ + + diff --git a/apps/gold/package.json b/apps/gold/package.json new file mode 100644 index 000000000..83f2243e5 --- /dev/null +++ b/apps/gold/package.json @@ -0,0 +1,26 @@ +{ + "name": "@vortexfi/gold", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "node --test tests/*.test.mjs" + }, + "dependencies": { + "@fontsource/cormorant-garamond": "^5.3.0", + "@fontsource/dm-sans": "^5.3.0", + "@phosphor-icons/react": "^2.1.10", + "@privy-io/react-auth": "^3.39.0", + "@vitejs/plugin-react": "^5.0.4", + "@vortexfi/sdk": "workspace:*", + "qrcode.react": "^4.2.0", + "react": "19.2.0", + "react-dom": "19.2.0", + "recharts": "^3.10.1", + "viem": "catalog:", + "vite": "^6.4.2" + } +} diff --git a/apps/gold/public/assets/gold-bar-cutout.png b/apps/gold/public/assets/gold-bar-cutout.png new file mode 100644 index 000000000..bf18eefeb Binary files /dev/null and b/apps/gold/public/assets/gold-bar-cutout.png differ diff --git a/apps/gold/public/assets/gold-bar-ivory.png b/apps/gold/public/assets/gold-bar-ivory.png new file mode 100644 index 000000000..017bea03c Binary files /dev/null and b/apps/gold/public/assets/gold-bar-ivory.png differ diff --git a/apps/gold/public/assets/gold-bar.png b/apps/gold/public/assets/gold-bar.png new file mode 100644 index 000000000..04fdd161a Binary files /dev/null and b/apps/gold/public/assets/gold-bar.png differ diff --git a/apps/gold/public/brand/ouro-app-icon.png b/apps/gold/public/brand/ouro-app-icon.png new file mode 100644 index 000000000..125a8f1cc Binary files /dev/null and b/apps/gold/public/brand/ouro-app-icon.png differ diff --git a/apps/gold/public/brand/ouro-wordmark.png b/apps/gold/public/brand/ouro-wordmark.png new file mode 100644 index 000000000..cee7b209f Binary files /dev/null and b/apps/gold/public/brand/ouro-wordmark.png differ diff --git a/apps/gold/src/App.jsx b/apps/gold/src/App.jsx new file mode 100644 index 000000000..ea1a65f17 --- /dev/null +++ b/apps/gold/src/App.jsx @@ -0,0 +1,388 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { ArrowLeft, ArrowRight, Bank, CaretDown, Check, CheckCircle, Clock, Copy, EnvelopeSimple, Eye, EyeSlash, Fingerprint, Info, LockKey, SealCheck, ShieldCheck, SignOut, TrendUp, Wallet, WarningCircle, X } from "@phosphor-icons/react"; +import { SellFlow } from "./SellFlow.jsx"; +import { MIN_BUY, QUICK_BUY_VALUES, DEFAULT_BUY, validBuyAmount, buyFeePercent } from "./lib/purchase-options.js"; +import { QRCodeSVG } from "qrcode.react"; +import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis } from "recharts"; +import { fetchPaxgMarket, getDemoMarket } from "./lib/market.js"; +import { readPaxgBalance } from "./lib/paxg.js"; +import { addRampHistory, clearActiveRamp, getActiveRamp, getRampHistory, saveActiveRamp } from "./lib/pilot-store.js"; +import { + classifyRamp, + clearVortexSession, + createBrazilSubaccount, + createPaxgQuote, + createVortexClient, + getBrazilBuyReadiness, + getPaxgAvailability, + getBrazilKycUploads, + pollBrazilKyc, + pollRamp, + registerPaxgBuy, + requestVortexOtp, + startRampSafely, + submitBrazilKyc, + uploadKycDocument, + verifyVortexOtp, +} from "./lib/vortex.js"; + +const DEMO_EMAIL = "ana.silva@gmail.com"; +const INITIAL_GRAMS = 12.634; +const HIGH_SLIPPAGE = 1.5; +const formatBRL = (value, digits = 2) => new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL", minimumFractionDigits: digits, maximumFractionDigits: digits }).format(Number(value || 0)); +const formatNumber = (value, digits = 3) => new Intl.NumberFormat("pt-BR", { minimumFractionDigits: digits, maximumFractionDigits: digits }).format(Number(value || 0)); +const shortAddress = (address) => address ? `${address.slice(0, 6)}…${address.slice(-4)}` : "Preparando…"; +const cleanCpf = (value) => String(value || "").replace(/\D/g, "").slice(0, 11); +const quoteSlippage = (quote, amount, pricePerGram) => Math.max(0, (1 - ((quote?.grams || 0) * pricePerGram) / Number(amount || 1)) * 100); + +function Logo({ compact = false }) { + return ouro.by Vortex; +} + +const IconButton = ({ label, children, className = "", ...props }) => ; + +function Modal({ title, description, onClose, children, wide = false, closeDisabled = false }) { + const closeRef = useRef(null); + useEffect(() => { + const previous = document.activeElement; + closeRef.current?.focus(); + const onKey = (event) => event.key === "Escape" && !closeDisabled && onClose(); + document.addEventListener("keydown", onKey); + document.body.classList.add("no-scroll"); + return () => { document.removeEventListener("keydown", onKey); document.body.classList.remove("no-scroll"); previous?.focus?.(); }; + }, [closeDisabled, onClose]); + return
!closeDisabled && onClose()}>
event.stopPropagation()}>{!closeDisabled && }

{title}

{description &&

{description}

}{children}
; +} + +function Landing({ onStart, onLearn }) { + return

COMPRE OURO COM PIX

Comprar ouro
ficou simples.

Escolha um valor. Pague com PIX.
{" "}Receba ouro com lastro físico, direto na sua carteira.

Barra de ouro fino de um quilograma
12,6 g

seu saldo em ouro

A partir de {formatBRL(MIN_BUY, 0)}