221 lines
6.7 KiB
TypeScript
221 lines
6.7 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
Box,
|
|
VStack,
|
|
Heading,
|
|
Text,
|
|
Flex,
|
|
Card,
|
|
Button,
|
|
Spinner,
|
|
HStack,
|
|
Textarea,
|
|
} from "@chakra-ui/react";
|
|
import { useTranslations } from "next-intl";
|
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
|
import {
|
|
useMySubscription,
|
|
useCancelSubscription,
|
|
} from "@/lib/api/subscriptions/use-hooks";
|
|
import { PlanBadge } from "./plan-badge";
|
|
import { useSession } from "next-auth/react";
|
|
import { useState } from "react";
|
|
import { LuCalendar, LuTriangleAlert, LuX, LuCheck } from "react-icons/lu";
|
|
import { useRouter } from "@/i18n/navigation";
|
|
|
|
/**
|
|
* Subscription info card for the Profile page.
|
|
* Shows current plan, billing dates, cancel option.
|
|
*/
|
|
export function SubscriptionCard() {
|
|
const t = useTranslations("subscription");
|
|
const tCommon = useTranslations("common");
|
|
const { data: session } = useSession();
|
|
const { data: subData, isLoading } = useMySubscription(!!session);
|
|
const router = useRouter();
|
|
|
|
const cardBg = useColorModeValue("white", "gray.800");
|
|
const borderColor = useColorModeValue("gray.100", "gray.700");
|
|
|
|
const [showCancelForm, setShowCancelForm] = useState(false);
|
|
const [cancelReason, setCancelReason] = useState("");
|
|
const cancelMutation = useCancelSubscription();
|
|
|
|
const subscription = subData?.data ?? null;
|
|
const plan = subscription?.plan ?? session?.user?.subscriptionPlan ?? "free";
|
|
const isFree = plan === "free";
|
|
|
|
const handleCancel = async () => {
|
|
await cancelMutation.mutateAsync({ reason: cancelReason || undefined });
|
|
setShowCancelForm(false);
|
|
setCancelReason("");
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
|
|
<Card.Body>
|
|
<Flex justify="center" py={8}>
|
|
<Spinner size="sm" color="primary.500" />
|
|
</Flex>
|
|
</Card.Body>
|
|
</Card.Root>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
|
|
<Card.Header>
|
|
<Flex justify="space-between" align="center">
|
|
<Heading as="h3" size="sm">
|
|
{t("title")}
|
|
</Heading>
|
|
<PlanBadge plan={plan} />
|
|
</Flex>
|
|
</Card.Header>
|
|
<Card.Body pt={0}>
|
|
<VStack gap={4} align="stretch">
|
|
{/* Subscription Details */}
|
|
{subscription && !isFree && (
|
|
<>
|
|
{subscription.currentPeriodEnd && (
|
|
<Flex justify="space-between" align="center">
|
|
<HStack gap={2} color="fg.muted">
|
|
<LuCalendar size={14} />
|
|
<Text fontSize="sm">{t("next-billing")}</Text>
|
|
</HStack>
|
|
<Text fontSize="sm" fontWeight="semibold">
|
|
{new Date(
|
|
subscription.currentPeriodEnd,
|
|
).toLocaleDateString()}
|
|
</Text>
|
|
</Flex>
|
|
)}
|
|
|
|
{subscription.cancelEffectiveDate && (
|
|
<Box
|
|
p={3}
|
|
bg="orange.50"
|
|
_dark={{ bg: "orange.950" }}
|
|
borderRadius="lg"
|
|
>
|
|
<HStack gap={2}>
|
|
<LuTriangleAlert size={14} color="orange" />
|
|
<Text
|
|
fontSize="sm"
|
|
color="orange.600"
|
|
_dark={{ color: "orange.300" }}
|
|
>
|
|
{t("cancelled-info", {
|
|
date: new Date(
|
|
subscription.cancelEffectiveDate,
|
|
).toLocaleDateString(),
|
|
})}
|
|
</Text>
|
|
</HStack>
|
|
</Box>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<Flex gap={2} direction={{ base: "column", sm: "row" }}>
|
|
{isFree ? (
|
|
<Button
|
|
onClick={() => router.push("/pricing")}
|
|
colorPalette="primary"
|
|
variant="solid"
|
|
size="sm"
|
|
borderRadius="lg"
|
|
flex={1}
|
|
>
|
|
{t("upgrade-cta")}
|
|
</Button>
|
|
) : (
|
|
<>
|
|
<Button
|
|
onClick={() => router.push("/pricing")}
|
|
colorPalette="primary"
|
|
variant="outline"
|
|
size="sm"
|
|
borderRadius="lg"
|
|
flex={1}
|
|
>
|
|
{t("manage")}
|
|
</Button>
|
|
|
|
{!subscription?.cancelEffectiveDate && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
colorPalette="red"
|
|
onClick={() => setShowCancelForm(true)}
|
|
>
|
|
{t("cancel")}
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
</Flex>
|
|
|
|
{/* Cancel Confirmation */}
|
|
{showCancelForm && (
|
|
<Box
|
|
p={4}
|
|
borderWidth="1px"
|
|
borderColor="red.200"
|
|
_dark={{ borderColor: "red.800" }}
|
|
borderRadius="lg"
|
|
>
|
|
<VStack gap={3} align="stretch">
|
|
<Text
|
|
fontSize="sm"
|
|
fontWeight="semibold"
|
|
color="red.600"
|
|
_dark={{ color: "red.300" }}
|
|
>
|
|
{t("cancel-confirm-title")}
|
|
</Text>
|
|
<Text fontSize="xs" color="fg.muted">
|
|
{t("cancel-confirm-message")}
|
|
</Text>
|
|
<Textarea
|
|
size="sm"
|
|
placeholder={t("cancel-reason-placeholder")}
|
|
value={cancelReason}
|
|
onChange={(e) => setCancelReason(e.target.value)}
|
|
rows={2}
|
|
/>
|
|
<HStack gap={2} justify="flex-end">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setShowCancelForm(false);
|
|
setCancelReason("");
|
|
}}
|
|
>
|
|
<LuX />
|
|
{tCommon("cancel")}
|
|
</Button>
|
|
<Button
|
|
colorPalette="red"
|
|
variant="solid"
|
|
size="sm"
|
|
loading={cancelMutation.isPending}
|
|
onClick={handleCancel}
|
|
>
|
|
<LuCheck />
|
|
{t("cancel")}
|
|
</Button>
|
|
</HStack>
|
|
</VStack>
|
|
</Box>
|
|
)}
|
|
</VStack>
|
|
</Card.Body>
|
|
</Card.Root>
|
|
);
|
|
}
|