diff --git a/api/invoice-numbers.ts b/api/invoice-numbers.ts new file mode 100644 index 0000000..ae1aad4 --- /dev/null +++ b/api/invoice-numbers.ts @@ -0,0 +1,39 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node'; +// 副檔名不可省:Vercel 以 nodenext 解析 api/,省略會編譯失敗(見 api/tsconfig.json) +import { parseInvoiceXml } from '../src/lib/invoice.js'; + +// 統一發票開獎號碼代理:抓財政部公開 RSS(固定網址、免金鑰), +// 解析成 JSON 回給前端。無使用者輸入,沒有 SSRF 面;不儲存任何資料。 +// 開獎兩個月一次,CDN 快取一小時綽綽有餘,也把流量壓到對得起官方伺服器。 + +const SOURCE = 'https://invoice.etax.nat.gov.tw/invoice.xml'; +const TIMEOUT_MS = 8000; +const UA = 'Mozilla/5.0 (compatible; TigletBot/1.0; +https://tiglet.vercel.app)'; + +export default async function handler(_req: VercelRequest, res: VercelResponse) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + let xml: string; + try { + const response = await fetch(SOURCE, { signal: controller.signal, headers: { 'User-Agent': UA, Accept: 'application/xml,text/xml' } }); + if (!response.ok) { + res.setHeader('Cache-Control', 'no-store'); + return res.status(502).json({ error: 'fetch-failed', status: response.status }); + } + xml = await response.text(); + } catch { + res.setHeader('Cache-Control', 'no-store'); + return res.status(502).json({ error: 'fetch-failed' }); + } finally { + clearTimeout(timer); + } + + const periods = parseInvoiceXml(xml); + if (periods.length === 0) { + res.setHeader('Cache-Control', 'no-store'); + return res.status(502).json({ error: 'parse-failed' }); + } + + res.setHeader('Cache-Control', 'public, s-maxage=3600, stale-while-revalidate=86400'); + return res.status(200).json({ periods }); +} diff --git a/e2e/invoice.spec.ts b/e2e/invoice.spec.ts new file mode 100644 index 0000000..9b1b9fd --- /dev/null +++ b/e2e/invoice.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from '@playwright/test'; +import { waitForIslands } from './helpers'; + +const PERIODS = { + periods: [ + { + period: '115年 03~04月', + link: 'https://www.etax.nat.gov.tw/etw-main/ETW183W2_11503', + special: '19531471', + grand: '85941329', + first: ['07225810', '20231230', '83518781'], + sixth: ['985'], + }, + { + period: '115年 01~02月', + link: 'https://www.etax.nat.gov.tw/etw-main/ETW183W2_11501', + special: '87510041', + grand: '32220522', + first: ['21677046', '44662410', '31262513'], + sixth: [], + }, + ], +}; + +function stub(page: import('@playwright/test').Page) { + return page.route('**/api/invoice-numbers', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PERIODS) }) + ); +} + +test('載入開獎號碼,末 3 碼沒中直接判定、8 碼對中頭獎', async ({ page }) => { + await stub(page); + await page.goto('/tools/invoice-lottery'); + await waitForIslands(page); + + // 最新一期的號碼上桌 + await expect(page.getByText('19531471')).toBeVisible(); + await expect(page.getByText('07225810')).toBeVisible(); + + const input = page.getByPlaceholder('例如 046'); + // 末 3 碼快篩:沒對中 → 明說「確定沒獎」 + await input.fill('000'); + await expect(page.getByRole('status')).toContainText('確定沒獎'); + + // 切到上一期,輸入完整 8 碼頭獎 + await page.getByLabel('開獎期別').selectOption({ label: '115年 01~02月' }); + await input.fill('21677046'); + await expect(page.getByRole('status')).toContainText('中頭獎'); + await expect(page.getByRole('status')).toContainText('200,000'); +}); + +test('末 3 碼對中增開六獎直接確定 200 元;對中頭獎末 3 提示可升級', async ({ page }) => { + await stub(page); + await page.goto('/tools/invoice-lottery'); + await waitForIslands(page); + + const input = page.getByPlaceholder('例如 046'); + await input.fill('985'); + await expect(page.getByRole('status')).toContainText('增開六獎'); + + await input.fill('810'); // 頭獎 07225810 的末 3 碼 + await expect(page.getByRole('status')).toContainText('六獎'); + await expect(page.getByRole('status')).toContainText('繼續輸入完整 8 碼'); +}); + +test('API 掛掉顯示錯誤並可重試', async ({ page }) => { + let calls = 0; + await page.route('**/api/invoice-numbers', (route) => { + calls += 1; + if (calls === 1) return route.fulfill({ status: 502, contentType: 'application/json', body: '{"error":"fetch-failed"}' }); + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PERIODS) }); + }); + await page.goto('/tools/invoice-lottery'); + await waitForIslands(page); + + await expect(page.getByText('開獎號碼取得失敗,請稍後再試。')).toBeVisible(); + await page.getByRole('button', { name: '重試' }).click(); + await expect(page.getByText('19531471')).toBeVisible(); +}); diff --git a/public/og/en/invoice-lottery.png b/public/og/en/invoice-lottery.png new file mode 100644 index 0000000..7576358 Binary files /dev/null and b/public/og/en/invoice-lottery.png differ diff --git a/public/og/zh/invoice-lottery.png b/public/og/zh/invoice-lottery.png new file mode 100644 index 0000000..331aa00 Binary files /dev/null and b/public/og/zh/invoice-lottery.png differ diff --git a/src/data/tools.ts b/src/data/tools.ts index 7ffa377..e489396 100644 --- a/src/data/tools.ts +++ b/src/data/tools.ts @@ -49,4 +49,5 @@ export const tools: Tool[] = [ { id: 'tw-id', title: '台灣證號', titleEn: 'Taiwan ID Validator', description: '身分證與統編檢查碼驗證,附測試號碼產生。', descriptionEn: 'Validate Taiwan national IDs and business numbers, with test generators.', category: '實用工具', path: '/tools/tw-id', icon: '🪪', status: 'available', keywords: ['身分證', '統編', '統一編號', 'id', 'gui', '驗證', 'validate', '檢查碼', 'taiwan'] }, { id: 'hash-id', title: '雜湊 / UUID', titleEn: 'Hash / UUID', description: '文字與檔案的 SHA 雜湊,加上 UUID 批次產生。', descriptionEn: 'SHA hashes for text and files, plus batch UUID generation.', category: '文字', path: '/tools/hash-id', icon: '🔏', status: 'available', keywords: ['hash', 'sha256', 'sha1', 'uuid', 'guid', '雜湊', 'checksum'] }, { id: 'url-audit', title: '網址健檢', titleEn: 'URL Audit', description: '檢查網址的 SEO、社群分享卡與 AI 引擎設定並評分。', descriptionEn: 'Audit a URL for SEO, social share cards and AI-engine readiness, with a score.', category: '實用工具', path: '/tools/url-audit', icon: '🔎', status: 'available', keywords: ['seo', 'og', 'open graph', 'geo', '健檢', 'meta', '分享', 'social', 'audit', 'twitter card'] }, + { id: 'invoice-lottery', title: '發票對獎', titleEn: 'Invoice Lottery', description: '財政部最新開獎號碼,輸入末 3 碼立刻對獎。', descriptionEn: 'Check Taiwan uniform-invoice numbers against the latest official draw.', category: '實用工具', path: '/tools/invoice-lottery', icon: '🧧', status: 'available', keywords: ['發票', '統一發票', '對獎', '中獎號碼', 'invoice', 'lottery', 'receipt', '雲端發票', '財政部'] }, ]; diff --git a/src/lib/__tests__/invoice.test.ts b/src/lib/__tests__/invoice.test.ts new file mode 100644 index 0000000..9743445 --- /dev/null +++ b/src/lib/__tests__/invoice.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import { parseInvoiceXml, checkInvoice, PRIZE_AMOUNT, type WinningNumbers } from '../invoice'; + +// 財政部 invoice.xml 的實際格式(節錄自 https://invoice.etax.nat.gov.tw/invoice.xml) +const REAL_XML = ` + + + <![CDATA[統一發票中獎號碼]]> + + <![CDATA[115年 03~04月]]> + + 特別獎:19531471

特獎:85941329

頭獎:07225810、20231230、83518781

]]>
+
+ + <![CDATA[115年 01~02月]]> + + 特別獎:87510041

特獎:32220522

頭獎:21677046、44662410、31262513

增開六獎:985、951

]]>
+
+
+
`; + +describe('parseInvoiceXml', () => { + it('解析真實格式:期別、連結、各獎號碼', () => { + const periods = parseInvoiceXml(REAL_XML); + expect(periods).toHaveLength(2); + expect(periods[0]).toEqual({ + period: '115年 03~04月', + link: 'https://www.etax.nat.gov.tw/etw-main/ETW183W2_11503', + special: '19531471', + grand: '85941329', + first: ['07225810', '20231230', '83518781'], + sixth: [], + }); + // 增開六獎有開的期別要抓到;號碼是字串,前導零不能掉 + expect(periods[1].sixth).toEqual(['985', '951']); + expect(periods[1].grand).toBe('32220522'); + }); + + it('號碼不完整的 item 直接略過,不產生半套資料', () => { + const broken = REAL_XML.replace('

特獎:85941329

', ''); + const periods = parseInvoiceXml(broken); + expect(periods).toHaveLength(1); + expect(periods[0].period).toBe('115年 01~02月'); + }); + + it('垃圾輸入回空陣列', () => { + expect(parseInvoiceXml('not xml at all')).toEqual([]); + expect(parseInvoiceXml('')).toEqual([]); + }); +}); + +const W: WinningNumbers = { + period: '115年 01~02月', + link: '', + special: '87510041', + grand: '32220522', + first: ['21677046', '44662410', '31262513'], + sixth: ['985', '951'], +}; + +describe('checkInvoice(完整 8 碼)', () => { + it('特別獎:8 碼全中 1,000 萬', () => { + expect(checkInvoice('87510041', W)).toEqual({ level: 'special', amount: 10_000_000, matched: '87510041', confirmed: true, upgradable: false }); + }); + + it('特獎:8 碼全中 200 萬', () => { + expect(checkInvoice('32220522', W)).toMatchObject({ level: 'grand', amount: 2_000_000 }); + }); + + it('頭獎全中 20 萬;末 7~3 碼對應二獎到六獎', () => { + expect(checkInvoice('21677046', W)).toMatchObject({ level: 'first', amount: 200_000 }); + expect(checkInvoice('91677046', W)).toMatchObject({ level: 'second', amount: 40_000 }); + expect(checkInvoice('99677046', W)).toMatchObject({ level: 'third', amount: 10_000 }); + expect(checkInvoice('99977046', W)).toMatchObject({ level: 'fourth', amount: 4_000 }); + expect(checkInvoice('99997046', W)).toMatchObject({ level: 'fifth', amount: 1_000 }); + expect(checkInvoice('99999046', W)).toMatchObject({ level: 'sixth', amount: 200 }); + }); + + it('增開六獎:末 3 碼相同 200 元', () => { + expect(checkInvoice('12345985', W)).toMatchObject({ level: 'addSixth', amount: 200 }); + }); + + it('特別獎末碼近似但非全中不算獎(特別獎只比全 8 碼)', () => { + // 末 7 碼與特別獎相同、與頭獎無關 → 沒中 + expect(checkInvoice('97510041', W)).toBeNull(); + }); + + it('多重命中取最高獎金', () => { + // 同時是頭獎末4(五獎)與增開六獎? 構造:末3=985 且末4 對中頭獎末4 —— 取五獎 1000 + const w2: WinningNumbers = { ...W, first: ['11112985'], sixth: ['985'] }; + expect(checkInvoice('99992985', w2)).toMatchObject({ level: 'fifth', amount: 1_000 }); + }); + + it('沒中回 null', () => { + expect(checkInvoice('00000000', W)).toBeNull(); + }); +}); + +describe('checkInvoice(末 3~7 碼快速對獎)', () => { + it('末 3 碼對中頭獎末 3 → 至少六獎已確定,且可能升級', () => { + const hit = checkInvoice('046', W)!; + expect(hit).toMatchObject({ level: 'sixth', amount: 200, confirmed: true }); + expect(hit.upgradable).toBe(true); // 補完 8 碼可能是二獎以上 + }); + + it('末 3 碼對中增開六獎 → 200 元確定,不會再升級', () => { + const hit = checkInvoice('951', W)!; + expect(hit).toMatchObject({ level: 'addSixth', amount: 200, confirmed: true }); + expect(hit.upgradable).toBe(false); + }); + + it('末 3 碼只對中特別獎末 3 → 未確定(特別獎要全 8 碼),confirmed false', () => { + const hit = checkInvoice('041', W)!; + expect(hit.confirmed).toBe(false); + expect(hit.level).toBe('special'); + }); + + it('末 3 碼什麼都沒對中 → null(可以確定沒中,不用再輸入)', () => { + expect(checkInvoice('000', W)).toBeNull(); + }); + + it('末 5 碼對中頭獎末 5 → 四獎確定、可能再升級', () => { + expect(checkInvoice('77046', W)).toMatchObject({ level: 'fourth', amount: 4_000, confirmed: true, upgradable: true }); + expect(checkInvoice('62513', W)).toMatchObject({ level: 'fourth', amount: 4_000, confirmed: true }); + }); + + it('少於 3 碼或含非數字 → null(無效輸入)', () => { + expect(checkInvoice('04', W)).toBeNull(); + expect(checkInvoice('abc46', W)).toBeNull(); + expect(checkInvoice('', W)).toBeNull(); + }); +}); + +describe('PRIZE_AMOUNT', () => { + it('獎金表符合財政部公告', () => { + expect(PRIZE_AMOUNT.special).toBe(10_000_000); + expect(PRIZE_AMOUNT.grand).toBe(2_000_000); + expect(PRIZE_AMOUNT.first).toBe(200_000); + expect(PRIZE_AMOUNT.sixth).toBe(200); + expect(PRIZE_AMOUNT.addSixth).toBe(200); + }); +}); diff --git a/src/lib/invoice.ts b/src/lib/invoice.ts new file mode 100644 index 0000000..04676e6 --- /dev/null +++ b/src/lib/invoice.ts @@ -0,0 +1,115 @@ +// 統一發票對獎:財政部 invoice.xml 解析與對獎規則。純函式, +// 由 api/invoice-numbers.ts(解析)與 InvoiceLottery 島(對獎)共用。 +// 號碼一律以字串處理,前導零不能掉(例:特獎 00507588)。 + +export interface WinningNumbers { + period: string; // 期別,如「115年 03~04月」 + link: string; // 財政部該期公告頁 + special: string; // 特別獎(8 碼,全中 1,000 萬) + grand: string; // 特獎(8 碼,全中 200 萬) + first: string[]; // 頭獎(8 碼三組;末 7~3 碼對應二獎~六獎) + sixth: string[]; // 增開六獎(3 碼,可能沒開) +} + +export type PrizeLevel = 'special' | 'grand' | 'first' | 'second' | 'third' | 'fourth' | 'fifth' | 'sixth' | 'addSixth'; + +export const PRIZE_AMOUNT: Record = { + special: 10_000_000, + grand: 2_000_000, + first: 200_000, + second: 40_000, + third: 10_000, + fourth: 4_000, + fifth: 1_000, + sixth: 200, + addSixth: 200, +}; + +/** 與頭獎共同末碼長度 → 獎級(8 碼全中即頭獎) */ +const SUFFIX_LEVEL: Record = { + 8: 'first', + 7: 'second', + 6: 'third', + 5: 'fourth', + 4: 'fifth', + 3: 'sixth', +}; + +export interface PrizeHit { + level: PrizeLevel; + amount: number; + matched: string; // 對中的官方號碼 + confirmed: boolean; // 以目前輸入位數已確定中此獎;false = 需要完整 8 碼才能確認(特別獎/特獎) + upgradable: boolean; // 再多輸入幾碼有機會升到更高獎級 +} + +export function parseInvoiceXml(xml: string): WinningNumbers[] { + const items = xml.match(/[\s\S]*?<\/item>/g) ?? []; + const out: WinningNumbers[] = []; + for (const item of items) { + const cdata = (tag: string) => item.match(new RegExp(`<${tag}>`))?.[1]?.trim() ?? ''; + const desc = cdata('description'); + const period = cdata('title'); + const special = desc.match(/特別獎:(\d{8})/)?.[1]; + const grand = desc.match(/特獎:(\d{8})/)?.[1]; + const first = desc.match(/頭獎:([\d、,\s]+)/)?.[1]?.match(/\d{8}/g) ?? []; + const sixth = desc.match(/增開六獎:([\d、,\s]+)/)?.[1]?.match(/\d{3}/g) ?? []; + // 缺任何必要獎項就整期略過,寧缺勿錯 + if (!period || !special || !grand || first.length === 0) continue; + out.push({ period, link: cdata('link'), special, grand, first, sixth }); + } + return out; +} + +function commonSuffixLen(a: string, b: string): number { + let n = 0; + while (n < a.length && n < b.length && a[a.length - 1 - n] === b[b.length - 1 - n]) n++; + return n; +} + +/** + * 對獎。輸入發票號碼的「末 3~8 碼」: + * - 8 碼:完整判定(特別獎/特獎要全中;頭獎系列取最長共同末碼;增開六獎比末 3)。 + * - 3~7 碼:能確定的獎(頭獎末碼系列、增開六獎)直接判定; + * 只有特別獎/特獎的末碼吻合時回未確認命中(confirmed: false),提示補完 8 碼。 + * - 什麼都沒對中回 null —— 末 3 碼就沒中的發票可以直接確定沒獎。 + */ +export function checkInvoice(digits: string, w: WinningNumbers): PrizeHit | null { + if (!/^\d{3,8}$/.test(digits)) return null; + const k = digits.length; + const full = k === 8; + + const confirmed: PrizeHit[] = []; + + if (full && digits === w.special) confirmed.push({ level: 'special', amount: PRIZE_AMOUNT.special, matched: w.special, confirmed: true, upgradable: false }); + if (full && digits === w.grand) confirmed.push({ level: 'grand', amount: PRIZE_AMOUNT.grand, matched: w.grand, confirmed: true, upgradable: false }); + + // 頭獎系列:共同末碼長度不能超過已輸入的位數;部分輸入時整段吻合(L === k)才有升級空間 + let anyFullTailMatch = false; + for (const f of w.first) { + const L = Math.min(commonSuffixLen(digits, f), k); + if (L < 3) continue; + if (!full && L === k) anyFullTailMatch = true; + confirmed.push({ level: SUFFIX_LEVEL[L], amount: PRIZE_AMOUNT[SUFFIX_LEVEL[L]], matched: f, confirmed: true, upgradable: false }); + } + + const last3 = digits.slice(-3); + if (w.sixth.includes(last3)) confirmed.push({ level: 'addSixth', amount: PRIZE_AMOUNT.addSixth, matched: last3, confirmed: true, upgradable: false }); + + // 特別獎/特獎:未滿 8 碼只能算「有機會」,要補完才能確認 + const potential: PrizeHit[] = []; + if (!full) { + if (w.special.endsWith(digits)) potential.push({ level: 'special', amount: PRIZE_AMOUNT.special, matched: w.special, confirmed: false, upgradable: false }); + if (w.grand.endsWith(digits)) potential.push({ level: 'grand', amount: PRIZE_AMOUNT.grand, matched: w.grand, confirmed: false, upgradable: false }); + } + + const best = (hits: PrizeHit[]) => hits.reduce((a, b) => (b.amount > a.amount ? b : a)); + + if (confirmed.length > 0) { + const hit = best(confirmed); + hit.upgradable = !full && (anyFullTailMatch || potential.length > 0); + return hit; + } + if (potential.length > 0) return best(potential); + return null; +} diff --git a/src/pages/en/tools/invoice-lottery.astro b/src/pages/en/tools/invoice-lottery.astro new file mode 100644 index 0000000..67b0a31 --- /dev/null +++ b/src/pages/en/tools/invoice-lottery.astro @@ -0,0 +1,12 @@ +--- +import BaseLayout from '../../../layouts/BaseLayout.astro'; +import InvoiceLottery from '../../../tools/InvoiceLottery.tsx'; +import { tools } from '../../../data/tools'; + +const tool = tools.find((t) => t.id === 'invoice-lottery')!; +--- + + ← All tools +

{tool.titleEn}

+ +
diff --git a/src/pages/tools/invoice-lottery.astro b/src/pages/tools/invoice-lottery.astro new file mode 100644 index 0000000..0cc3b4e --- /dev/null +++ b/src/pages/tools/invoice-lottery.astro @@ -0,0 +1,9 @@ +--- +import BaseLayout from '../../layouts/BaseLayout.astro'; +import InvoiceLottery from '../../tools/InvoiceLottery.tsx'; +--- + + ← 回到所有工具 +

發票對獎

+ +
diff --git a/src/tools/InvoiceLottery.tsx b/src/tools/InvoiceLottery.tsx new file mode 100644 index 0000000..eadd072 --- /dev/null +++ b/src/tools/InvoiceLottery.tsx @@ -0,0 +1,199 @@ +import { useEffect, useState } from 'react'; +import { checkInvoice, type PrizeLevel, type WinningNumbers } from '../lib/invoice'; +import type { Locale } from '../lib/i18n'; + +const L = { + zh: { + loading: '正在取得財政部開獎號碼…', + loadError: '開獎號碼取得失敗,請稍後再試。', + retry: '重試', + periodLabel: '開獎期別', + special: '特別獎', + grand: '特獎', + first: '頭獎', + addSixth: '增開六獎', + specialNote: '8 碼全中 1,000 萬', + grandNote: '8 碼全中 200 萬', + firstNote: '全中 20 萬;末 7~3 碼依序 4 萬/1 萬/4 千/1 千/200', + addSixthNote: '末 3 碼相同 200 元', + inputLabel: '輸入發票末 3 碼(可續輸到 8 碼確認獎級)', + inputPlaceholder: '例如 046', + tooShort: '輸入末 3 碼即可開始對獎', + noneShort: (n: number) => `末 ${n} 碼沒有對中 —— 這張確定沒獎,不用再輸入了`, + noneFull: '完整 8 碼沒有對中,這張沒獎', + win: (name: string, amount: string) => `🎉 中${name}!獎金 ${amount} 元`, + winMatched: (num: string) => `對中號碼:${num}`, + upgradeHint: '繼續輸入完整 8 碼,有機會是更高獎級!', + maybe: (name: string) => `末碼與${name}相同!輸入完整 8 碼確認是否全中`, + officialLink: '財政部官方公告', + levelNames: { + special: '特別獎', grand: '特獎', first: '頭獎', second: '二獎', third: '三獎', + fourth: '四獎', fifth: '五獎', sixth: '六獎', addSixth: '增開六獎', + } as Record, + rules: '二獎~六獎為發票末碼與任一組頭獎末碼相同;特別獎與特獎必須 8 碼全中,末碼相同不算。中獎請以財政部公告為準,並留意領獎期限。', + privacy: '對獎完全在你的瀏覽器進行;伺服器只代抓財政部公告的開獎號碼,你輸入的發票號碼不會送出。', + }, + en: { + loading: 'Fetching winning numbers from the Ministry of Finance…', + loadError: 'Could not load the winning numbers. Please try again.', + retry: 'Retry', + periodLabel: 'Draw period', + special: 'Special Prize', + grand: 'Grand Prize', + first: 'First Prize', + addSixth: 'Additional Sixth', + specialNote: 'all 8 digits — NT$10,000,000', + grandNote: 'all 8 digits — NT$2,000,000', + firstNote: 'all 8 — NT$200,000; last 7–3 digits win 40k / 10k / 4k / 1k / 200', + addSixthNote: 'last 3 digits — NT$200', + inputLabel: 'Enter the last 3 digits (keep typing up to 8 to confirm the tier)', + inputPlaceholder: 'e.g. 046', + tooShort: 'Type the last 3 digits to start checking', + noneShort: (n: number) => `Last ${n} digits don't match — this one is definitely not a winner`, + noneFull: 'No match on the full 8 digits — not a winner', + win: (name: string, amount: string) => `🎉 ${name} — NT$${amount}!`, + winMatched: (num: string) => `Matched number: ${num}`, + upgradeHint: 'Keep typing the full 8 digits — it could be a higher tier!', + maybe: (name: string) => `The tail matches the ${name}! Enter all 8 digits to confirm`, + officialLink: 'Official announcement', + levelNames: { + special: 'Special Prize', grand: 'Grand Prize', first: 'First Prize', second: 'Second Prize', + third: 'Third Prize', fourth: 'Fourth Prize', fifth: 'Fifth Prize', sixth: 'Sixth Prize', + addSixth: 'Additional Sixth Prize', + } as Record, + rules: 'Second to Sixth Prizes require the invoice tail to match a First Prize number; the Special and Grand Prizes require all 8 digits. Results are subject to the official announcement — mind the claim deadline.', + privacy: 'Checking happens entirely in your browser; the server only relays the official winning numbers. Your invoice number is never sent anywhere.', + }, +} as const; +type Dict = (typeof L)[Locale]; + +const numClass = 'font-mono tabular-nums text-ink'; + +function WinningCard({ w, t }: { w: WinningNumbers; t: Dict }) { + const rows: { label: string; note: string; nums: string[] }[] = [ + { label: t.special, note: t.specialNote, nums: [w.special] }, + { label: t.grand, note: t.grandNote, nums: [w.grand] }, + { label: t.first, note: t.firstNote, nums: w.first }, + ...(w.sixth.length > 0 ? [{ label: t.addSixth, note: t.addSixthNote, nums: w.sixth }] : []), + ]; + return ( +
+
+ {rows.map((r) => ( +
+
{r.label}
+
+ {r.nums.map((n) => ( + {n} + ))} +
+
{r.note}
+
+ ))} +
+ {w.link && ( + + {t.officialLink} ↗ + + )} +
+ ); +} + +function Result({ digits, w, t }: { digits: string; w: WinningNumbers; t: Dict }) { + if (digits.length < 3) return

{t.tooShort}

; + const hit = checkInvoice(digits, w); + if (!hit) { + return ( +

+ {digits.length < 8 ? t.noneShort(digits.length) : t.noneFull} +

+ ); + } + const name = t.levelNames[hit.level]; + if (!hit.confirmed) { + return

{t.maybe(name)}

; + } + return ( +
+

{t.win(name, hit.amount.toLocaleString())}

+

{t.winMatched(hit.matched)}

+ {hit.upgradable &&

{t.upgradeHint}

} +
+ ); +} + +export default function InvoiceLottery({ locale = 'zh' }: { locale?: Locale }) { + const t = L[locale]; + const [periods, setPeriods] = useState(null); + const [failed, setFailed] = useState(false); + const [periodIdx, setPeriodIdx] = useState(0); + const [digits, setDigits] = useState(''); + + async function load() { + setFailed(false); + setPeriods(null); + try { + const res = await fetch('/api/invoice-numbers'); + if (!res.ok) throw new Error(); + const data = await res.json(); + if (!Array.isArray(data.periods) || data.periods.length === 0) throw new Error(); + setPeriods(data.periods); + } catch { + setFailed(true); + } + } + useEffect(() => { + load(); + }, []); + + if (failed) { + return ( +
+

{t.loadError}

+ +
+ ); + } + if (!periods) return

{t.loading}

; + + const w = periods[Math.min(periodIdx, periods.length - 1)]; + + return ( +
+ + + + + + + + +

{t.rules}

+

{t.privacy}

+
+ ); +}