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:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user