feat(subscribe): Add unsubscribe functionality with confirmation messages and localized strings

This commit is contained in:
web@ppanel
2025-01-04 20:05:00 +07:00
parent cc834caa30
commit b2a2f42e53
30 changed files with 423 additions and 131 deletions
+16 -14
View File
@@ -14,7 +14,7 @@ import {
CardHeader,
CardTitle,
} from '@workspace/ui/components/card';
import { formatDate } from '@workspace/ui/utils';
import { formatDate, isBrowser } from '@workspace/ui/utils';
import { Copy } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
@@ -60,19 +60,21 @@ export default function Affiliate() {
<code className='bg-muted rounded px-2 py-1 text-2xl font-bold'>
{user?.refer_code}
</code>
<CopyToClipboard
text={`${location?.origin}/auth?invite=${user?.refer_code}`}
onCopy={(text, result) => {
if (result) {
toast.success(t('copySuccess'));
}
}}
>
<Button variant='secondary' size='sm' className='gap-2'>
<Copy className='h-4 w-4' />
{t('copyInviteLink')}
</Button>
</CopyToClipboard>
{isBrowser() && (
<CopyToClipboard
text={`${location?.origin}/auth?invite=${user?.refer_code}`}
onCopy={(text, result) => {
if (result) {
toast.success(t('copySuccess'));
}
}}
>
<Button variant='secondary' size='sm' className='gap-2'>
<Copy className='h-4 w-4' />
{t('copyInviteLink')}
</Button>
</CopyToClipboard>
)}
</div>
</CardContent>
</Card>
@@ -26,7 +26,7 @@ const PaymentMethods: React.FC<PaymentMethodsProps> = ({ value, onChange }) => {
return (
<>
<div className='font-semibold'>{t('paymentMethod')}</div>
<RadioGroup className='mb-6 grid grid-cols-5 gap-2' value={value} onValueChange={onChange}>
<RadioGroup className='grid grid-cols-5 gap-2' value={value} onValueChange={onChange}>
{data?.map((item) => (
<div key={item.mark}>
<RadioGroupItem value={item.mark} id={item.mark} className='peer sr-only' />
+6 -7
View File
@@ -24,21 +24,20 @@ import { SubscribeBilling } from './billing';
import { SubscribeDetail } from './detail';
interface RenewalProps {
token: string;
id: number;
subscribe: API.Subscribe;
}
export default function Renewal({ token, subscribe }: Readonly<RenewalProps>) {
export default function Renewal({ id, subscribe }: Readonly<RenewalProps>) {
const t = useTranslations('subscribe');
const { getUserInfo } = useGlobalStore();
const [open, setOpen] = useState<boolean>(false);
const router = useRouter();
const [params, setParams] = useState<Partial<API.RenewalOrderRequest>>({
quantity: 1,
subscribe_id: subscribe.id,
payment: 'balance',
coupon: '',
subscribe_token: token,
user_subscribe_id: id,
});
const [loading, startTransition] = useTransition();
@@ -55,15 +54,15 @@ export default function Renewal({ token, subscribe }: Readonly<RenewalProps>) {
});
useEffect(() => {
if (subscribe.id && token) {
if (subscribe.id && id) {
setParams((prev) => ({
...prev,
quantity: 1,
subscribe_id: subscribe.id,
subscribe_token: token,
user_subscribe_id: id,
}));
}
}, [subscribe.id, token]);
}, [subscribe.id, id]);
const handleChange = useCallback((field: keyof typeof params, value: string | number) => {
setParams((prev) => ({
@@ -3,8 +3,6 @@
import { Display } from '@/components/display';
import useGlobalStore from '@/config/use-global';
import { checkoutOrder, resetTraffic } from '@/services/user/order';
import { getAvailablePaymentMethods } from '@/services/user/payment';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Dialog,
@@ -14,59 +12,43 @@ import {
DialogTitle,
DialogTrigger,
} from '@workspace/ui/components/dialog';
import { Label } from '@workspace/ui/components/label';
import { RadioGroup, RadioGroupItem } from '@workspace/ui/components/radio-group';
import { LoaderCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
import Image from 'next/legacy/image';
import { useRouter } from 'next/navigation';
import { useEffect, useState, useTransition } from 'react';
import PaymentMethods from './payment-methods';
export default function ResetTraffic({
id,
token,
replacement,
}: {
interface ResetTrafficProps {
id: number;
token: string;
replacement?: number;
}) {
}
export default function ResetTraffic({ id, replacement }: Readonly<ResetTrafficProps>) {
const t = useTranslations('subscribe');
const { getUserInfo } = useGlobalStore();
const [open, setOpen] = useState<boolean>(false);
const router = useRouter();
const [params, setParams] = useState<API.ResetTrafficOrderRequest>({
subscribe_id: id,
payment: 'balance',
subscribe_token: token,
user_subscribe_id: id,
});
const [loading, startTransition] = useTransition();
const { data: paymentMethods } = useQuery({
queryKey: ['getAvailablePaymentMethods'],
queryFn: async () => {
const { data } = await getAvailablePaymentMethods();
return data.data?.list || [];
},
});
useEffect(() => {
if (id && token) {
if (id) {
setParams((prev) => ({
...prev,
quantity: 1,
subscribe_id: id,
subscribe_token: token,
user_subscribe_id: id,
}));
}
}, [id, token]);
}, [id]);
if (!replacement) return;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant='outline' size='sm'>
<Button variant='secondary' size='sm'>
{t('resetTraffic')}
</Button>
</DialogTrigger>
@@ -83,41 +65,15 @@ export default function ResetTraffic({
<Display type='currency' value={replacement} />
</span>
</div>
<div className='font-semibold'>{t('paymentMethod')}</div>
<RadioGroup
className='grid grid-cols-5 gap-2'
<PaymentMethods
value={params.payment}
onValueChange={(value) => {
onChange={(value) => {
setParams({
...params,
payment: value,
});
}}
>
{paymentMethods?.map((item) => {
return (
<div key={item.mark}>
<RadioGroupItem value={item.mark} id={item.mark} className='peer sr-only' />
<Label
htmlFor={item.mark}
className='border-muted bg-popover hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary flex flex-col items-center justify-between rounded-md border-2 py-2'
>
<div className='mb-3 size-12'>
<Image
src={item.icon || `/payment/${item.mark}.svg`}
width={48}
height={48}
alt={item.name!}
/>
</div>
<span className='w-full overflow-hidden text-ellipsis whitespace-nowrap text-center'>
{item.name || t(`methods.${item.mark}`)}
</span>
</Label>
</div>
);
})}
</RadioGroup>
/>
</div>
<Button
className='fixed bottom-0 left-0 w-full rounded-none md:relative md:mt-6'
@@ -0,0 +1,84 @@
'use client';
import { preUnsubscribe, unsubscribe } from '@/services/user/user';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@workspace/ui/components/dialog';
import { useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'sonner';
import { Display } from '../display';
interface UnsubscribeProps {
id: number;
allowDeduction?: boolean;
}
export default function Unsubscribe({ id, allowDeduction }: Readonly<UnsubscribeProps>) {
const [open, setOpen] = useState(false);
const router = useRouter();
const t = useTranslations('subscribe.unsubscribe');
const { data } = useQuery({
enabled: Boolean(open && id && allowDeduction),
queryKey: ['preUnsubscribe', id],
queryFn: async () => {
const { data } = await preUnsubscribe({ id });
return data.data?.deduction_amount;
},
});
const handleSubmit = async () => {
try {
await unsubscribe(
{ id },
{
skipErrorHandler: true,
},
);
toast.success(t('success'));
router.refresh();
setOpen(false);
} catch (error) {
toast.error(t('failed'));
}
};
if (!allowDeduction) return null;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant='destructive' size='sm'>
{t('unsubscribe')}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('confirmUnsubscribe')}</DialogTitle>
<DialogDescription>{t('confirmUnsubscribeDescription')}</DialogDescription>
</DialogHeader>
<p>{t('availableDeductionAmount')}</p>
<p className='text-primary text-2xl font-semibold'>
<Display type='currency' value={data} />
</p>
<p className='text-muted-foreground text-sm'>{t('deductionNote')}</p>
<DialogFooter>
<Button variant='outline' onClick={() => setOpen(false)}>
{t('cancel')}
</Button>
<Button onClick={handleSubmit}>{t('confirm')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}