Files
eryao/web/src/components/StorePage.tsx
T

167 lines
8.7 KiB
TypeScript
Raw Normal View History

import { useEffect, useState } from 'react';
import type { PointsBalance, PackageInfo } from '../lib/api';
import { getPointsBalance, getPackages, invalidatePointsCache } from '../lib/api';
2026-05-09 16:00:29 +08:00
interface Props {
locale: string;
dashboard: { brandName: string; navHome: string; navStore: string; navDivination: string; navManual: string; navAuto: string; navHistory: string; navLanguage: string; navSettings: string; logout: string };
store: { title: string; currentPoints: string; pointsLabel: string; rulesTitle: string; rule1: string; rule2: string; rule3: string; popularLabel: string; popularText: string; stepsTitle: string; step1: string; step2: string; step3: string; sideTitle: string; sideDesc: string };
pricing: { p1Name: string; p1Badge: string; p1Price: string; p1Credits: string; p1Desc: string; p2Name: string; p2Price: string; p2Credits: string; p2Desc: string; p3Name: string; p3Badge: string; p3Price: string; p3Credits: string; p3Desc: string; p4Name: string; p4Price: string; p4Credits: string; p4Desc: string; buyNow: string };
}
interface PackageDisplay {
name: string;
badge: string;
price: string;
credits: string;
desc: string;
featured: boolean;
productCode: string;
appStoreProductId: string;
starterEligible: boolean;
isStarter: boolean;
}
// Map product codes to display names from pricing translations
const PRODUCT_CODE_MAP: Record<string, string> = {
'new_user_pack': 'p1', // 新人专享包 60积分
'starter_pack': 'p2', // 入门补充包 100积分
'popular_pack': 'p3', // 常用加量包 210积分
'premium_pack': 'p4', // 高频进阶包 415积分
};
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">
<div className="w-12 h-12 rounded-xl bg-violet-50 flex items-center justify-center">
<span className="material-symbols-rounded text-violet-600 text-2xl">shopping_cart</span>
</div>
<p className="text-slate-900 text-lg font-bold">{s.sideTitle}</p>
<p className="text-slate-500 text-sm">{s.sideDesc}</p>
<div className="h-px bg-slate-100" />
<p className="text-slate-400 text-xs font-semibold">{s.popularLabel}</p>
<div className="bg-amber-50 rounded-xl p-3.5 text-amber-700 text-sm">{s.popularText}</div>
<p className="text-slate-400 text-xs font-semibold mt-2">{s.stepsTitle}</p>
<div className="flex items-center gap-2.5 text-sm text-slate-600"><span className="w-5 h-5 rounded-full bg-violet-100 text-violet-600 text-xs font-bold flex items-center justify-center">1</span>{s.step1}</div>
<div className="flex items-center gap-2.5 text-sm text-slate-600"><span className="w-5 h-5 rounded-full bg-violet-100 text-violet-600 text-xs font-bold flex items-center justify-center">2</span>{s.step2}</div>
<div className="flex items-center gap-2.5 text-sm text-slate-600"><span className="w-5 h-5 rounded-full bg-violet-100 text-violet-600 text-xs font-bold flex items-center justify-center">3</span>{s.step3}</div>
</div>
);
}
2026-05-09 16:00:29 +08:00
export default function StorePage({ store: s, pricing: p }: Props) {
const [points, setPoints] = useState<PointsBalance | null>(null);
const [packages, setPackages] = useState<PackageDisplay[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
Promise.all([
getPointsBalance(),
getPackages(),
])
.then(([pointsData, packagesData]) => {
setPoints(pointsData);
// Map backend packages to display format
const displayPkgs: PackageDisplay[] = packagesData.packages.map((pkg) => {
const key = PRODUCT_CODE_MAP[pkg.productCode] || 'p1';
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] || '',
credits: `${pkg.credits} ${s.pointsLabel}`,
desc: p[`${key}Desc` as keyof typeof p] || '',
featured: pkg.productCode === 'popular_pack', // 只有常用加量包是推荐
productCode: pkg.productCode,
appStoreProductId: pkg.appStoreProductId,
starterEligible: pkg.starterEligible,
isStarter: pkg.isStarter,
};
});
// Sort by sortOrder
displayPkgs.sort((a, b) => {
const pkgA = packagesData.packages.find(pkg => pkg.productCode === a.productCode);
const pkgB = packagesData.packages.find(pkg => pkg.productCode === b.productCode);
return (pkgA?.sortOrder || 0) - (pkgB?.sortOrder || 0);
});
setPackages(displayPkgs);
})
.catch(() => {
// Fallback to static data if API fails
setPoints({ balance: 0, frozenBalance: 0, availableBalance: 0, runCost: 20, canRun: false });
setPackages([]);
})
.finally(() => setLoading(false));
}, [p, s.pointsLabel]);
2026-05-09 16:00:29 +08:00
return (
<div className="flex flex-col gap-5 min-h-full">
<h1 className="text-slate-900 text-xl font-bold">{s.title}</h1>
2026-05-09 16:00:29 +08:00
{/* Top: Points hero + rules */}
<div className="flex flex-col lg:flex-row gap-5">
<div className="flex-1 rounded-2xl p-7 flex items-center gap-6" style={{ background: 'linear-gradient(135deg, #673AB7, #9C27B0)' }}>
<div className="w-[68px] h-[68px] rounded-full flex items-center justify-center" style={{ background: 'rgba(255,255,255,0.14)' }}>
<span className="material-symbols-rounded text-white text-3xl">account_balance_wallet</span>
</div>
<div>
<p className="text-violet-200 text-sm">{s.currentPoints}</p>
<p className="text-white text-3xl font-bold">
{loading ? '...' : points?.balance ?? 0}
<span className="text-base font-normal text-violet-200"> {s.pointsLabel}</span>
</p>
2026-05-09 16:00:29 +08:00
</div>
</div>
<div className="w-full lg:w-[320px] bg-white rounded-2xl p-5 border border-slate-200 flex flex-col gap-3.5 shrink-0">
<div className="flex items-center gap-2.5">
<span className="material-symbols-rounded text-violet-600 text-lg">info</span>
<span className="text-slate-900 text-sm font-bold">{s.rulesTitle}</span>
</div>
<p className="text-slate-500 text-sm">{s.rule1}</p>
<p className="text-slate-500 text-sm">{s.rule2}</p>
<p className="text-slate-500 text-sm">{s.rule3}</p>
</div>
</div>
{/* Mobile: Side panel below rules (visible only on mobile) */}
<div className="xl:hidden">
<SidePanel s={s} />
</div>
{/* Body: Packages + side panel (desktop) */}
2026-05-09 16:00:29 +08:00
<div className="flex flex-col xl:flex-row gap-5 flex-1 min-h-0">
<div className="flex-1 flex flex-col gap-4 overflow-y-auto">
{loading ? (
<div className="text-slate-500 text-center py-8">{s.sideDesc}</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{packages.map((pkg) => (
<div key={pkg.productCode} className={`bg-white rounded-2xl p-6 flex flex-col gap-3 border ${pkg.featured ? 'border-violet-400 ring-1 ring-violet-100' : 'border-slate-200'}`}>
<div className="flex items-center justify-between">
<span className="text-slate-900 font-bold text-base">{pkg.name}</span>
{pkg.badge && <span className={`text-xs px-2.5 py-0.5 rounded-full ${pkg.featured ? 'bg-violet-600 text-white' : 'bg-amber-100 text-amber-700'}`}>{pkg.badge}</span>}
</div>
<p className="text-slate-900 text-2xl font-extrabold">{pkg.price}</p>
<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}
>
{pkg.isStarter && !pkg.starterEligible ? (s.rulesTitle.includes('已购') ? '已购买' : 'Purchased') : p.buyNow}
</button>
2026-05-09 16:00:29 +08:00
</div>
))}
</div>
)}
2026-05-09 16:00:29 +08:00
</div>
{/* Desktop: Side panel (visible only on xl+) */}
<div className="hidden xl:block">
<SidePanel s={s} />
2026-05-09 16:00:29 +08:00
</div>
</div>
</div>
);
}