mirror of
https://github.com/tiennm99/sepay-playground.git
synced 2026-10-03 05:13:04 +00:00
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:
1 parent
c70c51aa64
commit
aa119868ce
10 files changed
+1073
No files matched your search
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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/)
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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.**
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user