feat: integrate CREEM web payment for credits purchase

Replace abandoned iOS App Store route with CREEM Merchant of Record
payment integration for web-based credits purchase.

Backend changes:
- Add CreemClient for CREEM API communication
- Add CreemService for checkout creation and webhook handling
- Add creem_transactions table for payment tracking
- Fix webhook payload parsing (id, order.id, customer.id structure)
- Integrate with existing points ledger system

Frontend changes:
- Display dynamic prices from CREEM API
- Support decimal price formatting (e.g., $1.00)
- Add checkout flow with redirect to CREEM hosted page
This commit is contained in:
zl-q
2026-05-11 18:38:21 +08:00
parent 3ff33640f4
commit f07e307e82
25 changed files with 989 additions and 45 deletions
+40 -6
View File
@@ -182,7 +182,7 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
const cats = useMemo(() => d.categories.split(','), [d.categories]);
const navigate = useNavigate();
const [category, setCategory] = useState<string>(cats[0]);
const [question, setQuestion] = useState<string>(text.defaultQuestion);
const [question, setQuestion] = useState<string>('');
const [selectedTime, setSelectedTime] = useState(() => formatDateTimeInput(new Date()));
const [yaoResults, setYaoResults] = useState<YaoType[]>([]);
const [guideStep, setGuideStep] = useState<number | null>(null);
@@ -190,6 +190,7 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
const points = pointsState.data ?? null;
const [showProcessing, setShowProcessing] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const { userProfile, setUserProfile } = useUserSettings();
// Shake state
@@ -436,6 +437,14 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
}
};
const handleError = (error: Error) => {
setShowProcessing(false);
setErrorMessage(error.message || 'Unknown error');
};
// Check if user has enough points
const hasEnoughPoints = points && points.availableBalance >= (points.runCost ?? 20);
return (
<div ref={scrollContainerRef} className="relative flex min-h-full flex-col gap-[22px]">
<div className="flex items-center justify-between gap-5">
@@ -482,7 +491,7 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
<textarea
value={question}
onChange={(event) => setQuestion(event.target.value)}
placeholder={d.questionPlaceholder}
placeholder={text.defaultQuestion}
className="min-h-0 flex-1 resize-none rounded-[10px] border border-slate-300 bg-white px-3.5 py-3 text-sm text-[#333333] outline-none focus:border-violet-500"
/>
</section>
@@ -612,12 +621,10 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
<>
<div
className="fixed inset-0 z-40 hidden bg-black/70 xl:block"
onClick={() => closeGuide()}
/>
<div
className="absolute inset-0 z-40 xl:hidden"
style={{ top: 0, height: '100vh' }}
onClick={() => closeGuide()}
/>
<div
@@ -705,10 +712,15 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
</div>
<div className="border-t border-slate-200 pt-3 flex justify-between text-sm">
<span className="text-slate-500">{text.confirmRemaining}</span>
<span className="text-slate-900 font-bold">
<span className={`font-bold ${hasEnoughPoints ? 'text-slate-900' : 'text-red-500'}`}>
{(points?.availableBalance ?? 0) - (points?.runCost ?? 20)}
</span>
</div>
{!hasEnoughPoints && (
<p className="text-red-500 text-sm font-medium">
{locale === 'en' ? 'Insufficient credits. Please purchase more.' : '积分不足,请先充值。'}
</p>
)}
</div>
<div className="flex gap-3">
<button
@@ -719,7 +731,12 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
</button>
<button
onClick={handleConfirm}
className="flex-1 h-11 rounded-full bg-violet-600 text-sm font-bold text-white hover:bg-violet-700 transition-colors"
disabled={!hasEnoughPoints}
className={`flex-1 h-11 rounded-full text-sm font-bold text-white transition-colors ${
hasEnoughPoints
? 'bg-violet-600 hover:bg-violet-700'
: 'bg-slate-300 cursor-not-allowed'
}`}
>
{text.confirm}
</button>
@@ -740,9 +757,26 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
}}
yaoStates={yaoResults}
onComplete={handleComplete}
onError={handleError}
/>
)}
{/* Error dialog */}
{errorMessage && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-2xl p-6 w-[400px] max-w-[90vw] flex flex-col gap-5 shadow-xl">
<h3 className="text-red-600 text-lg font-bold">{locale === 'en' ? 'Error' : '出错了'}</h3>
<p className="text-sm text-slate-600">{errorMessage}</p>
<button
onClick={() => setErrorMessage(null)}
className="h-11 w-full rounded-full bg-slate-100 text-sm font-bold text-slate-700 hover:bg-slate-200 transition-colors"
>
{locale === 'en' ? 'Close' : '关闭'}
</button>
</div>
</div>
)}
{/* Coin spin animation */}
<style>{`
@keyframes coin-spin {
+2 -2
View File
@@ -103,8 +103,8 @@ export default function Dashboard({ locale, translations: i18n }: DashboardProps
{i18n.heroCta}
</a>
{availablePoints !== undefined && (
<span className="text-violet-100 text-sm">
{locale === 'en' ? 'Available credits' : '可用积分'}: <strong className="text-white">{availablePoints}</strong>
<span className="text-violet-100 text-base">
{locale === 'en' ? 'Available credits' : '可用积分'}: <strong className="text-white text-xl">{availablePoints}</strong>
</span>
)}
</div>
+40 -6
View File
@@ -194,7 +194,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
const cats = useMemo(() => d.categories.split(','), [d.categories]);
const navigate = useNavigate();
const [category, setCategory] = useState<string>(cats[0]);
const [question, setQuestion] = useState<string>(text.defaultQuestion);
const [question, setQuestion] = useState<string>('');
const [selectedTime, setSelectedTime] = useState(() => formatDateTimeInput(new Date()));
const [coins, setCoins] = useState<[CoinFace, CoinFace, CoinFace]>(['zi', 'zi', 'zi']);
const [yaoResults, setYaoResults] = useState<YaoType[]>([]);
@@ -204,6 +204,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [showProcessing, setShowProcessing] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const { userProfile, setUserProfile } = useUserSettings();
// Refs for guide spotlight positioning
@@ -455,6 +456,14 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
}
};
const handleError = (error: Error) => {
setShowProcessing(false);
setErrorMessage(error.message || 'Unknown error');
};
// Check if user has enough points
const hasEnoughPoints = points && points.availableBalance >= (points.runCost ?? 20);
return (
<div ref={scrollContainerRef} className="relative flex min-h-full flex-col gap-[22px]">
<div className="flex items-center justify-between gap-5">
@@ -501,7 +510,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
<textarea
value={question}
onChange={(event) => setQuestion(event.target.value)}
placeholder={d.questionPlaceholder}
placeholder={text.defaultQuestion}
className="min-h-0 flex-1 resize-none rounded-[10px] border border-slate-300 bg-white px-3.5 py-3 text-sm text-[#333333] outline-none focus:border-violet-500"
/>
</section>
@@ -613,13 +622,11 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
{/* Dark overlay - fixed for desktop, covers viewport */}
<div
className="fixed inset-0 z-40 hidden bg-black/70 xl:block"
onClick={() => closeGuide()}
/>
{/* Mobile dark overlay - positioned within scroll container */}
<div
className="absolute inset-0 z-40 xl:hidden"
style={{ top: 0, height: '100vh' }}
onClick={() => closeGuide()}
/>
{/* Spotlight on target element - fixed for desktop, absolute for mobile */}
@@ -710,10 +717,15 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
</div>
<div className="border-t border-slate-200 pt-3 flex justify-between text-sm">
<span className="text-slate-500">{text.confirmRemaining}</span>
<span className="text-slate-900 font-bold">
<span className={`font-bold ${hasEnoughPoints ? 'text-slate-900' : 'text-red-500'}`}>
{(points?.availableBalance ?? 0) - (points?.runCost ?? 20)}
</span>
</div>
{!hasEnoughPoints && (
<p className="text-red-500 text-sm font-medium">
{locale === 'en' ? 'Insufficient credits. Please purchase more.' : '积分不足,请先充值。'}
</p>
)}
</div>
<div className="flex gap-3">
<button
@@ -724,7 +736,12 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
</button>
<button
onClick={handleConfirm}
className="flex-1 h-11 rounded-full bg-violet-600 text-sm font-bold text-white hover:bg-violet-700 transition-colors"
disabled={!hasEnoughPoints}
className={`flex-1 h-11 rounded-full text-sm font-bold text-white transition-colors ${
hasEnoughPoints
? 'bg-violet-600 hover:bg-violet-700'
: 'bg-slate-300 cursor-not-allowed'
}`}
>
{text.confirm}
</button>
@@ -745,8 +762,25 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
}}
yaoStates={yaoResults}
onComplete={handleComplete}
onError={handleError}
/>
)}
{/* Error dialog */}
{errorMessage && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-2xl p-6 w-[400px] max-w-[90vw] flex flex-col gap-5 shadow-xl">
<h3 className="text-red-600 text-lg font-bold">{locale === 'en' ? 'Error' : '出错了'}</h3>
<p className="text-sm text-slate-600">{errorMessage}</p>
<button
onClick={() => setErrorMessage(null)}
className="h-11 w-full rounded-full bg-slate-100 text-sm font-bold text-slate-700 hover:bg-slate-200 transition-colors"
>
{locale === 'en' ? 'Close' : '关闭'}
</button>
</div>
</div>
)}
</div>
);
}
+19 -9
View File
@@ -1,4 +1,5 @@
import { logout, getAuth } from '../lib/auth';
import { useState } from 'react';
import { logout, getAuth, clearAuth, redirectToLogin } from '../lib/auth';
import { usePoints, useProfile } from '../lib/resources';
interface Props {
@@ -13,13 +14,19 @@ export default function SettingsPage({ locale, settings: s }: Props) {
const profile = profileState.data ?? null;
const points = pointsState.data ?? null;
const loading = profileState.loading || pointsState.loading;
const [logoutLoading, setLogoutLoading] = useState(false);
const handleLogout = () => {
if (confirm(s.logoutConfirm)) {
logout().finally(() => {
window.location.href = `/${locale}/login`;
});
}
const handleLogout = async () => {
if (logoutLoading) return;
if (!confirm(s.logoutConfirm)) return;
setLogoutLoading(true);
// Clear local auth immediately and redirect
clearAuth();
// Fire backend logout in background (don't wait)
logout().catch(() => {});
// Redirect to login
redirectToLogin();
};
const authEmail = getAuth()?.user?.email;
@@ -198,9 +205,12 @@ export default function SettingsPage({ locale, settings: s }: Props) {
{/* Logout Button */}
<button
onClick={handleLogout}
className="bg-white rounded-2xl px-5 py-3.5 border border-red-200 flex items-center justify-between hover:bg-red-50 transition-colors"
disabled={logoutLoading}
className={`bg-white rounded-2xl px-5 py-3.5 border border-red-200 flex items-center justify-between hover:bg-red-50 transition-colors ${logoutLoading ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span className="text-red-500 text-sm font-medium">{s.logout}</span>
<span className="text-red-500 text-sm font-medium">
{logoutLoading ? (locale === 'en' ? 'Logging out...' : '退出中...') : s.logout}
</span>
<span className="material-symbols-rounded text-red-400 text-lg">logout</span>
</button>
</div>
+37 -7
View File
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { usePackages, usePoints } from '../lib/resources';
import { createCheckout } from '../lib/api';
interface Props {
locale: string;
@@ -16,7 +17,7 @@ interface PackageDisplay {
desc: string;
featured: boolean;
productCode: string;
appStoreProductId: string;
creemProductId: string | null;
starterEligible: boolean;
isStarter: boolean;
}
@@ -29,6 +30,18 @@ const PRODUCT_CODE_MAP: Record<string, string> = {
'premium_pack': 'p4', // 高频进阶包 415积分
};
// Format price from cents to display string
function formatPrice(cents: number | null, currency: string | null): string {
if (cents === null || currency === null) return '';
const dollars = cents / 100;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(dollars);
}
function SidePanel({ s }: { s: Props['store'] }) {
return (
<div className="w-full xl:w-[320px] bg-white rounded-2xl p-5 border border-slate-200 flex flex-col gap-4 shrink-0 overflow-y-auto">
@@ -52,20 +65,23 @@ export default function StorePage({ store: s, pricing: p }: Props) {
const pointsState = usePoints();
const packagesState = usePackages();
const points = pointsState.data ?? null;
const [purchasing, setPurchasing] = useState<string | null>(null);
const packages = useMemo<PackageDisplay[]>(() => {
const packagesData = packagesState.data;
if (!packagesData) return [];
const displayPkgs: PackageDisplay[] = packagesData.packages.map((pkg) => {
const key = PRODUCT_CODE_MAP[pkg.productCode] || 'p1';
const dynamicPrice = formatPrice(pkg.priceCents, pkg.currency);
return {
name: p[`${key}Name` as keyof typeof p] || pkg.productCode,
badge: pkg.isStarter ? (pkg.starterEligible ? p.p1Badge : '') : '',
price: p[`${key}Price` as keyof typeof p] || '',
price: dynamicPrice,
credits: `${pkg.credits} ${s.pointsLabel}`,
desc: p[`${key}Desc` as keyof typeof p] || '',
featured: pkg.productCode === 'popular_pack',
productCode: pkg.productCode,
appStoreProductId: pkg.appStoreProductId,
creemProductId: pkg.creemProductId,
starterEligible: pkg.starterEligible,
isStarter: pkg.isStarter,
};
@@ -77,8 +93,21 @@ export default function StorePage({ store: s, pricing: p }: Props) {
});
return displayPkgs;
}, [packagesState.data, p, s.pointsLabel]);
const loading = pointsState.loading || packagesState.loading;
const handleBuy = async (pkg: PackageDisplay) => {
if (!pkg.creemProductId) return;
setPurchasing(pkg.productCode);
try {
const result = await createCheckout(pkg.productCode);
window.location.href = result.checkoutUrl;
} catch (error) {
console.error('Failed to create checkout:', error);
setPurchasing(null);
}
};
return (
<div className="flex flex-col gap-5 min-h-full">
<h1 className="text-slate-900 text-xl font-bold">{s.title}</h1>
@@ -130,10 +159,11 @@ export default function StorePage({ store: s, pricing: p }: Props) {
<p className="text-violet-600 text-sm font-medium">{pkg.credits}</p>
<p className="text-slate-500 text-sm">{pkg.desc}</p>
<button
className={`w-full py-2.5 rounded-lg font-semibold text-sm mt-auto ${pkg.featured ? 'bg-violet-600 text-white hover:bg-violet-700' : 'bg-white text-violet-600 border border-violet-200 hover:bg-violet-50'} transition-colors`}
disabled={pkg.isStarter && !pkg.starterEligible}
className={`w-full py-2.5 rounded-lg font-semibold text-sm mt-auto ${pkg.featured ? 'bg-violet-600 text-white hover:bg-violet-700' : 'bg-white text-violet-600 border border-violet-200 hover:bg-violet-50'} transition-colors disabled:opacity-50 disabled:cursor-not-allowed`}
disabled={pkg.isStarter && !pkg.starterEligible || purchasing === pkg.productCode || !pkg.creemProductId}
onClick={() => handleBuy(pkg)}
>
{pkg.isStarter && !pkg.starterEligible ? (s.rulesTitle.includes('已购') ? '已购买' : 'Purchased') : p.buyNow}
{pkg.isStarter && !pkg.starterEligible ? (s.rulesTitle.includes('已购') ? '已购买' : 'Purchased') : purchasing === pkg.productCode ? '...' : p.buyNow}
</button>
</div>
))}