feat: 项目接口联调

This commit is contained in:
JiaJun
2026-04-10 09:27:11 +08:00
parent 906fa63870
commit af3ed15ba2
62 changed files with 4307 additions and 982 deletions

View File

@@ -1,18 +1,102 @@
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import HomePage from './views/home'
import RecordPage from './views/record'
import AccountPage from './views/account'
// import {useEffect} from 'react'
import {lazy, Suspense} from 'react'
import {BrowserRouter, Route, Routes} from 'react-router-dom'
import {AuthGuide} from '@/features/authGuide.tsx'
import {GlobalToast} from '@/features/notifications'
// import type { HostContextMessage } from '@/types'
const HomePage = lazy(() => import('./views/home'))
const RecordPage = lazy(() => import('./views/record'))
const AccountPage = lazy(() => import('./views/account'))
const GoodsPage = lazy(() => import('./views/goods'))
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/record" element={<RecordPage />} />
<Route path="/account" element={<AccountPage />} />
</Routes>
</BrowserRouter>
)
// useEffect(() => {
// const handleMessage = (event: MessageEvent<HostContextMessage>) => {
// const message = event.data
//
// if (!message || message.type !== 'IFRAME_CONTEXT' || !message.payload) {
// return
// }
//
// const {token, language} = message.payload
//
// console.log('postMessage', token, language)
//
// if (typeof token === 'string' && token.trim()) {
// sessionStorage.setItem('host_token', token)
// }
//
// if (typeof language === 'string' && language.trim()) {
// sessionStorage.setItem('host_language', language)
// document.documentElement.lang = language
// }
// }
//
// window.addEventListener('message', handleMessage)
//
// return () => {
// window.removeEventListener('message', handleMessage)
// }
// }, [])
// 本机测试
// window.postMessage(
// {
// type: 'IFRAME_CONTEXT',
// payload: {
// token: 'test-token-123',
// language: 'zh-CN',
// },
// },
// window.location.origin
// )
// 父页面发送iframe
// <iframe
// id="palyx-frame"
// src="https://your-iframe-app.example.com"
// style="width: 100%; border: 0;"
// ></iframe>
//
// <script>
// const iframe = document.getElementById('palyx-frame')
// const IFRAME_ORIGIN = 'https://your-iframe-app.example.com'
//
// iframe.addEventListener('load', () => {
// iframe.contentWindow.postMessage(
// {
// type: 'IFRAME_CONTEXT',
// payload: {
// token: 'token',
// language: 'zh-CN',
// },
// },
// window.location.origin
// )
// })
// </script>
return (
<BrowserRouter>
<GlobalToast/>
<AuthGuide>
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center bg-[#08070E] px-6 text-center text-[14px] text-white/68">
Loading page...
</div>
}
>
<Routes>
<Route path="/" element={<HomePage/>}/>
<Route path="/goods" element={<GoodsPage/>}/>
<Route path="/record" element={<RecordPage/>}/>
<Route path="/account" element={<AccountPage/>}/>
</Routes>
</Suspense>
</AuthGuide>
</BrowserRouter>
)
}
export default App

64
src/api/address.ts Normal file
View File

@@ -0,0 +1,64 @@
import {http, objectToFormData} from "@/lib";
import type {
AddressAddResponse,
AddressInfo,
AddressListResponse,
} from "@/types/address.type.ts";
import type {GoodListResponse} from "@/types/business.type.ts";
/** @description 获取收货地址 */
export const addressList = (params: {
session_id: string
}): Promise<AddressListResponse> => {
return http.get<AddressListResponse>('v1/mall/addressList', {
searchParams: {
session_id: params.session_id,
},
})
}
/** @description 添加地址*/
export const addressAdd = (params: {
session_id: string
} & AddressInfo): Promise<AddressAddResponse> => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {session_id: sessionId, ...rest} = params
const formData = objectToFormData(rest)
return http.post<AddressAddResponse>('v1/mall/addressAdd', {
body: formData,
searchParams: {
session_id: params.session_id,
},
})
}
/** @description 修改地址(含设为默认) */
export const addressEdit = (params: {
session_id: string
id: string
} & AddressInfo): Promise<GoodListResponse> => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {session_id: sessionId, ...rest} = params
const formData = objectToFormData(rest)
return http.post<GoodListResponse>('v1/mall/addressEdit', {
body: formData,
searchParams: {
session_id: params.session_id,
},
})
}
/** @description 删除地址*/
export const addressDelete = (params: {
id: string,
session_id: string
}): Promise<GoodListResponse> => {
const formData = objectToFormData({id:params.id})
return http.post<GoodListResponse>('v1/mall/addressDelete', {
body: formData,
searchParams: {
session_id: params.session_id,
},
})
}

15
src/api/auth.ts Normal file
View File

@@ -0,0 +1,15 @@
import {http, objectToFormData} from '@/lib'
import type {LoginParams, LoginResponse, ValidateTokenResponse} from "@/types/auth.type.ts";
/** @description 登录获取token */
export const login = (params: LoginParams) => http.get<LoginResponse>('v1/temLogin', {
searchParams: params,
})
/** @description Token 验证 */
export const validateToken = (token: string): Promise<ValidateTokenResponse> => {
const formData = objectToFormData({token})
return http.post<ValidateTokenResponse>('v1/mall/verifyToken', {
body: formData,
})
}

105
src/api/business.ts Normal file
View File

@@ -0,0 +1,105 @@
import {http, objectToFormData} from "@/lib";
import type {goodsType, GoodListResponse, OrdersResponse, PointsLogsResponse} from "@/types/business.type.ts";
/** @description 商品列表 */
export const goodList = (params: {
type: goodsType
}): Promise<GoodListResponse> => http.get<GoodListResponse>('v1/mall/items', {
searchParams: params,
})
/** @description 领取Claim
* @param params claim_request_id 领取请求ID使用 user_id + 时间戳生成
* */
export const claim = (params: {
claim_request_id: string,
session_id: string
}): Promise<GoodListResponse> => {
const formData = objectToFormData({claim_request_id: params.claim_request_id})
return http.post<GoodListResponse>('v1/mall/claim', {
body: formData,
searchParams: {
session_id: params.session_id,
},
})
}
/** @description 红利兑换Bonus Redeem
* @param params item_id 商品ID
* @param params session_id 会话ID
* */
export const bonusRedeem = (params: {
item_id: string,
session_id: string
}): Promise<GoodListResponse> => {
const formData = objectToFormData({item_id: params.item_id})
return http.post<GoodListResponse>('v1/mall/bonusRedeem', {
body: formData,
searchParams: {
session_id: params.session_id,
},
})
}
/** @description 实物兑换Physical Redeem
* @param params item_id 商品id
* @param params session_id 会话ID
* @param params address_id 收货地址id
* */
export const physicalRedeem = (params: {
item_id: string,
session_id: string,
address_id: string,
}): Promise<GoodListResponse> => {
const formData = objectToFormData({item_id: params.item_id, address_id: params.address_id})
return http.post<GoodListResponse>('v1/mall/physicalRedeem', {
body: formData,
searchParams: {
session_id: params.session_id,
},
})
}
/** @description 提现申请Withdraw Apply
* @param params item_id 商品id
* @param params session_id 会话ID
* */
export const withdrawApply = (params: {
item_id: string,
session_id: string
}): Promise<GoodListResponse> => {
const formData = objectToFormData({item_id: params.item_id})
return http.post<GoodListResponse>('v1/mall/withdrawApply', {
body: formData,
searchParams: {
session_id: params.session_id,
},
})
}
/** @description 订单列表 */
export const orders = (params: {
session_id: string
}): Promise<OrdersResponse> => {
return http.get<OrdersResponse>('v1/mall/orders', {
searchParams: {
session_id: params.session_id,
},
})
}
/** @description 积分流水 */
export const pointsLogs = (params: {
session_id: string
}): Promise<PointsLogsResponse> => {
return http.get<PointsLogsResponse>('v1/mall/pointsLogs', {
searchParams: {
session_id: params.session_id,
},
})
}

8
src/api/user.ts Normal file
View File

@@ -0,0 +1,8 @@
import type {UserAssetsParams, UserAssetsResponse} from "@/types/user.type.ts";
import {http} from "@/lib";
/** @description 用户资产*/
export const userAssets = (params: UserAssetsParams) => http.get<UserAssetsResponse>('v1/mall/assets', {
searchParams: params,
})

View File

@@ -1,9 +0,0 @@
<svg width="34" height="34" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<rect width="34" height="34" fill="url(#pattern0_18_206)"/>
<defs>
<pattern id="pattern0_18_206" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_18_206" transform="scale(0.0294118)"/>
</pattern>
<image id="image0_18_206" width="34" height="34" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACIAAAAiCAMAAAANmfvwAAAAAXNSR0IB2cksfwAAAblQTFRF//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//XrKAqIygAAAJN0Uk5TADWgz+Tq3siCGCy6//eUCVngHlvEuNPsHyL5hjmi29koU284EfILsHrKWvUKQocC6LwZ/qoE664X/cUboftgfZOlSE/Wn1iQRumyXTAdP27UCMmoDLEgIcOpxirS7b82/FJyKaMjN5KcmZuWRxDvYsJli4yJiIoySkCEfmjVrVZDOjvnByfLzHF1jWoNAfCDTOLx5sxRPAAAAg1JREFUeJxjZEAGjDDwHVkQic3FyIhQ+hGbEoE/SMpZGd9hKhH+CaY4GBm/gWhuxhfoSiQ/Awk+xn/PGRhkGN8D2UKP0JWwswINYnwA4Si+BhJi91CVKL8EssXvILtLkvEWihL1p0ALbiCcq/mYgUHuGooSuXcM8oxXEEp07zMwKF1CVqLPeIdB9TySp40YbzJonEFWYsp4jUH7JJISC8bLDJyvUSxi4WDQO4akxPoiA4PBERQlYt8YDG+/QCixO8fAYHwQRYkD0F7T/Qgl4l8ZGMz2oSgBGcxgvpcBSYfFHgYUJQyux4GE1S4Ix/0okLDZgaaE3+owyAmQ5ALSL/iYAU0JQyAjxAj1m2BSYS0DhpIQxu1IntZ9/ISBIWyL2CvflXAlDj8vMyADnxUMkZtBDD+okmjGDRDbdv7zOn2XDSSzLH4tskW6xmtA9n+xOgqO/zCeHw8050p8QVLicwBERjDORrGKIQ2aHYDMXMZ5QDJ5IgN2AFJSMAdoifEshGAhI+N/EM3E+J9paQxQScUUkEntCBVVk9BNMQOmv/xWJDGxb2hKnE4xMAQsQRar2YscRIWMDHV9DAzFjSj6JDIYGM4Zgz37/+ksRobmTgYtvxocvgFb1Ap0p+9yTHFPG3DiNu0tYexsZmCoK0NXofYMSXU30BmKl9GV9DQgKekt7WEsxHTABMYpEEbuVQAqHIIU/MvtggAAAABJRU5ErkJggg=="/>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -1,9 +0,0 @@
<svg width="32" height="34" viewBox="0 0 32 34" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<rect width="32" height="34" fill="url(#pattern0_18_197)"/>
<defs>
<pattern id="pattern0_18_197" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_18_197" transform="scale(0.03125 0.0294118)"/>
</pattern>
<image id="image0_18_197" width="32" height="34" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAiCAMAAAAJbCvNAAAAAXNSR0IB2cksfwAAAVZQTFRF//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//Xr//XrBUhsGwAAAHJ0Uk5TPej/4SUA2Mfv5+Xccunb5LTQ2r/XNEhGRUcrdGwC+wGxnuJxkmvNKd0fOjk7NW39wAzBz69eBGINwgUHpAlzgX97NtHqBmp29Wdp9Atd1WNcCArUZFrZ02CrFfpmtyeaDsoqLC+mMdbueM5bmZaXfoOYXJMCGQAAAb5JREFUeJxjZGBkZMAJGIGA7Q9ueQYG1n+MHL/wKWD/w8j1g4EThyWMXxl4vjPyf2bg+4pdAc9HBoHPIAWCn7Ar4H9HVQUCjMjh8Z+J8TWqArHn6NqlXiIrkHiKYb7McwbGP3AFUoyP0BXIP2H4959B4THMDXJogcX4gIFB8Q4Dx298vlBifMTzHqZAhZEBagYj4w0UoyAKNBkvI8T0GK8ASQVeoM8vQBUYnkEx2/QcgzHjSRCL1RjqC6mTyAqEP5idhiYTK6gVFoiABIbiYWPu/UCW0z6EG1CBw0GQfpfvXLsZ3LAp0HnwEUi672Fw3cHgiUWB+3eQ+S77GTy2YrVCR3YLkPTdBpE3wlAA1++9CUgL2aIr8GVcDySDNjIErAXSIY/Ooilw514NMT94FZBm0b6C7oaQlUDCYzdcP7ojQ1cwMEQyrkTohyiIXgFTELmEgSF2GVQ/11IGqIJ4xh1Alifji53//zEwJP/8twzI5VG+AtbD6LIP7gAnpavHYezUn0shDMaMWQg/sv5j+w5hwfQDFWTORCiIXabx8QmIkfFtKUyMkTVtpwc4phn/MT/awiAa9of1z61/B+CaALF6szdHXa+5AAAAAElFTkSuQmCC"/>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,18 +1,19 @@
import type { ButtonHTMLAttributes, PropsWithChildren } from "react";
import { twMerge } from "tailwind-merge";
type ButtonProps = PropsWithChildren<ButtonHTMLAttributes<HTMLButtonElement>>;
import type { ButtonProps } from '@/types'
export function Button({
children,
className = "",
type = "button",
variant = "orange",
...props
}: ButtonProps) {
const baseClassName = variant === "gray" ? "button-play-gray" : "button-play";
return (
<button
type={type}
className={twMerge("button-play", className)}
className={twMerge(baseClassName, className)}
{...props}
>
{children}

View File

@@ -1,3 +0,0 @@
export function Card(){
return (<div className={'w-full h-[]'}>card</div>)
}

View File

@@ -1,13 +1,11 @@
import type { ReactNode } from 'react'
import type { PageLayoutProps } from '@/types'
type PageLayoutProps = {
children: ReactNode
contentClassName?: string
}
function PageLayout({ children, contentClassName = 'w-[90%] lg:w-[60%] h-full mx-auto flex flex-col' }: PageLayoutProps) {
function PageLayout({
children,
contentClassName = 'mx-auto flex min-h-screen w-full max-w-[1180px] flex-col px-4 pb-8 sm:px-6 lg:px-8',
}: PageLayoutProps) {
return (
<main className="text-white h-screen w-screen overflow-x-hidden bg-[url('/home-figma-frame.png')] bg-cover bg-center bg-no-repeat">
<main className="min-h-screen w-full overflow-x-hidden bg-[url('/home-figma-frame.png')] bg-cover bg-center bg-no-repeat text-white">
<div className={contentClassName}>{children}</div>
</main>
)

View File

@@ -1,17 +1,5 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib'
type ModalProps = {
open: boolean
title?: ReactNode
children: ReactNode
footer?: ReactNode
onClose?: () => void
closeOnOverlayClick?: boolean
className?: string
bodyClassName?: string
}
import type { ModalProps } from '@/types'
export function Modal({
open,
@@ -34,7 +22,7 @@ export function Modal({
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-[16px]">
<div className="fixed inset-0 z-50 flex items-center justify-center p-[12px] sm:p-[16px]">
<button
type="button"
aria-label="Close modal"
@@ -46,16 +34,16 @@ export function Modal({
role="dialog"
aria-modal="true"
className={cn(
'relative z-10 w-full max-w-[560px] overflow-hidden rounded-[14px] shadow-[0_18px_60px_rgba(0,0,0,0.45)]',
'relative z-10 flex max-h-[calc(100dvh-24px)] w-full max-w-[560px] flex-col overflow-hidden rounded-[14px] shadow-[0_18px_60px_rgba(0,0,0,0.45)] sm:max-h-[calc(100vh-32px)]',
className,
)}
>
<div className="flex min-h-[58px] items-center justify-between bg-linear-to-r from-[#F96C02] to-[#FE9F00] px-[18px] py-[14px] text-white">
<div className="text-[18px] leading-[1.2] font-bold">{title}</div>
<div className="flex min-h-[58px] shrink-0 items-center justify-between bg-linear-to-r from-[#F96C02] to-[#FE9F00] px-[16px] py-[14px] text-white sm:px-[18px]">
<div className="pr-[12px] text-[16px] leading-[1.2] font-bold sm:text-[18px]">{title}</div>
{onClose ? (
<button
type="button"
className="flex h-[30px] w-[30px] items-center justify-center rounded-full bg-black/15 text-[18px] leading-none text-white transition-colors hover:bg-black/25"
className="flex h-[30px] w-[30px] items-center justify-center rounded-full bg-black/15 text-[18px] leading-none text-white transition-colors hover:bg-black/25 focus-visible:ring-2 focus-visible:ring-white/85 focus-visible:ring-offset-2 focus-visible:ring-offset-[#F96C02]"
onClick={onClose}
>
×
@@ -63,11 +51,20 @@ export function Modal({
) : null}
</div>
<div className="liquid-glass-bg !rounded-t-none bg-[#08070E]/75">
<div className={cn('px-[20px] py-[20px] text-white', bodyClassName)}>{children}</div>
<div className="liquid-glass-bg !rounded-t-none flex min-h-0 flex-1 flex-col bg-[#08070E]/75">
<div
className={cn(
'min-h-0 flex-1 overflow-y-auto px-[16px] py-[16px] text-white sm:px-[20px] sm:py-[20px]',
bodyClassName,
)}
>
{children}
</div>
{footer ? (
<div className="flex flex-wrap items-center justify-center gap-[60px] px-[20px] pb-[20px]">
<div className="shrink-0 border-t border-white/8 px-[16px] py-[14px] sm:px-[20px] sm:py-[16px]">
<div className="flex flex-wrap items-center justify-center gap-3 sm:gap-6">
{footer}
</div>
</div>
) : null}
</div>

View File

@@ -1,29 +1,18 @@
import type { ReactNode } from 'react'
export type TableColumn<T extends Record<string, string>> = {
label: string
key: keyof T
render?: (value: T[keyof T], record: T, index: number) => ReactNode
}
type BorderlessTableProps<T extends Record<string, string>> = {
columns: TableColumn<T>[]
dataSource: T[]
}
import type { BorderlessTableProps } from '@/types'
function BorderlessTable<T extends Record<string, string>>({
columns,
dataSource,
}: BorderlessTableProps<T>) {
return (
<div className="overflow-x-auto rounded-[12px] bg-[#08070E]/55 p-[10px]">
<table className="w-full min-w-[960px] table-fixed border-collapse text-left text-[14px] text-white">
<div className="overflow-x-auto rounded-[14px] border border-white/8 bg-[#08070E]/58 p-[10px] shadow-[0_12px_32px_rgba(0,0,0,0.22)]">
<table className="w-full min-w-[860px] table-fixed border-collapse text-left text-[14px] text-white">
<thead>
<tr className="bg-linear-to-r from-[#F96C02] to-[#FE9F00] text-white">
{columns.map((column) => (
<th
key={String(column.key)}
className="px-[16px] py-[14px] text-[13px] font-bold first:rounded-l-[10px] last:rounded-r-[10px]"
className="px-[16px] py-[14px] text-[12px] font-bold uppercase tracking-[0.08em] first:rounded-tl-[10px] last:rounded-tr-[10px]"
>
{column.label}
</th>
@@ -34,15 +23,22 @@ function BorderlessTable<T extends Record<string, string>>({
{dataSource.map((record, index) => (
<tr
key={`${record.name}-${index}`}
className="bg-white/4 transition-colors hover:bg-white/8"
className="border-b border-white/6 bg-white/4 transition-colors last:border-b-0 hover:bg-white/8"
>
{columns.map((column) => {
{columns.map((column, columnIndex) => {
const value = record[column.key]
const isLastRow = index === dataSource.length - 1
const isFirstColumn = columnIndex === 0
const isLastColumn = columnIndex === columns.length - 1
return (
<td
key={String(column.key)}
className="px-[16px] py-[16px] align-top text-[13px] text-white/90"
className={`px-[16px] py-[16px] align-top text-[13px] leading-[1.55] text-white/90 ${
isLastRow && isFirstColumn ? 'rounded-bl-[10px]' : ''
} ${
isLastRow && isLastColumn ? 'rounded-br-[10px]' : ''
}`}
>
{column.render ? column.render(value, record, index) : value}
</td>

39
src/constant/index.ts Normal file
View File

@@ -0,0 +1,39 @@
import {
ArrowRightLeft,
Gift,
Sparkles,
type LucideIcon,
} from 'lucide-react'
import type {goodsType} from '@/types/business.type.ts'
export type GoodCategoryMeta = {
name: string
ctaLabel: string
icon: LucideIcon
}
export const HOME_CATEGORY_META_MAP: Record<goodsType, GoodCategoryMeta> = {
WITHDRAW: {
name: 'Transfer to Platform',
ctaLabel: 'Transfer Now',
icon: ArrowRightLeft,
},
BONUS: {
name: 'Game Bonus',
ctaLabel: 'Redeem Bonus',
icon: Sparkles,
},
PHYSICAL: {
name: 'Physical Prizes',
ctaLabel: 'Claim Prize',
icon: Gift,
},
}
export const HOME_GOOD_TYPE_ORDER = ['WITHDRAW', 'BONUS', 'PHYSICAL'] as const satisfies readonly goodsType[]
export const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ?? 'https://playx-api.cjdhr.top'
/**@description 待处理 / 已完成 / 已发货 / 已驳回 */
export const ORDER_STATUS = ['PENDING','COMPLETED','SHIPPED','REJECTED']

View File

@@ -0,0 +1,37 @@
import type {AddAddressForm} from '@/types'
type AddressValidationResult =
| { valid: true }
| { valid: false; message: string }
export function validateAddressFormSubmission(addressForm: AddAddressForm): AddressValidationResult {
if (!addressForm.name.trim()) {
return {
valid: false,
message: 'Please enter the receiver name.',
}
}
if (!addressForm.phone.trim()) {
return {
valid: false,
message: 'Please enter a reachable mobile number.',
}
}
if (addressForm.region.length !== 3) {
return {
valid: false,
message: 'Please select province, city and district.',
}
}
if (!addressForm.detailedAddress.trim()) {
return {
valid: false,
message: 'Please enter the detailed address.',
}
}
return {valid: true}
}

View File

@@ -0,0 +1,2 @@
export * from './useAddressBook'
export * from './addressValidation'

View File

@@ -0,0 +1,187 @@
import {useCallback, useState} from 'react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {addressAdd, addressDelete, addressEdit, addressList} from '@/api/address.ts'
import {validateAddressFormSubmission} from '@/features/addressBook/addressValidation'
import {notifyError} from '@/features/notifications'
import {queryKeys} from '@/lib/queryKeys.ts'
import {emptyAddressFormMock} from '@/mock'
import {useUserStore} from '@/store/user.ts'
import type {AddressListItem} from '@/types/address.type.ts'
import type {AddAddressForm, AddressOption} from '@/types'
type UseAddressBookOptions = {
autoLoad?: boolean
}
export function getAddressText(item: AddressListItem) {
const regionText = item.region_text || item.region.map((part) => part.trim()).join(', ')
return [regionText, item.detail_address].filter(Boolean).join(', ')
}
export function mapAddressToOption(item: AddressListItem): AddressOption {
return {
id: String(item.id),
name: item.receiver_name,
phone: item.phone,
address: getAddressText(item),
isDefault: item.default_setting === 1,
}
}
export function mapAddressToForm(item: AddressListItem): AddAddressForm {
return {
name: item.receiver_name,
phone: item.phone,
region: item.region.map((part) => part.trim()).filter(Boolean).slice(0, 3),
detailedAddress: item.detail_address,
isDefault: item.default_setting === 1,
}
}
export function useAddressBook(options?: UseAddressBookOptions) {
const queryClient = useQueryClient()
const sessionId = useUserStore((state) => state.authInfo?.session_id ?? '')
const [addressForm, setAddressForm] = useState<AddAddressForm>(emptyAddressFormMock)
const addressListQuery = useQuery({
queryKey: queryKeys.addressList(sessionId),
enabled: Boolean(sessionId) && Boolean(options?.autoLoad),
queryFn: async () => {
const response = await addressList({session_id: sessionId!})
return response.data.list
},
})
const saveAddressMutation = useMutation({
mutationFn: async (editingAddress?: AddressListItem | null) => {
const payload = {
session_id: sessionId!,
receiver_name: addressForm.name.trim(),
phone: addressForm.phone.trim(),
region: addressForm.region.join(', '),
detail_address: addressForm.detailedAddress.trim(),
default_setting: addressForm.isDefault ? '1' : '0',
} as const
if (editingAddress) {
return await addressEdit({
...payload,
id: String(editingAddress.id),
})
}
return await addressAdd(payload)
},
})
const deleteAddressMutation = useMutation({
mutationFn: async (addressId: string) => {
return await addressDelete({
id: addressId,
session_id: sessionId!,
})
},
})
const addresses = addressListQuery.data ?? []
const addressOptions = addresses.map(mapAddressToOption)
const isAddressFormValid = validateAddressFormSubmission(addressForm).valid
const loadAddresses = useCallback(async () => {
if (!sessionId) {
queryClient.removeQueries({queryKey: queryKeys.addressList(sessionId)})
return []
}
try {
const result = await queryClient.fetchQuery({
queryKey: queryKeys.addressList(sessionId),
queryFn: async () => {
const response = await addressList({session_id: sessionId})
return response.data.list
},
})
return result
} catch {
return []
}
}, [queryClient, sessionId])
const resetAddressForm = () => {
setAddressForm(emptyAddressFormMock)
saveAddressMutation.reset()
}
const fillAddressForm = (address?: AddressListItem | null) => {
setAddressForm(address ? mapAddressToForm(address) : emptyAddressFormMock)
saveAddressMutation.reset()
}
const changeAddressForm = (field: keyof AddAddressForm, value: AddAddressForm[keyof AddAddressForm]) => {
setAddressForm((previous) => ({
...previous,
[field]: value,
}))
}
const saveAddress = async (editingAddress?: AddressListItem | null) => {
if (!sessionId) {
return null
}
const validation = validateAddressFormSubmission(addressForm)
if (!validation.valid) {
notifyError(validation.message)
return null
}
try {
const response = await saveAddressMutation.mutateAsync(editingAddress)
await queryClient.invalidateQueries({
queryKey: queryKeys.addressList(sessionId),
})
return {
addresses: await loadAddresses(),
response,
}
} catch {
return null
}
}
const removeAddress = async (addressId: string) => {
if (!sessionId) {
return null
}
try {
const response = await deleteAddressMutation.mutateAsync(addressId)
await queryClient.invalidateQueries({
queryKey: queryKeys.addressList(sessionId),
})
return response
} catch {
return null
}
}
return {
sessionId,
addresses,
addressOptions,
loading: addressListQuery.isPending,
addressForm,
isAddressFormValid,
submitLoading: saveAddressMutation.isPending,
deleteLoading: deleteAddressMutation.isPending,
loadAddresses,
resetAddressForm,
fillAddressForm,
changeAddressForm,
saveAddress,
removeAddress,
}
}

View File

@@ -0,0 +1,73 @@
import {useQuery, useQueryClient} from '@tanstack/react-query'
import {type PropsWithChildren, useEffect} from 'react'
import {login, validateToken} from '@/api/auth.ts'
import {userAssets} from '@/api/user.ts'
import {queryKeys} from '@/lib/queryKeys.ts'
import {useUserStore} from '@/store/user.ts'
export function AuthGuide({children}: PropsWithChildren) {
const queryClient = useQueryClient()
const setUserInfo = useUserStore((state) => state.setUserInfo)
const setAuthInfo = useUserStore((state) => state.setAuthInfo)
const setAssetsInfo = useUserStore((state) => state.setAssetsInfo)
const clearUserInfo = useUserStore((state) => state.clearUserInfo)
const authBootstrapQuery = useQuery({
queryKey: queryKeys.authBootstrap,
queryFn: async () => {
const loginResponse = await login({username: '+60777777777'})
const userInfo = loginResponse.data.userInfo
const validateResponse = await validateToken(userInfo.token)
const authInfo = validateResponse.data
const assetsResponse = await userAssets({
session_id: authInfo.session_id,
})
return {
userInfo,
authInfo,
assetsInfo: assetsResponse.data,
}
},
})
useEffect(() => {
if (!authBootstrapQuery.data) {
return
}
setUserInfo(authBootstrapQuery.data.userInfo)
setAuthInfo(authBootstrapQuery.data.authInfo)
setAssetsInfo(authBootstrapQuery.data.assetsInfo)
queryClient.setQueryData(queryKeys.assets(authBootstrapQuery.data.authInfo.session_id), authBootstrapQuery.data.assetsInfo)
}, [authBootstrapQuery.data, queryClient, setAssetsInfo, setAuthInfo, setUserInfo])
useEffect(() => {
if (!authBootstrapQuery.isError) {
return
}
clearUserInfo()
}, [authBootstrapQuery.isError, clearUserInfo])
if (authBootstrapQuery.isPending) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#08070E] px-6 text-center text-[14px] text-white/68">
Loading account data...
</div>
)
}
if (authBootstrapQuery.isError) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#08070E] px-6 text-center">
<div className="max-w-[420px] rounded-[14px] border border-white/10 bg-white/4 px-[18px] py-[16px]">
<div className="text-[16px] font-semibold text-white">Authentication failed</div>
<div className="mt-[8px] text-[13px] leading-[1.6] text-white/58">Please refresh and try again.</div>
</div>
</div>
)
}
return <>{children}</>
}

View File

@@ -0,0 +1,131 @@
import {useState} from 'react'
import {ChevronRight} from 'lucide-react'
import Button from '@/components/button'
import {HOME_CATEGORY_META_MAP} from '@/constant'
import type {ProductCategory, ProductItem} from '@/types'
type GoodsCategoryListProps = {
categories: ProductCategory[]
loading?: boolean
emptyText?: string
showMore?: boolean
onMoreClick?: (categoryId: ProductCategory['id']) => void
onRedeem: (product: ProductItem, categoryId: ProductCategory['id']) => void
}
function GoodsImage({
imageUrl,
}: {
imageUrl?: string
}) {
const [hasError, setHasError] = useState(false)
const showFallback = !imageUrl || hasError
return (
<div className="relative h-[116px] w-full overflow-hidden rounded-t-[10px] sm:h-[128px]">
{showFallback ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[radial-gradient(circle_at_top,rgba(250,106,0,0.18),transparent_58%),linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.02))] px-[12px] text-center">
<div className="h-[30px] w-[30px] rounded-[10px] border border-white/10 bg-white/6"></div>
<div className="mt-[10px] text-[12px] tracking-[0.08em] text-white/42">NO IMAGE</div>
</div>
) : null}
{imageUrl ? (
<img
src={imageUrl}
alt=""
className={`h-full w-full object-cover ${showFallback ? 'hidden' : 'block'}`}
onError={() => setHasError(true)}
/>
) : null}
</div>
)
}
export function GoodsCategoryList({
categories,
loading = false,
emptyText = 'No goods available yet.',
showMore = false,
onMoreClick,
onRedeem,
}: GoodsCategoryListProps) {
if (loading) {
return (
<div className="pb-[24px] mt-[20px]">
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
Loading ...
</div>
</div>
)
}
if (!categories.length) {
return (
<div className="pb-[24px]">
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
{emptyText}
</div>
</div>
)
}
return (
<div className="pb-[24px]">
{categories.map((category) => {
const CategoryIcon = HOME_CATEGORY_META_MAP[category.id].icon
return (
<div key={category.id} className="mt-[20px]">
<div className="mb-[10px] flex items-center justify-between gap-[12px]">
<div className="flex min-w-0 items-center gap-[10px]">
<div
className="flex h-[36px] w-[36px] items-center justify-center rounded-[12px] bg-[#FA6A00]/15 text-[#FE9F00]">
<CategoryIcon className="h-[18px] w-[18px]" aria-hidden="true"/>
</div>
<div className="truncate text-[15px] font-semibold text-white">{category.name}</div>
</div>
{showMore && onMoreClick ? (
<button
type="button"
className="flex shrink-0 items-center gap-[3px] text-[12px] font-light text-[#FA6A00] underline cursor-pointer"
onClick={() => onMoreClick(category.id)}
>
more
<ChevronRight className="h-[14px] w-[14px]" aria-hidden="true"/>
</button>
) : null}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
{category.items.map((product) => (
<div
key={product.id}
className="liquid-glass-bg flex min-h-[260px] w-full flex-col items-stretch justify-start overflow-hidden"
>
<GoodsImage imageUrl={product.imageUrl}/>
<div
className="flex flex-1 flex-col items-start justify-between gap-[12px] p-[12px] sm:p-[14px]"
>
<div className="space-y-[4px]">
<div className="text-[16px] font-medium text-white">{product.title}</div>
<div className="text-[13px] text-white/52">{product.subtitle}</div>
</div>
<div className="text-[16px] font-semibold text-[#FA6A00]">{product.score}</div>
<Button
className="h-[36px] w-full text-[13px]"
onClick={() => onRedeem(product, category.id)}
>
{product.ctaLabel}
</Button>
</div>
</div>
))}
</div>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,354 @@
import {
Check,
ChevronRight,
CirclePlus,
Gift,
MapPinHouse,
} from 'lucide-react'
import Button from '@/components/button'
import Modal from '@/components/modal'
import {RegionPicker} from '@/features/goods/RegionPicker'
import type {
AddAddressForm,
AddressOption,
ModalMode,
SelectedProductState,
} from '@/types'
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
}
type GoodsRedeemModalProps = {
selectedProduct: SelectedProductState | null
modalMode: ModalMode
addressOptions: AddressOption[]
selectedAddressId: string
addressForm: AddAddressForm
addressLoading: boolean
isAddAddressFormValid: boolean
submitLoading: boolean
onClose: () => void
onConfirm: () => void
onOpenAddAddress: () => void
onBackToSelectAddress: () => void
onSelectAddress: (addressId: string) => void
onChangeAddressForm: (field: keyof AddAddressForm, value: AddAddressForm[keyof AddAddressForm]) => void
forceOpen?: boolean
formOnly?: boolean
titleOverride?: string
confirmText?: string
}
export function GoodsRedeemModal({
selectedProduct,
modalMode,
addressOptions,
selectedAddressId,
addressForm,
addressLoading,
isAddAddressFormValid,
submitLoading,
onClose,
onConfirm,
onOpenAddAddress,
onBackToSelectAddress,
onSelectAddress,
onChangeAddressForm,
forceOpen = false,
formOnly = false,
titleOverride,
confirmText = 'Confirm',
}: GoodsRedeemModalProps) {
const selectedCategoryId = selectedProduct?.categoryId
const selectedProductData = selectedProduct?.product ?? null
const isPhysicalPrize = selectedCategoryId === 'PHYSICAL'
const isTransferToPlatform = selectedCategoryId === 'WITHDRAW'
const isGameBonus = selectedCategoryId === 'BONUS'
const isPhysicalPrizeSelection = modalMode === 'select-address' && isPhysicalPrize
const modalTitle = modalMode === 'add-address'
? 'Add Shipping Address'
: isTransferToPlatform
? 'Confirm Withdrawal'
: isGameBonus
? 'Confirm Bonus Redemption'
: 'Confirm Physical Reward'
const modalMaxWidthClassName = isTransferToPlatform
? 'max-w-[620px]'
: isGameBonus
? 'max-w-[460px]'
: 'max-w-[680px]'
const resolvedTitle = titleOverride ?? modalTitle
const isAddressFormMode = formOnly || isPhysicalPrize
const addressFormContent = (
<div
className="rounded-[12px] bg-[#171313]/88 px-[14px] py-[10px] shadow-[0_10px_30px_rgba(0,0,0,0.25)]">
<div
className="flex flex-col gap-[10px] border-b border-white/10 px-[6px] pb-[16px] sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-[8px] text-[18px] font-medium text-white">
<MapPinHouse className="h-[18px] w-[18px] text-[#FE9F00]" aria-hidden="true"/>
<span>Address Info</span>
</div>
<button
type="button"
className="flex items-center gap-[8px] text-[14px] text-white/85"
onClick={() => onChangeAddressForm('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-1 gap-[10px] px-[6px] py-[16px] sm:grid-cols-[170px_1fr] sm:items-center">
<label className="text-[14px] text-white/92">
Name<span className="text-[#FA6A00]">*</span>
</label>
<input
value={addressForm.name}
onChange={(event) => onChangeAddressForm('name', event.target.value)}
placeholder="Enter receiver's full name"
className="bg-transparent text-[14px] text-white outline-none placeholder:text-white/35"
/>
</div>
<div
className="grid grid-cols-1 gap-[10px] px-[6px] py-[16px] sm:grid-cols-[170px_1fr] sm:items-center">
<label className="text-[14px] text-white/92">
Phone Number<span className="text-[#FA6A00]">*</span>
</label>
<input
value={addressForm.phone}
type="number"
onChange={(event) => onChangeAddressForm('phone', event.target.value)}
placeholder="Enter a reachable mobile number"
className="bg-transparent text-[14px] text-white outline-none placeholder:text-white/35"
/>
</div>
<div
className="grid grid-cols-1 gap-[10px] px-[6px] py-[16px] sm:grid-cols-[170px_1fr] sm:items-center">
<label className="text-[14px] text-white/92">
Region<span className="text-[#FA6A00]">*</span>
</label>
<RegionPicker
value={addressForm.region}
onChange={(value) => onChangeAddressForm('region', value)}
/>
</div>
<div
className="grid grid-cols-1 gap-[10px] px-[6px] py-[16px] sm:grid-cols-[170px_1fr] sm:items-center">
<label className="text-[14px] text-white/92">
Detailed Address<span className="text-[#FA6A00]">*</span>
</label>
<input
value={addressForm.detailedAddress}
onChange={(event) => onChangeAddressForm('detailedAddress', event.target.value)}
placeholder="Street, building, unit and room number"
className="bg-transparent text-[14px] text-white outline-none placeholder:text-white/35"
/>
</div>
</div>
</div>
)
return (
<Modal
open={forceOpen || Boolean(selectedProduct)}
title={resolvedTitle}
onClose={onClose}
className={formOnly ? 'max-w-[680px]' : modalMaxWidthClassName}
bodyClassName="space-y-[18px]"
footer={
<>
<Button
type="button"
variant="gray"
className={`h-[38px] text-[14px] ${
isPhysicalPrizeSelection ? 'w-[124px]' : 'w-full sm:w-auto'
}`}
onClick={modalMode === 'add-address' ? onBackToSelectAddress : onClose}
disabled={submitLoading}
>
Cancel
</Button>
<Button
type="button"
className={`h-[38px] ${
isPhysicalPrizeSelection ? 'w-[124px]' : 'w-full sm:w-auto'
} ${modalMode === 'add-address' && !isAddAddressFormValid ? 'opacity-50' : ''}`}
onClick={onConfirm}
disabled={submitLoading || (modalMode === 'add-address' && !isAddAddressFormValid)}
>
{submitLoading ? 'Processing...' : confirmText}
</Button>
</>
}
>
{formOnly ? (
addressFormContent
) : 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.score)}</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.score)}</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="rounded-[12px] bg-[#1F1B1B]/82 px-[14px] py-[10px] shadow-[0_10px_28px_rgba(0,0,0,0.2)]">
<div
className="flex items-center justify-between gap-[12px] border-b border-white/8 py-[12px]">
<div className="text-[13px] text-white/82">Item</div>
<div className="flex items-center gap-[10px]">
<div className="text-right text-[15px] text-white">{selectedProductData.title}</div>
<div className="h-[54px] w-[54px] overflow-hidden rounded-[10px] shadow-[0_10px_18px_rgba(0,0,0,0.2)]">
{selectedProductData.imageUrl ? (
<img
src={selectedProductData.imageUrl}
alt={selectedProductData.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<Gift className="h-[24px] w-[24px] text-white" aria-hidden="true"/>
</div>
)}
</div>
</div>
</div>
<div className="flex items-center justify-between py-[12px]">
<div className="text-[13px] text-white/82">Points Cost</div>
<div className="text-[15px] text-white">{getNumericValue(selectedProductData.score)}</div>
</div>
</div>
<div>
<div className="mb-[12px] text-[14px] text-white/40">
Please select the address information to fill in.
</div>
<div className="overflow-hidden rounded-[12px] bg-[#1F1B1B]/82 shadow-[0_10px_28px_rgba(0,0,0,0.2)]">
{addressLoading ? (
<div className="px-[14px] py-[16px] text-[14px] text-white/60">
Loading address list...
</div>
) : null}
{addressOptions.map((address) => {
const isSelected = selectedAddressId === address.id
return (
<button
key={address.id}
type="button"
className={`flex w-full items-start gap-[12px] border-b border-white/6 px-[14px] py-[14px] text-left transition-colors last:border-b-0 hover:bg-white/4 ${
isSelected ? 'bg-white/[0.03]' : ''
}`}
onClick={() => onSelectAddress(address.id)}
>
<div
className="mt-[2px] flex h-[20px] w-[20px] shrink-0 items-center justify-center rounded-full">
{isSelected ? (
<span
className="flex h-[20px] w-[20px] items-center justify-center rounded-full bg-[#FA8A00] text-white shadow-[0_0_12px_rgba(250,138,0,0.45)]">
<Check className="h-[12px] w-[12px]" aria-hidden="true"/>
</span>
) : (
<span className="h-[20px] w-[20px] rounded-full border border-white/40"></span>
)}
</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-[12px] text-white/45">{address.phone}</div>
{address.isDefault ? (
<div
className="rounded-[4px] bg-[#FF7F7F] px-[5px] py-[1px] text-[10px] leading-none text-white">
Default
</div>
) : null}
</div>
<div className="mt-[6px] text-[13px] leading-[1.5] text-white/52">
{address.address}
</div>
</div>
</button>
)
})}
<button
type="button"
className="flex w-full items-center justify-between px-[14px] py-[16px] text-left transition-colors hover:bg-white/4 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]"
onClick={onOpenAddAddress}
disabled={addressLoading}
>
<div className="flex items-center gap-[10px]">
<span
className="flex h-[16px] w-[16px] items-center justify-center rounded-full border border-white/70 text-white">
<CirclePlus className="h-[12px] w-[12px]" aria-hidden="true"/>
</span>
<div className="text-[14px] text-white/82">Add Address</div>
</div>
<ChevronRight className="h-[16px] w-[16px] text-white/45" aria-hidden="true"/>
</button>
</div>
</div>
</>
) : isAddressFormMode ? addressFormContent : null}
</>
) : null}
</Modal>
)
}

View File

@@ -0,0 +1,158 @@
import {useEffect, useState} from 'react'
type ProvinceNode = {
c: string
n: string
p: string
}
type CityNode = {
c: string
n: string
p: string
y: string
}
type AreaNode = {
c: string
n: string
p: string
y: string
a: string
}
type RegionPickerProps = {
value: string[]
onChange: (value: string[]) => void
}
type RegionDataset = {
province: ProvinceNode[]
city: CityNode[]
area: AreaNode[]
}
function RegionSelect({
value,
placeholder,
options,
onChange,
disabled = false,
}: {
value: string
placeholder: string
options: Array<{c: string; n: string}>
onChange: (value: string) => void
disabled?: boolean
}) {
return (
<div className="relative">
<select
value={value}
onChange={(event) => onChange(event.target.value)}
disabled={disabled}
className="h-[42px] w-full appearance-none rounded-[10px] border border-white/10 bg-[#0E0B12] px-[12px] pr-[36px] text-[14px] text-white outline-none transition-colors focus:border-[#FA6A00] disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">{placeholder}</option>
{options.map((option) => (
<option key={option.c} value={option.n}>
{option.n}
</option>
))}
</select>
<div className="pointer-events-none absolute right-[12px] top-1/2 -translate-y-1/2 text-[12px] text-white/38">
v
</div>
</div>
)
}
export function RegionPicker({value, onChange}: RegionPickerProps) {
const [regionData, setRegionData] = useState<RegionDataset | null>(null)
const [loadFailed, setLoadFailed] = useState(false)
const [province = '', city = '', district = ''] = value
useEffect(() => {
let cancelled = false
const loadRegionData = async () => {
try {
const [provinceResponse, cityResponse, areaResponse] = await Promise.all([
fetch(`${import.meta.env.BASE_URL}china-data/province.min.json`),
fetch(`${import.meta.env.BASE_URL}china-data/city.min.json`),
fetch(`${import.meta.env.BASE_URL}china-data/area.min.json`),
])
if (!provinceResponse.ok || !cityResponse.ok || !areaResponse.ok) {
throw new Error('Failed to load region data')
}
const [provinceList, cityList, areaList] = await Promise.all([
provinceResponse.json() as Promise<ProvinceNode[]>,
cityResponse.json() as Promise<CityNode[]>,
areaResponse.json() as Promise<AreaNode[]>,
])
if (!cancelled) {
setRegionData({
province: provinceList,
city: cityList,
area: areaList,
})
setLoadFailed(false)
}
} catch {
if (!cancelled) {
setLoadFailed(true)
}
}
}
void loadRegionData()
return () => {
cancelled = true
}
}, [])
const provinceList = regionData?.province ?? []
const cityList = regionData?.city ?? []
const areaList = regionData?.area ?? []
const selectedProvince = provinceList.find((item) => item.n === province)
const selectedCity = cityList.find((item) => item.n === city && item.p === selectedProvince?.p)
const cities = selectedProvince
? cityList.filter((item) => item.p === selectedProvince.p)
: []
const districts = selectedCity
? areaList.filter((item) => item.p === selectedCity.p && item.y === selectedCity.y)
: []
return (
<div className="grid grid-cols-1 gap-[10px] sm:grid-cols-3">
<RegionSelect
value={province}
placeholder={loadFailed ? 'failed to load' : 'province'}
options={provinceList}
onChange={(nextProvince) => onChange(nextProvince ? [nextProvince] : [])}
disabled={loadFailed || !regionData}
/>
<RegionSelect
value={city}
placeholder="city"
options={cities}
disabled={loadFailed || !regionData || !province}
onChange={(nextCity) => onChange(nextCity ? [province, nextCity] : [province])}
/>
<RegionSelect
value={district}
placeholder="district"
options={districts}
disabled={loadFailed || !regionData || !city}
onChange={(nextDistrict) => onChange(nextDistrict ? [province, city, nextDistrict] : [province, city])}
/>
</div>
)
}

View File

@@ -0,0 +1,7 @@
export * from './GoodsCategoryList'
export * from './GoodsRedeemModal'
export * from './RegionPicker'
export * from './useAssetsRefresh'
export * from './useAssetsQuery'
export * from './useGoodsCatalog'
export * from './useGoodsRedeem'

View File

@@ -0,0 +1,61 @@
import type {SelectedProductState} from '@/types'
type RedeemValidationParams = {
sessionId?: string
selectedProduct: SelectedProductState | null
selectedAddressId: string
}
type RedeemValidationResult =
| { valid: true }
| { valid: false; message: string }
export function validateRedeemSubmission({
sessionId,
selectedProduct,
selectedAddressId,
}: RedeemValidationParams): RedeemValidationResult {
if (!sessionId) {
return {
valid: false,
message: 'Session expired. Please log in again.',
}
}
if (!selectedProduct) {
return {
valid: false,
message: 'No product selected.',
}
}
if (selectedProduct.categoryId === 'PHYSICAL' && !selectedAddressId) {
return {
valid: false,
message: 'Please select a shipping address.',
}
}
return {valid: true}
}
type AddAddressValidationParams = {
isAddAddressFormValid: boolean
}
type AddAddressValidationResult =
| { valid: true }
| { valid: false; message: string }
export function validateAddAddressSubmission({
isAddAddressFormValid,
}: AddAddressValidationParams): AddAddressValidationResult {
if (!isAddAddressFormValid) {
return {
valid: false,
message: 'Please complete all required address fields.',
}
}
return {valid: true}
}

View File

@@ -0,0 +1,39 @@
import {useEffect} from 'react'
import {useQuery} from '@tanstack/react-query'
import {userAssets} from '@/api/user.ts'
import {queryKeys} from '@/lib/queryKeys.ts'
import {useUserStore} from '@/store/user.ts'
export function useAssetsQuery() {
const sessionId = useUserStore((state) => state.authInfo?.session_id ?? '')
const setAssetsInfo = useUserStore((state) => state.setAssetsInfo)
const fallbackAssetsInfo = useUserStore((state) => state.assetsInfo)
const assetsQuery = useQuery({
queryKey: queryKeys.assets(sessionId),
enabled: Boolean(sessionId),
queryFn: async () => {
const response = await userAssets({
session_id: sessionId,
})
return response.data
},
initialData: fallbackAssetsInfo ?? undefined,
})
useEffect(() => {
if (!assetsQuery.data) {
return
}
setAssetsInfo(assetsQuery.data)
}, [assetsQuery.data, setAssetsInfo])
return {
assetsInfo: assetsQuery.data ?? null,
assetsLoading: assetsQuery.isPending,
assetsError: assetsQuery.error instanceof Error ? assetsQuery.error.message : null,
}
}

View File

@@ -0,0 +1,23 @@
import {useQueryClient} from '@tanstack/react-query'
import {queryKeys} from '@/lib/queryKeys.ts'
import {useUserStore} from '@/store/user.ts'
export function useAssetsRefresh() {
const queryClient = useQueryClient()
const sessionId = useUserStore((state) => state.authInfo?.session_id ?? '')
const invalidateAssets = async () => {
if (!sessionId) {
return
}
await queryClient.invalidateQueries({
queryKey: queryKeys.assets(sessionId),
})
}
return {
invalidateAssets,
}
}

View File

@@ -0,0 +1,89 @@
import {useQuery, useQueryClient} from '@tanstack/react-query'
import {goodList} from '@/api/business.ts'
import {
API_ORIGIN,
HOME_CATEGORY_META_MAP,
HOME_GOOD_TYPE_ORDER,
} from '@/constant'
import {queryKeys} from '@/lib/queryKeys.ts'
import type {ProductCategory, ProductItem} from '@/types'
import type {GoodsItem, goodsType} from '@/types/business.type.ts'
function getProductImageUrl(image?: string) {
if (!image) {
return undefined
}
if (/^https?:\/\//.test(image)) {
return image
}
return new URL(image, API_ORIGIN).toString()
}
function mapGoodItemToProductItem(item: GoodsItem, type: goodsType): ProductItem {
const categoryMeta = HOME_CATEGORY_META_MAP[type]
return {
id: String(item.id),
title: item.title,
subtitle: item.description,
score: `${item.score} Points`,
ctaLabel: categoryMeta.ctaLabel,
imageUrl: getProductImageUrl(item.image),
}
}
function buildProductCategories(groups: Record<goodsType, GoodsItem[]>): ProductCategory[] {
return HOME_GOOD_TYPE_ORDER.map((type) => ({
id: type,
name: HOME_CATEGORY_META_MAP[type].name,
items: groups[type].map((item) => mapGoodItemToProductItem(item, type)),
}))
}
export function isGoodsType(value: string | null | undefined): value is goodsType {
return value === 'WITHDRAW' || value === 'BONUS' || value === 'PHYSICAL'
}
type UseGoodsCatalogOptions = {
types?: readonly goodsType[]
}
export function useGoodsCatalog(options?: UseGoodsCatalogOptions) {
const queryClient = useQueryClient()
const types = options?.types?.length ? [...options.types] : HOME_GOOD_TYPE_ORDER
const goodsCatalogQuery = useQuery({
queryKey: queryKeys.goodsCatalog(types),
queryFn: async () => {
const responses = await Promise.all(
types.map(async (type) => {
const response = await goodList({type})
return [type, response.data.list] as const
}),
)
const groups = responses.reduce<Record<goodsType, GoodsItem[]>>(
(result, [type, items]) => {
result[type] = items
return result
},
{
WITHDRAW: [],
BONUS: [],
PHYSICAL: [],
},
)
return buildProductCategories(groups)
},
})
return {
productCategories: goodsCatalogQuery.data ?? [],
loading: goodsCatalogQuery.isPending,
error: goodsCatalogQuery.error instanceof Error ? goodsCatalogQuery.error.message : null,
invalidateGoods: () => queryClient.invalidateQueries({queryKey: ['goods-catalog']}),
}
}

View File

@@ -0,0 +1,156 @@
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {useState} from 'react'
import {bonusRedeem, physicalRedeem, withdrawApply} from '@/api/business.ts'
import {useAddressBook} from '@/features/addressBook'
import {notifyError, notifySuccess} from '@/features/notifications'
import {queryKeys} from '@/lib/queryKeys.ts'
import {validateAddAddressSubmission, validateRedeemSubmission} from '@/features/goods/redeemValidation'
import type {
ModalMode,
ProductCategory,
ProductItem,
SelectedProductState,
} from '@/types'
export function useGoodsRedeem() {
const queryClient = useQueryClient()
const addressBook = useAddressBook()
const [selectedProduct, setSelectedProduct] = useState<SelectedProductState | null>(null)
const [modalMode, setModalMode] = useState<ModalMode>('select-address')
const [selectedAddressId, setSelectedAddressId] = useState<string>('')
const redeemMutation = useMutation({
mutationFn: async (selectedProductState: SelectedProductState) => {
if (selectedProductState.categoryId === 'BONUS') {
return await bonusRedeem({
item_id: selectedProductState.product.id,
session_id: addressBook.sessionId,
})
}
if (selectedProductState.categoryId === 'PHYSICAL') {
return await physicalRedeem({
item_id: selectedProductState.product.id,
session_id: addressBook.sessionId,
address_id: selectedAddressId,
})
}
return await withdrawApply({
item_id: selectedProductState.product.id,
session_id: addressBook.sessionId,
})
},
})
const openRedeemModal = async (product: ProductItem, categoryId: ProductCategory['id']) => {
setSelectedProduct({
product,
categoryId,
})
addressBook.resetAddressForm()
if (categoryId === 'PHYSICAL') {
const addresses = await addressBook.loadAddresses()
const defaultAddress = addresses.find((item) => item.default_setting === 1) ?? addresses[0]
setSelectedAddressId(defaultAddress ? String(defaultAddress.id) : '')
setModalMode('select-address')
return
}
setModalMode('select-address')
}
const closeRedeemModal = () => {
setSelectedProduct(null)
setModalMode('select-address')
addressBook.resetAddressForm()
redeemMutation.reset()
}
const openAddAddress = () => {
setModalMode('add-address')
}
const backToSelectAddress = () => {
setModalMode('select-address')
}
const isAddAddressFormValid = addressBook.isAddressFormValid
const confirmRedeem = async () => {
if (modalMode === 'add-address') {
const addAddressValidation = validateAddAddressSubmission({
isAddAddressFormValid,
})
if (!addAddressValidation.valid) {
notifyError(addAddressValidation.message)
return
}
const savedAddress = await addressBook.saveAddress()
if (savedAddress) {
const defaultOption = savedAddress.addresses
.map((item) => ({
id: String(item.id),
isDefault: item.default_setting === 1,
}))
.find((item) => item.isDefault)
?? (savedAddress.addresses[0]
? {
id: String(savedAddress.addresses[0].id),
isDefault: savedAddress.addresses[0].default_setting === 1,
}
: null)
setSelectedAddressId(defaultOption?.id ?? '')
setModalMode('select-address')
notifySuccess(savedAddress.response, 'Address added successfully.')
}
return
}
const redeemValidation = validateRedeemSubmission({
sessionId: addressBook.sessionId,
selectedProduct,
selectedAddressId,
})
if (!redeemValidation.valid) {
notifyError(redeemValidation.message)
return
}
try {
const response = await redeemMutation.mutateAsync(selectedProduct!)
await Promise.all([
queryClient.invalidateQueries({queryKey: ['goods-catalog']}),
addressBook.sessionId
? queryClient.invalidateQueries({queryKey: queryKeys.assets(addressBook.sessionId)})
: Promise.resolve(),
])
notifySuccess(response, 'Redeem request submitted successfully.')
closeRedeemModal()
} catch {
// mutation error is surfaced via redeemMutation.error
}
}
return {
selectedProduct,
modalMode,
addressOptions: addressBook.addressOptions,
selectedAddressId,
addressForm: addressBook.addressForm,
addressLoading: addressBook.loading,
isAddAddressFormValid,
submitLoading: modalMode === 'add-address' ? addressBook.submitLoading : redeemMutation.isPending,
openRedeemModal,
closeRedeemModal,
openAddAddress,
backToSelectAddress,
setSelectedAddressId,
changeAddressForm: addressBook.changeAddressForm,
confirmRedeem,
}
}

View File

@@ -0,0 +1,16 @@
import type {ValidateTokenData} from '@/types/auth.type.ts'
type ClaimValidationResult =
| { valid: true }
| { valid: false; message: string }
export function validateClaimSubmission(authInfo: ValidateTokenData | null): ClaimValidationResult {
if (!authInfo?.session_id || !authInfo.user_id) {
return {
valid: false,
message: 'Session expired. Please log in again.',
}
}
return {valid: true}
}

View File

@@ -0,0 +1,32 @@
import {CircleAlert, CircleCheckBig} from 'lucide-react'
import {useToastStore} from './store'
export function GlobalToast() {
const toast = useToastStore((state) => state.toast)
if (!toast?.visible) {
return null
}
const isError = toast.type === 'error'
return (
<div className="pointer-events-none fixed right-[16px] top-[16px] z-[60] max-w-[320px]">
<div
className={`flex items-start gap-[10px] rounded-[14px] border px-[14px] py-[12px] text-white shadow-[0_18px_40px_rgba(0,0,0,0.35)] backdrop-blur-[8px] ${
isError
? 'border-[#FF9BA4]/35 bg-[#3A1318]/94'
: 'border-[#9BFFC0]/35 bg-[#11311E]/94'
}`}
>
{isError ? (
<CircleAlert className="mt-[1px] h-[18px] w-[18px] shrink-0 text-[#FF9BA4]" aria-hidden="true"/>
) : (
<CircleCheckBig className="mt-[1px] h-[18px] w-[18px] shrink-0 text-[#9BFFC0]" aria-hidden="true"/>
)}
<div className="text-[13px] leading-[1.55] text-white/92">{toast.message}</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,2 @@
export * from './GlobalToast'
export * from './store'

View File

@@ -0,0 +1,79 @@
import {create} from 'zustand'
export type ToastType = 'success' | 'error'
type ToastState = {
message: string
type: ToastType
visible: boolean
}
type ToastStore = {
toast: ToastState | null
showToast: (message: string, type?: ToastType) => void
clearToast: () => void
}
let toastTimer: ReturnType<typeof setTimeout> | null = null
export function resolveToastMessage(source: unknown, fallback = '') {
if (typeof source === 'string' && source.trim()) {
return source.trim()
}
if (typeof source === 'object' && source !== null && 'msg' in source) {
const message = (source as {msg?: unknown}).msg
if (typeof message === 'string' && message.trim()) {
return message.trim()
}
}
return fallback.trim()
}
export const useToastStore = create<ToastStore>((set) => ({
toast: null,
showToast: (message, type = 'success') => {
if (toastTimer) {
clearTimeout(toastTimer)
}
set({
toast: {
message,
type,
visible: true,
},
})
toastTimer = setTimeout(() => {
set({toast: null})
toastTimer = null
}, 2000)
},
clearToast: () => {
if (toastTimer) {
clearTimeout(toastTimer)
toastTimer = null
}
set({toast: null})
},
}))
export function notifySuccess(source: unknown, fallback = '') {
const message = resolveToastMessage(source, fallback)
if (!message) {
return
}
useToastStore.getState().showToast(message, 'success')
}
export function notifyError(source: unknown, fallback = '') {
const message = resolveToastMessage(source, fallback)
if (!message) {
return
}
useToastStore.getState().showToast(message, 'error')
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 363 KiB

View File

@@ -1,5 +1,11 @@
@import "tailwindcss";
html,
body,
#root {
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.button-play {
align-items: center;
@@ -16,7 +22,7 @@
color: #fff;
cursor: pointer;
display: inline-flex;
font-family: "JetBrains Mono",monospace;
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
justify-content: center;
line-height: 1;
list-style: none;
@@ -59,6 +65,64 @@
transform: translateY(2px);
}
.button-play-gray {
align-items: center;
appearance: none;
background-image: radial-gradient(100% 100% at 100% 0, #77716d 0, #66615d 45%, #55524f 100%);
border: 0;
border-radius: 6px;
box-shadow:
rgba(85, 82, 79, .42) 0 0 18px,
rgba(52, 49, 47, .35) 0 2px 4px,
rgba(52, 49, 47, .28) 0 7px 13px -3px,
#474440 0 -3px 0 inset;
box-sizing: border-box;
color: #fff;
cursor: pointer;
display: inline-flex;
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
justify-content: center;
line-height: 1;
list-style: none;
overflow: hidden;
padding-left: 16px;
padding-right: 16px;
position: relative;
text-align: left;
text-decoration: none;
transition: box-shadow .15s,transform .15s;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
white-space: nowrap;
will-change: box-shadow,transform;
font-size: 14px;
font-weight: bold;
}
.button-play-gray:focus {
box-shadow:
#474440 0 0 0 1.5px inset,
rgba(113, 108, 104, .5) 0 0 22px,
rgba(52, 49, 47, .35) 0 2px 4px,
rgba(52, 49, 47, .28) 0 7px 13px -3px,
#474440 0 -3px 0 inset;
}
.button-play-gray:hover {
box-shadow:
rgba(113, 108, 104, .58) 0 0 24px,
rgba(52, 49, 47, .35) 0 4px 8px,
rgba(52, 49, 47, .28) 0 7px 13px -3px,
#474440 0 -3px 0 inset;
transform: translateY(-2px);
}
.button-play-gray:active {
box-shadow: #474440 0 3px 7px inset, rgba(113, 108, 104, .28) 0 0 12px;
transform: translateY(2px);
}
.liquid-glass-bg {
position: relative;
overflow: hidden;
@@ -71,25 +135,3 @@
box-sizing: border-box;
font-weight: normal;
}
.progress-bar {
width: 100%;
height: 10px;
margin: 10px 0 8px;
overflow: hidden;
border-radius: 999px;
background: linear-gradient(180deg, rgba(18, 14, 10, 0.92) 0%, rgba(34, 22, 14, 0.96) 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.45);
}
.progress-bar__fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #fe9c00 0%, #fa6d02 100%);
box-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);
}

View File

@@ -1,5 +1,9 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export * from './request'
export * from './tool'

15
src/lib/query.ts Normal file
View File

@@ -0,0 +1,15 @@
import {QueryClient} from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: 0,
gcTime: 0,
refetchOnWindowFocus: false,
},
mutations: {
retry: false,
},
},
})

10
src/lib/queryKeys.ts Normal file
View File

@@ -0,0 +1,10 @@
import type {goodsType} from '@/types/business.type.ts'
export const queryKeys = {
authBootstrap: ['auth-bootstrap'] as const,
goodsCatalog: (types?: readonly goodsType[]) => ['goods-catalog', ...(types ?? ['all'])] as const,
assets: (sessionId: string) => ['assets', sessionId] as const,
addressList: (sessionId: string) => ['address-list', sessionId] as const,
orders: (sessionId: string) => ['orders', sessionId] as const,
pointsLogs: (sessionId: string) => ['points-logs', sessionId] as const,
}

183
src/lib/request.ts Normal file
View File

@@ -0,0 +1,183 @@
import ky, {HTTPError, type Input, type KyInstance, type Options} from 'ky'
import {notifyError, resolveToastMessage} from '@/features/notifications'
import {useUserStore} from '@/store/user.ts'
type RequestMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'
type ResponseType = 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData' | 'response'
type TokenFormatter = (token: string) => string
export type RequestResult<T, R extends ResponseType = 'json'> =
R extends 'text' ? string
: R extends 'blob' ? Blob
: R extends 'arrayBuffer' ? ArrayBuffer
: R extends 'formData' ? FormData
: R extends 'response' ? Response
: T
export type RequestConfig<R extends ResponseType = 'json'> = Omit<Options, 'hooks' | 'method'> & {
responseType?: R
}
type ApiEnvelope = {
code: number
msg?: string
}
export class RequestError<T = unknown> extends Error {
status?: number
data?: T
constructor(message: string, options?: {status?: number; data?: T; cause?: unknown}) {
super(message, {cause: options?.cause})
this.name = 'RequestError'
this.status = options?.status
this.data = options?.data
}
}
const isApiEnvelope = (value: unknown): value is ApiEnvelope =>
typeof value === 'object' &&
value !== null &&
'code' in value &&
typeof (value as {code?: unknown}).code === 'number'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api/'
const REQUEST_TIMEOUT = 10_000
let accessTokenFormatter: TokenFormatter = (token) => `Bearer ${token}`
export const setAccessTokenFormatter = (formatter?: TokenFormatter) => {
accessTokenFormatter = formatter ?? ((token) => `Bearer ${token}`)
}
const requestClient = ky.create({
baseUrl: API_BASE_URL,
timeout: REQUEST_TIMEOUT,
retry: 0,
headers: {
lang: 'zh',
},
hooks: {
beforeRequest: [
({request}) => {
const token = useUserStore.getState().userInfo?.token
if (!token) {
return
}
request.headers.set('Authorization', accessTokenFormatter(token))
},
],
beforeError: [
async ({error}) => {
if (error instanceof HTTPError) {
const message = resolveToastMessage(
error.data,
typeof error.data === 'string'
? error.data
: error.response.statusText || 'Request failed',
)
notifyError(message)
return new RequestError(message, {
status: error.response.status,
data: error.data,
cause: error,
})
}
const message = error.message || 'Network request failed'
notifyError(message)
return new RequestError(message, {
cause: error,
})
},
],
},
})
const isEmptyResponse = (response: Response) =>
response.status === 204 ||
response.status === 205 ||
response.headers.get('content-length') === '0'
async function parseResponse<T, R extends ResponseType>(
response: Response,
responseType: R,
): Promise<RequestResult<T, R>> {
if (responseType === 'response') {
return response as RequestResult<T, R>
}
if (isEmptyResponse(response)) {
return undefined as RequestResult<T, R>
}
switch (responseType) {
case 'text':
return (await response.text()) as RequestResult<T, R>
case 'blob':
return (await response.blob()) as RequestResult<T, R>
case 'arrayBuffer':
return (await response.arrayBuffer()) as RequestResult<T, R>
case 'formData':
return (await response.formData()) as RequestResult<T, R>
case 'json':
default:
{
const payload = await response.json()
if (isApiEnvelope(payload) && payload.code !== 1 && payload.code !== 200) {
const message = payload.msg?.trim() || 'Request failed'
notifyError(message)
throw new RequestError(message, {
status: response.status,
data: payload,
})
}
return payload as RequestResult<T, R>
}
}
}
async function request<T = unknown, R extends ResponseType = 'json'>(
method: RequestMethod,
input: Input,
config?: RequestConfig<R>,
): Promise<RequestResult<T, R>> {
const responseType = (config?.responseType ?? 'json') as R
const options = config ? {...config} : {}
delete options.responseType
const response = await requestClient(input, {
...options,
method,
})
return parseResponse<T, R>(response, responseType)
}
export const http = {
get: <T = unknown, R extends ResponseType = 'json'>(input: Input, config?: RequestConfig<R>): Promise<RequestResult<T, R>> =>
request<T, R>('get', input, config),
post: <T = unknown, R extends ResponseType = 'json'>(input: Input, config?: RequestConfig<R>): Promise<RequestResult<T, R>> =>
request<T, R>('post', input, config),
put: <T = unknown, R extends ResponseType = 'json'>(input: Input, config?: RequestConfig<R>): Promise<RequestResult<T, R>> =>
request<T, R>('put', input, config),
patch: <T = unknown, R extends ResponseType = 'json'>(input: Input, config?: RequestConfig<R>): Promise<RequestResult<T, R>> =>
request<T, R>('patch', input, config),
delete: <T = unknown, R extends ResponseType = 'json'>(input: Input, config?: RequestConfig<R>): Promise<RequestResult<T, R>> =>
request<T, R>('delete', input, config),
request: <T = unknown, R extends ResponseType = 'json'>(
method: RequestMethod,
input: Input,
config?: RequestConfig<R>,
): Promise<RequestResult<T, R>> => request<T, R>(method, input, config),
create: (options?: Options): KyInstance => requestClient.extend(options ?? {}),
}
export {requestClient}
export default http

28
src/lib/tool.ts Normal file
View File

@@ -0,0 +1,28 @@
type FormDataValue = string | number | boolean | Blob
type FormDataRecordValue =
| FormDataValue
| null
| undefined
| FormDataValue[]
export function objectToFormData(data: Record<string, FormDataRecordValue>): FormData {
const formData = new FormData()
Object.entries(data).forEach(([key, value]) => {
if (value == null) {
return
}
if (Array.isArray(value)) {
value.forEach((item) => {
formData.append(key, item instanceof Blob ? item : String(item))
})
return
}
formData.append(key, value instanceof Blob ? value : String(value))
})
return formData
}

View File

@@ -1,10 +1,14 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { queryClient } from '@/lib/query.ts'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
)

162
src/mock/index.ts Normal file
View File

@@ -0,0 +1,162 @@
import type {
AccountTableRow,
AddressOption,
AddAddressForm,
OrderRecord,
PointsRecord,
PointsRecordTone,
} from '@/types'
export const accountAddressRowsMock: 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',
},
]
export const initialAddressOptionsMock: AddressOption[] = [
{
id: 'address-shanghai',
name: 'Jia Jun',
phone: '+86 138 0000 1288',
address: 'No. 88 Century Avenue, Pudong New Area, Shanghai',
isDefault: true,
},
{
id: 'address-singapore',
name: 'Alicia Tan',
phone: '+65 9123 4567',
address: '18 Robinson Road, Singapore',
},
{
id: 'address-kuala-lumpur',
name: 'Marcus Lee',
phone: '+60 12 778 9911',
address: '27 Jalan Bukit Bintang, Kuala Lumpur',
},
]
export const emptyAddressFormMock: AddAddressForm = {
name: '',
phone: '',
region: [],
detailedAddress: '',
isDefault: false,
}
export const orderRecordsMock: 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',
},
]
export const pointsRecordsMock: 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',
},
]
export const pointsRecordToneClassNameMock: Record<PointsRecordTone, string> = {
positive: 'bg-[#9BFFC0] text-[#176640]',
negative: 'bg-[#FF9BA4] text-[#7B2634]',
}

38
src/store/user.ts Normal file
View File

@@ -0,0 +1,38 @@
import {create} from 'zustand'
import {createJSONStorage, persist} from 'zustand/middleware'
import type {LoginUserInfo, ValidateTokenData} from '@/types/auth.type.ts'
import type {UserAssetsData} from '@/types/user.type.ts'
type UserState = {
userInfo: LoginUserInfo | null
setUserInfo: (userInfo: LoginUserInfo) => void
authInfo: ValidateTokenData | null
setAuthInfo: (authInfo: ValidateTokenData) => void
assetsInfo: UserAssetsData | null
setAssetsInfo: (assetsInfo: UserAssetsData) => void
clearUserInfo: () => void
}
export const useUserStore = create<UserState>()(
persist(
(set) => ({
userInfo: null,
setUserInfo: (userInfo) => set({userInfo}),
authInfo: null,
setAuthInfo: (authInfo) => set({authInfo}),
assetsInfo: null,
setAssetsInfo: (assetsInfo) => set({assetsInfo}),
clearUserInfo: () => set({userInfo: null, authInfo: null, assetsInfo: null}),
}),
{
name: 'playx-user',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
userInfo: state.userInfo,
authInfo: state.authInfo,
assetsInfo: state.assetsInfo,
}),
},
),
)

37
src/types/address.type.ts Normal file
View File

@@ -0,0 +1,37 @@
export interface AddressInfo {
/**@description 收件人姓名 */
receiver_name: string,
/**@description 电话 */
phone: string,
/**@description 地区(数组或逗号分隔字符串) */
region: string,
/**@description 详细地址 */
detail_address: string,
/**@description 1 设为默认地址 */
default_setting: string,
}
export type AddressListItem = {
id: number
phone: string
region: string[]
detail_address: string
default_setting: 0 | 1
create_time: number
update_time: number
playx_user_asset_id: number
receiver_name: string
region_text: string
}
export type AddressListData = {
list: AddressListItem[]
}
export type AddressListResponse = ApiResponse<AddressListData>
export type AddressAddData = {
id: number
}
export type AddressAddResponse = ApiResponse<AddressAddData>

31
src/types/auth.type.ts Normal file
View File

@@ -0,0 +1,31 @@
export type LoginParams = {
username?: string
}
export type LoginUserInfo = {
id: number
username: string
nickname: string
playx_user_id: string
token: string
refresh_token: string
expires_in: number
}
export type LoginData = {
userInfo: LoginUserInfo
}
export type LoginResponse = ApiResponse<LoginData>
export interface ValidateTokenData {
session_id:string;
user_id:string;
name:string;
token_expire_at:string;
}
export type ValidateTokenResponse = ApiResponse<ValidateTokenData>

108
src/types/business.type.ts Normal file
View File

@@ -0,0 +1,108 @@
export type goodsType = 'BONUS' | 'PHYSICAL' | 'WITHDRAW'
export type GoodsItem = {
id: number
title: string
description: string
remark: string
score: number
amount: number
multiplier: number
category: string
category_title: string
type: string
image: string
stock: number | null
admin_id: number
sort: number
create_time: number
update_time: number
status: number
}
export type GoodsListData = {
list: GoodsItem[]
}
export type GoodListResponse = ApiResponse<GoodsListData>
export type OrderItem = {
id: number | string
user_id?: string
order_no?: string
external_transaction_id?: string
playx_transaction_id?: string
type?: goodsType | string
type_title?: string
category?: string
category_title?: string
title?: string
item_title?: string
mall_item_id?: number | string | null
mall_address_id?: number | string | null
points_cost?: number | string
amount?: number | string
multiplier?: number | string
tracking_no?: string | null
logistics_no?: string | null
shipping_company?: string | null
shipping_no?: string | null
receiver_name?: string | null
receiver_phone?: string | null
receiver_address?: string | null
fail_reason?: string | null
reject_reason?: string | null
grant_status?: string | null
retry_count?: number
status?: string | number
score?: number | string
points?: number | string
create_time?: number | string
created_at?: string
update_time?: number | string
start_time?: number | string | null
end_time?: number | string | null
mallItem?: Partial<GoodsItem> | null
}
export type OrdersData = {
list: OrderItem[]
}
export type OrdersResponse = ApiResponse<OrdersData>
export type PointsLogItem = {
id: number | string
biz_type?: string
direction?: 'IN' | 'OUT' | string
ts?: number | string
ref_id?: string
order_no?: string
order_status?: string
item_id?: number | string | null
item_type?: string
item_score?: number | string
cursor?: string
type?: string
title?: string
item_title?: string
description?: string
remark?: string
amount?: number | string
points?: number | string
score?: number | string
points_cost?: number | string
points_change?: number | string
change_points?: number | string
create_time?: number | string
created_at?: string
update_time?: number | string
mallItem?: Partial<GoodsItem> | null
}
export type PointsLogsData = {
list: PointsLogItem[]
next_cursor?: string | null
}
export type PointsLogsResponse = ApiResponse<PointsLogsData>

6
src/types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
type ApiResponse<T> = {
code: number
msg: string
time: number
data: T
}

View File

@@ -1 +1,139 @@
import type { ButtonHTMLAttributes, PropsWithChildren, ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import type {goodsType} from './business.type.ts'
export type RecordButtonType = 'order' | 'record'
export type HostContextMessage = {
type: 'IFRAME_CONTEXT'
payload?: {
token?: string
language?: string
}
}
export type ButtonVariant = 'orange' | 'gray'
export type ButtonProps = PropsWithChildren<ButtonHTMLAttributes<HTMLButtonElement>> & {
variant?: ButtonVariant
}
export type ModalProps = {
open: boolean
title?: ReactNode
children: ReactNode
footer?: ReactNode
onClose?: () => void
closeOnOverlayClick?: boolean
className?: string
bodyClassName?: string
}
export type PageLayoutProps = {
children: ReactNode
contentClassName?: string
}
export type TableColumn<T extends Record<string, string>> = {
label: string
key: keyof T
render?: (value: T[keyof T], record: T, index: number) => ReactNode
}
export type BorderlessTableProps<T extends Record<string, string>> = {
columns: TableColumn<T>[]
dataSource: T[]
}
export type QuickNavCardProps = {
icon: LucideIcon
label: string
to: string
}
export type ProductItem = {
id: string
title: string
subtitle: string
score: string
ctaLabel: string
imageClassName?: string
imageUrl?: string
}
export type ProductCategory = {
id: goodsType
name: string
items: ProductItem[]
}
export type SelectedProductState = {
categoryId: ProductCategory['id']
product: ProductItem
}
export type AddressOption = {
id: string
name: string
phone: string
address: string
isDefault?: boolean
}
export type ModalMode = 'select-address' | 'add-address'
export type AddAddressForm = {
name: string
phone: string
region: string[]
detailedAddress: string
isDefault: boolean
}
export type AccountTableRow = {
name: string
phone: string
address: string
code: string
action: string
setting: string
}
export type PointsRecordTone = 'positive' | 'negative'
export type OrderRecord = {
id: string
orderNumber?: string
date: string
time: string
category: string
title: string
trackingNumber?: string
status: string
points: string
}
export type PointsRecord = {
id: string
title: string
date: string
time: string
amount: string
tone: PointsRecordTone
}
export type TabButtonProps = {
active: boolean
label: string
icon: LucideIcon
onClick: () => void
}
export type OrderCardProps = {
record: OrderRecord
onOpenDetails: (record: OrderRecord) => void
}
export type PointsCardProps = {
record: PointsRecord
}

18
src/types/user.type.ts Normal file
View File

@@ -0,0 +1,18 @@
export type UserAssetsParams = {
session_id?: string
}
export interface UserAssetsData {
/**@description 可用积分 */
available_points: number;
/**@description 待领取积分 */
locked_points: number;
/**@description 今日已领取 */
today_claimed: number;
/**@description 今日可领取上限 */
today_limit: number;
/**@description 可提现现金(积分按配置比例换算) */
withdrawable_cash: number;
}
export type UserAssetsResponse = ApiResponse<UserAssetsData>

View File

@@ -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]'}> &lt; </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
View 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

View File

@@ -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>&gt;</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&apos;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>

View File

@@ -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} &nbsp; {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]">&lt;</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">