用户端调整
2.首页商品详情增加点击区域 3.浓香型 酱香型 点击增加提示:暂未开放 5.首页餐券2字更换为“好客权益” 6.门店页面地址筛选功能 (三级菜单,省市区) 7.好客权益:去使用点击交互调整为核销页面,单张好客权益点击核销页面带参数和金额 7.核销金额限制为可用余额最大数,不是500 9.收货地址电话号码增加校验,长度限制,地址或者未填提示,地区调整为省市区三级菜单 10.微信授权位置调整在手机验证码下侧
This commit is contained in:
@@ -9,8 +9,9 @@
|
|||||||
"lint": "echo ok"
|
"lint": "echo ok"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dukang/shared-ui": "workspace:*",
|
|
||||||
"@dukang/shared-types": "workspace:*",
|
"@dukang/shared-types": "workspace:*",
|
||||||
|
"@dukang/shared-ui": "workspace:*",
|
||||||
|
"element-china-area-data": "^6.1.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.26.0"
|
"react-router-dom": "^6.26.0"
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
type AppToastProps = {
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AppToast({ message }: AppToastProps) {
|
||||||
|
if (!message) return null;
|
||||||
|
return <div className="app-toast">{message}</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
REGION_ALL,
|
||||||
|
getCities,
|
||||||
|
getCitiesForPicker,
|
||||||
|
getDistricts,
|
||||||
|
getDistrictsForPicker,
|
||||||
|
getProvincesForPicker,
|
||||||
|
normalizeRegionSelection,
|
||||||
|
type RegionSelection,
|
||||||
|
} from '../lib/region-data';
|
||||||
|
|
||||||
|
type RegionPickerProps = {
|
||||||
|
open: boolean;
|
||||||
|
value: RegionSelection;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (region: RegionSelection) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PickerLevel = 'province' | 'city' | 'district';
|
||||||
|
|
||||||
|
const TABS: Array<{ key: PickerLevel; label: string }> = [
|
||||||
|
{ key: 'province', label: '省份' },
|
||||||
|
{ key: 'city', label: '城市' },
|
||||||
|
{ key: 'district', label: '区县' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function RegionPicker({ open, value, onClose, onConfirm }: RegionPickerProps) {
|
||||||
|
const [draft, setDraft] = useState<RegionSelection>(value);
|
||||||
|
const [activeTab, setActiveTab] = useState<PickerLevel>('province');
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setDraft(normalizeRegionSelection(value));
|
||||||
|
setActiveTab('province');
|
||||||
|
}, [open, value]);
|
||||||
|
|
||||||
|
const listItems = useMemo(() => {
|
||||||
|
if (activeTab === 'province') return getProvincesForPicker();
|
||||||
|
if (activeTab === 'city') return getCitiesForPicker(draft.province);
|
||||||
|
return getDistrictsForPicker(draft.province, draft.city);
|
||||||
|
}, [activeTab, draft.province, draft.city]);
|
||||||
|
|
||||||
|
const selectedValue =
|
||||||
|
activeTab === 'province' ? draft.province : activeTab === 'city' ? draft.city : draft.district;
|
||||||
|
|
||||||
|
const canConfirm = Boolean(draft.province && draft.city && draft.district);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
scrollActiveIntoView(listRef.current, selectedValue);
|
||||||
|
}, [open, activeTab, selectedValue, listItems.length]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
function scrollActiveIntoView(container: HTMLDivElement | null, label: string) {
|
||||||
|
if (!container || !label) return;
|
||||||
|
const active = container.querySelector<HTMLElement>(`[data-label="${CSS.escape(label)}"]`);
|
||||||
|
active?.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectProvince(province: string) {
|
||||||
|
if (province === REGION_ALL) {
|
||||||
|
setDraft({ province: REGION_ALL, city: REGION_ALL, district: REGION_ALL });
|
||||||
|
setActiveTab('city');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextCities = getCities(province);
|
||||||
|
const city = nextCities[0] ?? '';
|
||||||
|
const nextDistricts = getDistricts(province, city);
|
||||||
|
setDraft({
|
||||||
|
province,
|
||||||
|
city,
|
||||||
|
district: nextDistricts[0] ?? '',
|
||||||
|
});
|
||||||
|
setActiveTab('city');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCity(city: string) {
|
||||||
|
if (city === REGION_ALL) {
|
||||||
|
setDraft({ ...draft, city: REGION_ALL, district: REGION_ALL });
|
||||||
|
setActiveTab('district');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextDistricts = getDistricts(draft.province, city);
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
city,
|
||||||
|
district: nextDistricts[0] ?? '',
|
||||||
|
});
|
||||||
|
setActiveTab('district');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDistrict(district: string) {
|
||||||
|
setDraft({ ...draft, district });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectItem(item: string) {
|
||||||
|
if (activeTab === 'province') selectProvince(item);
|
||||||
|
else if (activeTab === 'city') selectCity(item);
|
||||||
|
else selectDistrict(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTabClick(tab: PickerLevel) {
|
||||||
|
if (tab === 'city' && !draft.province) return;
|
||||||
|
if (tab === 'district' && (!draft.province || !draft.city)) return;
|
||||||
|
setActiveTab(tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
if (!canConfirm) return;
|
||||||
|
onConfirm(normalizeRegionSelection(draft));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="region-picker-overlay" role="presentation" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="region-picker-sheet"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="选择地区"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="region-picker-toolbar">
|
||||||
|
<div className="region-picker-tabs" role="tablist">
|
||||||
|
{TABS.map((tab) => {
|
||||||
|
const disabled =
|
||||||
|
(tab.key === 'city' && !draft.province) ||
|
||||||
|
(tab.key === 'district' && (!draft.province || !draft.city));
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === tab.key}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`region-picker-tab${activeTab === tab.key ? ' active' : ''}`}
|
||||||
|
onClick={() => onTabClick(tab.key)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`region-picker-confirm${canConfirm ? ' ready' : ''}`}
|
||||||
|
disabled={!canConfirm}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
>
|
||||||
|
确定
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="region-picker-list" ref={listRef}>
|
||||||
|
{listItems.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item}
|
||||||
|
type="button"
|
||||||
|
data-label={item}
|
||||||
|
className={`region-picker-option${selectedValue === item ? ' selected' : ''}${
|
||||||
|
item === REGION_ALL ? ' region-picker-option--all' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => onSelectItem(item)}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
const MOBILE_PHONE_RE = /^1[3-9]\d{9}$/;
|
||||||
|
|
||||||
|
export function normalizePhoneInput(value: string): string {
|
||||||
|
return value.replace(/\D/g, '').slice(0, 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateMobilePhone(phone: string): { ok: boolean; message?: string } {
|
||||||
|
const trimmed = phone.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { ok: false, message: '请输入手机号码' };
|
||||||
|
}
|
||||||
|
if (trimmed.length !== 11) {
|
||||||
|
return { ok: false, message: '手机号码须为 11 位' };
|
||||||
|
}
|
||||||
|
if (!MOBILE_PHONE_RE.test(trimmed)) {
|
||||||
|
return { ok: false, message: '请输入正确的手机号码' };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { regionData } from 'element-china-area-data';
|
||||||
|
|
||||||
|
export type RegionTree = Record<string, Record<string, string[]>>;
|
||||||
|
|
||||||
|
/** 由国家标准省市区数据构建三级树 */
|
||||||
|
function buildRegionTree(): RegionTree {
|
||||||
|
const tree: RegionTree = {};
|
||||||
|
for (const province of regionData) {
|
||||||
|
const cities: Record<string, string[]> = {};
|
||||||
|
for (const city of province.children ?? []) {
|
||||||
|
cities[city.label] = (city.children ?? []).map((district) => district.label);
|
||||||
|
}
|
||||||
|
tree[province.label] = cities;
|
||||||
|
}
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REGION_TREE: RegionTree = buildRegionTree();
|
||||||
|
|
||||||
|
export const PROVINCES = Object.keys(REGION_TREE);
|
||||||
|
|
||||||
|
/** 三级选择「全市」选项(省/市/区列表首项) */
|
||||||
|
export const REGION_ALL = '全市';
|
||||||
|
|
||||||
|
export function getCities(province: string): string[] {
|
||||||
|
if (province === REGION_ALL) return [];
|
||||||
|
return Object.keys(REGION_TREE[province] ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDistricts(province: string, city: string): string[] {
|
||||||
|
if (province === REGION_ALL || city === REGION_ALL) return [];
|
||||||
|
return REGION_TREE[province]?.[city] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 省份列表(含全市) */
|
||||||
|
export function getProvincesForPicker(): string[] {
|
||||||
|
return [...PROVINCES];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 城市列表(含全市) */
|
||||||
|
export function getCitiesForPicker(province: string): string[] {
|
||||||
|
if (province === REGION_ALL) return [REGION_ALL];
|
||||||
|
return [...getCities(province)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 区县列表(含全市) */
|
||||||
|
export function getDistrictsForPicker(province: string, city: string): string[] {
|
||||||
|
if (province === REGION_ALL || city === REGION_ALL) return [REGION_ALL];
|
||||||
|
return [REGION_ALL, ...getDistricts(province, city)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRegion(province: string, city: string, district: string): string {
|
||||||
|
if (!province) return '';
|
||||||
|
if (province === REGION_ALL) return REGION_ALL;
|
||||||
|
if (city === REGION_ALL) return `${province} ${REGION_ALL}`;
|
||||||
|
if (district === REGION_ALL) return `${province} ${city} ${REGION_ALL}`;
|
||||||
|
if (!city || !district) return '';
|
||||||
|
return `${province} ${city} ${district}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RegionSelection = {
|
||||||
|
province: string;
|
||||||
|
city: string;
|
||||||
|
district: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_REGION: RegionSelection = {
|
||||||
|
province: '河南省',
|
||||||
|
city: '郑州市',
|
||||||
|
district: '金水区',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 校验已选地区是否仍存在于数据源中 */
|
||||||
|
export function normalizeRegionSelection(selection: RegionSelection): RegionSelection {
|
||||||
|
if (selection.province === REGION_ALL) {
|
||||||
|
return { province: REGION_ALL, city: REGION_ALL, district: REGION_ALL };
|
||||||
|
}
|
||||||
|
|
||||||
|
const province = PROVINCES.includes(selection.province)
|
||||||
|
? selection.province
|
||||||
|
: DEFAULT_REGION.province;
|
||||||
|
|
||||||
|
if (selection.city === REGION_ALL) {
|
||||||
|
return { province, city: REGION_ALL, district: REGION_ALL };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cities = getCities(province);
|
||||||
|
const city = cities.includes(selection.city) ? selection.city : (cities[0] ?? DEFAULT_REGION.city);
|
||||||
|
|
||||||
|
if (selection.district === REGION_ALL) {
|
||||||
|
return { province, city, district: REGION_ALL };
|
||||||
|
}
|
||||||
|
|
||||||
|
const districts = getDistricts(province, city);
|
||||||
|
const district = districts.includes(selection.district)
|
||||||
|
? selection.district
|
||||||
|
: (districts[0] ?? DEFAULT_REGION.district);
|
||||||
|
|
||||||
|
return { province, city, district };
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import SubPageHeader from '../components/SubPageHeader';
|
import SubPageHeader from '../components/SubPageHeader';
|
||||||
|
import RegionPicker from '../components/RegionPicker';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { buildAddressListUrl, readCheckoutContext } from '../lib/navigation';
|
import { buildAddressListUrl, readCheckoutContext } from '../lib/navigation';
|
||||||
|
import { DEFAULT_REGION, formatRegion } from '../lib/region-data';
|
||||||
|
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
|
||||||
|
|
||||||
type AddressForm = {
|
type AddressForm = {
|
||||||
receiverName: string;
|
receiverName: string;
|
||||||
@@ -14,35 +17,20 @@ type AddressForm = {
|
|||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const REGION_OPTIONS = [
|
|
||||||
{ province: '河南省', city: '郑州市', district: '金水区', label: '河南省 郑州市 金水区' },
|
|
||||||
{ province: '河南省', city: '郑州市', district: '二七区', label: '河南省 郑州市 二七区' },
|
|
||||||
{ province: '河南省', city: '洛阳市', district: '涧西区', label: '河南省 洛阳市 涧西区' },
|
|
||||||
{ province: '河南省', city: '洛阳市', district: '洛龙区', label: '河南省 洛阳市 洛龙区' },
|
|
||||||
{ province: '北京市', city: '北京市', district: '东城区', label: '北京市 东城区' },
|
|
||||||
{ province: '上海市', city: '上海市', district: '黄浦区', label: '上海市 黄浦区' },
|
|
||||||
{ province: '陕西省', city: '西安市', district: '雁塔区', label: '陕西省 西安市 雁塔区' },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function regionLabel(form: Pick<AddressForm, 'province' | 'city' | 'district'>) {
|
|
||||||
if (!form.province || !form.city || !form.district) return '';
|
|
||||||
return `${form.province} ${form.city} ${form.district}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AddressEditPage() {
|
export default function AddressEditPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const isEdit = Boolean(id);
|
const isEdit = Boolean(id);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [pickerDraft, setPickerDraft] = useState<typeof REGION_OPTIONS[number] | null>(null);
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
const [form, setForm] = useState<AddressForm>({
|
const [form, setForm] = useState<AddressForm>({
|
||||||
receiverName: '',
|
receiverName: '',
|
||||||
phone: '13800000001',
|
phone: '',
|
||||||
province: '河南省',
|
province: DEFAULT_REGION.province,
|
||||||
city: params.get('city') || '郑州市',
|
city: params.get('city') || DEFAULT_REGION.city,
|
||||||
district: '金水区',
|
district: DEFAULT_REGION.district,
|
||||||
detail: '',
|
detail: '',
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
});
|
});
|
||||||
@@ -71,35 +59,26 @@ export default function AddressEditPage() {
|
|||||||
});
|
});
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
const regionText = regionLabel(form);
|
const regionText = formatRegion(form.province, form.city, form.district);
|
||||||
|
|
||||||
function openPicker() {
|
function validateForm(): string | null {
|
||||||
const current =
|
if (!form.receiverName.trim()) return '请输入收货人姓名';
|
||||||
REGION_OPTIONS.find(
|
const phoneCheck = validateMobilePhone(form.phone);
|
||||||
(r) => r.province === form.province && r.city === form.city && r.district === form.district,
|
if (!phoneCheck.ok) return phoneCheck.message ?? '请输入正确的手机号码';
|
||||||
) ?? null;
|
if (!form.province || !form.city || !form.district) return '请选择所在地区';
|
||||||
setPickerDraft(current);
|
if (!form.detail.trim()) return '请输入详细地址';
|
||||||
setPickerOpen(true);
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
function confirmRegion() {
|
|
||||||
if (pickerDraft) {
|
|
||||||
setForm((f) => ({
|
|
||||||
...f,
|
|
||||||
province: pickerDraft.province,
|
|
||||||
city: pickerDraft.city,
|
|
||||||
district: pickerDraft.district,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
setPickerOpen(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!form.receiverName.trim()) return;
|
const validationError = validateForm();
|
||||||
if (!form.phone.trim()) return;
|
if (validationError) {
|
||||||
if (!regionText) return;
|
setError(validationError);
|
||||||
if (!form.detail.trim()) return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
try {
|
try {
|
||||||
if (isEdit && id) {
|
if (isEdit && id) {
|
||||||
await request('USER_H5', `/user/addresses/${id}`, {
|
await request('USER_H5', `/user/addresses/${id}`, {
|
||||||
@@ -135,7 +114,10 @@ export default function AddressEditPage() {
|
|||||||
className="address-edit-input"
|
className="address-edit-input"
|
||||||
placeholder="请输入姓名"
|
placeholder="请输入姓名"
|
||||||
value={form.receiverName}
|
value={form.receiverName}
|
||||||
onChange={(e) => setForm({ ...form, receiverName: e.target.value })}
|
onChange={(e) => {
|
||||||
|
setForm({ ...form, receiverName: e.target.value });
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="material-symbols-outlined address-edit-field-icon">person</span>
|
<span className="material-symbols-outlined address-edit-field-icon">person</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,20 +131,23 @@ export default function AddressEditPage() {
|
|||||||
type="tel"
|
type="tel"
|
||||||
className="address-edit-input"
|
className="address-edit-input"
|
||||||
placeholder="请输入手机号"
|
placeholder="请输入手机号"
|
||||||
|
maxLength={11}
|
||||||
|
inputMode="numeric"
|
||||||
value={form.phone}
|
value={form.phone}
|
||||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
onChange={(e) => {
|
||||||
|
setForm({ ...form, phone: normalizePhoneInput(e.target.value) });
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="material-symbols-outlined address-edit-field-icon">smartphone</span>
|
<span className="material-symbols-outlined address-edit-field-icon">smartphone</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" className="address-edit-field address-edit-region" onClick={openPicker}>
|
<button type="button" className="address-edit-field address-edit-region" onClick={() => setPickerOpen(true)}>
|
||||||
<label className="address-edit-label">所在地区</label>
|
<div className="address-edit-line address-edit-line--picker address-edit-line--region">
|
||||||
<div className="address-edit-line address-edit-line--picker">
|
|
||||||
<span className={regionText ? 'address-edit-region-value' : 'address-edit-region-placeholder'}>
|
<span className={regionText ? 'address-edit-region-value' : 'address-edit-region-placeholder'}>
|
||||||
{regionText || '请选择省/市/区'}
|
{regionText || '省份、城市、区县'}
|
||||||
</span>
|
</span>
|
||||||
<span className="material-symbols-outlined address-edit-field-icon">chevron_right</span>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -174,7 +159,10 @@ export default function AddressEditPage() {
|
|||||||
placeholder="街道、门牌号、小区名称等"
|
placeholder="街道、门牌号、小区名称等"
|
||||||
rows={3}
|
rows={3}
|
||||||
value={form.detail}
|
value={form.detail}
|
||||||
onChange={(e) => setForm({ ...form, detail: e.target.value })}
|
onChange={(e) => {
|
||||||
|
setForm({ ...form, detail: e.target.value });
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,6 +189,8 @@ export default function AddressEditPage() {
|
|||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{error && <p className="address-edit-error">{error}</p>}
|
||||||
|
|
||||||
<div className="address-edit-security">
|
<div className="address-edit-security">
|
||||||
<span className="material-symbols-outlined">location_on</span>
|
<span className="material-symbols-outlined">location_on</span>
|
||||||
<span>已通过杜康云安全加密处理</span>
|
<span>已通过杜康云安全加密处理</span>
|
||||||
@@ -223,51 +213,21 @@ export default function AddressEditPage() {
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{pickerOpen && (
|
<RegionPicker
|
||||||
<div
|
open={pickerOpen}
|
||||||
className="address-edit-picker-overlay"
|
value={{ province: form.province, city: form.city, district: form.district }}
|
||||||
role="presentation"
|
onClose={() => setPickerOpen(false)}
|
||||||
onClick={() => setPickerOpen(false)}
|
onConfirm={(region) => {
|
||||||
>
|
setForm((f) => ({
|
||||||
<div
|
...f,
|
||||||
className="address-edit-picker-sheet"
|
province: region.province,
|
||||||
role="dialog"
|
city: region.city,
|
||||||
aria-label="选择地区"
|
district: region.district,
|
||||||
onClick={(e) => e.stopPropagation()}
|
}));
|
||||||
>
|
setPickerOpen(false);
|
||||||
<div className="address-edit-picker-head">
|
setError('');
|
||||||
<h4>选择地区</h4>
|
}}
|
||||||
<button type="button" aria-label="关闭" onClick={() => setPickerOpen(false)}>
|
/>
|
||||||
<span className="material-symbols-outlined">close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="address-edit-picker-list">
|
|
||||||
{REGION_OPTIONS.map((r) => {
|
|
||||||
const selected =
|
|
||||||
pickerDraft?.province === r.province &&
|
|
||||||
pickerDraft?.city === r.city &&
|
|
||||||
pickerDraft?.district === r.district;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={r.label}
|
|
||||||
type="button"
|
|
||||||
className={`address-edit-picker-item${selected ? ' selected' : ''}`}
|
|
||||||
onClick={() => setPickerDraft(r)}
|
|
||||||
>
|
|
||||||
<span>{r.label}</span>
|
|
||||||
{selected && (
|
|
||||||
<span className="material-symbols-outlined">check_circle</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<button type="button" className="address-edit-picker-confirm" onClick={confirmRegion}>
|
|
||||||
确认选择
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ function usagePercent(coupon: CouponItem) {
|
|||||||
return Math.min(100, Math.round((Number(coupon.usedAmount) / total) * 100));
|
return Math.min(100, Math.round((Number(coupon.usedAmount) / total) * 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildRedeemUrl(coupon?: CouponItem) {
|
||||||
|
if (!coupon) return '/redeem';
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
couponId: coupon.id,
|
||||||
|
amount: String(coupon.balance),
|
||||||
|
});
|
||||||
|
return `/redeem?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function BenefitPage() {
|
export default function BenefitPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const listRef = useRef<HTMLElement>(null);
|
const listRef = useRef<HTMLElement>(null);
|
||||||
@@ -71,6 +80,10 @@ export default function BenefitPage() {
|
|||||||
listRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
listRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goRedeem(coupon?: CouponItem) {
|
||||||
|
navigate(buildRedeemUrl(coupon));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="benefit-page">
|
<div className="benefit-page">
|
||||||
<header className="benefit-header">
|
<header className="benefit-header">
|
||||||
@@ -110,8 +123,8 @@ export default function BenefitPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="benefit-action">
|
<section className="benefit-action">
|
||||||
<button type="button" className="benefit-use-btn" onClick={() => navigate('/stores')}>
|
<button type="button" className="benefit-use-btn" onClick={() => goRedeem()}>
|
||||||
<span className="material-symbols-outlined filled">storefront</span>
|
<span className="material-symbols-outlined filled">qr_code_2</span>
|
||||||
去使用
|
去使用
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
@@ -136,7 +149,21 @@ export default function BenefitPage() {
|
|||||||
{visible.length > 0 ? (
|
{visible.length > 0 ? (
|
||||||
<div className="benefit-list">
|
<div className="benefit-list">
|
||||||
{visible.map((c) => (
|
{visible.map((c) => (
|
||||||
<article key={c.id} className="benefit-card">
|
<article
|
||||||
|
key={c.id}
|
||||||
|
className={`benefit-card${tab === 'available' && c.balance > 0 ? ' benefit-card--clickable' : ''}`}
|
||||||
|
role={tab === 'available' && c.balance > 0 ? 'button' : undefined}
|
||||||
|
tabIndex={tab === 'available' && c.balance > 0 ? 0 : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
if (tab === 'available' && c.balance > 0) goRedeem(c);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (tab === 'available' && c.balance > 0 && (e.key === 'Enter' || e.key === ' ')) {
|
||||||
|
e.preventDefault();
|
||||||
|
goRedeem(c);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="benefit-card-inner">
|
<div className="benefit-card-inner">
|
||||||
<div className="benefit-card-value">
|
<div className="benefit-card-value">
|
||||||
<span className="benefit-card-value-label">好客权益</span>
|
<span className="benefit-card-value-label">好客权益</span>
|
||||||
@@ -167,16 +194,25 @@ export default function BenefitPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="benefit-card-foot">
|
<div className="benefit-card-foot">
|
||||||
<Link to={`/benefit/${c.id}`} className="benefit-card-no">
|
<Link
|
||||||
|
to={`/benefit/${c.id}`}
|
||||||
|
className="benefit-card-no"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
{formatCouponNo(c.couponNo)}
|
{formatCouponNo(c.couponNo)}
|
||||||
</Link>
|
</Link>
|
||||||
|
{tab === 'available' && c.balance > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="benefit-card-redeem"
|
className="benefit-card-redeem"
|
||||||
onClick={() => navigate('/redeem')}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goRedeem(c);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
立即核销
|
立即核销
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
|
|||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import ProductCarousel from '../components/ProductCarousel';
|
import ProductCarousel from '../components/ProductCarousel';
|
||||||
import TabMainHeader from '../components/TabMainHeader';
|
import TabMainHeader from '../components/TabMainHeader';
|
||||||
|
import AppToast from '../components/AppToast';
|
||||||
import { getProductImages } from '../lib/product-images';
|
import { getProductImages } from '../lib/product-images';
|
||||||
import CouponBadge from '@dukang/shared-ui/CouponBadge';
|
import CouponBadge from '@dukang/shared-ui/CouponBadge';
|
||||||
|
|
||||||
@@ -19,19 +20,33 @@ type Product = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AROMA_TABS = [
|
const AROMA_TABS = [
|
||||||
{ key: 'QINGXIANG', label: '清香型', disabled: false },
|
{ key: 'QINGXIANG', label: '清香型', open: true },
|
||||||
{ key: 'JIANGXIANG', label: '酱香型', disabled: true },
|
{ key: 'JIANGXIANG', label: '酱香型', open: false },
|
||||||
{ key: 'NONGXIANG', label: '浓香型', disabled: true },
|
{ key: 'NONGXIANG', label: '浓香型', open: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const [tab, setTab] = useState('QINGXIANG');
|
const [tab, setTab] = useState('QINGXIANG');
|
||||||
const [products, setProducts] = useState<Product[]>([]);
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
const [toast, setToast] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
request<Product[]>('USER_H5', '/catalog/products').then(setProducts);
|
request<Product[]>('USER_H5', '/catalog/products').then(setProducts);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
function showToast(message: string) {
|
||||||
|
setToast(message);
|
||||||
|
window.setTimeout(() => setToast(''), 2200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAromaTabClick(key: string, open: boolean) {
|
||||||
|
if (!open) {
|
||||||
|
showToast('暂未开放');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTab(key);
|
||||||
|
}
|
||||||
|
|
||||||
const filtered = products.filter((p) => p.aromaType === tab);
|
const filtered = products.filter((p) => p.aromaType === tab);
|
||||||
const onSale = tab === 'QINGXIANG';
|
const onSale = tab === 'QINGXIANG';
|
||||||
|
|
||||||
@@ -52,9 +67,8 @@ export default function HomePage() {
|
|||||||
<button
|
<button
|
||||||
key={t.key}
|
key={t.key}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={t.disabled}
|
className={`home-aroma-tab${tab === t.key ? ' active' : ''}${!t.open ? ' muted' : ''}`}
|
||||||
className={`home-aroma-tab${tab === t.key ? ' active' : ''}${t.disabled ? ' disabled' : ''}`}
|
onClick={() => onAromaTabClick(t.key, t.open)}
|
||||||
onClick={() => !t.disabled && setTab(t.key)}
|
|
||||||
>
|
>
|
||||||
{t.label}
|
{t.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -66,6 +80,7 @@ export default function HomePage() {
|
|||||||
{onSale &&
|
{onSale &&
|
||||||
filtered.map((p, index) => (
|
filtered.map((p, index) => (
|
||||||
<article key={p.id} className="home-product-card">
|
<article key={p.id} className="home-product-card">
|
||||||
|
<Link to={`/product/${p.id}`} className="home-product-link">
|
||||||
<ProductCarousel images={getProductImages(index)} alt={p.name} />
|
<ProductCarousel images={getProductImages(index)} alt={p.name} />
|
||||||
<div className="home-product-body">
|
<div className="home-product-body">
|
||||||
<div className="home-product-row">
|
<div className="home-product-row">
|
||||||
@@ -74,15 +89,20 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="home-product-sub">{p.subtitle}</p>
|
<p className="home-product-sub">{p.subtitle}</p>
|
||||||
<div className="home-product-footer">
|
<div className="home-product-footer">
|
||||||
<CouponBadge amount={p.benefitDisplay} />
|
<CouponBadge amount={p.benefitDisplay} label="好客权益" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<div className="home-product-actions">
|
||||||
<Link to={`/product/${p.id}`} className="home-buy-btn">
|
<Link to={`/product/${p.id}`} className="home-buy-btn">
|
||||||
立即购买
|
立即购买
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<AppToast message={toast} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import AppImage from '@dukang/shared-ui/AppImage';
|
import AppImage from '@dukang/shared-ui/AppImage';
|
||||||
import { request, saveAuth } from '../lib/api';
|
import { request, saveAuth } from '../lib/api';
|
||||||
|
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -22,6 +23,11 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
async function sendCode() {
|
async function sendCode() {
|
||||||
if (!ensureAgreed()) return;
|
if (!ensureAgreed()) return;
|
||||||
|
const phoneCheck = validateMobilePhone(phone);
|
||||||
|
if (!phoneCheck.ok) {
|
||||||
|
setMsg(phoneCheck.message ?? '请输入正确的手机号码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setMsg('');
|
setMsg('');
|
||||||
await request('USER_H5', '/auth/sms/send', {
|
await request('USER_H5', '/auth/sms/send', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -42,6 +48,15 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
if (!ensureAgreed()) return;
|
if (!ensureAgreed()) return;
|
||||||
|
const phoneCheck = validateMobilePhone(phone);
|
||||||
|
if (!phoneCheck.ok) {
|
||||||
|
setMsg(phoneCheck.message ?? '请输入正确的手机号码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!code.trim()) {
|
||||||
|
setMsg('请输入验证码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMsg('');
|
setMsg('');
|
||||||
try {
|
try {
|
||||||
@@ -77,17 +92,6 @@ export default function LoginPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="login-main">
|
<main className="login-main">
|
||||||
<button type="button" className="login-wechat-btn" onClick={wechatLogin}>
|
|
||||||
<span className="material-symbols-outlined login-wechat-icon">chat</span>
|
|
||||||
<span>微信一键授权</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="login-divider">
|
|
||||||
<span className="login-divider-line" />
|
|
||||||
<span className="login-divider-text">或者</span>
|
|
||||||
<span className="login-divider-line" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="login-card">
|
<div className="login-card">
|
||||||
<h3 className="login-card-title">手机验证码登录</h3>
|
<h3 className="login-card-title">手机验证码登录</h3>
|
||||||
<div className="login-field">
|
<div className="login-field">
|
||||||
@@ -96,8 +100,13 @@ export default function LoginPage() {
|
|||||||
type="tel"
|
type="tel"
|
||||||
className="login-field-input"
|
className="login-field-input"
|
||||||
placeholder="请输入手机号"
|
placeholder="请输入手机号"
|
||||||
|
maxLength={11}
|
||||||
|
inputMode="numeric"
|
||||||
value={phone}
|
value={phone}
|
||||||
onChange={(e) => setPhone(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setPhone(normalizePhoneInput(e.target.value));
|
||||||
|
setMsg('');
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="login-field">
|
<div className="login-field">
|
||||||
@@ -128,6 +137,17 @@ export default function LoginPage() {
|
|||||||
{loading ? '登录中...' : '登录'}
|
{loading ? '登录中...' : '登录'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="login-divider">
|
||||||
|
<span className="login-divider-line" />
|
||||||
|
<span className="login-divider-text">或者</span>
|
||||||
|
<span className="login-divider-line" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" className="login-wechat-btn" onClick={wechatLogin}>
|
||||||
|
<span className="material-symbols-outlined login-wechat-icon">chat</span>
|
||||||
|
<span>微信一键授权</span>
|
||||||
|
</button>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="login-footer">
|
<footer className="login-footer">
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export default function MinePage() {
|
|||||||
<span className="mine-asset-value">{formatMoney(benefitBalance)}</span>
|
<span className="mine-asset-value">{formatMoney(benefitBalance)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link to="/benefit" className="mine-asset-cta">
|
<Link to="/redeem" className="mine-asset-cta">
|
||||||
去使用
|
去使用
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
const REDEEM_MAX_AMOUNT = 500;
|
|
||||||
const REDEEM_TOKEN_TTL_SECONDS = 300;
|
const REDEEM_TOKEN_TTL_SECONDS = 300;
|
||||||
|
|
||||||
type BenefitSummary = {
|
type BenefitSummary = {
|
||||||
@@ -23,7 +22,12 @@ function formatTimer(seconds: number) {
|
|||||||
|
|
||||||
export default function RedeemPage() {
|
export default function RedeemPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const couponId = searchParams.get('couponId') ?? undefined;
|
||||||
|
const presetAmount = searchParams.get('amount');
|
||||||
|
|
||||||
const [summary, setSummary] = useState<BenefitSummary | null>(null);
|
const [summary, setSummary] = useState<BenefitSummary | null>(null);
|
||||||
|
const [couponBalance, setCouponBalance] = useState<number | null>(null);
|
||||||
const [amountInput, setAmountInput] = useState('');
|
const [amountInput, setAmountInput] = useState('');
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -32,12 +36,40 @@ export default function RedeemPage() {
|
|||||||
const [confirmAmount, setConfirmAmount] = useState(0);
|
const [confirmAmount, setConfirmAmount] = useState(0);
|
||||||
const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS);
|
const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS);
|
||||||
const timerRef = useRef<number | null>(null);
|
const timerRef = useRef<number | null>(null);
|
||||||
|
const presetApplied = useRef(false);
|
||||||
|
|
||||||
|
const redeemableMax = couponId
|
||||||
|
? (couponBalance ?? 0)
|
||||||
|
: (summary?.totalBalance ?? 0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
request<BenefitSummary>('USER_H5', '/benefit/summary').then(setSummary).catch(() => {});
|
request<BenefitSummary>('USER_H5', '/benefit/summary').then(setSummary).catch(() => {});
|
||||||
return () => stopTimer();
|
return () => stopTimer();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!couponId) {
|
||||||
|
setCouponBalance(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
request<Array<Record<string, unknown>>>('USER_H5', '/benefit/coupons')
|
||||||
|
.then((list) => {
|
||||||
|
const found = list.find((c) => String(c.id) === couponId);
|
||||||
|
if (found) {
|
||||||
|
setCouponBalance(Number(found.balance));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [couponId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (presetApplied.current) return;
|
||||||
|
if (presetAmount && Number(presetAmount) > 0) {
|
||||||
|
setAmountInput(formatMoney(Number(presetAmount)));
|
||||||
|
presetApplied.current = true;
|
||||||
|
}
|
||||||
|
}, [presetAmount]);
|
||||||
|
|
||||||
function stopTimer() {
|
function stopTimer() {
|
||||||
if (timerRef.current != null) {
|
if (timerRef.current != null) {
|
||||||
window.clearInterval(timerRef.current);
|
window.clearInterval(timerRef.current);
|
||||||
@@ -76,33 +108,34 @@ export default function RedeemPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fillMaxAmount() {
|
function fillMaxAmount() {
|
||||||
if (!summary) return;
|
if (redeemableMax <= 0) return;
|
||||||
setAmountInput(formatMoney(summary.maxRedeemAmount));
|
setAmountInput(formatMoney(redeemableMax));
|
||||||
setMsg('');
|
setMsg('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
const amount = parseAmount();
|
const amount = parseAmount();
|
||||||
if (!summary) return;
|
if (!summary) return;
|
||||||
|
const maxAllowed = couponId ? (couponBalance ?? 0) : summary.totalBalance;
|
||||||
|
|
||||||
if (amount <= 0) {
|
if (amount <= 0) {
|
||||||
setMsg('请输入核销金额');
|
setMsg('请输入核销金额');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (amount > summary.totalBalance) {
|
if (amount > maxAllowed) {
|
||||||
setMsg('核销金额不能超过可用余额');
|
setMsg(couponId ? '核销金额不能超过该权益可用余额' : '核销金额不能超过可用余额');
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (amount > REDEEM_MAX_AMOUNT) {
|
|
||||||
setMsg(`单次核销不能超过 ¥${REDEEM_MAX_AMOUNT}`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMsg('');
|
setMsg('');
|
||||||
try {
|
try {
|
||||||
|
const body: { amount: number; couponId?: string } = { amount };
|
||||||
|
if (couponId) body.couponId = couponId;
|
||||||
|
|
||||||
const data = await request<{ token: string; amount: number }>('USER_H5', '/redeem/tokens', {
|
const data = await request<{ token: string; amount: number }>('USER_H5', '/redeem/tokens', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ amount }),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
setToken(data.token);
|
setToken(data.token);
|
||||||
setConfirmAmount(data.amount);
|
setConfirmAmount(data.amount);
|
||||||
@@ -117,7 +150,6 @@ export default function RedeemPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxHint = summary ? Math.min(REDEEM_MAX_AMOUNT, summary.totalBalance) : REDEEM_MAX_AMOUNT;
|
|
||||||
const qrUrl = token
|
const qrUrl = token
|
||||||
? `https://api.qrserver.com/v1/create-qr-code/?size=192x192&data=${encodeURIComponent(token)}`
|
? `https://api.qrserver.com/v1/create-qr-code/?size=192x192&data=${encodeURIComponent(token)}`
|
||||||
: '';
|
: '';
|
||||||
@@ -133,7 +165,7 @@ export default function RedeemPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="redeem-header-btn"
|
className="redeem-header-btn"
|
||||||
aria-label="帮助"
|
aria-label="帮助"
|
||||||
onClick={() => window.alert('单次核销上限 ¥500,核销码 5 分钟内有效。')}
|
onClick={() => window.alert('核销金额不超过可用余额,核销码 5 分钟内有效。')}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined">help_outline</span>
|
<span className="material-symbols-outlined">help_outline</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -143,11 +175,13 @@ export default function RedeemPage() {
|
|||||||
<section className="redeem-balance-card">
|
<section className="redeem-balance-card">
|
||||||
<div className="redeem-balance-pattern" aria-hidden />
|
<div className="redeem-balance-pattern" aria-hidden />
|
||||||
<div className="redeem-balance-inner">
|
<div className="redeem-balance-inner">
|
||||||
<span className="redeem-balance-label">当前好客权益可用余额</span>
|
<span className="redeem-balance-label">
|
||||||
|
{couponId ? '当前权益可用余额' : '当前好客权益可用余额'}
|
||||||
|
</span>
|
||||||
<div className="redeem-balance-amount">
|
<div className="redeem-balance-amount">
|
||||||
<span className="redeem-balance-symbol">¥</span>
|
<span className="redeem-balance-symbol">¥</span>
|
||||||
<span className="redeem-balance-value">
|
<span className="redeem-balance-value">
|
||||||
{summary ? formatMoney(summary.totalBalance) : '--'}
|
{redeemableMax > 0 ? formatMoney(redeemableMax) : '--'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,7 +209,7 @@ export default function RedeemPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="redeem-amount-foot">
|
<div className="redeem-amount-foot">
|
||||||
<span>单次最高可核销 ¥{formatMoney(maxHint)}</span>
|
<span>最高可核销 ¥{formatMoney(redeemableMax)}</span>
|
||||||
<button type="button" className="redeem-fill-max" onClick={fillMaxAmount}>
|
<button type="button" className="redeem-fill-max" onClick={fillMaxAmount}>
|
||||||
全部核销
|
全部核销
|
||||||
</button>
|
</button>
|
||||||
@@ -187,7 +221,7 @@ export default function RedeemPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="redeem-submit-btn"
|
className="redeem-submit-btn"
|
||||||
disabled={loading || !summary || summary.totalBalance <= 0}
|
disabled={loading || redeemableMax <= 0}
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined">qr_code_2</span>
|
<span className="material-symbols-outlined">qr_code_2</span>
|
||||||
|
|||||||
@@ -3,10 +3,19 @@ import { Link, useNavigate } from 'react-router-dom';
|
|||||||
import AppImage from '@dukang/shared-ui/AppImage';
|
import AppImage from '@dukang/shared-ui/AppImage';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import TabMainHeader from '../components/TabMainHeader';
|
import TabMainHeader from '../components/TabMainHeader';
|
||||||
|
import RegionPicker from '../components/RegionPicker';
|
||||||
|
import {
|
||||||
|
DEFAULT_REGION,
|
||||||
|
formatRegion,
|
||||||
|
REGION_ALL,
|
||||||
|
type RegionSelection,
|
||||||
|
} from '../lib/region-data';
|
||||||
|
|
||||||
type StoreItem = {
|
type StoreItem = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
province?: string;
|
||||||
|
cityName?: string;
|
||||||
district: string;
|
district: string;
|
||||||
address: string;
|
address: string;
|
||||||
coverUrl?: string | null;
|
coverUrl?: string | null;
|
||||||
@@ -37,6 +46,8 @@ export default function StoreListPage() {
|
|||||||
const [stores, setStores] = useState<StoreItem[]>([]);
|
const [stores, setStores] = useState<StoreItem[]>([]);
|
||||||
const [categoryTab, setCategoryTab] = useState<string>('全部');
|
const [categoryTab, setCategoryTab] = useState<string>('全部');
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [region, setRegion] = useState<RegionSelection>(DEFAULT_REGION);
|
||||||
|
const [regionPickerOpen, setRegionPickerOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
request<StoreItem[]>('USER_H5', '/stores?cityCode=410100').then(setStores);
|
request<StoreItem[]>('USER_H5', '/stores?cityCode=410100').then(setStores);
|
||||||
@@ -47,6 +58,14 @@ export default function StoreListPage() {
|
|||||||
if (categoryTab !== '全部') {
|
if (categoryTab !== '全部') {
|
||||||
list = list.filter((s) => s.category?.name === categoryTab);
|
list = list.filter((s) => s.category?.name === categoryTab);
|
||||||
}
|
}
|
||||||
|
list = list.filter((s) => {
|
||||||
|
const province = s.province ?? '河南省';
|
||||||
|
const city = s.cityName ?? '郑州市';
|
||||||
|
if (region.province !== REGION_ALL && province !== region.province) return false;
|
||||||
|
if (region.city !== REGION_ALL && city !== region.city) return false;
|
||||||
|
if (region.district !== REGION_ALL && s.district !== region.district) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
const q = keyword.trim().toLowerCase();
|
const q = keyword.trim().toLowerCase();
|
||||||
if (q) {
|
if (q) {
|
||||||
list = list.filter(
|
list = list.filter(
|
||||||
@@ -57,7 +76,9 @@ export default function StoreListPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
}, [stores, categoryTab, keyword]);
|
}, [stores, categoryTab, keyword, region]);
|
||||||
|
|
||||||
|
const regionLabel = formatRegion(region.province, region.city, region.district);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page store-page">
|
<div className="page store-page">
|
||||||
@@ -65,9 +86,13 @@ export default function StoreListPage() {
|
|||||||
<TabMainHeader title="门店" />
|
<TabMainHeader title="门店" />
|
||||||
|
|
||||||
<div className="store-toolbar">
|
<div className="store-toolbar">
|
||||||
<button type="button" className="store-location">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="store-location"
|
||||||
|
onClick={() => setRegionPickerOpen(true)}
|
||||||
|
>
|
||||||
<span className="material-symbols-outlined">location_on</span>
|
<span className="material-symbols-outlined">location_on</span>
|
||||||
<span>郑州市 · 金水区</span>
|
<span>{regionLabel}</span>
|
||||||
<span className="material-symbols-outlined store-location-arrow">expand_more</span>
|
<span className="material-symbols-outlined store-location-arrow">expand_more</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="store-search">
|
<div className="store-search">
|
||||||
@@ -153,6 +178,16 @@ export default function StoreListPage() {
|
|||||||
<p className="store-list-end">没有更多门店了</p>
|
<p className="store-list-end">没有更多门店了</p>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<RegionPicker
|
||||||
|
open={regionPickerOpen}
|
||||||
|
value={region}
|
||||||
|
onClose={() => setRegionPickerOpen(false)}
|
||||||
|
onConfirm={(next) => {
|
||||||
|
setRegion(next);
|
||||||
|
setRegionPickerOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+181
-16
@@ -923,22 +923,9 @@
|
|||||||
border-bottom-color: var(--color-heritage-red);
|
border-bottom-color: var(--color-heritage-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-aroma-tab.disabled {
|
.home-aroma-tab.muted {
|
||||||
opacity: 0.5;
|
opacity: 0.65;
|
||||||
cursor: not-allowed;
|
cursor: pointer;
|
||||||
}
|
|
||||||
|
|
||||||
.home-product-list {
|
|
||||||
padding: var(--space-md) var(--space-page);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-empty {
|
|
||||||
text-align: center;
|
|
||||||
padding: 48px var(--space-md);
|
|
||||||
color: var(--color-subtle-gray);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-product-card {
|
.home-product-card {
|
||||||
@@ -948,6 +935,22 @@
|
|||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-product-link {
|
||||||
|
display: block;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-product-link:active {
|
||||||
|
opacity: 0.96;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-product-actions {
|
||||||
|
padding: 0 var(--space-gutter) var(--space-gutter);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.home-carousel-wrap {
|
.home-carousel-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -2766,6 +2769,160 @@
|
|||||||
transform: scale(0.95);
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.region-picker-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(26, 26, 26, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-picker-sheet {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 8px);
|
||||||
|
max-height: 56vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: address-picker-slide 0.28s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-picker-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-picker-tabs {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 28px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-picker-tab {
|
||||||
|
position: relative;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 14px 0 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 22px;
|
||||||
|
color: var(--color-subtle-gray);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-picker-tab.active {
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-picker-tab.active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--color-heritage-red);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-picker-tab:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-picker-confirm {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 14px 0 12px 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 22px;
|
||||||
|
color: var(--color-subtle-gray);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-picker-confirm.ready {
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-picker-confirm:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-picker-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding: 4px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-picker-option {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
text-align: left;
|
||||||
|
padding: 14px 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-picker-option:active {
|
||||||
|
background: rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-picker-option.selected {
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-picker-option--all {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-edit-line--region {
|
||||||
|
min-height: 48px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-edit-error {
|
||||||
|
margin: 0 var(--space-page);
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toast {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: calc(80px + env(safe-area-inset-bottom, 0px) + 16px);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 100;
|
||||||
|
max-width: calc(100% - 40px);
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(26, 26, 26, 0.88);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 地址管理列表页(stitch user_地址管理列表页) ── */
|
/* ── 地址管理列表页(stitch user_地址管理列表页) ── */
|
||||||
.address-list-page {
|
.address-list-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -4597,6 +4754,14 @@
|
|||||||
box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05);
|
box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.benefit-card--clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefit-card--clickable:active {
|
||||||
|
opacity: 0.96;
|
||||||
|
}
|
||||||
|
|
||||||
.benefit-card-inner {
|
.benefit-card-inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 128px;
|
min-height: 128px;
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ describe('validateMinPurchase', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('validateRedeemAmount', () => {
|
describe('validateRedeemAmount', () => {
|
||||||
it('rejects over balance or 500', () => {
|
it('rejects over balance or non-positive', () => {
|
||||||
expect(validateRedeemAmount(100, 50).ok).toBe(true);
|
expect(validateRedeemAmount(100, 50).ok).toBe(true);
|
||||||
expect(validateRedeemAmount(100, 501).ok).toBe(false);
|
expect(validateRedeemAmount(100, 100).ok).toBe(true);
|
||||||
expect(validateRedeemAmount(50, 60).ok).toBe(false);
|
expect(validateRedeemAmount(50, 60).ok).toBe(false);
|
||||||
expect(validateRedeemAmount(100, 0).ok).toBe(false);
|
expect(validateRedeemAmount(100, 0).ok).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -68,10 +68,10 @@ describe('allocateBenefitCoupons', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('calcBenefitSummary', () => {
|
describe('calcBenefitSummary', () => {
|
||||||
it('caps max redeem by total and limit', () => {
|
it('max redeem equals total balance', () => {
|
||||||
expect(calcBenefitSummary([300, 400])).toEqual({
|
expect(calcBenefitSummary([300, 400])).toEqual({
|
||||||
totalBalance: 700,
|
totalBalance: 700,
|
||||||
maxRedeemAmount: 500,
|
maxRedeemAmount: 700,
|
||||||
activeCouponCount: 2,
|
activeCouponCount: 2,
|
||||||
});
|
});
|
||||||
expect(calcBenefitSummary([100])).toEqual({
|
expect(calcBenefitSummary([100])).toEqual({
|
||||||
|
|||||||
@@ -29,11 +29,9 @@ export function validateMinPurchase(
|
|||||||
export function validateRedeemAmount(
|
export function validateRedeemAmount(
|
||||||
balance: number,
|
balance: number,
|
||||||
amount: number,
|
amount: number,
|
||||||
maxAmount = 500,
|
|
||||||
): { ok: boolean; message?: string } {
|
): { ok: boolean; message?: string } {
|
||||||
if (amount <= 0) return { ok: false, message: '核销金额必须大于 0' };
|
if (amount <= 0) return { ok: false, message: '核销金额必须大于 0' };
|
||||||
if (amount > balance) return { ok: false, message: '核销金额不能超过可用余额' };
|
if (amount > balance) return { ok: false, message: '核销金额不能超过可用余额' };
|
||||||
if (amount > maxAmount) return { ok: false, message: `单次核销不能超过 ¥${maxAmount}` };
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,13 +45,12 @@ export interface BenefitCouponBalance {
|
|||||||
export function allocateBenefitCoupons(
|
export function allocateBenefitCoupons(
|
||||||
coupons: BenefitCouponBalance[],
|
coupons: BenefitCouponBalance[],
|
||||||
amount: number,
|
amount: number,
|
||||||
maxAmount = 500,
|
|
||||||
): { ok: true; allocations: Array<{ couponId: string; amount: number }> } | { ok: false; message: string } {
|
): { ok: true; allocations: Array<{ couponId: string; amount: number }> } | { ok: false; message: string } {
|
||||||
const active = coupons
|
const active = coupons
|
||||||
.filter((c) => c.balance > 0)
|
.filter((c) => c.balance > 0)
|
||||||
.sort((a, b) => a.createdAt - b.createdAt);
|
.sort((a, b) => a.createdAt - b.createdAt);
|
||||||
const totalBalance = active.reduce((sum, c) => sum + c.balance, 0);
|
const totalBalance = active.reduce((sum, c) => sum + c.balance, 0);
|
||||||
const check = validateRedeemAmount(totalBalance, amount, maxAmount);
|
const check = validateRedeemAmount(totalBalance, amount);
|
||||||
if (!check.ok) return { ok: false, message: check.message! };
|
if (!check.ok) return { ok: false, message: check.message! };
|
||||||
|
|
||||||
let remaining = amount;
|
let remaining = amount;
|
||||||
@@ -74,12 +71,11 @@ export function allocateBenefitCoupons(
|
|||||||
|
|
||||||
export function calcBenefitSummary(
|
export function calcBenefitSummary(
|
||||||
balances: number[],
|
balances: number[],
|
||||||
maxAmount = 500,
|
|
||||||
): { totalBalance: number; maxRedeemAmount: number; activeCouponCount: number } {
|
): { totalBalance: number; maxRedeemAmount: number; activeCouponCount: number } {
|
||||||
const totalBalance = Math.round(balances.reduce((sum, b) => sum + b, 0) * 100) / 100;
|
const totalBalance = Math.round(balances.reduce((sum, b) => sum + b, 0) * 100) / 100;
|
||||||
return {
|
return {
|
||||||
totalBalance,
|
totalBalance,
|
||||||
maxRedeemAmount: Math.min(maxAmount, totalBalance),
|
maxRedeemAmount: totalBalance,
|
||||||
activeCouponCount: balances.length,
|
activeCouponCount: balances.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
type CouponBadgeProps = {
|
type CouponBadgeProps = {
|
||||||
amount: number | string;
|
amount: number | string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** 权益标签文案,默认「餐券」;首页使用「好客权益」 */
|
||||||
|
label?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CouponBadge({ amount, className = '' }: CouponBadgeProps) {
|
export default function CouponBadge({ amount, className = '', label = '餐券' }: CouponBadgeProps) {
|
||||||
return (
|
return (
|
||||||
<span className={`coupon-badge ${className}`.trim()}>
|
<span className={`coupon-badge ${className}`.trim()}>
|
||||||
¥{amount}餐券
|
¥{amount}{label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+15
@@ -84,6 +84,9 @@ importers:
|
|||||||
'@dukang/shared-ui':
|
'@dukang/shared-ui':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared-ui
|
version: link:../../packages/shared-ui
|
||||||
|
element-china-area-data:
|
||||||
|
specifier: ^6.1.0
|
||||||
|
version: 6.1.0
|
||||||
react:
|
react:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
@@ -1154,6 +1157,9 @@ packages:
|
|||||||
check-error@1.0.3:
|
check-error@1.0.3:
|
||||||
resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==}
|
resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==}
|
||||||
|
|
||||||
|
china-division@2.7.0:
|
||||||
|
resolution: {integrity: sha512-4uUPAT+1WfqDh5jytq7omdCmHNk3j+k76zEG/2IqaGcYB90c2SwcixttcypdsZ3T/9tN1TTpBDoeZn+Yw/qBEA==}
|
||||||
|
|
||||||
chokidar@3.6.0:
|
chokidar@3.6.0:
|
||||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||||
engines: {node: '>= 8.10.0'}
|
engines: {node: '>= 8.10.0'}
|
||||||
@@ -1359,6 +1365,9 @@ packages:
|
|||||||
electron-to-chromium@1.5.380:
|
electron-to-chromium@1.5.380:
|
||||||
resolution: {integrity: sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==}
|
resolution: {integrity: sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==}
|
||||||
|
|
||||||
|
element-china-area-data@6.1.0:
|
||||||
|
resolution: {integrity: sha512-IkpcjwQv2A/2AxFiSoaISZ+oMw1rZCPUSOg5sOCwT5jKc96TaawmKZeY81xfxXsO0QbKxU5LLc6AirhG52hUmg==}
|
||||||
|
|
||||||
emoji-regex@8.0.0:
|
emoji-regex@8.0.0:
|
||||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||||
|
|
||||||
@@ -3594,6 +3603,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
get-func-name: 2.0.2
|
get-func-name: 2.0.2
|
||||||
|
|
||||||
|
china-division@2.7.0: {}
|
||||||
|
|
||||||
chokidar@3.6.0:
|
chokidar@3.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
anymatch: 3.1.3
|
anymatch: 3.1.3
|
||||||
@@ -3768,6 +3779,10 @@ snapshots:
|
|||||||
|
|
||||||
electron-to-chromium@1.5.380: {}
|
electron-to-chromium@1.5.380: {}
|
||||||
|
|
||||||
|
element-china-area-data@6.1.0:
|
||||||
|
dependencies:
|
||||||
|
china-division: 2.7.0
|
||||||
|
|
||||||
emoji-regex@8.0.0: {}
|
emoji-regex@8.0.0: {}
|
||||||
|
|
||||||
emoji-regex@9.2.2: {}
|
emoji-regex@9.2.2: {}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { calcBenefitAmount, calcBenefitSummary, generateCouponNo } from '@dukang/domain';
|
import { calcBenefitAmount, calcBenefitSummary, generateCouponNo } from '@dukang/domain';
|
||||||
import { REDEEM_MAX_AMOUNT } from '@dukang/shared-types';
|
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
|
|
||||||
@@ -65,7 +64,6 @@ export class BenefitService {
|
|||||||
});
|
});
|
||||||
const summary = calcBenefitSummary(
|
const summary = calcBenefitSummary(
|
||||||
coupons.map((c) => Number(c.balance)),
|
coupons.map((c) => Number(c.balance)),
|
||||||
REDEEM_MAX_AMOUNT,
|
|
||||||
);
|
);
|
||||||
return serializeBigInt(summary);
|
return serializeBigInt(summary);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user