✨ feat(subscribe): Add unsubscribe functionality with confirmation messages and localized strings
This commit is contained in:
@@ -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' />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user