diff --git a/docs/superpowers/plans/2026-08-14-qris-decoder.md b/docs/superpowers/plans/2026-08-14-qris-decoder.md new file mode 100644 index 0000000..25947ce --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-qris-decoder.md @@ -0,0 +1,57 @@ +# QRIS Decoder Implementation Plan + +> **For agentic workers:** implement task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Ship a client-side QRIS (EMVCo) decoder: paste payload or QR image → summary + full TLV tree. + +**Architecture:** Pure lib `qris.lib.ts` (CRC + TLV parse + summary); thin island `QrisDecoder.tsx` (jsQR image decode + render); registry + EN/ID SEO. No new deps. + +**Tech Stack:** Astro + React island, Vitest, native JS, `jsqr` (already installed) for the image path. + +## Global Constraints + +- 100% client-side; parser is pure JS; image decode via already-installed `jsqr` (dynamic import). +- New tool `status: 'beta'`; Dev category; icon `Wallet`. +- SEO REQUIRED in EN + ID with `howTo`; Bahasa "tool" loanword, technical terms kept. +- Commit under personal noreply identity; no AI-attribution trailers; no absolute machine paths. + +--- + +### Task 1: Pure lib `qris.lib.ts` (TDD) + +**Files:** Create `src/tools/dev/qris.lib.ts`, Test `src/tools/dev/qris.lib.test.ts`. + +**Interfaces produced:** +- `crc16(input: string): string` +- `Tlv = { id: string; length: number; value: string; name: string; children?: Tlv[] }` +- `parseTlv(s: string): Tlv[]` +- `parseQris(payload: string): { summary: QrisSummary; tree: Tlv[] }` +- `QrisSummary` (fields per spec) + +- [ ] Failing tests: `crc16('123456789')==='29B1'`; `parseTlv('000201')` → one `{id:'00',length:2,value:'01'}`; nested tag 26 → children; malformed (`'0002'` truncated / bad len) → throws; `parseQris()` → merchantName 'TOKO BUDI', city 'JAKARTA', nmid 'ID1020012345678', amount '12345', mcc '5411', currency '360', initiationMethod 'static', crcValid true; tampered last char → crcValid false; `parseQris('garbage!!')` → throws. +- [ ] Run — confirm fail. +- [ ] Implement lib (CRC-16/CCITT-FALSE; recursive TLV with nested templates 26–51/62/64; summary extraction incl. NMID from merchant-account sub-tag 02). +- [ ] Run — confirm pass. + +### Task 2: Island `QrisDecoder.tsx` + +**Files:** Create `src/islands/dev/QrisDecoder.tsx`. + +- [ ] `TextArea` payload + `Dropzone` image + `usePasteImage`. Image → canvas → `getImageData` → `await import('jsqr')` → `.data` → setPayload. `useMemo` parse → result/error. Summary rows + CRC badge + `CopyButton`. Recursive TLV tree (indented children). i18n TR en+id. SSR-safe. + +### Task 3: Register + SEO + +**Files:** Modify `src/registry/tools.ts`, `src/registry/tool-seo.ts`. + +- [ ] Import `Wallet`; add ToolDef. +- [ ] Add EN + ID `qris-decoder` SEO entries (title/description/intro/howTo/faqs). + +### Task 4: Verify loop + +- [ ] `npx vitest run` green · `npm run lint` 0 errors · `npm run build` succeeds; `/tools/qris-decoder` + `/id/…` built. +- [ ] Hand review: parser rejects malformed without hanging; CRC path; SSR; empty/partial payload; reference-identity of derived props. + +### Task 5: Ship dev → prod + +- [ ] Commit on `feat/qris-decoder`, PR → develop, CI green, merge. +- [ ] Promote develop → main (`--admin`), confirm Cloudflare prod build, verify live URL. diff --git a/docs/superpowers/specs/2026-08-14-qris-decoder-design.md b/docs/superpowers/specs/2026-08-14-qris-decoder-design.md new file mode 100644 index 0000000..474a4fe --- /dev/null +++ b/docs/superpowers/specs/2026-08-14-qris-decoder-design.md @@ -0,0 +1,81 @@ +# QRIS Decoder — Design + +**Date:** 2026-08-14 +**Category:** Dev +**Tool id:** `qris-decoder` + +## Goal + +Decode an Indonesian QRIS code (EMVCo Merchant-Presented Mode QR) entirely in the browser and show the human-readable fields: merchant name, **NMID** (National Merchant ID), merchant city, amount, MCC, currency, static vs dynamic, acquirer, and **CRC validity** — plus a full tag-by-tag TLV breakdown. Nothing is uploaded. + +## Input (confirmed with user) + +- **Paste the payload string** (`00020101...`), OR +- **Drop / paste a QRIS QR image** → decode to the payload with `jsQR` (already a dependency; same engine as the existing QR Code Reader), then parse. + +## Detail level (confirmed) + +- **Summary** card of the key fields, plus an **expandable full TLV tree** (nested merchant-account template 26–51, additional-data template 62, language template 64). + +## EMVCo / QRIS parsing (the core knowledge) + +The payload is EMVCo TLV: each object is `ID(2) + LEN(2, decimal) + VALUE(LEN)`, concatenated. Root tags: +- `00` Payload Format Indicator · `01` Point of Initiation Method (`11`=static, `12`=dynamic) +- `26`–`51` Merchant Account Information (nested) — for QRIS: sub `00`=Globally Unique Identifier (e.g. `ID.CO.QRIS.WWW`), `01`=Merchant PAN, `02`=**NMID**, `03`=Merchant Criteria (UMI/UKE/UBE/URE) +- `52` MCC · `53` Currency (`360`=IDR) · `54` Amount · `55`–`57` tip/convenience +- `58` Country · `59` Merchant Name · `60` Merchant City · `61` Postal Code +- `62` Additional Data (nested: `01` bill, `05` reference, `07` terminal, …) +- `63` CRC · `64` Merchant Info — Language Template (nested) + +**CRC** is CRC-16/CCITT-FALSE (poly `0x1021`, init `0xFFFF`) over the whole payload up to and including the CRC tag+length (`6304`), compared to the trailing 4 hex chars. Pinned by the standard check vector: `crc16('123456789') === '29B1'`. + +## Architecture + +### Pure lib — `src/tools/dev/qris.lib.ts` (framework-free, unit-tested) + +- `crc16(input: string): string` — CRC-16/CCITT-FALSE, 4 upper-hex chars. +- `parseTlv(s: string): Tlv[]` — flat parse; throws on malformed (bad length / overrun). Nested templates parsed recursively (best-effort; a value that isn't clean TLV stays a leaf). +- `TAG_NAMES`, `SUBTAG_NAMES` — dictionaries; `Tlv = { id, length, value, name, children? }`. +- `parseQris(payload: string): { summary: QrisSummary; tree: Tlv[] }` — trims input, builds the tree, derives the summary, validates CRC. **Throws only on structurally invalid TLV**; a bad CRC is reported (`crcValid: false`), not thrown. +- `QrisSummary = { payloadFormat?, initiationMethod: 'static'|'dynamic'|'unknown', merchantName?, merchantCity?, postalCode?, countryCode?, currency?, amount?, mcc?, nmid?, merchantPan?, merchantCriteria?, acquirer?, crc?, crcValid }`. + +### Island — `src/islands/dev/QrisDecoder.tsx` (default export) + +- `TextArea` for the payload + `Dropzone accept="image/*"` (and `usePasteImage`) → canvas → `getImageData` → `await import('jsqr')` → `jsQR(...).data` → set payload. (Mirrors `QrRead.tsx`.) +- `useMemo` parse on the trimmed payload → result or error (`Alert`). +- **Summary** rendered as label→value rows (merchant, NMID, city, amount+currency, MCC, static/dynamic, acquirer) with a green/red **CRC valid** badge, plus a `CopyButton` for the payload. +- **Full TLV tree**: recursive labeled rows (`id · name` → value), children indented — modeled on `CronExplainer`'s labeled field boxes. +- i18n `TR` en + id (Bahasa: "tool" loanword, technical terms QRIS/NMID/MCC/CRC kept as-is). Signature `export default function QrisDecoder({ lang = 'en' }: { lang?: Lang })`. SSR-safe (jsQR + canvas only in handlers). + +### Registry — `src/registry/tools.ts` + +```ts +{ + id: 'qris-decoder', + name: 'QRIS Decoder', + category: 'Dev', + route: '/tools/qris-decoder', + keywords: ['qris', 'qr', 'emvco', 'payment', 'decode', 'nmid', 'merchant', 'indonesia', 'tlv'], + icon: Wallet, + summary: 'Decode a QRIS payment code — merchant, NMID, city, amount', + load: () => import('@/islands/dev/QrisDecoder'), + status: 'beta' +}, +``` +`Wallet` imported from `lucide-react` (exists). + +### SEO — `src/registry/tool-seo.ts` (REQUIRED, both locales) + +EN + ID `qris-decoder` with title/description/intro/**howTo**/faqs. Keywords: "QRIS decoder", "cek QRIS", "decode QRIS", "NMID QRIS", "baca QRIS". Bahasa uses "tool" loanword. + +### No new dependency, no globIgnores change + +`jsQR` is already installed and dynamic-imported; the parser is pure JS. + +## Testing + +`src/tools/dev/qris.lib.test.ts` — `crc16('123456789')==='29B1'`; `parseTlv` flat + nested + malformed-throws; `parseQris` on a hand-built ASCII QRIS (assert merchantName/city/nmid/amount/mcc/currency/initiationMethod, `crcValid: true`), tampered CRC → `crcValid: false`, garbage → throws. Island covered by build + manual smoke (jsQR/canvas can't run in jsdom). + +## Definition of done + +Spec+plan committed · `qris.lib.ts` unit-tested · EN + ID SEO with howTo · vitest + lint + build green · `/tools/qris-decoder` + `/id/…` built · merged to develop · promoted to main · Cloudflare prod build green · live URL verified · user told about PWA hard-refresh. diff --git a/src/islands/dev/QrisDecoder.tsx b/src/islands/dev/QrisDecoder.tsx new file mode 100644 index 0000000..a266763 --- /dev/null +++ b/src/islands/dev/QrisDecoder.tsx @@ -0,0 +1,276 @@ +import { useMemo, useState } from 'react'; +import jsQR from 'jsqr'; +import { Dropzone } from '@/components/ui/Dropzone'; +import { TextArea } from '@/components/ui/TextArea'; +import { Alert } from '@/components/ui/Alert'; +import { CopyButton } from '@/components/ui/CopyButton'; +import { usePasteImage } from '@/hooks/usePasteImage'; +import { parseQris, crc16, type Tlv, type QrisResult } from '@/tools/dev/qris.lib'; +import type { Lang } from '@/i18n/config'; + +// A syntactically valid sample QRIS with a correct trailing CRC. +const EXAMPLE_BASE = + '000201' + + '010211' + + '2644' + '0014ID.CO.QRIS.WWW' + '0215ID1020012345678' + '0303UMI' + + '52045411' + + '5303360' + + '540512345' + + '5802ID' + + '5909TOKO BUDI' + + '6007JAKARTA' + + '610512190' + + '62070703A01' + + '6304'; +const EXAMPLE = EXAMPLE_BASE + crc16(EXAMPLE_BASE); + +async function decodeImage(file: File): Promise { + const bitmap = await createImageBitmap(file); + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + ctx.drawImage(bitmap, 0, 0); + const img = ctx.getImageData(0, 0, canvas.width, canvas.height); + return jsQR(img.data, img.width, img.height)?.data ?? null; +} + +const TR: Record = { + en: { + intro: 'Decode an Indonesian QRIS code: paste the payload text or drop a QR image, and see the merchant, NMID, city, amount and every EMVCo field. Everything runs in your browser — nothing is uploaded.', + dropPrompt: 'Drop a QRIS image or click to browse', + dropSub: 'Decoded on your device · or paste an image (⌘V)', + payload: 'QRIS payload', + placeholder: 'Paste the QRIS string (starts with 00020101…) or drop an image above', + loadExample: 'Load example', + noQrFound: 'No QR code found in this image.', + cannotRead: 'Could not read the image file.', + summary: 'Summary', + merchant: 'Merchant name', + nmid: 'NMID', + city: 'Merchant city', + amount: 'Amount', + currency: 'Currency', + mcc: 'Category (MCC)', + type: 'Type', + staticL: 'Static', + dynamicL: 'Dynamic', + unknownL: 'Unknown', + acquirer: 'Acquirer / GUI', + criteria: 'Merchant criteria', + postal: 'Postal code', + country: 'Country', + crc: 'Checksum (CRC)', + valid: 'Valid', + invalid: 'Invalid', + breakdown: 'Full TLV breakdown', + parseError: 'This does not look like a valid QRIS / EMVCo payload.', + }, + id: { + intro: 'Dekode kode QRIS Indonesia: tempel teks payload atau letakkan gambar QR, lalu lihat merchant, NMID, kota, nominal, dan seluruh field EMVCo. Semuanya berjalan di browser Anda — tidak ada yang diunggah.', + dropPrompt: 'Letakkan gambar QRIS atau klik untuk memilih', + dropSub: 'Didekode di perangkat Anda · atau tempel gambar (⌘V)', + payload: 'Payload QRIS', + placeholder: 'Tempel teks QRIS (diawali 00020101…) atau letakkan gambar di atas', + loadExample: 'Muat contoh', + noQrFound: 'Tidak ada kode QR yang ditemukan pada gambar ini.', + cannotRead: 'Tidak dapat membaca file gambar.', + summary: 'Ringkasan', + merchant: 'Nama merchant', + nmid: 'NMID', + city: 'Kota merchant', + amount: 'Nominal', + currency: 'Mata uang', + mcc: 'Kategori (MCC)', + type: 'Tipe', + staticL: 'Statis', + dynamicL: 'Dinamis', + unknownL: 'Tidak diketahui', + acquirer: 'Acquirer / GUI', + criteria: 'Kriteria merchant', + postal: 'Kode pos', + country: 'Negara', + crc: 'Checksum (CRC)', + valid: 'Valid', + invalid: 'Tidak valid', + breakdown: 'Rincian TLV lengkap', + parseError: 'Ini sepertinya bukan payload QRIS / EMVCo yang valid.', + }, +}; + +function TlvRows({ nodes, depth = 0 }: { nodes: Tlv[]; depth?: number }) { + return ( + <> + {nodes.map((n, i) => ( +
+
+ {n.id} + {n.name} + {!n.children && {n.value || '∅'}} +
+ {n.children && } +
+ ))} + + ); +} + +export default function QrisDecoder({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const [payload, setPayload] = useState(''); + const [imgError, setImgError] = useState(''); + + const parsed = useMemo<{ result?: QrisResult; error?: boolean }>(() => { + const clean = payload.trim(); + if (!clean) return {}; + try { + return { result: parseQris(clean) }; + } catch { + return { error: true }; + } + }, [payload]); + + const handleFile = async (files: File[]) => { + setImgError(''); + if (files.length === 0) return; + try { + const decoded = await decodeImage(files[0]); + if (decoded) setPayload(decoded); + else setImgError(t.noQrFound); + } catch { + setImgError(t.cannotRead); + } + }; + + usePasteImage(file => handleFile([file])); + + const s = parsed.result?.summary; + const typeLabel = s + ? s.initiationMethod === 'static' ? t.staticL : s.initiationMethod === 'dynamic' ? t.dynamicL : t.unknownL + : ''; + + const rows: { label: string; value?: string }[] = s + ? [ + { label: t.merchant, value: s.merchantName }, + { label: t.nmid, value: s.nmid }, + { label: t.city, value: s.merchantCity }, + { label: t.amount, value: s.amount }, + { label: t.currency, value: s.currency }, + { label: t.mcc, value: s.mcc }, + { label: t.acquirer, value: s.acquirer }, + { label: t.criteria, value: s.merchantCriteria }, + { label: t.postal, value: s.postalCode }, + { label: t.country, value: s.countryCode }, + { label: t.type, value: typeLabel }, + ].filter(r => r.value) + : []; + + return ( +
+

{t.intro}

+ + +
+

{t.dropPrompt}

+

{t.dropSub}

+
+
+ + {imgError && {imgError}} + +
+
+ {t.payload} + +
+