'use client' import { useState, useEffect, useRef } from 'react' import { supabase } from '@/lib/supabase' const monthlyCosts = [ { item: "Supabase(資料庫 + 儲存)", amount: "NT$1,100", note: "依實際用量,每月帳單略有浮動" }, { item: "Claude Code(AI 開發工具)", amount: "NT$1,600", note: "月費 $100 USD,SnapPath 佔約 50%" }, { item: "Apple Developer Program", amount: "NT$264", note: "$99 USD / 年攤提" }, ] const exhibitionBudget = [ { item: "週末兩天展場租金", amount: "NT$8,000", note: "台中質感空間" }, { item: "20 幅 A3 攝影作品輸出", amount: "NT$5,000", note: "實體化你的街拍作品" }, { item: "20 幅 A3 質感展覽畫框", amount: "NT$9,000", note: "讓作品掛上展牆" }, ] const MONTHLY_GOAL = 2964 const EXHIBITION_GOAL = 22000 const totalMonthly = "NT$2,964" const RECUR_PUBLISHABLE_KEY = 'pk_live_36287edb05416c69634f245d8f76083864a798d065076f1f3562609b165eb6dc' const SPONSOR_TIERS = [ { label: 'NT$100', amount: 100, productId: 'dwahp7b2ynj0vx8ixemvj1xh' }, { label: 'NT$500', amount: 500, productId: 'rnc333r2nmzzb142kjsiwpsu' }, { label: 'NT$1,000', amount: 1000, productId: 'koxj83saf0xjtzmrw9uod1g6' }, ] const MONTHLY_TIERS = [ { label: 'NT$100/月', amount: 100, productId: 'crrdkly4y2ufymyy0wu70aqh' }, { label: 'NT$300/月', amount: 300, productId: 'mzu7811qwz8so3js7lxrsrtz' }, { label: 'NT$500/月', amount: 500, productId: 'jh0541tdye8fh3xseh54akwy' }, ] interface RecurInstance { redirectToCheckout: (options: { productId: string successUrl: string cancelUrl: string customerEmail?: string metadata?: Record }) => Promise } declare global { interface Window { RecurCheckout?: { init: (config: { publishableKey: string }) => RecurInstance } } } interface Sponsorship { id: number nickname: string amount: number message: string | null fund_type: 'general' | 'exhibition' created_at: string } export default function SponsorPage() { // Progress bar + sponsor wall data const [sponsors, setSponsors] = useState([]) const [totalRaised, setTotalRaised] = useState(0) const [loadingSponsors, setLoadingSponsors] = useState(true) // RECUR SDK const recurRef = useRef(null) const [recurReady, setRecurReady] = useState(false) const [checkingOut, setCheckingOut] = useState(null) const [checkoutError, setCheckoutError] = useState(null) // Form state const [nickname, setNickname] = useState('') const [email, setEmail] = useState('') const [amount, setAmount] = useState('') const [message, setMessage] = useState('') const [fundType, setFundType] = useState<'general' | 'exhibition'>('general') const [submitting, setSubmitting] = useState(false) const [submitResult, setSubmitResult] = useState<{ success: boolean; message: string } | null>(null) // Online payment fund type const [onlineFundType, setOnlineFundType] = useState<'general' | 'exhibition'>('general') // Load RECUR SDK useEffect(() => { const script = document.createElement('script') script.src = 'https://unpkg.com/recur-tw@0.13.2/dist/recur.umd.js' script.async = true script.onload = () => { if (window.RecurCheckout) { recurRef.current = window.RecurCheckout.init({ publishableKey: RECUR_PUBLISHABLE_KEY }) setRecurReady(true) } } document.head.appendChild(script) return () => { document.head.removeChild(script) } }, []) useEffect(() => { fetchSponsors() }, []) async function fetchSponsors() { try { setLoadingSponsors(true) const { data, error } = await supabase .from('sponsorships') .select('*') .eq('status', 'confirmed') .order('created_at', { ascending: false }) if (error) throw error const list = data || [] setSponsors(list) setTotalRaised(list.reduce((sum, s) => sum + s.amount, 0)) } catch { // Silently fail — progress bar shows 0 } finally { setLoadingSponsors(false) } } async function handleSubmit(e: React.FormEvent) { e.preventDefault() const parsedAmount = parseInt(amount, 10) if (!parsedAmount || parsedAmount <= 0) { setSubmitResult({ success: false, message: '請輸入有效金額' }) return } setSubmitting(true) setSubmitResult(null) try { const { error } = await supabase.from('sponsorships').insert({ nickname: nickname.trim() || '匿名', email: email.trim() || null, amount: parsedAmount, message: message.trim() || null, fund_type: fundType, }) if (error) throw error setSubmitResult({ success: true, message: '已收到匯款通知!確認後會更新進度 🙏' }) setNickname('') setEmail('') setAmount('') setMessage('') setFundType('general') } catch { setSubmitResult({ success: false, message: '送出失敗,請稍後再試' }) } finally { setSubmitting(false) } } async function handleCheckout(productId: string) { if (!recurRef.current) return setCheckingOut(productId) setCheckoutError(null) try { await recurRef.current.redirectToCheckout({ productId, successUrl: `${window.location.origin}/sponsor?success=1`, cancelUrl: `${window.location.origin}/sponsor`, metadata: { fund_type: onlineFundType }, }) } catch (err) { setCheckoutError(err instanceof Error ? err.message : '付款啟動失敗') setCheckingOut(null) } } // 本月贊助金額(只計一般贊助,展覽指定的不算在營運進度) const now = new Date() const thisMonthSponsors = sponsors.filter((s) => { const d = new Date(s.created_at) return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && s.fund_type !== 'exhibition' }) const thisMonthTotal = thisMonthSponsors.reduce((sum, s) => sum + s.amount, 0) // 展覽基金:直接指定展覽的 + 每個月一般贊助超過 2,964 的部分 const directExhibition = sponsors .filter((s) => s.fund_type === 'exhibition') .reduce((sum, s) => sum + s.amount, 0) const generalSponsors = sponsors.filter((s) => s.fund_type !== 'exhibition') const monthlyTotals = generalSponsors.reduce>((acc, s) => { const d = new Date(s.created_at) const key = `${d.getFullYear()}-${d.getMonth()}` acc[key] = (acc[key] || 0) + s.amount return acc }, {}) const overflowFund = Object.values(monthlyTotals).reduce( (sum, monthTotal) => sum + Math.max(0, monthTotal - MONTHLY_GOAL), 0 ) const exhibitionFund = directExhibition + overflowFund const monthlyPercentage = Math.round((thisMonthTotal / MONTHLY_GOAL) * 100) const monthlyBarWidth = Math.min(monthlyPercentage, 100) const overMonthlyGoal = monthlyPercentage > 100 const exhibitionPercentage = Math.round((exhibitionFund / EXHIBITION_GOAL) * 100) const exhibitionBarWidth = Math.min(exhibitionPercentage, 100) // 贊助排行(用 email 累計同一人,顯示暱稱) const sponsorRanking = Object.values( sponsors.reduce>((acc, s) => { const key = (s as Sponsorship & { email?: string }).email || s.nickname if (!acc[key]) acc[key] = { nickname: s.nickname, total: 0 } acc[key].total += s.amount return acc }, {}) ).sort((a, b) => b.total - a.total).slice(0, 5) return (
{/* Hero */}

從街頭到展牆

一起把作品實體化

SnapPath 是一個人做的街拍社群 App。沒有投資人,沒有廣告,就是一個喜歡街拍的人寫出來的東西。

目前伺服器與資料庫費用全由開發者自掏腰包。為了讓 SnapPath 能長久穩定地運作下去,我開啟了這個贊助計畫。

不僅如此,當我們累積到一定的能量,我想把大家在螢幕裡滑到的精采瞬間,真正掛上實體的展牆

每一分錢花在哪裡,你都看得到。

{/* === 階段一:維持運營 === */}
階段一

維持運營

伺服器不當機,SnapPath 才能陪大家繼續在街頭按下快門

{loadingSponsors ? (
) : ( <>
{monthlyBarWidth > 15 && ( {monthlyPercentage}% )}
本月已收到 NT${thisMonthTotal.toLocaleString()} 目標 {totalMonthly} / 月
{overMonthlyGoal && (

超過目標!多出的 NT${(thisMonthTotal - MONTHLY_GOAL).toLocaleString()} 納入展覽基金

)} )}
{/* 每月營運成本 */}

每月營運成本

{monthlyCosts.map((cost) => (
{cost.item}
{cost.note}
{cost.amount}
))}
每月合計 {totalMonthly}

* Google Play 開發者帳號為一次性費用 $25 USD,已付清。Vercel 網站託管目前使用免費方案。

{/* === 階段二:展覽里程碑 === */}
階段二

SnapPath 首場實體快閃攝影展

可直接指定贊助展覽基金,或由一般贊助超過營運費用的部分自動存入。累積達 NT$22,000 即啟動!

{/* 展覽進度條 */} {loadingSponsors ? (
) : ( <>
{exhibitionBarWidth > 12 && ( {exhibitionPercentage}% )}
展覽基金 NT${exhibitionFund.toLocaleString()} 解鎖 NT${EXHIBITION_GOAL.toLocaleString()}
)} {/* 展覽預算 */}

預算公開透明

{exhibitionBudget.map((item) => (
{item.item} {item.note}
{item.amount}
))}
展覽總計 NT$22,000
{/* === 贊助回饋 === */}

贊助回饋

{/* 前五名保障 */}
🏆

累計贊助前 5 名 — 展覽保障名額

你的攝影作品將保障入選展出,掛上實體展牆。從贊助者變成展覽的一份子。

{/* 500 元以上 */}
🎁

單筆或累計超過 NT$500

  • 展覽「感謝牆」署名 — 成為共同策展人
  • App 內「初代贊助者」專屬徽章
{/* 目前贊助排行 */} {sponsorRanking.length > 0 && (

展覽保障名額排行

{sponsorRanking.map((s, i) => (
{i + 1} {s.nickname}
NT${s.total.toLocaleString()}
))}
{sponsorRanking.length < 5 && (

還有 {5 - sponsorRanking.length} 個保障名額等你來拿

)}
)}
{/* 線上贊助 */}

線上贊助

選擇金額,信用卡線上付款

{SPONSOR_TIERS.map((tier) => ( ))}
{checkoutError &&

{checkoutError}

}

線上付款含 2.4% 金流手續費。想 100% 到帳?可用下方匯款方式。

{/* 定期定額贊助 */}

定期定額贊助

每月自動扣款,持續支持 SnapPath 運營

{MONTHLY_TIERS.map((tier) => ( ))}

可隨時取消,不綁約。透過信用卡每月自動扣款。

{/* 直接匯款 */}

直接匯款

零手續費,100% 到帳

銀行:國泰世華(013)

帳號:699-506-421-326

戶名:SnapPath

匯款後請填寫下方表單通知我們,方便確認入帳

{/* 匯款通知表單 */}

匯款通知

匯款後填寫此表單,我們確認入帳後會更新進度

setNickname(e.target.value)} placeholder="選填,預設為「匿名」" className="w-full px-4 py-3 bg-[#F5F5F0] border border-[#E5E5E0] rounded-xl text-[#2D2D2D] text-sm placeholder:text-[#999] focus:outline-none focus:border-[#D4715A] focus:ring-1 focus:ring-[#D4715A] transition-colors" />
setEmail(e.target.value)} placeholder="用於寄送感謝函及累計贊助紀錄" required className="w-full px-4 py-3 bg-[#F5F5F0] border border-[#E5E5E0] rounded-xl text-[#2D2D2D] text-sm placeholder:text-[#999] focus:outline-none focus:border-[#D4715A] focus:ring-1 focus:ring-[#D4715A] transition-colors" />
NT$ setAmount(e.target.value)} placeholder="輸入金額" required min="1" className="w-full pl-14 pr-4 py-3 bg-[#F5F5F0] border border-[#E5E5E0] rounded-xl text-[#2D2D2D] text-sm placeholder:text-[#999] focus:outline-none focus:border-[#D4715A] focus:ring-1 focus:ring-[#D4715A] transition-colors" />