diff --git a/.gitignore b/.gitignore index 452c4a1..c4b68a7 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ test-results/ # vercel CLI project link .vercel/ + +# design-review working files (screenshots, reports) +.taste-skill/ diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index ae63370..d6afe92 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -88,7 +88,7 @@ On-chain vault = truth for plan + balance. ## Demo & dry-run `pnpm demo` = zero secrets, PGlite (verified from a fresh clone). `/demo` → per-visitor demo saver + demo session; Never moves funds. -- Demo sessions use `servicesFor(session)` → demo quotes (live dry first, calibrated model fallback), demo status (time-based), demo vault (`demo_vaults` + balance from rows) +- Demo sessions use `servicesFor(session)` → demo quotes (live dry first, calibrated model fallback in `lib/core/feeModel.ts`, also used by the landing page's fee check), demo status (time-based), demo vault (`demo_vaults` + balance from rows) - Owner actions in demo → `POST /api/demo/owner-tx`, same rules as the contract; `fast-forward` 30 days, `reset` - Real worker queries **exclude demo users**; `demo-` hashes never link to explorers - `pnpm e2e` = Playwright PRD §8 journey on demo mode, desktop + phone, in CI diff --git a/README.md b/README.md index b687c8d..f2a917d 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ Listed per hackathon rule Q-005. All code in this repository was written during - **Data:** Drizzle ORM 0.36 + drizzle-kit, PGlite 0.2 (embedded Postgres), `postgres` 3.4 - **Contracts:** `near-sdk` 5.30, `near-contract-standards` 5.30 (test token only), `near-workspaces` 0.24, `cargo-near` 0.22, tokio, anyhow, serde_json - **Tooling:** TypeScript 5.9, tsx, Vitest 2, Playwright 1.64, ESLint 9, Prettier 3, pnpm 11, nanoid, server-only +- **Design:** references from land-book.com (finance and savings sites); public-domain and Creative Commons pictures and footage from Wikimedia Commons and The Metropolitan Museum of Art, credited in the page footer and listed in `public/images/credits.json` ## License diff --git a/ROADMAP.md b/ROADMAP.md index 7551b81..f1601bc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -145,6 +145,7 @@ Format: `- [task id] what blocks it · who can unblock · since date` - [x] **K6.5** [#59](https://github.com/MFrancis-dev/stack/issues/59) Dashboard: balance, next buy, totals, average price, where-is-my-money · 2026-11-05: built and reviewed in the browser (landing, redirect and signed-out states); signed-in views re-checked with demo data in Phase 7 - [x] **K6.6** [#60](https://github.com/MFrancis-dev/stack/issues/60) History: deposits, buys, skips (with reason), withdrawals; tx links · 2026-11-05: built and reviewed in the browser (landing, redirect and signed-out states); signed-in views re-checked with demo data in Phase 7 - [x] **K6.7** [#61](https://github.com/MFrancis-dev/stack/issues/61) Settings: withdraw, recover tokens · 2026-11-05: built and reviewed in the browser (landing, redirect and signed-out states); signed-in views re-checked with demo data in Phase 7 +- [x] **K6.9** [#120](https://github.com/MFrancis-dev/stack/issues/120) Redesign the public pages and restyle the app screens · 2026-11-05: landing + demo entry redesigned from land-book.com finance/savings references (bottle green + gold, edge-on coin motif, live fee check, real coin pictures + footage credited in the footer); app screens restyled as a ledger (sage ground, white panels, gold only on the stack); design gate 0 FAIL at 1440/768/390; Playwright journey green - [x] **K6.8** [#62](https://github.com/MFrancis-dev/stack/issues/62) Mobile + accessibility pass · 2026-11-05: browser-reviewed at 1280 px and 390 px (no horizontal overflow, focus styles, labels); signed-in pages to be reviewed again with demo data in Phase 7 ## Phase 7 — Demo mode & dry-run (Nov 11) @@ -218,6 +219,7 @@ Format: `- [task id] what blocks it · who can unblock · since date` ## Update log One line per contribution, newest first: `YYYY-MM-DD · who/agent · what changed · tasks` +- 2026-11-05 · agent · Redesign: new landing + demo entry (land-book references, coin motif, live fee check, credited pictures), app screens restyled to match; fee model moved to `lib/core/feeModel.ts`; e2e updated for the new landing copy · K6.9 - 2026-11-05 · agent · Hosted the demo on Vercel + Neon (build-time migrations, hosted DB guard, daily cleanup cron, .vercelignore, Node 22); loading states on overview/history; `E2E_BASE_URL` runs the journey against the live site (4/4 pass); README hosted URL; docs synced; `.gitleaksignore` for one fake test value (false positive) · K9.1, K9.2, K10.1 - 2026-11-05 · agent · Phase 10 README for judges and AI screeners; .env.example completed; README steps verified from a fresh clone of the public repo · K10.1–K10.5, K10.7 - 2026-11-05 · agent · Phase 8 hardening: wNEAR registration gate for NEAR plans, in-flight withdraw warning, log redaction + security headers, demo cleanup loop (new K8.9); K8.1/4/5/6 verified against existing tests; K8.8 blocked on deploy · K8.1–K8.9 diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index 9b1c8de..48e7bdd 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -38,13 +38,13 @@ export default function Dashboard() { const totalIn = sameAsset.reduce((s, b) => s + BigInt(b.amountIn), 0n); return ( -
-
+
+

Your stack

-

+

{target ? `${formatAmount(totalOut.toString(), target.decimals, target.key === "btc" ? 8 : 6)} ${target.symbol}` : "Nothing yet"}

{target && sameAsset.length > 0 ? ( @@ -57,7 +57,7 @@ export default function Dashboard() { {bars.length > 0 ?

Solid bars arrived. Striped bars are on their way. Dashed bars were skipped and cost nothing.

: null}
-
+
diff --git a/app/(dashboard)/fund/page.tsx b/app/(dashboard)/fund/page.tsx index c927ae6..5a219f8 100644 --- a/app/(dashboard)/fund/page.tsx +++ b/app/(dashboard)/fund/page.tsx @@ -49,7 +49,7 @@ function FundFlow() { if (attempt) { return (
-

Add funds

+

Add funds

@@ -59,7 +59,7 @@ function FundFlow() { return (
-

Add funds

+

Add funds

Send almost any token from any chain. It arrives in your vault as US dollars (USDC), ready for your next buys.

diff --git a/app/(dashboard)/history/page.tsx b/app/(dashboard)/history/page.tsx index d441146..3b5fc19 100644 --- a/app/(dashboard)/history/page.tsx +++ b/app/(dashboard)/history/page.tsx @@ -18,10 +18,10 @@ export default function HistoryPage() {
-

History

+

History

Every buy, including the ones that were skipped and why. Skipped buys never spend anything.

- + Download CSV
diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 2b4282e..762f33d 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -38,7 +38,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) { else if (v && !v.signedIn) body = (
-

Sign in to see your vault

+

Sign in to see your vault

); @@ -47,7 +47,8 @@ export default function DashboardLayout({ children }: { children: ReactNode }) { return (
- + + Stack
@@ -69,7 +70,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) { {n.label} diff --git a/app/(dashboard)/plan/page.tsx b/app/(dashboard)/plan/page.tsx index 817e0e9..64e47e2 100644 --- a/app/(dashboard)/plan/page.tsx +++ b/app/(dashboard)/plan/page.tsx @@ -15,7 +15,7 @@ export default function PlanPage() { return (
-

{v.state.targetAsset ? "Your plan" : "Set your plan"}

+

{v.state.targetAsset ? "Your plan" : "Set your plan"}

Your vault follows these rules on its own. You can change them or pause at any time; only your wallet can.

diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index 5cc053a..08b2e7c 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -16,7 +16,7 @@ export default function SettingsPage() { return (
-

Settings

+

Settings

Your vault is {v.vaultAccountId}. Only {v.accountId} can withdraw from it.

diff --git a/app/demo/page.tsx b/app/demo/page.tsx index dbcfec7..0e7a4be 100644 --- a/app/demo/page.tsx +++ b/app/demo/page.tsx @@ -2,15 +2,16 @@ /** * Demo entry: explains what you'll see and starts a private demo session (fresh example data, - * no wallet, no money). Roadmap: K7.3 + * no wallet, no money). Same look as the landing page. Roadmap: K7.3 */ import { useMutation } from "@tanstack/react-query"; +import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { Button } from "@/components/ui/Button"; -import { Notice } from "@/components/ui/Notice"; +import { Credits } from "@/components/landing/Credits"; import { api } from "@/lib/client/api"; +import "../landing.css"; export default function DemoPage() { const router = useRouter(); @@ -19,31 +20,64 @@ export default function DemoPage() { onSuccess: () => router.push("/dashboard"), }); return ( -
- - Stack - -

See Stack with example data

-

- You get your own example saver: $1,300 deposited from two chains, a plan to buy $100 of Bitcoin every week, and - four weeks of history, including one week that was skipped because fees were too high. -

-
    -
  • Change the plan and watch the fee estimate. Prices are live when we can reach NEAR Intents.
  • -
  • Add funds to see a deposit go from waiting to done in about a minute.
  • -
  • Fast-forward 30 days to see the plan keep buying on its own.
  • -
-
- -

No wallet and no money involved. Your example data is private to this browser.

+
+
+ + +
+
+

See Stack with example data

+

+ You get your own example saver: $1,300 deposited from two chains, $100 of Bitcoin + bought every week, and four weeks of history. +

+
    +
  • One week was skipped because fees were too high. See why in the history.
  • +
  • + Change the plan and watch the fee estimate. Prices are live when NEAR Intents can be + reached. +
  • +
  • Add funds and watch a deposit go from waiting to done in about a minute.
  • +
  • Fast-forward 30 days to see the plan keep buying on its own.
  • +
+
+ +

No wallet and no money involved. Your example data is private to this browser.

+ {start.isError ? ( +

+ {start.error.message} +

+ ) : null} +
+
+ +
+ Stacks of copper and silver coins +
Small amounts, often. The demo shows four weeks of it.
+
+
+ +
+ +
- {start.isError ? ( -
- {start.error.message} -
- ) : null} -
+
); } diff --git a/app/globals.css b/app/globals.css index 697db25..716ef26 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,19 +1,22 @@ @import "tailwindcss"; /* - * Stack design tokens (K6.x). Sage paper, green-black ink, moss for actions, brass for what you've - * stacked. Brass is decoration only — never used for text (contrast). + * App screen tokens (K6.x), the "ledger" style: a sage ground from the product's world, white + * panels with a faint edge, bottle-green ink for text and the one primary action, and deep gold only + * on the stack itself (the main data). The public pages use their own tokens (app/landing.css). */ @theme { - --color-paper: #f3f5f2; - --color-paper-deep: #e7ece7; - --color-ink: #18231f; - --color-sage: #56655d; - --color-mist: #d9e0da; - --color-moss: #2f6b4f; - --color-moss-deep: #24543e; - --color-brass: #b8892b; - --color-brass-soft: #e8d5a6; + --color-paper: #f2f5f2; + --color-paper-deep: #e8ede8; + --color-surface: #ffffff; + --color-ink: #133621; + --color-sage: #4f5e56; + --color-mist: #dde4de; + --color-moss: #133621; + --color-moss-deep: #1f4a2e; + --color-brass: #a37b12; + --color-brass-soft: #ecd9a0; + --color-gold: #f4d96c; --color-rust: #b4412f; --font-display: var(--font-bricolage), ui-sans-serif, system-ui, sans-serif; @@ -58,5 +61,9 @@ h3 { /* A buy that is on its way: brass stripes instead of animation. */ .stack-pending { - background-image: repeating-linear-gradient(135deg, var(--color-brass) 0 6px, var(--color-brass-soft) 6px 12px); + background-image: repeating-linear-gradient( + 135deg, + var(--color-brass) 0 6px, + var(--color-brass-soft) 6px 12px + ); } diff --git a/app/landing.css b/app/landing.css new file mode 100644 index 0000000..6d0b6f9 --- /dev/null +++ b/app/landing.css @@ -0,0 +1,1268 @@ +/* + * Landing page (public pages only: / and /demo). Direction "One motif": a gold coin seen edge-on, + * on bottle green. The coin's stadium shape is the only shape with character on the page: the hero + * stack, the buttons, the band, the fee check's disc and the footer blocks. + * Scoped under .lp so the app screens keep their own (ledger) tokens. + */ + +.lp { + --bg: #133621; + --surface: #1a4429; + --surface-2: #235333; + --ink: #f4d96c; + --muted: #c2b46c; + --accent: #f4d96c; + --accent-ink: #133621; + + --s1: clamp(56px, 6.6vw, 100px); /* display */ + --s2: clamp(34px, 3.6vw, 52px); /* section heading */ + --s3: clamp(24px, 2.5vw, 36px); /* statement */ + --lead: 24px; + --s4: 19px; /* body */ + --s5: 16px; /* small */ + + --m: clamp(16px, 4vw, 56px); + --gap: 24px; + --section: clamp(96px, 10vw, 150px); + --coin: 999px; + --ease: cubic-bezier(0.22, 1, 0.36, 1); + + background: var(--bg); + color: var(--ink); + font-size: var(--s4); + line-height: 1.55; + min-height: 100dvh; + overflow-x: clip; +} + +.lp ::selection { + background: var(--ink); + color: var(--bg); +} + +.lp :focus-visible { + outline: 3px solid var(--ink); + outline-offset: 3px; + border-radius: 6px; +} + +.lp h1, +.lp h2, +.lp h3 { + font-family: var(--font-display); + font-weight: 800; + letter-spacing: -0.03em; + line-height: 0.98; + text-wrap: balance; +} + +.lp a:not(.lp-btn) { + color: inherit; +} + +.lp-wrap { + padding-inline: var(--m); + max-width: 1600px; + margin-inline: auto; +} + +.lp-grid { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: var(--gap); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* ---------- Buttons: cut to the coin's edge (a stadium) ---------- */ + +.lp-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 52px; + padding: 0 28px; + border-radius: var(--coin); + background: var(--accent); + color: var(--accent-ink); + font-weight: 700; + font-size: var(--s5); + text-decoration: none; + border: 0; + cursor: pointer; + transition: + transform 180ms var(--ease), + background-color 180ms var(--ease); +} +.lp-btn:hover { + background: #fbe78f; + transform: translateY(-2px); +} +.lp-btn:active { + transform: translateY(1px) scale(0.98); +} +.lp-btn:disabled { + cursor: progress; + opacity: 0.8; +} +.lp-btn--ink { + background: var(--bg); + color: var(--ink); +} +.lp-btn--ink:hover { + background: var(--surface); +} + +.lp-link { + font-weight: 700; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 5px; + background: none; + border: 0; + padding: 0; + color: inherit; + font-size: inherit; + cursor: pointer; + transition: text-underline-offset 160ms var(--ease); +} +.lp-link:hover { + text-underline-offset: 8px; +} + +/* ---------- Navigation ---------- */ + +.lp-nav { + position: relative; + z-index: 3; + display: flex; + align-items: center; + gap: 48px; + height: 88px; +} +.lp-nav-actions { + margin-left: auto; +} +.lp-mark { + font-family: var(--font-display); + font-weight: 800; + font-size: 32px; + letter-spacing: -0.03em; + text-decoration: none; +} +.lp-nav ul { + display: flex; + gap: 32px; + list-style: none; + margin: 0; + padding: 0; + font-weight: 700; + font-size: var(--s5); +} +.lp-nav ul a { + text-decoration: none; + transition: opacity 160ms; +} +.lp-nav ul a:hover { + text-decoration: underline; + text-underline-offset: 6px; +} +.lp-nav-actions { + display: flex; + align-items: center; + gap: 24px; +} +.lp-nav .lp-btn { + min-height: 44px; + padding: 0 22px; + font-size: 16px; +} +/* The coin stack runs under the nav's right side; the action sits on the coins in green. */ +@media (min-width: 900px) { + .lp-nav .lp-btn { + background: var(--bg); + color: var(--ink); + } + .lp-nav .lp-btn:hover { + background: var(--surface); + } +} + +/* ---------- Hero: the stack ---------- */ + +.lp-hero { + position: relative; + min-height: max(680px, 100svh); + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto 1fr; + overflow: clip; +} +.lp-hero-copy { + position: relative; + z-index: 2; + align-self: center; + max-width: min(46vw, 760px); + padding-block: 40px 96px; +} +.lp-hero h1 { + font-size: var(--s1); +} +.lp-hero-copy p { + margin-top: 40px; + max-width: 30ch; + font-size: var(--lead); + line-height: 1.45; +} +.lp-hero-actions { + margin-top: 40px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 16px 32px; +} +.lp-hero-note { + margin-top: 24px; + max-width: 40ch; + font-size: var(--s5); + color: var(--muted); +} + +.lp-coins { + position: absolute; + z-index: 1; + left: 57vw; + top: 50%; + width: 66vw; + height: auto; + transform: translateY(-46%); + pointer-events: none; +} +.lp-coins rect { + fill: var(--accent); +} +.lp-coins .is-new { + transform-box: fill-box; + transform-origin: center; +} +.js .lp-coins .coin { + opacity: 0; + transform: translateY(-120px); + animation: coin-drop 700ms var(--ease) forwards; + animation-delay: calc(var(--i) * 70ms + 150ms); +} +@keyframes coin-drop { + to { + opacity: 1; + transform: none; + } +} + +/* ---------- Section rhythm ---------- */ + +.lp-section { + padding-top: var(--section); +} +.lp-h2 { + font-size: var(--s2); +} + +/* ---------- How it works: tiles that grow ---------- */ + +.lp-tiles { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 10px; + align-items: end; +} +.lp-tiles-head { + grid-column: 1 / span 7; + grid-row: 1; + align-self: start; + max-width: 14ch; +} +.lp-tile { + grid-row: 1; + display: grid; + grid-template-rows: auto auto; + align-self: end; +} +.lp-tile:nth-of-type(1) { + grid-column: 1 / span 3; +} +.lp-tile:nth-of-type(2) { + grid-column: 4 / span 4; +} +.lp-tile:nth-of-type(3) { + grid-column: 8 / span 5; +} +.lp-tile-field { + display: grid; + place-items: center; + padding: 24px; + overflow: hidden; +} +.lp-tile:nth-of-type(1) .lp-tile-field { + height: 30vh; + min-height: 250px; + background: var(--surface); +} +.lp-tile:nth-of-type(2) .lp-tile-field { + height: 44vh; + min-height: 340px; + background: var(--surface-2); +} +.lp-tile:nth-of-type(3) .lp-tile-field { + height: 60vh; + min-height: 440px; + background: var(--accent); +} +.lp-tile h3 { + margin-top: 20px; + font-size: var(--lead); + letter-spacing: -0.02em; +} +.lp-tile p { + margin-top: 6px; + max-width: 36ch; + font-size: var(--s5); +} + +/* Interface fragments: the product's own screens, in the app's ledger style. */ +.ui { + --u-bg: #ffffff; + --u-ground: #f2f5f2; + --u-text: #133621; + --u-muted: #56655d; + --u-border: #e1e7e2; + --u-gold: #a37b12; + font-family: var(--font-sans); + color: var(--u-text); + background: var(--u-bg); + border-radius: 8px; + font-size: 15px; + line-height: 22px; + width: 100%; + max-width: 340px; + padding: 18px; + text-align: left; + font-variant-numeric: tabular-nums; +} +.ui h4 { + font-family: var(--font-sans); + font-size: 16px; + font-weight: 700; + margin: 0 0 12px; +} +.ui dl { + display: grid; + grid-template-columns: auto 1fr; + gap: 8px 16px; + margin: 0; +} +.ui dt { + color: var(--u-muted); +} +.ui dd { + margin: 0; + text-align: right; + font-weight: 700; +} +.ui .ui-row { + display: flex; + justify-content: space-between; + gap: 12px; + padding-block: 9px; + border-top: 1px solid var(--u-border); /* taste-skill-ignore: row rule inside a data-ui list */ +} +.ui .ui-row:first-of-type { + border-top: 0; +} +.ui .ui-muted { + color: var(--u-muted); +} +.ui .ui-pill { + display: inline-block; + padding: 0 8px; + border-radius: 999px; + font-size: 13px; + line-height: 20px; + font-weight: 700; + background: #dcf3e2; + color: #0d3b1f; +} +.ui .ui-pill.is-skip { + background: #eef1ee; + color: #3d4a43; +} +.ui .ui-big { + font-size: 22px; + line-height: 28px; + font-weight: 700; +} +.ui-bars { + display: grid; + gap: 4px; + margin-top: 14px; +} +.ui-bars span { + display: block; + height: 10px; + border-radius: 999px; + background: var(--u-gold); +} +.ui-bars span.is-skip { + background: transparent; + outline: 1.5px dashed #8a968f; + outline-offset: -1.5px; +} +.lp-tile:nth-of-type(1) .ui { + transform: scale(0.9); +} +.lp-tile:nth-of-type(2) .ui { + transform: scale(0.96); +} +.lp-tile:nth-of-type(3) .ui { + max-width: 380px; +} + +/* ---------- Fees: object on a disc (the live fee check) ---------- */ + +.lp-fees { + position: relative; + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: var(--gap); + align-items: end; +} +.lp-fees-list { + grid-column: 1 / span 4; + grid-row: 1; + align-self: end; + padding-bottom: 4vh; + font-family: var(--font-display); + font-weight: 800; + font-size: var(--s2); + line-height: 1.15; + letter-spacing: -0.02em; +} +.lp-fees-list h2 { + font-size: inherit; + line-height: inherit; + color: var(--muted); + max-width: 12ch; + margin-bottom: 16px; +} +.lp-fees-list ul { + list-style: none; + margin: 0; + padding: 0; +} +.lp-fees-list button { + font: inherit; + letter-spacing: inherit; + background: none; + border: 0; + padding: 0; + color: var(--muted); + cursor: pointer; + text-align: left; + display: inline-flex; + align-items: center; + gap: 14px; + transition: + color 200ms, + transform 200ms var(--ease); +} +.lp-fees-list button::before { + content: ""; + width: 0.7em; + height: 0.26em; + border-radius: var(--coin); + background: currentColor; + opacity: 0; + transition: opacity 200ms; +} +.lp-fees-list button:hover { + transform: translateX(4px); +} +.lp-fees-list button[aria-pressed="true"] { + color: var(--ink); +} +.lp-fees-list button[aria-pressed="true"]::before { + opacity: 1; +} +.lp-disc-area { + grid-column: 5 / span 5; + grid-row: 1; + align-self: center; + position: relative; + aspect-ratio: 1; + width: min(64vh, 100%); + justify-self: center; +} +.lp-disc { + position: absolute; + inset: 0; + border-radius: 50%; + background: var(--disc, var(--accent)); + transition: background-color 300ms; + overflow: hidden; +} +.lp-disc video, +.lp-disc img { + position: absolute; + left: 50%; + top: 50%; + width: 196%; + max-width: none; + height: auto; + transform: translate(-50%, -54%); + mix-blend-mode: multiply; + filter: brightness(1.18) contrast(1.15); +} +.js .lp-shutter { + clip-path: inset(0 0 100% 0 round 50%); + transition: clip-path 1100ms var(--ease); +} +.js .lp-shutter video { + transform: translate(-50%, -54%) scale(1.12); + transition: transform 1100ms var(--ease); +} +.js .lp-shutter.in { + clip-path: inset(0 0 0 0 round 50%); +} +.js .lp-shutter.in video { + transform: translate(-50%, -54%); +} +.lp-fees-panel { + grid-column: 10 / span 3; + grid-row: 1; + align-self: center; + display: grid; + gap: 20px; +} +.lp-fees-panel .ui { + max-width: none; +} +.lp-fees-fact { + font-size: var(--s5); + max-width: 32ch; +} +.ui label { + display: block; + font-weight: 700; + margin-bottom: 6px; +} +.ui input[type="range"] { + width: 100%; + accent-color: #133621; + height: 28px; +} +.ui .ui-amount { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 4px; +} +.ui .ui-verdict { + margin-top: 14px; + padding: 10px 12px; + border-radius: 6px; + background: #dcf3e2; + color: #0d3b1f; + font-weight: 700; +} +.ui .ui-verdict.is-skip { + background: #fff4d6; + color: #6b4a00; +} + +/* ---------- Coin band ---------- */ + +.lp-band { + margin-top: var(--section); + display: grid; + grid-template-columns: repeat(14, minmax(0, 1fr)); + gap: 8px; + padding-inline: var(--m); +} +.lp-band span { + height: clamp(28px, 3.4vw, 54px); + border-radius: var(--coin); + background: var(--accent); +} +.lp-band span.is-skip { + background: var(--surface-2); +} +.lp-band span.is-pending { + background: var(--muted); +} +.lp-band-caption { + margin-top: 16px; + padding-inline: var(--m); + font-size: var(--s5); +} + +/* ---------- Safety: paragraph with picture words ---------- */ + +.lp-statement { + font-family: var(--font-display); + font-weight: 700; + font-size: var(--s3); + line-height: 1.32; + letter-spacing: -0.015em; + max-width: 34em; +} +.lp-slot { + display: inline-block; + height: 1em; + width: 1.9em; + border-radius: var(--coin); + vertical-align: -0.14em; + overflow: hidden; + margin-inline: 0.12em; + background: var(--surface-2); +} +.lp-slot img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.lp-slot--ui { + width: auto; + padding-inline: 0.45em; + background: #ffffff; + color: #133621; + font-family: var(--font-sans); + font-size: 0.5em; + height: 2em; + line-height: 2em; + vertical-align: 0.2em; + font-weight: 700; +} +.lp-toggle { + display: inline-flex; + align-items: center; + justify-content: flex-end; + width: 1.9em; + height: 1em; + padding: 0.14em; + margin-inline: 0.12em; + vertical-align: -0.14em; + border-radius: var(--coin); + background: var(--accent); +} +.lp-toggle::after { + content: ""; + width: 0.72em; + height: 0.72em; + border-radius: 50%; + background: var(--bg); +} +.lp-safety-more { + margin-top: 72px; + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: var(--gap); +} +.lp-safety-more > div { + grid-column: 7 / span 6; + display: grid; + gap: 20px; + justify-items: start; +} +.lp-safety-pics { + margin-top: 72px; + display: grid; + grid-template-columns: 5fr 4fr 3fr; + gap: 10px; + align-items: end; +} +.lp-safety-pics figure { + margin: 0; +} +.lp-safety-pics img { + width: 100%; + display: block; + object-fit: cover; + border-radius: var(--coin); +} +.lp-safety-pics figure:nth-child(1) img { + aspect-ratio: 16 / 7; +} +.lp-safety-pics figure:nth-child(2) img { + aspect-ratio: 16 / 9; +} +.lp-safety-pics figure:nth-child(3) img { + aspect-ratio: 1; + border-radius: 50%; +} +.lp-safety-pics figcaption { + margin-top: 12px; + font-size: var(--s5); +} + +/* ---------- Questions: index cards ---------- */ + +.lp-faq { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: var(--gap); +} +.lp-faq > * { + grid-column: 3 / span 8; +} +.lp-faq h2 { + font-size: var(--s2); + margin-bottom: 56px; +} +.lp-cards { + display: grid; +} +.lp-card { + --tab-x: calc(var(--i) * 150px); + position: relative; + margin-top: -14px; + z-index: var(--i); + background: var(--card); + color: var(--card-ink, var(--ink)); + clip-path: polygon( + var(--tab-x) 0, + calc(var(--tab-x) + 200px) 0, + calc(var(--tab-x) + 216px) 40px, + 100% 40px, + 100% 100%, + 0 100%, + 0 40px, + var(--tab-x) 40px + ); +} +.lp-card:first-child { + margin-top: 0; +} +.lp-card-tab { + position: absolute; + top: 0; + left: var(--tab-x); + width: 200px; + height: 40px; + display: flex; + align-items: center; + padding-left: 20px; + font-size: var(--s5); + font-weight: 700; +} +.lp-card button { + display: block; + width: 100%; + text-align: left; + background: none; + border: 0; + color: inherit; + cursor: pointer; + padding: 58px 28px 30px; + font-family: var(--font-display); + font-weight: 700; + font-size: var(--lead); + letter-spacing: -0.01em; + line-height: 1.25; +} +.lp-card button:hover span { + text-decoration: underline; + text-underline-offset: 5px; +} +.lp-card h3 { + font-family: var(--font-display); + font-size: var(--lead); + letter-spacing: -0.01em; +} +.lp-card-answer { + padding: 0 28px 34px; + max-width: 54ch; + font-size: var(--s4); +} + +/* ---------- Start: the first screen, already open ---------- */ + +.lp-start { + margin-top: var(--section); + background: var(--accent); + color: var(--accent-ink); +} +.lp-start .lp-wrap { + padding-block: clamp(80px, 9vw, 128px); + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: var(--gap); + align-items: center; +} +.lp-start-copy { + grid-column: 1 / span 6; + align-self: center; +} +.lp-start-copy h2 { + font-size: var(--s1); + line-height: 0.92; +} +.lp-start-copy p { + margin-top: 24px; + max-width: 34ch; + font-size: var(--s4); +} +.lp-dialog { + grid-column: 8 / span 5; + justify-self: end; + width: min(100%, 440px); + max-width: 440px; + padding: 24px; + box-shadow: 0 24px 60px rgba(10, 30, 18, 0.28); /* taste-skill-ignore: the dialog floats above the page */ +} +.lp-dialog h3 { + font-family: var(--font-sans); + font-size: var(--lead); + letter-spacing: 0; + margin-bottom: 18px; +} +.ui .ui-seg { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 4px; + padding: 4px; + border-radius: 999px; + background: var(--u-ground); + margin-bottom: 18px; +} +.ui .ui-seg button { + min-height: 40px; + border: 0; + border-radius: 999px; + background: transparent; + font: inherit; + font-weight: 700; + color: var(--u-muted); + cursor: pointer; +} +.ui .ui-seg button[aria-pressed="true"] { + background: #133621; + color: #f4d96c; +} +.ui .ui-field { + display: flex; + align-items: center; + border: 1px solid #c9d2cc; /* taste-skill-ignore: form field edge */ + border-radius: 8px; + padding: 0 12px; + min-height: 48px; + font-size: var(--s4); + margin-bottom: 14px; +} +.ui .ui-field input { + border: 0; + outline: 0; + font: inherit; + width: 100%; + color: var(--u-text); + background: transparent; + padding-left: 4px; +} +.ui .ui-field:focus-within { + outline: 3px solid #133621; + outline-offset: 1px; +} +.lp-dialog .lp-btn { + width: 100%; + margin-top: 18px; + background: #133621; + color: #f4d96c; +} +.lp-dialog .lp-btn:hover { + background: #1a4429; +} + +/* ---------- Footer: the mark, built from coins ---------- */ + +.lp-foot { + background: var(--bg); + padding-block: 96px 40px; +} +.lp-blocks { + display: block; + width: 100%; + height: auto; +} +.lp-blocks path { + fill: var(--accent); +} +.lp-blocks text { + fill: var(--bg); + font-family: var(--font-display); + font-weight: 800; + font-size: 120px; +} +.lp-foot-links { + margin-top: 56px; + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: var(--gap); + font-size: var(--s5); +} +.lp-foot-links ul { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 10px; + grid-column: span 2; +} +.lp-foot-links ul a { + text-decoration: none; +} +.lp-foot-links ul a:hover { + text-decoration: underline; + text-underline-offset: 4px; +} +.lp-foot-about { + grid-column: 9 / span 4; + display: grid; + gap: 10px; + align-content: start; +} +.lp-foot-last { + margin-top: 56px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 24px; + font-size: var(--s5); +} +.lp-credits { + margin-top: 24px; + font-size: var(--s5); + line-height: 1.5; + color: var(--muted); + max-width: 120ch; +} +.lp-foot .lp-btn { + min-height: 44px; + white-space: nowrap; +} + +/* ---------- Entrances (only with script; content shows without it) ---------- */ + +.js .rv { + opacity: 0; + transform: translateY(24px); + transition: + opacity 700ms var(--ease), + transform 700ms var(--ease); + transition-delay: calc(var(--d, 0) * 80ms); +} +.js .rv.in { + opacity: 1; + transform: none; +} + +/* ---------- Demo entry page ---------- */ + +.lp-demo { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: var(--gap); + padding-block: 64px var(--section); + align-items: center; +} +.lp-demo-copy { + grid-column: 1 / span 6; +} +.lp-demo-copy h1 { + font-size: var(--s1); +} +.lp-demo-copy > p { + margin-top: 32px; + max-width: 34ch; + font-size: var(--lead); + line-height: 1.45; +} +.lp-demo-copy ul { + margin: 28px 0 0; + padding: 0; + list-style: none; + display: grid; + gap: 12px; + max-width: 44ch; +} +.lp-demo-copy li { + display: grid; + grid-template-columns: 1.4em 1fr; + gap: 12px; + align-items: baseline; +} +.lp-demo-copy li::before { + content: ""; + height: 0.42em; + border-radius: var(--coin); + background: var(--accent); +} +.lp-demo-actions { + margin-top: 40px; + display: grid; + justify-items: start; + gap: 14px; +} +.lp-demo-actions p { + color: var(--muted); + font-size: var(--s5); +} +.lp-demo-pic { + grid-column: 8 / span 5; + margin: 0; +} +.lp-demo-pic img { + width: 100%; + aspect-ratio: 4 / 5; + object-fit: cover; + border-radius: 999px 999px 0 0; + display: block; +} +.lp-demo-pic figcaption { + margin-top: 12px; + font-size: var(--s5); +} +.lp-error { + margin-top: 12px; + padding: 12px 16px; + border-radius: 8px; + background: #fff4d6; + color: #6b1f12; + font-size: var(--s5); +} + +/* ---------- Phone ---------- */ + +@media (max-width: 899px) { + .lp-nav ul { + display: none; + } + .lp-nav { + height: 72px; + } + .lp-mark { + font-size: 28px; + } + .lp-hero { + min-height: 100svh; + } + .lp-hero-copy { + align-self: start; + max-width: none; + padding-top: 9svh; + } + .lp-hero h1 { + font-size: 11.5vw; + } + .lp-hero-copy p { + margin-top: 28px; + font-size: var(--s4); + } + .lp-hero-actions { + margin-top: 28px; + } + /* On a phone the stack sits under the copy, in the flow, and runs off the sides and the bottom. */ + .lp-coins { + position: relative; + left: auto; + top: auto; + transform: none; + width: 130vw; + max-width: none; + margin: 8px calc(-15vw - var(--m)) -46vw; + } + .lp-hero-copy { + padding-bottom: 32px; + } + .lp-tiles-head { + grid-column: 1 / -1; + margin-bottom: 20px; + } + .lp-tiles { + grid-template-columns: 1fr; + gap: 28px; + } + .lp-tile, + .lp-tiles-head { + grid-row: auto; + } + .lp-tile:nth-of-type(n) { + grid-column: 1; + } + .lp-tile:nth-of-type(1) .lp-tile-field { + height: auto; + min-height: 0; + aspect-ratio: 100 / 64; + } + .lp-tile:nth-of-type(2) .lp-tile-field { + height: auto; + min-height: 0; + aspect-ratio: 100 / 76; + } + .lp-tile:nth-of-type(3) .lp-tile-field { + height: auto; + min-height: 0; + aspect-ratio: 100 / 100; + } + .lp-tile h3 { + font-size: var(--s4); + } + .lp-tile:nth-of-type(n) .ui { + transform: none; + } + .lp-fees { + min-height: 0; + grid-template-columns: 1fr; + } + .lp-fees > * { + grid-column: 1 !important; + grid-row: auto !important; + } + .lp-disc-area { + width: 86vw; + } + .lp-fees-list { + padding-bottom: 0; + font-size: var(--lead); + } + .lp-fees-list h2 { + max-width: none; + font-size: var(--s2); + } + .lp-fees-list ul { + display: flex; + flex-wrap: wrap; + gap: 8px 24px; + } + .lp-statement { + font-size: var(--lead); + } + .lp-slot:not(.lp-slot--ui) { + width: 1.5em; + } + .lp-safety-more > div { + grid-column: 1 / -1; + } + .lp-safety-pics { + grid-template-columns: 1fr 1fr; + } + .lp-safety-pics figure:nth-child(1) { + grid-column: 1 / -1; + } + .lp-band { + grid-template-columns: repeat(7, minmax(0, 1fr)); + } + .lp-band span:nth-child(n + 8) { + display: none; + } + .lp-faq > * { + grid-column: 1 / -1; + } + .lp-faq h2 { + margin-bottom: 36px; + } + .lp-card { + --tab-x: calc(var(--i) * 48px); + clip-path: polygon( + var(--tab-x) 0, + calc(var(--tab-x) + 120px) 0, + calc(var(--tab-x) + 132px) 40px, + 100% 40px, + 100% 100%, + 0 100%, + 0 40px, + var(--tab-x) 40px + ); + } + .lp-card-tab { + width: 120px; + padding-left: 14px; + } + .lp-card button { + font-size: var(--s4); + padding: 54px 20px 26px; + } + .lp-card h3 { + font-size: var(--s4); + } + .lp-card-answer { + padding: 0 20px 28px; + font-size: var(--s5); + } + .lp-start .lp-wrap { + grid-template-columns: 1fr; + align-content: start; + padding-block: 64px 48px; + gap: 40px; + } + .lp-start-copy, + .lp-dialog { + grid-column: 1; + padding-bottom: 0; + justify-self: stretch; + } + .lp-start-copy h2 { + font-size: 13vw; + } + .lp-dialog .lp-btn { + min-height: 52px; + } + .lp-foot-links ul { + grid-column: span 6; + } + .lp-foot-about { + grid-column: 1 / -1; + } + .lp-foot-links { + grid-template-columns: repeat(12, minmax(0, 1fr)); + } + .lp-demo { + grid-template-columns: 1fr; + padding-top: 24px; + } + .lp-demo-copy, + .lp-demo-pic { + grid-column: 1; + } +} + +@media (min-width: 600px) and (max-width: 899px) { + .lp-coins { + width: 78vw; + justify-self: end; + margin: 8px calc(-1 * var(--m)) -34vw auto; + } +} + +@media (prefers-reduced-motion: reduce) { + .js .rv, + .js .lp-coins .coin, + .js .lp-shutter, + .js .lp-shutter video { + opacity: 1 !important; + transform: none !important; + clip-path: none !important; + animation: none !important; + } + .js .lp-shutter video { + transform: translate(-50%, -54%) !important; + } +} diff --git a/app/page.tsx b/app/page.tsx index ae5bbdc..d176dc8 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,84 +1,458 @@ /** - * Landing: what Stack is, sign in, demo. Roadmap: K6.1 + * Landing: what Stack is, the live fee check, the vault's rules, questions, demo + sign in. + * Direction "One motif": a gold coin seen edge-on on bottle green (see app/landing.css). + * Roadmap: K6.1 */ +import Image from "next/image"; import Link from "next/link"; -import { StackColumn, type StackBar } from "@/components/vault/StackColumn"; -import { SignInButton } from "@/components/wallet/SignInButton"; +import { Credits } from "@/components/landing/Credits"; +import { FeeCheck } from "@/components/landing/FeeCheck"; +import { NavSignIn } from "@/components/landing/NavSignIn"; +import { Questions, type Question } from "@/components/landing/Questions"; +import { Reveal } from "@/components/landing/Reveal"; +import { StartPlan } from "@/components/landing/StartPlan"; +import "./landing.css"; -// Illustration only: twelve weekly $50 buys, two skipped when fees were too high. -const EXAMPLE: StackBar[] = [62, 58, 0, 61, 66, 64, 0, 59, 63, 67, 65, 60].map((w, i) => ({ - key: String(i), - weight: w, - state: w === 0 ? "skipped" : i === 11 ? "pending" : "done", - label: `Week ${i + 1}`, -})); +const REPO = "https://github.com/MFrancis-dev/stack"; +const SETTLE = "https://github.com/MFrancis-dev/settle"; + +// The hero stack: ten weekly buys seen edge-on, each nudged a little off the one below it. +const COIN_OFFSETS = [44, 10, 58, 22, 0, 40, 14, 52, 26, 6]; +const COIN_H = 112; + +// The band between the halves of the page: an example quarter of weekly buys. +const BAND_WEEKS: ("buy" | "skip" | "pending")[] = [ + "buy", + "buy", + "buy", + "skip", + "buy", + "buy", + "buy", + "buy", + "buy", + "skip", + "buy", + "buy", + "buy", + "pending", +]; +const COIN_GAP = 18; + +const QUESTIONS: Question[] = [ + { + tab: "Trust", + q: "What can Stack's own server do with my money?", + a: ( +

+ Only start a buy your vault allows: at most one per period, never more than your amount, + delivered to the address you set. It can't withdraw, change your plan or change where + coins go. If its key were ever stolen, the most it could misdirect is one buy per period + until you press Pause. +

+ ), + }, + { + tab: "Fees", + q: "What counts as fees, and what if they jump?", + a: ( +

+ Fees are everything between the dollars you spend and the coins you get: the swap spread and + the network's delivery charge. Before every buy Stack gets a live quote. If its fees + are above your limit, that week is skipped and nothing is spent. +

+ ), + }, + { + tab: "Funding", + q: "What can I fund my vault with?", + a: ( +

+ USDC, USDT, SOL, ETH and most other tokens on the chains NEAR Intents supports. Whatever you + send arrives in your vault as US dollars (USDC on NEAR), ready for your next buys. +

+ ), + }, + { + tab: "Leaving", + q: "Can I take my dollars back out?", + a: ( +

+ Yes, at any time, with one transaction from your own wallet. Withdrawing is always open, + even while a plan is running, and only you can do it. +

+ ), + }, + { + tab: "Cost", + q: "What does Stack charge?", + a: ( +

+ Nothing on top. You pay the swap and network fees shown before each buy, and a little NEAR + for the transactions you sign yourself. +

+ ), + }, +]; export default async function Home({ searchParams }: { searchParams: Promise<{ next?: string }> }) { const { next } = await searchParams; + const coinsHeight = COIN_OFFSETS.length * (COIN_H + COIN_GAP); + return ( -
-
- Stack - - Try the demo - -
- -
-
-

Put your savings on a schedule.

-

- Fund your vault once from whatever you hold, on any chain. Stack buys Bitcoin, Ether or NEAR every week and - sends it straight to your own wallet. No exchange account, and nobody can freeze it. +

+ + +
+ + +
+

+ Savings on a schedule +

+

+ Stack buys Bitcoin, Ether or NEAR for you every week, skips the weeks when fees run + high, and sends the coins to your own wallet.

-
- - - See it with example data +
+ + Try it with example data +
-

Signing in only asks your NEAR wallet to sign a message. It costs nothing.

+

+ No wallet or money needed for the demo. Signing in only asks your wallet to sign a + message. +

-
- -
- An example stack: twelve weekly buys of $50. Two were skipped because fees that week were above the limit, so - nothing was spent. + +
+ +
+
+
+

+ Set it up once +

+ +
+
+
+

Add funds

+
+
You send
+
250 USDC on Solana
+
Arrives as
+
$249.10
+
+
+ In your vault +
+
+
+

Funded

+

Send almost any token from any chain. It lands in your vault as dollars.

+
+ +
+
+
+

Your plan

+
+
Buy
+
Bitcoin
+
How much
+
$100 every week
+
Fee limit
+
3.00%
+
Send to
+
bc1qw5…f3t4
+
+
+
+

Planned

+

+ Pick the coin, the amount, how often, your own address and the most you'll pay + in fees. +

+
+ +
+
+
+

Latest buys

+
+ + 30 Oct · $100 + + 0.00122812 BTC +
+
+ + 23 Oct · $100 + + 0.00122812 BTC +
+
+ + 16 Oct · $0 + + Skipped: fees 3.95% +
+
+ + 9 Oct · $100 + + 0.00122812 BTC +
+ +
+
+

Stacked

+

+ Each week your vault buys and the coins arrive in your wallet. Expensive weeks are + skipped. +

+
+
+
+ +
+ +
+ + {/* Fourteen weeks laid out as coins: gold bought, green skipped for fees, the last on its way. */} +
+ +
+ Fourteen weeks of a $100 plan: twelve buys, two weeks skipped when fees ran high, and + this week's buy on its way.
-
-
-

- How it works -

- {[ - ["Fund your vault", "Send USDC, SOL, ETH or most other tokens from any chain. It arrives in your vault as dollars."], - ["Set your plan", "Pick what to buy, your own wallet address, how much and how often, and the most you'll pay in fees."], - ["Stack buys for you", "On schedule, your vault buys and the coins land in your wallet. Expensive weeks are skipped."], - ].map(([title, body], i) => ( -
- {i + 1} -

{title}

-

{body}

+
+

+ Your vault, your rules +

+

+ Your dollars sit in a vault + + + {" "} + only you can empty. Each buy is capped by the vault's own rules + + ≤ $100 a week + + . Pause +

+ +
+
+

+ These limits are written into your vault's contract on NEAR, not kept as a + promise on a server. Each one has a test you can run. +

+ + Read the vault's rules + +
- ))} -
-
-

- Your vault, your rules -

-
    -
  • Only you can withdraw or change where your coins go. Stack can’t.
  • -
  • Each buy is capped by your vault’s own rules: one per period, never more than your amount.
  • -
  • Pause at any time with one tap. Your dollars stay in your vault until you withdraw them.
  • -
-
+
+
+ A black 19th-century money box with its key in the lock +
+ A 19th-century money box. The key was the whole security model. +
+
+
+ Stacks of copper and silver coins +
Small amounts, often, add up.
+
+
+ A gold coin from a buried hoard, on a pale background +
A gold coin from a hoard someone kept for themselves.
+
+
+
-
Built for NEAR HACKATHON on NEAR Intents.
-
+
+
+

+ Fair questions +

+
+ +
+
+
+ +
+
+
+

Start your stack

+

+ The button opens a private example vault with four weeks of history. No wallet, no + money, nothing to install. +

+
+ +
+
+ + +
+ {/* The name built as a growing stack: five blocks, each a step taller, letters knocked out. */} + + {["S", "T", "A", "C", "K"].map((l, i) => { + const h = 260 * (0.6 + i * 0.1); + const x = i * 201; + return ( + + + + {l} + + + ); + })} + +
+ + +
+

Built for the NEAR HACKATHON on NEAR Intents.

+

Your coins go to your own wallet. Stack never holds your keys.

+
+
+
+ © 2026 Stack. MIT licence. + + Open the demo + +
+ +
+
); } diff --git a/components/fund/DepositPanel.tsx b/components/fund/DepositPanel.tsx index 651e3c6..0c124d3 100644 --- a/components/fund/DepositPanel.tsx +++ b/components/fund/DepositPanel.tsx @@ -44,8 +44,8 @@ export function DepositPanel({ }); return ( -
-

+
+

Send exactly {amountLabel}

diff --git a/components/history/BuyTable.tsx b/components/history/BuyTable.tsx index 99db537..557389a 100644 --- a/components/history/BuyTable.tsx +++ b/components/history/BuyTable.tsx @@ -17,24 +17,24 @@ export function BuyTable({ buys }: { buys: BuyDto[] }) { - - - - - - - + + + + + + + {buys.map((b) => ( - - - + + diff --git a/components/landing/Credits.tsx b/components/landing/Credits.tsx new file mode 100644 index 0000000..72afe4f --- /dev/null +++ b/components/landing/Credits.tsx @@ -0,0 +1,65 @@ +/** + * Credits for the found pictures and footage on the public pages (public/images/credits.json). + * Roadmap: K6.1 + */ + +const CREDITS = [ + { + title: "19th century money box", + by: "Prolete", + licence: "CC0", + via: "Wikimedia Commons", + page: "https://commons.wikimedia.org/wiki/File:19th_century_money_box.jpg", + }, + { + title: "Gold coin hoard 2007T433", + by: "The British Museum, Harriet Louth", + licence: "CC BY-SA 2.0", + licenceUrl: "https://creativecommons.org/licenses/by-sa/2.0/", + via: "Wikimedia Commons", + page: "https://commons.wikimedia.org/w/index.php?curid=55527893", + }, + { + title: "Edge of a one-pound coin (1985)", + by: "LihUlv", + licence: "CC0", + via: "Wikimedia Commons", + page: "https://commons.wikimedia.org/wiki/File:%D8%AD%D8%A7%D9%81%D8%A9_%D8%B9%D9%85%D9%84%D8%A9_%D8%A7%D9%84%D9%88%D8%A7%D8%AD%D8%AF_%D8%AC%D9%86%D9%8A%D9%87_%D8%A7%D8%B3%D8%AA%D8%B1%D9%84%D9%8A%D9%86%D9%8A1985.edge_of_a_one-pound_coin.mpg", + }, + { + title: "Stacks of Coins", + by: "Kevin Schneider", + licence: "CC0", + via: "Wikimedia Commons", + page: "https://commons.wikimedia.org/wiki/File:Stacks_of_Coins.jpg", + }, + { + title: "Stack of coins 0214", + by: "Dori", + licence: "public domain", + via: "Wikimedia Commons", + page: "https://commons.wikimedia.org/wiki/File:Stack_of_coins_0214.jpg", + }, + { + title: "Gold Coin of the Parisii", + by: "Celtic maker, 2nd century BCE", + licence: "CC0", + via: "The Metropolitan Museum of Art", + page: "https://www.metmuseum.org/art/collection/search/464811", + }, +]; + +export function Credits() { + return ( +

+ Pictures and footage:{" "} + {CREDITS.map((c, i) => ( + + “{c.title}” by {c.by},{" "} + {c.licenceUrl ? {c.licence} : c.licence}, via {c.via} + {i < CREDITS.length - 1 ? "; " : "."} + + ))} +

+ ); +} diff --git a/components/landing/FeeCheck.tsx b/components/landing/FeeCheck.tsx new file mode 100644 index 0000000..b2dfd9f --- /dev/null +++ b/components/landing/FeeCheck.tsx @@ -0,0 +1,117 @@ +"use client"; + +/** + * The landing page's live fee check: pick a coin and a weekly amount, see the expected fees and + * whether that buy would go ahead at a 3% limit. Uses the fee model calibrated on real quotes; + * the real app checks a live, signed quote before every buy. + * Roadmap: K6.1 + */ + +import { useState } from "react"; +import { TARGET_ASSETS, type TargetKey } from "@/lib/core/assets"; +import { modelCostBps, NETWORK_FEE_USD, SPREAD_BPS } from "@/lib/core/feeModel"; + +const LIMIT_BPS = 300; +const COINS: { key: TargetKey; name: string; disc: string }[] = [ + { key: "btc", name: "Bitcoin", disc: "#f4d96c" }, + { key: "eth", name: "Ether", disc: "#e8c75a" }, + { key: "near", name: "NEAR", disc: "#f8e7a2" }, +]; +const STEPS = [10, 15, 20, 25, 30, 40, 50, 60, 75, 100, 150, 200, 250, 300, 400, 500]; + +/** Smallest weekly amount, rounded up to a dollar, whose fees stay within the limit. */ +function minUnderLimit(key: TargetKey): number { + return Math.ceil(NETWORK_FEE_USD[key]! / ((LIMIT_BPS - SPREAD_BPS) / 10_000)); +} + +export function FeeCheck() { + const [coin, setCoin] = useState("btc"); + const [step, setStep] = useState(STEPS.indexOf(100)); + const usd = STEPS[step]!; + const asset = TARGET_ASSETS[coin]; + const bps = modelCostBps(asset.assetId, usd); + const skipped = bps > LIMIT_BPS; + const pct = (bps / 100).toFixed(2); + const receive = ((usd * (10_000 - bps)) / 10_000).toFixed(2); + const min = minUnderLimit(coin); + const name = COINS.find((c) => c.key === coin)!.name; + + return ( +
+
+

Fees, checked every week

+
    + {COINS.map((c) => ( +
  • + +
  • + ))} +
+
+ +
c.key === coin)!.disc }} + > +
+ +
+
+ +
+
+

Next {name} buy

+
+ + ${usd} +
+ setStep(Number(e.target.value))} + aria-valuetext={`$${usd} a week`} + /> +
+
Fees
+
{pct}%
+
You get
+
+ ${receive} of {asset.symbol} +
+
+

+ {skipped + ? `Above your 3% limit: this week is skipped and nothing is spent.` + : `Within your 3% limit: this buy goes ahead.`} +

+
+

+ {coin === "btc" + ? `Bitcoin's network charges about $${NETWORK_FEE_USD.btc!.toFixed(2)} to deliver each buy, so buys under $${min} cost more than 3%.` + : coin === "eth" + ? `Ether costs about $${NETWORK_FEE_USD.eth!.toFixed(2)} to deliver, so anything from $${min} a week stays under 3%.` + : `NEAR costs about $${NETWORK_FEE_USD.near!.toFixed(2)} to deliver, so even small weekly buys stay well under 3%.`}{" "} + Estimates from real quotes; every real buy is checked against a live one. +

+
+
+ ); +} diff --git a/components/landing/NavSignIn.tsx b/components/landing/NavSignIn.tsx new file mode 100644 index 0000000..b7cb3cf --- /dev/null +++ b/components/landing/NavSignIn.tsx @@ -0,0 +1,32 @@ +"use client"; + +/** "Sign in with NEAR" as a plain link-style control for the public pages. Roadmap: K6.1 */ + +import { useRouter } from "next/navigation"; +import { useWallet } from "@/components/wallet/WalletProvider"; + +export function NavSignIn({ next = "/dashboard" }: { next?: string }) { + const { signIn, busy, error } = useWallet(); + const router = useRouter(); + return ( + + + {error ? ( + + {error} + + ) : null} + + ); +} diff --git a/components/landing/Questions.tsx b/components/landing/Questions.tsx new file mode 100644 index 0000000..ee932df --- /dev/null +++ b/components/landing/Questions.tsx @@ -0,0 +1,60 @@ +"use client"; + +/** + * Questions, filed like index cards: one open at a time, the first open on load. + * Roadmap: K6.1 + */ + +import { useState, type ReactNode } from "react"; + +export interface Question { + tab: string; + q: string; + a: ReactNode; +} + +const CARD_COLOURS = [ + { card: "#1a4429", ink: "#f4d96c" }, + { card: "#235333", ink: "#f4d96c" }, + { card: "#f4d96c", ink: "#133621" }, +]; + +export function Questions({ items }: { items: Question[] }) { + const [open, setOpen] = useState(0); + return ( +
+ {items.map((item, i) => { + const c = CARD_COLOURS[i % CARD_COLOURS.length]!; + const isOpen = open === i; + return ( +
+ +

+ +

+ +
+ ); + })} +
+ ); +} diff --git a/components/landing/Reveal.tsx b/components/landing/Reveal.tsx new file mode 100644 index 0000000..782f134 --- /dev/null +++ b/components/landing/Reveal.tsx @@ -0,0 +1,35 @@ +"use client"; + +/** + * Turns on the landing page's entrances: marks as scripted (content stays visible without + * script) and reveals `.rv` and `.lp-shutter` elements once as they enter the screen. + * Roadmap: K6.1 + */ + +import { useEffect } from "react"; + +export function Reveal() { + useEffect(() => { + const root = document.documentElement; + root.classList.add("js"); + const targets = document.querySelectorAll(".rv, .lp-shutter"); + if (!("IntersectionObserver" in window)) { + targets.forEach((el) => el.classList.add("in")); + return; + } + const io = new IntersectionObserver( + (entries) => { + for (const e of entries) { + if (e.isIntersecting) { + e.target.classList.add("in"); + io.unobserve(e.target); + } + } + }, + { rootMargin: "0px 0px -12% 0px" }, + ); + targets.forEach((el) => io.observe(el)); + return () => io.disconnect(); + }, []); + return null; +} diff --git a/components/landing/StartPlan.tsx b/components/landing/StartPlan.tsx new file mode 100644 index 0000000..92fa89a --- /dev/null +++ b/components/landing/StartPlan.tsx @@ -0,0 +1,80 @@ +"use client"; + +/** + * The closing section's dialog: the plan screen, already open. Changing it updates the fee line; + * the button opens a private demo (no wallet, no money). + * Roadmap: K6.1, K7.3 + */ + +import { useMutation } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { api } from "@/lib/client/api"; +import { TARGET_ASSETS, type TargetKey } from "@/lib/core/assets"; +import { modelCostBps } from "@/lib/core/feeModel"; + +const OPTIONS: { key: TargetKey; label: string }[] = [ + { key: "btc", label: "Bitcoin" }, + { key: "eth", label: "Ether" }, + { key: "near", label: "NEAR" }, +]; + +export function StartPlan() { + const router = useRouter(); + const [coin, setCoin] = useState("btc"); + const [amount, setAmount] = useState("100"); + const usd = Number(amount); + const valid = Number.isFinite(usd) && usd >= 1 && usd <= 100_000; + const bps = valid ? modelCostBps(TARGET_ASSETS[coin].assetId, usd) : null; + const yearly = valid ? (usd * 52).toLocaleString("en-US") : null; + const start = useMutation({ + mutationFn: () => api("/api/demo/start", { method: "POST" }), + onSuccess: () => router.push("/dashboard"), + }); + + return ( +
+

Plan a weekly buy

+
+ {OPTIONS.map((o) => ( + + ))} +
+ +
+ $ + setAmount(e.target.value.replace(/[^\d.]/g, ""))} + /> +
+

+ {valid && bps !== null + ? `Every week, about ${(bps / 100).toFixed(2)}% in fees, so $${yearly} a year into ${TARGET_ASSETS[coin].chainName === "Ethereum" ? "Ether" : TARGET_ASSETS[coin].chainName}.` + : "Enter an amount between $1 and $100,000."} +

+ + {start.isError ? ( +

+ {start.error.message} +

+ ) : null} +
+ ); +} diff --git a/components/plan/PauseButton.tsx b/components/plan/PauseButton.tsx index 96f9aae..f0a46fe 100644 --- a/components/plan/PauseButton.tsx +++ b/components/plan/PauseButton.tsx @@ -29,7 +29,7 @@ export function PauseButton() { {hasPlan ? (
Your buys, newest first
DateSpentBoughtFeesResultProof
DateSpentBoughtFeesResultProof
{new Date(b.createdAt).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" })} + {SPENT.has(b.status) ? `$${formatUsdc(b.amountIn)}` : Nothing spent} {b.status === "success" || b.status === "sent" || b.status === "processing" ? formatTarget(b.amountOut, b.targetAsset) : "—"}{b.costBps === null ? "—" : `${(b.costBps / 100).toFixed(2)}%`}{b.status === "success" || b.status === "sent" || b.status === "processing" ? formatTarget(b.amountOut, b.targetAsset) : "—"}{b.costBps === null ? "—" : `${(b.costBps / 100).toFixed(2)}%`}