🎉 feat: initialization
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { Separator } from "@workspace/ui/components/separator";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Display } from "@/components/display";
|
||||
|
||||
interface SubscribeBillingProps {
|
||||
order?: Partial<
|
||||
API.OrderDetail & {
|
||||
unit_price: number;
|
||||
unit_time: string;
|
||||
subscribe_discount: number;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
export function SubscribeBilling({ order }: Readonly<SubscribeBillingProps>) {
|
||||
const { t } = useTranslation("subscribe");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="font-semibold">
|
||||
{t("billing.billingTitle", "Billing Detail")}
|
||||
</div>
|
||||
<ul className="grid grid-cols-2 gap-3 *:flex *:items-center *:justify-between lg:grid-cols-1">
|
||||
{order?.type && [1, 2].includes(order?.type) && (
|
||||
<li>
|
||||
<span className="text-muted-foreground">
|
||||
{t("billing.duration", "Duration")}
|
||||
</span>
|
||||
<span>
|
||||
{order?.quantity || 1}{" "}
|
||||
{t(order?.unit_time || "Month", order?.unit_time || "Month")}
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<span className="text-muted-foreground">
|
||||
{t("billing.price", "Price")}
|
||||
</span>
|
||||
<span>
|
||||
<Display
|
||||
type="currency"
|
||||
value={order?.price || order?.unit_price}
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-muted-foreground">
|
||||
{t("billing.productDiscount", "Product Discount")}
|
||||
</span>
|
||||
<span>
|
||||
<Display type="currency" value={order?.discount} />
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-muted-foreground">
|
||||
{t("billing.couponDiscount", "Coupon Discount")}
|
||||
</span>
|
||||
<span>
|
||||
<Display type="currency" value={order?.coupon_discount} />
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-muted-foreground">
|
||||
{t("billing.fee", "Fee")}
|
||||
</span>
|
||||
<span>
|
||||
<Display type="currency" value={order?.fee_amount} />
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-muted-foreground">
|
||||
{t("billing.gift", "Gift")}
|
||||
</span>
|
||||
<span>
|
||||
<Display type="currency" value={order?.gift_amount} />
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between font-semibold">
|
||||
<span className="text-muted-foreground">
|
||||
{t("billing.total", "Total")}
|
||||
</span>
|
||||
<span>
|
||||
<Display type="currency" value={order?.amount} />
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import type React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface CouponInputProps {
|
||||
coupon?: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const CouponInput: React.FC<CouponInputProps> = ({ coupon, onChange }) => {
|
||||
const { t } = useTranslation("subscribe");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="font-semibold">{t("coupon", "Coupon")}</div>
|
||||
<Input
|
||||
onChange={(e) => onChange(e.target.value.trim())}
|
||||
placeholder={t("enterCoupon", "Enter Coupon")}
|
||||
value={coupon}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CouponInput;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Display } from "@/components/display";
|
||||
|
||||
interface SubscribeDetailProps {
|
||||
subscribe?: Partial<
|
||||
API.Subscribe & {
|
||||
name: string;
|
||||
quantity: number;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
export function SubscribeDetail({ subscribe }: Readonly<SubscribeDetailProps>) {
|
||||
const { t } = useTranslation("subscribe");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="font-semibold">
|
||||
{t("detail.productDetail", "Product Detail")}
|
||||
</div>
|
||||
<ul className="grid grid-cols-1 gap-3 *:flex *:items-center *:justify-between lg:grid-cols-1">
|
||||
{subscribe?.name && (
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="line-clamp-2 flex-1 text-muted-foreground">
|
||||
{subscribe?.name}
|
||||
</span>
|
||||
<span>
|
||||
x <span>{subscribe?.quantity || 1}</span>
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<span className="text-muted-foreground">
|
||||
{t("detail.availableTraffic", "Available Traffic")}
|
||||
</span>
|
||||
<span>
|
||||
<Display type="traffic" unlimited value={subscribe?.traffic} />
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-muted-foreground">
|
||||
{t("detail.connectionSpeed", "Connection Speed")}
|
||||
</span>
|
||||
<span>
|
||||
<Display
|
||||
type="trafficSpeed"
|
||||
unlimited
|
||||
value={subscribe?.speed_limit}
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-muted-foreground">
|
||||
{t("detail.connectedDevices", "Connected Devices")}
|
||||
</span>
|
||||
<span>
|
||||
<Display type="number" unlimited value={subscribe?.device_limit} />
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { Label } from "@workspace/ui/components/label";
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
} from "@workspace/ui/components/radio-group";
|
||||
import type React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface DurationSelectorProps {
|
||||
quantity: number;
|
||||
unitTime?: string;
|
||||
discounts?: Array<{ quantity: number; discount: number }>;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
const DurationSelector: React.FC<DurationSelectorProps> = ({
|
||||
quantity,
|
||||
unitTime = "Month",
|
||||
discounts = [],
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation("subscribe");
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
onChange(Number(value));
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const DurationOption: React.FC<{ value: string; label: string }> = ({
|
||||
value,
|
||||
label,
|
||||
}) => (
|
||||
<div className="relative">
|
||||
<RadioGroupItem className="peer sr-only" id={value} value={value} />
|
||||
<Label
|
||||
className="relative flex h-full flex-col items-center justify-center gap-2 rounded-md border-2 border-muted bg-popover p-2 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary"
|
||||
htmlFor={value}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
|
||||
const currentDiscount = discounts?.find(
|
||||
(item) => item.quantity === quantity
|
||||
)?.discount;
|
||||
const discountPercentage = currentDiscount ? 100 - currentDiscount : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="font-semibold">
|
||||
{t("purchaseDuration", "Purchase Duration")}
|
||||
</div>
|
||||
<RadioGroup
|
||||
className="flex flex-wrap gap-3"
|
||||
onValueChange={handleChange}
|
||||
value={String(quantity)}
|
||||
>
|
||||
{unitTime !== "Minute" && (
|
||||
<DurationOption label={`1 / ${t(unitTime)}`} value="1" />
|
||||
)}
|
||||
{discounts?.map((item) => (
|
||||
<DurationOption
|
||||
key={item.quantity}
|
||||
label={`${item.quantity} / ${t(unitTime)}`}
|
||||
value={String(item.quantity)}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("discountInfo", "Discount Info")}:
|
||||
</span>
|
||||
{discountPercentage > 0 ? (
|
||||
<Badge className="h-6 text-sm" variant="destructive">
|
||||
-{discountPercentage}% {t("discount", "Discount")}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="h-6 text-muted-foreground text-sm">--</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DurationSelector;
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
} from "@workspace/ui/components/card";
|
||||
import { Separator } from "@workspace/ui/components/separator";
|
||||
import Empty from "@workspace/ui/composed/empty";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import { cn } from "@workspace/ui/lib/utils";
|
||||
import { querySubscribeList } from "@workspace/ui/services/user/subscribe";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Display } from "@/components/display";
|
||||
import { SubscribeDetail } from "./detail";
|
||||
import Purchase from "./purchase";
|
||||
|
||||
export default function Subscribe() {
|
||||
const { t, i18n } = useTranslation("subscribe");
|
||||
const unitTimeMap: Record<string, string> = {
|
||||
Day: t("Day", "Day"),
|
||||
Hour: t("Hour", "Hour"),
|
||||
Minute: t("Minute", "Minute"),
|
||||
Month: t("Month", "Month"),
|
||||
NoLimit: t("NoLimit", "No Limit"),
|
||||
Year: t("Year", "Year"),
|
||||
};
|
||||
const locale = i18n.language;
|
||||
const [subscribe, setSubscribe] = useState<API.Subscribe>();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["querySubscribeList", locale],
|
||||
queryFn: async () => {
|
||||
console.log("Fetching subscription list...");
|
||||
const { data } = await querySubscribeList({ language: locale });
|
||||
return data.data?.list || [];
|
||||
},
|
||||
});
|
||||
|
||||
const filteredData = data?.filter((item) => item.show);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredData?.map((item) => (
|
||||
<Card className="relative flex flex-col" key={item.id}>
|
||||
<CardHeader className="font-medium text-xl">
|
||||
{item.name}
|
||||
</CardHeader>
|
||||
<CardContent className="*:!text-sm flex flex-grow flex-col gap-3">
|
||||
{/* <div className='font-semibold'>{t('productDescription')}</div> */}
|
||||
<ul className="flex flex-grow flex-col gap-3">
|
||||
{(() => {
|
||||
let parsedDescription: {
|
||||
description: string;
|
||||
features: Array<{
|
||||
icon: string;
|
||||
label: string;
|
||||
type: "default" | "success" | "destructive";
|
||||
}>;
|
||||
};
|
||||
try {
|
||||
parsedDescription = JSON.parse(item.description);
|
||||
} catch {
|
||||
parsedDescription = { description: "", features: [] };
|
||||
}
|
||||
|
||||
const { description, features } = parsedDescription;
|
||||
return (
|
||||
<>
|
||||
{description && (
|
||||
<li className="text-muted-foreground">
|
||||
{description}
|
||||
</li>
|
||||
)}
|
||||
{features?.map(
|
||||
(
|
||||
feature: {
|
||||
icon: string;
|
||||
label: string;
|
||||
type: "default" | "success" | "destructive";
|
||||
},
|
||||
index: number
|
||||
) => (
|
||||
<li
|
||||
className={cn("flex items-center gap-1", {
|
||||
"text-muted-foreground line-through":
|
||||
feature.type === "destructive",
|
||||
})}
|
||||
key={index}
|
||||
>
|
||||
{feature.icon && (
|
||||
<Icon
|
||||
className={cn("size-5 text-primary", {
|
||||
"text-green-500":
|
||||
feature.type === "success",
|
||||
"text-destructive":
|
||||
feature.type === "destructive",
|
||||
})}
|
||||
icon={feature.icon}
|
||||
/>
|
||||
)}
|
||||
{feature.label}
|
||||
</li>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</ul>
|
||||
<SubscribeDetail
|
||||
subscribe={{
|
||||
...item,
|
||||
name: undefined,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
<Separator />
|
||||
<CardFooter className="flex flex-col gap-2">
|
||||
<h2 className="pb-8 font-semibold text-2xl sm:text-3xl">
|
||||
<Display type="currency" value={item.unit_price} />
|
||||
<span className="font-medium text-base">
|
||||
/
|
||||
{unitTimeMap[item.unit_time!] ||
|
||||
t(item.unit_time || "Month", item.unit_time || "Month")}
|
||||
</span>
|
||||
</h2>
|
||||
<Button
|
||||
className="absolute bottom-0 w-full rounded-t-none rounded-b-xl"
|
||||
onClick={() => {
|
||||
setSubscribe(item);
|
||||
}}
|
||||
>
|
||||
{t("buy", "Buy")}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{filteredData?.length === 0 && <Empty />}
|
||||
</div>
|
||||
<Purchase setSubscribe={setSubscribe} subscribe={subscribe} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Label } from "@workspace/ui/components/label";
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
} from "@workspace/ui/components/radio-group";
|
||||
import { cn } from "@workspace/ui/lib/utils";
|
||||
import { getAvailablePaymentMethods } from "@workspace/ui/services/user/portal";
|
||||
import type React from "react";
|
||||
import { memo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface PaymentMethodsProps {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
balance?: boolean;
|
||||
}
|
||||
|
||||
const PaymentMethods: React.FC<PaymentMethodsProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
balance = true,
|
||||
}) => {
|
||||
const { t } = useTranslation("subscribe");
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["getAvailablePaymentMethods", { balance }],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAvailablePaymentMethods();
|
||||
const list = data.data?.list || [];
|
||||
const methods = balance ? list : list.filter((item) => item.id !== -1);
|
||||
const defaultMethod = methods.find((item) => item.id)?.id;
|
||||
if (defaultMethod) onChange(defaultMethod);
|
||||
return methods;
|
||||
},
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<div className="font-semibold">
|
||||
{t("paymentMethod", "Payment Method")}
|
||||
</div>
|
||||
<RadioGroup
|
||||
className="grid grid-cols-2 gap-2 md:grid-cols-5"
|
||||
onValueChange={(val) => {
|
||||
console.log(val);
|
||||
onChange(Number(val));
|
||||
}}
|
||||
value={String(value)}
|
||||
>
|
||||
{data?.map((item) => (
|
||||
<div className="relative" key={item.id}>
|
||||
<RadioGroupItem
|
||||
className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0"
|
||||
id={String(item.id)}
|
||||
value={String(item.id)}
|
||||
/>
|
||||
<Label
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover py-2 hover:bg-accent hover:text-accent-foreground",
|
||||
String(value) === String(item.id) ? "border-primary" : ""
|
||||
)}
|
||||
htmlFor={String(item.id)}
|
||||
>
|
||||
<div className="flex size-12 items-center justify-center">
|
||||
<img
|
||||
alt={item.name}
|
||||
height={48}
|
||||
src={item.icon || "/payment/balance.svg"}
|
||||
width={48}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-full overflow-hidden text-ellipsis whitespace-nowrap text-center">
|
||||
{item.name}
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(PaymentMethods);
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Card, CardContent } from "@workspace/ui/components/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@workspace/ui/components/dialog";
|
||||
import { Separator } from "@workspace/ui/components/separator";
|
||||
import { preCreateOrder, purchase } from "@workspace/ui/services/user/order";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState, useTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CouponInput from "@/sections/subscribe/coupon-input";
|
||||
import DurationSelector from "@/sections/subscribe/duration-selector";
|
||||
import PaymentMethods from "@/sections/subscribe/payment-methods";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
import { SubscribeBilling } from "./billing";
|
||||
import { SubscribeDetail } from "./detail";
|
||||
|
||||
interface PurchaseProps {
|
||||
subscribe?: API.Subscribe;
|
||||
setSubscribe: (subscribe?: API.Subscribe) => void;
|
||||
}
|
||||
|
||||
export default function Purchase({
|
||||
subscribe,
|
||||
setSubscribe,
|
||||
}: Readonly<PurchaseProps>) {
|
||||
const { t } = useTranslation("subscribe");
|
||||
const { getUserInfo } = useGlobalStore();
|
||||
const router = useRouter();
|
||||
const [params, setParams] = useState<Partial<API.PurchaseOrderRequest>>({
|
||||
quantity: 1,
|
||||
subscribe_id: 0,
|
||||
payment: -1,
|
||||
coupon: "",
|
||||
});
|
||||
const [loading, startTransition] = useTransition();
|
||||
const lastSuccessOrderRef = useRef<any>(null);
|
||||
|
||||
const { data: order } = useQuery({
|
||||
enabled: !!subscribe?.id,
|
||||
queryKey: ["preCreateOrder", params],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const { data } = await preCreateOrder({
|
||||
...params,
|
||||
subscribe_id: subscribe?.id as number,
|
||||
} as API.PurchaseOrderRequest);
|
||||
const result = data.data;
|
||||
if (result) {
|
||||
lastSuccessOrderRef.current = result;
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (lastSuccessOrderRef.current) {
|
||||
return lastSuccessOrderRef.current;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (subscribe) {
|
||||
setParams((prev) => ({
|
||||
...prev,
|
||||
quantity: 1,
|
||||
subscribe_id: subscribe?.id,
|
||||
}));
|
||||
}
|
||||
}, [subscribe]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(field: keyof typeof params, value: string | number) => {
|
||||
setParams((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const response = await purchase(params as API.PurchaseOrderRequest);
|
||||
const orderNo = response.data.data?.order_no;
|
||||
if (orderNo) {
|
||||
getUserInfo();
|
||||
router.navigate({ to: "/payment", search: { order_no: orderNo } });
|
||||
}
|
||||
} catch (_error) {
|
||||
/* empty */
|
||||
}
|
||||
});
|
||||
}, [params, router, getUserInfo]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSubscribe(undefined);
|
||||
}}
|
||||
open={!!subscribe?.id}
|
||||
>
|
||||
<DialogContent className="flex h-full flex-col overflow-hidden border-none p-0 md:h-auto md:max-w-screen-lg">
|
||||
<DialogHeader className="p-6 pb-0">
|
||||
<DialogTitle>{t("buySubscription", "Buy Subscription")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid w-full flex-grow gap-3 overflow-auto p-6 pt-0 lg:grid-cols-2">
|
||||
<Card className="border-transparent shadow-none md:border-inherit md:shadow">
|
||||
<CardContent className="grid gap-3 text-sm">
|
||||
<SubscribeDetail
|
||||
subscribe={{
|
||||
...subscribe,
|
||||
quantity: params.quantity,
|
||||
}}
|
||||
/>
|
||||
<Separator />
|
||||
<SubscribeBilling
|
||||
order={{
|
||||
...order,
|
||||
quantity: params.quantity,
|
||||
unit_price: subscribe?.unit_price,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex flex-col justify-between text-sm">
|
||||
<div className="mb-6 grid gap-3">
|
||||
<DurationSelector
|
||||
discounts={subscribe?.discount}
|
||||
onChange={(value) => {
|
||||
handleChange("quantity", value);
|
||||
}}
|
||||
quantity={params.quantity as number}
|
||||
unitTime={subscribe?.unit_time}
|
||||
/>
|
||||
<CouponInput
|
||||
coupon={params.coupon}
|
||||
onChange={(value) => handleChange("coupon", value)}
|
||||
/>
|
||||
<PaymentMethods
|
||||
onChange={(value) => {
|
||||
handleChange("payment", value);
|
||||
}}
|
||||
value={params.payment as number}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="fixed bottom-0 left-0 w-full md:relative md:mt-6"
|
||||
disabled={loading}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{loading && <LoaderCircle className="mr-2 animate-spin" />}
|
||||
{t("buyNow", "Buy Now")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@workspace/ui/components/dialog";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { recharge } from "@workspace/ui/services/user/order";
|
||||
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
import PaymentMethods from "./payment-methods";
|
||||
|
||||
export default function Recharge(
|
||||
props: Readonly<React.ComponentProps<typeof Button>>
|
||||
) {
|
||||
const { t } = useTranslation("subscribe");
|
||||
const { common } = useGlobalStore();
|
||||
const { currency } = common;
|
||||
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const [loading, startTransition] = useTransition();
|
||||
|
||||
const [params, setParams] = useState<API.RechargeOrderRequest>({
|
||||
amount: 0,
|
||||
payment: 1,
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={setOpen} open={open}>
|
||||
<DialogTrigger asChild>
|
||||
<Button {...props}>{t("recharge", "Recharge")}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="flex h-full flex-col overflow-hidden md:h-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("balanceRecharge", "Balance Recharge")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("rechargeDescription", "Recharge your account balance")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col justify-between text-sm">
|
||||
<div className="grid gap-3">
|
||||
<div className="font-semibold">
|
||||
{t("rechargeAmount", "Recharge Amount")}
|
||||
</div>
|
||||
<div className="flex">
|
||||
<EnhancedInput
|
||||
formatInput={(value) => unitConversion("centsToDollars", value)}
|
||||
formatOutput={(value) =>
|
||||
unitConversion("dollarsToCents", value)
|
||||
}
|
||||
min={0}
|
||||
onValueChange={(value) => {
|
||||
setParams((prev) => ({
|
||||
...prev,
|
||||
amount: value as number,
|
||||
}));
|
||||
}}
|
||||
placeholder={t("enterAmount", "Enter Amount")}
|
||||
prefix={currency.currency_symbol}
|
||||
suffix={currency.currency_unit}
|
||||
type="number"
|
||||
value={params.amount}
|
||||
/>
|
||||
</div>
|
||||
<PaymentMethods
|
||||
balance={false}
|
||||
onChange={(value) => setParams({ ...params, payment: value })}
|
||||
value={params.payment}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="fixed bottom-0 left-0 w-full rounded-none md:relative md:mt-6"
|
||||
disabled={loading || !params.amount}
|
||||
onClick={() => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const response = await recharge(params);
|
||||
const orderNo = response.data.data?.order_no;
|
||||
if (orderNo) {
|
||||
window.location.href = `/payment?order_no=${orderNo}`;
|
||||
setOpen(false);
|
||||
}
|
||||
} catch (_error) {
|
||||
/* empty */
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
{loading && <LoaderCircle className="mr-2 animate-spin" />}
|
||||
{t("rechargeNow", "Recharge Now")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Card, CardContent } from "@workspace/ui/components/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@workspace/ui/components/dialog";
|
||||
import { Separator } from "@workspace/ui/components/separator";
|
||||
import { preCreateOrder, renewal } from "@workspace/ui/services/user/order";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState, useTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CouponInput from "@/sections/subscribe/coupon-input";
|
||||
import DurationSelector from "@/sections/subscribe/duration-selector";
|
||||
import PaymentMethods from "@/sections/subscribe/payment-methods";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
import { SubscribeBilling } from "./billing";
|
||||
import { SubscribeDetail } from "./detail";
|
||||
|
||||
interface RenewalProps {
|
||||
id: number;
|
||||
subscribe: API.Subscribe;
|
||||
}
|
||||
|
||||
export default function Renewal({ id, subscribe }: Readonly<RenewalProps>) {
|
||||
const { t } = useTranslation("subscribe");
|
||||
const { getUserInfo } = useGlobalStore();
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const [params, setParams] = useState<Partial<API.RenewalOrderRequest>>({
|
||||
quantity: 1,
|
||||
payment: -1,
|
||||
coupon: "",
|
||||
user_subscribe_id: id,
|
||||
});
|
||||
const [loading, startTransition] = useTransition();
|
||||
const lastSuccessOrderRef = useRef<any>(null);
|
||||
|
||||
const { data: order } = useQuery({
|
||||
enabled: !!subscribe.id && open,
|
||||
queryKey: ["preCreateOrder", params],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const { data } = await preCreateOrder({
|
||||
...params,
|
||||
subscribe_id: subscribe.id,
|
||||
} as API.PurchaseOrderRequest);
|
||||
const result = data.data;
|
||||
if (result) {
|
||||
lastSuccessOrderRef.current = result;
|
||||
}
|
||||
return result;
|
||||
} catch (_error) {
|
||||
if (lastSuccessOrderRef.current) {
|
||||
return lastSuccessOrderRef.current;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (subscribe.id && id) {
|
||||
setParams((prev) => ({
|
||||
...prev,
|
||||
quantity: 1,
|
||||
subscribe_id: subscribe.id,
|
||||
user_subscribe_id: id,
|
||||
}));
|
||||
}
|
||||
}, [subscribe.id, id]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(field: keyof typeof params, value: string | number) => {
|
||||
setParams((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const response = await renewal(params as API.RenewalOrderRequest);
|
||||
const orderNo = response.data.data?.order_no;
|
||||
if (orderNo) {
|
||||
getUserInfo();
|
||||
window.location.href = `/payment?order_no=${orderNo}`;
|
||||
}
|
||||
} catch (_error) {
|
||||
/* empty */
|
||||
}
|
||||
});
|
||||
}, [params, getUserInfo]);
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={setOpen} open={open}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">{t("renew", "Renew")}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="flex h-full flex-col overflow-hidden md:h-auto md:max-w-screen-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("renewSubscription", "Renew Subscription")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid w-full gap-3 lg:grid-cols-2">
|
||||
<Card className="border-transparent shadow-none md:border-inherit md:shadow">
|
||||
<CardContent className="grid gap-3 p-0 text-sm md:p-6">
|
||||
<SubscribeDetail
|
||||
subscribe={{
|
||||
...subscribe,
|
||||
quantity: params.quantity,
|
||||
}}
|
||||
/>
|
||||
<Separator />
|
||||
<SubscribeBilling
|
||||
order={{
|
||||
...order,
|
||||
quantity: params.quantity,
|
||||
unit_price: subscribe?.unit_price,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex flex-col justify-between text-sm">
|
||||
<div className="mb-6 grid gap-3">
|
||||
<DurationSelector
|
||||
discounts={subscribe?.discount}
|
||||
onChange={(value) => {
|
||||
handleChange("quantity", value);
|
||||
}}
|
||||
quantity={params.quantity!}
|
||||
unitTime={subscribe?.unit_time}
|
||||
/>
|
||||
<CouponInput
|
||||
coupon={params.coupon}
|
||||
onChange={(value) => handleChange("coupon", value)}
|
||||
/>
|
||||
<PaymentMethods
|
||||
onChange={(value) => {
|
||||
handleChange("payment", value);
|
||||
}}
|
||||
value={params.payment as number}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="fixed bottom-0 left-0 w-full md:relative md:mt-6"
|
||||
disabled={loading}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{loading && <LoaderCircle className="mr-2 animate-spin" />}
|
||||
{t("buyNow", "Buy Now")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@workspace/ui/components/dialog";
|
||||
import { resetTraffic } from "@workspace/ui/services/user/order";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Display } from "@/components/display";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
import PaymentMethods from "./payment-methods";
|
||||
|
||||
interface ResetTrafficProps {
|
||||
id: number;
|
||||
replacement?: number;
|
||||
}
|
||||
export default function ResetTraffic({
|
||||
id,
|
||||
replacement,
|
||||
}: Readonly<ResetTrafficProps>) {
|
||||
const { t } = useTranslation("subscribe");
|
||||
const { getUserInfo } = useGlobalStore();
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const [params, setParams] = useState<API.ResetTrafficOrderRequest>({
|
||||
payment: -1,
|
||||
user_subscribe_id: id,
|
||||
});
|
||||
const [loading, startTransition] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
setParams((prev) => ({
|
||||
...prev,
|
||||
quantity: 1,
|
||||
user_subscribe_id: id,
|
||||
}));
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
if (!replacement) return;
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={setOpen} open={open}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="secondary">
|
||||
{t("resetTraffic", "Reset Traffic")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="flex h-full flex-col overflow-hidden md:h-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("resetTrafficTitle", "Reset Traffic")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("resetTrafficDescription", "Reset your subscription traffic")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col justify-between text-sm">
|
||||
<div className="grid gap-3">
|
||||
<div className="flex justify-between font-semibold">
|
||||
<span>{t("resetPrice", "Reset Price")}</span>
|
||||
<span>
|
||||
<Display type="currency" value={replacement} />
|
||||
</span>
|
||||
</div>
|
||||
<PaymentMethods
|
||||
onChange={(value) => {
|
||||
setParams({
|
||||
...params,
|
||||
payment: value,
|
||||
});
|
||||
}}
|
||||
value={params.payment}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="fixed bottom-0 left-0 w-full rounded-none md:relative md:mt-6"
|
||||
disabled={loading}
|
||||
onClick={async () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const response = await resetTraffic(params);
|
||||
const orderNo = response.data.data?.order_no;
|
||||
if (orderNo) {
|
||||
getUserInfo();
|
||||
window.location.href = `/payment?order_no=${orderNo}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
{loading && <LoaderCircle className="mr-2 animate-spin" />}
|
||||
{t("buyNow", "Buy Now")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
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 { preUnsubscribe, unsubscribe } from "@workspace/ui/services/user/user";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Display } from "@/components/display";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
|
||||
interface UnsubscribeProps {
|
||||
id: number;
|
||||
allowDeduction?: boolean;
|
||||
}
|
||||
|
||||
export default function Unsubscribe({
|
||||
id,
|
||||
allowDeduction,
|
||||
}: Readonly<UnsubscribeProps>) {
|
||||
const { t } = useTranslation("subscribe");
|
||||
const { common, getUserInfo } = useGlobalStore();
|
||||
const single_model = common.subscribe.single_model;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
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("unsubscribe.success", "Unsubscribed successfully"));
|
||||
await getUserInfo();
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("unsubscribe.failed", "Unsubscribe failed"));
|
||||
}
|
||||
};
|
||||
|
||||
if (!(single_model || allowDeduction)) return null;
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={setOpen} open={open}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="destructive">
|
||||
{t("unsubscribe.unsubscribe", "Unsubscribe")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("unsubscribe.confirmUnsubscribe", "Confirm Unsubscribe")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"unsubscribe.confirmUnsubscribeDescription",
|
||||
"Are you sure you want to unsubscribe?"
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p>{t("unsubscribe.residualValue", "Residual Value")}</p>
|
||||
<p className="font-semibold text-2xl text-primary">
|
||||
<Display type="currency" value={data} />
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"unsubscribe.unsubscribeDescription",
|
||||
"The residual value will be refunded to your account"
|
||||
)}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setOpen(false)} variant="outline">
|
||||
{t("unsubscribe.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>
|
||||
{t("unsubscribe.confirm", "Confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user