From 15eaf059bab10d69481720567180a711ba153b26 Mon Sep 17 00:00:00 2001 From: kareem Date: Sun, 26 Oct 2025 21:29:52 +0300 Subject: [PATCH 01/78] Add login page --- app/globals.css | 232 ++++++++------ app/layout.tsx | 2 + app/login/components/GoogleLoginButton.tsx | 29 ++ app/login/components/LoginContainer.tsx | 66 ++++ app/login/components/LoginForm.tsx | 119 +++++++ app/login/components/LoginPresentational.tsx | 67 ++++ app/login/page.tsx | 12 + app/login/services/authService.ts | 0 app/login/types/authTypes.ts | 0 app/page.tsx | 2 +- components/ui/card.tsx | 83 +++++ components/ui/input.tsx | 22 ++ components/ui/label.tsx | 24 ++ components/ui/toast.tsx | 127 ++++++++ components/ui/toaster.tsx | 35 +++ hooks/use-toast.ts | 188 +++++++++++ messages/ar/auth.json | 8 +- messages/en/auth.json | 8 +- package-lock.json | 314 ++++++++++++++++++- package.json | 2 + 20 files changed, 1238 insertions(+), 102 deletions(-) create mode 100644 app/login/components/GoogleLoginButton.tsx create mode 100644 app/login/components/LoginContainer.tsx create mode 100644 app/login/components/LoginForm.tsx create mode 100644 app/login/components/LoginPresentational.tsx create mode 100644 app/login/page.tsx create mode 100644 app/login/services/authService.ts create mode 100644 app/login/types/authTypes.ts create mode 100644 components/ui/card.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/toast.tsx create mode 100644 components/ui/toaster.tsx create mode 100644 hooks/use-toast.ts diff --git a/app/globals.css b/app/globals.css index a316cee..3ce5fa5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3,113 +3,149 @@ @custom-variant dark (&:is(.dark *)); +:root { + --background: oklch(0.98 0 0); + --foreground: oklch(0.21 0.03 263.61); + --card: oklch(1.00 0 0); + --card-foreground: oklch(0.21 0.03 263.61); + --popover: oklch(1.00 0 0); + --popover-foreground: oklch(0.21 0.03 263.61); + --primary: oklch(0.48 0.20 260.47); + --primary-foreground: oklch(1.00 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.37 0.03 259.73); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.55 0.02 264.41); + --accent: oklch(0.95 0.02 260.18); + --accent-foreground: oklch(0.48 0.20 260.47); + --destructive: oklch(0.58 0.22 27.29); + --border: oklch(0.93 0.01 261.82); + --input: oklch(0.93 0.01 261.82); + --ring: oklch(0.48 0.20 260.47); + --chart-1: oklch(0.48 0.20 260.47); + --chart-2: oklch(0.56 0.24 260.92); + --chart-3: oklch(0.40 0.16 259.61); + --chart-4: oklch(0.43 0.16 259.82); + --chart-5: oklch(0.29 0.07 261.20); + --sidebar: oklch(0.97 0 0); + --sidebar-foreground: oklch(0.21 0.03 263.61); + --sidebar-primary: oklch(0.48 0.20 260.47); + --sidebar-primary-foreground: oklch(1.00 0 0); + --sidebar-accent: oklch(0.95 0.02 260.18); + --sidebar-accent-foreground: oklch(0.48 0.20 260.47); + --sidebar-border: oklch(0.93 0.01 261.82); + --sidebar-ring: oklch(0.48 0.20 260.47); + + --font-sans: Inter, sans-serif; + --font-serif: Source Serif 4, serif; + --font-mono: IBM Plex Mono, monospace; + + --radius: 0.375rem; + + --shadow-2xs: 0 1px 3px 0px oklch(0.00 0 0 / 0.05); + --shadow-xs: 0 1px 3px 0px oklch(0.00 0 0 / 0.05); + --shadow-sm: 0 1px 3px 0px oklch(0.00 0 0 / 0.10), 0 1px 2px -1px oklch(0.00 0 0 / 0.10); + --shadow: 0 1px 3px 0px oklch(0.00 0 0 / 0.10), 0 1px 2px -1px oklch(0.00 0 0 / 0.10); + --shadow-md: 0 1px 3px 0px oklch(0.00 0 0 / 0.10), 0 2px 4px -1px oklch(0.00 0 0 / 0.10); + --shadow-lg: 0 1px 3px 0px oklch(0.00 0 0 / 0.10), 0 4px 6px -1px oklch(0.00 0 0 / 0.10); + --shadow-xl: 0 1px 3px 0px oklch(0.00 0 0 / 0.10), 0 8px 10px -1px oklch(0.00 0 0 / 0.10); + --shadow-2xl: 0 1px 3px 0px oklch(0.00 0 0 / 0.25); + +} + +.dark { + --background: oklch(0.26 0.03 262.67); + --foreground: oklch(0.93 0.01 261.82); + --card: oklch(0.30 0.03 260.51); + --card-foreground: oklch(0.93 0.01 261.82); + --popover: oklch(0.30 0.03 260.51); + --popover-foreground: oklch(0.93 0.01 261.82); + --primary: oklch(0.56 0.24 260.92); + --primary-foreground: oklch(1.00 0 0); + --secondary: oklch(0.35 0.04 261.40); + --secondary-foreground: oklch(0.93 0.01 261.82); + --muted: oklch(0.30 0.03 260.51); + --muted-foreground: oklch(0.71 0.02 261.33); + --accent: oklch(0.33 0.04 264.63); + --accent-foreground: oklch(0.93 0.01 261.82); + --destructive: oklch(0.64 0.21 25.39); + --border: oklch(0.35 0.04 261.40); + --input: oklch(0.35 0.04 261.40); + --ring: oklch(0.56 0.24 260.92); + --chart-1: oklch(0.56 0.24 260.92); + --chart-2: oklch(0.48 0.20 260.47); + --chart-3: oklch(0.69 0.17 255.59); + --chart-4: oklch(0.43 0.16 259.82); + --chart-5: oklch(0.29 0.07 261.20); + --sidebar: oklch(0.26 0.03 262.67); + --sidebar-foreground: oklch(0.93 0.01 261.82); + --sidebar-primary: oklch(0.56 0.24 260.92); + --sidebar-primary-foreground: oklch(1.00 0 0); + --sidebar-accent: oklch(0.33 0.04 264.63); + --sidebar-accent-foreground: oklch(0.93 0.01 261.82); + --sidebar-border: oklch(0.35 0.04 261.40); + --sidebar-ring: oklch(0.56 0.24 260.92); + + --shadow-2xs: 0 1px 3px 0px oklch(0.00 0 0 / 0.05); + --shadow-xs: 0 1px 3px 0px oklch(0.00 0 0 / 0.05); + --shadow-sm: 0 1px 3px 0px oklch(0.00 0 0 / 0.10), 0 1px 2px -1px oklch(0.00 0 0 / 0.10); + --shadow: 0 1px 3px 0px oklch(0.00 0 0 / 0.10), 0 1px 2px -1px oklch(0.00 0 0 / 0.10); + --shadow-md: 0 1px 3px 0px oklch(0.00 0 0 / 0.10), 0 2px 4px -1px oklch(0.00 0 0 / 0.10); + --shadow-lg: 0 1px 3px 0px oklch(0.00 0 0 / 0.10), 0 4px 6px -1px oklch(0.00 0 0 / 0.10); + --shadow-xl: 0 1px 3px 0px oklch(0.00 0 0 / 0.10), 0 8px 10px -1px oklch(0.00 0 0 / 0.10); + --shadow-2xl: 0 1px 3px 0px oklch(0.00 0 0 / 0.25); +} + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); - --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); - --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); - --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + --font-serif: var(--font-serif); + --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); -} -:root { - --radius: 0.65rem; - --background: oklch(1 0 0); - --foreground: oklch(0.141 0.005 285.823); - --card: oklch(1 0 0); - --card-foreground: oklch(0.141 0.005 285.823); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.141 0.005 285.823); - --primary: oklch(0.488 0.243 264.376); - --primary-foreground: oklch(0.97 0.014 254.604); - --secondary: oklch(0.967 0.001 286.375); - --secondary-foreground: oklch(0.21 0.006 285.885); - --muted: oklch(0.967 0.001 286.375); - --muted-foreground: oklch(0.552 0.016 285.938); - --accent: oklch(0.967 0.001 286.375); - --accent-foreground: oklch(0.21 0.006 285.885); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.92 0.004 286.32); - --input: oklch(0.92 0.004 286.32); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.809 0.105 251.813); - --chart-2: oklch(0.623 0.214 259.815); - --chart-3: oklch(0.546 0.245 262.881); - --chart-4: oklch(0.488 0.243 264.376); - --chart-5: oklch(0.424 0.199 265.638); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.141 0.005 285.823); - --sidebar-primary: oklch(0.546 0.245 262.881); - --sidebar-primary-foreground: oklch(0.97 0.014 254.604); - --sidebar-accent: oklch(0.967 0.001 286.375); - --sidebar-accent-foreground: oklch(0.21 0.006 285.885); - --sidebar-border: oklch(0.92 0.004 286.32); - --sidebar-ring: oklch(0.708 0 0); -} - -.dark { - --background: oklch(0.141 0.005 285.823); - --foreground: oklch(0.985 0 0); - --card: oklch(0.21 0.006 285.885); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.21 0.006 285.885); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.488 0.243 264.376); - --primary-foreground: oklch(0.97 0.014 254.604); - --secondary: oklch(0.274 0.006 286.033); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.274 0.006 286.033); - --muted-foreground: oklch(0.705 0.015 286.067); - --accent: oklch(0.274 0.006 286.033); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.809 0.105 251.813); - --chart-2: oklch(0.623 0.214 259.815); - --chart-3: oklch(0.546 0.245 262.881); - --chart-4: oklch(0.488 0.243 264.376); - --chart-5: oklch(0.424 0.199 265.638); - --sidebar: oklch(0.21 0.006 285.885); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.623 0.214 259.815); - --sidebar-primary-foreground: oklch(0.97 0.014 254.604); - --sidebar-accent: oklch(0.274 0.006 286.033); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.439 0 0); + --shadow-2xs: var(--shadow-2xs); + --shadow-xs: var(--shadow-xs); + --shadow-sm: var(--shadow-sm); + --shadow: var(--shadow); + --shadow-md: var(--shadow-md); + --shadow-lg: var(--shadow-lg); + --shadow-xl: var(--shadow-xl); + --shadow-2xl: var(--shadow-2xl); } diff --git a/app/layout.tsx b/app/layout.tsx index 5629056..bd164e1 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,6 +5,7 @@ import { getMessages } from "next-intl/server"; import { cookies } from "next/headers"; import { ThemeProvider } from "@/contexts/ThemeProvider"; import { LanguageProvider } from "@/contexts/LanguageProvider"; +import { Toaster } from "@/components/ui/toaster"; import "./globals.css"; const geistSans = Geist({ @@ -44,6 +45,7 @@ export default async function RootLayout({ {children} + diff --git a/app/login/components/GoogleLoginButton.tsx b/app/login/components/GoogleLoginButton.tsx new file mode 100644 index 0000000..ef691f1 --- /dev/null +++ b/app/login/components/GoogleLoginButton.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Chrome } from "lucide-react"; + +interface GoogleLoginButtonProps { + onClick: () => void; + isLoading?: boolean; + text: string; +} + +export function GoogleLoginButton({ + onClick, + isLoading, + text, +}: GoogleLoginButtonProps) { + return ( + + ); +} diff --git a/app/login/components/LoginContainer.tsx b/app/login/components/LoginContainer.tsx new file mode 100644 index 0000000..eaface2 --- /dev/null +++ b/app/login/components/LoginContainer.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { LoginFormData } from "./LoginForm"; +import { useToast } from "@/hooks/use-toast"; + +export interface LoginContainerProps { + children: (props: LoginPresentationalProps) => React.ReactNode; +} + +export interface LoginPresentationalProps { + isLoading: boolean; + handleLogin: (data: LoginFormData) => Promise; + handleGoogleLogin: () => Promise; + t: (key: string) => string; +} + +export function LoginContainer({ children }: LoginContainerProps) { + const t = useTranslations(""); + const { toast } = useToast(); + const [isLoading, setIsLoading] = useState(false); + + const handleLogin = async (data: LoginFormData) => { + setIsLoading(true); + try { + toast({ + title: t("loginSuccess"), + description: t("welcomeBack"), + }); + + } catch (error: any) { + toast({ + title: t("loginError"), + description: + error.response?.data?.message || + error.message || + "Something went wrong", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + const handleGoogleLogin = async () => { + setIsLoading(true); + try { + toast({ + title: t("loginSuccess"), + description: t("welcomeBack"), + }); + + } catch (error: any) { + toast({ + title: t("loginError"), + description: error.message || "Google login failed", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + return <>{children({ isLoading, handleLogin, handleGoogleLogin, t })}; +} diff --git a/app/login/components/LoginForm.tsx b/app/login/components/LoginForm.tsx new file mode 100644 index 0000000..1f9dfb1 --- /dev/null +++ b/app/login/components/LoginForm.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { Eye, EyeOff, Loader2 } from "lucide-react"; + +interface LoginFormProps { + onSubmit: (data: LoginFormData) => Promise; + isLoading: boolean; +} + +export interface LoginFormData { + emailOrUsername: string; + password: string; + rememberMe?: boolean; +} + +export function LoginForm({ onSubmit, isLoading }: LoginFormProps) { + const t = useTranslations(""); + const [showPassword, setShowPassword] = useState(false); + + const loginSchema = z.object({ + emailOrUsername: z.string().min(1, { message: t("emailRequired") }), + password: z.string().min(8, { message: t("passwordTooShort") }), + rememberMe: z.boolean().optional().default(false), + }); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { + emailOrUsername: "", + password: "", + rememberMe: false, + }, + }); + + return ( +
+
+ + + {errors.emailOrUsername && ( +

+ {errors.emailOrUsername.message} +

+ )} +
+ +
+ +
+ + +
+ {errors.password && ( +

{errors.password.message}

+ )} +
+ + + + + + +
+ ); +} diff --git a/app/login/components/LoginPresentational.tsx b/app/login/components/LoginPresentational.tsx new file mode 100644 index 0000000..ce76183 --- /dev/null +++ b/app/login/components/LoginPresentational.tsx @@ -0,0 +1,67 @@ +"use client"; + +import Link from "next/link"; +import { LoginForm, LoginFormData } from "./LoginForm"; +import { GoogleLoginButton } from "./GoogleLoginButton"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +export interface LoginPresentationalProps { + isLoading: boolean; + handleLogin: (data: LoginFormData) => Promise; + handleGoogleLogin: () => Promise; + t: (key: string) => string; +} + +export function LoginPresentational({ + isLoading, + handleLogin, + handleGoogleLogin, + t, +}: LoginPresentationalProps) { + return ( +
+ + + {t("welcomeBack")} + + + + +
+
+ +
+
+ + {t("continueWith")} + +
+
+ + +
+ +
+ {t("dontHaveAccount")}{" "} + + {t("signUp")} + +
+
+
+
+ ); +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..f027ec4 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { LoginContainer } from "./components/LoginContainer"; +import { LoginPresentational } from "./components/LoginPresentational"; + +export default function LoginPage() { + return ( + + {(props) => } + + ); +} diff --git a/app/login/services/authService.ts b/app/login/services/authService.ts new file mode 100644 index 0000000..e69de29 diff --git a/app/login/types/authTypes.ts b/app/login/types/authTypes.ts new file mode 100644 index 0000000..e69de29 diff --git a/app/page.tsx b/app/page.tsx index b83030b..d7c9f99 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -5,7 +5,7 @@ import { LanguageSwitcher } from "@/components/common/LanguageSwitcher"; import { ThemeSwitcher } from "@/components/common/ThemeSwitcher"; export default function Home() { - const t = useTranslations(); + const t = useTranslations(""); return (
diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..188e729 --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,83 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = "CardFooter"; + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/components/ui/input.tsx b/components/ui/input.tsx new file mode 100644 index 0000000..611b65d --- /dev/null +++ b/components/ui/input.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ); + } +); +Input.displayName = "Input"; + +export { Input }; diff --git a/components/ui/label.tsx b/components/ui/label.tsx new file mode 100644 index 0000000..7c96064 --- /dev/null +++ b/components/ui/label.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +); + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx new file mode 100644 index 0000000..6905cf3 --- /dev/null +++ b/components/ui/toast.tsx @@ -0,0 +1,127 @@ +import * as React from "react"; +import * as ToastPrimitives from "@radix-ui/react-toast"; +import { cva, type VariantProps } from "class-variance-authority"; +import { X } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const ToastProvider = ToastPrimitives.Provider; + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastViewport.displayName = ToastPrimitives.Viewport.displayName; + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ); +}); +Toast.displayName = ToastPrimitives.Root.displayName; + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastAction.displayName = ToastPrimitives.Action.displayName; + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +ToastClose.displayName = ToastPrimitives.Close.displayName; + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastTitle.displayName = ToastPrimitives.Title.displayName; + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastDescription.displayName = ToastPrimitives.Description.displayName; + +type ToastProps = React.ComponentPropsWithoutRef; + +type ToastActionElement = React.ReactElement; + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +}; diff --git a/components/ui/toaster.tsx b/components/ui/toaster.tsx new file mode 100644 index 0000000..364b797 --- /dev/null +++ b/components/ui/toaster.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useToast } from "@/hooks/use-toast"; +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "@/components/ui/toast"; + +export function Toaster() { + const { toasts } = useToast(); + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ); + })} + +
+ ); +} diff --git a/hooks/use-toast.ts b/hooks/use-toast.ts new file mode 100644 index 0000000..44bfdbd --- /dev/null +++ b/hooks/use-toast.ts @@ -0,0 +1,188 @@ +"use client"; + +import * as React from "react"; + +import type { ToastActionElement, ToastProps } from "@/components/ui/toast"; + +const TOAST_LIMIT = 1; +const TOAST_REMOVE_DELAY = 1000000; + +type ToasterToast = ToastProps & { + id: string; + title?: React.ReactNode; + description?: React.ReactNode; + action?: ToastActionElement; +}; + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const; + +let count = 0; + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER; + return count.toString(); +} + +type ActionType = typeof actionTypes; + +type Action = + | { + type: ActionType["ADD_TOAST"]; + toast: ToasterToast; + } + | { + type: ActionType["UPDATE_TOAST"]; + toast: Partial; + } + | { + type: ActionType["DISMISS_TOAST"]; + toastId?: ToasterToast["id"]; + } + | { + type: ActionType["REMOVE_TOAST"]; + toastId?: ToasterToast["id"]; + }; + +interface State { + toasts: ToasterToast[]; +} + +const toastTimeouts = new Map>(); + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return; + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId); + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }); + }, TOAST_REMOVE_DELAY); + + toastTimeouts.set(toastId, timeout); +}; + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + }; + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + }; + + case "DISMISS_TOAST": { + const { toastId } = action; + + if (toastId) { + addToRemoveQueue(toastId); + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id); + }); + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + }; + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + }; + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + }; + } +}; + +const listeners: Array<(state: State) => void> = []; + +let memoryState: State = { toasts: [] }; + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action); + listeners.forEach((listener) => { + listener(memoryState); + }); +} + +type Toast = Omit; + +function toast({ ...props }: Toast) { + const id = genId(); + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }); + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss(); + }, + }, + }); + + return { + id: id, + dismiss, + update, + }; +} + +function useToast() { + const [state, setState] = React.useState(memoryState); + + React.useEffect(() => { + listeners.push(setState); + return () => { + const index = listeners.indexOf(setState); + if (index > -1) { + listeners.splice(index, 1); + } + }; + }, [state]); + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + }; +} + +export { useToast, toast }; diff --git a/messages/ar/auth.json b/messages/ar/auth.json index 073e906..2b81f37 100644 --- a/messages/ar/auth.json +++ b/messages/ar/auth.json @@ -2,7 +2,10 @@ "login": "تسجيل الدخول", "register": "إنشاء حساب", "email": "البريد الإلكتروني", + "emailOrUsername": "البريد الإلكتروني / اسم المستخدم", + "emailOrUsernamePlaceholder": "أدخل بريدك الإلكتروني أو اسم المستخدم", "password": "كلمة المرور", + "passwordPlaceholder": "أدخل كلمة المرور", "confirmPassword": "تأكيد كلمة المرور", "forgotPassword": "نسيت كلمة المرور؟", "resetPassword": "إعادة تعيين كلمة المرور", @@ -20,5 +23,8 @@ "loginSuccess": "تم تسجيل الدخول بنجاح", "loginError": "فشل تسجيل الدخول. يرجى التحقق من بيانات الاعتماد الخاصة بك.", "registrationSuccess": "تم التسجيل بنجاح", - "registrationError": "فشل التسجيل. يرجى المحاولة مرة أخرى." + "registrationError": "فشل التسجيل. يرجى المحاولة مرة أخرى.", + "welcomeBack": "مرحبًا بعودتك", + "continueWith": "أو استمر مع", + "continueWithGoogle": "استمر مع Google" } diff --git a/messages/en/auth.json b/messages/en/auth.json index 902cb6f..453ca34 100644 --- a/messages/en/auth.json +++ b/messages/en/auth.json @@ -2,7 +2,10 @@ "login": "Login", "register": "Register", "email": "Email", + "emailOrUsername": "Email / Username", + "emailOrUsernamePlaceholder": "Enter your email or username", "password": "Password", + "passwordPlaceholder": "Enter your password", "confirmPassword": "Confirm Password", "forgotPassword": "Forgot Password?", "resetPassword": "Reset Password", @@ -20,5 +23,8 @@ "loginSuccess": "Login successful", "loginError": "Login failed. Please check your credentials.", "registrationSuccess": "Registration successful", - "registrationError": "Registration failed. Please try again." + "registrationError": "Registration failed. Please try again.", + "welcomeBack": "Welcome back", + "continueWith": "Or Continue With", + "continueWithGoogle": "Continue with Google" } diff --git a/package-lock.json b/package-lock.json index 7d4c48d..7625d91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,9 @@ "version": "0.1.0", "dependencies": { "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-toast": "^1.2.15", "@tanstack/react-query": "^5.90.5", "@tanstack/react-query-devtools": "^5.90.2", "axios": "^1.12.2", @@ -1276,6 +1278,38 @@ "node": ">=12.4.0" } }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -1291,6 +1325,142 @@ } } }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", @@ -1309,6 +1479,148 @@ } } }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1724,7 +2036,7 @@ "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" diff --git a/package.json b/package.json index 7872b35..a827b85 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ }, "dependencies": { "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-toast": "^1.2.15", "@tanstack/react-query": "^5.90.5", "@tanstack/react-query-devtools": "^5.90.2", "axios": "^1.12.2", From 54e8b9d2eaad8d574b86769c8d2850b21cbf3a0d Mon Sep 17 00:00:00 2001 From: kareem Date: Wed, 29 Oct 2025 21:01:10 +0300 Subject: [PATCH 02/78] Add Register Route --- .../login/components/LoginContainer.tsx | 0 app/(pages)/login/components/LoginForm.tsx | 120 +++++++++++ .../login/components/LoginPresentational.tsx | 8 +- app/{ => (pages)}/login/page.tsx | 0 .../login/services/authService.ts | 0 app/{ => (pages)}/login/types/authTypes.ts | 0 .../register/components/RegisterContainer.tsx | 66 ++++++ .../register/components/RegisterForm.tsx | 197 ++++++++++++++++++ .../components/RegisterPresentational.tsx | 69 ++++++ app/(pages)/register/page.tsx | 12 ++ app/(pages)/register/services/authService.ts | 0 app/(pages)/register/types/authTypes.ts | 0 app/globals.css | 188 +++++++++-------- app/login/components/LoginForm.tsx | 119 ----------- .../common}/GoogleLoginButton.tsx | 8 +- components/ui/input.tsx | 2 +- messages/ar/auth.json | 19 +- messages/en/auth.json | 19 +- 18 files changed, 613 insertions(+), 214 deletions(-) rename app/{ => (pages)}/login/components/LoginContainer.tsx (100%) create mode 100644 app/(pages)/login/components/LoginForm.tsx rename app/{ => (pages)}/login/components/LoginPresentational.tsx (88%) rename app/{ => (pages)}/login/page.tsx (100%) rename app/{ => (pages)}/login/services/authService.ts (100%) rename app/{ => (pages)}/login/types/authTypes.ts (100%) create mode 100644 app/(pages)/register/components/RegisterContainer.tsx create mode 100644 app/(pages)/register/components/RegisterForm.tsx create mode 100644 app/(pages)/register/components/RegisterPresentational.tsx create mode 100644 app/(pages)/register/page.tsx create mode 100644 app/(pages)/register/services/authService.ts create mode 100644 app/(pages)/register/types/authTypes.ts delete mode 100644 app/login/components/LoginForm.tsx rename {app/login/components => components/common}/GoogleLoginButton.tsx (70%) diff --git a/app/login/components/LoginContainer.tsx b/app/(pages)/login/components/LoginContainer.tsx similarity index 100% rename from app/login/components/LoginContainer.tsx rename to app/(pages)/login/components/LoginContainer.tsx diff --git a/app/(pages)/login/components/LoginForm.tsx b/app/(pages)/login/components/LoginForm.tsx new file mode 100644 index 0000000..9e55146 --- /dev/null +++ b/app/(pages)/login/components/LoginForm.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { Eye, EyeOff, Loader2 } from "lucide-react"; +import { useLanguage } from "@/contexts/LanguageProvider"; + +interface LoginFormProps { + onSubmit: (data: LoginFormData) => Promise; + isLoading: boolean; +} + +export interface LoginFormData { + emailOrUsername: string; + password: string; + rememberMe?: boolean; +} + +export function LoginForm({ onSubmit, isLoading }: LoginFormProps) { + const t = useTranslations(""); + const language = useLanguage(); + const [showPassword, setShowPassword] = useState(false); + + const loginSchema = z.object({ + emailOrUsername: z.string().min(1, { message: t("emailRequired") }), + password: z.string().min(8, { message: t("passwordTooShort") }), + rememberMe: z.boolean().optional().default(false), + }); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { + emailOrUsername: "", + password: "", + rememberMe: false, + }, + }); + + return ( +
+
+
+ + {errors.emailOrUsername && ( +

+ {errors.emailOrUsername.message} +

+ )} +
+ +
+
+ + +
+ {errors.password && ( +

{errors.password.message}

+ )} +
+ + + +
+ + +
+ ); +} diff --git a/app/login/components/LoginPresentational.tsx b/app/(pages)/login/components/LoginPresentational.tsx similarity index 88% rename from app/login/components/LoginPresentational.tsx rename to app/(pages)/login/components/LoginPresentational.tsx index ce76183..f27ade0 100644 --- a/app/login/components/LoginPresentational.tsx +++ b/app/(pages)/login/components/LoginPresentational.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { LoginForm, LoginFormData } from "./LoginForm"; -import { GoogleLoginButton } from "./GoogleLoginButton"; +import { GoogleLoginButton } from "../../../../components/common/GoogleLoginButton"; import { Card, CardContent, @@ -28,7 +28,9 @@ export function LoginPresentational({
- {t("welcomeBack")} + + {t("welcomeBack")} + @@ -47,7 +49,7 @@ export function LoginPresentational({ diff --git a/app/login/page.tsx b/app/(pages)/login/page.tsx similarity index 100% rename from app/login/page.tsx rename to app/(pages)/login/page.tsx diff --git a/app/login/services/authService.ts b/app/(pages)/login/services/authService.ts similarity index 100% rename from app/login/services/authService.ts rename to app/(pages)/login/services/authService.ts diff --git a/app/login/types/authTypes.ts b/app/(pages)/login/types/authTypes.ts similarity index 100% rename from app/login/types/authTypes.ts rename to app/(pages)/login/types/authTypes.ts diff --git a/app/(pages)/register/components/RegisterContainer.tsx b/app/(pages)/register/components/RegisterContainer.tsx new file mode 100644 index 0000000..04bb57e --- /dev/null +++ b/app/(pages)/register/components/RegisterContainer.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { RegisterFormData } from "./RegisterForm"; +import { useToast } from "@/hooks/use-toast"; + +export interface RegisterContainerProps { + children: (props: RegisterPresentationalProps) => React.ReactNode; +} + +export interface RegisterPresentationalProps { + isLoading: boolean; + handleRegister: (data: RegisterFormData) => Promise; + handleGoogleRegister: () => Promise; + t: (key: string) => string; +} + +export function RegisterContainer({ children }: RegisterContainerProps) { + const t = useTranslations(""); + const { toast } = useToast(); + const [isLoading, setIsLoading] = useState(false); + + const handleRegister = async (data: RegisterFormData) => { + setIsLoading(true); + try { + toast({ + title: t("registerSuccess"), + description: t("accountCreated"), + }); + } catch (error: any) { + toast({ + title: t("registerError"), + description: + error.response?.data?.message || + error.message || + "Something went wrong", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + const handleGoogleRegister = async () => { + setIsLoading(true); + try { + toast({ + title: t("registerSuccess"), + description: t("accountCreated"), + }); + } catch (error: any) { + toast({ + title: t("registerError"), + description: error.message || "Google registration failed", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + return ( + <>{children({ isLoading, handleRegister, handleGoogleRegister, t })} + ); +} diff --git a/app/(pages)/register/components/RegisterForm.tsx b/app/(pages)/register/components/RegisterForm.tsx new file mode 100644 index 0000000..e15a238 --- /dev/null +++ b/app/(pages)/register/components/RegisterForm.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { Eye, EyeOff, Loader2 } from "lucide-react"; +import { useLanguage } from "@/contexts/LanguageProvider"; + +interface RegisterFormProps { + onSubmit: (data: RegisterFormData) => Promise; + isLoading: boolean; +} + +export interface RegisterFormData { + fullName: string; + email: string; + phoneNumber: string; + password: string; + confirmPassword: string; +} + +export function RegisterForm({ onSubmit, isLoading }: RegisterFormProps) { + const t = useTranslations(""); + const language = useLanguage(); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + + const registerSchema = z + .object({ + fullName: z.string().min(2, { message: t("fullNameRequired") }), + email: z.string().email({ message: t("invalidEmail") }), + phoneNumber: z + .string() + .min(10, { message: t("phoneNumberTooShort") }) + .regex(/^[0-9+\-\s()]*$/, { message: t("invalidPhoneNumber") }), + password: z.string().min(8, { message: t("passwordTooShort") }), + confirmPassword: z + .string() + .min(1, { message: t("confirmPasswordRequired") }), + }) + .refine((data) => data.password === data.confirmPassword, { + message: t("passwordMismatch"), + path: ["confirmPassword"], + }); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(registerSchema), + defaultValues: { + fullName: "", + email: "", + phoneNumber: "", + password: "", + confirmPassword: "", + }, + }); + + return ( +
+
+
+ + {errors.fullName && ( +

+ {errors.fullName.message} +

+ )} +
+ +
+ + {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+ + {errors.phoneNumber && ( +

+ {errors.phoneNumber.message} +

+ )} +
+ +
+
+ + +
+ {errors.password && ( +

+ {errors.password.message} +

+ )} +
+ +
+
+ + +
+ {errors.confirmPassword && ( +

+ {errors.confirmPassword.message} +

+ )} +
+ + +
+
+ ); +} diff --git a/app/(pages)/register/components/RegisterPresentational.tsx b/app/(pages)/register/components/RegisterPresentational.tsx new file mode 100644 index 0000000..2a8af06 --- /dev/null +++ b/app/(pages)/register/components/RegisterPresentational.tsx @@ -0,0 +1,69 @@ +"use client"; + +import Link from "next/link"; +import { RegisterForm, RegisterFormData } from "./RegisterForm"; +import { GoogleLoginButton } from "../../../../components/common/GoogleLoginButton"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +export interface RegisterPresentationalProps { + isLoading: boolean; + handleRegister: (data: RegisterFormData) => Promise; + handleGoogleRegister: () => Promise; + t: (key: string) => string; +} + +export function RegisterPresentational({ + isLoading, + handleRegister, + handleGoogleRegister, + t, +}: RegisterPresentationalProps) { + return ( +
+ + + + {t("createAccount")} + + + + + +
+
+ +
+
+ + {t("continueWith")} + +
+
+ + +
+ +
+ {t("alreadyHaveAccount")}{" "} + + {t("signIn")} + +
+
+
+
+ ); +} diff --git a/app/(pages)/register/page.tsx b/app/(pages)/register/page.tsx new file mode 100644 index 0000000..2e9d553 --- /dev/null +++ b/app/(pages)/register/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { RegisterContainer } from "./components/RegisterContainer"; +import { RegisterPresentational } from "./components/RegisterPresentational"; + +export default function RegisterPage() { + return ( + + {(props) => } + + ); +} diff --git a/app/(pages)/register/services/authService.ts b/app/(pages)/register/services/authService.ts new file mode 100644 index 0000000..e69de29 diff --git a/app/(pages)/register/types/authTypes.ts b/app/(pages)/register/types/authTypes.ts new file mode 100644 index 0000000..e69de29 diff --git a/app/globals.css b/app/globals.css index 3ce5fa5..05c2f92 100644 --- a/app/globals.css +++ b/app/globals.css @@ -4,96 +4,111 @@ @custom-variant dark (&:is(.dark *)); :root { - --background: oklch(0.98 0 0); - --foreground: oklch(0.21 0.03 263.61); - --card: oklch(1.00 0 0); - --card-foreground: oklch(0.21 0.03 263.61); - --popover: oklch(1.00 0 0); - --popover-foreground: oklch(0.21 0.03 263.61); - --primary: oklch(0.48 0.20 260.47); - --primary-foreground: oklch(1.00 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.37 0.03 259.73); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.55 0.02 264.41); - --accent: oklch(0.95 0.02 260.18); - --accent-foreground: oklch(0.48 0.20 260.47); - --destructive: oklch(0.58 0.22 27.29); - --border: oklch(0.93 0.01 261.82); - --input: oklch(0.93 0.01 261.82); - --ring: oklch(0.48 0.20 260.47); - --chart-1: oklch(0.48 0.20 260.47); - --chart-2: oklch(0.56 0.24 260.92); - --chart-3: oklch(0.40 0.16 259.61); - --chart-4: oklch(0.43 0.16 259.82); - --chart-5: oklch(0.29 0.07 261.20); - --sidebar: oklch(0.97 0 0); - --sidebar-foreground: oklch(0.21 0.03 263.61); - --sidebar-primary: oklch(0.48 0.20 260.47); - --sidebar-primary-foreground: oklch(1.00 0 0); - --sidebar-accent: oklch(0.95 0.02 260.18); - --sidebar-accent-foreground: oklch(0.48 0.20 260.47); - --sidebar-border: oklch(0.93 0.01 261.82); - --sidebar-ring: oklch(0.48 0.20 260.47); - + --background: oklch(0.9730 0.0133 286.1503); + --foreground: oklch(0.3015 0.0572 282.4176); + --card: oklch(1.0000 0 0); + --card-foreground: oklch(0.3015 0.0572 282.4176); + --popover: oklch(1.0000 0 0); + --popover-foreground: oklch(0.3015 0.0572 282.4176); + --primary: oklch(0.5417 0.1790 288.0332); + --primary-foreground: oklch(1.0000 0 0); + --secondary: oklch(0.9174 0.0435 292.6901); + --secondary-foreground: oklch(0.4143 0.1039 288.1742); + --muted: oklch(0.9580 0.0133 286.1454); + --muted-foreground: oklch(0.5426 0.0465 284.7435); + --accent: oklch(0.9221 0.0373 262.1410); + --accent-foreground: oklch(0.3015 0.0572 282.4176); + --destructive: oklch(0.6861 0.2061 14.9941); + --destructive-foreground: oklch(1.0000 0 0); + --border: oklch(0.9115 0.0216 285.9625); + --input: oklch(0.9115 0.0216 285.9625); + --ring: oklch(0.5417 0.1790 288.0332); + --chart-1: oklch(0.5417 0.1790 288.0332); + --chart-2: oklch(0.7042 0.1602 288.9880); + --chart-3: oklch(0.5679 0.2113 276.7065); + --chart-4: oklch(0.6356 0.1922 281.8054); + --chart-5: oklch(0.4509 0.1758 279.3838); + --sidebar: oklch(0.9580 0.0133 286.1454); + --sidebar-foreground: oklch(0.3015 0.0572 282.4176); + --sidebar-primary: oklch(0.5417 0.1790 288.0332); + --sidebar-primary-foreground: oklch(1.0000 0 0); + --sidebar-accent: oklch(0.9221 0.0373 262.1410); + --sidebar-accent-foreground: oklch(0.3015 0.0572 282.4176); + --sidebar-border: oklch(0.9115 0.0216 285.9625); + --sidebar-ring: oklch(0.5417 0.1790 288.0332); --font-sans: Inter, sans-serif; - --font-serif: Source Serif 4, serif; - --font-mono: IBM Plex Mono, monospace; - - --radius: 0.375rem; - - --shadow-2xs: 0 1px 3px 0px oklch(0.00 0 0 / 0.05); - --shadow-xs: 0 1px 3px 0px oklch(0.00 0 0 / 0.05); - --shadow-sm: 0 1px 3px 0px oklch(0.00 0 0 / 0.10), 0 1px 2px -1px oklch(0.00 0 0 / 0.10); - --shadow: 0 1px 3px 0px oklch(0.00 0 0 / 0.10), 0 1px 2px -1px oklch(0.00 0 0 / 0.10); - --shadow-md: 0 1px 3px 0px oklch(0.00 0 0 / 0.10), 0 2px 4px -1px oklch(0.00 0 0 / 0.10); - --shadow-lg: 0 1px 3px 0px oklch(0.00 0 0 / 0.10), 0 4px 6px -1px oklch(0.00 0 0 / 0.10); - --shadow-xl: 0 1px 3px 0px oklch(0.00 0 0 / 0.10), 0 8px 10px -1px oklch(0.00 0 0 / 0.10); - --shadow-2xl: 0 1px 3px 0px oklch(0.00 0 0 / 0.25); - + --font-serif: Georgia, serif; + --font-mono: JetBrains Mono, monospace; + --radius: 0.5rem; + --shadow-x: 0px; + --shadow-y: 4px; + --shadow-blur: 10px; + --shadow-spread: 0px; + --shadow-opacity: 0.12; + --shadow-color: hsl(240 30% 25%); + --shadow-2xs: 0px 4px 10px 0px hsl(240 30% 25% / 0.06); + --shadow-xs: 0px 4px 10px 0px hsl(240 30% 25% / 0.06); + --shadow-sm: 0px 4px 10px 0px hsl(240 30% 25% / 0.12), 0px 1px 2px -1px hsl(240 30% 25% / 0.12); + --shadow: 0px 4px 10px 0px hsl(240 30% 25% / 0.12), 0px 1px 2px -1px hsl(240 30% 25% / 0.12); + --shadow-md: 0px 4px 10px 0px hsl(240 30% 25% / 0.12), 0px 2px 4px -1px hsl(240 30% 25% / 0.12); + --shadow-lg: 0px 4px 10px 0px hsl(240 30% 25% / 0.12), 0px 4px 6px -1px hsl(240 30% 25% / 0.12); + --shadow-xl: 0px 4px 10px 0px hsl(240 30% 25% / 0.12), 0px 8px 10px -1px hsl(240 30% 25% / 0.12); + --shadow-2xl: 0px 4px 10px 0px hsl(240 30% 25% / 0.30); + --tracking-normal: 0em; + --spacing: 0.25rem; } .dark { - --background: oklch(0.26 0.03 262.67); - --foreground: oklch(0.93 0.01 261.82); - --card: oklch(0.30 0.03 260.51); - --card-foreground: oklch(0.93 0.01 261.82); - --popover: oklch(0.30 0.03 260.51); - --popover-foreground: oklch(0.93 0.01 261.82); - --primary: oklch(0.56 0.24 260.92); - --primary-foreground: oklch(1.00 0 0); - --secondary: oklch(0.35 0.04 261.40); - --secondary-foreground: oklch(0.93 0.01 261.82); - --muted: oklch(0.30 0.03 260.51); - --muted-foreground: oklch(0.71 0.02 261.33); - --accent: oklch(0.33 0.04 264.63); - --accent-foreground: oklch(0.93 0.01 261.82); - --destructive: oklch(0.64 0.21 25.39); - --border: oklch(0.35 0.04 261.40); - --input: oklch(0.35 0.04 261.40); - --ring: oklch(0.56 0.24 260.92); - --chart-1: oklch(0.56 0.24 260.92); - --chart-2: oklch(0.48 0.20 260.47); - --chart-3: oklch(0.69 0.17 255.59); - --chart-4: oklch(0.43 0.16 259.82); - --chart-5: oklch(0.29 0.07 261.20); - --sidebar: oklch(0.26 0.03 262.67); - --sidebar-foreground: oklch(0.93 0.01 261.82); - --sidebar-primary: oklch(0.56 0.24 260.92); - --sidebar-primary-foreground: oklch(1.00 0 0); - --sidebar-accent: oklch(0.33 0.04 264.63); - --sidebar-accent-foreground: oklch(0.93 0.01 261.82); - --sidebar-border: oklch(0.35 0.04 261.40); - --sidebar-ring: oklch(0.56 0.24 260.92); - - --shadow-2xs: 0 1px 3px 0px oklch(0.00 0 0 / 0.05); - --shadow-xs: 0 1px 3px 0px oklch(0.00 0 0 / 0.05); - --shadow-sm: 0 1px 3px 0px oklch(0.00 0 0 / 0.10), 0 1px 2px -1px oklch(0.00 0 0 / 0.10); - --shadow: 0 1px 3px 0px oklch(0.00 0 0 / 0.10), 0 1px 2px -1px oklch(0.00 0 0 / 0.10); - --shadow-md: 0 1px 3px 0px oklch(0.00 0 0 / 0.10), 0 2px 4px -1px oklch(0.00 0 0 / 0.10); - --shadow-lg: 0 1px 3px 0px oklch(0.00 0 0 / 0.10), 0 4px 6px -1px oklch(0.00 0 0 / 0.10); - --shadow-xl: 0 1px 3px 0px oklch(0.00 0 0 / 0.10), 0 8px 10px -1px oklch(0.00 0 0 / 0.10); - --shadow-2xl: 0 1px 3px 0px oklch(0.00 0 0 / 0.25); + --background: oklch(0.1743 0.0227 283.7998); + --foreground: oklch(0.9185 0.0257 285.8834); + --card: oklch(0.2284 0.0384 282.9324); + --card-foreground: oklch(0.9185 0.0257 285.8834); + --popover: oklch(0.2284 0.0384 282.9324); + --popover-foreground: oklch(0.9185 0.0257 285.8834); + --primary: oklch(0.7162 0.1597 290.3962); + --primary-foreground: oklch(0.1743 0.0227 283.7998); + --secondary: oklch(0.3139 0.0736 283.4591); + --secondary-foreground: oklch(0.8367 0.0849 285.9111); + --muted: oklch(0.2710 0.0621 281.4377); + --muted-foreground: oklch(0.7166 0.0462 285.1741); + --accent: oklch(0.3354 0.0828 280.9705); + --accent-foreground: oklch(0.9185 0.0257 285.8834); + --destructive: oklch(0.6861 0.2061 14.9941); + --destructive-foreground: oklch(1.0000 0 0); + --border: oklch(0.3261 0.0597 282.5832); + --input: oklch(0.3261 0.0597 282.5832); + --ring: oklch(0.7162 0.1597 290.3962); + --chart-1: oklch(0.7162 0.1597 290.3962); + --chart-2: oklch(0.6382 0.1047 274.9117); + --chart-3: oklch(0.7482 0.1235 244.7492); + --chart-4: oklch(0.7124 0.0977 186.6761); + --chart-5: oklch(0.7546 0.1831 346.8124); + --sidebar: oklch(0.2284 0.0384 282.9324); + --sidebar-foreground: oklch(0.9185 0.0257 285.8834); + --sidebar-primary: oklch(0.7162 0.1597 290.3962); + --sidebar-primary-foreground: oklch(0.1743 0.0227 283.7998); + --sidebar-accent: oklch(0.3354 0.0828 280.9705); + --sidebar-accent-foreground: oklch(0.9185 0.0257 285.8834); + --sidebar-border: oklch(0.3261 0.0597 282.5832); + --sidebar-ring: oklch(0.7162 0.1597 290.3962); + --font-sans: Inter, sans-serif; + --font-serif: Georgia, serif; + --font-mono: JetBrains Mono, monospace; + --radius: 0.5rem; + --shadow-x: 0px; + --shadow-y: 4px; + --shadow-blur: 10px; + --shadow-spread: 0px; + --shadow-opacity: 0.12; + --shadow-color: hsl(240 30% 25%); + --shadow-2xs: 0px 4px 10px 0px hsl(240 30% 25% / 0.06); + --shadow-xs: 0px 4px 10px 0px hsl(240 30% 25% / 0.06); + --shadow-sm: 0px 4px 10px 0px hsl(240 30% 25% / 0.12), 0px 1px 2px -1px hsl(240 30% 25% / 0.12); + --shadow: 0px 4px 10px 0px hsl(240 30% 25% / 0.12), 0px 1px 2px -1px hsl(240 30% 25% / 0.12); + --shadow-md: 0px 4px 10px 0px hsl(240 30% 25% / 0.12), 0px 2px 4px -1px hsl(240 30% 25% / 0.12); + --shadow-lg: 0px 4px 10px 0px hsl(240 30% 25% / 0.12), 0px 4px 6px -1px hsl(240 30% 25% / 0.12); + --shadow-xl: 0px 4px 10px 0px hsl(240 30% 25% / 0.12), 0px 8px 10px -1px hsl(240 30% 25% / 0.12); + --shadow-2xl: 0px 4px 10px 0px hsl(240 30% 25% / 0.30); } @theme inline { @@ -112,6 +127,7 @@ --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); diff --git a/app/login/components/LoginForm.tsx b/app/login/components/LoginForm.tsx deleted file mode 100644 index 1f9dfb1..0000000 --- a/app/login/components/LoginForm.tsx +++ /dev/null @@ -1,119 +0,0 @@ -"use client"; - -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import * as z from "zod"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { useTranslations } from "next-intl"; -import { useState } from "react"; -import { Eye, EyeOff, Loader2 } from "lucide-react"; - -interface LoginFormProps { - onSubmit: (data: LoginFormData) => Promise; - isLoading: boolean; -} - -export interface LoginFormData { - emailOrUsername: string; - password: string; - rememberMe?: boolean; -} - -export function LoginForm({ onSubmit, isLoading }: LoginFormProps) { - const t = useTranslations(""); - const [showPassword, setShowPassword] = useState(false); - - const loginSchema = z.object({ - emailOrUsername: z.string().min(1, { message: t("emailRequired") }), - password: z.string().min(8, { message: t("passwordTooShort") }), - rememberMe: z.boolean().optional().default(false), - }); - - const { - register, - handleSubmit, - formState: { errors }, - } = useForm({ - resolver: zodResolver(loginSchema), - defaultValues: { - emailOrUsername: "", - password: "", - rememberMe: false, - }, - }); - - return ( -
-
- - - {errors.emailOrUsername && ( -

- {errors.emailOrUsername.message} -

- )} -
- -
- -
- - -
- {errors.password && ( -

{errors.password.message}

- )} -
- - - - - - -
- ); -} diff --git a/app/login/components/GoogleLoginButton.tsx b/components/common/GoogleLoginButton.tsx similarity index 70% rename from app/login/components/GoogleLoginButton.tsx rename to components/common/GoogleLoginButton.tsx index ef691f1..918cfd9 100644 --- a/app/login/components/GoogleLoginButton.tsx +++ b/components/common/GoogleLoginButton.tsx @@ -7,22 +7,24 @@ interface GoogleLoginButtonProps { onClick: () => void; isLoading?: boolean; text: string; + iconSize?: number; } export function GoogleLoginButton({ onClick, isLoading, text, + iconSize = 4, }: GoogleLoginButtonProps) { return ( ); diff --git a/components/ui/input.tsx b/components/ui/input.tsx index 611b65d..fb6c679 100644 --- a/components/ui/input.tsx +++ b/components/ui/input.tsx @@ -8,7 +8,7 @@ const Input = React.forwardRef>( Date: Thu, 30 Oct 2025 16:04:51 +0300 Subject: [PATCH 03/78] update the toast close icon dir to match the selected lang --- components/ui/toast.tsx | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index 6905cf3..4b0d016 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -4,6 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { X } from "lucide-react"; import { cn } from "@/lib/utils"; +import { useLanguage } from "@/contexts/LanguageProvider"; const ToastProvider = ToastPrimitives.Provider; @@ -71,19 +72,24 @@ ToastAction.displayName = ToastPrimitives.Action.displayName; const ToastClose = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - -)); +>(({ className, ...props }, ref) => { + const { direction } = useLanguage(); + + return ( + + + + ); +}); ToastClose.displayName = ToastPrimitives.Close.displayName; const ToastTitle = React.forwardRef< From 990a9d0ada9cd6e119a4bbca3d94920c871f411e Mon Sep 17 00:00:00 2001 From: kareem Date: Thu, 30 Oct 2025 16:12:52 +0300 Subject: [PATCH 04/78] update the context code --- contexts/LanguageProvider.tsx | 5 ----- contexts/ThemeProvider.tsx | 1 - 2 files changed, 6 deletions(-) diff --git a/contexts/LanguageProvider.tsx b/contexts/LanguageProvider.tsx index 0273258..9c9337d 100644 --- a/contexts/LanguageProvider.tsx +++ b/contexts/LanguageProvider.tsx @@ -28,18 +28,13 @@ export function LanguageProvider({ const direction = locale === "ar" ? "rtl" : "ltr"; useEffect(() => { - // Update document direction and lang document.documentElement.dir = direction; document.documentElement.lang = locale; }, [locale, direction]); const setLocale = async (newLocale: Locale) => { setLocaleState(newLocale); - - // Set cookie for persistence document.cookie = `NEXT_LOCALE=${newLocale}; path=/; max-age=31536000; SameSite=Lax`; - - // Refresh to apply new locale (without changing URL) router.refresh(); }; diff --git a/contexts/ThemeProvider.tsx b/contexts/ThemeProvider.tsx index c42f027..f4d3afa 100644 --- a/contexts/ThemeProvider.tsx +++ b/contexts/ThemeProvider.tsx @@ -18,7 +18,6 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { useEffect(() => { setMounted(true); - // Get theme from localStorage or system preference const savedTheme = localStorage.getItem("theme") as Theme; const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") .matches From e3bc1abbcd1f9e644e4d6988acfb06c10cdd80b1 Mon Sep 17 00:00:00 2001 From: kareem Date: Tue, 4 Nov 2025 20:20:21 +0200 Subject: [PATCH 05/78] Update the Setup and the Folder Stracture --- app/(pages)/login/page.tsx | 4 +- app/(pages)/login/services/authService.ts | 0 app/(pages)/register/page.tsx | 4 +- app/(pages)/register/services/authService.ts | 0 app/layout.tsx | 19 +- components/common/ThemeSwitcher.tsx | 1 - components/ui/toaster.tsx | 2 +- contexts/QueryProvider.tsx | 37 +++ .../login/components/LoginContainer.tsx | 6 +- .../login/components/LoginPresentational.tsx | 4 +- .../login/components/loginForm}/LoginForm.tsx | 19 +- .../login/components/loginForm/LoginSchema.ts | 9 + .../login/types/authTypes.ts | 0 .../register/components/RegisterContainer.tsx | 4 +- .../components/RegisterPresentational.tsx | 4 +- .../components/registerForm}/RegisterForm.tsx | 22 +- .../components/registerForm/registerSchema.ts | 21 ++ .../register/types/authTypes.ts | 0 hooks/{use-toast.ts => useToast.ts} | 2 +- i18n.ts | 12 +- lib/apiClient.ts | 215 ++++++++---------- lib/tokenManager.ts | 24 ++ {messages => locales}/ar/auth.json | 0 {messages => locales}/ar/common.json | 0 {messages => locales}/ar/dashboard.json | 0 {messages => locales}/ar/patients.json | 0 {messages => locales}/ar/records.json | 0 {messages => locales}/ar/settings.json | 0 {messages => locales}/en/auth.json | 0 {messages => locales}/en/common.json | 0 {messages => locales}/en/dashboard.json | 0 {messages => locales}/en/patients.json | 0 {messages => locales}/en/records.json | 0 {messages => locales}/en/settings.json | 0 34 files changed, 222 insertions(+), 187 deletions(-) delete mode 100644 app/(pages)/login/services/authService.ts delete mode 100644 app/(pages)/register/services/authService.ts create mode 100644 contexts/QueryProvider.tsx rename {app/(pages) => features}/login/components/LoginContainer.tsx (93%) rename {app/(pages) => features}/login/components/LoginPresentational.tsx (93%) rename {app/(pages)/login/components => features/login/components/loginForm}/LoginForm.tsx (86%) create mode 100644 features/login/components/loginForm/LoginSchema.ts rename {app/(pages) => features}/login/types/authTypes.ts (100%) rename {app/(pages) => features}/register/components/RegisterContainer.tsx (93%) rename {app/(pages) => features}/register/components/RegisterPresentational.tsx (92%) rename {app/(pages)/register/components => features/register/components/registerForm}/RegisterForm.tsx (88%) create mode 100644 features/register/components/registerForm/registerSchema.ts rename {app/(pages) => features}/register/types/authTypes.ts (100%) rename hooks/{use-toast.ts => useToast.ts} (99%) create mode 100644 lib/tokenManager.ts rename {messages => locales}/ar/auth.json (100%) rename {messages => locales}/ar/common.json (100%) rename {messages => locales}/ar/dashboard.json (100%) rename {messages => locales}/ar/patients.json (100%) rename {messages => locales}/ar/records.json (100%) rename {messages => locales}/ar/settings.json (100%) rename {messages => locales}/en/auth.json (100%) rename {messages => locales}/en/common.json (100%) rename {messages => locales}/en/dashboard.json (100%) rename {messages => locales}/en/patients.json (100%) rename {messages => locales}/en/records.json (100%) rename {messages => locales}/en/settings.json (100%) diff --git a/app/(pages)/login/page.tsx b/app/(pages)/login/page.tsx index f027ec4..1f5d71c 100644 --- a/app/(pages)/login/page.tsx +++ b/app/(pages)/login/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { LoginContainer } from "./components/LoginContainer"; -import { LoginPresentational } from "./components/LoginPresentational"; +import { LoginContainer } from "@/features/login/components/LoginContainer"; +import { LoginPresentational } from "@/features/login/components/LoginPresentational"; export default function LoginPage() { return ( diff --git a/app/(pages)/login/services/authService.ts b/app/(pages)/login/services/authService.ts deleted file mode 100644 index e69de29..0000000 diff --git a/app/(pages)/register/page.tsx b/app/(pages)/register/page.tsx index 2e9d553..ac64c6b 100644 --- a/app/(pages)/register/page.tsx +++ b/app/(pages)/register/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { RegisterContainer } from "./components/RegisterContainer"; -import { RegisterPresentational } from "./components/RegisterPresentational"; +import { RegisterContainer } from "@/features/register/components/RegisterContainer"; +import { RegisterPresentational } from "@/features/register/components/RegisterPresentational"; export default function RegisterPage() { return ( diff --git a/app/(pages)/register/services/authService.ts b/app/(pages)/register/services/authService.ts deleted file mode 100644 index e69de29..0000000 diff --git a/app/layout.tsx b/app/layout.tsx index bd164e1..80cb2f8 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,6 +5,7 @@ import { getMessages } from "next-intl/server"; import { cookies } from "next/headers"; import { ThemeProvider } from "@/contexts/ThemeProvider"; import { LanguageProvider } from "@/contexts/LanguageProvider"; +import { QueryProvider } from "@/contexts/QueryProvider"; import { Toaster } from "@/components/ui/toaster"; import "./globals.css"; @@ -41,14 +42,16 @@ export default async function RootLayout({ - - - - {children} - - - - + + + + + {children} + + + + + ); diff --git a/components/common/ThemeSwitcher.tsx b/components/common/ThemeSwitcher.tsx index c77605c..7a1d994 100644 --- a/components/common/ThemeSwitcher.tsx +++ b/components/common/ThemeSwitcher.tsx @@ -12,7 +12,6 @@ export function ThemeSwitcher() { setMounted(true); }, []); - // Prevent hydration mismatch by not rendering until mounted if (!mounted) { return (
{errors.password && ( -

{errors.password.message}

+

+ {errors.password.message} +

)}
-
+
+
+ + +
+ + {t("forgotPassword")} + +
+ - -
); } diff --git a/features/register/components/registerForm/RegisterForm.tsx b/features/register/components/registerForm/RegisterForm.tsx index 873d8ec..dc62d3f 100644 --- a/features/register/components/registerForm/RegisterForm.tsx +++ b/features/register/components/registerForm/RegisterForm.tsx @@ -4,6 +4,7 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { Eye, EyeOff, Loader2 } from "lucide-react"; @@ -21,6 +22,7 @@ export interface RegisterFormData { phoneNumber: string; password: string; confirmPassword: string; + rememberMe?: boolean; } export function RegisterForm({ onSubmit, isLoading }: RegisterFormProps) { @@ -41,6 +43,7 @@ export function RegisterForm({ onSubmit, isLoading }: RegisterFormProps) { phoneNumber: "", password: "", confirmPassword: "", + rememberMe: false, }, }); @@ -163,6 +166,20 @@ export function RegisterForm({ onSubmit, isLoading }: RegisterFormProps) { )}
+
+ + +
+ + + ); +} diff --git a/features/auth/complete-profile/components/completeProfileForm/CompleteProfileSchema.ts b/features/auth/complete-profile/components/completeProfileForm/CompleteProfileSchema.ts new file mode 100644 index 0000000..2fd7931 --- /dev/null +++ b/features/auth/complete-profile/components/completeProfileForm/CompleteProfileSchema.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; + +export const createCompleteProfileSchema = ( + t: (key: string) => string, + requirePhone: boolean = false +) => { + const baseSchema = { + gender: z.enum(["MALE", "FEMALE"], { + message: t("genderRequired"), + }), + date_of_birth: z.string().min(1, t("dateOfBirthRequired")), + }; + + if (requirePhone) { + return z.object({ + phone: z + .string() + .min(1, t("phoneNumberRequired")) + .length(11, t("phoneNumberMustBe11Digits")) + .regex(/^(010|011|012|015)\d{8}$/, t("invalidPhoneNumberFormat")), + ...baseSchema, + }); + } + + return z.object(baseSchema); +}; + +export type CompleteProfileFormData = z.infer< + ReturnType +>; diff --git a/features/auth/complete-profile/index.ts b/features/auth/complete-profile/index.ts new file mode 100644 index 0000000..c200043 --- /dev/null +++ b/features/auth/complete-profile/index.ts @@ -0,0 +1,5 @@ +export { CompleteProfileContainer } from "./components/CompleteProfileContainer"; +export { CompleteProfilePresentational } from "./components/CompleteProfilePresentational"; +export * from "./api/completeProfile.api"; +export * from "./query/useCompleteProfile.query"; +export * from "./types/completeProfileTypes"; diff --git a/features/auth/complete-profile/query/useCompleteProfile.query.ts b/features/auth/complete-profile/query/useCompleteProfile.query.ts new file mode 100644 index 0000000..1b50801 --- /dev/null +++ b/features/auth/complete-profile/query/useCompleteProfile.query.ts @@ -0,0 +1,141 @@ +import { useMutation, UseMutationResult } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import { + completeProfile, + updateGoogleUserPhone, +} from "../api/completeProfile.api"; +import { + CompleteProfileRequest, + CompleteProfileResponse, + UpdateGoogleUserPhoneRequest, + UpdateGoogleUserPhoneResponse, + ApiError, +} from "../types/completeProfileTypes"; +import { toast } from "@/hooks/useToast"; +import { useLanguage } from "@/contexts/LanguageProvider"; +import { useRouter } from "next/navigation"; +import { useUserStore } from "@/stores/useUserStore"; + +export const useCompleteProfile = (): UseMutationResult< + CompleteProfileResponse, + AxiosError, + CompleteProfileRequest +> => { + const { locale } = useLanguage(); + const router = useRouter(); + const updateUser = useUserStore((state) => state.updateUser); + const setUser = useUserStore((state) => state.setUser); + const user = useUserStore((state) => state.user); + + return useMutation({ + mutationFn: completeProfile, + onSuccess: (data) => { + if (data.data) { + if (user) { + updateUser({ + gender: data.data.gender, + date_of_birth: data.data.date_of_birth, + isVerified: data.data.isVerified, + phone: data.data.phone || user.phone, + email: data.data.email || user.email, + name: data.data.name || user.name, + username: data.data.username || user.username, + }); + + } else { + const currentUser = useUserStore.getState().user; + + setUser({ + ...data.data, + email: data.data.email || currentUser?.email || "", + name: data.data.name || currentUser?.name || "", + phone: data.data.phone || currentUser?.phone || "", + username: data.data.username || currentUser?.username, + id: data.data.id || currentUser?.id, + }); + } + } + + const successMessage = + locale === "ar" + ? data.messageAr || "تم إكمال الملف الشخصي بنجاح" + : data.messageEn || "Profile completed successfully"; + + toast({ + title: locale === "ar" ? "نجح" : "Success", + description: successMessage, + }); + + router.push("/dashboard"); + }, + onError: (error) => { + const errorData = error.response?.data; + const errorMessage = + locale === "ar" + ? errorData?.messageAr || "حدث خطأ أثناء إكمال الملف الشخصي" + : errorData?.messageEn || + "An error occurred while completing profile"; + + toast({ + variant: "destructive", + title: locale === "ar" ? "خطأ" : "Error", + description: errorMessage, + }); + + if (process.env.NODE_ENV === "development") { + console.error("Complete Profile Error:", error); + } + }, + }); +}; + +export const useUpdateGoogleUserPhone = (): UseMutationResult< + UpdateGoogleUserPhoneResponse, + AxiosError, + UpdateGoogleUserPhoneRequest +> => { + const { locale } = useLanguage(); + const updateUser = useUserStore((state) => state.updateUser); + const user = useUserStore((state) => state.user); + + return useMutation({ + mutationFn: updateGoogleUserPhone, + onSuccess: (data, variables) => { + if (user) { + updateUser({ phone: variables.phone }); + } else { + console.warn( + "User is null" + ); + } + + const successMessage = + locale === "ar" + ? data.messageAr || "تم تحديث رقم الهاتف بنجاح" + : data.messageEn || "Phone number updated successfully"; + + toast({ + title: locale === "ar" ? "نجح" : "Success", + description: successMessage, + }); + }, + onError: (error) => { + const errorData = error.response?.data; + const errorMessage = + locale === "ar" + ? errorData?.messageAr || "حدث خطأ أثناء تحديث رقم الهاتف" + : errorData?.messageEn || + "An error occurred while updating phone number"; + + toast({ + variant: "destructive", + title: locale === "ar" ? "خطأ" : "Error", + description: errorMessage, + }); + + if (process.env.NODE_ENV === "development") { + console.error("Update Phone Error:", error); + } + }, + }); +}; diff --git a/features/auth/complete-profile/types/completeProfileTypes.ts b/features/auth/complete-profile/types/completeProfileTypes.ts new file mode 100644 index 0000000..da08a3f --- /dev/null +++ b/features/auth/complete-profile/types/completeProfileTypes.ts @@ -0,0 +1,39 @@ +export interface CompleteProfileRequest { + gender: "MALE" | "FEMALE"; + date_of_birth: string; +} + +export interface CompleteProfileResponse { + data: User; + message: string; + messageEn?: string; + messageAr?: string; +} + +export interface UpdateGoogleUserPhoneRequest { + phone: string; +} + +export interface UpdateGoogleUserPhoneResponse { + message: string; + messageEn?: string; + messageAr?: string; +} + +export interface User { + id?: string; + email: string; + name: string; + username?: string; + phone?: string | null; + gender?: "MALE" | "FEMALE" | null; + date_of_birth?: string | null; + isVerified: boolean; + created_at?: string; + updated_at?: string; +} + +export interface ApiError { + messageEn: string; + messageAr: string; +} diff --git a/features/auth/google-auth/api/googleAuth.api.ts b/features/auth/google-auth/api/googleAuth.api.ts new file mode 100644 index 0000000..08e1a59 --- /dev/null +++ b/features/auth/google-auth/api/googleAuth.api.ts @@ -0,0 +1,17 @@ +import { api } from "@/lib/apiClient"; +import { + UpdatePhoneRequest, + UpdatePhoneResponse, +} from "../types/googleAuthTypes"; + +export const initiateGoogleAuth = (): void => { + const API_BASE_URL = + process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:3000"; + window.location.href = `${API_BASE_URL}/auth/google`; +}; + +export const updateGoogleUserPhone = async ( + data: UpdatePhoneRequest +): Promise => { + return api.patch("/auth/google/update-phone", data); +}; diff --git a/features/auth/google-auth/index.ts b/features/auth/google-auth/index.ts new file mode 100644 index 0000000..79440c3 --- /dev/null +++ b/features/auth/google-auth/index.ts @@ -0,0 +1,3 @@ +export * from "./types/googleAuthTypes"; +export * from "./api/googleAuth.api"; +export * from "./query/useGoogleAuth.query"; diff --git a/features/auth/google-auth/query/useGoogleAuth.query.ts b/features/auth/google-auth/query/useGoogleAuth.query.ts new file mode 100644 index 0000000..1bef518 --- /dev/null +++ b/features/auth/google-auth/query/useGoogleAuth.query.ts @@ -0,0 +1,62 @@ +import { useMutation, UseMutationResult } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import { updateGoogleUserPhone } from "../api/googleAuth.api"; +import { + UpdatePhoneRequest, + UpdatePhoneResponse, + ApiError, +} from "../types/googleAuthTypes"; +import { toast } from "@/hooks/useToast"; +import { useLanguage } from "@/contexts/LanguageProvider"; +import { useRouter } from "next/navigation"; +import { useUserStore } from "@/stores/useUserStore"; + +export const useUpdateGoogleUserPhone = (): UseMutationResult< + UpdatePhoneResponse, + AxiosError, + UpdatePhoneRequest +> => { + const { locale } = useLanguage(); + const router = useRouter(); + const setUser = useUserStore((state) => state.setUser); + + return useMutation({ + mutationFn: updateGoogleUserPhone, + onSuccess: (data) => { + if (data.data) { + setUser(data.data); + } + + const successMessage = + data.message || + (locale === "ar" + ? data.messageAr || "تم تحديث رقم الهاتف بنجاح" + : data.messageEn || "Phone number updated successfully"); + + toast({ + title: locale === "ar" ? "نجح" : "Success", + description: successMessage, + }); + + router.push("/dashboard"); + }, + onError: (error) => { + const errorData = error.response?.data; + const errorMessage = + locale === "ar" + ? errorData?.messageAr || "حدث خطأ أثناء تحديث رقم الهاتف" + : errorData?.messageEn || + "An error occurred while updating phone number"; + + toast({ + variant: "destructive", + title: locale === "ar" ? "خطأ" : "Error", + description: errorMessage, + }); + + if (process.env.NODE_ENV === "development") { + console.error("Update Phone Error:", error); + } + }, + }); +}; diff --git a/features/auth/google-auth/types/googleAuthTypes.ts b/features/auth/google-auth/types/googleAuthTypes.ts new file mode 100644 index 0000000..e231294 --- /dev/null +++ b/features/auth/google-auth/types/googleAuthTypes.ts @@ -0,0 +1,35 @@ +export interface GoogleAuthResponse { + data?: User; + message?: string; + messageEn?: string; + messageAr?: string; + token?: string; +} + +export interface UpdatePhoneRequest { + phone: string; +} + +export interface UpdatePhoneResponse { + data?: User; + message?: string; + messageEn?: string; + messageAr?: string; +} + +export interface User { + id: string; + email: string; + name: string; + phone?: string | null; + gender?: "MALE" | "FEMALE" | null; + date_of_birth?: string | null; + isVerified: boolean; + created_at: string; + updated_at: string; +} + +export interface ApiError { + messageEn: string; + messageAr: string; +} diff --git a/features/auth/login/api/login.api.ts b/features/auth/login/api/login.api.ts new file mode 100644 index 0000000..ce56964 --- /dev/null +++ b/features/auth/login/api/login.api.ts @@ -0,0 +1,23 @@ +import { api } from "@/lib/apiClient"; +import { + LoginRequest, + LoginResponse, + LogoutResponse, + RefreshTokenResponse, +} from "../types/authTypes"; + +export const loginUser = async ( + credentials: LoginRequest +): Promise => { + return api.post("/auth/login", credentials, { + skipAuth: true, + }); +}; + +export const logoutUser = async (): Promise => { + return api.post("/auth/logout"); +}; + +export const refreshToken = async (): Promise => { + return api.post("/auth/refresh"); +}; diff --git a/features/auth/login/components/LoginContainer.tsx b/features/auth/login/components/LoginContainer.tsx new file mode 100644 index 0000000..2e670f1 --- /dev/null +++ b/features/auth/login/components/LoginContainer.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { LoginFormData } from "./loginForm/LoginForm"; +import { useLogin } from "../query/useLogin.query"; +import { initiateGoogleAuth } from "@/features/auth/google-auth/api/googleAuth.api"; + +export interface LoginContainerProps { + children: (props: LoginPresentationalProps) => React.ReactNode; +} + +export interface LoginPresentationalProps { + isLoading: boolean; + handleLogin: (data: LoginFormData) => Promise; + handleGoogleLogin: () => void; + t: (key: string) => string; +} + +export function LoginContainer({ children }: LoginContainerProps) { + const t = useTranslations(""); + const loginMutation = useLogin(); + + const handleLogin = async (data: LoginFormData) => { + await loginMutation.mutateAsync({ + emailOrUsername: data.emailOrUsername, + password: data.password, + rememberMe: data.rememberMe ?? false, + }); + }; + + const handleGoogleLogin = () => { + initiateGoogleAuth(); + }; + + return ( + <> + {children({ + isLoading: loginMutation.isPending, + handleLogin, + handleGoogleLogin, + t, + })} + + ); +} diff --git a/features/login/components/LoginPresentational.tsx b/features/auth/login/components/LoginPresentational.tsx similarity index 97% rename from features/login/components/LoginPresentational.tsx rename to features/auth/login/components/LoginPresentational.tsx index ded7bfe..5f2df55 100644 --- a/features/login/components/LoginPresentational.tsx +++ b/features/auth/login/components/LoginPresentational.tsx @@ -14,7 +14,7 @@ import { export interface LoginPresentationalProps { isLoading: boolean; handleLogin: (data: LoginFormData) => Promise; - handleGoogleLogin: () => Promise; + handleGoogleLogin: () => void; t: (key: string) => string; } diff --git a/features/login/components/loginForm/LoginForm.tsx b/features/auth/login/components/loginForm/LoginForm.tsx similarity index 100% rename from features/login/components/loginForm/LoginForm.tsx rename to features/auth/login/components/loginForm/LoginForm.tsx diff --git a/features/login/components/loginForm/LoginSchema.ts b/features/auth/login/components/loginForm/LoginSchema.ts similarity index 100% rename from features/login/components/loginForm/LoginSchema.ts rename to features/auth/login/components/loginForm/LoginSchema.ts diff --git a/features/auth/login/index.ts b/features/auth/login/index.ts new file mode 100644 index 0000000..19ffb7d --- /dev/null +++ b/features/auth/login/index.ts @@ -0,0 +1,7 @@ +export * from "./types/authTypes"; +export * from "./api/login.api"; +export * from "./query/useLogin.query"; + +export { LoginContainer } from "./components/LoginContainer"; +export { LoginPresentational } from "./components/LoginPresentational"; +export { LoginForm } from "./components/loginForm/LoginForm"; diff --git a/features/auth/login/query/useLogin.query.ts b/features/auth/login/query/useLogin.query.ts new file mode 100644 index 0000000..6d34dd1 --- /dev/null +++ b/features/auth/login/query/useLogin.query.ts @@ -0,0 +1,140 @@ +import { useMutation, UseMutationResult } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import { loginUser, logoutUser, refreshToken } from "../api/login.api"; +import { + LoginRequest, + LoginResponse, + LogoutResponse, + RefreshTokenResponse, + ApiError, +} from "../types/authTypes"; +import { setAuthToken, removeAuthToken } from "@/lib/tokenManager"; +import { toast } from "@/hooks/useToast"; +import { useLanguage } from "@/contexts/LanguageProvider"; +import { useRouter } from "next/navigation"; +import { useUserStore } from "@/stores/useUserStore"; + +export const useLogin = (): UseMutationResult< + LoginResponse, + AxiosError, + LoginRequest +> => { + const { locale } = useLanguage(); + const router = useRouter(); + const setUser = useUserStore((state) => state.setUser); + + return useMutation({ + mutationFn: loginUser, + onSuccess: (data) => { + if (data.data) { + setUser(data.data); + } else { + console.warn("No Data"); + } + + const successMessage = + data.message || + (locale === "ar" + ? data.messageAr || "تم تسجيل الدخول بنجاح" + : data.messageEn || "Login successful"); + + toast({ + title: locale === "ar" ? "نجح" : "Success", + description: successMessage, + }); + + router.push("/dashboard"); + }, + onError: (error) => { + const errorData = error.response?.data; + const errorMessage = + locale === "ar" + ? errorData?.messageAr || "حدث خطأ أثناء تسجيل الدخول" + : errorData?.messageEn || "An error occurred during login"; + + toast({ + variant: "destructive", + title: locale === "ar" ? "خطأ" : "Error", + description: errorMessage, + }); + + if (process.env.NODE_ENV === "development") { + console.error("Login Error:", error); + } + }, + }); +}; + +export const useLogout = (): UseMutationResult< + LogoutResponse, + AxiosError, + void +> => { + const { locale } = useLanguage(); + const router = useRouter(); + const clearUser = useUserStore((state) => state.clearUser); + + return useMutation({ + mutationFn: logoutUser, + onSuccess: (data) => { + removeAuthToken(); + clearUser(); + + const successMessage = + locale === "ar" + ? data.messageAr || "تم تسجيل الخروج بنجاح" + : data.messageEn || "Logout successful"; + + toast({ + title: locale === "ar" ? "نجح" : "Success", + description: successMessage, + }); + + router.push("/login"); + }, + onError: (error) => { + removeAuthToken(); + clearUser(); + + const errorData = error.response?.data; + const errorMessage = + locale === "ar" + ? errorData?.messageAr || "حدث خطأ أثناء تسجيل الخروج" + : errorData?.messageEn || "An error occurred during logout"; + + toast({ + variant: "destructive", + title: locale === "ar" ? "خطأ" : "Error", + description: errorMessage, + }); + + router.push("/login"); + }, + }); +}; + +export const useRefreshToken = (): UseMutationResult< + RefreshTokenResponse, + AxiosError, + void +> => { + return useMutation({ + mutationFn: refreshToken, + onSuccess: (data) => { + if (data.token) { + setAuthToken(data.token, 7); + } + }, + onError: (error) => { + removeAuthToken(); + + if (typeof window !== "undefined") { + window.location.href = "/login"; + } + + if (process.env.NODE_ENV === "development") { + console.error("Token Refresh Error:", error); + } + }, + }); +}; diff --git a/features/auth/login/types/authTypes.ts b/features/auth/login/types/authTypes.ts new file mode 100644 index 0000000..5e172d8 --- /dev/null +++ b/features/auth/login/types/authTypes.ts @@ -0,0 +1,41 @@ +export interface LoginRequest { + emailOrUsername: string; + password: string; + rememberMe: boolean; +} + +export interface LoginResponse { + data?: User; + message?: string; + messageEn?: string; + messageAr?: string; + token?: string; +} + +export interface LogoutResponse { + messageEn?: string; + messageAr?: string; +} + +export interface RefreshTokenResponse { + messageEn?: string; + messageAr?: string; + token?: string; +} + +export interface User { + id: string; + email: string; + name: string; + phone?: string | null; + gender?: "MALE" | "FEMALE" | null; + date_of_birth?: string | null; + isVerified: boolean; + created_at: string; + updated_at: string; +} + +export interface ApiError { + messageEn: string; + messageAr: string; +} diff --git a/features/auth/password/api/password.api.ts b/features/auth/password/api/password.api.ts new file mode 100644 index 0000000..283aad4 --- /dev/null +++ b/features/auth/password/api/password.api.ts @@ -0,0 +1,23 @@ +import { api } from "@/lib/apiClient"; +import { + ForgetPasswordRequest, + ForgetPasswordResponse, + ResetPasswordRequest, + ResetPasswordResponse, +} from "../types/passwordTypes"; + +export const forgetPassword = async ( + data: ForgetPasswordRequest +): Promise => { + return api.post("/auth/forget-password", data, { + skipAuth: true, + }); +}; + +export const resetPassword = async ( + data: ResetPasswordRequest +): Promise => { + return api.post("/auth/reset-password", data, { + skipAuth: true, + }); +}; diff --git a/features/auth/password/components/ForgotPasswordContainer.tsx b/features/auth/password/components/ForgotPasswordContainer.tsx new file mode 100644 index 0000000..3865d02 --- /dev/null +++ b/features/auth/password/components/ForgotPasswordContainer.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { ForgotPasswordFormData } from "./forgotPasswordForm/ForgotPasswordForm"; +import { useForgetPassword } from "../query/usePassword.query"; + +export interface ForgotPasswordContainerProps { + children: (props: ForgotPasswordPresentationalProps) => React.ReactNode; +} + +export interface ForgotPasswordPresentationalProps { + isLoading: boolean; + handleForgotPassword: (data: ForgotPasswordFormData) => Promise; + t: (key: string) => string; +} + +export function ForgotPasswordContainer({ + children, +}: ForgotPasswordContainerProps) { + const t = useTranslations(""); + const forgetPasswordMutation = useForgetPassword(); + + const handleForgotPassword = async (data: ForgotPasswordFormData) => { + await forgetPasswordMutation.mutateAsync({ + email: data.email, + }); + }; + + return ( + <> + {children({ + isLoading: forgetPasswordMutation.isPending, + handleForgotPassword, + t, + })} + + ); +} diff --git a/features/auth/password/components/ForgotPasswordPresentational.tsx b/features/auth/password/components/ForgotPasswordPresentational.tsx new file mode 100644 index 0000000..0e4f964 --- /dev/null +++ b/features/auth/password/components/ForgotPasswordPresentational.tsx @@ -0,0 +1,57 @@ +"use client"; + +import Link from "next/link"; +import { + ForgotPasswordForm, + ForgotPasswordFormData, +} from "./forgotPasswordForm/ForgotPasswordForm"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { KeyRound } from "lucide-react"; + +export interface ForgotPasswordPresentationalProps { + isLoading: boolean; + handleForgotPassword: (data: ForgotPasswordFormData) => Promise; + t: (key: string) => string; +} + +export function ForgotPasswordPresentational({ + isLoading, + handleForgotPassword, + t, +}: ForgotPasswordPresentationalProps) { + return ( +
+ + +
+ +
+ + {t("forgotPassword")} + + + {t("forgotPasswordDescription")} + +
+ + + + + + {t("backToLogin")} + + +
+
+ ); +} diff --git a/features/auth/password/components/ResetPasswordContainer.tsx b/features/auth/password/components/ResetPasswordContainer.tsx new file mode 100644 index 0000000..622e50b --- /dev/null +++ b/features/auth/password/components/ResetPasswordContainer.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { ResetPasswordFormData } from "./resetPasswordForm/ResetPasswordForm"; +import { useResetPassword } from "../query/usePassword.query"; + +export interface ResetPasswordContainerProps { + children: (props: ResetPasswordPresentationalProps) => React.ReactNode; + token: string; +} + +export interface ResetPasswordPresentationalProps { + isLoading: boolean; + handleResetPassword: (data: ResetPasswordFormData) => Promise; + t: (key: string) => string; +} + +export function ResetPasswordContainer({ + children, + token, +}: ResetPasswordContainerProps) { + const t = useTranslations(""); + const resetPasswordMutation = useResetPassword(); + + const handleResetPassword = async (data: ResetPasswordFormData) => { + await resetPasswordMutation.mutateAsync({ + token, + newPassword: data.newPassword, + }); + }; + + return ( + <> + {children({ + isLoading: resetPasswordMutation.isPending, + handleResetPassword, + t, + })} + + ); +} diff --git a/features/auth/password/components/ResetPasswordPresentational.tsx b/features/auth/password/components/ResetPasswordPresentational.tsx new file mode 100644 index 0000000..edb290b --- /dev/null +++ b/features/auth/password/components/ResetPasswordPresentational.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { + ResetPasswordForm, + ResetPasswordFormData, +} from "./resetPasswordForm/ResetPasswordForm"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Lock } from "lucide-react"; + +export interface ResetPasswordPresentationalProps { + isLoading: boolean; + handleResetPassword: (data: ResetPasswordFormData) => Promise; + t: (key: string) => string; +} + +export function ResetPasswordPresentational({ + isLoading, + handleResetPassword, + t, +}: ResetPasswordPresentationalProps) { + return ( +
+ + +
+ +
+ + {t("resetPassword")} + + + {t("resetPasswordDescription")} + +
+ + + +
+
+ ); +} diff --git a/features/auth/password/components/forgotPasswordForm/ForgotPasswordForm.tsx b/features/auth/password/components/forgotPasswordForm/ForgotPasswordForm.tsx new file mode 100644 index 0000000..37b7176 --- /dev/null +++ b/features/auth/password/components/forgotPasswordForm/ForgotPasswordForm.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useTranslations } from "next-intl"; +import { Loader2, Mail } from "lucide-react"; +import { createForgotPasswordSchema } from "./ForgotPasswordSchema"; + +interface ForgotPasswordFormProps { + onSubmit: (data: ForgotPasswordFormData) => Promise; + isLoading: boolean; +} + +export interface ForgotPasswordFormData { + email: string; +} + +export function ForgotPasswordForm({ + onSubmit, + isLoading, +}: ForgotPasswordFormProps) { + const t = useTranslations(""); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(createForgotPasswordSchema(t)), + defaultValues: { + email: "", + }, + }); + + return ( +
+
+ +
+ + +
+ {errors.email && ( +

{errors.email.message}

+ )} +
+ + +
+ ); +} diff --git a/features/auth/password/components/forgotPasswordForm/ForgotPasswordSchema.ts b/features/auth/password/components/forgotPasswordForm/ForgotPasswordSchema.ts new file mode 100644 index 0000000..100f2cf --- /dev/null +++ b/features/auth/password/components/forgotPasswordForm/ForgotPasswordSchema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const createForgotPasswordSchema = (t: (key: string) => string) => { + return z.object({ + email: z.string().min(1, t("emailRequired")).email(t("invalidEmail")), + }); +}; + +export type ForgotPasswordFormData = z.infer< + ReturnType +>; diff --git a/features/auth/password/components/resetPasswordForm/ResetPasswordForm.tsx b/features/auth/password/components/resetPasswordForm/ResetPasswordForm.tsx new file mode 100644 index 0000000..bf0e1ba --- /dev/null +++ b/features/auth/password/components/resetPasswordForm/ResetPasswordForm.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { Loader2, Eye, EyeOff, Lock } from "lucide-react"; +import { useLanguage } from "@/contexts/LanguageProvider"; +import { createResetPasswordSchema } from "./ResetPasswordSchema"; + +interface ResetPasswordFormProps { + onSubmit: (data: ResetPasswordFormData) => Promise; + isLoading: boolean; +} + +export interface ResetPasswordFormData { + newPassword: string; + confirmPassword: string; +} + +export function ResetPasswordForm({ + onSubmit, + isLoading, +}: ResetPasswordFormProps) { + const t = useTranslations(""); + const language = useLanguage(); + const [showNewPassword, setShowNewPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(createResetPasswordSchema(t)), + defaultValues: { + newPassword: "", + confirmPassword: "", + }, + }); + + return ( +
+
+ +
+ + + +
+ {errors.newPassword && ( +

+ {errors.newPassword.message} +

+ )} +
+ +
+ +
+ + + +
+ {errors.confirmPassword && ( +

+ {errors.confirmPassword.message} +

+ )} +
+ + +
+ ); +} diff --git a/features/auth/password/components/resetPasswordForm/ResetPasswordSchema.ts b/features/auth/password/components/resetPasswordForm/ResetPasswordSchema.ts new file mode 100644 index 0000000..b2c6983 --- /dev/null +++ b/features/auth/password/components/resetPasswordForm/ResetPasswordSchema.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +export const createResetPasswordSchema = (t: (key: string) => string) => { + return z + .object({ + newPassword: z + .string() + .min(1, t("passwordRequired")) + .min(8, t("passwordTooShort")) + .max(32, t("passwordTooLong")), + confirmPassword: z.string().min(1, t("confirmPasswordRequired")), + }) + .refine((data) => data.newPassword === data.confirmPassword, { + message: t("passwordMismatch"), + path: ["confirmPassword"], + }); +}; + +export type ResetPasswordFormData = z.infer< + ReturnType +>; diff --git a/features/auth/password/index.ts b/features/auth/password/index.ts new file mode 100644 index 0000000..db77d26 --- /dev/null +++ b/features/auth/password/index.ts @@ -0,0 +1,8 @@ +export { ForgotPasswordContainer } from "./components/ForgotPasswordContainer"; +export { ForgotPasswordPresentational } from "./components/ForgotPasswordPresentational"; +export { ResetPasswordContainer } from "./components/ResetPasswordContainer"; +export { ResetPasswordPresentational } from "./components/ResetPasswordPresentational"; + +export * from "./types/passwordTypes"; +export * from "./api/password.api"; +export * from "./query/usePassword.query"; diff --git a/features/auth/password/query/usePassword.query.ts b/features/auth/password/query/usePassword.query.ts new file mode 100644 index 0000000..0cbae3b --- /dev/null +++ b/features/auth/password/query/usePassword.query.ts @@ -0,0 +1,100 @@ +import { useMutation, UseMutationResult } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import { forgetPassword, resetPassword } from "../api/password.api"; +import { + ForgetPasswordRequest, + ForgetPasswordResponse, + ResetPasswordRequest, + ResetPasswordResponse, + ApiError, +} from "../types/passwordTypes"; +import { toast } from "@/hooks/useToast"; +import { useLanguage } from "@/contexts/LanguageProvider"; +import { useRouter } from "next/navigation"; + +export const useForgetPassword = (): UseMutationResult< + ForgetPasswordResponse, + AxiosError, + ForgetPasswordRequest +> => { + const { locale } = useLanguage(); + + return useMutation({ + mutationFn: forgetPassword, + onSuccess: (data) => { + const successMessage = + locale === "ar" + ? data.messageAr || + "تم إرسال رابط إعادة تعيين كلمة المرور إلى بريدك الإلكتروني" + : data.messageEn || "Password reset link has been sent to your email"; + + toast({ + title: locale === "ar" ? "نجح" : "Success", + description: successMessage, + }); + }, + onError: (error) => { + const errorData = error.response?.data; + const errorMessage = + locale === "ar" + ? errorData?.messageAr || + "حدث خطأ أثناء إرسال رابط إعادة تعيين كلمة المرور" + : errorData?.messageEn || + "An error occurred while sending password reset link"; + + toast({ + variant: "destructive", + title: locale === "ar" ? "خطأ" : "Error", + description: errorMessage, + }); + + if (process.env.NODE_ENV === "development") { + console.error("Forget Password Error:", error); + } + }, + }); +}; + +export const useResetPassword = (): UseMutationResult< + ResetPasswordResponse, + AxiosError, + ResetPasswordRequest +> => { + const { locale } = useLanguage(); + const router = useRouter(); + + return useMutation({ + mutationFn: resetPassword, + onSuccess: (data) => { + const successMessage = + locale === "ar" + ? data.messageAr || "تم إعادة تعيين كلمة المرور بنجاح" + : data.messageEn || "Password reset successfully"; + + toast({ + title: locale === "ar" ? "نجح" : "Success", + description: successMessage, + }); + + router.push("/login"); + }, + onError: (error) => { + const errorData = error.response?.data; + const errorMessage = + locale === "ar" + ? errorData?.messageAr || "حدث خطأ أثناء إعادة تعيين كلمة المرور" + : errorData?.messageEn || + "An error occurred while resetting password"; + + toast({ + variant: "destructive", + title: locale === "ar" ? "خطأ" : "Error", + description: errorMessage, + }); + + if (process.env.NODE_ENV === "development") { + console.error("Reset Password Error:", error); + } + }, + }); +}; diff --git a/features/auth/password/types/passwordTypes.ts b/features/auth/password/types/passwordTypes.ts new file mode 100644 index 0000000..656726a --- /dev/null +++ b/features/auth/password/types/passwordTypes.ts @@ -0,0 +1,23 @@ +export interface ForgetPasswordRequest { + email: string; +} + +export interface ForgetPasswordResponse { + messageEn?: string; + messageAr?: string; +} + +export interface ResetPasswordRequest { + token: string; + newPassword: string; +} + +export interface ResetPasswordResponse { + messageEn?: string; + messageAr?: string; +} + +export interface ApiError { + messageEn: string; + messageAr: string; +} diff --git a/features/auth/register/api/register.api.ts b/features/auth/register/api/register.api.ts new file mode 100644 index 0000000..4af243c --- /dev/null +++ b/features/auth/register/api/register.api.ts @@ -0,0 +1,32 @@ +import { api } from "@/lib/apiClient"; +import { + SignupRequest, + SignupResponse, + VerifyOTPRequest, + VerifyOTPResponse, + CompleteProfileRequest, + CompleteProfileResponse, +} from "../types/authTypes"; + +export const signupUser = async ( + userData: SignupRequest +): Promise => { + return api.post("/auth/signup", userData, { + skipAuth: true, + }); +}; + +export const verifyOTP = async ( + otpData: VerifyOTPRequest +): Promise => { + return api.patch("/auth/verify-otp", otpData); +}; + +export const completeProfile = async ( + profileData: CompleteProfileRequest +): Promise => { + return api.patch( + "/auth/complete-profile-info", + profileData + ); +}; diff --git a/features/auth/register/components/RegisterContainer.tsx b/features/auth/register/components/RegisterContainer.tsx new file mode 100644 index 0000000..49bc3a1 --- /dev/null +++ b/features/auth/register/components/RegisterContainer.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { RegisterFormData } from "./registerForm/RegisterForm"; +import { useSignup } from "../query/useRegister.query"; +import { initiateGoogleAuth } from "@/features/auth/google-auth/api/googleAuth.api"; +import { useAuthFlowStore } from "@/stores/useAuthFlowStore"; + +export interface RegisterContainerProps { + children: (props: RegisterPresentationalProps) => React.ReactNode; +} + +export interface RegisterPresentationalProps { + isLoading: boolean; + handleRegister: (data: RegisterFormData) => Promise; + handleGoogleRegister: () => void; + t: (key: string) => string; +} + +export function RegisterContainer({ children }: RegisterContainerProps) { + const t = useTranslations(""); + const signupMutation = useSignup(); + const setSignupMethod = useAuthFlowStore((state) => state.setSignupMethod); + + const handleRegister = async (data: RegisterFormData) => { + await signupMutation.mutateAsync({ + email: data.email, + name: data.fullName, + password: data.password, + phone: data.phoneNumber, + }); + }; + + const handleGoogleRegister = () => { + setSignupMethod("google"); + initiateGoogleAuth(); + }; + + return ( + <> + {children({ + isLoading: signupMutation.isPending, + handleRegister, + handleGoogleRegister, + t, + })} + + ); +} diff --git a/features/register/components/RegisterPresentational.tsx b/features/auth/register/components/RegisterPresentational.tsx similarity index 97% rename from features/register/components/RegisterPresentational.tsx rename to features/auth/register/components/RegisterPresentational.tsx index 8c0c5fe..7eedf28 100644 --- a/features/register/components/RegisterPresentational.tsx +++ b/features/auth/register/components/RegisterPresentational.tsx @@ -14,7 +14,7 @@ import { export interface RegisterPresentationalProps { isLoading: boolean; handleRegister: (data: RegisterFormData) => Promise; - handleGoogleRegister: () => Promise; + handleGoogleRegister: () => void; t: (key: string) => string; } diff --git a/features/register/components/registerForm/RegisterForm.tsx b/features/auth/register/components/registerForm/RegisterForm.tsx similarity index 100% rename from features/register/components/registerForm/RegisterForm.tsx rename to features/auth/register/components/registerForm/RegisterForm.tsx diff --git a/features/register/components/registerForm/registerSchema.ts b/features/auth/register/components/registerForm/registerSchema.ts similarity index 78% rename from features/register/components/registerForm/registerSchema.ts rename to features/auth/register/components/registerForm/registerSchema.ts index ba81918..8419ea5 100644 --- a/features/register/components/registerForm/registerSchema.ts +++ b/features/auth/register/components/registerForm/registerSchema.ts @@ -7,8 +7,10 @@ export const createRegisterSchema = (t: (key: string) => string) => { email: z.string().email({ message: t("invalidEmail") }), phoneNumber: z .string() - .min(10, { message: t("phoneNumberTooShort") }) - .regex(/^[0-9+\-\s()]*$/, { message: t("invalidPhoneNumber") }), + .length(11, { message: t("phoneNumberMustBe11Digits") }) + .regex(/^(010|011|012|015)\d{8}$/, { + message: t("invalidPhoneNumberFormat"), + }), password: z.string().min(8, { message: t("passwordTooShort") }), confirmPassword: z .string() diff --git a/features/auth/register/index.ts b/features/auth/register/index.ts new file mode 100644 index 0000000..5325f61 --- /dev/null +++ b/features/auth/register/index.ts @@ -0,0 +1,9 @@ +export * from "./types/authTypes"; + +export * from "./api/register.api"; + +export * from "./query/useRegister.query"; + +export { RegisterContainer } from "./components/RegisterContainer"; +export { RegisterPresentational } from "./components/RegisterPresentational"; +export { RegisterForm } from "./components/registerForm/RegisterForm"; diff --git a/features/auth/register/query/useRegister.query.ts b/features/auth/register/query/useRegister.query.ts new file mode 100644 index 0000000..ed22e50 --- /dev/null +++ b/features/auth/register/query/useRegister.query.ts @@ -0,0 +1,172 @@ +import { useMutation, UseMutationResult } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import { signupUser, verifyOTP, completeProfile } from "../api/register.api"; +import { + SignupRequest, + SignupResponse, + VerifyOTPRequest, + VerifyOTPResponse, + CompleteProfileRequest, + CompleteProfileResponse, + ApiError, +} from "../types/authTypes"; +import { toast } from "@/hooks/useToast"; +import { useLanguage } from "@/contexts/LanguageProvider"; +import { useRouter } from "next/navigation"; +import { useUserStore } from "@/stores/useUserStore"; +import { useAuthFlowStore } from "@/stores/useAuthFlowStore"; + +export const useSignup = (): UseMutationResult< + SignupResponse, + AxiosError, + SignupRequest +> => { + const { locale } = useLanguage(); + const router = useRouter(); + const setUser = useUserStore((state) => state.setUser); + const setSignupMethod = useAuthFlowStore((state) => state.setSignupMethod); + + return useMutation({ + mutationFn: signupUser, + onSuccess: (data) => { + setSignupMethod("email"); + + if (data.data) { + setUser(data.data); + } + + const successMessage = + data.message || + (locale === "ar" + ? data.messageAr || + "تم إنشاء الحساب بنجاح. يرجى التحقق من بريدك الإلكتروني" + : data.messageEn || + "Account created successfully. Please check your email for verification code"); + + toast({ + title: locale === "ar" ? "نجح" : "Success", + description: successMessage, + }); + + router.push("/verify-email"); + }, + onError: (error) => { + const errorData = error.response?.data; + const errorMessage = + locale === "ar" + ? errorData?.messageAr || "حدث خطأ أثناء إنشاء الحساب" + : errorData?.messageEn || "An error occurred during registration"; + + toast({ + variant: "destructive", + title: locale === "ar" ? "خطأ" : "Error", + description: errorMessage, + }); + + if (process.env.NODE_ENV === "development") { + console.error("Signup Error:", error); + } + }, + }); +}; + +export const useVerifyOTP = (): UseMutationResult< + VerifyOTPResponse, + AxiosError, + VerifyOTPRequest +> => { + const { locale } = useLanguage(); + const router = useRouter(); + + return useMutation({ + mutationFn: verifyOTP, + onSuccess: (data) => { + const successMessage = + data.message || + (locale === "ar" + ? data.messageAr || "تم التحقق من البريد الإلكتروني بنجاح" + : data.messageEn || "Email verified successfully"); + + toast({ + title: locale === "ar" ? "نجح" : "Success", + description: successMessage, + }); + + router.push("/complete-profile"); + }, + onError: (error) => { + const errorData = error.response?.data; + const errorMessage = + locale === "ar" + ? errorData?.messageAr || "رمز التحقق غير صحيح" + : errorData?.messageEn || "Invalid verification code"; + + toast({ + variant: "destructive", + title: locale === "ar" ? "خطأ" : "Error", + description: errorMessage, + }); + + if (process.env.NODE_ENV === "development") { + console.error("OTP Verification Error:", error); + } + }, + }); +}; + +export const useCompleteProfile = (): UseMutationResult< + CompleteProfileResponse, + AxiosError, + CompleteProfileRequest +> => { + const { locale } = useLanguage(); + const router = useRouter(); + const updateUser = useUserStore((state) => state.updateUser); + const clearSignupMethod = useAuthFlowStore( + (state) => state.clearSignupMethod + ); + + return useMutation({ + mutationFn: completeProfile, + onSuccess: (data) => { + if (data.data) { + updateUser({ + gender: data.data.gender, + date_of_birth: data.data.date_of_birth, + }); + } + + clearSignupMethod(); + + const successMessage = + data.message || + (locale === "ar" + ? data.messageAr || "تم تحديث الملف الشخصي بنجاح" + : data.messageEn || "Profile completed successfully"); + + toast({ + title: locale === "ar" ? "نجح" : "Success", + description: successMessage, + }); + + router.push("/dashboard"); + }, + onError: (error) => { + const errorData = error.response?.data; + const errorMessage = + locale === "ar" + ? errorData?.messageAr || "حدث خطأ أثناء تحديث الملف الشخصي" + : errorData?.messageEn || "An error occurred while updating profile"; + + toast({ + variant: "destructive", + title: locale === "ar" ? "خطأ" : "Error", + description: errorMessage, + }); + + if (process.env.NODE_ENV === "development") { + console.error("Complete Profile Error:", error); + } + }, + }); +}; diff --git a/features/auth/register/types/authTypes.ts b/features/auth/register/types/authTypes.ts new file mode 100644 index 0000000..0930e5e --- /dev/null +++ b/features/auth/register/types/authTypes.ts @@ -0,0 +1,53 @@ +export interface SignupRequest { + email: string; + name: string; + password: string; + phone: string; +} + +export interface SignupResponse { + data?: User; + message?: string; + messageEn?: string; + messageAr?: string; +} + +export interface VerifyOTPRequest { + otp: string; +} + +export interface VerifyOTPResponse { + data?: boolean; + message?: string; + messageEn?: string; + messageAr?: string; +} + +export interface CompleteProfileRequest { + gender: "MALE" | "FEMALE" | "OTHER"; + date_of_birth: string; +} + +export interface CompleteProfileResponse { + data?: User; + message?: string; + messageEn?: string; + messageAr?: string; +} + +export interface User { + id: string; + email: string; + name: string; + phone?: string | null; + gender?: "MALE" | "FEMALE" | null; + date_of_birth?: string | null; + isVerified: boolean; + created_at: string; + updated_at: string; +} + +export interface ApiError { + messageEn: string; + messageAr: string; +} diff --git a/features/auth/verify-email/api/verifyEmail.api.ts b/features/auth/verify-email/api/verifyEmail.api.ts new file mode 100644 index 0000000..82e85ce --- /dev/null +++ b/features/auth/verify-email/api/verifyEmail.api.ts @@ -0,0 +1,16 @@ +import { api } from "@/lib/apiClient"; +import { + VerifyEmailRequest, + VerifyEmailResponse, + ResendOtpResponse, +} from "../types/verifyEmailTypes"; + +export const verifyEmail = async ( + data: VerifyEmailRequest +): Promise => { + return api.patch("/auth/verify-otp", data); +}; + +export const resendOtp = async (): Promise => { + return api.post("/auth/resend-otp"); +}; diff --git a/features/auth/verify-email/components/VerifyEmailContainer.tsx b/features/auth/verify-email/components/VerifyEmailContainer.tsx new file mode 100644 index 0000000..e93389b --- /dev/null +++ b/features/auth/verify-email/components/VerifyEmailContainer.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { useState, useEffect } from "react"; +import { VerifyEmailFormData } from "./verifyEmailForm/VerifyEmailForm"; +import { useVerifyEmail, useResendOtp } from "../query/useVerifyEmail.query"; + +export interface VerifyEmailContainerProps { + children: (props: VerifyEmailPresentationalProps) => React.ReactNode; +} + +export interface VerifyEmailPresentationalProps { + isLoading: boolean; + handleVerifyEmail: (data: VerifyEmailFormData) => Promise; + handleResendOtp: () => void; + isResendDisabled: boolean; + resendCountdown: number; + t: (key: string) => string; +} + +const RESEND_COOLDOWN = 60; + +export function VerifyEmailContainer({ children }: VerifyEmailContainerProps) { + const t = useTranslations(""); + const verifyEmailMutation = useVerifyEmail(); + const resendOtpMutation = useResendOtp(); + + const [resendCountdown, setResendCountdown] = useState(0); + const [isResendDisabled, setIsResendDisabled] = useState(false); + + useEffect(() => { + let timer: NodeJS.Timeout; + if (resendCountdown > 0) { + timer = setTimeout(() => { + setResendCountdown(resendCountdown - 1); + }, 1000); + } else { + setIsResendDisabled(false); + } + return () => clearTimeout(timer); + }, [resendCountdown]); + + const handleVerifyEmail = async (data: VerifyEmailFormData) => { + await verifyEmailMutation.mutateAsync({ + otp: data.otp, + }); + }; + + const handleResendOtp = () => { + resendOtpMutation.mutate(); + setIsResendDisabled(true); + setResendCountdown(RESEND_COOLDOWN); + }; + + return ( + <> + {children({ + isLoading: verifyEmailMutation.isPending, + handleVerifyEmail, + handleResendOtp, + isResendDisabled, + resendCountdown, + t, + })} + + ); +} diff --git a/features/auth/verify-email/components/VerifyEmailPresentational.tsx b/features/auth/verify-email/components/VerifyEmailPresentational.tsx new file mode 100644 index 0000000..f9ac2c4 --- /dev/null +++ b/features/auth/verify-email/components/VerifyEmailPresentational.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { + VerifyEmailForm, + VerifyEmailFormData, +} from "./verifyEmailForm/VerifyEmailForm"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Mail } from "lucide-react"; + +export interface VerifyEmailPresentationalProps { + isLoading: boolean; + handleVerifyEmail: (data: VerifyEmailFormData) => Promise; + handleResendOtp: () => void; + isResendDisabled: boolean; + resendCountdown: number; + t: (key: string) => string; +} + +export function VerifyEmailPresentational({ + isLoading, + handleVerifyEmail, + handleResendOtp, + isResendDisabled, + resendCountdown, + t, +}: VerifyEmailPresentationalProps) { + return ( +
+ + +
+ +
+ + {t("verifyYourEmail")} + + + {t("verifyEmailDescription")} + +
+ + + +
+
+ ); +} diff --git a/features/auth/verify-email/components/verifyEmailForm/VerifyEmailForm.tsx b/features/auth/verify-email/components/verifyEmailForm/VerifyEmailForm.tsx new file mode 100644 index 0000000..279244e --- /dev/null +++ b/features/auth/verify-email/components/verifyEmailForm/VerifyEmailForm.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@/components/ui/button"; +import { useTranslations } from "next-intl"; +import { Loader2 } from "lucide-react"; +import { createVerifyEmailSchema } from "./VerifyEmailSchema"; +import { OtpInput } from "@/components/ui/otp-input"; + +interface VerifyEmailFormProps { + onSubmit: (data: VerifyEmailFormData) => Promise; + isLoading: boolean; + onResendOtp: () => void; + isResendDisabled: boolean; + resendCountdown: number; +} + +export interface VerifyEmailFormData { + otp: string; +} + +export function VerifyEmailForm({ + onSubmit, + isLoading, + onResendOtp, + isResendDisabled, + resendCountdown, +}: VerifyEmailFormProps) { + const t = useTranslations(""); + + const { + setValue, + handleSubmit, + formState: { errors }, + watch, + } = useForm({ + resolver: zodResolver(createVerifyEmailSchema(t)), + defaultValues: { + otp: "", + }, + }); + + const otpValue = watch("otp"); + + const handleOtpChange = (value: string) => { + setValue("otp", value, { shouldValidate: true }); + if (value.length === 6) { + handleSubmit(onSubmit)(); + } + }; + + return ( +
+
+ + {errors.otp && ( +

+ {errors.otp.message} +

+ )} +
+ + + +
+

+ {t("didntReceiveCode")} +

+ +
+
+ ); +} diff --git a/features/auth/verify-email/components/verifyEmailForm/VerifyEmailSchema.ts b/features/auth/verify-email/components/verifyEmailForm/VerifyEmailSchema.ts new file mode 100644 index 0000000..c40c195 --- /dev/null +++ b/features/auth/verify-email/components/verifyEmailForm/VerifyEmailSchema.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const createVerifyEmailSchema = (t: (key: string) => string) => { + return z.object({ + otp: z + .string() + .min(1, t("otpRequired")) + .length(6, t("otpMustBe6Digits")) + .regex(/^\d+$/, t("otpMustBeNumeric")), + }); +}; + +export type VerifyEmailFormData = z.infer< + ReturnType +>; diff --git a/features/auth/verify-email/index.ts b/features/auth/verify-email/index.ts new file mode 100644 index 0000000..6157ef5 --- /dev/null +++ b/features/auth/verify-email/index.ts @@ -0,0 +1,5 @@ +export { VerifyEmailContainer } from "./components/VerifyEmailContainer"; +export { VerifyEmailPresentational } from "./components/VerifyEmailPresentational"; +export * from "./api/verifyEmail.api"; +export * from "./query/useVerifyEmail.query"; +export * from "./types/verifyEmailTypes"; diff --git a/features/auth/verify-email/query/useVerifyEmail.query.ts b/features/auth/verify-email/query/useVerifyEmail.query.ts new file mode 100644 index 0000000..7138f51 --- /dev/null +++ b/features/auth/verify-email/query/useVerifyEmail.query.ts @@ -0,0 +1,98 @@ +import { useMutation, UseMutationResult } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import { verifyEmail, resendOtp } from "../api/verifyEmail.api"; +import { + VerifyEmailRequest, + VerifyEmailResponse, + ResendOtpResponse, + ApiError, +} from "../types/verifyEmailTypes"; +import { toast } from "@/hooks/useToast"; +import { useLanguage } from "@/contexts/LanguageProvider"; +import { useRouter } from "next/navigation"; +import { useUserStore } from "@/stores/useUserStore"; + +export const useVerifyEmail = (): UseMutationResult< + VerifyEmailResponse, + AxiosError, + VerifyEmailRequest +> => { + const { locale } = useLanguage(); + const router = useRouter(); + const updateUser = useUserStore((state) => state.updateUser); + + return useMutation({ + mutationFn: verifyEmail, + onSuccess: (data) => { + updateUser({ isVerified: true }); + const successMessage = + locale === "ar" + ? data.messageAr || "تم التحقق من البريد الإلكتروني بنجاح" + : data.messageEn || "Email verified successfully"; + + toast({ + title: locale === "ar" ? "نجح" : "Success", + description: successMessage, + }); + + router.push("/complete-profile"); + }, + onError: (error) => { + const errorData = error.response?.data; + const errorMessage = + locale === "ar" + ? errorData?.messageAr || "رمز التحقق غير صالح" + : errorData?.messageEn || "Invalid OTP code"; + + toast({ + variant: "destructive", + title: locale === "ar" ? "خطأ" : "Error", + description: errorMessage, + }); + + if (process.env.NODE_ENV === "development") { + console.error("Verify Email Error:", error); + } + }, + }); +}; + +export const useResendOtp = (): UseMutationResult< + ResendOtpResponse, + AxiosError, + void +> => { + const { locale } = useLanguage(); + + return useMutation({ + mutationFn: resendOtp, + onSuccess: (data) => { + const successMessage = + locale === "ar" + ? data.messageAr || "تم إرسال رمز التحقق مرة أخرى" + : data.messageEn || "OTP resent successfully"; + + toast({ + title: locale === "ar" ? "نجح" : "Success", + description: successMessage, + }); + }, + onError: (error) => { + const errorData = error.response?.data; + const errorMessage = + locale === "ar" + ? errorData?.messageAr || "فشل إرسال رمز التحقق" + : errorData?.messageEn || "Failed to resend OTP"; + + toast({ + variant: "destructive", + title: locale === "ar" ? "خطأ" : "Error", + description: errorMessage, + }); + + if (process.env.NODE_ENV === "development") { + console.error("Resend OTP Error:", error); + } + }, + }); +}; diff --git a/features/auth/verify-email/types/verifyEmailTypes.ts b/features/auth/verify-email/types/verifyEmailTypes.ts new file mode 100644 index 0000000..f0d48af --- /dev/null +++ b/features/auth/verify-email/types/verifyEmailTypes.ts @@ -0,0 +1,20 @@ +export interface VerifyEmailRequest { + otp: string; +} + +export interface VerifyEmailResponse { + data: boolean; + message: string; + messageEn?: string; + messageAr?: string; +} + +export interface ResendOtpResponse { + messageEn?: string; + messageAr?: string; +} + +export interface ApiError { + messageEn: string; + messageAr: string; +} diff --git a/features/dashboard/api/dashboard.api.ts b/features/dashboard/api/dashboard.api.ts new file mode 100644 index 0000000..bf35b4b --- /dev/null +++ b/features/dashboard/api/dashboard.api.ts @@ -0,0 +1,9 @@ +import { api } from "@/lib/apiClient"; +import { DashboardUser, GoogleUserDataResponse } from "../types/dashboardTypes"; + +export const getCurrentUser = async (): Promise => { + const response = await api.get( + "/auth/google/userData" + ); + return response.data; +}; diff --git a/features/dashboard/components/DashboardContainer.tsx b/features/dashboard/components/DashboardContainer.tsx new file mode 100644 index 0000000..3019b60 --- /dev/null +++ b/features/dashboard/components/DashboardContainer.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { useDashboard } from "../query/useDashboard.query"; +import { useLogout } from "@/features/auth/login/query/useLogin.query"; +import { DashboardUser } from "../types/dashboardTypes"; + +export interface DashboardContainerProps { + children: (props: DashboardPresentationalProps) => React.ReactNode; +} + +export interface DashboardPresentationalProps { + user: DashboardUser | null; + isLoading: boolean; + isError: boolean; + onLogout: () => void; + t: (key: string) => string; +} + +export function DashboardContainer({ children }: DashboardContainerProps) { + const t = useTranslations(""); + const { data: user, isLoading, isError } = useDashboard(); + const logoutMutation = useLogout(); + + const handleLogout = () => { + logoutMutation.mutate(); + }; + + return ( + <> + {children({ + user: user || null, + isLoading, + isError, + onLogout: handleLogout, + t, + })} + + ); +} diff --git a/features/dashboard/components/DashboardPresentational.tsx b/features/dashboard/components/DashboardPresentational.tsx new file mode 100644 index 0000000..173f09f --- /dev/null +++ b/features/dashboard/components/DashboardPresentational.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { DashboardUser } from "../types/dashboardTypes"; +import { Loader2, User, LogOut } from "lucide-react"; + +export interface DashboardPresentationalProps { + user: DashboardUser | null; + isLoading: boolean; + isError: boolean; + onLogout: () => void; + t: (key: string) => string; +} + +export function DashboardPresentational({ + user, + isLoading, + isError, + onLogout, + t, +}: DashboardPresentationalProps) { + if (isLoading || !user) { + return ( +
+
+ +

{t("loading")}

+
+
+ ); + } + + if (isError) { + return ( +
+ + + {t("error")} + {t("failedToLoadUserData")} + + +
+ ); + } + + return ( +
+
+
+
+

{t("dashboard")}

+

{t("dashboardDescription")}

+
+ +
+ + + +
+
+ +
+
+ + {t("welcomeBack")}, {user.name}! + + + {t("dashboardDescription")} + +
+
+
+ +
+
+

{t("email")}

+

{user.email}

+
+ {user.phone && ( +
+

+ {t("phoneNumber")} +

+

{user.phone}

+
+ )} + {user.gender && ( +
+

{t("gender")}

+

{t(user.gender.toLowerCase())}

+
+ )} + {user.date_of_birth && ( +
+

+ {t("dateOfBirth")} +

+

+ {new Date(user.date_of_birth).toLocaleDateString()} +

+
+ )} +
+

+ {t("accountStatus")} +

+

+ {user.isVerified ? t("verified") : t("notVerified")} +

+
+
+
+
+ +
+ + + {t("recentActivity")} + + +

+ {t("noActivityYet")} +

+
+
+ + + + {t("statistics")} + + +

+ {t("noStatisticsYet")} +

+
+
+ + + + {t("quickActions")} + + +

{t("comingSoon")}

+
+
+
+
+
+ ); +} diff --git a/features/dashboard/index.ts b/features/dashboard/index.ts new file mode 100644 index 0000000..cf4cc8b --- /dev/null +++ b/features/dashboard/index.ts @@ -0,0 +1,5 @@ +export { DashboardContainer } from "./components/DashboardContainer"; +export { DashboardPresentational } from "./components/DashboardPresentational"; +export * from "./api/dashboard.api"; +export * from "./query/useDashboard.query"; +export * from "./types/dashboardTypes"; diff --git a/features/dashboard/query/useDashboard.query.ts b/features/dashboard/query/useDashboard.query.ts new file mode 100644 index 0000000..3b1d749 --- /dev/null +++ b/features/dashboard/query/useDashboard.query.ts @@ -0,0 +1,33 @@ +import { useQuery, UseQueryResult } from "@tanstack/react-query"; +import { DashboardUser } from "../types/dashboardTypes"; +import { useUserStore } from "@/stores/useUserStore"; +import { getCurrentUser } from "../api/dashboard.api"; + +export const useDashboard = (): UseQueryResult => { + const user = useUserStore((state) => state.user); + const hasHydrated = useUserStore((state) => state._hasHydrated); + const setUser = useUserStore((state) => state.setUser); + + return useQuery({ + queryKey: ["dashboard", "user"], + queryFn: async () => { + if (user) { + return user as DashboardUser; + } + + try { + const userData = await getCurrentUser(); + + setUser(userData); + + return userData as DashboardUser; + } catch (error) { + throw new Error("Failed to load user data. Please login again."); + } + }, + enabled: hasHydrated, + staleTime: Infinity, + gcTime: Infinity, + retry: 1, + }); +}; diff --git a/features/dashboard/types/dashboardTypes.ts b/features/dashboard/types/dashboardTypes.ts new file mode 100644 index 0000000..e668b68 --- /dev/null +++ b/features/dashboard/types/dashboardTypes.ts @@ -0,0 +1,22 @@ +export interface DashboardUser { + id?: string; + email: string; + name: string; + username?: string; + phone?: string | null; + gender?: "MALE" | "FEMALE" | null; + date_of_birth?: string | null; + isVerified: boolean; + created_at?: string; + updated_at?: string; +} + +export interface GoogleUserDataResponse { + data: DashboardUser; + message: string; +} + +export interface ApiError { + messageEn: string; + messageAr: string; +} diff --git a/features/login/components/LoginContainer.tsx b/features/login/components/LoginContainer.tsx deleted file mode 100644 index caec0e7..0000000 --- a/features/login/components/LoginContainer.tsx +++ /dev/null @@ -1,64 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useTranslations } from "next-intl"; -import { LoginFormData } from "./loginForm/LoginForm"; -import { useToast } from "@/hooks/useToast"; - -export interface LoginContainerProps { - children: (props: LoginPresentationalProps) => React.ReactNode; -} - -export interface LoginPresentationalProps { - isLoading: boolean; - handleLogin: (data: LoginFormData) => Promise; - handleGoogleLogin: () => Promise; - t: (key: string) => string; -} - -export function LoginContainer({ children }: LoginContainerProps) { - const t = useTranslations(""); - const { toast } = useToast(); - const [isLoading, setIsLoading] = useState(false); - - const handleLogin = async (data: LoginFormData) => { - setIsLoading(true); - try { - toast({ - title: t("loginSuccess"), - description: t("welcomeBack"), - }); - } catch (error: any) { - toast({ - title: t("loginError"), - description: - error.response?.data?.message || - error.message || - "Something went wrong", - variant: "destructive", - }); - } finally { - setIsLoading(false); - } - }; - - const handleGoogleLogin = async () => { - setIsLoading(true); - try { - toast({ - title: t("loginSuccess"), - description: t("welcomeBack"), - }); - } catch (error: any) { - toast({ - title: t("loginError"), - description: error.message || "Google login failed", - variant: "destructive", - }); - } finally { - setIsLoading(false); - } - }; - - return <>{children({ isLoading, handleLogin, handleGoogleLogin, t })}; -} diff --git a/features/login/types/authTypes.ts b/features/login/types/authTypes.ts deleted file mode 100644 index e69de29..0000000 diff --git a/features/register/components/RegisterContainer.tsx b/features/register/components/RegisterContainer.tsx deleted file mode 100644 index c441199..0000000 --- a/features/register/components/RegisterContainer.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useTranslations } from "next-intl"; -import { RegisterFormData } from "./registerForm/RegisterForm"; -import { useToast } from "@/hooks/useToast"; - -export interface RegisterContainerProps { - children: (props: RegisterPresentationalProps) => React.ReactNode; -} - -export interface RegisterPresentationalProps { - isLoading: boolean; - handleRegister: (data: RegisterFormData) => Promise; - handleGoogleRegister: () => Promise; - t: (key: string) => string; -} - -export function RegisterContainer({ children }: RegisterContainerProps) { - const t = useTranslations(""); - const { toast } = useToast(); - const [isLoading, setIsLoading] = useState(false); - - const handleRegister = async (data: RegisterFormData) => { - setIsLoading(true); - try { - toast({ - title: t("registerSuccess"), - description: t("accountCreated"), - }); - } catch (error: any) { - toast({ - title: t("registerError"), - description: - error.response?.data?.message || - error.message || - "Something went wrong", - variant: "destructive", - }); - } finally { - setIsLoading(false); - } - }; - - const handleGoogleRegister = async () => { - setIsLoading(true); - try { - toast({ - title: t("registerSuccess"), - description: t("accountCreated"), - }); - } catch (error: any) { - toast({ - title: t("registerError"), - description: error.message || "Google registration failed", - variant: "destructive", - }); - } finally { - setIsLoading(false); - } - }; - - return ( - <>{children({ isLoading, handleRegister, handleGoogleRegister, t })} - ); -} diff --git a/features/register/types/authTypes.ts b/features/register/types/authTypes.ts deleted file mode 100644 index e69de29..0000000 diff --git a/lib/apiClient.ts b/lib/apiClient.ts index fa24da0..dc44e32 100644 --- a/lib/apiClient.ts +++ b/lib/apiClient.ts @@ -7,7 +7,7 @@ import axios, { import { getAuthToken, removeAuthToken } from "./tokenManager"; const API_BASE_URL = - process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:3001/api"; + process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:3000"; // single Axios instance const axiosInstance = axios.create({ @@ -16,13 +16,14 @@ const axiosInstance = axios.create({ headers: { "Content-Type": "application/json", }, + withCredentials: true, }); -// Adds auth token if available + axiosInstance.interceptors.request.use( (config: InternalAxiosRequestConfig) => { const token = getAuthToken(); - if (token) { + if (token && !config.headers.Authorization) { config.headers.Authorization = `Bearer ${token}`; } return config; @@ -32,7 +33,7 @@ axiosInstance.interceptors.request.use( } ); -// Response interceptor +// Response interceptor axiosInstance.interceptors.response.use( (response) => { return response; @@ -119,7 +120,7 @@ export class ApiClient { return response.data; } - // Helper method to handle skipAuth option + // handle skipAuth option private prepareConfig( config?: AxiosRequestConfig & { skipAuth?: boolean } ): AxiosRequestConfig { diff --git a/lib/tokenManager.ts b/lib/tokenManager.ts index ec1dc22..b3a022f 100644 --- a/lib/tokenManager.ts +++ b/lib/tokenManager.ts @@ -1,5 +1,6 @@ import Cookies from "js-cookie"; + const TOKEN_COOKIE_NAME = "auth_token"; const isProduction = process.env.NODE_ENV === "production"; @@ -13,12 +14,12 @@ export const setAuthToken = ( ): void => { Cookies.set(TOKEN_COOKIE_NAME, token, { expires: expiresInDays, - secure: isProduction, // HTTPS only in production - sameSite: "strict", + secure: isProduction, + sameSite: "lax", path: "/", }); }; export const removeAuthToken = (): void => { Cookies.remove(TOKEN_COOKIE_NAME, { path: "/" }); -}; \ No newline at end of file +}; diff --git a/locales/ar/auth.json b/locales/ar/auth.json index 8c7abee..76b586a 100644 --- a/locales/ar/auth.json +++ b/locales/ar/auth.json @@ -1,6 +1,7 @@ { "login": "تسجيل الدخول", "register": "إنشاء حساب", + "dashboard": "لوحة التحكم", "email": "البريد الإلكتروني", "emailOrUsername": "البريد الإلكتروني / اسم المستخدم", "emailOrUsernamePlaceholder": "أدخل بريدك الإلكتروني أو اسم المستخدم", @@ -14,10 +15,23 @@ "fullNameRequired": "الاسم الكامل مطلوب", "phoneNumber": "رقم الهاتف", "phoneNumberPlaceholder": "أدخل رقم هاتفك", + "phoneNumberRequired": "رقم الهاتف مطلوب", + "phoneNumberMustBe11Digits": "يجب أن يتكون رقم الهاتف من 11 رقمًا بالضبط", + "invalidPhoneNumberFormat": "يجب أن يبدأ رقم الهاتف بـ 010 أو 011 أو 012 أو 015", "phoneNumberTooShort": "يجب أن يتكون رقم الهاتف من 10 أرقام على الأقل", "invalidPhoneNumber": "تنسيق رقم الهاتف غير صالح", "forgotPassword": "نسيت كلمة المرور؟", + "forgotPasswordDescription": "أدخل عنوان بريدك الإلكتروني وسنرسل لك رابطًا لإعادة تعيين كلمة المرور.", + "sendResetLink": "إرسال رابط إعادة التعيين", + "sending": "جارٍ الإرسال...", + "backToLogin": "العودة لتسجيل الدخول", "resetPassword": "إعادة تعيين كلمة المرور", + "resetPasswordDescription": "أدخل كلمة المرور الجديدة أدناه.", + "newPassword": "كلمة المرور الجديدة", + "newPasswordPlaceholder": "أدخل كلمة المرور الجديدة", + "resetting": "جارٍ إعادة التعيين...", + "invalidResetToken": "رمز إعادة التعيين غير صالح أو منتهي الصلاحية", + "passwordTooLong": "يجب أن تكون كلمة المرور أقل من 32 حرفًا", "signIn": "تسجيل الدخول", "signUp": "إنشاء حساب", "signOut": "تسجيل الخروج", @@ -43,5 +57,41 @@ "welcomeBack": "مرحبًا بعودتك", "continueWith": "أو استمر مع", "continueWithGoogle": "استمر مع جوجل", - "registerWithGoogle": "سجل باستخدام جوجل" + "registerWithGoogle": "سجل باستخدام جوجل", + "verifyYourEmail": "تحقق من بريدك الإلكتروني", + "verifyEmailDescription": "لقد أرسلنا رمزًا مكونًا من 6 أرقام إلى بريدك الإلكتروني. يرجى إدخاله أدناه للتحقق من حسابك.", + "verifyEmail": "تحقق من البريد الإلكتروني", + "verifying": "جارٍ التحقق...", + "otpRequired": "رمز التحقق مطلوب", + "otpMustBe6Digits": "يجب أن يتكون رمز التحقق من 6 أرقام بالضبط", + "otpMustBeNumeric": "يجب أن يحتوي رمز التحقق على أرقام فقط", + "didntReceiveCode": "لم تتلق الرمز؟", + "resendOtp": "إعادة إرسال رمز التحقق", + "completeYourProfile": "أكمل ملفك الشخصي", + "completeProfileDescription": "يرجى تقديم معلومات إضافية لإكمال ملفك الشخصي.", + "completeProfileDescriptionWithPhone": "يرجى تقديم رقم هاتفك ومعلومات إضافية لإكمال ملفك الشخصي.", + "completeProfile": "إكمال الملف الشخصي", + "completing": "جارٍ الإكمال...", + "gender": "الجنس", + "genderRequired": "يرجى اختيار جنسك", + "selectGender": "اختر جنسك", + "male": "ذكر", + "female": "أنثى", + "other": "آخر", + "dateOfBirth": "تاريخ الميلاد", + "dateOfBirthRequired": "تاريخ الميلاد مطلوب", + "welcomeUser": "مرحبًا، {name}!", + "dashboardDescription": "هذه نظرة عامة على حسابك", + "loading": "جارٍ التحميل...", + "error": "خطأ", + "failedToLoadUserData": "فشل تحميل بيانات المستخدم", + "accountStatus": "حالة الحساب", + "verified": "تم التحقق", + "notVerified": "لم يتم التحقق", + "recentActivity": "النشاط الأخير", + "noActivityYet": "لا يوجد نشاط بعد", + "statistics": "الإحصائيات", + "noStatisticsYet": "لا توجد إحصائيات متاحة", + "quickActions": "إجراءات سريعة", + "comingSoon": "قريبًا" } diff --git a/locales/en/auth.json b/locales/en/auth.json index 42a6dd7..301f624 100644 --- a/locales/en/auth.json +++ b/locales/en/auth.json @@ -1,6 +1,7 @@ { "login": "Login", "register": "Register", + "dashboard": "Dashboard", "email": "Email", "emailOrUsername": "Email / Username", "emailOrUsernamePlaceholder": "Enter your email or username", @@ -14,10 +15,23 @@ "fullNameRequired": "Full name is required", "phoneNumber": "Phone Number", "phoneNumberPlaceholder": "Enter your phone number", + "phoneNumberRequired": "Phone number is required", + "phoneNumberMustBe11Digits": "Phone number must be exactly 11 digits", + "invalidPhoneNumberFormat": "Phone number must start with 010, 011, 012, or 015", "phoneNumberTooShort": "Phone number must be at least 10 digits", "invalidPhoneNumber": "Invalid phone number format", "forgotPassword": "Forgot Password?", + "forgotPasswordDescription": "Enter your email address and we'll send you a link to reset your password.", + "sendResetLink": "Send Reset Link", + "sending": "Sending...", + "backToLogin": "Back to Login", "resetPassword": "Reset Password", + "resetPasswordDescription": "Enter your new password below.", + "newPassword": "New Password", + "newPasswordPlaceholder": "Enter your new password", + "resetting": "Resetting...", + "invalidResetToken": "Invalid or expired reset token", + "passwordTooLong": "Password must be less than 32 characters", "signIn": "Sign In", "signUp": "Sign Up", "signOut": "Sign Out", @@ -43,5 +57,41 @@ "welcomeBack": "Welcome back", "continueWith": "Or Continue With", "continueWithGoogle": "Continue with Google", - "registerWithGoogle": "Register with Google" + "registerWithGoogle": "Register with Google", + "verifyYourEmail": "Verify Your Email", + "verifyEmailDescription": "We've sent a 6-digit code to your email. Please enter it below to verify your account.", + "verifyEmail": "Verify Email", + "verifying": "Verifying...", + "otpRequired": "OTP code is required", + "otpMustBe6Digits": "OTP must be exactly 6 digits", + "otpMustBeNumeric": "OTP must contain only numbers", + "didntReceiveCode": "Didn't receive the code?", + "resendOtp": "Resend OTP", + "completeYourProfile": "Complete Your Profile", + "completeProfileDescription": "Please provide additional information to complete your profile.", + "completeProfileDescriptionWithPhone": "Please provide your phone number and additional information to complete your profile.", + "completeProfile": "Complete Profile", + "completing": "Completing...", + "gender": "Gender", + "genderRequired": "Please select your gender", + "selectGender": "Select your gender", + "male": "Male", + "female": "Female", + "other": "Other", + "dateOfBirth": "Date of Birth", + "dateOfBirthRequired": "Date of birth is required", + "welcomeUser": "Welcome, {name}!", + "dashboardDescription": "Here's your overview", + "loading": "Loading...", + "error": "Error", + "failedToLoadUserData": "Failed to load user data", + "accountStatus": "Account Status", + "verified": "Verified", + "notVerified": "Not Verified", + "recentActivity": "Recent Activity", + "noActivityYet": "No activity yet", + "statistics": "Statistics", + "noStatisticsYet": "No statistics available", + "quickActions": "Quick Actions", + "comingSoon": "Coming soon" } diff --git a/package-lock.json b/package-lock.json index ddea343..97e5b55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@hookform/resolvers": "^5.2.2", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-toast": "^1.2.15", "@tanstack/react-query": "^5.90.5", @@ -472,6 +473,44 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, "node_modules/@formatjs/ecma402-abstract": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", @@ -1279,12 +1318,41 @@ "node": ">=12.4.0" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-checkbox": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", @@ -1371,6 +1439,21 @@ } } }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dismissable-layer": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", @@ -1398,6 +1481,64 @@ } } }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-label": { "version": "2.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", @@ -1421,6 +1562,38 @@ } } }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-portal": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", @@ -1492,6 +1665,49 @@ } } }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", @@ -1644,6 +1860,24 @@ } } }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-size": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", @@ -1685,6 +1919,12 @@ } } }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2739,6 +2979,18 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", @@ -3392,6 +3644,12 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -4328,6 +4586,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -6172,6 +6439,75 @@ "dev": true, "license": "MIT" }, + "node_modules/react-remove-scroll": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", + "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -7160,6 +7496,27 @@ "punycode": "^2.1.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-intl": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.4.0.tgz", @@ -7174,6 +7531,28 @@ "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" } }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 56756d3..a58d273 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@hookform/resolvers": "^5.2.2", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-toast": "^1.2.15", "@tanstack/react-query": "^5.90.5", diff --git a/stores/useAuthFlowStore.ts b/stores/useAuthFlowStore.ts new file mode 100644 index 0000000..3ce6542 --- /dev/null +++ b/stores/useAuthFlowStore.ts @@ -0,0 +1,32 @@ +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; + +export type SignupMethod = "email" | "google" | null; + +interface AuthFlowState { + signupMethod: SignupMethod; + + // Actions + setSignupMethod: (method: SignupMethod) => void; + clearSignupMethod: () => void; +} + +export const useAuthFlowStore = create()( + persist( + (set) => ({ + signupMethod: null, + + setSignupMethod: (method: SignupMethod) => { + set({ signupMethod: method }); + }, + + clearSignupMethod: () => { + set({ signupMethod: null }); + }, + }), + { + name: "auth-flow-storage", + storage: createJSONStorage(() => localStorage), + } + ) +); diff --git a/stores/useUserStore.ts b/stores/useUserStore.ts new file mode 100644 index 0000000..046ab80 --- /dev/null +++ b/stores/useUserStore.ts @@ -0,0 +1,75 @@ +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; + +export interface User { + id?: string; + email: string; + name: string; + username?: string; + phone?: string | null; + gender?: "MALE" | "FEMALE" | null; + date_of_birth?: string | null; + isVerified: boolean; + created_at?: string; + updated_at?: string; +} + +interface UserState { + user: User | null; + isAuthenticated: boolean; + _hasHydrated: boolean; + + setUser: (user: User) => void; + updateUser: (updates: Partial) => void; + clearUser: () => void; + setHasHydrated: (state: boolean) => void; + + getUser: () => User | null; +} + +export const useUserStore = create()( + persist( + (set, get) => ({ + user: null, + isAuthenticated: false, + _hasHydrated: false, + + setUser: (user: User) => { + set({ user, isAuthenticated: true }); + }, + + updateUser: (updates: Partial) => { + const currentUser = get().user; + if (currentUser) { + const updatedUser = { ...currentUser, ...updates }; + set({ user: updatedUser }); + } + }, + + clearUser: () => { + set({ user: null, isAuthenticated: false }); + }, + + setHasHydrated: (state: boolean) => { + set({ _hasHydrated: state }); + }, + + getUser: () => { + return get().user; + }, + }), + { + name: "user-storage", + storage: createJSONStorage(() => localStorage), + partialize: (state) => ({ + user: state.user, + isAuthenticated: state.isAuthenticated, + }), + onRehydrateStorage: () => (state) => { + if (state) { + state.setHasHydrated(true); + } + }, + } + ) +); From f5d4fd4d7dd44053eda6edd28f385d67d9cb4f05 Mon Sep 17 00:00:00 2001 From: Kareem Abdel Nabi <116344832+karreemm@users.noreply.github.com> Date: Mon, 10 Nov 2025 21:05:00 +0200 Subject: [PATCH 08/78] Update README.md --- README.md | 214 ------------------------------------------------------ 1 file changed, 214 deletions(-) diff --git a/README.md b/README.md index 62c994d..741e11f 100644 --- a/README.md +++ b/README.md @@ -1,215 +1 @@ # GP Frontend - -A modern Next.js frontend application with feature-based architecture, TypeScript, and bilingual support (English/Arabic). - -## 🏗️ Architecture - -This project follows a **feature-based architecture** where each feature is self-contained with its own: - -- **API layer** - HTTP client functions -- **Query layer** - React Query hooks -- **Types** - TypeScript definitions -- **Components** - UI components - -## 📁 Project Structure - -``` -├── app/ # Next.js app directory -│ ├── (pages)/ # Page routes -│ ├── layout.tsx -│ └── globals.css -│ -├── features/ # Feature modules -│ ├── login/ # Login feature -│ │ ├── api/ -│ │ ├── query/ -│ │ ├── types/ -│ │ └── components/ -│ ├── register/ # Registration feature -│ ├── password/ # Password reset feature -│ └── google-auth/ # Google OAuth feature -│ -├── components/ # Shared components -│ ├── common/ # Common components -│ ├── layout/ # Layout components -│ └── ui/ # UI components (shadcn) -│ -├── contexts/ # React contexts -│ ├── LanguageProvider.tsx -│ ├── ThemeProvider.tsx -│ └── QueryProvider.tsx -│ -├── hooks/ # Custom hooks -├── lib/ # Utility libraries -│ ├── apiClient.ts # Axios instance -│ ├── tokenManager.ts # Token management -│ └── utils.ts -│ -└── locales/ # Internationalization - ├── en/ # English translations - └── ar/ # Arabic translations -``` - -## 🚀 Getting Started - -### Prerequisites - -- Node.js 18+ -- npm or yarn - -### Installation - -```bash -# Install dependencies -npm install - -# Run development server -npm run dev - -# Build for production -npm run build - -# Start production server -npm start -``` - -## 🔐 Authentication Features - -### Login - -- Email/Username and password authentication -- Remember me functionality -- Automatic token management -- Redirects based on user status - -### Registration - -- Multi-step registration flow -- Email verification with OTP -- Profile completion -- Phone number validation - -### Password Reset - -- Forget password flow -- Email-based token reset -- Secure password update - -### Google OAuth - -- One-click Google sign-in -- Phone number update for Google users - -## 🌍 Internationalization - -Supports English and Arabic with RTL (Right-to-Left) layout for Arabic. - -```typescript -import { useLanguage } from "@/contexts/LanguageProvider"; - -const { locale, setLocale } = useLanguage(); -``` - -## 🎨 Theming - -Light and dark mode support using next-themes. - -```typescript -import { useTheme } from "next-themes"; - -const { theme, setTheme } = useTheme(); -``` - -## 📡 API Integration - -All API endpoints are configured to work with the backend Swagger specification. - -### Base URL - -Set in `.env.local`: - -``` -NEXT_PUBLIC_API_BASE_URL=http://localhost:3000 -``` - -### Usage Example - -```typescript -import { useLogin } from "@/features/login"; - -function LoginPage() { - const loginMutation = useLogin(); - - const handleLogin = async (data) => { - await loginMutation.mutateAsync({ - emailOrUsername: data.email, - password: data.password, - rememberMe: true, - }); - }; - - return ; -} -``` - -## 🔧 Tech Stack - -- **Framework**: Next.js 14+ (App Router) -- **Language**: TypeScript -- **Styling**: Tailwind CSS -- **UI Components**: shadcn/ui -- **State Management**: React Query (TanStack Query) -- **HTTP Client**: Axios -- **Form Handling**: React Hook Form -- **Validation**: Zod -- **Internationalization**: next-intl -- **Icons**: Lucide React - -## 📚 Documentation - -- [`RESTRUCTURING_SUMMARY.md`](./RESTRUCTURING_SUMMARY.md) - Detailed restructuring information -- [`QUICK_REFERENCE.md`](./QUICK_REFERENCE.md) - Quick reference guide with examples -- [`MIGRATION_COMPLETE.md`](./MIGRATION_COMPLETE.md) - Migration completion summary - -## 🧪 Testing - -```bash -# Run tests -npm test - -# Run tests with coverage -npm run test:coverage -``` - -## 📝 Code Style - -This project uses ESLint and Prettier for code formatting. - -```bash -# Lint code -npm run lint - -# Format code -npm run format -``` - -## 🤝 Contributing - -1. Create a feature branch -2. Make your changes -3. Write/update tests -4. Submit a pull request - -## 📄 License - -[Your License Here] - -## 🔗 Links - -- [Backend API Documentation](./swagger.yaml) -- [Design System](#) -- [Project Board](#) - ---- - -Built with ❤️ by the GP Team From ec4e173e492e8b1ee6e079283c45d216f7a9288a Mon Sep 17 00:00:00 2001 From: kareem Date: Tue, 11 Nov 2025 19:09:29 +0200 Subject: [PATCH 09/78] remove unwanted code from the API Client --- .../components/CompleteProfileContainer.tsx | 27 +--- features/auth/login/api/login.api.ts | 2 +- features/auth/login/query/useLogin.query.ts | 1 + features/auth/password/api/password.api.ts | 2 - features/auth/register/api/register.api.ts | 1 - lib/apiClient.ts | 140 +++++++++--------- 6 files changed, 69 insertions(+), 104 deletions(-) diff --git a/features/auth/complete-profile/components/CompleteProfileContainer.tsx b/features/auth/complete-profile/components/CompleteProfileContainer.tsx index 28373c8..c5f61e6 100644 --- a/features/auth/complete-profile/components/CompleteProfileContainer.tsx +++ b/features/auth/complete-profile/components/CompleteProfileContainer.tsx @@ -38,43 +38,18 @@ export function CompleteProfileContainer({ const handleCompleteProfile = async (data: CompleteProfileFormData) => { try { - // First, update phone if required (for Google users) if (requirePhone && data.phone) { - console.log("🔵 STEP 1: Updating phone for Google user"); - console.log("📱 Phone value from form:", data.phone); - console.log("📱 Phone type:", typeof data.phone); - console.log("📱 Phone length:", data.phone.length); - - const phonePayload = { phone: data.phone }; - console.log("📤 Sending payload:", phonePayload); - - const phoneResult = await updatePhoneMutation.mutateAsync(phonePayload); - console.log("✅ Phone updated successfully, result:", phoneResult); setPhoneUpdated(true); - } else { - console.log("⏭️ Skipping phone update:", { - requirePhone, - hasPhone: !!data.phone, - }); } - // Then complete the profile with gender and date of birth - console.log("🔵 STEP 2: Completing profile"); - console.log("👤 Profile data:", { - gender: data.gender, - date_of_birth: data.date_of_birth, - }); - await completeProfileMutation.mutateAsync({ gender: data.gender, date_of_birth: data.date_of_birth, }); - console.log("🎉 All steps completed successfully!"); clearSignupMethod(); } catch (error) { - console.error("❌ Error during profile completion:", error); - // Error handling is done in the mutation hooks + console.error("Error", error); } }; diff --git a/features/auth/login/api/login.api.ts b/features/auth/login/api/login.api.ts index ce56964..2de2829 100644 --- a/features/auth/login/api/login.api.ts +++ b/features/auth/login/api/login.api.ts @@ -10,11 +10,11 @@ export const loginUser = async ( credentials: LoginRequest ): Promise => { return api.post("/auth/login", credentials, { - skipAuth: true, }); }; export const logoutUser = async (): Promise => { + console.log("Logging out user..."); return api.post("/auth/logout"); }; diff --git a/features/auth/login/query/useLogin.query.ts b/features/auth/login/query/useLogin.query.ts index 6d34dd1..0f4f548 100644 --- a/features/auth/login/query/useLogin.query.ts +++ b/features/auth/login/query/useLogin.query.ts @@ -79,6 +79,7 @@ export const useLogout = (): UseMutationResult< onSuccess: (data) => { removeAuthToken(); clearUser(); + console.log("User logged out successfully"); const successMessage = locale === "ar" diff --git a/features/auth/password/api/password.api.ts b/features/auth/password/api/password.api.ts index 283aad4..ed1ab05 100644 --- a/features/auth/password/api/password.api.ts +++ b/features/auth/password/api/password.api.ts @@ -10,7 +10,6 @@ export const forgetPassword = async ( data: ForgetPasswordRequest ): Promise => { return api.post("/auth/forget-password", data, { - skipAuth: true, }); }; @@ -18,6 +17,5 @@ export const resetPassword = async ( data: ResetPasswordRequest ): Promise => { return api.post("/auth/reset-password", data, { - skipAuth: true, }); }; diff --git a/features/auth/register/api/register.api.ts b/features/auth/register/api/register.api.ts index 4af243c..67ac45c 100644 --- a/features/auth/register/api/register.api.ts +++ b/features/auth/register/api/register.api.ts @@ -12,7 +12,6 @@ export const signupUser = async ( userData: SignupRequest ): Promise => { return api.post("/auth/signup", userData, { - skipAuth: true, }); }; diff --git a/lib/apiClient.ts b/lib/apiClient.ts index dc44e32..bc43ab5 100644 --- a/lib/apiClient.ts +++ b/lib/apiClient.ts @@ -2,51 +2,85 @@ import axios, { AxiosInstance, AxiosRequestConfig, AxiosError, - InternalAxiosRequestConfig, } from "axios"; -import { getAuthToken, removeAuthToken } from "./tokenManager"; const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:3000"; -// single Axios instance +// Single Axios instance with cookie support const axiosInstance = axios.create({ baseURL: API_BASE_URL, timeout: 30000, // 30 seconds headers: { "Content-Type": "application/json", }, - withCredentials: true, + withCredentials: true, // CRITICAL: Sends cookies with every request }); - -axiosInstance.interceptors.request.use( - (config: InternalAxiosRequestConfig) => { - const token = getAuthToken(); - if (token && !config.headers.Authorization) { - config.headers.Authorization = `Bearer ${token}`; +// Token refresh logic +let refreshTokenPromise: Promise | null = null; + +const refreshAuthToken = async (): Promise => { + try { + // Call refresh endpoint - new cookies are set automatically + await axiosInstance.post("/auth/refresh"); + + // No manual token storage needed! + // Browser automatically updates cookies via Set-Cookie header + + refreshTokenPromise = null; + } catch (error) { + refreshTokenPromise = null; + + if (typeof window !== "undefined") { + console.error("❌ Token refresh failed, redirecting to login"); + + // Clear any client-side auth state if you have it + localStorage.removeItem('user'); + sessionStorage.clear(); + + window.location.href = "/login"; } - return config; - }, - (error: AxiosError) => { - return Promise.reject(error); + + throw error; } -); +}; -// Response interceptor +// Response interceptor - handles 401 errors and token refresh axiosInstance.interceptors.response.use( (response) => { return response; }, - (error: AxiosError) => { - if (error.response?.status === 401) { - removeAuthToken(); + async (error: AxiosError) => { + const originalRequest = error.config as any; + + // Handle 401 unauthorized errors (expired access token) + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + // If a refresh is already in progress, wait for it + if (refreshTokenPromise) { + try { + await refreshTokenPromise; + return axiosInstance(originalRequest); + } catch (refreshError) { + return Promise.reject(refreshError); + } + } + + // Start a new token refresh + refreshTokenPromise = refreshAuthToken(); - if (typeof window !== "undefined") { - window.location.href = "/login"; + try { + await refreshTokenPromise; + // Retry the original request with new cookies + return axiosInstance(originalRequest); + } catch (refreshError) { + return Promise.reject(refreshError); } } + // Handle network errors if (!error.response) { console.error("Network error:", error.message); } @@ -62,85 +96,43 @@ export class ApiClient { this.client = client; } - async get( - url: string, - config?: AxiosRequestConfig & { skipAuth?: boolean } - ): Promise { - const response = await this.client.get(url, this.prepareConfig(config)); + async get(url: string, config?: AxiosRequestConfig): Promise { + const response = await this.client.get(url, config); return response.data; } async post( url: string, data?: any, - config?: AxiosRequestConfig & { skipAuth?: boolean } + config?: AxiosRequestConfig ): Promise { - const response = await this.client.post( - url, - data, - this.prepareConfig(config) - ); + const response = await this.client.post(url, data, config); return response.data; } async put( url: string, data?: any, - config?: AxiosRequestConfig & { skipAuth?: boolean } + config?: AxiosRequestConfig ): Promise { - const response = await this.client.put( - url, - data, - this.prepareConfig(config) - ); + const response = await this.client.put(url, data, config); return response.data; } async patch( url: string, data?: any, - config?: AxiosRequestConfig & { skipAuth?: boolean } + config?: AxiosRequestConfig ): Promise { - const response = await this.client.patch( - url, - data, - this.prepareConfig(config) - ); + const response = await this.client.patch(url, data, config); return response.data; } - async delete( - url: string, - config?: AxiosRequestConfig & { skipAuth?: boolean } - ): Promise { - const response = await this.client.delete( - url, - this.prepareConfig(config) - ); + async delete(url: string, config?: AxiosRequestConfig): Promise { + const response = await this.client.delete(url, config); return response.data; } - - // handle skipAuth option - private prepareConfig( - config?: AxiosRequestConfig & { skipAuth?: boolean } - ): AxiosRequestConfig { - if (!config) return {}; - - const { skipAuth, ...axiosConfig } = config; - - if (skipAuth) { - return { - ...axiosConfig, - headers: { - ...axiosConfig.headers, - Authorization: undefined, - }, - }; - } - - return axiosConfig; - } } export const api = new ApiClient(); -export default api; +export default api; \ No newline at end of file From 78cf48590d2049a747a9ee9b84e2a7fe4e0878cd Mon Sep 17 00:00:00 2001 From: kareem Date: Tue, 11 Nov 2025 22:26:13 +0200 Subject: [PATCH 10/78] Enhance the calender component --- components/common/DatePickerPopover.tsx | 87 +++++++ components/ui/calendar.tsx | 216 ++++++++++++++++++ components/ui/popover.tsx | 48 ++++ .../CompleteProfileForm.tsx | 37 +-- locales/ar/auth.json | 1 + locales/en/auth.json | 1 + package-lock.json | 159 ++++++++++++- package.json | 5 +- 8 files changed, 538 insertions(+), 16 deletions(-) create mode 100644 components/common/DatePickerPopover.tsx create mode 100644 components/ui/calendar.tsx create mode 100644 components/ui/popover.tsx diff --git a/components/common/DatePickerPopover.tsx b/components/common/DatePickerPopover.tsx new file mode 100644 index 0000000..f21736e --- /dev/null +++ b/components/common/DatePickerPopover.tsx @@ -0,0 +1,87 @@ +"use client"; + +import * as React from "react"; +import { Calendar } from "@/components/ui/calendar"; +import { Button } from "@/components/ui/button"; +import { Calendar as CalendarIcon } from "lucide-react"; +import { format } from "date-fns"; +import { cn } from "@/lib/utils"; + +interface DatePickerPopoverProps { + date?: Date; + onDateChange: (date: Date | undefined) => void; + placeholder?: string; + disabled?: boolean; + hasError?: boolean; + fromYear?: number; + toYear?: number; + disableFutureDates?: boolean; + disablePastDates?: boolean; +} + +export function DatePickerPopover({ + date, + onDateChange, + placeholder = "Pick a date", + disabled = false, + hasError = false, + fromYear = 1900, + toYear = new Date().getFullYear(), + disableFutureDates = false, + disablePastDates = false, +}: DatePickerPopoverProps) { + const [isOpen, setIsOpen] = React.useState(false); + + const handleDateSelect = (selectedDate: Date | undefined) => { + onDateChange(selectedDate); + setIsOpen(false); + }; + + return ( + <> + + + {isOpen && ( +
setIsOpen(false)} + > +
e.stopPropagation()} + > + { + if (disableFutureDates && date > new Date()) return true; + if (disablePastDates && date < new Date()) return true; + if (date < new Date(fromYear, 0, 1)) return true; + if (date > new Date(toYear, 11, 31)) return true; + return false; + }} + captionLayout="dropdown" + fromYear={fromYear} + toYear={toYear} + className="rounded-md border shadow-sm" + /> +
+
+ )} + + ); +} diff --git a/components/ui/calendar.tsx b/components/ui/calendar.tsx new file mode 100644 index 0000000..7fc9dc8 --- /dev/null +++ b/components/ui/calendar.tsx @@ -0,0 +1,216 @@ +"use client" + +import * as React from "react" +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from "lucide-react" +import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString("default", { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn( + "flex gap-4 flex-col md:flex-row relative", + defaultClassNames.months + ), + month: cn("flex flex-col w-full gap-4", defaultClassNames.month), + nav: cn( + "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between", + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", + defaultClassNames.button_next + ), + month_caption: cn( + "flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)", + defaultClassNames.month_caption + ), + dropdowns: cn( + "w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5", + defaultClassNames.dropdowns + ), + dropdown_root: cn( + "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md", + defaultClassNames.dropdown_root + ), + dropdown: cn( + "absolute bg-popover inset-0 opacity-0", + defaultClassNames.dropdown + ), + caption_label: cn( + "select-none font-medium", + captionLayout === "label" + ? "text-sm" + : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5", + defaultClassNames.caption_label + ), + table: "w-full border-collapse", + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none", + defaultClassNames.weekday + ), + week: cn("flex w-full mt-2", defaultClassNames.week), + week_number_header: cn( + "select-none w-(--cell-size)", + defaultClassNames.week_number_header + ), + week_number: cn( + "text-[0.8rem] select-none text-muted-foreground", + defaultClassNames.week_number + ), + day: cn( + "relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none", + props.showWeekNumber + ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md" + : "[&:first-child[data-selected=true]_button]:rounded-l-md", + defaultClassNames.day + ), + range_start: cn( + "rounded-l-md bg-accent", + defaultClassNames.range_start + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end), + today: cn( + "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", + defaultClassNames.today + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled + ), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return ( +
+ ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + ...props +}: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( + + + + + + + setLocale("en")} + className={`cursor-pointer ${locale === "en" ? "bg-accent" : ""}`} + > + + English + + + setLocale("ar")} + className={`cursor-pointer ${locale === "ar" ? "bg-accent" : ""}`} + > + + العربية + + + + ); } diff --git a/components/common/ThemeSwitcher.tsx b/components/common/ThemeSwitcher.tsx index 7a1d994..d9d68a7 100644 --- a/components/common/ThemeSwitcher.tsx +++ b/components/common/ThemeSwitcher.tsx @@ -3,6 +3,7 @@ import { useTheme } from "@/contexts/ThemeProvider"; import { Moon, Sun } from "lucide-react"; import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; export function ThemeSwitcher() { const { theme, toggleTheme } = useTheme(); @@ -14,27 +15,22 @@ export function ThemeSwitcher() { if (!mounted) { return ( - + ); } return ( - + + + ); } diff --git a/components/layout/Navbar.tsx b/components/layout/Navbar.tsx new file mode 100644 index 0000000..76c7cfa --- /dev/null +++ b/components/layout/Navbar.tsx @@ -0,0 +1,189 @@ +"use client"; + +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { Menu, X } from "lucide-react"; +import { useState, useEffect, useRef } from "react"; +import { useUserStore } from "@/stores/useUserStore"; +import { Button } from "@/components/ui/button"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { LanguageSwitcher } from "@/components/common/LanguageSwitcher"; +import { ThemeSwitcher } from "@/components/common/ThemeSwitcher"; + +export function Navbar() { + const t = useTranslations(""); + const [isOpen, setIsOpen] = useState(false); + const { user, isAuthenticated } = useUserStore(); + const menuRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isOpen]); + + const navLinks = [ + { href: "/clinics", label: t("clinics") }, + { href: "/contact", label: t("contactUs") }, + ]; + + const getInitials = (name: string) => { + return name + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2); + }; + + return ( + + ); +} diff --git a/components/layout/index.ts b/components/layout/index.ts new file mode 100644 index 0000000..d97ce39 --- /dev/null +++ b/components/layout/index.ts @@ -0,0 +1 @@ +export { Navbar } from "./Navbar"; diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..71e428b --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,53 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..bbe6fb0 --- /dev/null +++ b/components/ui/dropdown-menu.tsx @@ -0,0 +1,257 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/components/ui/sheet.tsx b/components/ui/sheet.tsx new file mode 100644 index 0000000..84649ad --- /dev/null +++ b/components/ui/sheet.tsx @@ -0,0 +1,139 @@ +"use client" + +import * as React from "react" +import * as SheetPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Sheet({ ...props }: React.ComponentProps) { + return +} + +function SheetTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function SheetClose({ + ...props +}: React.ComponentProps) { + return +} + +function SheetPortal({ + ...props +}: React.ComponentProps) { + return +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = "right", + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left" +}) { + return ( + + + + {children} + + + Close + + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/features/auth/complete-profile/components/CompleteProfilePresentational.tsx b/features/auth/complete-profile/components/CompleteProfilePresentational.tsx index c605078..14818fc 100644 --- a/features/auth/complete-profile/components/CompleteProfilePresentational.tsx +++ b/features/auth/complete-profile/components/CompleteProfilePresentational.tsx @@ -29,7 +29,7 @@ export function CompleteProfilePresentational({ t, }: CompleteProfilePresentationalProps) { return ( -
+
diff --git a/features/auth/login/components/LoginPresentational.tsx b/features/auth/login/components/LoginPresentational.tsx index 5f2df55..d80c54b 100644 --- a/features/auth/login/components/LoginPresentational.tsx +++ b/features/auth/login/components/LoginPresentational.tsx @@ -25,7 +25,7 @@ export function LoginPresentational({ t, }: LoginPresentationalProps) { return ( -
+
diff --git a/features/auth/password/components/ForgotPasswordPresentational.tsx b/features/auth/password/components/ForgotPasswordPresentational.tsx index 0e4f964..8356f2b 100644 --- a/features/auth/password/components/ForgotPasswordPresentational.tsx +++ b/features/auth/password/components/ForgotPasswordPresentational.tsx @@ -27,7 +27,7 @@ export function ForgotPasswordPresentational({ t, }: ForgotPasswordPresentationalProps) { return ( -
+
diff --git a/features/auth/password/components/ResetPasswordPresentational.tsx b/features/auth/password/components/ResetPasswordPresentational.tsx index edb290b..bfd609b 100644 --- a/features/auth/password/components/ResetPasswordPresentational.tsx +++ b/features/auth/password/components/ResetPasswordPresentational.tsx @@ -25,7 +25,7 @@ export function ResetPasswordPresentational({ t, }: ResetPasswordPresentationalProps) { return ( -
+
diff --git a/features/auth/register/components/RegisterPresentational.tsx b/features/auth/register/components/RegisterPresentational.tsx index 7eedf28..8c5e321 100644 --- a/features/auth/register/components/RegisterPresentational.tsx +++ b/features/auth/register/components/RegisterPresentational.tsx @@ -25,7 +25,7 @@ export function RegisterPresentational({ t, }: RegisterPresentationalProps) { return ( -
+
diff --git a/features/auth/verify-email/components/VerifyEmailPresentational.tsx b/features/auth/verify-email/components/VerifyEmailPresentational.tsx index f9ac2c4..a7ebcbf 100644 --- a/features/auth/verify-email/components/VerifyEmailPresentational.tsx +++ b/features/auth/verify-email/components/VerifyEmailPresentational.tsx @@ -31,7 +31,7 @@ export function VerifyEmailPresentational({ t, }: VerifyEmailPresentationalProps) { return ( -
+
diff --git a/locales/ar/common.json b/locales/ar/common.json index bd148a4..598d82c 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5,6 +5,10 @@ "records": "السجلات الطبية", "settings": "الإعدادات", "logout": "تسجيل الخروج", + "login": "تسجيل الدخول", + "signup": "إنشاء حساب", + "clinics": "العيادات", + "contactUs": "تواصل معنا", "loading": "جاري التحميل...", "error": "خطأ", "success": "نجح", @@ -16,5 +20,6 @@ "search": "بحث", "filter": "تصفية", "export": "تصدير", - "import": "استيراد" + "import": "استيراد", + "navigation": "قائمة التنقل" } diff --git a/locales/en/common.json b/locales/en/common.json index 534f4ae..ebdcc9d 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5,6 +5,10 @@ "records": "Medical Records", "settings": "Settings", "logout": "Logout", + "login": "Login", + "signup": "Sign Up", + "clinics": "Clinics", + "contactUs": "Contact Us", "loading": "Loading...", "error": "Error", "success": "Success", @@ -16,5 +20,6 @@ "search": "Search", "filter": "Filter", "export": "Export", - "import": "Import" + "import": "Import", + "navigation": "Navigation Menu" } diff --git a/package-lock.json b/package-lock.json index 9be0fb4..5c48137 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,10 @@ "version": "0.1.0", "dependencies": { "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", @@ -1362,6 +1365,71 @@ } } }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.11.tgz", + "integrity": "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-checkbox": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", @@ -1466,6 +1534,60 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", @@ -1508,6 +1630,35 @@ } } }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", @@ -1589,6 +1740,64 @@ } } }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popover": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", @@ -1765,6 +1974,37 @@ } } }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", @@ -1948,6 +2188,24 @@ } } }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-layout-effect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", @@ -7708,6 +7966,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 4fab059..2487199 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,10 @@ }, "dependencies": { "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", From 431358c2b9e2879de42da4121972d0b3942f4579 Mon Sep 17 00:00:00 2001 From: kareem Date: Tue, 9 Dec 2025 18:04:59 +0200 Subject: [PATCH 17/78] refactor some code, add the Footer, edit the layout. --- app/layout.tsx | 5 ++-- app/page.tsx | 2 +- components/layout/Footer.tsx | 28 +++++++++++++++++++ components/layout/index.ts | 1 + .../CompleteProfilePresentational.tsx | 2 +- .../query/useCompleteProfile.query.ts | 2 +- .../types/completeProfileTypes.ts | 2 +- .../google-auth/query/useGoogleAuth.query.ts | 2 +- .../auth/google-auth/types/googleAuthTypes.ts | 2 +- .../login/components/LoginPresentational.tsx | 2 +- features/auth/login/query/useLogin.query.ts | 2 +- features/auth/login/types/authTypes.ts | 2 +- .../ForgotPasswordPresentational.tsx | 2 +- .../ResetPasswordPresentational.tsx | 2 +- features/auth/password/types/passwordTypes.ts | 2 +- .../components/RegisterPresentational.tsx | 2 +- .../auth/register/query/useRegister.query.ts | 2 +- features/auth/register/types/authTypes.ts | 2 +- .../components/VerifyEmailPresentational.tsx | 2 +- .../query/useVerifyEmail.query.ts | 2 +- .../verify-email/types/verifyEmailTypes.ts | 2 +- .../components/DashboardPresentational.tsx | 2 +- features/dashboard/types/dashboardTypes.ts | 2 +- hooks/useAuthSync.ts | 2 +- lib/apiClient.ts | 17 ++--------- lib/auth.ts | 2 +- locales/ar/common.json | 4 ++- locales/en/common.json | 4 ++- stores/useAuthFlowStore.ts | 1 - stores/useUserStore.ts | 2 +- types/common.ts | 16 +---------- types/index.ts | 1 + types/user.ts | 15 ++++++++++ 33 files changed, 80 insertions(+), 58 deletions(-) create mode 100644 components/layout/Footer.tsx create mode 100644 types/user.ts diff --git a/app/layout.tsx b/app/layout.tsx index 4baed24..9b8f6d5 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -7,7 +7,7 @@ import { ThemeProvider } from "@/contexts/ThemeProvider"; import { LanguageProvider } from "@/contexts/LanguageProvider"; import { QueryProvider } from "@/contexts/QueryProvider"; import { Toaster } from "@/components/ui/toaster"; -import { Navbar } from "@/components/layout/Navbar"; +import { Navbar, Footer } from "@/components/layout"; import "./globals.css"; const geistSans = Geist({ @@ -47,12 +47,13 @@ export default async function RootLayout({ -
+
{children}
+