feat: 项目接口联调
This commit is contained in:
@@ -1,98 +1,289 @@
|
||||
import PageLayout from '@/components/layout'
|
||||
import BorderlessTable, { type TableColumn } from '@/components/table'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {useState} from 'react'
|
||||
|
||||
type AccountTableRow = {
|
||||
import PageLayout from '@/components/layout'
|
||||
import BorderlessTable from '@/components/table'
|
||||
import Modal from '@/components/modal'
|
||||
import Button from '@/components/button'
|
||||
import {Link} from 'react-router-dom'
|
||||
import {ArrowLeft, BadgeCheck, MapPinHouse, PencilLine, Plus, Trash2} from 'lucide-react'
|
||||
import {useAddressBook} from '@/features/addressBook'
|
||||
import {GoodsRedeemModal} from '@/features/goods'
|
||||
import {notifySuccess} from '@/features/notifications'
|
||||
import type {AddressListItem} from '@/types/address.type.ts'
|
||||
import type {TableColumn} from '@/types'
|
||||
|
||||
type AddressTableRow = {
|
||||
id: string
|
||||
name: string
|
||||
phone: string
|
||||
address: string
|
||||
code: string
|
||||
action: string
|
||||
setting: string
|
||||
}
|
||||
|
||||
function AccountPage() {
|
||||
const columns: TableColumn<AccountTableRow>[] = [
|
||||
const addressBook = useAddressBook({autoLoad: true})
|
||||
const [addressModalOpen, setAddressModalOpen] = useState(false)
|
||||
const [editingAddress, setEditingAddress] = useState<AddressListItem | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<AddressListItem | null>(null)
|
||||
|
||||
const rows: AddressTableRow[] = addressBook.addresses.map((item) => ({
|
||||
id: String(item.id),
|
||||
name: item.receiver_name,
|
||||
phone: item.phone,
|
||||
address: addressBook.addressOptions.find((option) => option.id === String(item.id))?.address ?? '',
|
||||
action: 'Edit',
|
||||
setting: item.default_setting === 1 ? 'Default' : 'Optional',
|
||||
}))
|
||||
|
||||
const isAddressFormValid = addressBook.isAddressFormValid
|
||||
|
||||
const handleOpenAddAddress = () => {
|
||||
setEditingAddress(null)
|
||||
addressBook.resetAddressForm()
|
||||
setAddressModalOpen(true)
|
||||
}
|
||||
|
||||
const handleOpenEditAddress = (address: AddressListItem) => {
|
||||
setEditingAddress(address)
|
||||
addressBook.fillAddressForm(address)
|
||||
setAddressModalOpen(true)
|
||||
}
|
||||
|
||||
const handleCloseAddressModal = () => {
|
||||
setAddressModalOpen(false)
|
||||
setEditingAddress(null)
|
||||
addressBook.resetAddressForm()
|
||||
}
|
||||
|
||||
const handleSubmitAddress = async () => {
|
||||
const saved = await addressBook.saveAddress(editingAddress)
|
||||
if (saved) {
|
||||
handleCloseAddressModal()
|
||||
notifySuccess(saved.response, editingAddress ? 'Address updated successfully.' : 'Address added successfully.')
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deleteTarget) {
|
||||
return
|
||||
}
|
||||
|
||||
const deleted = await addressBook.removeAddress(String(deleteTarget.id))
|
||||
if (deleted) {
|
||||
setDeleteTarget(null)
|
||||
notifySuccess(deleted, 'Address deleted successfully.')
|
||||
}
|
||||
}
|
||||
|
||||
const columns: TableColumn<AddressTableRow>[] = [
|
||||
{
|
||||
label: 'Name',
|
||||
key: 'name',
|
||||
render: (value: string) => <div>{value}</div>
|
||||
render: (value: string) => <div className="font-medium text-white">{value}</div>,
|
||||
},
|
||||
{
|
||||
label: 'Phone / Mobile',
|
||||
key: 'phone',
|
||||
render: (value: string) => <div>{value}</div>
|
||||
render: (value: string) => <div className="text-white/72">{value}</div>,
|
||||
},
|
||||
{
|
||||
label: 'Address',
|
||||
key: 'address',
|
||||
render: (value: string) => <div>{value}</div>
|
||||
},
|
||||
{
|
||||
label: 'Postal Code',
|
||||
key: 'code',
|
||||
render: (value: string) => <div>{value}</div>
|
||||
render: (value: string) => <div className="max-w-[280px] text-white/72">{value}</div>,
|
||||
},
|
||||
{
|
||||
label: 'Action',
|
||||
key: 'action',
|
||||
render: (value: string) => <div>{value}</div>
|
||||
render: (_value: string, _record: AddressTableRow, index: number) => (
|
||||
<div className="flex items-center gap-[8px]">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-[6px] rounded-full bg-white/6 px-[10px] py-[6px] text-[12px] text-white/82 transition-colors hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]"
|
||||
onClick={() => handleOpenEditAddress(addressBook.addresses[index])}
|
||||
>
|
||||
<PencilLine className="h-[12px] w-[12px]" aria-hidden="true" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-[6px] rounded-full bg-[#4B1818]/70 px-[10px] py-[6px] text-[12px] text-[#FFB1B1] transition-colors hover:bg-[#612121]/80 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]"
|
||||
onClick={() => setDeleteTarget(addressBook.addresses[index])}
|
||||
>
|
||||
<Trash2 className="h-[12px] w-[12px]" aria-hidden="true" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Default Setting',
|
||||
key: 'setting',
|
||||
render: (value: string) => <div>{value}</div>
|
||||
}
|
||||
]
|
||||
|
||||
const dataSource: AccountTableRow[] = [
|
||||
{
|
||||
name: 'Jia Jun',
|
||||
phone: '+86 138 0000 1288',
|
||||
address: 'No. 88 Century Avenue, Pudong New Area, Shanghai',
|
||||
code: '200120',
|
||||
action: 'Edit',
|
||||
setting: 'Default',
|
||||
},
|
||||
{
|
||||
name: 'Alicia Tan',
|
||||
phone: '+65 9123 4567',
|
||||
address: '18 Robinson Road, Singapore',
|
||||
code: '048547',
|
||||
action: 'Edit',
|
||||
setting: 'Optional',
|
||||
},
|
||||
{
|
||||
name: 'Marcus Lee',
|
||||
phone: '+60 12 778 9911',
|
||||
address: '27 Jalan Bukit Bintang, Kuala Lumpur',
|
||||
code: '55100',
|
||||
action: 'Edit',
|
||||
setting: 'Optional',
|
||||
render: (value: string) => (
|
||||
<div
|
||||
className={`inline-flex rounded-full px-[10px] py-[5px] text-[12px] ${
|
||||
value === 'Default'
|
||||
? 'bg-[#FA6A00]/14 text-[#FFB36D]'
|
||||
: 'bg-white/6 text-white/62'
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<PageLayout contentClassName="min-h-screen">
|
||||
<PageLayout contentClassName="mx-auto flex min-h-screen w-full max-w-[1120px] flex-col px-4 pb-8 sm:px-6 lg:px-8">
|
||||
<Link
|
||||
to="/"
|
||||
className={'relative text-[#F56E10] flex h-[40px] w-full items-center justify-center bg-[#08070E]/70'}
|
||||
className="mt-[12px] flex h-[44px] items-center justify-between rounded-[12px] bg-[#08070E]/72 px-[14px] text-[#F56E10] transition-colors hover:bg-[#0D0A14]/80 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E] sm:mt-[16px]"
|
||||
>
|
||||
<div className={'absolute left-[16px]'}> < </div>
|
||||
<div>Account</div>
|
||||
<div className="flex items-center gap-[8px]">
|
||||
<ArrowLeft className="h-[16px] w-[16px]" aria-hidden="true" />
|
||||
<span className="text-[14px] font-medium text-white/92">Back</span>
|
||||
</div>
|
||||
<div className="text-[15px] font-semibold text-[#F56E10]">Account</div>
|
||||
<div className="w-[52px]"></div>
|
||||
</Link>
|
||||
|
||||
<div className={'mt-[20px] w-full flex items-center justify-center'}>
|
||||
|
||||
<div className={'w-[80%]'}>
|
||||
<div className={'w-full mb-[10px] flex items-center justify-between'}>
|
||||
<div>My Shipping Address</div>
|
||||
<div className={'liquid-glass-bg px-[10px] py-[5px] text-sm'}>Add Address</div>
|
||||
<div className="mx-auto mt-[20px] w-full max-w-[1000px]">
|
||||
<div className="mb-[14px] flex flex-col gap-[12px] sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-[10px]">
|
||||
<div className="flex h-[38px] w-[38px] items-center justify-center rounded-[12px] bg-[#FA6A00]/15 text-[#FE9F00]">
|
||||
<MapPinHouse className="h-[18px] w-[18px]" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[16px] font-semibold text-white">My Shipping Address</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BorderlessTable columns={columns} dataSource={dataSource} />
|
||||
<button
|
||||
type="button"
|
||||
className="liquid-glass-bg inline-flex h-[40px] items-center justify-center gap-[8px] px-[14px] text-sm text-white transition-colors hover:bg-white/28 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]"
|
||||
onClick={handleOpenAddAddress}
|
||||
>
|
||||
<Plus className="h-[14px] w-[14px]" aria-hidden="true" />
|
||||
Add Address
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{addressBook.loading ? (
|
||||
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
|
||||
Loading address list...
|
||||
</div>
|
||||
) : !addressBook.addresses.length ? (
|
||||
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
|
||||
No shipping address found. Add one to start redeeming physical rewards.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-[12px] lg:hidden">
|
||||
{rows.map((item, index) => (
|
||||
<div key={item.id} className="liquid-glass-bg p-[14px]">
|
||||
<div className="flex items-start justify-between gap-[12px]">
|
||||
<div>
|
||||
<div className="text-[16px] font-semibold text-white">{item.name}</div>
|
||||
<div className="mt-[4px] text-[13px] text-white/62">{item.phone}</div>
|
||||
</div>
|
||||
<div
|
||||
className={`inline-flex rounded-full px-[10px] py-[5px] text-[12px] ${
|
||||
item.setting === 'Default'
|
||||
? 'bg-[#FA6A00]/14 text-[#FFB36D]'
|
||||
: 'bg-white/6 text-white/62'
|
||||
}`}
|
||||
>
|
||||
{item.setting === 'Default' ? (
|
||||
<span className="inline-flex items-center gap-[5px]">
|
||||
<BadgeCheck className="h-[12px] w-[12px]" aria-hidden="true" />
|
||||
{item.setting}
|
||||
</span>
|
||||
) : item.setting}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-[12px] rounded-[10px] bg-black/12 p-[12px]">
|
||||
<div className="text-[12px] uppercase tracking-[0.08em] text-white/44">Address</div>
|
||||
<div className="mt-[6px] text-[13px] leading-[1.6] text-white/78">{item.address}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-[12px] inline-flex items-center gap-[6px] rounded-full bg-white/6 px-[10px] py-[6px] text-[12px] text-white/82 transition-colors hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]"
|
||||
onClick={() => handleOpenEditAddress(addressBook.addresses[index])}
|
||||
>
|
||||
<PencilLine className="h-[12px] w-[12px]" aria-hidden="true" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-[10px] inline-flex items-center gap-[6px] rounded-full bg-[#4B1818]/70 px-[10px] py-[6px] text-[12px] text-[#FFB1B1] transition-colors hover:bg-[#612121]/80 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]"
|
||||
onClick={() => setDeleteTarget(addressBook.addresses[index])}
|
||||
>
|
||||
<Trash2 className="h-[12px] w-[12px]" aria-hidden="true" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<BorderlessTable columns={columns} dataSource={rows} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<GoodsRedeemModal
|
||||
selectedProduct={null}
|
||||
modalMode="add-address"
|
||||
addressOptions={[]}
|
||||
selectedAddressId=""
|
||||
addressForm={addressBook.addressForm}
|
||||
addressLoading={false}
|
||||
isAddAddressFormValid={isAddressFormValid}
|
||||
submitLoading={addressBook.submitLoading}
|
||||
onClose={handleCloseAddressModal}
|
||||
onConfirm={handleSubmitAddress}
|
||||
onOpenAddAddress={handleOpenAddAddress}
|
||||
onBackToSelectAddress={handleCloseAddressModal}
|
||||
onSelectAddress={() => {}}
|
||||
onChangeAddressForm={addressBook.changeAddressForm}
|
||||
forceOpen={addressModalOpen}
|
||||
formOnly
|
||||
titleOverride={editingAddress ? 'Edit Shipping Address' : 'Add Shipping Address'}
|
||||
confirmText={editingAddress ? 'Save Changes' : 'Add Address'}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={Boolean(deleteTarget)}
|
||||
title="Delete Address"
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
className="max-w-[420px]"
|
||||
bodyClassName="space-y-[18px]"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="gray"
|
||||
className="h-[38px] w-full sm:w-auto sm:min-w-[120px]"
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
disabled={addressBook.deleteLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="h-[38px] w-full sm:w-auto sm:min-w-[120px]"
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={addressBook.deleteLoading}
|
||||
>
|
||||
{addressBook.deleteLoading ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="rounded-[12px] bg-[#1C1818]/82 px-[14px] py-[18px] text-[15px] leading-[1.65] text-white/92 shadow-[0_10px_30px_rgba(0,0,0,0.2)]">
|
||||
{deleteTarget ? `Delete the address for ${deleteTarget.receiver_name}? ` : ''}
|
||||
</div>
|
||||
</Modal>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
68
src/views/goods/index.tsx
Normal file
68
src/views/goods/index.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import {ArrowLeft} from 'lucide-react'
|
||||
import {Link, useSearchParams} from 'react-router-dom'
|
||||
|
||||
import PageLayout from '@/components/layout'
|
||||
import {HOME_GOOD_TYPE_ORDER} from '@/constant'
|
||||
import {
|
||||
GoodsCategoryList,
|
||||
GoodsRedeemModal,
|
||||
isGoodsType,
|
||||
useGoodsCatalog,
|
||||
useGoodsRedeem,
|
||||
} from '@/features/goods'
|
||||
|
||||
function GoodsPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const queryType = searchParams.get('type')
|
||||
const selectedType = isGoodsType(queryType) ? queryType : HOME_GOOD_TYPE_ORDER[0]
|
||||
const {productCategories, loading} = useGoodsCatalog({types: [selectedType]})
|
||||
const redeem = useGoodsRedeem()
|
||||
const visibleCategories = productCategories.filter((category) => category.id === selectedType)
|
||||
|
||||
return (
|
||||
<PageLayout contentClassName="mx-auto flex min-h-screen w-full max-w-[1180px] flex-col px-4 pb-8 sm:px-6 lg:px-8">
|
||||
<Link
|
||||
to="/"
|
||||
className="mt-[12px] flex h-[44px] items-center justify-between rounded-[12px] bg-[#08070E]/72 px-[14px] text-[#F56E10] transition-colors hover:bg-[#0D0A14]/80 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E] sm:mt-[16px]"
|
||||
>
|
||||
<div className="flex items-center gap-[8px]">
|
||||
<ArrowLeft className="h-[16px] w-[16px]" aria-hidden="true"/>
|
||||
<span className="text-[14px] font-medium text-white/92">Back</span>
|
||||
</div>
|
||||
<div className="text-[15px] font-semibold text-[#F56E10]">{queryType}</div>
|
||||
<div className="w-[52px]"></div>
|
||||
</Link>
|
||||
|
||||
<div className="mx-auto w-full max-w-[1120px] pt-[18px] pb-[24px]">
|
||||
<div className="h-px bg-white/16"></div>
|
||||
<div className="mt-[14px]">
|
||||
<GoodsCategoryList
|
||||
categories={visibleCategories}
|
||||
loading={loading}
|
||||
emptyText="No goods found for this category."
|
||||
onRedeem={redeem.openRedeemModal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GoodsRedeemModal
|
||||
selectedProduct={redeem.selectedProduct}
|
||||
modalMode={redeem.modalMode}
|
||||
addressOptions={redeem.addressOptions}
|
||||
selectedAddressId={redeem.selectedAddressId}
|
||||
addressForm={redeem.addressForm}
|
||||
addressLoading={redeem.addressLoading}
|
||||
isAddAddressFormValid={redeem.isAddAddressFormValid}
|
||||
submitLoading={redeem.submitLoading}
|
||||
onClose={redeem.closeRedeemModal}
|
||||
onConfirm={redeem.confirmRedeem}
|
||||
onOpenAddAddress={redeem.openAddAddress}
|
||||
onBackToSelectAddress={redeem.backToSelectAddress}
|
||||
onSelectAddress={redeem.setSelectedAddressId}
|
||||
onChangeAddressForm={redeem.changeAddressForm}
|
||||
/>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default GoodsPage
|
||||
@@ -1,258 +1,87 @@
|
||||
import { useState } from 'react'
|
||||
import {useState} from 'react'
|
||||
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import recordSvg from '@/assets/record.svg'
|
||||
import accountSvg from '@/assets/account.svg'
|
||||
import PageLayout from '@/components/layout'
|
||||
import Modal from '@/components/modal'
|
||||
import { Link } from 'react-router-dom'
|
||||
import Button from '@/components/button'
|
||||
import {Link, useNavigate} from 'react-router-dom'
|
||||
import {
|
||||
ChevronRight,
|
||||
Coins,
|
||||
Gauge,
|
||||
History,
|
||||
UserRound,
|
||||
Wallet,
|
||||
} from 'lucide-react'
|
||||
import type {
|
||||
ProductCategory,
|
||||
QuickNavCardProps,
|
||||
} from '@/types'
|
||||
import {
|
||||
GoodsCategoryList,
|
||||
GoodsRedeemModal,
|
||||
useAssetsQuery,
|
||||
useAssetsRefresh,
|
||||
useGoodsCatalog,
|
||||
useGoodsRedeem,
|
||||
} from '@/features/goods'
|
||||
import {validateClaimSubmission} from '@/features/home/claimValidation'
|
||||
import {claim} from '@/api/business.ts'
|
||||
import {notifyError, notifySuccess} from '@/features/notifications'
|
||||
import {useUserStore} from "@/store/user.ts";
|
||||
|
||||
type QuickNavCardProps = {
|
||||
icon: string
|
||||
label: string
|
||||
to: string
|
||||
}
|
||||
|
||||
type ProductItem = {
|
||||
id: string
|
||||
title: string
|
||||
subtitle: string
|
||||
points: string
|
||||
ctaLabel: string
|
||||
imageClassName: string
|
||||
}
|
||||
|
||||
type ProductCategory = {
|
||||
id: string
|
||||
name: string
|
||||
items: ProductItem[]
|
||||
}
|
||||
|
||||
type SelectedProductState = {
|
||||
categoryId: ProductCategory['id']
|
||||
product: ProductItem
|
||||
}
|
||||
|
||||
type AddressOption = {
|
||||
id: string
|
||||
name: string
|
||||
phone: string
|
||||
address: string
|
||||
postalCode: string
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
type ModalMode = 'select-address' | 'add-address'
|
||||
|
||||
type AddAddressForm = {
|
||||
name: string
|
||||
phone: string
|
||||
region: string
|
||||
detailedAddress: string
|
||||
postalCode: string
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
function QuickNavCard({ icon, label, to }: QuickNavCardProps) {
|
||||
function QuickNavCard({icon: Icon, label, to}: QuickNavCardProps) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={'liquid-glass-bg rounded-[10px] flex items-center pr-[10px] cursor-pointer'}
|
||||
className="liquid-glass-bg flex items-center justify-between gap-[12px] rounded-[12px] px-[12px] py-[8px] text-[13px] text-white/88 transition-colors hover:bg-white/28 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]"
|
||||
>
|
||||
<div className={'flex px-[10px] py-[5px] items-center gap-[5px]'}>
|
||||
<div className={'p-[5px] rounded-[10px] bg-linear-to-b from-[#FB8001] to-[#FCAA2C]'}>
|
||||
<img src={icon} className={'w-[16px] h-[16px]'} />
|
||||
<div className="flex min-w-0 items-center gap-[10px]">
|
||||
<div
|
||||
className="flex h-[32px] w-[32px] items-center justify-center rounded-[10px] bg-linear-to-b from-[#FB8001] to-[#FCAA2C] text-white shadow-[0_8px_18px_rgba(250,109,2,0.24)]">
|
||||
<Icon className="h-[16px] w-[16px] shrink-0" aria-hidden="true"/>
|
||||
</div>
|
||||
<div>{label}</div>
|
||||
<div className="truncate font-medium capitalize">{label}</div>
|
||||
</div>
|
||||
<div>></div>
|
||||
<ChevronRight className="h-[16px] w-[16px] shrink-0 text-white/70" aria-hidden="true"/>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const productCategories: ProductCategory[] = [
|
||||
{
|
||||
id: 'transfer-to-platform',
|
||||
name: 'Transfer to Platform',
|
||||
items: [
|
||||
{
|
||||
id: 'transfer-100',
|
||||
title: 'Transfer 100',
|
||||
subtitle: '1 x Turnover',
|
||||
points: '1000 Points',
|
||||
ctaLabel: 'Transfer Now',
|
||||
imageClassName: 'bg-[linear-gradient(135deg,_#5B1D00_0%,_#FA6A00_55%,_#FFBC6D_100%)]',
|
||||
},
|
||||
{
|
||||
id: 'transfer-300',
|
||||
title: 'Transfer 300',
|
||||
subtitle: '2 x Turnover',
|
||||
points: '2800 Points',
|
||||
ctaLabel: 'Transfer Now',
|
||||
imageClassName: 'bg-[linear-gradient(135deg,_#1C1A48_0%,_#5050D8_55%,_#8AB6FF_100%)]',
|
||||
},
|
||||
{
|
||||
id: 'transfer-500',
|
||||
title: 'Transfer 500',
|
||||
subtitle: '3 x Turnover',
|
||||
points: '4200 Points',
|
||||
ctaLabel: 'Transfer Now',
|
||||
imageClassName: 'bg-[linear-gradient(135deg,_#153A35_0%,_#1F9D8B_55%,_#8BF3D8_100%)]',
|
||||
},
|
||||
{
|
||||
id: 'transfer-1000',
|
||||
title: 'Transfer 1000',
|
||||
subtitle: '5 x Turnover',
|
||||
points: '7800 Points',
|
||||
ctaLabel: 'Transfer Now',
|
||||
imageClassName: 'bg-[linear-gradient(135deg,_#421515_0%,_#C93D3D_52%,_#FF9B9B_100%)]',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'game-bonus',
|
||||
name: 'Game Bonus',
|
||||
items: [
|
||||
{
|
||||
id: 'bonus-spin',
|
||||
title: 'Lucky Spin Pack',
|
||||
subtitle: 'Bonus Voucher',
|
||||
points: '850 Points',
|
||||
ctaLabel: 'Redeem Bonus',
|
||||
imageClassName: 'bg-[linear-gradient(135deg,_#28103D_0%,_#8E3DD1_50%,_#F1A8FF_100%)]',
|
||||
},
|
||||
{
|
||||
id: 'bonus-chip',
|
||||
title: 'Chip Booster',
|
||||
subtitle: 'Casino Special',
|
||||
points: '1600 Points',
|
||||
ctaLabel: 'Redeem Bonus',
|
||||
imageClassName: 'bg-[linear-gradient(135deg,_#2D2407_0%,_#C79200_52%,_#FFE58A_100%)]',
|
||||
},
|
||||
{
|
||||
id: 'bonus-cashback',
|
||||
title: 'Cashback Card',
|
||||
subtitle: 'Weekly Reward',
|
||||
points: '2400 Points',
|
||||
ctaLabel: 'Redeem Bonus',
|
||||
imageClassName: 'bg-[linear-gradient(135deg,_#062A2F_0%,_#1296A5_52%,_#6DE2F0_100%)]',
|
||||
},
|
||||
{
|
||||
id: 'bonus-vip',
|
||||
title: 'VIP Match Bonus',
|
||||
subtitle: 'Limited Access',
|
||||
points: '5000 Points',
|
||||
ctaLabel: 'Redeem Bonus',
|
||||
imageClassName: 'bg-[linear-gradient(135deg,_#35120A_0%,_#DD6C2F_52%,_#FFC09A_100%)]',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'physical-prizes',
|
||||
name: 'Physical Prizes',
|
||||
items: [
|
||||
{
|
||||
id: 'prize-headset',
|
||||
title: 'Gaming Headset',
|
||||
subtitle: 'Physical Delivery',
|
||||
points: '6800 Points',
|
||||
ctaLabel: 'Claim Prize',
|
||||
imageClassName: 'bg-[linear-gradient(135deg,_#111827_0%,_#374151_50%,_#A3AAB8_100%)]',
|
||||
},
|
||||
{
|
||||
id: 'prize-mouse',
|
||||
title: 'Wireless Mouse',
|
||||
subtitle: 'Physical Delivery',
|
||||
points: '4200 Points',
|
||||
ctaLabel: 'Claim Prize',
|
||||
imageClassName: 'bg-[linear-gradient(135deg,_#19212F_0%,_#3A5B84_50%,_#98B8E3_100%)]',
|
||||
},
|
||||
{
|
||||
id: 'prize-speaker',
|
||||
title: 'Bluetooth Speaker',
|
||||
subtitle: 'Physical Delivery',
|
||||
points: '7300 Points',
|
||||
ctaLabel: 'Claim Prize',
|
||||
imageClassName: 'bg-[linear-gradient(135deg,_#32120F_0%,_#A73F2F_50%,_#F0A28D_100%)]',
|
||||
},
|
||||
{
|
||||
id: 'prize-keyboard',
|
||||
title: 'Mechanical Keyboard',
|
||||
subtitle: 'Physical Delivery',
|
||||
points: '9500 Points',
|
||||
ctaLabel: 'Claim Prize',
|
||||
imageClassName: 'bg-[linear-gradient(135deg,_#142C17_0%,_#2B7A38_52%,_#8DE69B_100%)]',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
function getProgressPercent(current = 0, total = 0) {
|
||||
if (total <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const initialAddressOptions: AddressOption[] = [
|
||||
{
|
||||
id: 'address-shanghai',
|
||||
name: 'Jia Jun',
|
||||
phone: '+86 138 0000 1288',
|
||||
address: 'No. 88 Century Avenue, Pudong New Area, Shanghai',
|
||||
postalCode: '200120',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
id: 'address-singapore',
|
||||
name: 'Alicia Tan',
|
||||
phone: '+65 9123 4567',
|
||||
address: '18 Robinson Road, Singapore',
|
||||
postalCode: '048547',
|
||||
},
|
||||
{
|
||||
id: 'address-kuala-lumpur',
|
||||
name: 'Marcus Lee',
|
||||
phone: '+60 12 778 9911',
|
||||
address: '27 Jalan Bukit Bintang, Kuala Lumpur',
|
||||
postalCode: '55100',
|
||||
},
|
||||
]
|
||||
|
||||
const emptyAddressForm: AddAddressForm = {
|
||||
name: '',
|
||||
phone: '',
|
||||
region: '',
|
||||
detailedAddress: '',
|
||||
postalCode: '',
|
||||
isDefault: false,
|
||||
}
|
||||
|
||||
function getNumericValue(value: string) {
|
||||
const matched = value.match(/\d+/)
|
||||
return matched ? matched[0] : value
|
||||
}
|
||||
|
||||
function getTurnoverRequirement(subtitle: string) {
|
||||
const matched = subtitle.match(/\d+/)
|
||||
return matched ? `${matched[0]}x` : subtitle
|
||||
return Math.min((current / total) * 100, 100)
|
||||
}
|
||||
|
||||
function HomePage() {
|
||||
const [selectedProduct, setSelectedProduct] = useState<SelectedProductState | null>(null)
|
||||
const [claimModalOpen, setClaimModalOpen] = useState(false)
|
||||
const [modalMode, setModalMode] = useState<ModalMode>('select-address')
|
||||
const [addressOptions, setAddressOptions] = useState<AddressOption[]>(initialAddressOptions)
|
||||
const [selectedAddressId, setSelectedAddressId] = useState<string>(initialAddressOptions[0]?.id ?? '')
|
||||
const [addressForm, setAddressForm] = useState<AddAddressForm>(emptyAddressForm)
|
||||
const navigate = useNavigate()
|
||||
const authInfo = useUserStore(state => state.authInfo)
|
||||
const {productCategories, loading} = useGoodsCatalog()
|
||||
const {invalidateAssets} = useAssetsRefresh()
|
||||
const redeem = useGoodsRedeem()
|
||||
const claimMutation = useMutation({
|
||||
mutationFn: async (claimRequestId: string) => {
|
||||
return await claim({
|
||||
claim_request_id: claimRequestId,
|
||||
session_id: authInfo!.session_id,
|
||||
})
|
||||
},
|
||||
})
|
||||
const syncBalanceMutation = useMutation({
|
||||
mutationFn: invalidateAssets,
|
||||
})
|
||||
|
||||
const handleOpenRedeemModal = (product: ProductItem, categoryId: ProductCategory['id']) => {
|
||||
setSelectedProduct({
|
||||
product,
|
||||
categoryId,
|
||||
})
|
||||
setSelectedAddressId(addressOptions[0]?.id ?? '')
|
||||
setModalMode('select-address')
|
||||
setAddressForm(emptyAddressForm)
|
||||
}
|
||||
|
||||
const handleCloseRedeemModal = () => {
|
||||
setSelectedProduct(null)
|
||||
setModalMode('select-address')
|
||||
setAddressForm(emptyAddressForm)
|
||||
}
|
||||
const {assetsInfo} = useAssetsQuery()
|
||||
const claimProgress = getProgressPercent(assetsInfo?.today_claimed, assetsInfo?.today_limit)
|
||||
const previewCategories: ProductCategory[] = productCategories.map((category) => ({
|
||||
...category,
|
||||
items: category.items.slice(0, 4),
|
||||
}))
|
||||
|
||||
const handleOpenClaimModal = () => {
|
||||
setClaimModalOpen(true)
|
||||
@@ -260,392 +89,158 @@ function HomePage() {
|
||||
|
||||
const handleCloseClaimModal = () => {
|
||||
setClaimModalOpen(false)
|
||||
claimMutation.reset()
|
||||
}
|
||||
|
||||
const handleOpenAddAddress = () => {
|
||||
setModalMode('add-address')
|
||||
const handleSyncBalance = async () => {
|
||||
try {
|
||||
await syncBalanceMutation.mutateAsync()
|
||||
notifySuccess('Balance synced successfully.')
|
||||
} catch {
|
||||
// request interceptor handles interface error toast
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangeAddressForm = (field: keyof AddAddressForm, value: string | boolean) => {
|
||||
setAddressForm((previous) => ({
|
||||
...previous,
|
||||
[field]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
const isAddAddressFormValid = [
|
||||
addressForm.name,
|
||||
addressForm.phone,
|
||||
addressForm.region,
|
||||
addressForm.detailedAddress,
|
||||
].every((value) => value.trim())
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (modalMode === 'add-address') {
|
||||
if (!isAddAddressFormValid) {
|
||||
return
|
||||
}
|
||||
|
||||
const newAddress: AddressOption = {
|
||||
id: `address-${Date.now()}`,
|
||||
name: addressForm.name.trim(),
|
||||
phone: addressForm.phone.trim(),
|
||||
address: `${addressForm.region.trim()}, ${addressForm.detailedAddress.trim()}`,
|
||||
postalCode: addressForm.postalCode.trim() || 'N/A',
|
||||
isDefault: addressForm.isDefault,
|
||||
}
|
||||
|
||||
setAddressOptions((previous) => {
|
||||
const normalizedPrevious = addressForm.isDefault
|
||||
? previous.map((item) => ({ ...item, isDefault: false }))
|
||||
: previous
|
||||
|
||||
return [...normalizedPrevious, newAddress]
|
||||
})
|
||||
setSelectedAddressId(newAddress.id)
|
||||
setAddressForm(emptyAddressForm)
|
||||
setModalMode('select-address')
|
||||
const handleConfirmClaim = async () => {
|
||||
const claimValidation = validateClaimSubmission(authInfo)
|
||||
if (!claimValidation.valid) {
|
||||
notifyError(claimValidation.message)
|
||||
return
|
||||
}
|
||||
handleCloseRedeemModal()
|
||||
|
||||
try {
|
||||
const response = await claimMutation.mutateAsync(`${authInfo!.user_id}${Date.now()}`)
|
||||
await invalidateAssets()
|
||||
notifySuccess(response, 'Claim submitted successfully.')
|
||||
setClaimModalOpen(false)
|
||||
} catch {
|
||||
// request errors are surfaced by the shared request toast
|
||||
}
|
||||
}
|
||||
|
||||
const selectedCategoryId = selectedProduct?.categoryId
|
||||
const selectedProductData = selectedProduct?.product ?? null
|
||||
const isPhysicalPrize = selectedCategoryId === 'physical-prizes'
|
||||
const isTransferToPlatform = selectedCategoryId === 'transfer-to-platform'
|
||||
const isGameBonus = selectedCategoryId === 'game-bonus'
|
||||
const modalTitle = modalMode === 'add-address'
|
||||
? 'Add Shipping Address'
|
||||
: isTransferToPlatform
|
||||
? 'Confirm Withdrawal'
|
||||
: isGameBonus
|
||||
? 'Confirm Bonus Redemption'
|
||||
: 'Redeem Product'
|
||||
const modalMaxWidthClassName = isTransferToPlatform
|
||||
? 'max-w-[620px]'
|
||||
: isGameBonus
|
||||
? 'max-w-[460px]'
|
||||
: 'max-w-[720px]'
|
||||
const handleMoreClick = (type: ProductCategory['id']) => {
|
||||
navigate(`/goods?type=${type}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className={'flex justify-end gap-2 py-[10px]'}>
|
||||
<QuickNavCard to="/record" icon={recordSvg} label="record" />
|
||||
<QuickNavCard to="/account" icon={accountSvg} label="account" />
|
||||
<div
|
||||
className="grid grid-cols-2 gap-2 py-[14px] sm:ml-auto sm:flex sm:w-auto sm:grid-cols-none sm:justify-end">
|
||||
<QuickNavCard to="/record" icon={History} label="record"/>
|
||||
<QuickNavCard to="/account" icon={UserRound} label="account"/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={'flex gap-[5px]'}>
|
||||
<div className={'liquid-glass-bg h-[167px] w-[267px] p-[10px] flex flex-col justify-start'}>
|
||||
<div>Claimable Points</div>
|
||||
<div>2,880</div>
|
||||
<div>Yesterday's losses
|
||||
have been converted
|
||||
to points.Claim to use.
|
||||
<div className="mt-[4px]">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-stretch">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:w-[544px] lg:shrink-0">
|
||||
<div className="liquid-glass-bg flex min-h-[167px] flex-col justify-between p-[14px]">
|
||||
<div className="flex items-start justify-between gap-[12px]">
|
||||
<div>
|
||||
<div className="text-[13px] uppercase tracking-[0.16em] text-white/58">Claimable
|
||||
Points
|
||||
</div>
|
||||
<div
|
||||
className="mt-[10px] text-[34px] font-semibold leading-none text-white">{assetsInfo?.locked_points || 0}</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex h-[38px] w-[38px] items-center justify-center rounded-[12px] bg-[#FA6A00]/16 text-[#FE9F00]">
|
||||
<Coins className="h-[18px] w-[18px]" aria-hidden="true"/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-w-[28ch] text-[13px] leading-[1.6] text-white/68">
|
||||
Yesterday's losses have been converted into points. Claim them to use in rewards.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'liquid-glass-bg h-[167px] w-[267px] p-[10px] flex flex-col justify-start'}>
|
||||
<div>Daily Claim Limit</div>
|
||||
<div>1,500</div>
|
||||
<div
|
||||
className={'progress-bar'}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={1500}
|
||||
aria-valuenow={800}
|
||||
>
|
||||
<div className={'progress-bar__fill'} style={{ width: '53.33%' }}></div>
|
||||
<div className="liquid-glass-bg flex min-h-[167px] flex-col justify-around p-[14px]">
|
||||
<div className="flex items-start justify-between gap-[12px]">
|
||||
<div>
|
||||
<div className="text-[13px] uppercase tracking-[0.16em] text-white/58">Daily Claim
|
||||
Limit
|
||||
</div>
|
||||
<div
|
||||
className="mt-[10px] text-[34px] font-semibold leading-none text-white">{assetsInfo?.locked_points}</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex h-[38px] w-[38px] items-center justify-center rounded-[12px] bg-[#FA6A00]/16 text-[#FE9F00]">
|
||||
<Gauge className="h-[18px] w-[18px]" aria-hidden="true"/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mt-[14px] h-[10px] w-full overflow-hidden rounded-full border border-white/8 bg-[linear-gradient(180deg,rgba(18,14,10,0.92)_0%,rgba(34,22,14,0.96)_100%)] shadow-[inset_0_1px_2px_rgba(0,0,0,0.45)]"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={assetsInfo?.today_limit || 0}
|
||||
aria-valuenow={assetsInfo?.today_claimed || 0}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-linear-to-r from-[#FE9C00] to-[#FA6D02] shadow-[inset_0_0_0_1px_rgba(255,220,160,0.35),0_0_8px_rgba(254,156,0,0.55),0_0_16px_rgba(250,109,2,0.4),0_0_24px_rgba(250,109,2,0.22)]"
|
||||
style={{width: `${claimProgress}%`}}
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
className="mt-[10px] text-[13px] text-white/68">Claimed: <span className={'text-[#FE9C00]'}>{assetsInfo?.today_claimed || 0}</span> / {assetsInfo?.today_limit || 0}</div>
|
||||
</div>
|
||||
<div>Claimed: 800 / 1500</div>
|
||||
</div>
|
||||
|
||||
<div className={'flex flex-col gap-[5px]'}>
|
||||
<div className={'liquid-glass-bg flex-1'}>
|
||||
<div>Available for Withdrawal (Cash)</div>
|
||||
<div>152 CNY</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||||
<div
|
||||
className="liquid-glass-bg flex min-h-[109px] flex-col justify-between p-[14px] sm:p-[16px]">
|
||||
<div className="flex items-start justify-between gap-[12px]">
|
||||
<div>
|
||||
<div className="text-[13px] uppercase tracking-[0.16em] text-white/58">Available for
|
||||
Withdrawal
|
||||
</div>
|
||||
<div
|
||||
className="mt-[10px] text-[32px] font-semibold leading-none text-white">{assetsInfo?.withdrawable_cash || 0} CNY</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex h-[38px] w-[38px] items-center justify-center rounded-[12px] bg-[#FA6A00]/16 text-[#FE9F00]">
|
||||
<Wallet className="h-[18px] w-[18px]" aria-hidden="true"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'h-[54px] w-[564px] liquid-glass-bg flex gap-[10px] p-[5px]'}>
|
||||
<button className={'button-play flex-1'} onClick={handleOpenClaimModal}>
|
||||
<div className="liquid-glass-bg grid grid-cols-2 gap-[10px] p-[5px]">
|
||||
<Button className="h-[44px] w-full text-[13px]" onClick={handleOpenClaimModal}>
|
||||
Claim Now
|
||||
</button>
|
||||
<button className={'button-play flex-1'}>Sync Balance</button>
|
||||
</Button>
|
||||
<Button
|
||||
variant={'gray'}
|
||||
className="h-[44px] w-full text-[13px]"
|
||||
onClick={handleSyncBalance}
|
||||
disabled={syncBalanceMutation.isPending}
|
||||
>
|
||||
{syncBalanceMutation.isPending ? 'Syncing...' : 'Sync Balance'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{
|
||||
productCategories.map((category) => (<div key={category.id} className={'mt-[20px]'}>
|
||||
<div className={'flex items-center justify-between mb-[5px]'}>
|
||||
<div className={'font-bold text-[14px]'}>{category.name}</div>
|
||||
<div
|
||||
className={'text-[#FA6A00] text-[12px] font-light underline cursor-pointer'}>more
|
||||
</div>
|
||||
</div>
|
||||
<div className={'grid grid-cols-1 gap-4 md:grid-cols-4 xl:grid-cols-4'}>
|
||||
{
|
||||
category.items.map((product) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className={'liquid-glass-bg aspect-[215/260] w-full flex flex-col items-stretch justify-start'}>
|
||||
<div className={`${product.imageClassName} w-full h-[40%] rounded-t-[10px]`}></div>
|
||||
<div
|
||||
className={'p-[10px] w-full flex-1 flex flex-col justify-around items-start'}>
|
||||
<div>{product.title}</div>
|
||||
<div className={'text-neutral-500'}>{product.subtitle}</div>
|
||||
<div className={'text-[#FA6A00]'}>{product.points}</div>
|
||||
<button
|
||||
className={'button-play w-full h-[30px]'}
|
||||
onClick={() => handleOpenRedeemModal(product, category.id)}
|
||||
>
|
||||
{product.ctaLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>))
|
||||
}
|
||||
</div>
|
||||
<GoodsCategoryList
|
||||
categories={previewCategories}
|
||||
loading={loading}
|
||||
emptyText="No goods available yet."
|
||||
showMore
|
||||
onMoreClick={handleMoreClick}
|
||||
onRedeem={redeem.openRedeemModal}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={Boolean(selectedProduct)}
|
||||
title={modalTitle}
|
||||
onClose={handleCloseRedeemModal}
|
||||
className={modalMaxWidthClassName}
|
||||
bodyClassName="space-y-[18px]"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="h-[38px] rounded-[8px] border border-white/15 bg-white/5 px-[18px] text-[14px] text-white transition-colors hover:bg-white/10"
|
||||
onClick={modalMode === 'add-address' ? () => setModalMode('select-address') : handleCloseRedeemModal}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`button-play h-[38px] ${modalMode === 'add-address' && !isAddAddressFormValid ? 'opacity-50' : ''}`}
|
||||
onClick={handleConfirm}
|
||||
disabled={modalMode === 'add-address' && !isAddAddressFormValid}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{selectedProductData ? (
|
||||
<>
|
||||
{isTransferToPlatform ? (
|
||||
<div className="rounded-[14px] bg-[#1C1818]/80 px-[18px] py-[14px] shadow-[0_10px_30px_rgba(0,0,0,0.22)]">
|
||||
<div className="divide-y divide-white/8">
|
||||
<div className="flex items-center justify-between py-[14px] text-white">
|
||||
<div className="text-[18px]">Withdrawal Amount</div>
|
||||
<div className="text-[18px]">{getNumericValue(selectedProductData.title)}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-[14px] text-white">
|
||||
<div className="text-[18px]">Points Required</div>
|
||||
<div className="text-[18px]">{getNumericValue(selectedProductData.points)}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-[14px] text-white">
|
||||
<div className="text-[18px] underline decoration-[#1E90FF] underline-offset-[3px]">
|
||||
Turnover Requirement
|
||||
</div>
|
||||
<div className="text-[18px]">{getTurnoverRequirement(selectedProductData.subtitle)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-[10px] text-center text-[18px] text-white/45">
|
||||
Submit withdrawal request?
|
||||
</div>
|
||||
</div>
|
||||
) : isGameBonus ? (
|
||||
<div className="rounded-[10px] bg-[#1C1818]/80 px-[16px] py-[8px] shadow-[0_10px_30px_rgba(0,0,0,0.22)]">
|
||||
<div className="divide-y divide-white/8">
|
||||
<div className="flex items-center justify-between py-[14px] text-white">
|
||||
<div className="text-[13px] text-white/78">Item</div>
|
||||
<div className="text-[14px]">{selectedProductData.title}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-[14px] text-white">
|
||||
<div className="text-[13px] text-white/78">Points Required</div>
|
||||
<div className="text-[14px]">{getNumericValue(selectedProductData.points)}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-[14px] text-white">
|
||||
<div className="text-[13px] text-white/78">Turnover Requirement</div>
|
||||
<div className="text-[14px]">{getTurnoverRequirement(selectedProductData.subtitle)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : modalMode === 'select-address' && isPhysicalPrize ? (
|
||||
<>
|
||||
<div className="flex gap-[14px] rounded-[12px] bg-white/5 p-[12px]">
|
||||
<div
|
||||
className={`${selectedProductData.imageClassName} h-[110px] w-[140px] shrink-0 rounded-[10px]`}
|
||||
></div>
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-between">
|
||||
<div>
|
||||
<div className="text-[20px] font-bold text-white">{selectedProductData.title}</div>
|
||||
<div className="mt-[6px] text-[13px] text-white/60">{selectedProductData.subtitle}</div>
|
||||
</div>
|
||||
<div className="text-[18px] font-bold text-[#FA6A00]">{selectedProductData.points}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-[10px] text-[14px] font-bold text-white">Select Shipping Address</div>
|
||||
<div className="space-y-[10px]">
|
||||
{addressOptions.map((address) => {
|
||||
const isSelected = selectedAddressId === address.id
|
||||
|
||||
return (
|
||||
<button
|
||||
key={address.id}
|
||||
type="button"
|
||||
className={`flex w-full items-start gap-[12px] rounded-[12px] border px-[14px] py-[14px] text-left transition-colors ${
|
||||
isSelected
|
||||
? 'border-[#FA6A00] bg-[#FA6A00]/12'
|
||||
: 'border-white/10 bg-white/4 hover:bg-white/8'
|
||||
}`}
|
||||
onClick={() => setSelectedAddressId(address.id)}
|
||||
>
|
||||
<div
|
||||
className={`mt-[3px] h-[16px] w-[16px] rounded-full border ${
|
||||
isSelected
|
||||
? 'border-[#FA6A00] bg-[#FA6A00] shadow-[0_0_12px_rgba(250,106,0,0.45)]'
|
||||
: 'border-white/30'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`m-auto mt-[3px] h-[6px] w-[6px] rounded-full bg-white ${
|
||||
isSelected ? 'block' : 'hidden'
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-[8px]">
|
||||
<div className="text-[14px] font-bold text-white">{address.name}</div>
|
||||
<div className="text-[13px] text-white/60">{address.phone}</div>
|
||||
{address.isDefault ? (
|
||||
<div className="rounded-full bg-[#FA6A00]/18 px-[8px] py-[2px] text-[11px] text-[#FFB36D]">
|
||||
Default
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-[6px] text-[13px] leading-[1.5] text-white/75">
|
||||
{address.address}
|
||||
</div>
|
||||
<div className="mt-[4px] text-[12px] text-white/45">
|
||||
Postal Code: {address.postalCode}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-[12px] rounded-[12px] border border-dashed border-[#FA6A00]/55 bg-[#FA6A00]/8 px-[14px] py-[16px] text-left transition-colors hover:bg-[#FA6A00]/12"
|
||||
onClick={handleOpenAddAddress}
|
||||
>
|
||||
<div className="flex h-[18px] w-[18px] items-center justify-center rounded-full bg-[#FA6A00] text-[14px] leading-none text-white">
|
||||
+
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[14px] font-bold text-white">Add New Address</div>
|
||||
<div className="mt-[4px] text-[12px] text-white/55">
|
||||
Create a shipping address for this redemption
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : isPhysicalPrize ? (
|
||||
<div className="rounded-[12px] bg-[#171313]/88 px-[14px] py-[10px] shadow-[0_10px_30px_rgba(0,0,0,0.25)]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-[6px] pb-[16px]">
|
||||
<div className="text-[18px] font-medium text-white">Address Info</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-[8px] text-[14px] text-white/85"
|
||||
onClick={() => handleChangeAddressForm('isDefault', !addressForm.isDefault)}
|
||||
>
|
||||
<span
|
||||
className={`flex h-[16px] w-[16px] items-center justify-center rounded-full border ${
|
||||
addressForm.isDefault ? 'border-[#FA6A00]' : 'border-white/35'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-[8px] w-[8px] rounded-full ${
|
||||
addressForm.isDefault ? 'bg-[#FA6A00]' : 'bg-transparent'
|
||||
}`}
|
||||
></span>
|
||||
</span>
|
||||
<span>Default Address</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-white/10">
|
||||
<div className="grid grid-cols-[170px_1fr] items-center gap-[10px] px-[6px] py-[16px]">
|
||||
<label className="text-[14px] text-white/92">
|
||||
Name<span className="text-[#FA6A00]">*</span>
|
||||
</label>
|
||||
<input
|
||||
value={addressForm.name}
|
||||
onChange={(event) => handleChangeAddressForm('name', event.target.value)}
|
||||
placeholder="Full Name"
|
||||
className="bg-transparent text-[14px] text-white outline-none placeholder:text-white/35"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-[170px_1fr] items-center gap-[10px] px-[6px] py-[16px]">
|
||||
<label className="text-[14px] text-white/92">
|
||||
Phone Number<span className="text-[#FA6A00]">*</span>
|
||||
</label>
|
||||
<input
|
||||
value={addressForm.phone}
|
||||
onChange={(event) => handleChangeAddressForm('phone', event.target.value)}
|
||||
placeholder="Phone Number"
|
||||
className="bg-transparent text-[14px] text-white outline-none placeholder:text-white/35"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-[170px_1fr] items-center gap-[10px] px-[6px] py-[16px]">
|
||||
<label className="text-[14px] text-white/92">
|
||||
Region<span className="text-[#FA6A00]">*</span>
|
||||
</label>
|
||||
<input
|
||||
value={addressForm.region}
|
||||
onChange={(event) => handleChangeAddressForm('region', event.target.value)}
|
||||
placeholder="State, City, District"
|
||||
className="bg-transparent text-[14px] text-white outline-none placeholder:text-white/35"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-[170px_1fr] items-center gap-[10px] px-[6px] py-[16px]">
|
||||
<label className="text-[14px] text-white/92">
|
||||
Detailed Address<span className="text-[#FA6A00]">*</span>
|
||||
</label>
|
||||
<input
|
||||
value={addressForm.detailedAddress}
|
||||
onChange={(event) => handleChangeAddressForm('detailedAddress', event.target.value)}
|
||||
placeholder="Apt, Suite, Bldg, etc"
|
||||
className="bg-transparent text-[14px] text-white outline-none placeholder:text-white/35"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-[170px_1fr] items-center gap-[10px] px-[6px] py-[16px]">
|
||||
<label className="text-[14px] text-white/92">Postal Code</label>
|
||||
<input
|
||||
value={addressForm.postalCode}
|
||||
onChange={(event) => handleChangeAddressForm('postalCode', event.target.value)}
|
||||
placeholder="Postal Code"
|
||||
className="bg-transparent text-[14px] text-white outline-none placeholder:text-white/35"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</Modal>
|
||||
<GoodsRedeemModal
|
||||
selectedProduct={redeem.selectedProduct}
|
||||
modalMode={redeem.modalMode}
|
||||
addressOptions={redeem.addressOptions}
|
||||
selectedAddressId={redeem.selectedAddressId}
|
||||
addressForm={redeem.addressForm}
|
||||
addressLoading={redeem.addressLoading}
|
||||
isAddAddressFormValid={redeem.isAddAddressFormValid}
|
||||
submitLoading={redeem.submitLoading}
|
||||
onClose={redeem.closeRedeemModal}
|
||||
onConfirm={redeem.confirmRedeem}
|
||||
onOpenAddAddress={redeem.openAddAddress}
|
||||
onBackToSelectAddress={redeem.backToSelectAddress}
|
||||
onSelectAddress={redeem.setSelectedAddressId}
|
||||
onChangeAddressForm={redeem.changeAddressForm}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={claimModalOpen}
|
||||
@@ -655,22 +250,23 @@ function HomePage() {
|
||||
bodyClassName="space-y-[18px]"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="h-[38px] min-w-[120px] rounded-[8px] border border-white/10 bg-white/10 px-[18px] text-[14px] text-white/75 transition-colors hover:bg-white/14"
|
||||
onClick={handleCloseClaimModal}
|
||||
>
|
||||
<Button type="button" variant={'gray'} className="h-[38px] w-full sm:w-auto sm:min-w-[130px]"
|
||||
onClick={handleCloseClaimModal}
|
||||
disabled={claimMutation.isPending}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="button-play h-[38px] min-w-[130px]" onClick={handleCloseClaimModal}>
|
||||
Confirm
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<Button type="button" className="h-[38px] w-full sm:w-auto sm:min-w-[130px]"
|
||||
onClick={handleConfirmClaim}
|
||||
disabled={claimMutation.isPending}>
|
||||
{claimMutation.isPending ? 'Processing...' : 'Confirm'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="rounded-[12px] bg-[#1C1818]/82 px-[14px] py-[18px] text-[17px] leading-[1.65] text-white/92 shadow-[0_10px_30px_rgba(0,0,0,0.2)]">
|
||||
Once pending points are transferred to your available balance, they can be redeemed or withdrawn.
|
||||
Confirm claim?
|
||||
<div
|
||||
className="rounded-[12px] bg-[#1C1818]/82 px-[14px] py-[18px] text-[17px] leading-[1.65] text-white/92 shadow-[0_10px_30px_rgba(0,0,0,0.2)]">
|
||||
After converting the points to be collected into usable points, they can be redeemed or withdrawn. Are you sure to claim it?
|
||||
</div>
|
||||
</Modal>
|
||||
</PageLayout>
|
||||
|
||||
@@ -1,131 +1,246 @@
|
||||
import { useState } from 'react'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
|
||||
import PageLayout from '@/components/layout'
|
||||
import {ORDER_STATUS} from '@/constant'
|
||||
import { cn } from '@/lib'
|
||||
import Modal from '@/components/modal'
|
||||
import type { RecordButtonType } from '@/types'
|
||||
import Button from '@/components/button'
|
||||
import type { OrderCardProps, OrderRecord, PointsCardProps, RecordButtonType, TabButtonProps } from '@/types'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeft, ChevronRight, Coins, PackageSearch } from 'lucide-react'
|
||||
import {orders, pointsLogs} from '@/api/business.ts'
|
||||
import {queryKeys} from '@/lib/queryKeys.ts'
|
||||
import { useUserStore } from '@/store/user.ts'
|
||||
import type {OrderItem, PointsLogItem} from '@/types/business.type.ts'
|
||||
|
||||
type OrderRecord = {
|
||||
id: string
|
||||
date: string
|
||||
time: string
|
||||
category: string
|
||||
title: string
|
||||
trackingNumber?: string
|
||||
status: string
|
||||
points: string
|
||||
}
|
||||
|
||||
type PointsRecord = {
|
||||
id: string
|
||||
title: string
|
||||
date: string
|
||||
time: string
|
||||
amount: string
|
||||
tone: 'positive' | 'negative'
|
||||
}
|
||||
|
||||
const orderRecords: OrderRecord[] = [
|
||||
{
|
||||
id: 'order-1',
|
||||
date: '2025-03-04',
|
||||
time: '10:20',
|
||||
category: 'Bonus',
|
||||
title: 'Daily Rebate 50',
|
||||
status: 'Issued',
|
||||
points: '-500 points',
|
||||
},
|
||||
{
|
||||
id: 'order-2',
|
||||
date: '2025-03-03',
|
||||
time: '14:00',
|
||||
category: 'Physical',
|
||||
title: 'Weekly Bonus 200',
|
||||
trackingNumber: 'SF1234567890',
|
||||
status: 'Shipped',
|
||||
points: '-1200 points',
|
||||
},
|
||||
{
|
||||
id: 'order-3',
|
||||
date: '2025-03-02',
|
||||
time: '09:15',
|
||||
category: 'Withdrawal',
|
||||
title: 'Wireless Earbuds',
|
||||
status: 'Issued',
|
||||
points: '-1000 points',
|
||||
},
|
||||
{
|
||||
id: 'order-4',
|
||||
date: '2025-03-01',
|
||||
time: '16:30',
|
||||
category: 'Bonus',
|
||||
title: 'Fitness Tracker',
|
||||
status: 'Pending',
|
||||
points: '-1800 points',
|
||||
},
|
||||
{
|
||||
id: 'order-5',
|
||||
date: '2025-02-28',
|
||||
time: '11:00',
|
||||
category: 'Physical',
|
||||
title: 'Withdraw 100',
|
||||
status: 'Rejected',
|
||||
points: '-2500 points',
|
||||
},
|
||||
]
|
||||
|
||||
const pointsRecords: PointsRecord[] = [
|
||||
{
|
||||
id: 'points-1',
|
||||
title: 'Bonus Redemption - Daily Rewards 50',
|
||||
date: '2025-03-04',
|
||||
time: '10:20',
|
||||
amount: '-500',
|
||||
tone: 'negative',
|
||||
},
|
||||
{
|
||||
id: 'points-2',
|
||||
title: "Claim Yesterday's Protection Funds",
|
||||
date: '2025-03-04',
|
||||
time: '09:20',
|
||||
amount: '+800',
|
||||
tone: 'positive',
|
||||
},
|
||||
{
|
||||
id: 'points-3',
|
||||
title: 'Physical Item Redemption - Bluetooth Headphones',
|
||||
date: '2025-03-03',
|
||||
time: '14:00',
|
||||
amount: '-1200',
|
||||
tone: 'negative',
|
||||
},
|
||||
{
|
||||
id: 'points-4',
|
||||
title: "Claim Yesterday's Protection Funds",
|
||||
date: '2025-03-03',
|
||||
time: '09:00',
|
||||
amount: '+700',
|
||||
tone: 'positive',
|
||||
},
|
||||
{
|
||||
id: 'points-5',
|
||||
title: 'Withdraw to Platform - 100',
|
||||
date: '2025-03-02',
|
||||
time: '09:15',
|
||||
amount: '-1000',
|
||||
tone: 'negative',
|
||||
},
|
||||
]
|
||||
|
||||
const amountToneClassName: Record<PointsRecord['tone'], string> = {
|
||||
const pointsRecordToneClassName = {
|
||||
positive: 'bg-[#9BFFC0] text-[#176640]',
|
||||
negative: 'bg-[#FF9BA4] text-[#7B2634]',
|
||||
} as const
|
||||
|
||||
function formatDatePart(value: number) {
|
||||
return String(value).padStart(2, '0')
|
||||
}
|
||||
|
||||
function getDateTimeParts(value?: number | string) {
|
||||
if (value == null) {
|
||||
return { date: '--', time: '--:--' }
|
||||
}
|
||||
|
||||
const numericValue = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value
|
||||
const date = new Date(
|
||||
typeof numericValue === 'number'
|
||||
? String(numericValue).length === 13
|
||||
? numericValue
|
||||
: numericValue * 1000
|
||||
: numericValue,
|
||||
)
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return { date: '--', time: '--:--' }
|
||||
}
|
||||
|
||||
return {
|
||||
date: `${date.getFullYear()}-${formatDatePart(date.getMonth() + 1)}-${formatDatePart(date.getDate())}`,
|
||||
time: `${formatDatePart(date.getHours())}:${formatDatePart(date.getMinutes())}`,
|
||||
}
|
||||
}
|
||||
|
||||
function toTitleCase(value: string) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.split(/[\s_-]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part[0]?.toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function getOrderStatus(status?: string | number) {
|
||||
if (typeof status === 'string') {
|
||||
const normalizedStatus = status.trim().toUpperCase()
|
||||
const matchedStatus = ORDER_STATUS.find((item) => item === normalizedStatus)
|
||||
if (matchedStatus) {
|
||||
switch (matchedStatus) {
|
||||
case 'PENDING':
|
||||
return 'Pending'
|
||||
case 'COMPLETED':
|
||||
return 'Completed'
|
||||
case 'SHIPPED':
|
||||
return 'Shipped'
|
||||
case 'REJECTED':
|
||||
return 'Rejected'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedStatus = typeof status === 'string' && /^\d+$/.test(status)
|
||||
? Number(status)
|
||||
: status
|
||||
|
||||
if (typeof normalizedStatus === 'number') {
|
||||
switch (normalizedStatus) {
|
||||
case 0:
|
||||
return 'Pending'
|
||||
case 1:
|
||||
return 'Completed'
|
||||
case 2:
|
||||
return 'Shipped'
|
||||
case 3:
|
||||
return 'Rejected'
|
||||
default:
|
||||
return String(normalizedStatus)
|
||||
}
|
||||
}
|
||||
|
||||
if (!normalizedStatus) {
|
||||
return 'Pending'
|
||||
}
|
||||
|
||||
return toTitleCase(normalizedStatus)
|
||||
}
|
||||
|
||||
function getOrderCategory(item: OrderItem) {
|
||||
if (item.type?.trim()) {
|
||||
return item.type.trim().toUpperCase()
|
||||
}
|
||||
|
||||
if (item.type_title) {
|
||||
return item.type_title
|
||||
}
|
||||
|
||||
if (item.category_title) {
|
||||
return item.category_title
|
||||
}
|
||||
|
||||
if (item.type) {
|
||||
switch (item.type) {
|
||||
case 'BONUS':
|
||||
return 'Bonus'
|
||||
case 'PHYSICAL':
|
||||
return 'Physical'
|
||||
case 'WITHDRAW':
|
||||
return 'Transfer to Platform'
|
||||
default:
|
||||
return toTitleCase(item.type)
|
||||
}
|
||||
}
|
||||
|
||||
if (item.category) {
|
||||
return toTitleCase(item.category)
|
||||
}
|
||||
|
||||
return 'Order'
|
||||
}
|
||||
|
||||
function getOrderPoints(item: OrderItem) {
|
||||
const rawValue = item.points_cost
|
||||
|
||||
if (rawValue == null || rawValue === '') {
|
||||
return {display: '--'}
|
||||
}
|
||||
|
||||
const numericValue = typeof rawValue === 'string' ? Number(rawValue) : rawValue
|
||||
if (Number.isNaN(numericValue)) {
|
||||
const textValue = String(rawValue)
|
||||
return {display: `${textValue} points`}
|
||||
}
|
||||
|
||||
return {
|
||||
display: `${numericValue} points`,
|
||||
}
|
||||
}
|
||||
|
||||
function getTrackingNumber(item: OrderItem) {
|
||||
const shippingNumber = item.shipping_no?.trim()
|
||||
const logisticsNumber = item.logistics_no?.trim()
|
||||
const trackingNumber = item.tracking_no?.trim()
|
||||
const shippingCompany = item.shipping_company?.trim()
|
||||
const resolvedNumber = shippingNumber || logisticsNumber || trackingNumber
|
||||
|
||||
if (!resolvedNumber) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return shippingCompany ? `${shippingCompany} ${resolvedNumber}` : resolvedNumber
|
||||
}
|
||||
|
||||
function mapOrderItemToRecord(item: OrderItem): OrderRecord {
|
||||
const { date, time } = getDateTimeParts(item.create_time ?? item.created_at)
|
||||
const orderNumber = item.external_transaction_id?.trim() || item.order_no ? String(item.external_transaction_id?.trim() || item.order_no) : undefined
|
||||
const points = getOrderPoints(item)
|
||||
|
||||
return {
|
||||
id: String(item.id),
|
||||
orderNumber,
|
||||
date,
|
||||
time,
|
||||
category: getOrderCategory(item),
|
||||
title: item.item_title ?? item.title ?? item.mallItem?.title ?? 'Untitled Order',
|
||||
trackingNumber: getTrackingNumber(item),
|
||||
status: getOrderStatus(item.status),
|
||||
points: points.display,
|
||||
}
|
||||
}
|
||||
|
||||
function getPointsRecordAmount(item: PointsLogItem) {
|
||||
const rawValue = item.points
|
||||
|
||||
if (rawValue == null || rawValue === '') {
|
||||
return {
|
||||
amount: '--',
|
||||
tone: 'negative' as const,
|
||||
}
|
||||
}
|
||||
|
||||
const numericValue = typeof rawValue === 'string' ? Number(rawValue) : rawValue
|
||||
if (Number.isNaN(numericValue)) {
|
||||
const textValue = String(rawValue).trim()
|
||||
return {
|
||||
amount: item.direction === 'IN'
|
||||
? textValue.startsWith('+') ? textValue : `+${textValue.replace(/^[+-]/, '')}`
|
||||
: item.direction === 'OUT'
|
||||
? textValue.startsWith('-') ? textValue : `-${textValue.replace(/^[+-]/, '')}`
|
||||
: textValue,
|
||||
tone: item.direction === 'IN' ? 'positive' as const : 'negative' as const,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
amount: `${item.direction === 'IN' ? '+' : item.direction === 'OUT' ? '-' : ''}${Math.abs(numericValue)}`,
|
||||
tone: item.direction === 'IN' ? 'positive' as const : 'negative' as const,
|
||||
}
|
||||
}
|
||||
|
||||
function getPointsRecordTitle(item: PointsLogItem) {
|
||||
return [
|
||||
item.item_title,
|
||||
item.title,
|
||||
item.mallItem?.title,
|
||||
item.biz_type,
|
||||
item.type,
|
||||
item.description,
|
||||
item.remark?.split('\n')[0],
|
||||
].find((value) => typeof value === 'string' && value.trim())?.trim() ?? 'Points Record'
|
||||
}
|
||||
|
||||
function mapPointsLogItemToRecord(item: PointsLogItem) {
|
||||
const {date, time} = getDateTimeParts(item.ts ?? item.create_time ?? item.created_at ?? item.update_time)
|
||||
const amount = getPointsRecordAmount(item)
|
||||
|
||||
return {
|
||||
id: String(item.id),
|
||||
title: getPointsRecordTitle(item),
|
||||
date,
|
||||
time,
|
||||
amount: amount.amount,
|
||||
tone: amount.tone,
|
||||
}
|
||||
}
|
||||
|
||||
function getOrderStatusClassName(status: string) {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'issued':
|
||||
case 'completed':
|
||||
return 'bg-[#9BFFC0] text-[#176640]'
|
||||
case 'shipped':
|
||||
return 'bg-[#95F0FF] text-[#116A79]'
|
||||
@@ -138,29 +253,19 @@ function getOrderStatusClassName(status: string) {
|
||||
}
|
||||
}
|
||||
|
||||
type TabButtonProps = {
|
||||
active: boolean
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
type OrderCardProps = {
|
||||
record: OrderRecord
|
||||
onOpenDetails: (record: OrderRecord) => void
|
||||
}
|
||||
|
||||
function TabButton({ active, label, onClick }: TabButtonProps) {
|
||||
function TabButton({ active, label, icon: Icon, onClick }: TabButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'min-w-[92px] cursor-pointer rounded-[6px] border px-[14px] py-[7px] text-[13px] transition-colors',
|
||||
'inline-flex min-w-[140px] cursor-pointer items-center justify-center gap-[8px] rounded-[10px] border px-[14px] py-[9px] text-[13px] transition-colors focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]',
|
||||
active
|
||||
? 'border-[#F99A0B] bg-linear-to-r from-[#F96C02] to-[#FE9F00] text-white shadow-[0_0_16px_rgba(249,108,2,0.22)]'
|
||||
: 'border-white/35 bg-white/3 text-[#B8B1AA] hover:bg-white/6',
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon className="h-[15px] w-[15px]" aria-hidden="true" />
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
@@ -168,12 +273,12 @@ function TabButton({ active, label, onClick }: TabButtonProps) {
|
||||
|
||||
function OrderCard({ record, onOpenDetails }: OrderCardProps) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[10px] shadow-[0_10px_30px_rgba(0,0,0,0.24)]">
|
||||
<div className="bg-linear-to-r from-[#F96C02] to-[#FE9F00] px-[12px] py-[8px] text-[14px] text-white">
|
||||
<div className="overflow-hidden rounded-[12px] shadow-[0_10px_30px_rgba(0,0,0,0.24)]">
|
||||
<div className="bg-linear-to-r from-[#F96C02] to-[#FE9F00] px-[12px] py-[9px] text-[13px] text-white sm:text-[14px]">
|
||||
{record.date} {record.time} • {record.category}
|
||||
</div>
|
||||
<div className="liquid-glass-bg !rounded-t-none flex items-center justify-between px-[12px] py-[12px]">
|
||||
<div className="min-w-0 flex-1 pr-[16px]">
|
||||
<div className="liquid-glass-bg !rounded-t-none flex flex-col gap-[14px] px-[12px] py-[14px] sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[16px] font-medium text-white">{record.title}</div>
|
||||
{record.trackingNumber ? (
|
||||
<div className="mt-[4px] text-[13px] text-white/45">
|
||||
@@ -182,14 +287,15 @@ function OrderCard({ record, onOpenDetails }: OrderCardProps) {
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="mt-[8px] text-[13px] text-[#FA6A00]"
|
||||
className="mt-[10px] inline-flex items-center gap-[5px] text-[13px] text-[#FA6A00] focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]"
|
||||
onClick={() => onOpenDetails(record)}
|
||||
>
|
||||
Check the details
|
||||
<ChevronRight className="h-[14px] w-[14px]" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end gap-[10px]">
|
||||
<div className="flex shrink-0 items-center justify-between gap-[10px] sm:flex-col sm:items-end">
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-[6px] px-[8px] py-[3px] text-[11px] leading-none',
|
||||
@@ -198,27 +304,27 @@ function OrderCard({ record, onOpenDetails }: OrderCardProps) {
|
||||
>
|
||||
{record.status}
|
||||
</div>
|
||||
<div className="text-[13px] text-white/85">{record.points}</div>
|
||||
<div className="text-[13px] text-white/85">{record.points.replace(/^-/, '')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PointsCard({ record }: { record: PointsRecord }) {
|
||||
function PointsCard({ record }: PointsCardProps) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[10px] shadow-[0_10px_30px_rgba(0,0,0,0.24)]">
|
||||
<div className="bg-linear-to-r from-[#F96C02] to-[#FE9F00] px-[12px] py-[8px] text-[15px] text-white">
|
||||
<div className="overflow-hidden rounded-[12px] shadow-[0_10px_30px_rgba(0,0,0,0.24)]">
|
||||
<div className="bg-linear-to-r from-[#F96C02] to-[#FE9F00] px-[12px] py-[9px] text-[14px] text-white sm:text-[15px]">
|
||||
{record.title}
|
||||
</div>
|
||||
<div className="liquid-glass-bg !rounded-t-none flex items-center justify-between px-[12px] py-[22px]">
|
||||
<div className="liquid-glass-bg !rounded-t-none flex flex-col gap-[10px] px-[12px] py-[18px] sm:flex-row sm:items-center sm:justify-between sm:py-[22px]">
|
||||
<div className="text-[13px] text-white/40">
|
||||
{record.date} {record.time}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-[6px] px-[10px] py-[3px] text-[12px] leading-none',
|
||||
amountToneClassName[record.tone],
|
||||
'inline-flex w-fit rounded-[6px] px-[10px] py-[3px] text-[12px] leading-none',
|
||||
pointsRecordToneClassName[record.tone],
|
||||
)}
|
||||
>
|
||||
{record.amount}
|
||||
@@ -228,7 +334,92 @@ function PointsCard({ record }: { record: PointsRecord }) {
|
||||
)
|
||||
}
|
||||
|
||||
function OrdersTabContent({
|
||||
sessionId,
|
||||
onOpenDetails,
|
||||
}: {
|
||||
sessionId: string
|
||||
onOpenDetails: (record: OrderRecord) => void
|
||||
}) {
|
||||
const ordersQuery = useQuery({
|
||||
queryKey: queryKeys.orders(sessionId),
|
||||
enabled: Boolean(sessionId),
|
||||
gcTime: 0,
|
||||
queryFn: async () => {
|
||||
const response = await orders({
|
||||
session_id: sessionId,
|
||||
})
|
||||
|
||||
return (response.data.list ?? []).map(mapOrderItemToRecord)
|
||||
},
|
||||
})
|
||||
const orderRecords = ordersQuery.data ?? []
|
||||
|
||||
if (ordersQuery.isPending) {
|
||||
return (
|
||||
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!orderRecords.length) {
|
||||
return (
|
||||
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
|
||||
No Data
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{orderRecords.map((record) => (
|
||||
<OrderCard key={record.id} record={record} onOpenDetails={onOpenDetails} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PointsTabContent({sessionId}: {sessionId: string}) {
|
||||
const pointsLogsQuery = useQuery({
|
||||
queryKey: queryKeys.pointsLogs(sessionId),
|
||||
enabled: Boolean(sessionId),
|
||||
gcTime: 0,
|
||||
queryFn: async () => {
|
||||
const response = await pointsLogs({
|
||||
session_id: sessionId,
|
||||
})
|
||||
|
||||
return (response.data.list ?? []).map(mapPointsLogItemToRecord)
|
||||
},
|
||||
})
|
||||
const pointsRecords = pointsLogsQuery.data ?? []
|
||||
|
||||
if (pointsLogsQuery.isPending) {
|
||||
return (
|
||||
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!pointsRecords.length) {
|
||||
return (
|
||||
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
|
||||
No Data
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{pointsRecords.map((record) => <PointsCard key={record.id} record={record} />)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function RecordPage() {
|
||||
const sessionId = useUserStore((state) => state.authInfo?.session_id ?? '')
|
||||
const [tab, setTab] = useState<RecordButtonType>('order')
|
||||
const [selectedOrder, setSelectedOrder] = useState<OrderRecord | null>(null)
|
||||
|
||||
@@ -236,30 +427,50 @@ function RecordPage() {
|
||||
setSelectedOrder(null)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedOrder(null)
|
||||
}, [tab])
|
||||
|
||||
return (
|
||||
<PageLayout contentClassName="min-h-screen">
|
||||
<PageLayout contentClassName="mx-auto flex min-h-screen w-full max-w-[980px] flex-col px-4 pb-8 sm:px-6 lg:px-8">
|
||||
<Link
|
||||
to="/"
|
||||
className="relative flex h-[40px] w-full items-center justify-center bg-[#08070E]/70 text-[#F56E10]"
|
||||
className="mt-[12px] flex h-[44px] items-center justify-between rounded-[12px] bg-[#08070E]/72 px-[14px] text-[#F56E10] transition-colors hover:bg-[#0D0A14]/80 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E] sm:mt-[16px]"
|
||||
>
|
||||
<div className="absolute left-[16px]"><</div>
|
||||
<div>Record</div>
|
||||
<div className="flex items-center gap-[8px]">
|
||||
<ArrowLeft className="h-[16px] w-[16px]" aria-hidden="true" />
|
||||
<span className="text-[14px] font-medium text-white/92">Back</span>
|
||||
</div>
|
||||
<div className="text-[15px] font-semibold text-[#F56E10]">Record</div>
|
||||
<div className="w-[52px]"></div>
|
||||
</Link>
|
||||
|
||||
<div className="mx-auto w-[60%] pt-[14px] pb-[24px]">
|
||||
<div className="flex gap-[8px]">
|
||||
<TabButton active={tab === 'order'} label="My Orders" onClick={() => setTab('order')} />
|
||||
<TabButton active={tab === 'record'} label="Points Record" onClick={() => setTab('record')} />
|
||||
<div className="mx-auto w-full max-w-[860px] pt-[18px] pb-[24px]">
|
||||
<div className="mb-[12px] flex justify-end">
|
||||
<div className="flex gap-[8px]">
|
||||
<TabButton
|
||||
active={tab === 'order'}
|
||||
icon={PackageSearch}
|
||||
label="My Orders"
|
||||
onClick={() => setTab('order')}
|
||||
/>
|
||||
<TabButton
|
||||
active={tab === 'record'}
|
||||
icon={Coins}
|
||||
label="Points Record"
|
||||
onClick={() => setTab('record')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-[10px] border-t border-white/20"></div>
|
||||
<div className="h-px bg-white/16"></div>
|
||||
|
||||
<div className="mt-[12px] flex flex-col gap-[12px]">
|
||||
{tab === 'order'
|
||||
? orderRecords.map((record) => (
|
||||
<OrderCard key={record.id} record={record} onOpenDetails={setSelectedOrder} />
|
||||
))
|
||||
: pointsRecords.map((record) => <PointsCard key={record.id} record={record} />)}
|
||||
<div className="mt-[14px] flex flex-col gap-[12px]">
|
||||
{tab === 'order' ? (
|
||||
<OrdersTabContent key="order" sessionId={sessionId} onOpenDetails={setSelectedOrder} />
|
||||
) : (
|
||||
<PointsTabContent key="record" sessionId={sessionId} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -267,22 +478,25 @@ function RecordPage() {
|
||||
open={Boolean(selectedOrder)}
|
||||
title="Order Details"
|
||||
onClose={handleCloseDetails}
|
||||
className="max-w-[380px]"
|
||||
className="max-w-[420px]"
|
||||
bodyClassName="pt-[0px]"
|
||||
footer={
|
||||
<button type="button" className="button-play h-[36px] min-w-[94px]" onClick={handleCloseDetails}>
|
||||
<Button type="button" className="h-[36px] w-full sm:min-w-[94px] sm:w-auto" onClick={handleCloseDetails}>
|
||||
Close
|
||||
</button>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{selectedOrder ? (
|
||||
<div className="rounded-[8px] bg-[#1C1818]/78 px-[12px] py-[6px]">
|
||||
<div className="rounded-[10px] bg-[#1C1818]/78 px-[12px] py-[6px]">
|
||||
{[
|
||||
{ label: 'Order Number', value: `ORD${selectedOrder.date.replaceAll('-', '')}${selectedOrder.time.replace(':', '')}001` },
|
||||
{ label: 'Order Number', value: selectedOrder.orderNumber ?? '--' },
|
||||
{ label: 'Order Time', value: `${selectedOrder.date} ${selectedOrder.time}` },
|
||||
{ label: 'Order Type', value: selectedOrder.category },
|
||||
{ label: 'Item Name', value: selectedOrder.title },
|
||||
{ label: 'Points', value: selectedOrder.points.replace('-', '') },
|
||||
...(selectedOrder.trackingNumber
|
||||
? [{ label: 'Tracking Number', value: selectedOrder.trackingNumber }]
|
||||
: []),
|
||||
{ label: 'Points', value: selectedOrder.points.replace(/^-/, '') },
|
||||
{ label: 'Status', value: selectedOrder.status },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="border-b border-white/8 py-[10px] last:border-b-0">
|
||||
|
||||
Reference in New Issue
Block a user