🎉 chore(init): project initialization

This commit is contained in:
web@ppanel
2024-11-14 01:22:43 +07:00
commit 829edfa824
479 changed files with 61413 additions and 0 deletions
@@ -0,0 +1,255 @@
'use client';
import { Display } from '@/components/display';
import useGlobalStore from '@/config/use-global';
import { checkoutOrder, queryOrderDetail } from '@/services/user/order';
import { Icon } from '@iconify/react';
import { formatDate } from '@repo/ui/utils';
import { Badge } from '@shadcn/ui/badge';
import { Button } from '@shadcn/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@shadcn/ui/card';
import { addMinutes, format } from '@shadcn/ui/lib/date-fns';
import { Separator } from '@shadcn/ui/separator';
import { useQuery } from '@tanstack/react-query';
import { useCountDown } from 'ahooks';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { QRCodeCanvas } from 'qrcode.react';
import { useEffect, useState } from 'react';
import { SubscribeBilling } from '../subscribe/billing';
import { SubscribeDetail } from '../subscribe/detail';
import StripePayment from './stripe';
export default function Page() {
const t = useTranslations('order');
const { getUserInfo } = useGlobalStore();
const [order_no, setOrderNo] = useState<string>();
const [enabled, setEnabled] = useState<boolean>(false);
const { data } = useQuery({
enabled: enabled,
queryKey: ['queryOrderDetail', order_no],
queryFn: async () => {
const { data } = await queryOrderDetail({ order_no: order_no! });
if (data?.data?.status !== 1) {
getUserInfo();
setEnabled(false);
}
return data?.data;
},
refetchInterval: 3000,
});
const { data: payment } = useQuery({
enabled: !!order_no,
queryKey: ['checkoutOrder', order_no],
queryFn: async () => {
const { data } = await checkoutOrder({ orderNo: order_no! });
return data?.data;
},
});
useEffect(() => {
const searchParams = new URLSearchParams(location.search);
if (searchParams.get('order_no')) {
setOrderNo(searchParams.get('order_no')!);
setEnabled(true);
}
}, []);
const [countDown, formattedRes] = useCountDown({
targetDate: data && format(addMinutes(data?.created_at, 15), "yyyy-MM-dd'T'HH:mm:ss.SSSxxx"),
});
const { hours, minutes, seconds } = formattedRes;
const countdownDisplay =
countDown > 0 ? (
<>
{hours.toString().length === 1 ? `0${hours}` : hours} :{' '}
{minutes.toString().length === 1 ? `0${minutes}` : minutes} :{' '}
{seconds.toString().length === 1 ? `0${seconds}` : seconds}
</>
) : (
<>{t('timeExpired')}</>
);
return (
<div className='grid gap-4 xl:grid-cols-2'>
<Card className='order-2 xl:order-1'>
<CardHeader className='bg-muted/50 flex flex-row items-start'>
<div className='grid gap-0.5'>
<CardTitle className='flex flex-col text-lg'>
{t('orderNumber')}
<span>{data?.orderNo}</span>
</CardTitle>
<CardDescription>
{t('createdAt')}: {formatDate(data?.created_at)}
</CardDescription>
</div>
</CardHeader>
<CardContent className='grid gap-3 p-6 text-sm'>
<div className='font-semibold'>{t('paymentMethod')}</div>
<dl className='grid gap-3'>
<div className='flex items-center justify-between'>
<dt className='text-muted-foreground'>
<Badge>{t(`methods.${data?.method}`)}</Badge>
</dt>
</div>
</dl>
<Separator />
{data?.type && [1, 2].includes(data.type) && (
<SubscribeDetail
subscribe={{
...data?.subscribe,
quantity: data?.quantity,
}}
/>
)}
{data?.type === 3 && (
<>
<div className='font-semibold'></div>
<ul className='grid grid-cols-2 gap-3 *:flex *:items-center *:justify-between lg:grid-cols-1'>
<li className='flex items-center justify-between'>
<span className='text-muted-foreground line-clamp-2 flex-1'></span>
<span>
<Display type='currency' value={data.amount} />
</span>
</li>
</ul>
</>
)}
{data?.type === 4 && (
<>
<div className='font-semibold'>{t('balanceRecharge')}</div>
<ul className='grid grid-cols-2 gap-3 *:flex *:items-center *:justify-between lg:grid-cols-1'>
<li className='flex items-center justify-between'>
<span className='text-muted-foreground line-clamp-2 flex-1'>
{t('rechargeAmount')}
</span>
<span>
<Display type='currency' value={data.amount} />
</span>
</li>
</ul>
</>
)}
<Separator />
<SubscribeBilling
order={{
...data,
coupon: data?.reduction,
quantity: data?.quantity,
unit_price: data?.subscribe?.unit_price,
type: data?.type,
}}
/>
</CardContent>
</Card>
<Card className='order-1 flex flex-auto items-center justify-center xl:order-2'>
<CardContent className='py-16'>
{data?.status && [2, 5].includes(data?.status) && (
<div className='flex flex-col items-center gap-8 text-center'>
<h3 className='text-2xl font-bold tracking-tight'>{t('paymentSuccess')}</h3>
<Icon icon='mdi:success-circle-outline' className='text-7xl text-green-500' />
<div className='flex gap-4'>
<Button asChild>
<Link href='/dashboard'>{t('subscribeNow')}</Link>
</Button>
<Button variant='outline'>
<Link href='/document'>{t('viewDocument')}</Link>
</Button>
</div>
</div>
)}
{data?.status === 1 && payment?.type === 'link' && (
<div className='flex flex-col items-center gap-8 text-center'>
<h3 className='text-2xl font-bold tracking-tight'>{t('waitingForPayment')}</h3>
<p className='flex items-center text-3xl font-bold'>{countdownDisplay}</p>
<Icon icon='mdi:access-time' className='text-muted-foreground text-7xl' />
<div className='flex gap-4'>
<Button
onClick={() => {
const width = 600;
const height = 800;
const left = (screen.width - width) / 2;
const top = (screen.height - height) / 2;
window.open(
payment?.checkout_url,
'newWindow',
`width=${width},height=${height},top=${top},left=${left},menubar=0,scrollbars=1,resizable=1,status=1,titlebar=0,toolbar=0,location=1`,
);
}}
>
{t('goToPayment')}
</Button>
<Button variant='outline'>
<Link href='/subscribe'>{t('productList')}</Link>
</Button>
</div>
</div>
)}
{data?.status === 1 && payment?.type === 'qr' && (
<div className='flex flex-col items-center gap-8 text-center'>
<h3 className='text-2xl font-bold tracking-tight'>{t('scanToPay')}</h3>
<p className='flex items-center text-3xl font-bold'>{countdownDisplay}</p>
<QRCodeCanvas
value={payment?.checkout_url || ''}
size={208}
imageSettings={{
src: `/payment/alipay_f2f.svg`,
width: 48,
height: 48,
excavate: true,
}}
/>
<div className='flex gap-4'>
<Button asChild>
<Link href='/subscribe'>{t('productList')}</Link>
</Button>
<Button asChild variant='outline'>
<Link href='/order'>{t('orderList')}</Link>
</Button>
</div>
</div>
)}
{data?.status === 1 && payment?.type === 'stripe' && (
<div className='flex flex-col items-center gap-8 text-center'>
<h3 className='text-2xl font-bold tracking-tight'>{t('scanToPay')}</h3>
<p className='flex items-center text-3xl font-bold'>{countdownDisplay}</p>
{payment.stripe && <StripePayment {...payment.stripe} />}
<div className='flex gap-4'>
<Button asChild>
<Link href='/subscribe'>{t('productList')}</Link>
</Button>
<Button asChild variant='outline'>
<Link href='/order'>{t('orderList')}</Link>
</Button>
</div>
</div>
)}
{data?.status && [3, 4].includes(data?.status) && (
<div className='flex flex-col items-center gap-8 text-center'>
<h3 className='text-2xl font-bold tracking-tight'>{t('orderClosed')}</h3>
<Icon icon='mdi:cancel' className='text-7xl text-red-500' />
<div className='flex gap-4'>
<Button asChild>
<Link href='/subscribe'>{t('productList')}</Link>
</Button>
<Button asChild variant='outline'>
<Link href='/order'>{t('orderList')}</Link>
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,109 @@
import { Elements, useStripe } from '@stripe/react-stripe-js';
import { loadStripe, PaymentIntentResult } from '@stripe/stripe-js';
import { QRCodeCanvas } from 'qrcode.react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
interface StripePaymentProps {
method: string;
client_secret: string;
publishable_key: string;
}
const StripePayment: React.FC<StripePaymentProps> = ({
method,
client_secret,
publishable_key,
}) => {
const stripePromise = useMemo(() => loadStripe(publishable_key), [publishable_key]);
return (
<Elements stripe={stripePromise}>
<CheckoutForm method={method} client_secret={client_secret} />
</Elements>
);
};
const CheckoutForm: React.FC<Omit<StripePaymentProps, 'publishable_key'>> = ({
client_secret,
method,
}) => {
const stripe = useStripe();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null);
const [isSubmitted, setIsSubmitted] = useState(false);
const handleError = useCallback((message: string) => {
setErrorMessage(message);
setIsSubmitted(false);
}, []);
const confirmPayment = useCallback(async (): Promise<PaymentIntentResult | null> => {
if (!stripe) {
handleError('Stripe.js is not loaded.');
return null;
}
if (method === 'alipay') {
return await stripe.confirmAlipayPayment(
client_secret,
{ return_url: window.location.href },
{ handleActions: false },
);
}
return await stripe.confirmWechatPayPayment(
client_secret,
{
payment_method_options: { wechat_pay: { client: 'web' } },
},
{ handleActions: false },
);
}, [client_secret, method, stripe, handleError]);
const autoSubmit = useCallback(async () => {
if (isSubmitted) return;
setIsSubmitted(true);
try {
const result = await confirmPayment();
if (!result) return;
const { error, paymentIntent } = result;
if (error) return handleError(error.message!);
if (paymentIntent?.status === 'requires_action') {
const nextAction = paymentIntent.next_action as any;
const qrUrl =
method === 'alipay'
? nextAction?.alipay_handle_redirect?.url
: nextAction?.wechat_pay_display_qr_code?.image_url_svg;
setQrCodeUrl(qrUrl || null);
}
} catch (error) {
handleError('An unexpected error occurred');
}
}, [confirmPayment, isSubmitted, handleError, method]);
useEffect(() => {
autoSubmit();
}, [autoSubmit]);
return qrCodeUrl ? (
<QRCodeCanvas
value={qrCodeUrl}
size={208}
imageSettings={{
src: `/payment/${method}.svg`,
width: 48,
height: 48,
excavate: true,
}}
/>
) : (
errorMessage
);
};
export default StripePayment;