feat(catalog): add product fulfillment flags and auto SKU

Enable online/cross-city/on-site purchase switches with trade gates, HQ and proxy UI, and server-generated DK SKUs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-02 18:10:16 +08:00
parent 49a5c057db
commit 847a3f0dc3
15 changed files with 601 additions and 101 deletions
@@ -86,6 +86,22 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
.finally(() => setLoadingOptions(false));
}, [open]);
const selectedProduct = options?.products.find((p) => p.id === productId);
const allowOnline = selectedProduct ? selectedProduct.allowOnlinePurchase !== false : true;
const allowOnSite = !!selectedProduct?.allowOnSitePickup;
const allowCrossCity = selectedProduct ? selectedProduct.allowCrossCityDelivery !== false : true;
useEffect(() => {
if (!open || !selectedProduct) return;
if (!allowOnline && allowOnSite && deliveryMode !== 'ON_SITE_PICKUP') {
setDeliveryMode('ON_SITE_PICKUP');
return;
}
if (allowOnline && !allowOnSite && deliveryMode !== 'ADDRESS') {
setDeliveryMode('ADDRESS');
}
}, [open, selectedProduct, allowOnline, allowOnSite, deliveryMode]);
useEffect(() => {
if (!open || !productId || quantity < 1 || step !== 'form') {
if (step === 'form') setPreview(null);
@@ -128,7 +144,6 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
setCreated(null);
setCodeUrl(null);
setPayLoading(false);
setMockConfirming(false);
}
function handleClose() {
@@ -140,12 +155,12 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
if (!productId) return '请选择商品';
if (deliveryMode === 'ADDRESS') {
if (!allowOnline) return '该商品不支持线上购买';
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
if (!addressDetail.trim()) return '请填写详细地址';
if (!autoReceive) return '配送到址须勾选同意自动收货';
} else {
const product = options?.products.find((p) => p.id === productId);
if (product && product.allowOnSitePickup === false) return '该商品不支持现场提货';
} else if (!allowOnSite) {
return '该商品不支持现场提货';
}
if (!preview) return '请等待费用计算完成';
return null;
@@ -329,20 +344,32 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
) : null}
<Form.Item label="履约方式" required>
<Radio.Group
value={deliveryMode}
onChange={(e) => {
setDeliveryMode(e.target.value);
if (e.target.value !== 'ADDRESS') setAutoReceive(false);
}}
options={[
{ value: 'ADDRESS', label: '配送到址' },
{ value: 'ON_SITE_PICKUP', label: '现场提货' },
]}
/>
{allowOnline && allowOnSite ? (
<Radio.Group
value={deliveryMode}
onChange={(e) => {
setDeliveryMode(e.target.value);
if (e.target.value !== 'ADDRESS') setAutoReceive(false);
}}
options={[
{ value: 'ADDRESS', label: '配送到址' },
{ value: 'ON_SITE_PICKUP', label: '现场提货' },
]}
/>
) : (
<Typography.Text type="secondary">
{!allowOnline && allowOnSite
? '该商品仅支持现场提货'
: allowOnline && !allowOnSite
? allowCrossCity
? '该商品仅支持配送到址(含跨城)'
: '该商品仅支持配送到址(不可跨城)'
: '该商品暂无可选履约方式'}
</Typography.Text>
)}
</Form.Item>
{deliveryMode === 'ADDRESS' ? (
{deliveryMode === 'ADDRESS' && allowOnline ? (
<>
<Form.Item label="收货人(选填)">
<Input
+71 -13
View File
@@ -32,6 +32,8 @@ type Row = {
status: string;
sortOrder: number;
allowOnSitePickup?: boolean;
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[];
mainImageUrl?: string | null;
@@ -53,6 +55,8 @@ type ProductFormValues = {
status?: string;
sortOrder?: number;
allowOnSitePickup?: boolean;
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[];
coverUrl?: string;
@@ -72,13 +76,17 @@ type UserPickRow = {
function mapDetailToForm(d: Record<string, unknown>) {
const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
const row = d as Row;
return {
...d,
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
carouselUrls: ((d as Row).carouselUrls?.length ? (d as Row).carouselUrls : ['']) as string[],
detailImageUrls: ((d as Row).detailImageUrls?.length ? (d as Row).detailImageUrls : ['']) as string[],
visibilityWhitelistEnabled: !!(d as Row).visibilityWhitelistEnabled,
visibilityPhones: ((d as Row).visibilityPhones ?? []) as string[],
carouselUrls: (row.carouselUrls?.length ? row.carouselUrls : ['']) as string[],
detailImageUrls: (row.detailImageUrls?.length ? row.detailImageUrls : ['']) as string[],
allowOnlinePurchase: row.allowOnlinePurchase !== false,
allowCrossCityDelivery: row.allowOnlinePurchase === false ? false : row.allowCrossCityDelivery !== false,
allowOnSitePickup: !!row.allowOnSitePickup,
visibilityWhitelistEnabled: !!row.visibilityWhitelistEnabled,
visibilityPhones: (row.visibilityPhones ?? []) as string[],
storyTitle: detail.storyTitle ?? '',
storyText: detail.storyText ?? '',
features: detail.features?.length
@@ -109,7 +117,6 @@ function buildProductPayload(v: ProductFormValues) {
.filter(Boolean);
return {
skuCode: v.skuCode,
barcode69: v.barcode69,
name: v.name,
subtitle: v.subtitle,
@@ -120,6 +127,9 @@ function buildProductPayload(v: ProductFormValues) {
status: v.status,
sortOrder: v.sortOrder,
allowOnSitePickup: !!v.allowOnSitePickup,
allowOnlinePurchase: v.allowOnlinePurchase !== false,
allowCrossCityDelivery:
v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false,
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
visibilityPhones,
coverUrl: v.coverUrl,
@@ -288,8 +298,8 @@ function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormIns
<>
{mode === 'create' && (
<>
<Form.Item name="skuCode" label="SKU" rules={[{ required: true }]}>
<Input />
<Form.Item label="SKU">
<Input disabled placeholder="保存后自动生成" />
</Form.Item>
<Form.Item name="barcode69" label="69码" rules={[{ required: true }]}>
<Input />
@@ -320,6 +330,36 @@ function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormIns
<Form.Item name="sortOrder" label="排序">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="allowOnlinePurchase"
label="允许线上购买"
valuePropName="checked"
extra="配送到址(同城)"
>
<Switch
checkedChildren="开"
unCheckedChildren="关"
onChange={(checked) => {
if (!checked) form.setFieldsValue({ allowCrossCityDelivery: false });
}}
/>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.allowOnlinePurchase !== cur.allowOnlinePurchase}>
{() => (
<Form.Item
name="allowCrossCityDelivery"
label="允许跨城配送"
valuePropName="checked"
extra="须先开启线上购买"
>
<Switch
checkedChildren="开"
unCheckedChildren="关"
disabled={!form.getFieldValue('allowOnlinePurchase')}
/>
</Form.Item>
)}
</Form.Item>
<Form.Item name="allowOnSitePickup" label="允许现场取货" valuePropName="checked">
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
@@ -383,10 +423,19 @@ export default function ProductsPage() {
v ? <Tag color="orange">{row.visibilityPhones?.length ?? 0}</Tag> : <Tag></Tag>,
},
{
title: '现场取货',
dataIndex: 'allowOnSitePickup',
width: 90,
render: (v: boolean) => (v ? <Tag color="green"></Tag> : <Tag></Tag>),
title: '履约',
key: 'fulfillment',
width: 160,
render: (_, row) => (
<Space size={[0, 4]} wrap>
{row.allowOnlinePurchase !== false ? <Tag color="blue">线</Tag> : null}
{row.allowOnlinePurchase !== false && row.allowCrossCityDelivery !== false ? (
<Tag color="cyan"></Tag>
) : null}
{row.allowOnSitePickup ? <Tag color="green"></Tag> : null}
{row.allowOnlinePurchase === false && !row.allowOnSitePickup ? <Tag></Tag> : null}
</Space>
),
},
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
@@ -431,12 +480,16 @@ export default function ProductsPage() {
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1240 }}
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1320 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="编辑商品" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && (
<Button type="primary" onClick={async () => {
const v = await editForm.validateFields();
if (!v.allowOnlinePurchase && !v.allowOnSitePickup) {
message.error('请至少勾选「允许线上购买」或「允许现场取货」之一');
return;
}
const payload = buildProductPayload(v);
await request(`/admin/products/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
message.success('已保存');
@@ -471,6 +524,10 @@ export default function ProductsPage() {
</Drawer>
<Modal title="新建商品" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
const v = await createForm.validateFields();
if (!v.allowOnlinePurchase && !v.allowOnSitePickup) {
message.error('请至少勾选「允许线上购买」或「允许现场取货」之一');
return;
}
const payload = buildProductPayload(v);
await request('/admin/products', { method: 'POST', body: JSON.stringify(payload) });
message.success('已创建');
@@ -479,7 +536,8 @@ export default function ProductsPage() {
void reload();
}} width={720}>
<Form form={createForm} layout="vertical" initialValues={{
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0, allowOnSitePickup: false,
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0,
allowOnlinePurchase: true, allowCrossCityDelivery: true, allowOnSitePickup: false,
visibilityWhitelistEnabled: false, visibilityPhones: [],
carouselUrls: [''], detailImageUrls: [''],
features: [{ icon: 'water_drop', title: '', desc: '' }],
+57 -21
View File
@@ -151,6 +151,22 @@ export default function ProxyOrderPage() {
autoReceive,
]);
const selectedProduct = options?.products.find((p) => p.id === productId);
const allowOnline = selectedProduct ? selectedProduct.allowOnlinePurchase !== false : true;
const allowOnSite = !!selectedProduct?.allowOnSitePickup;
const allowCrossCity = selectedProduct ? selectedProduct.allowCrossCityDelivery !== false : true;
useEffect(() => {
if (!selectedProduct) return;
if (!allowOnline && allowOnSite && deliveryMode !== 'ON_SITE_PICKUP') {
setDeliveryMode('ON_SITE_PICKUP');
return;
}
if (allowOnline && !allowOnSite && deliveryMode !== 'ADDRESS') {
setDeliveryMode('ADDRESS');
}
}, [selectedProduct, allowOnline, allowOnSite, deliveryMode]);
useEffect(() => {
if (!productId || quantity < 1) {
setPreview(null);
@@ -169,8 +185,14 @@ export default function ProxyOrderPage() {
}),
silent: true,
})
.then(setPreview)
.catch(() => setPreview(null))
.then((data) => {
setPreview(data);
setMsg('');
})
.catch((e) => {
setPreview(null);
setMsg(e instanceof Error ? e.message : '费用预览失败');
})
.finally(() => setPreviewLoading(false));
}, 300);
return () => clearTimeout(timer);
@@ -180,11 +202,14 @@ export default function ProxyOrderPage() {
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
if (!productId) return '请选择商品';
if (deliveryMode === 'ADDRESS') {
if (!allowOnline) return '该商品不支持线上购买';
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
if (!addressDetail.trim()) return '请填写详细地址';
if (!autoReceive) return '配送到址须勾选同意自动收货';
} else if (!allowOnSite) {
return '该商品不支持现场提货';
}
if (!preview) return '请等待费用计算完成';
if (!preview) return msg || '请等待费用计算完成';
return null;
}
@@ -305,7 +330,6 @@ export default function ProxyOrderPage() {
}
}
const selectedProduct = options?.products.find((p) => p.id === productId);
const deliveryLabel =
preview?.deliveryType === 'ON_SITE_PICKUP'
? '现场提货'
@@ -477,25 +501,37 @@ export default function ProxyOrderPage() {
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-proxy-mode-tabs">
<button
type="button"
className={`partner-proxy-mode-tab${deliveryMode === 'ADDRESS' ? ' is-active' : ''}`}
onClick={() => setDeliveryMode('ADDRESS')}
>
</button>
<button
type="button"
className={`partner-proxy-mode-tab${deliveryMode === 'ON_SITE_PICKUP' ? ' is-active' : ''}`}
onClick={() => setDeliveryMode('ON_SITE_PICKUP')}
>
</button>
</div>
{allowOnline && allowOnSite ? (
<div className="partner-proxy-mode-tabs">
<button
type="button"
className={`partner-proxy-mode-tab${deliveryMode === 'ADDRESS' ? ' is-active' : ''}`}
onClick={() => setDeliveryMode('ADDRESS')}
>
</button>
<button
type="button"
className={`partner-proxy-mode-tab${deliveryMode === 'ON_SITE_PICKUP' ? ' is-active' : ''}`}
onClick={() => setDeliveryMode('ON_SITE_PICKUP')}
>
</button>
</div>
) : (
<p className="label-md text-muted">
{!allowOnline && allowOnSite
? '该商品仅支持现场提货'
: allowOnline && !allowOnSite
? allowCrossCity
? '该商品仅支持配送到址(含跨城)'
: '该商品仅支持配送到址(不可跨城)'
: '该商品暂无可选履约方式'}
</p>
)}
</section>
{deliveryMode === 'ADDRESS' ? (
{deliveryMode === 'ADDRESS' && allowOnline ? (
<>
<section className="partner-form-section">
<label className="partner-form-label"></label>
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import { request } from '../lib/api';
@@ -9,12 +9,30 @@ function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
type FulfillmentFilter = 'ALL' | 'ONLINE' | 'CROSS_CITY' | 'ON_SITE';
const FILTERS: Array<{ key: FulfillmentFilter; label: string }> = [
{ key: 'ALL', label: '全部' },
{ key: 'ONLINE', label: '线上' },
{ key: 'CROSS_CITY', label: '跨城' },
{ key: 'ON_SITE', label: '现场' },
];
function matchFilter(p: PartnerProxyOrderProductOption, filter: FulfillmentFilter): boolean {
if (filter === 'ALL') return true;
if (filter === 'ONLINE') return p.allowOnlinePurchase !== false;
if (filter === 'CROSS_CITY') return p.allowCrossCityDelivery !== false;
return !!p.allowOnSitePickup;
}
export default function ProxyOrderProductsPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const selectedId = searchParams.get('selected') || '';
const [products, setProducts] = useState<PartnerProxyOrderProductOption[]>([]);
const [loading, setLoading] = useState(true);
const [keyword, setKeyword] = useState('');
const [filter, setFilter] = useState<FulfillmentFilter>('ALL');
useEffect(() => {
request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options')
@@ -23,6 +41,15 @@ export default function ProxyOrderProductsPage() {
.finally(() => setLoading(false));
}, []);
const filtered = useMemo(() => {
const q = keyword.trim().toLowerCase();
return products.filter((p) => {
if (!matchFilter(p, filter)) return false;
if (!q) return true;
return `${p.name} ${p.spec}`.toLowerCase().includes(q);
});
}, [products, keyword, filter]);
function pick(product: PartnerProxyOrderProductOption) {
navigate(`/proxy-order?productId=${encodeURIComponent(product.id)}`, { replace: true });
}
@@ -30,12 +57,32 @@ export default function ProxyOrderProductsPage() {
return (
<div className="page partner-proxy-order-page">
<PageHeader title="选择酒品" onBack={() => navigate(-1)} />
<div className="partner-proxy-product-toolbar">
<input
className="partner-input"
placeholder="搜索酒品名称 / 规格"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<div className="partner-proxy-product-filters">
{FILTERS.map((f) => (
<button
key={f.key}
type="button"
className={`partner-proxy-product-filter${filter === f.key ? ' is-active' : ''}`}
onClick={() => setFilter(f.key)}
>
{f.label}
</button>
))}
</div>
</div>
<main className="partner-proxy-product-list">
{loading ? <p className="label-md text-muted"></p> : null}
{!loading && products.length === 0 ? (
<p className="label-md text-muted"></p>
{!loading && filtered.length === 0 ? (
<p className="label-md text-muted"></p>
) : null}
{products.map((p) => {
{filtered.map((p) => {
const benefit = p.benefitAmount != null ? Number(p.benefitAmount) : Number(p.price);
const active = p.id === selectedId;
return (
@@ -59,6 +106,11 @@ export default function ProxyOrderProductsPage() {
</div>
<p className="partner-proxy-product-spec">{p.spec}</p>
<p className="partner-proxy-product-benefit"> ¥{fmtMoney(benefit)}</p>
<div className="partner-proxy-product-tags">
{p.allowOnlinePurchase !== false ? <span className="partner-proxy-tag">线</span> : null}
{p.allowCrossCityDelivery !== false ? <span className="partner-proxy-tag"></span> : null}
{p.allowOnSitePickup ? <span className="partner-proxy-tag"></span> : null}
</div>
</div>
</button>
);
+44
View File
@@ -3374,6 +3374,50 @@ body {
color: var(--color-muted, #999);
}
.partner-proxy-product-toolbar {
padding: 0 16px 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.partner-proxy-product-filters {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.partner-proxy-product-filter {
border: 1px solid var(--color-border);
background: #fff;
border-radius: 999px;
padding: 4px 12px;
font-size: 12px;
color: var(--color-muted, #888);
}
.partner-proxy-product-filter.is-active {
border-color: var(--color-heritage-red);
color: var(--color-heritage-red);
background: rgba(166, 29, 36, 0.06);
}
.partner-proxy-product-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.partner-proxy-tag {
font-size: 11px;
line-height: 16px;
padding: 1px 6px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.04);
color: var(--color-muted, #666);
}
.partner-proxy-product-list {
padding: 0 16px 24px;
display: flex;
+53 -12
View File
@@ -35,8 +35,26 @@ type Product = {
carouselUrls?: string[] | null;
aromaType: string;
allowOnSitePickup?: boolean;
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
};
type FulfillmentFilter = 'ALL' | 'ONLINE' | 'CROSS_CITY' | 'ON_SITE';
const FULFILLMENT_FILTERS: Array<{ key: FulfillmentFilter; label: string }> = [
{ key: 'ALL', label: '全部' },
{ key: 'ONLINE', label: '线上' },
{ key: 'CROSS_CITY', label: '跨城' },
{ key: 'ON_SITE', label: '现场' },
];
function matchFulfillmentFilter(p: Product, filter: FulfillmentFilter): boolean {
if (filter === 'ALL') return true;
if (filter === 'ONLINE') return p.allowOnlinePurchase !== false;
if (filter === 'CROSS_CITY') return p.allowCrossCityDelivery !== false;
return !!p.allowOnSitePickup;
}
type MiniHomeConfig = {
banners: string[];
footerUrl: string | null;
@@ -59,6 +77,7 @@ function aromaSectionId(key: AromaKey) {
export default function HomePage() {
const [activeAroma, setActiveAroma] = useState<AromaKey>('QINGXIANG');
const [fulfillmentFilter, setFulfillmentFilter] = useState<FulfillmentFilter>('ALL');
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [displayCity, setDisplayCity] = useState('郑州市');
@@ -144,18 +163,23 @@ export default function HomePage() {
Taro.navigateTo({ url: returnPath });
}
const filteredProducts = useMemo(
() => products.filter((p) => matchFulfillmentFilter(p, fulfillmentFilter)),
[products, fulfillmentFilter],
);
const productsByAroma = useMemo(() => {
const map: Record<AromaKey, Product[]> = {
QINGXIANG: [],
JIANGXIANG: [],
NONGXIANG: [],
};
for (const p of products) {
for (const p of filteredProducts) {
const key = p.aromaType as AromaKey;
if (key in map) map[key].push(p);
}
return map;
}, [products]);
}, [filteredProducts]);
const banners = miniHome.banners;
const footerUrl = miniHome.footerUrl;
@@ -255,15 +279,17 @@ export default function HomePage() {
</Text>
) : null}
<Text
className="home-buy-btn"
onClick={(e) => {
e.stopPropagation?.();
openProductDetail(p.id);
}}
>
</Text>
{p.allowOnlinePurchase !== false ? (
<Text
className="home-buy-btn"
onClick={(e) => {
e.stopPropagation?.();
openProductDetail(p.id);
}}
>
</Text>
) : null}
</View>
</View>
</View>
@@ -309,13 +335,28 @@ export default function HomePage() {
<Text className="home-aroma-city">{displayCity}</Text>
</View>
<View className="home-fulfillment-filters">
{FULFILLMENT_FILTERS.map((f) => (
<Text
key={f.key}
className={`home-fulfillment-chip${fulfillmentFilter === f.key ? ' home-fulfillment-chip--active' : ''}`}
onClick={() => setFulfillmentFilter(f.key)}
>
{f.label}
</Text>
))}
</View>
<View className="home-product-list">
{loading ? <View className="home-empty"></View> : null}
{!loading && products.length === 0 ? (
<View className="home-empty"></View>
) : null}
{!loading && products.length > 0 && filteredProducts.length === 0 ? (
<View className="home-empty"></View>
) : null}
{!loading &&
products.length > 0 &&
filteredProducts.length > 0 &&
AROMA_TABS.map((t) => {
const list = productsByAroma[t.key];
return (
@@ -31,6 +31,9 @@ type Product = ProductImageSource & {
benefitAmount?: number;
benefitDisplay?: number;
detailContent?: ProductDetailContentDto | null;
allowOnSitePickup?: boolean;
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
};
export default function ProductDetailPage() {
@@ -92,6 +95,21 @@ export default function ProductDetailPage() {
Taro.navigateTo({ url: returnPath });
}
async function goOnSitePickup() {
if (!productId) return;
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=1`;
if (!isLoggedIn()) {
goLogin(returnPath);
return;
}
const ready = await ensurePayReady(returnPath);
if (!ready) return;
Taro.navigateTo({ url: returnPath });
}
const allowOnline = product?.allowOnlinePurchase !== false;
const allowOnSite = !!product?.allowOnSitePickup;
if (!product) {
return (
<PageShell variant="scroll" className="product-detail-page">
@@ -199,9 +217,21 @@ export default function ProductDetailPage() {
<Image className="product-detail-bar-home-icon" src={iconHome} mode="aspectFit" />
<Text className="product-detail-bar-home-label"></Text>
</View>
<View className="product-detail-buy-btn" onClick={() => void goBuy()}>
<Text className="product-detail-buy-btn-text"></Text>
</View>
{allowOnSite ? (
<View className="product-detail-pickup-btn" onClick={() => void goOnSitePickup()}>
<Text className="product-detail-pickup-btn-text"></Text>
</View>
) : null}
{allowOnline ? (
<View className="product-detail-buy-btn" onClick={() => void goBuy()}>
<Text className="product-detail-buy-btn-text"></Text>
</View>
) : null}
{!allowOnline && !allowOnSite ? (
<View className="product-detail-buy-btn product-detail-buy-btn--disabled">
<Text className="product-detail-buy-btn-text"></Text>
</View>
) : null}
</View>
</PageShell>
);
+24
View File
@@ -103,6 +103,30 @@
border-bottom-color: var(--color-heritage-red);
}
.home-fulfillment-filters {
display: flex;
align-items: center;
gap: 8px;
padding: 4px var(--space-page) 2px;
overflow-x: auto;
}
.home-fulfillment-chip {
flex-shrink: 0;
padding: 4px 12px;
font-size: 12px;
line-height: 18px;
color: var(--color-subtle-gray);
background: rgba(0, 0, 0, 0.04);
border-radius: 999px;
}
.home-fulfillment-chip--active {
color: var(--color-heritage-red);
background: rgba(179, 38, 30, 0.1);
font-weight: 600;
}
.home-product-list {
display: flex;
flex-direction: column;
@@ -301,6 +301,25 @@
color: var(--color-on-surface-variant);
}
.product-detail-pickup-btn {
flex: 1;
height: 48px;
border-radius: 999px;
background: transparent;
border: 1px solid var(--color-heritage-red);
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.product-detail-pickup-btn-text {
color: var(--color-heritage-red);
font-family: var(--font-headline);
font-size: 15px;
font-weight: 700;
}
.product-detail-buy-btn {
flex: 1;
height: 48px;
@@ -312,6 +331,12 @@
box-shadow: 0 8px 24px rgba(166, 29, 36, 0.2);
}
.product-detail-buy-btn--disabled {
background: #c4c4c4;
box-shadow: none;
pointer-events: none;
}
.product-detail-buy-btn-text {
color: #fff;
font-family: var(--font-headline);