docs: bootstrap SePay VietQR playground research, tech-stack, design, wireframes

Initial scaffolding from prior planning session: SePay/Upstash/Vercel research notes, locked tech-stack (SvelteKit 2 + Svelte 5 + JS + pnpm + Tailwind v4 + shadcn-svelte + adapter-vercel + @upstash/redis), technical-minimal design guidelines, HTML wireframes for / and /pay (form/awaiting/paid), plus handoff notes. No application code yet.
This commit is contained in:
tiennm99 committed 2026-05-24 15:11:07 +07:00
1 parent c70c51aa64
commit aa119868ce
10 files changed
+1073

No files matched your search

+98
View File
@@ -0,0 +1,98 @@
# SePay VietQR Playground — Design Guidelines
## Style Direction
**Technical-minimal.** Neutral grays + single indigo accent + sharp 4px-radius corners + monospace for codes/amounts. Why: dev audience scanning code; QR-payment domain benefits from feeling like a terminal/dashboard (precise, trustworthy, low-noise). No gradients, no shadows beyond `sm`, no decorative illustration. Information hierarchy carries weight.
## Color Tokens
### Light mode
| Token | Hex | Use |
|---|---|---|
| `bg` | `#FAFAFA` | page background |
| `surface` | `#FFFFFF` | cards, inputs |
| `border` | `#E5E5E5` | dividers, input borders |
| `text-primary` | `#0A0A0A` | body, headings |
| `text-muted` | `#737373` | labels, hints, timestamps |
| `accent` | `#4F46E5` | primary CTA, focus ring (indigo-600) |
| `accent-hover` | `#4338CA` | hover state |
| `success` | `#16A34A` | paid badge, confirmation |
| `warning` | `#D97706` | pending badge |
| `danger` | `#DC2626` | failed badge, cancel link |
### Dark mode
| Token | Hex |
|---|---|
| `bg` | `#0A0A0A` |
| `surface` | `#171717` |
| `border` | `#262626` |
| `text-primary` | `#FAFAFA` |
| `text-muted` | `#A3A3A3` |
| `accent` | `#6366F1` |
| `accent-hover` | `#818CF8` |
| `success` | `#22C55E` |
| `warning` | `#F59E0B` |
| `danger` | `#EF4444` |
All foreground/background pairs meet WCAG AA (≥4.5:1 for body, ≥3:1 for large text).
## Typography
- **Sans (UI):** `Inter` — broad Vietnamese diacritics support, neutral, dev-familiar.
- **Mono (codes/amounts):** `JetBrains Mono` — distinct `0/O`, `1/l/I`, ideal for order codes & VND figures.
- **Scale (px):** 12 (micro/label) · 14 (body-sm) · 16 (body) · 20 (subhead) · 30 (hero/page title).
- **Weights:** 400 (body), 500 (UI labels, buttons), 600 (headings).
- **Line-height:** 1.5 body, 1.2 headings, 1 for mono single-line amounts.
## Spacing
4-px base scale: `4, 8, 12, 16, 24, 32, 48`. No values outside this set. Card inner padding = 24. Stack gap between form rows = 16. Section gap = 32.
## Component Patterns
- **Button — primary:** filled `accent`, white text, h=40, px=16, radius=4, weight 500. Hover → `accent-hover`. Focus → 2px outline ring offset 2px in `accent`.
- **Button — ghost:** transparent, `text-muted`, hover → `text-primary` + `surface` bg. Used for "cancel / new order".
- **Input:** h=40, border 1px `border`, radius=4, surface bg, focus → border `accent` + 1px ring. Mono font for amount input.
- **Card:** `surface` bg, 1px `border`, radius=4, padding 24. No shadow in light mode; `shadow-sm` only in dark.
- **Badge (status):** inline-flex, h=20, px=8, radius=4, text-12 weight 500, mono. `pending` = warning bg-tint + warning text; `paid` = success bg-tint + success text; `failed` = danger bg-tint + danger text. Optional leading dot.
- **Code block / inline code:** mono, surface bg, 1px border, radius=4, px=6 py=2 (inline) or padding 12 (block).
## Motion
- State transitions (A→B→C on `/pay`): 150ms fade-in + 4px translate-y. No scale, no bounce.
- Waiting spinner: 1.2s linear rotate, 16px, accent-colored, 2px stroke.
- Button press: 100ms opacity 0.9.
- Respect `prefers-reduced-motion: reduce` → disable transitions, swap spinner for static dot.
## shadcn-svelte Mapping
Components installed via `pnpm dlx shadcn-svelte@latest add <name>`. Files land in `src/lib/components/ui/<name>/`.
| Pattern | shadcn-svelte component |
|---|---|
| Primary / ghost button | `Button` (variant `default` / `ghost`) |
| Amount input | `Input` (with `Label`) |
| Card container | `Card` (`Card.Root` / `Card.Header` / `Card.Content` / `Card.Footer`) |
| Status badge | `Badge` (variant custom: pending/paid/failed) |
| Toast on copy / cancel | `Sonner` (svelte-sonner) |
| Cancel confirm | `AlertDialog` |
| Skeleton while QR fetches | `Skeleton` |
| Inline code | custom `<code>` styled per tokens |
Dark mode via `mode-watcher` (shadcn-svelte standard). Icons via `lucide-svelte`.
## Accessibility
- Contrast: all text ≥4.5:1, UI controls ≥3:1.
- Focus ring: 2px solid `accent`, 2px offset, never removed; visible on keyboard nav only (`:focus-visible`).
- Tab order on `/pay` form: amount input → demo-amount link → Generate QR button.
- Amount input: `inputmode="numeric"`, `aria-label="Amount in VND"`, live validation announced via `aria-live="polite"`.
- Awaiting state: `role="status"` on waiting indicator, screen-reader text "Waiting for payment".
- Paid state: `role="status"` announces "Payment received".
- Min touch target 44×44 on mobile (button h=40 + padding satisfies via tap area).
- All links/buttons keyboard-activatable; no pointer-only handlers.
## Layout Rules
- Mobile-first. Max content width 480px on `/pay`, 560px on `/`. Centered, 16px horizontal page padding on mobile, 24px ≥768px.
- Single column throughout. No sidebars. No multi-step UI on `/pay` — same route, swap state.
+69
View File
@@ -0,0 +1,69 @@
# Handoff — pause point
**Status:** bootstrap paused after design approval gate. Planning + implementation deferred to next session.
## What was decided (locked)
### Stack (see `docs/tech-stack.md`)
- SvelteKit 2 + Svelte 5 (runes) + **JavaScript** (JSDoc for typedefs)
- **pnpm** (lockfile committed; `packageManager` field)
- Tailwind v4 + **shadcn-svelte** (button, input, label, card, badge, alert) + `lucide-svelte` + `mode-watcher`
- `@upstash/redis` via `Redis.fromEnv()`
- `@sveltejs/adapter-vercel`
- Node.js runtime for all server routes
- **Dev helper included:** `POST /api/dev/simulate-webhook` gated by `!import.meta.env.PROD`
### Design (see `docs/design-guidelines.md` + `docs/wireframe/*.html`)
- Technical-minimal style, indigo accent (`#4F46E5` light / `#6366F1` dark)
- Inter (UI) + JetBrains Mono (codes/amounts)
- 4px radius, 4-base spacing, 150ms fade transitions, reduced-motion safe
- Three states on `/pay` (form / awaiting / paid), same route
- Mobile-first, single column
- **NOT YET APPROVED BY USER** — design gate was deferred. Re-ask on resume.
### Research (see `docs/research-*.md`)
- `research-sepay.md` — VietQR endpoint, webhook payload, auth header, order-code extraction, idempotency on `id`
- `research-upstash.md` — `@upstash/redis` patterns, `nx:true` for dedup, Vercel marketplace integration
- `research-vercel.md` — Node runtime for webhook, env var setup, ngrok for local
## What's left
1. **Re-ask design gate** (was deferred, not approved).
2. **Run `/ck:plan --auto`** with full requirements — see `docs/tech-stack.md` + design guidelines as inputs. Plan dir → `./plans/<name>/`.
3. **Run `/ck:cook --auto <plan-path>`** to scaffold + implement.
4. **Onboarding**: walk user through env vars (`.env.example` → `.env.local`), `pnpm install`, `pnpm dev`, ngrok setup, SePay dashboard webhook URL config, `vercel deploy`.
5. **Final report** + commit gate.
6. **Run `/ck:journal`** for the session record.
## Open questions to confirm on resume
- Design approval gate (style direction, accent color).
- Any further stack tweaks (currently locked: shadcn-svelte over Skeleton/plain).
## Files created this session
```
docs/
├── tech-stack.md
├── design-guidelines.md
├── handoff.md ← this file
├── research-sepay.md
├── research-upstash.md
├── research-vercel.md
└── wireframe/
├── home.html
├── pay-form.html
├── pay-awaiting.html
└── pay-paid.html
plans/ ← empty, planning not run yet
```
No code written. No `package.json`, no source files. Pure docs.
## Resume command (suggested)
```
/ck:bootstrap --auto resume from docs/handoff.md
```
Or just: "continue the SePay bootstrap, design is approved" → I'll run `/ck:plan --auto` → `/ck:cook --auto`.
+208
View File
@@ -0,0 +1,208 @@
# SePay Payment Integration Research
## 1. VietQR Image Generation Endpoint
**Endpoint:** `https://qr.sepay.vn/img`
**Required Query Parameters:**
- `acc`: Bank account/virtual account number
- `bank`: Bank code or short name (from `qr.sepay.vn/banks.json`)
**Optional Parameters:**
- `amount`: Transfer amount in VND
- `des`: Transfer memo/description (this is where order codes embed)
- `template`: Display style (`default`, `compact`, `qronly`). Defaults to full QR.
- `download`: Set to `true` to force download instead of inline display
**Example:**
```
https://qr.sepay.vn/img?acc=0010000000355&bank=Vietcombank&amount=100000&des=ORDER123
```
**Sources:** [SePay QR Code Guide](https://docs.sepay.vn/tao-qr-code-vietqr-dong.html), [SePay Developer QR Docs](https://developer.sepay.vn/en/tien-ich-khac/tao-qr-code)
---
## 2. Webhook Payload Fields
**POST Endpoint:** Your configured webhook URL receives JSON:
```json
{
"id": 92704,
"gateway": "Vietcombank",
"transactionDate": "2023-03-25 14:02:37",
"accountNumber": "0123499999",
"code": "ORDER123",
"content": "transfer to buy iphone",
"transferType": "in",
"transferAmount": 2277000,
"accumulated": 19077000,
"subAccount": null,
"referenceCode": "MBVCB.3278907687",
"description": ""
}
```
**Field Mapping:**
- `id`: Unique transaction ID (use for deduplication)
- `code`: **Extracted** order code (auto-matched by SePay config; can be `null`)
- `content`: Raw transfer memo from bank (source for manual extraction)
- `referenceCode`: Bank's internal reference (immutable)
- `transferType`: `"in"` (incoming) or `"out"` (outgoing)
- `accumulated`: Account balance after transfer
**Which field for order matching?**
- Prefer `code` if configured in dashboard to auto-extract (more reliable)
- Fall back to `content` if `code` is `null` (use pattern matching)
**Sources:** [SePay Webhook Integration](https://docs.sepay.vn/tich-hop-webhooks.html), [Webhook Programming](https://developer.sepay.vn/en/sepay-webhooks/lap-trinh-webhook)
---
## 3. Webhook Authentication
**Method:** API Key in `Authorization` header
**Header Format:**
```
Authorization: Apikey YOUR_SEPAY_API_KEY
```
**Configuration:**
- Generate/retrieve key from **SePay Dashboard → Settings → API Keys** (exact location unconfirmed in docs)
- Laravel package refers to this as **webhook token** in `.env`
- No additional signature validation required if using API Key auth
**Alternative Auth Methods (optional):**
- HMAC-SHA256 signature in header
- OAuth 2.0 tokens
- No auth (not recommended for production)
**Sources:** [SePay Webhook Docs](https://docs.sepay.vn/tich-hop-webhooks.html), [Laravel SePay Package](https://github.com/sepayvn/laravel-sepay)
---
## 4. Order Code Extraction (Auto-Matching)
**Configuration Location:** Dashboard → Company Settings → General Configuration
**Extraction Rules:**
- **Pattern prefix:** Configurable in SePay dashboard (e.g., `SE`, `SEVQR`, `SEPAY`, or custom)
- **Default pattern:** Often `SE` (seen in Laravel implementation)
- **Format:** Prefix + alphanumeric code in transfer `content` field
- **Examples:**
- Transfer memo: `"SEVQR123456..."` → extracted `code: "123456"`
- Transfer memo: `"PAYOrder789"` → extracted `code: "789"` (if `PAY` configured)
**VietinBank Special Rule:**
- Requires memo format: `"SEVQR" + transfer_content` for QR transfers
**Webhook Field:** Auto-extracted code appears in `code` field (null if no match)
**Manual Extraction Fallback:**
- If `code` is null, regex-parse `content` field using dashboard-configured pattern
**Sources:** [SePay Webhook Integration](https://docs.sepay.vn/tich-hop-webhooks.html), [Laravel SePay Implementation](https://github.com/sepayvn/laravel-sepay)
---
## 5. Webhook Retries & Idempotency
**Retry Behavior:**
- **Failed webhooks:** Auto-retry up to **7 times** over max **5-hour window**
- **Retry interval:** Fibonacci-spaced delays
- **Success criteria:** HTTP 200/201 + JSON body `{"success": true}` within 30 seconds
**Deduplication (CRITICAL):**
- **Stable ID:** `id` field is immutable per transaction and remains constant across all retries
- **Dedup strategy:**
- Create `UNIQUE` constraint on `(gateway, transactionDate, accountNumber, transferAmount)` or just `id`
- Check if `id` exists before processing; return 200 if duplicate detected
- Use `INSERT IGNORE` or conditional insert to prevent race conditions
**Guaranteed Dedup:**
- Store processed `id` values in database with expiration >= 5 hours
- Check at transaction-start before queuing business logic
**Sources:** [SePay Webhook Docs](https://docs.sepay.vn/tich-hop-webhooks.html)
---
## 6. Bank Codes & Short Names
**Canonical List:** `https://qr.sepay.vn/banks.json` (JSON array of bank objects)
**JSON Structure (inferred):**
```json
[
{
"code": "ACB",
"bin": "970416",
"shortName": "ACB",
"name": "Asia Commercial Bank"
},
{
"code": "VCB",
"shortName": "Vietcombank",
"name": "Ngân hàng TMCP Ngoại Thương Việt Nam"
},
{
"code": "MB",
"shortName": "MBBank",
"name": "Ngân hàng TMCP Quân Đội"
}
]
```
**Parameter Acceptance:** QR endpoint accepts either `code` or `shortName` in `bank` param
**Common Banks (examples):**
- `Vietcombank` (short name)
- `ACB` (code)
- `MBBank` (short name)
- `VietinBank`, `BIDV`, `HDBank`, `Techcombank`, `SHB`, `OCB`, `KienLongBank`, `MSB`
**Bank-Specific Rules:**
- **OCB, KienLongBank, MSB:** Require virtual accounts
- **VietinBank:** Personal/business transfers require `SEVQR` prefix in memo
**Sources:** [SePay QR Developer Docs](https://developer.sepay.vn/en/tien-ich-khac/tao-qr-code), [VietQR API Banks](https://api.vietqr.vn/vi/danh-sach-ma-ngan-hang)
---
## Implementation Checklist for Next.js
- [ ] Fetch & cache `qr.sepay.vn/banks.json` at startup
- [ ] Build QR URL with `acc`, `bank`, `amount`, `des` (embed order ID in `des`)
- [ ] Create `/api/webhooks/sepay` route to receive POSTs
- [ ] Validate `Authorization: Apikey` header matches env config
- [ ] Check `id` field exists in database before processing (dedup)
- [ ] Extract order code from `code` field (preferred) or parse `content` (fallback)
- [ ] Insert webhook record into DB with `INSERT IGNORE` to ensure idempotency
- [ ] Return `{success: true}` within 30s; queue business logic asynchronously
- [ ] Update order status on successful match
- [ ] Log unmatched transactions for manual review
---
## Unresolved Questions
1. **Dashboard API Key field name:** Exact label in SePay dashboard for webhook auth key (docs reference step 3.3 but no UI screenshots provided)
2. **banks.json schema:** Confirmed endpoint exists but structure inferred from VietQR API; SePay-specific field names unverified
3. **Code extraction config:** Exact UI path and format for dashboard pattern configuration (location varies by docs version)
4. **VietinBank SEVQR requirement:** Confirmation if this applies to all transfer types or only QR-generated transfers
5. **Webhook signature validation:** If using API Key, are additional HMAC/signature headers expected? (docs mention option but don't clarify when required)
---
## Sources Consulted
- [SePay Official Docs - QR Code](https://docs.sepay.vn/tao-qr-code-vietqr-dong.html)
- [SePay Developer - QR Generation](https://developer.sepay.vn/en/tien-ich-khac/tao-qr-code)
- [SePay Official Docs - Webhooks](https://docs.sepay.vn/tich-hop-webhooks.html)
- [SePay Developer - Webhook Integration](https://developer.sepay.vn/en/sepay-webhooks/tich-hop-webhook)
- [SePay Developer - Webhook Programming](https://developer.sepay.vn/en/sepay-webhooks/lap-trinh-webhook)
- [Laravel SePay Package](https://github.com/sepayvn/laravel-sepay)
- [VietQR API Bank Codes](https://api.vietqr.vn/vi/danh-sach-ma-ngan-hang)
- [SePay Blog - Free QR Creation](https://sepay.vn/blog/tao-ma-qr-ngan-hang-mien-phi-huong-dan-nhanh/)
+104
View File
@@ -0,0 +1,104 @@
# Upstash Redis for Next.js 15 + Vercel: Payment Demo Integration
## 1. Client Library
**Package**: `@upstash/redis` ✓ Confirmed correct.
- **Latest Version**: 1.38.0 (released 18 May 2026).
- **Approach**: REST-based HTTP client (no connection pooling), edge-runtime compatible.
- **Breaking Changes**: No recent breaking changes between v1.x versions; stable for production use.
- **Status**: Actively maintained; suitable for demo → production migration.
## 2. Environment Variables
**Canonical names** (when not using Vercel integration):
- `UPSTASH_REDIS_REST_URL` ✓
- `UPSTASH_REDIS_REST_TOKEN` ✓
`Redis.fromEnv()` reads **both** automatically.
**Vercel-managed alternative** (if using Vercel Marketplace integration):
- `KV_REST_API_URL`
- `KV_REST_API_TOKEN`
Both sets work with `fromEnv()`; choose one per deployment context.
## 3. Edge vs Node Runtime
**Same client runs on both runtimes.** No caveats.
- Webhook routes (POST handlers) work identically on `runtime: 'nodejs'` and `runtime: 'edge'`.
- REST-based design eliminates connection state issues.
- Tested pattern: middleware → webhook routes → server components all use same `Redis.fromEnv()` call.
## 4. Required Patterns
### SET with TTL (store order)
```ts
await redis.set("order:123", { id: 123, total: 99.99 }, { ex: 3600 })
```
Returns success boolean. TTL in seconds; auto-expires.
### GET (retrieve order)
```ts
const order = await redis.get("order:123")
// Auto-deserialized to object
```
### SET NX (idempotent webhook dedup)
```ts
const wasSet = await redis.set("webhook:event-uuid", { timestamp: Date.now() }, { nx: true })
if (wasSet) { /* process event */ }
```
Returns `true` if key was new, `false` if already existed (prevents duplicate processing).
### JSON Serialization
**Auto-stringify enabled by default:**
- ❌ Don't: `await redis.set("order", JSON.stringify({...}))`
- ✅ Do: `await redis.set("order", {...})`
SDK handles serialization transparently. No manual stringify needed.
## 5. Vercel Integration
**Available since 2024; active as of May 2026.**
- Product name: "Upstash for Vercel" (Upstash Redis)
- **URL**: vercel.com/marketplace/upstash
- **Process**:
1. Click "Install" on Vercel Marketplace.
2. Choose: auto-create new Upstash account (Vercel-managed) OR link existing Upstash Console account.
3. Configure database name/region/plan through Vercel dashboard.
4. Env vars auto-injected into all Vercel deployments.
5. Unified billing with Vercel invoice.
**Note**: Vercel KV (old Upstash wrapper) was deprecated Dec 2024; new projects use direct Upstash integration.
## 6. Pricing (Free Tier Suitable for Demo)
**Free Tier** (no credit card):
- **Storage**: 256 MB
- **Commands/month**: 500,000 (≈16.7K/day)
- **Bandwidth**: 10 GB/month
- **Cost**: $0
**Pay-as-you-go**: $0.20 per 100K commands after free tier exhausted.
**Demo suitability**: ✓ Sufficient for order storage + webhook idempotency log for <100 test events/day. Scales on-demand if traffic increases.
---
## Sources
- [npm: @upstash/redis](https://www.npmjs.com/package/@upstash/redis)
- [Upstash TypeScript SDK Documentation](https://upstash.com/docs/redis/sdks/ts/getstarted)
- [Next.js with Redis Tutorial](https://upstash.com/docs/redis/tutorials/nextjs_with_redis)
- [Next.js App Router Quickstart](https://upstash.com/docs/redis/quickstarts/nextjs-app-router)
- [Vercel Integration Guide](https://upstash.com/docs/redis/howto/vercelintegration)
- [Upstash Pricing & Limits](https://upstash.com/docs/redis/overall/pricing)
- [Redis SET NX for Idempotency](https://redis.io/tutorials/data-deduplication-with-redis/)
- [Upstash Blog: JSON Support](https://upstash.com/blog/redis-json)
- [Vercel Marketplace: Upstash Redis](https://vercel.com/marketplace/upstash)
---
## Unresolved Questions
None. All six requirements verified against official sources dated 2025–2026. Ready for implementation.
+207
View File
@@ -0,0 +1,207 @@
# Next.js 15 + Vercel Webhook Deployment Research
## 1. Webhook Route Runtime: `nodejs` vs `edge`
**Recommendation: `nodejs` (default)**
| Dimension | nodejs | edge |
|-----------|--------|------|
| **Cold Start** | Slower | Faster (~10x) |
| **Available APIs** | Full Node.js + Web APIs | Web APIs only |
| **HMAC Verification** | ✓ Full crypto support | ✓ (`crypto` available) |
| **Third-party Libs** | Most npm packages | Web-API-only packages |
| **Streaming** | ✓ Supported | ✓ Supported |
| **Database Queries** | ✓ Native drivers | Depends on lib |
| **File System** | ✓ `fs` module | ✗ Blocked |
**Trade-off**: For SePay webhooks (no HMAC, just `Authorization` header), edge has zero advantage over the latency gain (~50ms). Choose nodejs unless you deploy >1000 webhooks/min.
**For SePay spec**: `Authorization: Apikey <KEY>` doesn't require raw-body access; both runtimes handle it. But nodejs is simpler and safer for database updates.
**Code**:
```typescript
// app/api/webhooks/sepay/route.ts
export const runtime = 'nodejs' // explicit, though default
export async function POST(request: Request) {
const authHeader = request.headers.get('authorization')
const body = await request.json() // or request.text() if needed
// process...
}
```
---
## 2. Body Parsing & Auth Header Verification
**Pattern for SePay**:
```typescript
export async function POST(request: Request) {
// Auth check first (fail fast)
const authHeader = request.headers.get('authorization')
if (!authHeader?.startsWith('Apikey ')) {
return new Response('Unauthorized', { status: 401 })
}
const apiKey = authHeader.split(' ')[1]
if (apiKey !== process.env.SEPAY_WEBHOOK_KEY) {
return new Response('Forbidden', { status: 403 })
}
// Body parsing (safe, no raw-body requirement for SePay)
try {
const payload = await request.json()
// Process payment event...
} catch (error) {
return new Response('Invalid JSON', { status: 400 })
}
}
```
**Key Points**:
- SePay uses simple API key auth, **no HMAC signature** required → no need for raw body buffering.
- `request.json()` is safe and idiomatic in Next.js App Router.
- `request.headers.get()` uses Web API (case-insensitive by spec).
- Body can only be read **once** per request (stream constraint).
---
## 3. Env Var Setup on Vercel
**Dashboard or CLI**:
```bash
# Add to Vercel (prompts for value)
vercel env add SEPAY_WEBHOOK_KEY
# Choose environments: Production, Preview, Development
# Sensitive = encrypted, hidden from logs/UI
```
**Local Development**:
```bash
# Pull Vercel dev env vars into .env.local
vercel env pull
# Or manually add to .env.local (git-ignored)
SEPAY_WEBHOOK_KEY=sk_test_...
```
**Best Practice**:
- Set `SEPAY_WEBHOOK_KEY` as **sensitive** in Vercel (defaults to this).
- Add `.env.local` to `.gitignore`.
- `vercel dev` auto-loads dev env vars; no need to `pull` for that.
- **Size limit**: 5 KB per env var on edge runtime; 64 KB total per deployment on nodejs.
---
## 4. Project Config: `vercel.json` Required?
**Answer: No**, for vanilla Next.js 15 + webhooks.
**Zero-config Default**:
- Vercel auto-detects `next.json` presence → builds with `next build`, serves with `next start`.
- Framework detection, routes, rewrites, ISR all automatic.
**When to Add `vercel.json`**:
- Custom build/start commands
- Cron jobs (`functions.crons`)
- Function-level memory or timeout overrides
- Middleware/edge function config
- Custom domains, redirects, or headers
**Minimal Config** (if needed):
```json
{
"version": 2,
"framework": "nextjs",
"buildCommand": "next build",
"env": {
"SEPAY_WEBHOOK_KEY": "@sepay_webhook_key"
}
}
```
The `@sepay_webhook_key` syntax references dashboard-stored secrets; avoid inline secrets.
---
## 5. Public URL for Webhooks During Local Dev
**Ranking** (by ease + reliability):
1. **ngrok** (Recommended for SePay)
- `ngrok http 3000` → `https://abc123.ngrok.io`
- Pros: Session inspection, replay, wildcard DNS, free tier
- Cons: Changes URL each restart (but ngrok CLI supports fixed domains on pro)
- **Ideal for**: Testing payment flows, debugging webhook payloads
2. **Cloudflare Tunnel** (via `cloudflared`)
- `cloudflared tunnel --url localhost:3000`
- Pros: Free, persistent (Zero Trust dashboard), enterprise-grade
- Cons: Requires cloudflare.com account setup, slightly slower onboarding
3. **Vercel Preview Deployments**
- `vercel` on feature branch → preview URL with env vars
- Pros: Production-like env, real Vercel infra
- Cons: ~30s deploy time, not for rapid iteration
**Verdict**: **Use ngrok for local iteration**, Vercel preview for full-stack testing.
---
## 6. Next.js 15 Specifics: SSE, Streaming, Server Actions
**Status**: Next.js 15 stable, React 19 GA compatible.
**For Payment Polling/SSE**:
- ✓ **Streaming Route Handlers**: Return `ReadableStream` directly.
```typescript
export async function GET(request: Request) {
const stream = new ReadableStream({
start(controller) {
// SSE: send payment status updates
controller.enqueue('data: {"status":"pending"}\n\n')
}
})
return new Response(stream, {
headers: { 'Content-Type': 'text/event-stream' }
})
}
```
- ✓ **Server Actions**: Stable, no gotchas for payment state mutations.
- ✓ **`unstable_after`** (experimental): Schedule work post-response (e.g., SePay reconciliation).
**Gotchas**: None specific to webhooks. SSE works on nodejs runtime; edge has timeout constraints (~25s).
---
## Summary Table
| Question | Answer | Source |
|----------|--------|--------|
| Runtime for webhooks? | `nodejs` (default, explicit) | [Next.js Edge Docs](https://nextjs.org/docs/app/api-reference/edge) |
| Read auth header in POST? | `request.headers.get('authorization')` | [Next.js headers API](https://nextjs.org/docs/app/api-reference/functions/headers) |
| Body parsing? | `await request.json()` (no HMAC, no raw-body need) | Next.js Route Handler spec |
| Env vars locally? | `vercel env pull` → `.env.local` | [Vercel Env Docs](https://vercel.com/docs/environment-variables) |
| `vercel.json` needed? | No, for vanilla Next.js 15 | [Vercel Config Docs](https://vercel.com/docs/project-configuration/vercel-json) |
| Local webhook URL? | ngrok (best DX) or Cloudflare Tunnel | Tunnel/ngrok comparison |
| SSE support? | ✓ ReadableStream on nodejs runtime | [Next.js Streaming](https://nextjs.org/learn/dashboard-app/streaming) |
---
## Unresolved Questions
1. **SePay API rate limits**: Does SePay cap webhook retry attempts? Affects error handling strategy.
2. **Webhook signature format**: Confirm SePay sends plain JSON (no custom encoding) in body.
3. **Preview env var inheritance**: Do Vercel preview deployments auto-inherit production env vars, or require explicit setup?
4. **Edge function maxDuration**: What's the timeout for SSE on edge runtime (if needed later)?
---
## Sources
- [Next.js Edge Runtime API Reference](https://nextjs.org/docs/app/api-reference/edge)
- [Next.js headers Function](https://nextjs.org/docs/app/api-reference/functions/headers)
- [Vercel Environment Variables](https://vercel.com/docs/environment-variables)
- [Vercel Static Configuration](https://vercel.com/docs/project-configuration/vercel-json)
- [Next.js Streaming (App Router)](https://nextjs.org/learn/dashboard-app/streaming)
- [Testing Webhooks with ngrok](https://inventivehq.com/blog/testing-webhooks-locally-ngrok-guide)
- [Cloudflare Tunnel Docs](https://developers.cloudflare.com/pages/how-to/preview-with-cloudflare-tunnel/)
- [Next.js 15 Release Blog](https://nextjs.org/blog/next-15)
+112
View File
@@ -0,0 +1,112 @@
# Tech Stack — sepay-playground
User-fixed stack. Research confirms feasibility.
## Runtime / Framework
- **SvelteKit** (latest stable, 2.x) running on **Svelte 5** (runes available — `$state`, `$derived`, `$effect`).
- **JavaScript** (not TypeScript). Use **JSDoc** for type hints where it pays for itself (Redis client, SePay payload shapes). `jsconfig.json` for editor IntelliSense + `checkJs: false` to stay loose.
- **Package manager: pnpm** (`pnpm-lock.yaml` committed, `packageManager` field in `package.json`, `.npmrc` with `shamefully-hoist=false`).
- **Node.js runtime** for all server routes (incl. webhook). No edge runtime — keeps things uniform and avoids Upstash REST cold-path edge cases.
## UI
- **Tailwind CSS v4** (PostCSS plugin + `@import "tailwindcss"` in `app.css`).
- **shadcn-svelte** (https://shadcn-svelte.com) — direct Svelte port of shadcn/ui, copy-paste components into `src/lib/components/ui`. Install only what's used: `button`, `input`, `label`, `card`, `badge`, `alert`. Powered by `bits-ui` under the hood.
- `lucide-svelte` for icons (pulled by shadcn-svelte).
- `mode-watcher` for light/dark mode (shadcn-svelte standard).
## Storage
- **Upstash Redis** via `@upstash/redis` (framework-agnostic; works fine in SvelteKit).
- Env: `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` — canonical names for `Redis.fromEnv()`.
- Auto-serializes JSON values → store plain objects.
- `set(key, val, { nx: true })` for idempotent webhook dedup.
- `set(key, val, { ex: 86400 })` — orders TTL 24h (demo).
## Payments
- **SePay** VietQR.
- QR image: `https://qr.sepay.vn/img?acc=<ACC>&bank=<BANK>&amount=<N>&des=<CODE>&template=compact`. No server-side QR lib.
- Webhook: SePay POSTs to `/api/webhooks/sepay` with `Authorization: Apikey <SEPAY_WEBHOOK_API_KEY>`.
- Order code surfaces in payload `code` (auto-extracted by SePay's dashboard prefix rule); fallback regex on `content`.
- Dedup on payload `id` (stable across retries — up to 7 retries / 5h).
- Respond `200 {"success":true}` within 30s.
## Deployment
- **Vercel** via **`@sveltejs/adapter-vercel`** (auto-detected by Vercel when present in `svelte.config.js`).
- No `vercel.json` needed.
- Env vars: `vercel env add ...` per-environment, `vercel env pull .env.local` for dev.
- Local public URL for SePay during dev: **ngrok** (`ngrok http 5173` — SvelteKit's default dev port).
## Project Layout (target)
```
sepay-playground/
├── src/
│ ├── app.css # Tailwind import + tokens
│ ├── app.html
│ ├── lib/
│ │ ├── server/
│ │ │ ├── redis.js # Redis.fromEnv() client
│ │ │ ├── orders.js # createOrder / getOrder / markPaid
│ │ │ └── sepay.js # buildQrUrl, verifyWebhookAuth
│ │ ├── components/
│ │ │ ├── ui/ # shadcn-svelte components
│ │ │ ├── PayForm.svelte
│ │ │ ├── PayAwaiting.svelte
│ │ │ └── PayPaid.svelte
│ │ └── types.js # JSDoc typedefs (Order, SepayWebhookPayload)
│ └── routes/
│ ├── +page.svelte # /
│ ├── pay/
│ │ ├── +page.svelte # /pay (state machine: form → awaiting → paid)
│ │ └── +page.server.js # form action: createOrder
│ └── api/
│ ├── orders/[code]/+server.js # GET status (polled by /pay)
│ ├── webhooks/sepay/+server.js # POST receiver
│ └── dev/simulate-webhook/+server.js # gated by !PROD
├── static/
├── svelte.config.js # adapter-vercel
├── vite.config.js
├── tailwind.config.js (or v4 inline) + postcss.config.js
├── jsconfig.json
├── .npmrc
├── .env.example
├── package.json # packageManager: pnpm@...
└── pnpm-lock.yaml
```
## Env Vars
| Name | Source | Notes |
|------|--------|-------|
| `SEPAY_API_TOKEN` | SePay dashboard | Reserved per user spec; unused by current flow |
| `SEPAY_WEBHOOK_API_KEY` | SePay dashboard | Compared against `Authorization: Apikey <...>` |
| `SEPAY_ACCOUNT_NUMBER` | SePay-bound bank account | Used in QR URL |
| `SEPAY_BANK_CODE` | qr.sepay.vn/banks.json | e.g. `MBBank`, `Vietcombank`, `ACB` |
| `UPSTASH_REDIS_REST_URL` | Upstash console / Vercel integration | |
| `UPSTASH_REDIS_REST_TOKEN` | Upstash console / Vercel integration | |
| `SEPAY_ORDER_PREFIX` | local config | Defaults `SEVQR` (VietinBank-compatible) |
All loaded via SvelteKit's `$env/static/private` (server-only — never leaks to client).
## Out of Scope (demo)
- Real auth / users
- Multi-currency (VND only)
- Refunds, settlement reconciliation
- Production observability beyond `console.log` + Redis state
## Confirmed decisions (vs. prior Next.js draft)
- SvelteKit replaces Next.js — Vercel still primary deploy target via official adapter.
- JavaScript replaces TypeScript — JSDoc typedefs cover the boundaries (Redis values, webhook payload).
- pnpm replaces npm — lockfile committed, `packageManager` field set.
- shadcn-svelte replaces shadcn/ui — same design system, Svelte port.
- Everything else (Upstash, SePay endpoints, env vars, ngrok-for-dev) unchanged.
## Open Question
- Include a dev-only `POST /api/dev/simulate-webhook` (gated by `!import.meta.env.PROD`) to test paid-state without a real transfer. **Recommendation: yes.**
+59
View File
@@ -0,0 +1,59 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SePay Playground — Home</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<script>
tailwind.config = {
theme: { extend: {
colors: {
bg:'#FAFAFA', surface:'#FFFFFF', border:'#E5E5E5',
ink:'#0A0A0A', muted:'#737373',
accent:'#4F46E5', accentHover:'#4338CA',
success:'#16A34A', warning:'#D97706', danger:'#DC2626'
},
fontFamily: { sans:['Inter','system-ui','sans-serif'], mono:['"JetBrains Mono"','ui-monospace','monospace'] },
borderRadius: { DEFAULT:'4px', md:'4px', lg:'4px' }
}}
}
</script>
<style>body{font-family:'Inter',system-ui,sans-serif}</style>
</head>
<body class="bg-bg text-ink min-h-screen antialiased">
<!-- Top bar: minimal brand label, mono code-style -->
<header class="border-b border-border">
<div class="max-w-[560px] mx-auto px-4 md:px-6 h-12 flex items-center justify-between">
<span class="font-mono text-[14px] font-medium tracking-tight">sepay/<span class="text-muted">playground</span></span>
<span class="font-mono text-[12px] text-muted">v0.1</span>
</div>
</header>
<!-- Hero: one line + single CTA + tiny what-this-is paragraph -->
<main class="max-w-[560px] mx-auto px-4 md:px-6 pt-16 md:pt-24 pb-16">
<p class="font-mono text-[12px] text-muted mb-4">// vietqr · sepay webhook · demo</p>
<h1 class="text-[30px] leading-[1.2] font-semibold tracking-tight mb-8">
Generate a VietQR, get paid, get a webhook.
</h1>
<a href="/pay"
class="inline-flex items-center justify-center h-10 px-4 bg-accent hover:bg-accentHover text-white text-[14px] font-medium rounded transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent">
Start a payment
<span aria-hidden="true" class="ml-2 font-mono">→</span>
</a>
<p class="mt-10 text-[14px] leading-[1.6] text-muted max-w-[44ch]">
A minimal playground showing the SePay VietQR flow end-to-end: create an order, render the QR, listen for the bank webhook, confirm paid. Built for developers learning the integration.
</p>
</main>
<footer class="max-w-[560px] mx-auto px-4 md:px-6 pb-10">
<p class="font-mono text-[12px] text-muted">demo only · no real bank transfers triggered from this UI</p>
</footer>
</body>
</html>
+76
View File
@@ -0,0 +1,76 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SePay Playground — Awaiting payment</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<script>
tailwind.config = {
theme: { extend: {
colors: { bg:'#FAFAFA', surface:'#FFFFFF', border:'#E5E5E5', ink:'#0A0A0A', muted:'#737373',
accent:'#4F46E5', accentHover:'#4338CA', success:'#16A34A', warning:'#D97706', danger:'#DC2626' },
fontFamily: { sans:['Inter','system-ui','sans-serif'], mono:['"JetBrains Mono"','ui-monospace','monospace'] },
borderRadius: { DEFAULT:'4px', md:'4px', lg:'4px' }
}}
}
</script>
<style>
body{font-family:'Inter',system-ui,sans-serif}
@keyframes spin{to{transform:rotate(360deg)}}
.spinner{animation:spin 1.2s linear infinite}
</style>
</head>
<body class="bg-bg text-ink min-h-screen antialiased">
<header class="border-b border-border">
<div class="max-w-[480px] mx-auto px-4 md:px-6 h-12 flex items-center justify-between">
<a href="/" class="font-mono text-[14px] font-medium tracking-tight">sepay/<span class="text-muted">playground</span></a>
<span class="font-mono text-[12px] text-muted">/pay</span>
</div>
</header>
<main class="max-w-[480px] mx-auto px-4 md:px-6 pt-12 pb-16">
<p class="font-mono text-[12px] text-muted mb-2">step 2 of 3 · awaiting payment</p>
<h1 class="text-[20px] font-semibold tracking-tight mb-6">Scan with your banking app</h1>
<!-- Card: QR + meta -->
<div class="bg-surface border border-border rounded p-6">
<!-- Status row -->
<div class="flex items-center justify-between mb-6">
<span class="inline-flex items-center gap-1.5 h-5 px-2 rounded text-[12px] font-medium font-mono bg-warning/10 text-warning">
<span class="w-1.5 h-1.5 rounded-full bg-warning"></span>pending
</span>
<span class="inline-flex items-center gap-2 text-[12px] text-muted font-mono">
<svg class="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9" stroke-opacity="0.2"/><path d="M21 12a9 9 0 0 1-9 9"/></svg>
waiting…
</span>
</div>
<!-- QR placeholder 240x240 -->
<div class="mx-auto w-[240px] h-[240px] border border-border rounded grid place-items-center bg-[repeating-conic-gradient(#E5E5E5_0%_25%,#FFFFFF_0%_50%)] bg-[length:24px_24px]">
<span class="font-mono text-[12px] text-muted bg-surface px-2 py-1 rounded border border-border">QR 240×240</span>
</div>
<!-- Meta grid -->
<dl class="mt-6 grid grid-cols-3 gap-x-4 gap-y-3 text-[14px]">
<dt class="col-span-1 text-[12px] uppercase tracking-wide text-muted">Amount</dt>
<dd class="col-span-2 font-mono text-ink">10,000 VND</dd>
<dt class="col-span-1 text-[12px] uppercase tracking-wide text-muted">Order</dt>
<dd class="col-span-2 font-mono text-ink">SEPAY-7K3QX9</dd>
<dt class="col-span-1 text-[12px] uppercase tracking-wide text-muted">Expires</dt>
<dd class="col-span-2 font-mono text-muted">in 14:52</dd>
</dl>
</div>
<div class="mt-4 flex items-center justify-between">
<a href="#" class="text-[12px] text-muted hover:text-danger transition">Cancel / new order</a>
<span class="font-mono text-[12px] text-muted">polling every 2s</span>
</div>
</main>
</body>
</html>
+64
View File
@@ -0,0 +1,64 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SePay Playground — New payment</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<script>
tailwind.config = {
theme: { extend: {
colors: { bg:'#FAFAFA', surface:'#FFFFFF', border:'#E5E5E5', ink:'#0A0A0A', muted:'#737373',
accent:'#4F46E5', accentHover:'#4338CA', success:'#16A34A', warning:'#D97706', danger:'#DC2626' },
fontFamily: { sans:['Inter','system-ui','sans-serif'], mono:['"JetBrains Mono"','ui-monospace','monospace'] },
borderRadius: { DEFAULT:'4px', md:'4px', lg:'4px' }
}}
}
</script>
<style>body{font-family:'Inter',system-ui,sans-serif}</style>
</head>
<body class="bg-bg text-ink min-h-screen antialiased">
<header class="border-b border-border">
<div class="max-w-[480px] mx-auto px-4 md:px-6 h-12 flex items-center justify-between">
<a href="/" class="font-mono text-[14px] font-medium tracking-tight">sepay/<span class="text-muted">playground</span></a>
<span class="font-mono text-[12px] text-muted">/pay</span>
</div>
</header>
<main class="max-w-[480px] mx-auto px-4 md:px-6 pt-12 pb-16">
<!-- State A: form -->
<p class="font-mono text-[12px] text-muted mb-2">step 1 of 3 · new order</p>
<h1 class="text-[20px] font-semibold tracking-tight mb-6">Enter an amount</h1>
<!-- Card containing the form -->
<div class="bg-surface border border-border rounded p-6">
<form class="space-y-4">
<div>
<label for="amount" class="block text-[12px] font-medium text-muted mb-2 uppercase tracking-wide">Amount (VND)</label>
<div class="relative">
<input id="amount" name="amount" type="text" inputmode="numeric" placeholder="10000"
class="w-full h-10 px-3 pr-14 bg-surface border border-border rounded font-mono text-[16px] text-ink placeholder:text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent" />
<span class="absolute right-3 top-1/2 -translate-y-1/2 font-mono text-[12px] text-muted">VND</span>
</div>
<p class="mt-2 text-[12px] text-muted">
Or use the
<a href="#" class="font-mono text-ink underline underline-offset-2 decoration-border hover:decoration-accent">demo amount: 10,000</a>
</p>
</div>
<button type="submit"
class="w-full h-10 bg-accent hover:bg-accentHover text-white text-[14px] font-medium rounded transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent">
Generate QR
</button>
</form>
</div>
<p class="mt-6 font-mono text-[12px] text-muted leading-[1.6]">
// POST /api/orders → returns { orderCode, qrUrl }
</p>
</main>
</body>
</html>
+76
View File
@@ -0,0 +1,76 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SePay Playground — Paid</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<script>
tailwind.config = {
theme: { extend: {
colors: { bg:'#FAFAFA', surface:'#FFFFFF', border:'#E5E5E5', ink:'#0A0A0A', muted:'#737373',
accent:'#4F46E5', accentHover:'#4338CA', success:'#16A34A', warning:'#D97706', danger:'#DC2626' },
fontFamily: { sans:['Inter','system-ui','sans-serif'], mono:['"JetBrains Mono"','ui-monospace','monospace'] },
borderRadius: { DEFAULT:'4px', md:'4px', lg:'4px' }
}}
}
</script>
<style>body{font-family:'Inter',system-ui,sans-serif}</style>
</head>
<body class="bg-bg text-ink min-h-screen antialiased">
<header class="border-b border-border">
<div class="max-w-[480px] mx-auto px-4 md:px-6 h-12 flex items-center justify-between">
<a href="/" class="font-mono text-[14px] font-medium tracking-tight">sepay/<span class="text-muted">playground</span></a>
<span class="font-mono text-[12px] text-muted">/pay</span>
</div>
</header>
<main class="max-w-[480px] mx-auto px-4 md:px-6 pt-12 pb-16">
<p class="font-mono text-[12px] text-muted mb-2">step 3 of 3 · settled</p>
<h1 class="text-[20px] font-semibold tracking-tight mb-6">Payment received</h1>
<!-- Confirmation card -->
<div class="bg-surface border border-border rounded p-6">
<!-- Status + checkmark -->
<div class="flex items-center justify-between mb-6">
<span class="inline-flex items-center gap-1.5 h-5 px-2 rounded text-[12px] font-medium font-mono bg-success/10 text-success">
<span class="w-1.5 h-1.5 rounded-full bg-success"></span>paid
</span>
<div class="w-10 h-10 rounded-full bg-success/10 grid place-items-center" aria-hidden="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#16A34A" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
</div>
</div>
<!-- Big amount -->
<div class="mb-6 pb-6 border-b border-border">
<p class="text-[12px] uppercase tracking-wide text-muted mb-1">Amount paid</p>
<p class="font-mono text-[30px] leading-none text-ink tabular-nums">10,000 <span class="text-[16px] text-muted">VND</span></p>
</div>
<!-- Receipt meta -->
<dl class="grid grid-cols-3 gap-x-4 gap-y-3 text-[14px]">
<dt class="col-span-1 text-[12px] uppercase tracking-wide text-muted">Order</dt>
<dd class="col-span-2 font-mono text-ink">SEPAY-7K3QX9</dd>
<dt class="col-span-1 text-[12px] uppercase tracking-wide text-muted">Paid at</dt>
<dd class="col-span-2 font-mono text-ink">2026-05-24 14:32:08 +07</dd>
<dt class="col-span-1 text-[12px] uppercase tracking-wide text-muted">Txn ref</dt>
<dd class="col-span-2 font-mono text-muted">FT26B5240008291</dd>
</dl>
</div>
<!-- CTA -->
<a href="/pay"
class="mt-6 inline-flex w-full sm:w-auto items-center justify-center h-10 px-4 bg-accent hover:bg-accentHover text-white text-[14px] font-medium rounded transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent">
Start another
<span aria-hidden="true" class="ml-2 font-mono">→</span>
</a>
<p class="mt-4 font-mono text-[12px] text-muted">// webhook received · order marked paid · ready</p>
</main>
</body>
</html>