276 lines
10 KiB
TypeScript
276 lines
10 KiB
TypeScript
import { Button, Card, Empty, Form, Input, Select, Space, Spin, Tag } from 'antd';
|
||
import { SearchOutlined } from '@ant-design/icons';
|
||
import type { PointsMallProduct } from '../../../api';
|
||
import type { PointsMallPageLogicOutput } from '../PointsMallPageLogic';
|
||
import ExchangeModal from '../components/ExchangeModal';
|
||
import ConfirmExchangeModal from '../components/ConfirmExchangeModal';
|
||
import type { ExchangeFormValues } from '../types';
|
||
|
||
interface Props {
|
||
logic: PointsMallPageLogicOutput;
|
||
}
|
||
|
||
export default function PointsMallPageWeb({ logic }: Props) {
|
||
const [exchangeForm] = Form.useForm<ExchangeFormValues>();
|
||
const {
|
||
overview,
|
||
overviewLoading,
|
||
categories,
|
||
categoryId,
|
||
q,
|
||
sort,
|
||
page,
|
||
pageSize,
|
||
productsLoading,
|
||
products,
|
||
total,
|
||
userPoints,
|
||
totalSpentUSD,
|
||
banner,
|
||
promoEntries,
|
||
exchangeModalVisible,
|
||
confirmModalVisible,
|
||
selectedProduct,
|
||
exchangeQuantity,
|
||
pendingExpiresAt,
|
||
exchangeLoading,
|
||
setCategoryId,
|
||
setQ,
|
||
setSort,
|
||
setPage,
|
||
setPageSize,
|
||
handleExchangeClick,
|
||
setExchangeModalVisible,
|
||
setConfirmModalVisible,
|
||
} = logic;
|
||
|
||
return (
|
||
<div className="page-container" style={{ maxWidth: 1400 }}>
|
||
<div className="points-mall-hero">
|
||
<div className="points-mall-header">
|
||
<div className="points-mall-title-section">
|
||
<h1 className="page-title stats-page-title">积分商城</h1>
|
||
<p className="page-subtitle stats-page-subtitle">使用积分兑换权益、工具和活动礼包。积分通过 API 调用消费自动累积,1 美元 = 1000 积分。</p>
|
||
</div>
|
||
<div className="points-mall-balance-card">
|
||
{overviewLoading ? (
|
||
<Spin />
|
||
) : (
|
||
<div className="points-balance-row">
|
||
<div>
|
||
<div className="points-balance-label">我的积分</div>
|
||
<div className="points-balance-value">{userPoints.toLocaleString()}</div>
|
||
<div className="points-balance-subtext">
|
||
累计消费 ${typeof totalSpentUSD === 'number' ? totalSpentUSD.toFixed(2) : '--'}
|
||
</div>
|
||
</div>
|
||
<Tag
|
||
bordered={false}
|
||
style={{ margin: 0, borderRadius: 999, background: 'var(--color-brand-soft)', color: 'var(--color-brand)' }}
|
||
>
|
||
{String(overview?.me?.level || 'Lv.0')}
|
||
</Tag>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Card className="points-mall-category-card" bodyStyle={{ padding: 0 }}>
|
||
<div className="points-mall-category-body">
|
||
<div className="points-mall-category-row">
|
||
<span className="points-mall-category-label">商品分类</span>
|
||
{categories.map((c) => (
|
||
<Button
|
||
key={c.id}
|
||
size="small"
|
||
type={c.id === categoryId ? 'primary' : 'default'}
|
||
style={{ borderRadius: 999 }}
|
||
onClick={() => {
|
||
setPage(1);
|
||
setCategoryId(c.id);
|
||
}}
|
||
>
|
||
{c.name}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
<div className="points-mall-banner-section">
|
||
<div className="points-mall-banner-card">
|
||
<div className="points-mall-banner-header">
|
||
<div>
|
||
<div className="points-mall-banner-title">{banner?.title || '本期活动'}</div>
|
||
<div className="points-mall-banner-subtitle">{banner?.subtitle || 'Up to 25% Off'}</div>
|
||
</div>
|
||
<Button type="primary" style={{ borderRadius: 12, height: 40, fontWeight: 600 }}>
|
||
查看活动
|
||
</Button>
|
||
</div>
|
||
<div className="points-mall-banner-footer">banner 图片与跳转链接由后端配置</div>
|
||
</div>
|
||
|
||
<div className="points-mall-promo-grid">
|
||
{promoEntries.slice(0, 2).map((p) => (
|
||
<div key={p.id} className="points-mall-promo-card">
|
||
<div style={{ minWidth: 0 }}>
|
||
<div className="points-mall-promo-title">{p.title}</div>
|
||
<div className="points-mall-promo-subtitle">{p.subtitle}</div>
|
||
</div>
|
||
<Button size="small" className="points-mall-exchange-btn">
|
||
进入
|
||
</Button>
|
||
</div>
|
||
))}
|
||
{promoEntries.length < 2 && <div className="points-mall-promo-empty">促销入口由后端配置</div>}
|
||
</div>
|
||
</div>
|
||
|
||
<Card className="stats-page-chart-card">
|
||
<div className="points-mall-filters-row">
|
||
<Space size={10} wrap className="points-mall-filters-left">
|
||
<Input
|
||
value={q}
|
||
onChange={(e) => {
|
||
setPage(1);
|
||
setQ(e.target.value);
|
||
}}
|
||
prefix={<SearchOutlined />}
|
||
placeholder="搜索商品"
|
||
allowClear
|
||
className="points-mall-search-input"
|
||
/>
|
||
<Select
|
||
value={sort}
|
||
className="points-mall-filter-select"
|
||
onChange={(v) => {
|
||
setPage(1);
|
||
setSort(v);
|
||
}}
|
||
options={[
|
||
{ value: 'popular', label: '热度优先' },
|
||
{ value: 'newest', label: '最新上架' },
|
||
{ value: 'price_asc', label: '积分从低到高' },
|
||
{ value: 'price_desc', label: '积分从高到低' },
|
||
]}
|
||
/>
|
||
<Select
|
||
value={pageSize}
|
||
className="points-mall-filter-select-small"
|
||
onChange={(v) => {
|
||
setPage(1);
|
||
setPageSize(v);
|
||
}}
|
||
options={[
|
||
{ value: 12, label: '每页 12' },
|
||
{ value: 24, label: '每页 24' },
|
||
{ value: 48, label: '每页 48' },
|
||
]}
|
||
/>
|
||
</Space>
|
||
<div className="points-mall-total-text">共 {total.toLocaleString()} 件商品</div>
|
||
</div>
|
||
|
||
{productsLoading ? (
|
||
<Spin style={{ marginTop: 40, display: 'block' }} />
|
||
) : products.length === 0 ? (
|
||
<Empty description="暂无商品" style={{ marginTop: 60 }} />
|
||
) : (
|
||
<div className="points-mall-products-grid">
|
||
{products.map((p: PointsMallProduct) => (
|
||
<div key={p.id} className="points-mall-product-card">
|
||
<div className="points-mall-product-cover" />
|
||
<div className="points-mall-product-body">
|
||
<div className="points-mall-product-header">
|
||
<div className="points-mall-product-info">
|
||
<div className="points-mall-product-name">{p.name}</div>
|
||
<div className="points-mall-product-desc">{p.subtitle}</div>
|
||
</div>
|
||
{p.tags?.length ? (
|
||
<Tag bordered={false} className="points-mall-product-tag">
|
||
{p.tags[0]}
|
||
</Tag>
|
||
) : null}
|
||
</div>
|
||
<div className="points-mall-product-price-row">
|
||
<div>
|
||
<span className="points-mall-product-price">{Number(p.pointsPrice).toLocaleString()}</span>
|
||
<span className="points-mall-product-price-label">积分</span>
|
||
</div>
|
||
<Button
|
||
type="primary"
|
||
className="points-mall-exchange-btn points-mall-product-exchange-btn"
|
||
disabled={userPoints < p.pointsPrice || exchangeLoading}
|
||
onClick={() => handleExchangeClick(p)}
|
||
>
|
||
{userPoints < p.pointsPrice ? '积分不足' : '兑换'}
|
||
</Button>
|
||
</div>
|
||
<div className="points-mall-product-footer">
|
||
<span>库存 {p.stock}</span>
|
||
<span>已兑 {p.sold}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{!!total && (
|
||
<div className="points-mall-pagination">
|
||
<Space size={10}>
|
||
<Button disabled={page <= 1} onClick={() => setPage((v) => Math.max(1, v - 1))} className="points-mall-exchange-btn">
|
||
上一页
|
||
</Button>
|
||
<Tag bordered={false} className="points-mall-pagination-tag">
|
||
第 {page} 页
|
||
</Tag>
|
||
<Button disabled={page * pageSize >= total} onClick={() => setPage((v) => v + 1)} className="points-mall-exchange-btn">
|
||
下一页
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
|
||
<ExchangeModal
|
||
open={exchangeModalVisible}
|
||
product={selectedProduct}
|
||
quantity={exchangeQuantity}
|
||
expiresAt={pendingExpiresAt}
|
||
loading={exchangeLoading}
|
||
form={exchangeForm}
|
||
onSubmit={() => {
|
||
exchangeForm.validateFields().then((values) => logic.handleExchangeSubmit(values));
|
||
}}
|
||
onCancel={() => {
|
||
setExchangeModalVisible(false);
|
||
exchangeForm.resetFields();
|
||
logic.setPendingOrderId(null);
|
||
logic.setPendingExpiresAt(null);
|
||
logic.setSelectedProduct(null);
|
||
logic.setExchangeQuantity(1);
|
||
}}
|
||
/>
|
||
|
||
<ConfirmExchangeModal
|
||
open={confirmModalVisible}
|
||
product={selectedProduct}
|
||
userPoints={userPoints}
|
||
quantity={exchangeQuantity}
|
||
loading={exchangeLoading}
|
||
onQuantityChange={(v) => logic.setExchangeQuantity(Math.max(1, Math.floor(v || 1)))}
|
||
onConfirm={logic.handleConfirmExchange}
|
||
onCancel={() => {
|
||
if (exchangeLoading) return;
|
||
setConfirmModalVisible(false);
|
||
logic.setSelectedProduct(null);
|
||
logic.setExchangeQuantity(1);
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|