423 lines
12 KiB
TypeScript
423 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
Box,
|
|
Flex,
|
|
Heading,
|
|
Text,
|
|
Card,
|
|
VStack,
|
|
HStack,
|
|
Separator,
|
|
Spinner,
|
|
Input,
|
|
Button,
|
|
IconButton,
|
|
} from "@chakra-ui/react";
|
|
import { useTranslations } from "next-intl";
|
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
|
import { SlideUp } from "@/components/motion";
|
|
import { Avatar } from "@/components/ui/data-display/avatar";
|
|
import { useSession } from "next-auth/react";
|
|
import { useUserBettingStats } from "@/lib/api/coupons/use-hooks";
|
|
import type { UserBettingStatsDto } from "@/lib/api/coupons/types";
|
|
import {
|
|
LuMail,
|
|
LuUser,
|
|
LuCalendar,
|
|
LuShield,
|
|
LuTrendingUp,
|
|
LuTarget,
|
|
LuTicket,
|
|
LuPen,
|
|
LuCheck,
|
|
LuX,
|
|
LuLock,
|
|
} from "react-icons/lu";
|
|
import { useState } from "react";
|
|
import { useUpdateProfile, useChangePassword } from "@/lib/api/users/use-hooks";
|
|
import { Field } from "@/components/ui/forms/field";
|
|
import { useForm } from "react-hook-form";
|
|
import * as yup from "yup";
|
|
import { yupResolver } from "@hookform/resolvers/yup";
|
|
import { PasswordInput } from "@/components/ui/forms/password-input";
|
|
import { useRouter } from "next/navigation";
|
|
|
|
interface InfoRowProps {
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
value: string;
|
|
}
|
|
|
|
function InfoRow({ icon, label, value }: InfoRowProps) {
|
|
return (
|
|
<Flex justify="space-between" align="center" py={2}>
|
|
<HStack gap={2} color="fg.muted">
|
|
{icon}
|
|
<Text fontSize="sm">{label}</Text>
|
|
</HStack>
|
|
<Text fontSize="sm" fontWeight="semibold">
|
|
{value}
|
|
</Text>
|
|
</Flex>
|
|
);
|
|
}
|
|
|
|
const profileSchema = yup.object({
|
|
firstName: yup.string().required(),
|
|
lastName: yup.string().required(),
|
|
});
|
|
|
|
type ProfileForm = yup.InferType<typeof profileSchema>;
|
|
|
|
const passwordSchema = yup.object({
|
|
currentPassword: yup.string().required(),
|
|
newPassword: yup.string().min(8).required(),
|
|
confirmPassword: yup
|
|
.string()
|
|
.oneOf([yup.ref("newPassword")], "Passwords must match")
|
|
.required(),
|
|
});
|
|
|
|
type PasswordForm = yup.InferType<typeof passwordSchema>;
|
|
|
|
export default function ProfileContent() {
|
|
const t = useTranslations("profile");
|
|
const tCommon = useTranslations("common");
|
|
const { data: session, update: updateSession } = useSession();
|
|
const { data: statsData, isLoading: statsLoading } = useUserBettingStats();
|
|
|
|
const cardBg = useColorModeValue("white", "gray.800");
|
|
const borderColor = useColorModeValue("gray.100", "gray.700");
|
|
|
|
const user = session?.user;
|
|
const stats = statsData?.data as UserBettingStatsDto | undefined;
|
|
|
|
// Edit profile state
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const updateProfile = useUpdateProfile();
|
|
|
|
const {
|
|
handleSubmit: handleProfileSubmit,
|
|
register: profileRegister,
|
|
formState: { errors: profileErrors },
|
|
reset: resetProfile,
|
|
} = useForm<ProfileForm>({
|
|
resolver: yupResolver(profileSchema),
|
|
mode: "onChange",
|
|
defaultValues: {
|
|
firstName: user?.name?.split(" ")[0] || "",
|
|
lastName: user?.name?.split(" ").slice(1).join(" ") || "",
|
|
},
|
|
});
|
|
|
|
const onProfileSubmit = async (data: ProfileForm) => {
|
|
await updateProfile.mutateAsync(data);
|
|
await updateSession();
|
|
setIsEditing(false);
|
|
};
|
|
|
|
// Change password state
|
|
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
|
const changePassword = useChangePassword();
|
|
const router = useRouter();
|
|
|
|
const {
|
|
handleSubmit: handlePasswordSubmit,
|
|
register: passwordRegister,
|
|
formState: { errors: passwordErrors },
|
|
reset: resetPassword,
|
|
} = useForm<PasswordForm>({
|
|
resolver: yupResolver(passwordSchema),
|
|
mode: "onChange",
|
|
});
|
|
|
|
const onPasswordSubmit = async (data: PasswordForm) => {
|
|
await changePassword.mutateAsync({
|
|
currentPassword: data.currentPassword,
|
|
newPassword: data.newPassword,
|
|
});
|
|
resetPassword();
|
|
setShowPasswordForm(false);
|
|
};
|
|
|
|
return (
|
|
<SlideUp>
|
|
<Box maxW="2xl" mx="auto">
|
|
<Heading as="h1" size="xl" fontWeight="bold" mb={6}>
|
|
{t("title")}
|
|
</Heading>
|
|
|
|
{/* Profile Card */}
|
|
<Card.Root
|
|
bg={cardBg}
|
|
borderColor={borderColor}
|
|
borderRadius="xl"
|
|
mb={6}
|
|
>
|
|
<Card.Body>
|
|
<Flex
|
|
direction={{ base: "column", sm: "row" }}
|
|
align="center"
|
|
gap={6}
|
|
>
|
|
<Avatar name={user?.name || "User"} variant="solid" size="2xl" />
|
|
<VStack gap={1} align={{ base: "center", sm: "flex-start" }}>
|
|
<Heading as="h2" size="lg">
|
|
{user?.name || "—"}
|
|
</Heading>
|
|
<Text color="fg.muted" fontSize="sm">
|
|
{user?.email || "—"}
|
|
</Text>
|
|
</VStack>
|
|
</Flex>
|
|
</Card.Body>
|
|
</Card.Root>
|
|
|
|
{/* Account Info */}
|
|
<Card.Root
|
|
bg={cardBg}
|
|
borderColor={borderColor}
|
|
borderRadius="xl"
|
|
mb={6}
|
|
>
|
|
<Card.Header>
|
|
<Flex justify="space-between" align="center">
|
|
<Heading as="h3" size="sm">
|
|
{t("personal-info")}
|
|
</Heading>
|
|
{!isEditing ? (
|
|
<IconButton
|
|
variant="ghost"
|
|
size="sm"
|
|
aria-label={tCommon("edit")}
|
|
onClick={() => setIsEditing(true)}
|
|
>
|
|
<LuPen />
|
|
</IconButton>
|
|
) : null}
|
|
</Flex>
|
|
</Card.Header>
|
|
<Card.Body pt={0}>
|
|
{isEditing ? (
|
|
<Flex
|
|
as="form"
|
|
direction="column"
|
|
gap={4}
|
|
onSubmit={handleProfileSubmit(onProfileSubmit)}
|
|
>
|
|
<Field
|
|
label={t("first-name")}
|
|
errorText={profileErrors.firstName?.message}
|
|
invalid={!!profileErrors.firstName}
|
|
>
|
|
<Input
|
|
{...profileRegister("firstName")}
|
|
placeholder={t("first-name")}
|
|
/>
|
|
</Field>
|
|
<Field
|
|
label={t("last-name")}
|
|
errorText={profileErrors.lastName?.message}
|
|
invalid={!!profileErrors.lastName}
|
|
>
|
|
<Input
|
|
{...profileRegister("lastName")}
|
|
placeholder={t("last-name")}
|
|
/>
|
|
</Field>
|
|
<HStack gap={2} justify="flex-end">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setIsEditing(false);
|
|
resetProfile();
|
|
}}
|
|
>
|
|
<LuX />
|
|
{tCommon("cancel")}
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
size="sm"
|
|
loading={updateProfile.isPending}
|
|
>
|
|
<LuCheck />
|
|
{tCommon("save")}
|
|
</Button>
|
|
</HStack>
|
|
</Flex>
|
|
) : (
|
|
<>
|
|
<InfoRow
|
|
icon={<LuUser />}
|
|
label={t("full-name")}
|
|
value={user?.name || "—"}
|
|
/>
|
|
<Separator />
|
|
<InfoRow
|
|
icon={<LuMail />}
|
|
label={t("email")}
|
|
value={user?.email || "—"}
|
|
/>
|
|
<Separator />
|
|
<InfoRow
|
|
icon={<LuShield />}
|
|
label={t("role")}
|
|
value={
|
|
(user as Record<string, unknown>)?.roles
|
|
? String((user as Record<string, unknown>).roles)
|
|
: "User"
|
|
}
|
|
/>
|
|
<Separator />
|
|
<InfoRow
|
|
icon={<LuCalendar />}
|
|
label={t("member-since")}
|
|
value="—"
|
|
/>
|
|
</>
|
|
)}
|
|
</Card.Body>
|
|
</Card.Root>
|
|
|
|
{/* Change Password */}
|
|
<Card.Root
|
|
bg={cardBg}
|
|
borderColor={borderColor}
|
|
borderRadius="xl"
|
|
mb={6}
|
|
>
|
|
<Card.Header>
|
|
<Flex justify="space-between" align="center">
|
|
<Heading as="h3" size="sm">
|
|
<HStack gap={2}>
|
|
<LuLock />
|
|
<Text>{t("change-password")}</Text>
|
|
</HStack>
|
|
</Heading>
|
|
{!showPasswordForm ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setShowPasswordForm(true)}
|
|
>
|
|
{tCommon("edit")}
|
|
</Button>
|
|
) : null}
|
|
</Flex>
|
|
</Card.Header>
|
|
<Card.Body pt={0}>
|
|
{showPasswordForm ? (
|
|
<Flex
|
|
as="form"
|
|
direction="column"
|
|
gap={4}
|
|
onSubmit={handlePasswordSubmit(onPasswordSubmit)}
|
|
>
|
|
<Field
|
|
label={t("current-password")}
|
|
errorText={passwordErrors.currentPassword?.message}
|
|
invalid={!!passwordErrors.currentPassword}
|
|
>
|
|
<PasswordInput
|
|
{...passwordRegister("currentPassword")}
|
|
placeholder={t("current-password")}
|
|
/>
|
|
</Field>
|
|
<Field
|
|
label={t("new-password")}
|
|
errorText={passwordErrors.newPassword?.message}
|
|
invalid={!!passwordErrors.newPassword}
|
|
>
|
|
<PasswordInput
|
|
{...passwordRegister("newPassword")}
|
|
placeholder={t("new-password")}
|
|
/>
|
|
</Field>
|
|
<Field
|
|
label={t("confirm-password")}
|
|
errorText={passwordErrors.confirmPassword?.message}
|
|
invalid={!!passwordErrors.confirmPassword}
|
|
>
|
|
<PasswordInput
|
|
{...passwordRegister("confirmPassword")}
|
|
placeholder={t("confirm-password")}
|
|
/>
|
|
</Field>
|
|
<HStack gap={2} justify="flex-end">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setShowPasswordForm(false);
|
|
resetPassword();
|
|
}}
|
|
>
|
|
<LuX />
|
|
{tCommon("cancel")}
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
size="sm"
|
|
loading={changePassword.isPending}
|
|
onClick={() => router.refresh()}
|
|
>
|
|
<LuCheck />
|
|
{tCommon("save")}
|
|
</Button>
|
|
</HStack>
|
|
</Flex>
|
|
) : (
|
|
<Text fontSize="sm" color="fg.muted" py={2}>
|
|
{t("change-password-desc")}
|
|
</Text>
|
|
)}
|
|
</Card.Body>
|
|
</Card.Root>
|
|
|
|
{/* Betting Stats */}
|
|
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
|
|
<Card.Header>
|
|
<Heading as="h3" size="sm">
|
|
{t("betting-stats")}
|
|
</Heading>
|
|
</Card.Header>
|
|
<Card.Body pt={0}>
|
|
{statsLoading ? (
|
|
<Flex justify="center" py={6}>
|
|
<Spinner size="sm" color="primary.500" />
|
|
</Flex>
|
|
) : (
|
|
<>
|
|
<InfoRow
|
|
icon={<LuTicket />}
|
|
label={t("total-coupons")}
|
|
value={String(stats?.totalCoupons ?? "—")}
|
|
/>
|
|
<Separator />
|
|
<InfoRow
|
|
icon={<LuTrendingUp />}
|
|
label={t("win-rate")}
|
|
value={
|
|
stats?.winRate != null
|
|
? `${Math.round(stats.winRate)}%`
|
|
: "—"
|
|
}
|
|
/>
|
|
<Separator />
|
|
<InfoRow
|
|
icon={<LuTarget />}
|
|
label={t("total-profit")}
|
|
value={stats?.wonBets != null ? String(stats.wonBets) : "—"}
|
|
/>
|
|
</>
|
|
)}
|
|
</Card.Body>
|
|
</Card.Root>
|
|
</Box>
|
|
</SlideUp>
|
|
);
|
|
}
|