Skip to content

Commit

Permalink
✨ feat(subscribe): Add unsubscribe functionality with confirmation me…
Browse files Browse the repository at this point in the history
…ssages and localized strings
  • Loading branch information
web-ppanel committed Jan 4, 2025
1 parent cc834ca commit b2a2f42
Show file tree
Hide file tree
Showing 30 changed files with 423 additions and 131 deletions.
2 changes: 1 addition & 1 deletion apps/admin/services/admin/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// @ts-ignore

// API 更新时间:
// API 唯一标识:
import * as announcement from './announcement';
Expand Down
10 changes: 4 additions & 6 deletions apps/user/app/(main)/(user)/dashboard/content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { toast } from 'sonner';

import Renewal from '@/components/subscribe/renewal';
import ResetTraffic from '@/components/subscribe/reset-traffic';
import Unsubscribe from '@/components/subscribe/unsubscribe';
import useGlobalStore from '@/config/use-global';
import { getStat } from '@/services/common/common';
import { getPlatform } from '@/utils/common';
Expand Down Expand Up @@ -145,12 +146,9 @@ export default function Content() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ResetTraffic
id={item.subscribe_id}
token={item.token}
replacement={item.subscribe.replacement}
/>
<Renewal token={item.token} subscribe={item.subscribe} />
<ResetTraffic id={item.id} replacement={item.subscribe.replacement} />
<Renewal id={item.id} subscribe={item.subscribe} />
<Unsubscribe id={item.id} allowDeduction={item.subscribe.allow_deduction} />
</div>
</CardHeader>
<CardContent>
Expand Down
30 changes: 16 additions & 14 deletions apps/user/components/affiliate/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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>
Expand Down
2 changes: 1 addition & 1 deletion apps/user/components/subscribe/payment-methods.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' />
Expand Down
13 changes: 6 additions & 7 deletions apps/user/components/subscribe/renewal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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) => ({
Expand Down
68 changes: 12 additions & 56 deletions apps/user/components/subscribe/reset-traffic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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>
Expand All @@ -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'
Expand Down
84 changes: 84 additions & 0 deletions apps/user/components/subscribe/unsubscribe.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
15 changes: 13 additions & 2 deletions apps/user/locales/cs-CZ/subscribe.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@
"renewSubscription": "Obnovit předplatné",
"resetPrice": "Obnovit cenu",
"resetTraffic": "Obnovit provoz",
"resetTrafficDescription": "Obnovit provoz pouze pro aktuální měsíc",
"resetTrafficTitle": "Obnovit provoz"
"resetTrafficDescription": "Obnovit provoz na nulu a zahájit nový fakturační cyklus",
"resetTrafficTitle": "Obnovit provoz",
"unsubscribe": {
"availableDeductionAmount": "Dostupná částka k odečtení:",
"cancel": "Zrušit",
"confirm": "Potvrdit",
"confirmUnsubscribe": "Potvrdit odhlášení",
"confirmUnsubscribeDescription": "Opravdu se chcete odhlásit z odběru?",
"deductionNote": "Upozornění: Pokud se nyní odhlásíte, zbývající hodnota vašeho předplatného bude vrácena jako odpočitatelný zůstatek na váš účet, který můžete použít při dalším nákupu nebo obnovení předplatného.",
"failed": "Nepodařilo se odhlásit z odběru. Zkuste to prosím znovu.",
"success": "Úspěšně jste se odhlásili z odběru.",
"unsubscribe": "Odhlásit odběr"
}
}
15 changes: 13 additions & 2 deletions apps/user/locales/de-DE/subscribe.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@
"renewSubscription": "Abonnement erneuern",
"resetPrice": "Preis zurücksetzen",
"resetTraffic": "Datenverkehr zurücksetzen",
"resetTrafficDescription": "Verkehr nur für den aktuellen Monat zurücksetzen",
"resetTrafficTitle": "Datenverkehr zurücksetzen"
"resetTrafficDescription": "Setzen Sie den Datenverkehr auf null zurück und starten Sie einen neuen Abrechnungszyklus",
"resetTrafficTitle": "Datenverkehr zurücksetzen",
"unsubscribe": {
"availableDeductionAmount": "Verfügbarer Abzugsbetrag:",
"cancel": "Abbrechen",
"confirm": "Bestätigen",
"confirmUnsubscribe": "Abbestellung bestätigen",
"confirmUnsubscribeDescription": "Sind Sie sicher, dass Sie abbestellen möchten?",
"deductionNote": "Bitte beachten Sie: Wenn Sie jetzt kündigen, wird der verbleibende Wert Ihres Abonnements als abzugsfähiges Guthaben auf Ihr Konto zurückerstattet, das für Ihren nächsten Abonnementkauf oder die Verlängerung verwendet werden kann.",
"failed": "Abbestellung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"success": "Sie wurden erfolgreich abbestellt.",
"unsubscribe": "Abbestellen"
}
}
15 changes: 13 additions & 2 deletions apps/user/locales/en-US/subscribe.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@
"renewSubscription": "Renew Subscription",
"resetPrice": "Reset Price",
"resetTraffic": "Reset Traffic",
"resetTrafficDescription": "Reset traffic for the current month only",
"resetTrafficTitle": "Reset Traffic"
"resetTrafficDescription": "Reset traffic to zero, and start a new billing cycle",
"resetTrafficTitle": "Reset Traffic",
"unsubscribe": {
"availableDeductionAmount": "Available deduction amount:",
"cancel": "Cancel",
"confirm": "Confirm",
"confirmUnsubscribe": "Confirm Unsubscribe",
"confirmUnsubscribeDescription": "Are you sure you want to unsubscribe?",
"deductionNote": "Please note: If you unsubscribe now, the remaining value of your subscription will be refunded as a deductible balance to your account, which can be used for your next subscription purchase or renewal.",
"failed": "Failed to unsubscribe. Please try again.",
"success": "You have been unsubscribed successfully.",
"unsubscribe": "Unsubscribe"
}
}
Loading

0 comments on commit b2a2f42

Please sign in to comment.