Initial commit: silicondeck/shadcn-dashboard-landing-template (nextjs-version) + CLAUDE.md
- Next.js 16.1.1 + React 19.2.3 + Tailwind v4 + shadcn/ui v3 - Template scaffold (App Router with (auth)/(dashboard)/landing route groups) - pnpm v10 lockfile - CLAUDE.md describing multi-tenant Appwrite architecture, 8 modules, Gitea+Coolify deploy
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Image from "next/image"
|
||||
|
||||
export function ForbiddenError() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
|
||||
<Image
|
||||
src='https://ui.shadcn.com/placeholder.svg'
|
||||
alt='placeholder image'
|
||||
width={960}
|
||||
height={540}
|
||||
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
|
||||
/>
|
||||
<div className='text-center'>
|
||||
<h1 className='mb-4 text-3xl font-bold'>403</h1>
|
||||
<h2 className="mb-3 text-2xl font-semibold">Forbidden</h2>
|
||||
<p>Access to this resource is forbidden. You don't have the necessary permissions to view this page.</p>
|
||||
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
|
||||
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back Home</Button>
|
||||
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
|
||||
Contact Us
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ForbiddenError } from "./components/forbidden-error"
|
||||
|
||||
export default function ForbiddenPage() {
|
||||
return <ForbiddenError />
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Image from "next/image"
|
||||
|
||||
export function InternalServerError() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
|
||||
<Image
|
||||
src='https://ui.shadcn.com/placeholder.svg'
|
||||
alt='placeholder image'
|
||||
width={960}
|
||||
height={540}
|
||||
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
|
||||
/>
|
||||
<div className='text-center'>
|
||||
<h1 className='mb-4 text-3xl font-bold'>500</h1>
|
||||
<h2 className="mb-3 text-2xl font-semibold">Internal Server Error</h2>
|
||||
<p>Something went wrong on our end. We're working to fix the issue. Please try again later.</p>
|
||||
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
|
||||
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back Home</Button>
|
||||
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
|
||||
Contact Us
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { InternalServerError } from "./components/internal-server-error"
|
||||
|
||||
export default function InternalServerErrorPage() {
|
||||
return <InternalServerError />
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Image from "next/image"
|
||||
|
||||
export function NotFoundError() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
|
||||
<Image
|
||||
src='https://ui.shadcn.com/placeholder.svg'
|
||||
alt='placeholder image'
|
||||
width={960}
|
||||
height={540}
|
||||
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
|
||||
/>
|
||||
<div className='text-center'>
|
||||
<h1 className='mb-4 text-3xl font-bold'>404</h1>
|
||||
<h2 className="mb-3 text-2xl font-semibold">Page Not Found</h2>
|
||||
<p>The page you are looking for doesn't exist or has been moved to another location.</p>
|
||||
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
|
||||
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back Home</Button>
|
||||
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
|
||||
Contact Us
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NotFoundError } from "./components/not-found-error"
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return <NotFoundError />
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Image from "next/image"
|
||||
|
||||
export function UnauthorizedError() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
|
||||
<Image
|
||||
src='https://ui.shadcn.com/placeholder.svg'
|
||||
alt='placeholder image'
|
||||
width={960}
|
||||
height={540}
|
||||
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
|
||||
/>
|
||||
<div className='text-center'>
|
||||
<h1 className='mb-4 text-3xl font-bold'>401</h1>
|
||||
<h2 className="mb-3 text-2xl font-semibold">Unauthorized</h2>
|
||||
<p>You don't have permission to access this resource. Please sign in or contact your administrator.</p>
|
||||
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
|
||||
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back Home</Button>
|
||||
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
|
||||
Contact Us
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { UnauthorizedError } from "./components/unauthorized-error"
|
||||
|
||||
export default function UnauthorizedPage() {
|
||||
return <UnauthorizedError />
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Image from "next/image"
|
||||
|
||||
export function UnderMaintenanceError() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
|
||||
<Image
|
||||
src='https://ui.shadcn.com/placeholder.svg'
|
||||
alt='placeholder image'
|
||||
width={960}
|
||||
height={540}
|
||||
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
|
||||
/>
|
||||
<div className='text-center'>
|
||||
<h1 className='mb-4 text-3xl font-bold'>503</h1>
|
||||
<h2 className="mb-3 text-2xl font-semibold">Under Maintenance</h2>
|
||||
<p>The service is currently unavailable. Please try again later.</p>
|
||||
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
|
||||
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back Home</Button>
|
||||
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
|
||||
Contact Us
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { UnderMaintenanceError } from "./components/under-maintenance-error"
|
||||
|
||||
export default function UnderMaintenancePage() {
|
||||
return <UnderMaintenanceError />
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
export function ForgotPasswordForm2({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"form">) {
|
||||
return (
|
||||
<form className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<h1 className="text-2xl font-bold">Forgot your password?</h1>
|
||||
<p className="text-muted-foreground text-sm text-balance">
|
||||
Enter your email address and we'll send you a link to reset your password
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" type="email" placeholder="m@example.com" required />
|
||||
</div>
|
||||
<Button type="submit" className="w-full cursor-pointer">
|
||||
Send Reset Link
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center text-sm">
|
||||
Remember your password?{" "}
|
||||
<a href="/auth/sign-in-2" className="underline underline-offset-4">
|
||||
Back to sign in
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ForgotPasswordForm2 } from "./components/forgot-password-form-2"
|
||||
import { Logo } from "@/components/logo"
|
||||
import Link from "next/link"
|
||||
import Image from "next/image"
|
||||
|
||||
export default function ForgotPassword2Page() {
|
||||
return (
|
||||
<div className="grid min-h-svh lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-4 p-6 md:p-10">
|
||||
<div className="flex justify-center gap-2 md:justify-start">
|
||||
<Link href="/" className="flex items-center gap-2 font-medium">
|
||||
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
|
||||
<Logo size={24} />
|
||||
</div>
|
||||
ShadcnStore
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="w-full max-w-md">
|
||||
<ForgotPasswordForm2 />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-muted relative hidden lg:block">
|
||||
<Image
|
||||
src="https://ui.shadcn.com/placeholder.svg"
|
||||
alt="Image"
|
||||
fill
|
||||
className="object-cover dark:brightness-[0.95] dark:invert"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Logo } from "@/components/logo"
|
||||
import Link from "next/link"
|
||||
import Image from "next/image"
|
||||
|
||||
export function ForgotPasswordForm3({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card className="overflow-hidden p-0">
|
||||
<CardContent className="grid p-0 md:grid-cols-2">
|
||||
<form className="p-6 md:p-8">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex justify-center mb-2">
|
||||
<Link href="/" className="flex items-center gap-2 font-medium">
|
||||
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
|
||||
<Logo size={24} />
|
||||
</div>
|
||||
<span className="text-xl">ShadcnStore</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<h1 className="text-2xl font-bold">Forgot your password?</h1>
|
||||
<p className="text-muted-foreground text-balance">
|
||||
Enter your email to reset your ShadcnStore account password
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full cursor-pointer">
|
||||
Send Reset Link
|
||||
</Button>
|
||||
<div className="text-center text-sm">
|
||||
Remember your password?{" "}
|
||||
<a href="/auth/sign-in-3" className="underline underline-offset-4">
|
||||
Back to sign in
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div className="bg-muted relative hidden md:block">
|
||||
<Image
|
||||
src="https://ui.shadcn.com/placeholder.svg"
|
||||
alt="Image"
|
||||
fill
|
||||
className="object-cover dark:brightness-[0.95] dark:invert"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
|
||||
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
|
||||
and <a href="#">Privacy Policy</a>.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ForgotPasswordForm3 } from "./components/forgot-password-form-3"
|
||||
|
||||
export default function ForgotPassword3Page() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<ForgotPasswordForm3 className="w-full max-w-sm md:max-w-4xl" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
export function ForgotPasswordForm1({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">Forgot your password?</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email address and we'll send you a link to reset your password
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full cursor-pointer">
|
||||
Send Reset Link
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center text-sm">
|
||||
Remember your password?{" "}
|
||||
<a href="/auth/sign-in" className="underline underline-offset-4">
|
||||
Back to sign in
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ForgotPasswordForm1 } from "./components/forgot-password-form-1"
|
||||
import { Logo } from "@/components/logo"
|
||||
import Link from "next/link"
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return (
|
||||
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||
<Link href="/" className="flex items-center gap-2 self-center font-medium">
|
||||
<div className="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-md">
|
||||
<Logo size={24} />
|
||||
</div>
|
||||
ShadcnStore
|
||||
</Link>
|
||||
<ForgotPasswordForm1 />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Authentication - ShadcnStore",
|
||||
description: "Sign in to your account or create a new one",
|
||||
};
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
export function LoginForm2({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"form">) {
|
||||
return (
|
||||
<form className={cn("flex flex-col gap-6", className)} {...props} action="/dashboard">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<h1 className="text-2xl font-bold">Login to your account</h1>
|
||||
<p className="text-muted-foreground text-sm text-balance">
|
||||
Enter your email below to login to your account
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" type="email" placeholder="test@example.com" defaultValue="test@example.com" required />
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<a
|
||||
href="/auth/forgot-password-2"
|
||||
className="ml-auto text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
Forgot your password?
|
||||
</a>
|
||||
</div>
|
||||
<Input id="password" type="password" defaultValue="password" required />
|
||||
</div>
|
||||
<Button type="submit" className="w-full cursor-pointer">
|
||||
Login
|
||||
</Button>
|
||||
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
|
||||
<span className="bg-background text-muted-foreground relative z-10 px-2">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="outline" className="w-full cursor-pointer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Login with GitHub
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
<a href="/auth/sign-up-2" className="underline underline-offset-4">
|
||||
Sign up
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { LoginForm2 } from "./components/login-form-2"
|
||||
import { Logo } from "@/components/logo"
|
||||
import Link from "next/link"
|
||||
import Image from "next/image"
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="grid min-h-svh lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-4 p-6 md:p-10">
|
||||
<div className="flex justify-center gap-2 md:justify-start">
|
||||
<Link href="/" className="flex items-center gap-2 font-medium">
|
||||
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
|
||||
<Logo size={24} />
|
||||
</div>
|
||||
ShadcnStore
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="w-full max-w-md">
|
||||
<LoginForm2 />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-muted relative hidden lg:block">
|
||||
<Image
|
||||
src="https://ui.shadcn.com/placeholder.svg"
|
||||
alt="Image"
|
||||
fill
|
||||
className="object-cover dark:brightness-[0.95] dark:invert"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Logo } from "@/components/logo"
|
||||
import Link from "next/link"
|
||||
import Image from "next/image"
|
||||
|
||||
export function LoginForm3({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card className="overflow-hidden p-0">
|
||||
<CardContent className="grid p-0 md:grid-cols-2">
|
||||
<form className="p-6 md:p-8" action="/dashboard">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex justify-center mb-2">
|
||||
<Link href="/" className="flex items-center gap-2 font-medium">
|
||||
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
|
||||
<Logo size={24} />
|
||||
</div>
|
||||
<span className="text-xl">ShadcnStore</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<h1 className="text-2xl font-bold">Welcome back</h1>
|
||||
<p className="text-muted-foreground text-balance">
|
||||
Login to your ShadcnStore account
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="test@example.com"
|
||||
defaultValue="test@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<a
|
||||
href="/auth/forgot-password-3"
|
||||
className="ml-auto text-sm underline-offset-2 hover:underline"
|
||||
>
|
||||
Forgot your password?
|
||||
</a>
|
||||
</div>
|
||||
<Input id="password" type="password" defaultValue="password" required />
|
||||
</div>
|
||||
<Button type="submit" className="w-full cursor-pointer">
|
||||
Login
|
||||
</Button>
|
||||
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
|
||||
<span className="bg-card text-muted-foreground relative z-10 px-2">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Button variant="outline" type="button" className="w-full cursor-pointer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Login with Apple</span>
|
||||
</Button>
|
||||
<Button variant="outline" type="button" className="w-full cursor-pointer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Login with Google</span>
|
||||
</Button>
|
||||
<Button variant="outline" type="button" className="w-full cursor-pointer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Login with Meta</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
<a href="/auth/sign-up-3" className="underline underline-offset-4">
|
||||
Sign up
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div className="bg-muted relative hidden md:block">
|
||||
<Image
|
||||
src="https://ui.shadcn.com/placeholder.svg"
|
||||
alt="Image"
|
||||
fill
|
||||
className="object-cover dark:brightness-[0.95] dark:invert"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
|
||||
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
|
||||
and <a href="#">Privacy Policy</a>.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { LoginForm3 } from "./components/login-form-3"
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm md:max-w-4xl">
|
||||
<LoginForm3 />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
|
||||
const loginFormSchema = z.object({
|
||||
email: z.string().email("Invalid email address"),
|
||||
password: z.string().min(6, "Password must be at least 6 characters"),
|
||||
})
|
||||
|
||||
type LoginFormValues = z.infer<typeof loginFormSchema>
|
||||
|
||||
export function LoginForm1({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
const form = useForm<LoginFormValues>({
|
||||
resolver: zodResolver(loginFormSchema),
|
||||
defaultValues: {
|
||||
email: "test@example.com",
|
||||
password: "password",
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">Welcome back</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email below to login to your account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form action="/">
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="test@example.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center">
|
||||
<FormLabel>Password</FormLabel>
|
||||
<a
|
||||
href="/auth/forgot-password"
|
||||
className="ml-auto text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
Forgot your password?
|
||||
</a>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" className="w-full cursor-pointer">
|
||||
Login
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" className="w-full cursor-pointer" type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Login with Google
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
<a href="/auth/sign-up" className="underline underline-offset-4">
|
||||
Sign up
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
|
||||
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
|
||||
and <a href="#">Privacy Policy</a>.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { LoginForm1 } from "./components/login-form-1"
|
||||
import { Logo } from "@/components/logo"
|
||||
import Link from "next/link"
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||
<Link href="/" className="flex items-center gap-2 self-center font-medium">
|
||||
<div className="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-md">
|
||||
<Logo size={24} />
|
||||
</div>
|
||||
ShadcnStore
|
||||
</Link>
|
||||
<LoginForm1 />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
|
||||
export function SignupForm2({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"form">) {
|
||||
return (
|
||||
<form className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<h1 className="text-2xl font-bold">Create your account</h1>
|
||||
<p className="text-muted-foreground text-sm text-balance">
|
||||
Enter your information to create a new account
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="firstName">First Name</Label>
|
||||
<Input id="firstName" placeholder="John" required />
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="lastName">Last Name</Label>
|
||||
<Input id="lastName" placeholder="Doe" required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" type="email" placeholder="m@example.com" required />
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input id="password" type="password" required />
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<Input id="confirmPassword" type="password" required />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="terms" required />
|
||||
<Label htmlFor="terms" className="text-sm">
|
||||
I agree to the{" "}
|
||||
<a href="#" className="underline underline-offset-4 hover:text-primary">
|
||||
Terms of Service
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a href="#" className="underline underline-offset-4 hover:text-primary">
|
||||
Privacy Policy
|
||||
</a>
|
||||
</Label>
|
||||
</div>
|
||||
<Button type="submit" className="w-full cursor-pointer">
|
||||
Create Account
|
||||
</Button>
|
||||
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
|
||||
<span className="bg-background text-muted-foreground relative z-10 px-2">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="outline" className="w-full cursor-pointer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Sign up with GitHub
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center text-sm">
|
||||
Already have an account?{" "}
|
||||
<a href="/auth/sign-in-2" className="underline underline-offset-4">
|
||||
Sign in
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { SignupForm2 } from "./components/signup-form-2"
|
||||
import { Logo } from "@/components/logo"
|
||||
import Link from "next/link"
|
||||
import Image from "next/image"
|
||||
|
||||
export default function SignUp2Page() {
|
||||
return (
|
||||
<div className="grid min-h-svh lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-4 p-6 md:p-10">
|
||||
<div className="flex justify-center gap-2 md:justify-start">
|
||||
<Link href="/" className="flex items-center gap-2 font-medium">
|
||||
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
|
||||
<Logo size={24} />
|
||||
</div>
|
||||
ShadcnStore
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="w-full max-w-md">
|
||||
<SignupForm2 />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-muted relative hidden lg:block">
|
||||
<Image
|
||||
src="https://ui.shadcn.com/placeholder.svg"
|
||||
alt="Image"
|
||||
fill
|
||||
className="object-cover dark:brightness-[0.95] dark:invert"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Logo } from "@/components/logo"
|
||||
import Link from "next/link"
|
||||
import Image from "next/image"
|
||||
|
||||
export function SignupForm3({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card className="overflow-hidden p-0">
|
||||
<CardContent className="grid p-0 md:grid-cols-2">
|
||||
<form className="p-6 md:p-8">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex justify-center mb-2">
|
||||
<Link href="/" className="flex items-center gap-2 font-medium">
|
||||
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
|
||||
<Logo size={24} />
|
||||
</div>
|
||||
<span className="text-xl">ShadcnStore</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<h1 className="text-2xl font-bold">Create your account</h1>
|
||||
<p className="text-muted-foreground text-balance">
|
||||
Enter your information to create a new account
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="firstName">First Name</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
placeholder="John"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="lastName">Last Name</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
placeholder="Doe"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input id="password" type="password" required />
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<Input id="confirmPassword" type="password" required />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="terms" required />
|
||||
<Label htmlFor="terms" className="text-sm">
|
||||
I agree to the{" "}
|
||||
<a href="#" className="underline underline-offset-4 hover:text-primary">
|
||||
Terms of Service
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a href="#" className="underline underline-offset-4 hover:text-primary">
|
||||
Privacy Policy
|
||||
</a>
|
||||
</Label>
|
||||
</div>
|
||||
<Button type="submit" className="w-full cursor-pointer">
|
||||
Create Account
|
||||
</Button>
|
||||
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
|
||||
<span className="bg-card text-muted-foreground relative z-10 px-2">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Button variant="outline" type="button" className="w-full cursor-pointer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Sign up with Apple</span>
|
||||
</Button>
|
||||
<Button variant="outline" type="button" className="w-full cursor-pointer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Sign up with Google</span>
|
||||
</Button>
|
||||
<Button variant="outline" type="button" className="w-full cursor-pointer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Sign up with Meta</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center text-sm">
|
||||
Already have an account?{" "}
|
||||
<a href="/auth/sign-in-3" className="underline underline-offset-4">
|
||||
Sign in
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div className="bg-muted relative hidden md:block">
|
||||
<Image
|
||||
src="https://ui.shadcn.com/placeholder.svg"
|
||||
alt="Image"
|
||||
fill
|
||||
className="object-cover dark:brightness-[0.95] dark:invert"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
|
||||
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
|
||||
and <a href="#">Privacy Policy</a>.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SignupForm3 } from "./components/signup-form-3"
|
||||
|
||||
export default function SignUp3Page() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<SignupForm3 className="w-full max-w-5xl" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
|
||||
const signupFormSchema = z.object({
|
||||
firstName: z.string().min(1, "First name is required"),
|
||||
lastName: z.string().min(1, "Last name is required"),
|
||||
email: z.string().email("Invalid email address"),
|
||||
password: z.string().min(6, "Password must be at least 6 characters"),
|
||||
confirmPassword: z.string().min(6, "Please confirm your password"),
|
||||
terms: z.boolean().refine(val => val === true, "You must agree to the terms"),
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirmPassword"],
|
||||
})
|
||||
|
||||
type SignupFormValues = z.infer<typeof signupFormSchema>
|
||||
|
||||
export function SignupForm1({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
const form = useForm<SignupFormValues>({
|
||||
resolver: zodResolver(signupFormSchema),
|
||||
defaultValues: {
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
terms: false,
|
||||
},
|
||||
})
|
||||
|
||||
function onSubmit(data: SignupFormValues) {
|
||||
console.log("Signup attempt:", data)
|
||||
// Here you would typically handle the signup
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">Create Account</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your information to create a new account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="firstName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>First Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="John" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Last Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Doe" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terms"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-start space-x-2">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="text-sm">
|
||||
I agree to the terms of service and privacy policy
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" className="w-full cursor-pointer">
|
||||
Create Account
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" className="w-full cursor-pointer" type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Sign up with Google
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center text-sm">
|
||||
Already have an account?{" "}
|
||||
<a href="/auth/sign-in" className="underline underline-offset-4">
|
||||
Sign in
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
|
||||
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
|
||||
and <a href="#">Privacy Policy</a>.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { SignupForm1 } from "./components/signup-form-1"
|
||||
import { Logo } from "@/components/logo"
|
||||
import Link from "next/link"
|
||||
|
||||
export default function SignUpPage() {
|
||||
return (
|
||||
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||
<Link href="/" className="flex items-center gap-2 self-center font-medium">
|
||||
<div className="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-md">
|
||||
<Logo size={24} />
|
||||
</div>
|
||||
ShadcnStore
|
||||
</Link>
|
||||
<SignupForm1 />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Calendar as CalendarIcon,
|
||||
Clock,
|
||||
MapPin,
|
||||
Users,
|
||||
MoreHorizontal,
|
||||
Search,
|
||||
Grid3X3,
|
||||
List,
|
||||
ChevronDown,
|
||||
Menu
|
||||
} from "lucide-react"
|
||||
import { format, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday, isSameDay } from "date-fns"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from "@/components/ui/dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { type CalendarEvent } from "../types"
|
||||
|
||||
// Import data
|
||||
import eventsData from "../data/events.json"
|
||||
|
||||
interface CalendarMainProps {
|
||||
selectedDate?: Date
|
||||
onDateSelect?: (date: Date) => void
|
||||
onMenuClick?: () => void
|
||||
events?: CalendarEvent[]
|
||||
onEventClick?: (event: CalendarEvent) => void
|
||||
}
|
||||
|
||||
export function CalendarMain({ selectedDate, onDateSelect, onMenuClick, events, onEventClick }: CalendarMainProps) {
|
||||
// Convert JSON events to CalendarEvent objects with proper Date objects, fallback to imported data
|
||||
const sampleEvents: CalendarEvent[] = events || eventsData.map(event => ({
|
||||
...event,
|
||||
date: new Date(event.date),
|
||||
type: event.type as "meeting" | "event" | "personal" | "task" | "reminder"
|
||||
}))
|
||||
|
||||
const [currentDate, setCurrentDate] = useState(selectedDate || new Date())
|
||||
const [viewMode, setViewMode] = useState<"month" | "week" | "day" | "list">("month")
|
||||
const [showEventDialog, setShowEventDialog] = useState(false)
|
||||
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null)
|
||||
|
||||
const monthStart = startOfMonth(currentDate)
|
||||
const monthEnd = endOfMonth(currentDate)
|
||||
|
||||
// Extend to show full weeks (including previous/next month days)
|
||||
const calendarStart = new Date(monthStart)
|
||||
calendarStart.setDate(calendarStart.getDate() - monthStart.getDay())
|
||||
|
||||
const calendarEnd = new Date(monthEnd)
|
||||
calendarEnd.setDate(calendarEnd.getDate() + (6 - monthEnd.getDay()))
|
||||
|
||||
const calendarDays = eachDayOfInterval({ start: calendarStart, end: calendarEnd })
|
||||
|
||||
const getEventsForDay = (date: Date) => {
|
||||
return sampleEvents.filter(event => isSameDay(event.date, date))
|
||||
}
|
||||
|
||||
const navigateMonth = (direction: "prev" | "next") => {
|
||||
setCurrentDate(direction === "prev" ? subMonths(currentDate, 1) : addMonths(currentDate, 1))
|
||||
}
|
||||
|
||||
const goToToday = () => {
|
||||
setCurrentDate(new Date())
|
||||
}
|
||||
|
||||
const handleEventClick = (event: CalendarEvent) => {
|
||||
if (onEventClick) {
|
||||
onEventClick(event)
|
||||
} else {
|
||||
setSelectedEvent(event)
|
||||
setShowEventDialog(true)
|
||||
}
|
||||
}
|
||||
|
||||
const renderCalendarGrid = () => {
|
||||
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-background">
|
||||
{/* Calendar Header */}
|
||||
<div className="grid grid-cols-7 border-b">
|
||||
{weekDays.map(day => (
|
||||
<div key={day} className="p-4 text-center font-medium text-sm text-muted-foreground border-r last:border-r-0">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar Body */}
|
||||
<div className="grid grid-cols-7 flex-1">
|
||||
{calendarDays.map(day => {
|
||||
const dayEvents = getEventsForDay(day)
|
||||
const isCurrentMonth = isSameMonth(day, currentDate)
|
||||
const isDayToday = isToday(day)
|
||||
const isSelected = selectedDate && isSameDay(day, selectedDate)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
className={cn(
|
||||
"min-h-[120px] border-r border-b last:border-r-0 p-2 cursor-pointer transition-colors",
|
||||
isCurrentMonth ? "bg-background hover:bg-accent/50" : "bg-muted/30 text-muted-foreground",
|
||||
isSelected && "ring-2 ring-primary ring-inset",
|
||||
isDayToday && "bg-accent/20"
|
||||
)}
|
||||
onClick={() => onDateSelect?.(day)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className={cn(
|
||||
"text-sm font-medium",
|
||||
isDayToday && "bg-primary text-primary-foreground rounded-md w-6 h-6 flex items-center justify-center text-xs"
|
||||
)}>
|
||||
{format(day, 'd')}
|
||||
</span>
|
||||
{dayEvents.length > 2 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
+{dayEvents.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{dayEvents.slice(0, 2).map(event => (
|
||||
<div
|
||||
key={event.id}
|
||||
className={cn(
|
||||
"text-xs p-1 rounded-sm text-white cursor-pointer truncate",
|
||||
event.color
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEventClick(event)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span className="truncate">{event.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderListView = () => {
|
||||
const upcomingEvents = sampleEvents
|
||||
.filter(event => event.date >= new Date())
|
||||
.sort((a, b) => a.date.getTime() - b.date.getTime())
|
||||
|
||||
return (
|
||||
<div className="flex-1 p-6">
|
||||
<div className="space-y-4">
|
||||
{upcomingEvents.map(event => (
|
||||
<Card key={event.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => handleEventClick(event)}>
|
||||
<CardContent className="px-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn("w-3 h-3 rounded-full mt-1.5", event.color)} />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">{event.title}</h3>
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center flex-wrap gap-1">
|
||||
<CalendarIcon className="w-4 h-4" />
|
||||
{format(event.date, 'MMM d, yyyy')}
|
||||
</div>
|
||||
<div className="flex items-center flex-wrap gap-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
{event.time}
|
||||
</div>
|
||||
<div className="flex items-center flex-wrap gap-1">
|
||||
<MapPin className="w-4 h-4" />
|
||||
{event.location}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex -space-x-2">
|
||||
{event.attendees.slice(0, 3).map((attendee, index) => (
|
||||
<Avatar key={index} className="border-2 border-background">
|
||||
<AvatarFallback className="text-xs">{attendee}</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="cursor-pointer">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col flex-wrap gap-4 p-6 border-b md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
{/* Mobile Menu Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="xl:hidden cursor-pointer"
|
||||
onClick={onMenuClick}
|
||||
>
|
||||
<Menu className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => navigateMonth("prev")} className="cursor-pointer">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => navigateMonth("next")} className="cursor-pointer">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={goToToday} className="cursor-pointer">
|
||||
Today
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{format(currentDate, 'MMMM yyyy')}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||
<Input placeholder="Search events..." className="pl-10 w-64" />
|
||||
</div>
|
||||
|
||||
{/* View Mode Toggle */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="cursor-pointer">
|
||||
{viewMode === "month" && <Grid3X3 className="w-4 h-4 mr-2" />}
|
||||
{viewMode === "list" && <List className="w-4 h-4 mr-2" />}
|
||||
{viewMode.charAt(0).toUpperCase() + viewMode.slice(1)}
|
||||
<ChevronDown className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => setViewMode("month")} className="cursor-pointer">
|
||||
<Grid3X3 className="w-4 h-4 mr-2" />
|
||||
Month
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setViewMode("list")} className="cursor-pointer">
|
||||
<List className="w-4 h-4 mr-2" />
|
||||
List
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar Content */}
|
||||
{viewMode === "month" ? renderCalendarGrid() : renderListView()}
|
||||
|
||||
{/* Event Detail Dialog */}
|
||||
<Dialog open={showEventDialog} onOpenChange={setShowEventDialog}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedEvent?.title || "Event Details"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
View and manage this calendar event
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedEvent && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
|
||||
<span>{format(selectedEvent.date, 'EEEE, MMMM d, yyyy')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-muted-foreground" />
|
||||
<span>{selectedEvent.time} ({selectedEvent.duration})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-muted-foreground" />
|
||||
<span>{selectedEvent.location}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-muted-foreground" />
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Attendees:</span>
|
||||
<div className="flex -space-x-2">
|
||||
{selectedEvent.attendees.map((attendee: string, index: number) => (
|
||||
<Avatar key={index} className="w-6 h-6 border-2 border-background">
|
||||
<AvatarFallback className="text-xs">{attendee}</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className={cn("text-white", selectedEvent.color)}>
|
||||
{selectedEvent.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button variant="outline" className="flex-1 cursor-pointer" onClick={() => {
|
||||
setShowEventDialog(false)
|
||||
}}>Edit</Button>
|
||||
<Button variant="destructive" className="flex-1 cursor-pointer" onClick={() => {
|
||||
setShowEventDialog(false)
|
||||
}}>Delete</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client"
|
||||
|
||||
import { Plus } from "lucide-react"
|
||||
|
||||
import { Calendars } from "./calendars"
|
||||
import { DatePicker } from "./date-picker"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
interface CalendarSidebarProps {
|
||||
selectedDate?: Date
|
||||
onDateSelect?: (date: Date) => void
|
||||
onNewCalendar?: () => void
|
||||
onNewEvent?: () => void
|
||||
events?: Array<{ date: Date; count: number }>
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CalendarSidebar({
|
||||
selectedDate,
|
||||
onDateSelect,
|
||||
onNewCalendar,
|
||||
onNewEvent,
|
||||
events = [],
|
||||
className
|
||||
}: CalendarSidebarProps) {
|
||||
return (
|
||||
<div className={`flex flex-col h-full bg-background rounded-lg ${className}`}>
|
||||
{/* Add New Event Button */}
|
||||
<div className="p-6 border-b">
|
||||
<Button
|
||||
className="w-full cursor-pointer"
|
||||
onClick={onNewEvent}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add New Event
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Date Picker */}
|
||||
<DatePicker
|
||||
selectedDate={selectedDate}
|
||||
onDateSelect={onDateSelect}
|
||||
events={events}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Calendars */}
|
||||
<div className="flex-1 p-4">
|
||||
<Calendars
|
||||
onNewCalendar={onNewCalendar}
|
||||
onCalendarToggle={(calendarId, visible) => {
|
||||
console.log(`Calendar ${calendarId} visibility: ${visible}`)
|
||||
}}
|
||||
onCalendarEdit={(calendarId) => {
|
||||
console.log(`Edit calendar: ${calendarId}`)
|
||||
}}
|
||||
onCalendarDelete={(calendarId) => {
|
||||
console.log(`Delete calendar: ${calendarId}`)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start cursor-pointer"
|
||||
onClick={onNewCalendar}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Calendar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Calendar as CalendarIcon,
|
||||
Clock,
|
||||
MapPin,
|
||||
Users,
|
||||
Search,
|
||||
Grid3X3,
|
||||
List,
|
||||
ChevronDown,
|
||||
Menu,
|
||||
Plus
|
||||
} from "lucide-react"
|
||||
import { format, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday, isSameDay } from "date-fns"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from "@/components/ui/dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { type CalendarEvent } from "../types"
|
||||
|
||||
// Import data
|
||||
import eventsData from "../data/events.json"
|
||||
import calendarsData from "../data/calendars.json"
|
||||
|
||||
interface CalendarMainProps {
|
||||
eventDates?: Array<{ date: Date; count: number }>
|
||||
}
|
||||
|
||||
export function CalendarMain({ eventDates = [] }: CalendarMainProps) {
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date())
|
||||
const [currentDate, setCurrentDate] = useState(new Date())
|
||||
const [viewMode, setViewMode] = useState<"month" | "week" | "day" | "list">("month")
|
||||
const [showEventDialog, setShowEventDialog] = useState(false)
|
||||
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null)
|
||||
const [showCalendarSheet, setShowCalendarSheet] = useState(false)
|
||||
|
||||
// Convert JSON events to CalendarEvent objects with proper Date objects
|
||||
const sampleEvents: CalendarEvent[] = eventsData.map(event => ({
|
||||
...event,
|
||||
date: new Date(event.date),
|
||||
type: event.type as "meeting" | "event" | "personal" | "task" | "reminder"
|
||||
}))
|
||||
|
||||
const monthStart = startOfMonth(currentDate)
|
||||
const monthEnd = endOfMonth(currentDate)
|
||||
|
||||
// Extend to show full weeks (including previous/next month days)
|
||||
const calendarStart = new Date(monthStart)
|
||||
calendarStart.setDate(calendarStart.getDate() - monthStart.getDay())
|
||||
|
||||
const calendarEnd = new Date(monthEnd)
|
||||
calendarEnd.setDate(calendarEnd.getDate() + (6 - monthEnd.getDay()))
|
||||
|
||||
const calendarDays = eachDayOfInterval({ start: calendarStart, end: calendarEnd })
|
||||
|
||||
const getEventsForDay = (date: Date) => {
|
||||
return sampleEvents.filter(event => isSameDay(event.date, date))
|
||||
}
|
||||
|
||||
const navigateMonth = (direction: "prev" | "next") => {
|
||||
setCurrentDate(direction === "prev" ? subMonths(currentDate, 1) : addMonths(currentDate, 1))
|
||||
}
|
||||
|
||||
const goToToday = () => {
|
||||
setCurrentDate(new Date())
|
||||
}
|
||||
|
||||
const handleEventClick = (event: CalendarEvent) => {
|
||||
setSelectedEvent(event)
|
||||
setShowEventDialog(true)
|
||||
}
|
||||
|
||||
const handleDateSelect = (date: Date) => {
|
||||
setSelectedDate(date)
|
||||
}
|
||||
|
||||
const handleNewCalendar = () => {
|
||||
console.log("Creating new calendar")
|
||||
// In a real app, this would open a new calendar form
|
||||
}
|
||||
|
||||
const handleNewEvent = () => {
|
||||
console.log("Creating new event")
|
||||
// In a real app, this would open event form
|
||||
}
|
||||
|
||||
const renderCalendarGrid = () => {
|
||||
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-background">
|
||||
{/* Calendar Header */}
|
||||
<div className="grid grid-cols-7 border-b">
|
||||
{weekDays.map(day => (
|
||||
<div key={day} className="p-4 text-center font-medium text-sm text-muted-foreground border-r last:border-r-0">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<div className="grid grid-cols-7 min-h-[600px]">
|
||||
{calendarDays.map((day) => {
|
||||
const dayEvents = getEventsForDay(day)
|
||||
const isCurrentMonth = isSameMonth(day, currentDate)
|
||||
const isDayToday = isToday(day)
|
||||
const isSelected = isSameDay(day, selectedDate)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
className={cn(
|
||||
"relative border-r border-b last:border-r-0 p-2 min-h-[120px] hover:bg-muted/50 cursor-pointer transition-colors",
|
||||
!isCurrentMonth && "text-muted-foreground bg-muted/20",
|
||||
isDayToday && "bg-blue-50 dark:bg-blue-900/20",
|
||||
isSelected && "bg-blue-100 dark:bg-blue-800/30"
|
||||
)}
|
||||
onClick={() => handleDateSelect(day)}
|
||||
>
|
||||
{/* Date Number */}
|
||||
<div className={cn(
|
||||
"text-sm font-medium mb-1",
|
||||
isDayToday && "text-blue-600 dark:text-blue-400"
|
||||
)}>
|
||||
{format(day, 'd')}
|
||||
</div>
|
||||
|
||||
{/* Events */}
|
||||
<div className="space-y-1">
|
||||
{dayEvents.slice(0, 3).map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className={cn(
|
||||
"text-xs px-2 py-1 rounded text-white cursor-pointer hover:opacity-80 transition-opacity truncate",
|
||||
event.color
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEventClick(event)
|
||||
}}
|
||||
>
|
||||
{event.time} {event.title}
|
||||
</div>
|
||||
))}
|
||||
{dayEvents.length > 3 && (
|
||||
<div className="text-xs text-muted-foreground px-2">
|
||||
+{dayEvents.length - 3} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderSidebar = () => (
|
||||
<div className="w-full h-full bg-background border-r">
|
||||
<div className="p-4 border-b">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold">Calendar</h2>
|
||||
<Button size="sm" onClick={handleNewEvent}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Event
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Date Picker */}
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={(date) => date && handleDateSelect(date)}
|
||||
className="rounded-md border"
|
||||
modifiers={{
|
||||
eventDay: eventDates.map(ed => ed.date)
|
||||
}}
|
||||
modifiersStyles={{
|
||||
eventDay: { fontWeight: 'bold' }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mini Calendars List */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium">My Calendars</h3>
|
||||
<Button variant="ghost" size="sm" onClick={handleNewCalendar}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{calendarsData.map((calendar) => (
|
||||
<div key={calendar.id} className="flex items-center space-x-2">
|
||||
<div className={cn("w-3 h-3 rounded-full", calendar.color)} />
|
||||
<span className="text-sm">{calendar.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg bg-background relative">
|
||||
<div className="flex min-h-[800px]">
|
||||
{/* Desktop Sidebar */}
|
||||
<div className="hidden xl:block w-80 flex-shrink-0">
|
||||
{renderSidebar()}
|
||||
</div>
|
||||
|
||||
{/* Main Calendar Panel */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Calendar Toolbar */}
|
||||
<div className="border-b bg-background px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Mobile Menu Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="xl:hidden"
|
||||
onClick={() => setShowCalendarSheet(true)}
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Month Navigation */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigateMonth("prev")}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h2 className="text-lg font-semibold min-w-[140px] text-center">
|
||||
{format(currentDate, 'MMMM yyyy')}
|
||||
</h2>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigateMonth("next")}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={goToToday}>
|
||||
Today
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="hidden sm:flex items-center space-x-2">
|
||||
<Button variant="ghost" size="sm" className="text-xs">
|
||||
<Search className="h-4 w-4 mr-1" />
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* View Mode Toggle */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Grid3X3 className="h-4 w-4 mr-1" />
|
||||
{viewMode === "month" ? "Month" : viewMode === "week" ? "Week" : viewMode === "day" ? "Day" : "List"}
|
||||
<ChevronDown className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setViewMode("month")}>
|
||||
<Grid3X3 className="h-4 w-4 mr-2" />
|
||||
Month
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setViewMode("week")}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
Week
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setViewMode("day")}>
|
||||
<CalendarIcon className="h-4 w-4 mr-2" />
|
||||
Day
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setViewMode("list")}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
List
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar Content */}
|
||||
{renderCalendarGrid()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile/Tablet Sheet */}
|
||||
<Sheet open={showCalendarSheet} onOpenChange={setShowCalendarSheet}>
|
||||
<SheetContent side="left" className="w-80 p-0">
|
||||
<SheetHeader className="p-4 pb-2">
|
||||
<SheetTitle>Calendar</SheetTitle>
|
||||
<SheetDescription>
|
||||
Browse dates and manage your calendar events
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
{renderSidebar()}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* Event Details Dialog */}
|
||||
<Dialog open={showEventDialog} onOpenChange={setShowEventDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedEvent?.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Event details and information
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedEvent && (
|
||||
<div className="space-y-4 pt-4">
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{selectedEvent.time} • {selectedEvent.duration}</span>
|
||||
</div>
|
||||
|
||||
{selectedEvent.location && (
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
<MapPin className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{selectedEvent.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedEvent.attendees.length > 0 && (
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex space-x-1">
|
||||
{selectedEvent.attendees.map((attendee, index) => (
|
||||
<Avatar key={index} className="h-6 w-6">
|
||||
<AvatarFallback className="text-xs">
|
||||
{attendee}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedEvent.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{selectedEvent.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-2 pt-4">
|
||||
<Badge variant="secondary" className={cn("text-white", selectedEvent.color)}>
|
||||
{selectedEvent.type}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client"
|
||||
|
||||
import { CalendarSidebar } from "./calendar-sidebar"
|
||||
import { CalendarMain } from "./calendar-main"
|
||||
import { EventForm } from "./event-form"
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
import { type CalendarEvent } from "../types"
|
||||
import { useCalendar } from "../use-calendar"
|
||||
|
||||
interface CalendarProps {
|
||||
events: CalendarEvent[]
|
||||
eventDates: Array<{ date: Date; count: number }>
|
||||
}
|
||||
|
||||
export function Calendar({ events, eventDates }: CalendarProps) {
|
||||
const calendar = useCalendar(events)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border rounded-lg bg-background relative">
|
||||
<div className="flex min-h-[800px]">
|
||||
{/* Desktop Sidebar - Hidden on mobile/tablet, shown on extra large screens */}
|
||||
<div className="hidden xl:block w-80 flex-shrink-0 border-r">
|
||||
<CalendarSidebar
|
||||
selectedDate={calendar.selectedDate}
|
||||
onDateSelect={calendar.handleDateSelect}
|
||||
onNewCalendar={calendar.handleNewCalendar}
|
||||
onNewEvent={calendar.handleNewEvent}
|
||||
events={eventDates}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Calendar Panel */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<CalendarMain
|
||||
selectedDate={calendar.selectedDate}
|
||||
onDateSelect={calendar.handleDateSelect}
|
||||
onMenuClick={() => calendar.setShowCalendarSheet(true)}
|
||||
events={calendar.events}
|
||||
onEventClick={calendar.handleEditEvent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile/Tablet Sheet - Positioned relative to calendar container */}
|
||||
<Sheet open={calendar.showCalendarSheet} onOpenChange={calendar.setShowCalendarSheet}>
|
||||
<SheetContent side="left" className="w-80 p-0" style={{ position: 'absolute' }}>
|
||||
<SheetHeader className="p-4 pb-2">
|
||||
<SheetTitle>Calendar</SheetTitle>
|
||||
<SheetDescription>
|
||||
Browse dates and manage your calendar events
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<CalendarSidebar
|
||||
selectedDate={calendar.selectedDate}
|
||||
onDateSelect={calendar.handleDateSelect}
|
||||
onNewCalendar={calendar.handleNewCalendar}
|
||||
onNewEvent={calendar.handleNewEvent}
|
||||
events={eventDates}
|
||||
className="h-full"
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
{/* Event Form Dialog */}
|
||||
<EventForm
|
||||
event={calendar.editingEvent}
|
||||
open={calendar.showEventForm}
|
||||
onOpenChange={calendar.setShowEventForm}
|
||||
onSave={calendar.handleSaveEvent}
|
||||
onDelete={calendar.handleDeleteEvent}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Check, ChevronRight, Plus, Eye, EyeOff, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface CalendarItem {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
visible: boolean
|
||||
type: "personal" | "work" | "shared"
|
||||
}
|
||||
|
||||
interface CalendarGroup {
|
||||
name: string
|
||||
items: CalendarItem[]
|
||||
}
|
||||
|
||||
interface CalendarsProps {
|
||||
calendars?: {
|
||||
name: string
|
||||
items: string[]
|
||||
}[]
|
||||
onCalendarToggle?: (calendarId: string, visible: boolean) => void
|
||||
onCalendarEdit?: (calendarId: string) => void
|
||||
onCalendarDelete?: (calendarId: string) => void
|
||||
onNewCalendar?: () => void
|
||||
}
|
||||
|
||||
// Enhanced calendar data with colors and visibility
|
||||
const enhancedCalendars: CalendarGroup[] = [
|
||||
{
|
||||
name: "My Calendars",
|
||||
items: [
|
||||
{ id: "personal", name: "Personal", color: "bg-blue-500", visible: true, type: "personal" },
|
||||
{ id: "work", name: "Work", color: "bg-green-500", visible: true, type: "work" },
|
||||
{ id: "family", name: "Family", color: "bg-pink-500", visible: true, type: "personal" }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Favorites",
|
||||
items: [
|
||||
{ id: "holidays", name: "Holidays", color: "bg-red-500", visible: true, type: "shared" },
|
||||
{ id: "birthdays", name: "Birthdays", color: "bg-purple-500", visible: true, type: "personal" }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Other",
|
||||
items: [
|
||||
{ id: "travel", name: "Travel", color: "bg-orange-500", visible: false, type: "personal" },
|
||||
{ id: "reminders", name: "Reminders", color: "bg-yellow-500", visible: true, type: "personal" },
|
||||
{ id: "deadlines", name: "Deadlines", color: "bg-red-600", visible: true, type: "work" }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
export function Calendars({
|
||||
onCalendarToggle,
|
||||
onCalendarEdit,
|
||||
onCalendarDelete,
|
||||
onNewCalendar
|
||||
}: CalendarsProps) {
|
||||
const [calendarData, setCalendarData] = useState(enhancedCalendars)
|
||||
|
||||
const handleToggleVisibility = (calendarId: string) => {
|
||||
setCalendarData(prev => prev.map(group => ({
|
||||
...group,
|
||||
items: group.items.map(item =>
|
||||
item.id === calendarId
|
||||
? { ...item, visible: !item.visible }
|
||||
: item
|
||||
)
|
||||
})))
|
||||
|
||||
const calendar = calendarData.flatMap(g => g.items).find(c => c.id === calendarId)
|
||||
if (calendar) {
|
||||
onCalendarToggle?.(calendarId, !calendar.visible)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{calendarData.map((calendar, index) => (
|
||||
<div key={calendar.name}>
|
||||
<Collapsible
|
||||
defaultOpen={index === 0}
|
||||
className="group/collapsible"
|
||||
>
|
||||
<CollapsibleTrigger className="flex items-center justify-between w-full p-2 hover:bg-accent hover:text-accent-foreground rounded-md cursor-pointer">
|
||||
<span className="text-sm font-medium">{calendar.name}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{index === 0 && (
|
||||
<div
|
||||
className="h-5 w-5 flex items-center justify-center opacity-0 group-hover/collapsible:opacity-100 cursor-pointer hover:bg-accent rounded-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onNewCalendar?.()
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
<ChevronRight className="h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-90" />
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className="mt-2 space-y-1">
|
||||
{calendar.items.map((item) => (
|
||||
<div key={item.id} className="group/calendar-item">
|
||||
<div className="flex items-center justify-between p-2 hover:bg-accent/50 rounded-md">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
{/* Calendar Color & Visibility Toggle */}
|
||||
<button
|
||||
onClick={() => handleToggleVisibility(item.id)}
|
||||
className={cn(
|
||||
"flex aspect-square size-4 shrink-0 items-center justify-center rounded-sm border transition-all cursor-pointer",
|
||||
item.visible
|
||||
? cn("border-transparent text-white", item.color)
|
||||
: "border-border bg-transparent"
|
||||
)}
|
||||
>
|
||||
{item.visible && <Check className="size-3" />}
|
||||
</button>
|
||||
|
||||
{/* Calendar Name */}
|
||||
<span
|
||||
className={cn(
|
||||
"flex-1 truncate text-sm cursor-pointer",
|
||||
!item.visible && "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => handleToggleVisibility(item.id)}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
|
||||
{/* Visibility Icon */}
|
||||
<div className="opacity-0 group-hover/calendar-item:opacity-100">
|
||||
{item.visible ? (
|
||||
<Eye className="h-3 w-3 text-muted-foreground" />
|
||||
) : (
|
||||
<EyeOff className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* More Options */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
className="h-5 w-5 flex items-center justify-center p-0 opacity-0 group-hover/calendar-item:opacity-100 cursor-pointer hover:bg-accent rounded-sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-3 w-3" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="right">
|
||||
<DropdownMenuItem
|
||||
onClick={() => onCalendarEdit?.(item.id)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Edit calendar
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleToggleVisibility(item.id)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{item.visible ? "Hide" : "Show"} calendar
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onCalendarDelete?.(item.id)}
|
||||
className="cursor-pointer text-destructive"
|
||||
>
|
||||
Delete calendar
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
|
||||
interface DatePickerProps {
|
||||
selectedDate?: Date
|
||||
onDateSelect?: (date: Date) => void
|
||||
events?: Array<{ date: Date; count: number }>
|
||||
}
|
||||
|
||||
export function DatePicker({ selectedDate, onDateSelect, events = [] }: DatePickerProps) {
|
||||
const [date, setDate] = useState<Date | undefined>(selectedDate || new Date())
|
||||
|
||||
const handleDateSelect = (selectedDate: Date | undefined) => {
|
||||
if (selectedDate) {
|
||||
setDate(selectedDate)
|
||||
onDateSelect?.(selectedDate)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a map of dates with events for styling
|
||||
const eventDates = events.reduce((acc, event) => {
|
||||
const dateKey = event.date.toDateString()
|
||||
acc[dateKey] = event.count
|
||||
return acc
|
||||
}, {} as Record<string, number>)
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={handleDateSelect}
|
||||
className="w-full [&_[role=gridcell]_button]:cursor-pointer [&_button]:cursor-pointer"
|
||||
modifiers={{
|
||||
hasEvents: (date) => {
|
||||
const eventCount = eventDates[date.toDateString()]
|
||||
return Boolean(eventCount && eventCount > 0)
|
||||
}
|
||||
}}
|
||||
modifiersClassNames={{
|
||||
hasEvents: "relative after:absolute after:bottom-1 after:right-1 after:w-1.5 after:h-1.5 after:bg-primary after:rounded-full"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { CalendarIcon, Clock, MapPin, Users, Type, Tag } from "lucide-react"
|
||||
import { format } from "date-fns"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@/components/ui/popover"
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { type CalendarEvent } from "../types"
|
||||
|
||||
interface EventFormProps {
|
||||
event?: CalendarEvent | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: (event: Partial<CalendarEvent>) => void
|
||||
onDelete?: (eventId: number) => void
|
||||
}
|
||||
|
||||
const eventTypes = [
|
||||
{ value: "meeting", label: "Meeting", color: "bg-blue-500" },
|
||||
{ value: "event", label: "Event", color: "bg-green-500" },
|
||||
{ value: "personal", label: "Personal", color: "bg-pink-500" },
|
||||
{ value: "task", label: "Task", color: "bg-orange-500" },
|
||||
{ value: "reminder", label: "Reminder", color: "bg-purple-500" }
|
||||
]
|
||||
|
||||
const timeSlots = [
|
||||
"9:00 AM", "9:30 AM", "10:00 AM", "10:30 AM", "11:00 AM", "11:30 AM",
|
||||
"12:00 PM", "12:30 PM", "1:00 PM", "1:30 PM", "2:00 PM", "2:30 PM",
|
||||
"3:00 PM", "3:30 PM", "4:00 PM", "4:30 PM", "5:00 PM", "5:30 PM",
|
||||
"6:00 PM", "6:30 PM", "7:00 PM", "7:30 PM", "8:00 PM", "8:30 PM"
|
||||
]
|
||||
|
||||
const durationOptions = [
|
||||
"15 min", "30 min", "45 min", "1 hour", "1.5 hours", "2 hours", "3 hours", "All day"
|
||||
]
|
||||
|
||||
export function EventForm({ event, open, onOpenChange, onSave, onDelete }: EventFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
title: event?.title || "",
|
||||
date: event?.date || new Date(),
|
||||
time: event?.time || "9:00 AM",
|
||||
duration: event?.duration || "1 hour",
|
||||
type: event?.type || "meeting",
|
||||
location: event?.location || "",
|
||||
description: event?.description || "",
|
||||
attendees: event?.attendees || [],
|
||||
allDay: false,
|
||||
reminder: true
|
||||
})
|
||||
|
||||
const [showCalendar, setShowCalendar] = useState(false)
|
||||
const [newAttendee, setNewAttendee] = useState("")
|
||||
|
||||
const handleSave = () => {
|
||||
const eventData: Partial<CalendarEvent> = {
|
||||
...formData,
|
||||
id: event?.id,
|
||||
color: eventTypes.find(t => t.value === formData.type)?.color || "bg-blue-500"
|
||||
}
|
||||
onSave(eventData)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (event?.id && onDelete) {
|
||||
onDelete(event.id)
|
||||
onOpenChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
const addAttendee = () => {
|
||||
if (newAttendee.trim() && !formData.attendees.includes(newAttendee.trim())) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
attendees: [...prev.attendees, newAttendee.trim()]
|
||||
}))
|
||||
setNewAttendee("")
|
||||
}
|
||||
}
|
||||
|
||||
const removeAttendee = (attendee: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
attendees: prev.attendees.filter(a => a !== attendee)
|
||||
}))
|
||||
}
|
||||
|
||||
const selectedEventType = eventTypes.find(t => t.value === formData.type)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<div className={cn("w-3 h-3 rounded-full", selectedEventType?.color)} />
|
||||
{event ? "Edit Event" : "Create New Event"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{event ? "Make changes to this event" : "Add a new event to your calendar"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Event Title */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title" className="flex items-center gap-2">
|
||||
<Type className="w-4 h-4" />
|
||||
Event Title
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="Enter event title..."
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
||||
className="text-lg font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Event Type */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Tag className="w-4 h-4" />
|
||||
Event Type
|
||||
</Label>
|
||||
<Select value={formData.type} onValueChange={(value) => setFormData(prev => ({ ...prev, type: value as CalendarEvent["type"] }))}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{eventTypes.map(type => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn("w-3 h-3 rounded-full", type.color)} />
|
||||
{type.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date and Time */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<CalendarIcon className="w-4 h-4" />
|
||||
Date
|
||||
</Label>
|
||||
<Popover open={showCalendar} onOpenChange={setShowCalendar}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start text-left font-normal">
|
||||
{format(formData.date, "PPP")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={formData.date}
|
||||
onSelect={(date) => {
|
||||
if (date) {
|
||||
setFormData(prev => ({ ...prev, date }))
|
||||
setShowCalendar(false)
|
||||
}
|
||||
}}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
Time
|
||||
</Label>
|
||||
<Select value={formData.time} onValueChange={(value) => setFormData(prev => ({ ...prev, time: value }))}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{timeSlots.map(time => (
|
||||
<SelectItem key={time} value={time}>{time}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration and All Day */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Duration</Label>
|
||||
<Select value={formData.duration} onValueChange={(value) => setFormData(prev => ({ ...prev, duration: value }))}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{durationOptions.map(duration => (
|
||||
<SelectItem key={duration} value={duration}>{duration}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Options</Label>
|
||||
<div className="flex items-center space-x-4 h-10">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="all-day"
|
||||
checked={formData.allDay}
|
||||
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, allDay: checked }))}
|
||||
/>
|
||||
<Label htmlFor="all-day" className="text-sm">All day</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="reminder"
|
||||
checked={formData.reminder}
|
||||
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, reminder: checked }))}
|
||||
/>
|
||||
<Label htmlFor="reminder" className="text-sm">Reminder</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="location" className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4" />
|
||||
Location
|
||||
</Label>
|
||||
<Input
|
||||
id="location"
|
||||
placeholder="Add location..."
|
||||
value={formData.location}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, location: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Attendees */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
Attendees
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Add attendee..."
|
||||
value={newAttendee}
|
||||
onChange={(e) => setNewAttendee(e.target.value)}
|
||||
onKeyPress={(e) => e.key === "Enter" && addAttendee()}
|
||||
/>
|
||||
<Button onClick={addAttendee} variant="outline" className="cursor-pointer">Add</Button>
|
||||
</div>
|
||||
{formData.attendees.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{formData.attendees.map((attendee, index) => (
|
||||
<Badge key={index} variant="secondary" className="flex items-center gap-2 px-2 py-1">
|
||||
<Avatar className="w-5 h-5">
|
||||
<AvatarFallback className="text-[10px] font-medium">
|
||||
{attendee.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm">{attendee}</span>
|
||||
<button
|
||||
onClick={() => removeAttendee(attendee)}
|
||||
className="text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Add description..."
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-6">
|
||||
<Button onClick={handleSave} className="flex-1 cursor-pointer">
|
||||
{event ? "Update Event" : "Create Event"}
|
||||
</Button>
|
||||
{event && onDelete && (
|
||||
<Button onClick={handleDelete} variant="destructive" className="cursor-pointer">
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => onOpenChange(false)} variant="outline" className="cursor-pointer">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Clock,
|
||||
Users,
|
||||
Plus,
|
||||
Settings,
|
||||
Download,
|
||||
Share,
|
||||
Bell
|
||||
} from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
interface QuickActionsProps {
|
||||
onNewEvent?: () => void
|
||||
onNewMeeting?: () => void
|
||||
onNewReminder?: () => void
|
||||
onSettings?: () => void
|
||||
}
|
||||
|
||||
export function QuickActions({
|
||||
onNewEvent,
|
||||
onNewMeeting,
|
||||
onNewReminder,
|
||||
onSettings
|
||||
}: QuickActionsProps) {
|
||||
const quickStats = [
|
||||
{ label: "Today's Events", value: "3", color: "bg-blue-500" },
|
||||
{ label: "This Week", value: "12", color: "bg-green-500" },
|
||||
{ label: "Pending", value: "2", color: "bg-orange-500" }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Quick Stats */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Overview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{quickStats.map((stat, index) => (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${stat.color}`} />
|
||||
<span className="text-sm text-muted-foreground">{stat.label}</span>
|
||||
</div>
|
||||
<Badge variant="secondary">{stat.value}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Quick Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start cursor-pointer"
|
||||
onClick={onNewEvent}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Event
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start cursor-pointer"
|
||||
onClick={onNewMeeting}
|
||||
>
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
Schedule Meeting
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start cursor-pointer"
|
||||
onClick={onNewReminder}
|
||||
>
|
||||
<Bell className="w-4 h-4 mr-2" />
|
||||
Set Reminder
|
||||
</Button>
|
||||
|
||||
<Separator className="my-3" />
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start cursor-pointer"
|
||||
>
|
||||
<Share className="w-4 h-4 mr-2" />
|
||||
Share Calendar
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start cursor-pointer"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start cursor-pointer"
|
||||
onClick={onSettings}
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Settings
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Upcoming Events */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
Next Up
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full mt-2" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">Team Standup</p>
|
||||
<p className="text-xs text-muted-foreground">9:00 AM • Conference Room A</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-2 h-2 bg-purple-500 rounded-full mt-2" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">Design Review</p>
|
||||
<p className="text-xs text-muted-foreground">2:00 PM • Virtual</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { type CalendarEvent, type Calendar } from "./types"
|
||||
|
||||
// Import JSON data
|
||||
import eventsData from "./data/events.json"
|
||||
import eventDatesData from "./data/event-dates.json"
|
||||
import calendarsData from "./data/calendars.json"
|
||||
|
||||
// Convert JSON events to CalendarEvent objects with proper Date objects
|
||||
// Always use current month and year, but preserve day and time from JSON
|
||||
export const events: CalendarEvent[] = eventsData.map(event => {
|
||||
const now = new Date()
|
||||
const currentYear = now.getFullYear()
|
||||
const currentMonth = now.getMonth() // 0-based month
|
||||
|
||||
// Parse the day from the date string (format: "11T09:00:00.000Z")
|
||||
const dayAndTime = event.date.split('T')
|
||||
const day = parseInt(dayAndTime[0])
|
||||
const timeStr = dayAndTime[1] // "09:00:00.000Z"
|
||||
|
||||
// Parse hours and minutes from time string
|
||||
const timeParts = timeStr.split(':')
|
||||
const hours = parseInt(timeParts[0])
|
||||
const minutes = parseInt(timeParts[1])
|
||||
|
||||
// Create date with current year/month but original day and time
|
||||
const eventDate = new Date(currentYear, currentMonth, day, hours, minutes)
|
||||
|
||||
return {
|
||||
...event,
|
||||
date: eventDate,
|
||||
type: event.type as "meeting" | "event" | "personal" | "task" | "reminder"
|
||||
}
|
||||
})
|
||||
|
||||
// Convert event dates for calendar picker - also use current month/year
|
||||
export const eventDates = eventDatesData.map(item => {
|
||||
const now = new Date()
|
||||
const currentYear = now.getFullYear()
|
||||
const currentMonth = now.getMonth()
|
||||
|
||||
// Parse day from date string
|
||||
const day = parseInt(item.date.split('T')[0])
|
||||
const eventDate = new Date(currentYear, currentMonth, day)
|
||||
|
||||
return {
|
||||
date: eventDate,
|
||||
count: item.count
|
||||
}
|
||||
})
|
||||
|
||||
// Calendars data
|
||||
export const calendars: Calendar[] = calendarsData as Calendar[]
|
||||
|
||||
// Export individual collections for convenience
|
||||
export { eventsData, eventDatesData, calendarsData }
|
||||
@@ -0,0 +1,37 @@
|
||||
[
|
||||
{
|
||||
"id": "personal",
|
||||
"name": "Personal",
|
||||
"color": "bg-blue-500",
|
||||
"visible": true,
|
||||
"type": "personal"
|
||||
},
|
||||
{
|
||||
"id": "work",
|
||||
"name": "Work",
|
||||
"color": "bg-green-500",
|
||||
"visible": true,
|
||||
"type": "work"
|
||||
},
|
||||
{
|
||||
"id": "shared",
|
||||
"name": "Team Calendar",
|
||||
"color": "bg-purple-500",
|
||||
"visible": true,
|
||||
"type": "shared"
|
||||
},
|
||||
{
|
||||
"id": "meetings",
|
||||
"name": "Meetings",
|
||||
"color": "bg-orange-500",
|
||||
"visible": true,
|
||||
"type": "work"
|
||||
},
|
||||
{
|
||||
"id": "events",
|
||||
"name": "Events",
|
||||
"color": "bg-pink-500",
|
||||
"visible": true,
|
||||
"type": "shared"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
[
|
||||
{
|
||||
"date": "11T00:00:00.000Z",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"date": "15T00:00:00.000Z",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"date": "18T00:00:00.000Z",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"date": "20T00:00:00.000Z",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"date": "22T00:00:00.000Z",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"date": "25T00:00:00.000Z",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"date": "27T00:00:00.000Z",
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Team Standup",
|
||||
"date": "11T09:00:00.000Z",
|
||||
"time": "9:00 AM",
|
||||
"duration": "30 min",
|
||||
"type": "meeting",
|
||||
"attendees": ["JD", "SM", "AR"],
|
||||
"location": "Conference Room A",
|
||||
"color": "bg-blue-500",
|
||||
"description": "Daily team standup meeting to discuss progress and blockers"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Design Review",
|
||||
"date": "11T14:00:00.000Z",
|
||||
"time": "2:00 PM",
|
||||
"duration": "1 hour",
|
||||
"type": "meeting",
|
||||
"attendees": ["ER", "LC"],
|
||||
"location": "Virtual",
|
||||
"color": "bg-purple-500",
|
||||
"description": "Review new UI designs and provide feedback"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Product Launch",
|
||||
"date": "15T10:00:00.000Z",
|
||||
"time": "10:00 AM",
|
||||
"duration": "2 hours",
|
||||
"type": "event",
|
||||
"attendees": ["TL", "ST"],
|
||||
"location": "Main Hall",
|
||||
"color": "bg-green-500",
|
||||
"description": "Official product launch event with stakeholders"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Client Presentation",
|
||||
"date": "18T15:00:00.000Z",
|
||||
"time": "3:00 PM",
|
||||
"duration": "1 hour",
|
||||
"type": "meeting",
|
||||
"attendees": ["AT", "SM"],
|
||||
"location": "Client Office",
|
||||
"color": "bg-orange-500",
|
||||
"description": "Present project progress to client stakeholders"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Birthday Party 🎉",
|
||||
"date": "20T19:00:00.000Z",
|
||||
"time": "7:00 PM",
|
||||
"duration": "3 hours",
|
||||
"type": "personal",
|
||||
"attendees": ["PB", "VB"],
|
||||
"location": "Home",
|
||||
"color": "bg-pink-500",
|
||||
"description": "Birthday celebration with friends and family"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Calendar } from "./components/calendar"
|
||||
import { events, eventDates } from "./data"
|
||||
|
||||
export default function CalendarPage() {
|
||||
return (
|
||||
<div className="px-4 lg:px-6">
|
||||
<Calendar events={events} eventDates={eventDates} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface CalendarEvent {
|
||||
id: number
|
||||
title: string
|
||||
date: Date
|
||||
time: string
|
||||
duration: string
|
||||
type: "meeting" | "event" | "personal" | "task" | "reminder"
|
||||
attendees: string[]
|
||||
location: string
|
||||
color: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface Calendar {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
visible: boolean
|
||||
type: "personal" | "work" | "shared"
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
import { type CalendarEvent } from "./types"
|
||||
|
||||
export interface UseCalendarState {
|
||||
selectedDate: Date
|
||||
showEventForm: boolean
|
||||
editingEvent: CalendarEvent | null
|
||||
showCalendarSheet: boolean
|
||||
events: CalendarEvent[]
|
||||
}
|
||||
|
||||
export interface UseCalendarActions {
|
||||
setSelectedDate: (date: Date) => void
|
||||
setShowEventForm: (show: boolean) => void
|
||||
setEditingEvent: (event: CalendarEvent | null) => void
|
||||
setShowCalendarSheet: (show: boolean) => void
|
||||
handleDateSelect: (date: Date) => void
|
||||
handleNewEvent: () => void
|
||||
handleNewCalendar: () => void
|
||||
handleSaveEvent: (eventData: Partial<CalendarEvent>) => void
|
||||
handleDeleteEvent: (eventId: number) => void
|
||||
handleEditEvent: (event: CalendarEvent) => void
|
||||
}
|
||||
|
||||
export interface UseCalendarReturn extends UseCalendarState, UseCalendarActions {}
|
||||
|
||||
export function useCalendar(initialEvents: CalendarEvent[] = []): UseCalendarReturn {
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date())
|
||||
const [showEventForm, setShowEventForm] = useState(false)
|
||||
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
|
||||
const [showCalendarSheet, setShowCalendarSheet] = useState(false)
|
||||
const [events] = useState<CalendarEvent[]>(initialEvents)
|
||||
|
||||
const handleDateSelect = useCallback((date: Date) => {
|
||||
setSelectedDate(date)
|
||||
// Auto-close mobile sheet when date is selected
|
||||
setShowCalendarSheet(false)
|
||||
}, [])
|
||||
|
||||
const handleNewEvent = useCallback(() => {
|
||||
setEditingEvent(null)
|
||||
setShowEventForm(true)
|
||||
}, [])
|
||||
|
||||
const handleNewCalendar = useCallback(() => {
|
||||
console.log("Creating new calendar")
|
||||
// In a real app, this would open a new calendar form
|
||||
}, [])
|
||||
|
||||
const handleSaveEvent = useCallback((eventData: Partial<CalendarEvent>) => {
|
||||
console.log("Saving event:", eventData)
|
||||
// In a real app, this would save to a backend
|
||||
setShowEventForm(false)
|
||||
setEditingEvent(null)
|
||||
}, [])
|
||||
|
||||
const handleDeleteEvent = useCallback((eventId: number) => {
|
||||
console.log("Deleting event:", eventId)
|
||||
// In a real app, this would delete from backend
|
||||
setShowEventForm(false)
|
||||
setEditingEvent(null)
|
||||
}, [])
|
||||
|
||||
const handleEditEvent = useCallback((event: CalendarEvent) => {
|
||||
setEditingEvent(event)
|
||||
setShowEventForm(true)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// State
|
||||
selectedDate,
|
||||
showEventForm,
|
||||
editingEvent,
|
||||
showCalendarSheet,
|
||||
events,
|
||||
// Actions
|
||||
setSelectedDate,
|
||||
setShowEventForm,
|
||||
setEditingEvent,
|
||||
setShowCalendarSheet,
|
||||
handleDateSelect,
|
||||
handleNewEvent,
|
||||
handleNewCalendar,
|
||||
handleSaveEvent,
|
||||
handleDeleteEvent,
|
||||
handleEditEvent,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Phone,
|
||||
Video,
|
||||
Info,
|
||||
Search,
|
||||
MoreVertical,
|
||||
Users,
|
||||
Bell,
|
||||
BellOff
|
||||
} from "lucide-react"
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "@/components/ui/tooltip"
|
||||
import { type Conversation, type User } from "../use-chat"
|
||||
|
||||
interface ChatHeaderProps {
|
||||
conversation: Conversation | null
|
||||
users: User[]
|
||||
onToggleMute?: () => void
|
||||
onToggleInfo?: () => void
|
||||
}
|
||||
|
||||
export function ChatHeader({
|
||||
conversation,
|
||||
users,
|
||||
onToggleMute,
|
||||
onToggleInfo
|
||||
}: ChatHeaderProps) {
|
||||
if (!conversation) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-muted-foreground">Select a conversation to start chatting</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getConversationUsers = () => {
|
||||
if (conversation.type === "direct") {
|
||||
return users.filter(user => conversation.participants.includes(user.id))
|
||||
}
|
||||
return users.filter(user => conversation.participants.includes(user.id))
|
||||
}
|
||||
|
||||
const conversationUsers = getConversationUsers()
|
||||
const primaryUser = conversationUsers[0]
|
||||
|
||||
const getStatusText = () => {
|
||||
if (conversation.type === "group") {
|
||||
const onlineCount = conversationUsers.filter(user => user.status === "online").length
|
||||
return `${conversation.participants.length} members, ${onlineCount} online`
|
||||
} else if (primaryUser) {
|
||||
switch (primaryUser.status) {
|
||||
case "online":
|
||||
return "Active now"
|
||||
case "away":
|
||||
return "Away"
|
||||
case "offline":
|
||||
return `Last seen ${new Date(primaryUser.lastSeen).toLocaleDateString()}`
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (conversation.type === "group") return "text-muted-foreground"
|
||||
|
||||
switch (primaryUser?.status) {
|
||||
case "online":
|
||||
return "text-green-600"
|
||||
case "away":
|
||||
return "text-yellow-600"
|
||||
case "offline":
|
||||
return "text-muted-foreground"
|
||||
default:
|
||||
return "text-muted-foreground"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between h-full">
|
||||
{/* Left side - Avatar and info */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10 cursor-pointer">
|
||||
<AvatarImage src={conversation.avatar} alt={conversation.name} />
|
||||
<AvatarFallback>
|
||||
{conversation.type === "group" ? (
|
||||
<Users className="h-5 w-5" />
|
||||
) : (
|
||||
conversation.name.split(' ').map(n => n[0]).join('').slice(0, 2)
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-semibold truncate">{conversation.name}</h2>
|
||||
{conversation.isMuted && (
|
||||
<BellOff className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
{conversation.type === "group" && (
|
||||
<Badge variant="secondary" className="text-xs cursor-pointer">
|
||||
Group
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className={`text-sm ${getStatusColor()}`}>
|
||||
{getStatusText()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Action buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider>
|
||||
{/* Search */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="cursor-pointer">
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Search in conversation</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Phone call */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="cursor-pointer">
|
||||
<Phone className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Voice call</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Video call */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="cursor-pointer">
|
||||
<Video className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Video call</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Info */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggleInfo}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Conversation info</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* More options */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="cursor-pointer">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={onToggleMute}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{conversation.isMuted ? (
|
||||
<>
|
||||
<Bell className="h-4 w-4 mr-2" />
|
||||
Unmute conversation
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BellOff className="h-4 w-4 mr-2" />
|
||||
Mute conversation
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
Search messages
|
||||
</DropdownMenuItem>
|
||||
{conversation.type === "group" && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
Manage members
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="cursor-pointer text-destructive">
|
||||
Delete conversation
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Menu, X } from "lucide-react"
|
||||
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ConversationList } from "./conversation-list"
|
||||
import { ChatHeader } from "./chat-header"
|
||||
import { MessageList } from "./message-list"
|
||||
import { MessageInput } from "./message-input"
|
||||
import { useChat, type Conversation, type Message, type User } from "../use-chat"
|
||||
|
||||
interface ChatProps {
|
||||
conversations: Conversation[]
|
||||
messages: Record<string, Message[]>
|
||||
users: User[]
|
||||
}
|
||||
|
||||
export function Chat({
|
||||
conversations,
|
||||
messages,
|
||||
users,
|
||||
}: ChatProps) {
|
||||
const {
|
||||
selectedConversation,
|
||||
setSelectedConversation,
|
||||
setConversations,
|
||||
setMessages,
|
||||
setUsers,
|
||||
addMessage,
|
||||
toggleMute,
|
||||
} = useChat()
|
||||
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
||||
|
||||
// Close sidebar when clicking outside on mobile
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (typeof window !== "undefined" ? window.innerWidth : 0 >= 1024) { // lg breakpoint
|
||||
setIsSidebarOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener('resize', handleResize)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Initialize data
|
||||
useEffect(() => {
|
||||
setConversations(conversations)
|
||||
setUsers(users)
|
||||
|
||||
// Set messages for all conversations
|
||||
Object.entries(messages).forEach(([conversationId, conversationMessages]) => {
|
||||
setMessages(conversationId, conversationMessages)
|
||||
})
|
||||
|
||||
// Auto-select first conversation if none selected
|
||||
if (!selectedConversation && conversations.length > 0) {
|
||||
setSelectedConversation(conversations[0].id)
|
||||
}
|
||||
}, [conversations, messages, users, selectedConversation, setConversations, setMessages, setUsers, setSelectedConversation])
|
||||
|
||||
const currentConversation = conversations.find(conv => conv.id === selectedConversation)
|
||||
const currentMessages = selectedConversation ? messages[selectedConversation] || [] : []
|
||||
|
||||
const handleSendMessage = (content: string) => {
|
||||
if (!selectedConversation) return
|
||||
|
||||
const newMessage = {
|
||||
id: `msg-${Date.now()}`,
|
||||
content,
|
||||
timestamp: new Date().toISOString(),
|
||||
senderId: "current-user",
|
||||
type: "text" as const,
|
||||
isEdited: false,
|
||||
reactions: [],
|
||||
replyTo: null,
|
||||
}
|
||||
|
||||
addMessage(selectedConversation, newMessage)
|
||||
}
|
||||
|
||||
const handleToggleMute = () => {
|
||||
if (selectedConversation) {
|
||||
toggleMute(selectedConversation)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div className="h-full min-h-[600px] max-h-[calc(100vh-200px)] flex rounded-lg border overflow-hidden bg-background">
|
||||
{/* Mobile Sidebar Overlay */}
|
||||
{isSidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Conversations Sidebar - Responsive */}
|
||||
<div className={`
|
||||
w-100 border-r bg-background flex-shrink-0
|
||||
${isSidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||
lg:relative lg:block
|
||||
fixed inset-y-0 left-0 z-50
|
||||
transition-transform duration-300 ease-in-out
|
||||
`}>
|
||||
{/* Sidebar Header with Close Button (Mobile Only) */}
|
||||
<div className="lg:hidden p-4 border-b flex items-center justify-between bg-background">
|
||||
<h2 className="text-lg font-semibold">Messages</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ConversationList
|
||||
conversations={conversations}
|
||||
selectedConversation={selectedConversation}
|
||||
onSelectConversation={(id) => {
|
||||
setSelectedConversation(id)
|
||||
setIsSidebarOpen(false) // Close sidebar on mobile after selection
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chat Panel - Flexible Width */}
|
||||
<div className="flex-1 flex flex-col min-w-0 bg-background">
|
||||
{/* Chat Header with Hamburger Menu */}
|
||||
<div className="flex items-center h-16 px-4 border-b bg-background">
|
||||
{/* Hamburger Menu Button - Only visible when sidebar is hidden on mobile */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsSidebarOpen(true)}
|
||||
className="cursor-pointer lg:hidden mr-2"
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex-1">
|
||||
<ChatHeader
|
||||
conversation={currentConversation || null}
|
||||
users={users}
|
||||
onToggleMute={handleToggleMute}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{selectedConversation ? (
|
||||
<>
|
||||
<MessageList
|
||||
messages={currentMessages}
|
||||
users={users}
|
||||
/>
|
||||
|
||||
{/* Message Input */}
|
||||
<MessageInput
|
||||
onSendMessage={handleSendMessage}
|
||||
placeholder={`Message ${currentConversation?.name || ""}...`}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold mb-2">Welcome to Chat</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Select a conversation to start messaging
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client"
|
||||
|
||||
import { format, isToday, isYesterday, isThisWeek, isThisYear } from "date-fns"
|
||||
import {
|
||||
Search,
|
||||
Pin,
|
||||
VolumeX,
|
||||
MoreHorizontal,
|
||||
Users,
|
||||
Hash
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useChat, type Conversation } from "../use-chat"
|
||||
|
||||
interface ConversationListProps {
|
||||
conversations: Conversation[]
|
||||
selectedConversation: string | null
|
||||
onSelectConversation: (conversationId: string) => void
|
||||
}
|
||||
|
||||
// Enhanced time formatting function
|
||||
function formatMessageTime(timestamp: string): string {
|
||||
const date = new Date(timestamp)
|
||||
|
||||
if (isToday(date)) {
|
||||
return format(date, 'h:mm a') // 3:30 PM
|
||||
} else if (isYesterday(date)) {
|
||||
return 'Yesterday'
|
||||
} else if (isThisWeek(date)) {
|
||||
return format(date, 'EEEE') // Day name
|
||||
} else if (isThisYear(date)) {
|
||||
return format(date, 'MMM d') // Jan 15
|
||||
} else {
|
||||
return format(date, 'dd/MM/yy') // 15/01/24
|
||||
}
|
||||
}
|
||||
|
||||
export function ConversationList({
|
||||
conversations,
|
||||
selectedConversation,
|
||||
onSelectConversation
|
||||
}: ConversationListProps) {
|
||||
const { searchQuery, setSearchQuery, togglePin, toggleMute } = useChat()
|
||||
|
||||
const filteredConversations = conversations.filter((conversation) =>
|
||||
conversation.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
const sortedConversations = filteredConversations.sort((a, b) => {
|
||||
// Pinned conversations first
|
||||
if (a.isPinned && !b.isPinned) return -1
|
||||
if (!a.isPinned && b.isPinned) return 1
|
||||
|
||||
// Then by last message timestamp
|
||||
return new Date(b.lastMessage.timestamp).getTime() - new Date(a.lastMessage.timestamp).getTime()
|
||||
})
|
||||
|
||||
const getOnlineStatus = (conversation: Conversation) => {
|
||||
if (conversation.type === "direct" && conversation.participants.length === 1) {
|
||||
// In a real app, you'd check user online status
|
||||
return Math.random() > 0.5 // Mock online status
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b flex-shrink-0">
|
||||
<h2 className="text-lg font-semibold">Messages</h2>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="p-4 border-b flex-shrink-0">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search conversations..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 cursor-text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conversations */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2">
|
||||
{sortedConversations.map((conversation) => (
|
||||
<div
|
||||
key={conversation.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-3 rounded-lg cursor-pointer relative group overflow-hidden hover:bg-accent/50 transition-colors",
|
||||
selectedConversation === conversation.id
|
||||
? "bg-accent text-accent-foreground"
|
||||
: ""
|
||||
)}
|
||||
onClick={() => onSelectConversation(conversation.id)}
|
||||
>
|
||||
{/* Avatar with online indicator */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<Avatar className={cn(
|
||||
"h-12 w-12",
|
||||
selectedConversation === conversation.id && "ring-2 ring-background"
|
||||
)}>
|
||||
<AvatarImage src={conversation.avatar} alt={conversation.name} />
|
||||
<AvatarFallback className="text-sm">
|
||||
{conversation.type === "group" ? (
|
||||
<Users className="h-5 w-5" />
|
||||
) : (
|
||||
conversation.name.split(' ').map(n => n[0]).join('').slice(0, 2)
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
{/* Online indicator for direct messages */}
|
||||
{conversation.type === "direct" && getOnlineStatus(conversation) && (
|
||||
<div className="absolute -bottom-1 -right-1 h-4 w-4 bg-green-500 border-2 border-background rounded-full" />
|
||||
)}
|
||||
|
||||
{/* Group indicator */}
|
||||
{conversation.type === "group" && (
|
||||
<div className="absolute -bottom-1 -right-1 h-4 w-4 bg-blue-500 border-2 border-background rounded-full flex items-center justify-center">
|
||||
<Hash className="h-2 w-2 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="flex items-center justify-between mb-1 min-w-0">
|
||||
<div className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
|
||||
<h3 className="font-medium truncate min-w-0 max-w-[180px]">{conversation.name}</h3>
|
||||
{conversation.isPinned && (
|
||||
<Pin className="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
{conversation.isMuted && (
|
||||
<VolumeX className="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2 whitespace-nowrap">
|
||||
{formatMessageTime(conversation.lastMessage.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||
<p className="text-sm text-muted-foreground truncate flex-1 min-w-0 max-w-[200px]">
|
||||
{conversation.lastMessage.content}
|
||||
</p>
|
||||
|
||||
{/* Unread count */}
|
||||
{conversation.unreadCount > 0 && (
|
||||
<Badge variant="default" className="ml-2 min-w-[20px] h-5 text-xs cursor-pointer flex-shrink-0">
|
||||
{conversation.unreadCount > 99 ? "99+" : conversation.unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions menu */}
|
||||
<div className="opacity-0 group-hover:opacity-100 ml-2 flex-shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 cursor-pointer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
togglePin(conversation.id)
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Pin className="h-4 w-4 mr-2" />
|
||||
{conversation.isPinned ? "Unpin" : "Pin"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleMute(conversation.id)
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<VolumeX className="h-4 w-4 mr-2" />
|
||||
{conversation.isMuted ? "Unmute" : "Mute"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="cursor-pointer text-destructive">
|
||||
Delete conversation
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
"use client"
|
||||
|
||||
import { format, isToday, isYesterday, isThisWeek, isThisYear } from "date-fns"
|
||||
import {
|
||||
Search,
|
||||
Pin,
|
||||
VolumeX,
|
||||
MoreVertical,
|
||||
Users,
|
||||
Hash,
|
||||
Settings,
|
||||
UserPlus,
|
||||
Filter
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useChat, type Conversation } from "../use-chat"
|
||||
|
||||
interface ConversationListProps {
|
||||
conversations: Conversation[]
|
||||
selectedConversation: string | null
|
||||
onSelectConversation: (conversationId: string) => void
|
||||
}
|
||||
|
||||
// Enhanced time formatting function
|
||||
function formatMessageTime(timestamp: string): string {
|
||||
const date = new Date(timestamp)
|
||||
|
||||
if (isToday(date)) {
|
||||
return format(date, 'h:mm a') // 3:30 PM
|
||||
} else if (isYesterday(date)) {
|
||||
return 'Yesterday'
|
||||
} else if (isThisWeek(date)) {
|
||||
return format(date, 'EEEE') // Day name
|
||||
} else if (isThisYear(date)) {
|
||||
return format(date, 'MMM d') // Jan 15
|
||||
} else {
|
||||
return format(date, 'dd/MM/yy') // 15/01/24
|
||||
}
|
||||
}
|
||||
|
||||
export function ConversationList({
|
||||
conversations,
|
||||
selectedConversation,
|
||||
onSelectConversation
|
||||
}: ConversationListProps) {
|
||||
const { searchQuery, setSearchQuery } = useChat()
|
||||
|
||||
const filteredConversations = conversations.filter((conversation) =>
|
||||
conversation.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
const sortedConversations = filteredConversations.sort((a, b) => {
|
||||
// Pinned conversations first
|
||||
if (a.isPinned && !b.isPinned) return -1
|
||||
if (!a.isPinned && b.isPinned) return 1
|
||||
|
||||
// Then by last message timestamp
|
||||
return new Date(b.lastMessage.timestamp).getTime() - new Date(a.lastMessage.timestamp).getTime()
|
||||
})
|
||||
|
||||
const getOnlineStatus = (conversation: Conversation) => {
|
||||
if (conversation.type === "direct" && conversation.participants.length === 1) {
|
||||
// In a real app, you'd check user online status
|
||||
return Math.random() > 0.5 // Mock online status
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Header - Hidden on mobile (handled by parent) */}
|
||||
<div className="hidden lg:flex items-center justify-between h-16 px-4 border-b flex-shrink-0">
|
||||
<h2 className="text-lg font-semibold">Messages</h2>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 cursor-pointer"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
New Chat
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Filter Messages
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Chat Settings
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-4 py-3 border-b flex-shrink-0">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search conversations..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 cursor-text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conversations */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2">
|
||||
{sortedConversations.map((conversation) => (
|
||||
<div
|
||||
key={conversation.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-3 rounded-lg cursor-pointer relative overflow-hidden hover:bg-accent/50 transition-colors",
|
||||
selectedConversation === conversation.id
|
||||
? "bg-accent text-accent-foreground"
|
||||
: ""
|
||||
)}
|
||||
onClick={() => onSelectConversation(conversation.id)}
|
||||
>
|
||||
{/* Avatar with online indicator */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<Avatar className={cn(
|
||||
"h-12 w-12",
|
||||
selectedConversation === conversation.id && "ring-2 ring-background"
|
||||
)}>
|
||||
<AvatarImage src={conversation.avatar} alt={conversation.name} />
|
||||
<AvatarFallback className="text-sm">
|
||||
{conversation.type === "group" ? (
|
||||
<Users className="h-5 w-5" />
|
||||
) : (
|
||||
conversation.name.split(' ').map(n => n[0]).join('').slice(0, 2)
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
{/* Online indicator for direct messages */}
|
||||
{conversation.type === "direct" && getOnlineStatus(conversation) && (
|
||||
<div className="absolute -bottom-1 -right-1 h-4 w-4 bg-green-500 border-2 border-background rounded-full" />
|
||||
)}
|
||||
|
||||
{/* Group indicator */}
|
||||
{conversation.type === "group" && (
|
||||
<div className="absolute -bottom-1 -right-1 h-4 w-4 bg-blue-500 border-2 border-background rounded-full flex items-center justify-center">
|
||||
<Hash className="h-2 w-2 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="flex items-center justify-between mb-1 min-w-0">
|
||||
<div className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden pr-2">
|
||||
<h3 className="font-medium truncate min-w-0 max-w-[160px] lg:max-w-[180px]">{conversation.name}</h3>
|
||||
{conversation.isPinned && (
|
||||
<Pin className="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
{conversation.isMuted && (
|
||||
<VolumeX className="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 whitespace-nowrap">
|
||||
{formatMessageTime(conversation.lastMessage.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||
<p className="text-sm text-muted-foreground truncate flex-1 min-w-0 max-w-[180px] lg:max-w-[200px] pr-2">
|
||||
{conversation.lastMessage.content}
|
||||
</p>
|
||||
|
||||
{/* Unread count */}
|
||||
{conversation.unreadCount > 0 && (
|
||||
<Badge variant="default" className="min-w-[20px] h-5 text-xs cursor-pointer flex-shrink-0">
|
||||
{conversation.unreadCount > 99 ? "99+" : conversation.unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef } from "react"
|
||||
import {
|
||||
Send,
|
||||
Paperclip,
|
||||
Smile,
|
||||
Image as ImageIcon,
|
||||
FileText,
|
||||
Mic,
|
||||
MoreHorizontal
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
interface MessageInputProps {
|
||||
onSendMessage: (content: string) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function MessageInput({
|
||||
onSendMessage,
|
||||
disabled = false,
|
||||
placeholder = "Type a message..."
|
||||
}: MessageInputProps) {
|
||||
const [message, setMessage] = useState("")
|
||||
const [isTyping, setIsTyping] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const handleSendMessage = () => {
|
||||
const trimmedMessage = message.trim()
|
||||
if (trimmedMessage && !disabled) {
|
||||
onSendMessage(trimmedMessage)
|
||||
setMessage("")
|
||||
setIsTyping(false)
|
||||
|
||||
// Reset textarea height
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value
|
||||
setMessage(value)
|
||||
|
||||
// Auto-resize textarea
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto"
|
||||
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`
|
||||
}
|
||||
|
||||
// Handle typing indicator
|
||||
if (value.trim() && !isTyping) {
|
||||
setIsTyping(true)
|
||||
} else if (!value.trim() && isTyping) {
|
||||
setIsTyping(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = (type: "image" | "file") => {
|
||||
// In a real app, this would open a file picker
|
||||
console.log(`Upload ${type}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t p-4">
|
||||
<div className="flex items-end gap-2">
|
||||
{/* Attachment button */}
|
||||
<TooltipProvider>
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
className="cursor-pointer disabled:cursor-not-allowed"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Attach file</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleFileUpload("image")}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<ImageIcon className="h-4 w-4 mr-2" />
|
||||
Photo or video
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleFileUpload("file")}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Document
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Message input */}
|
||||
<div className="flex-1 relative">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
placeholder={placeholder}
|
||||
value={message}
|
||||
onChange={handleTextareaChange}
|
||||
onKeyDown={handleKeyPress}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"min-h-[40px] max-h-[120px] resize-none cursor-text disabled:cursor-not-allowed",
|
||||
"pr-20" // Space for emoji and more buttons
|
||||
)}
|
||||
rows={1}
|
||||
/>
|
||||
|
||||
{/* Input action buttons */}
|
||||
<div className="absolute right-2 bottom-2 flex items-center gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className="h-6 w-6 p-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
>
|
||||
<Smile className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Add emoji</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className="h-6 w-6 p-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>More options</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Voice message or send button */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{message.trim() ? (
|
||||
<Button
|
||||
onClick={handleSendMessage}
|
||||
disabled={disabled}
|
||||
className="cursor-pointer disabled:cursor-not-allowed"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
className="cursor-pointer disabled:cursor-not-allowed"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{message.trim() ? "Send message" : "Voice message"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
{/* Typing indicator */}
|
||||
{isTyping && (
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
You are typing...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import { format, isToday, isYesterday } from "date-fns"
|
||||
import { CheckCheck, MoreHorizontal, Reply, Copy, Trash2 } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { type Message, type User } from "../use-chat"
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[]
|
||||
users: User[]
|
||||
currentUserId?: string
|
||||
}
|
||||
|
||||
export function MessageList({ messages, users, currentUserId = "current-user" }: MessageListProps) {
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const previousMessageCountRef = useRef(0)
|
||||
const isInitialLoadRef = useRef(true)
|
||||
const previousConversationRef = useRef<string | null>(null)
|
||||
|
||||
// Reset scroll behavior when switching conversations
|
||||
useEffect(() => {
|
||||
const currentConversationId = messages.length > 0 ? messages[0]?.id?.split('-')[0] : null
|
||||
if (currentConversationId !== previousConversationRef.current) {
|
||||
isInitialLoadRef.current = true
|
||||
previousConversationRef.current = currentConversationId
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
// Auto-scroll to bottom only when new messages are added (not on initial load)
|
||||
useEffect(() => {
|
||||
// Skip auto-scroll on initial load
|
||||
if (isInitialLoadRef.current) {
|
||||
isInitialLoadRef.current = false
|
||||
previousMessageCountRef.current = messages.length
|
||||
return
|
||||
}
|
||||
|
||||
// Only auto-scroll if new messages were added
|
||||
if (messages.length > previousMessageCountRef.current && bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
|
||||
previousMessageCountRef.current = messages.length
|
||||
}, [messages])
|
||||
|
||||
const getUserById = (userId: string) => {
|
||||
if (userId === currentUserId) {
|
||||
return {
|
||||
id: currentUserId,
|
||||
name: "You",
|
||||
avatar: "https://notion-avatars.netlify.app/api/avatar/?preset=male-7",
|
||||
status: "online" as const,
|
||||
email: "you@example.com",
|
||||
lastSeen: new Date().toISOString(),
|
||||
role: "Developer",
|
||||
department: "Engineering"
|
||||
}
|
||||
}
|
||||
return users.find(user => user.id === userId)
|
||||
}
|
||||
|
||||
const formatMessageTime = (timestamp: string) => {
|
||||
const date = new Date(timestamp)
|
||||
if (isToday(date)) {
|
||||
return format(date, "HH:mm")
|
||||
} else if (isYesterday(date)) {
|
||||
return `Yesterday ${format(date, "HH:mm")}`
|
||||
} else {
|
||||
return format(date, "MMM d, HH:mm")
|
||||
}
|
||||
}
|
||||
|
||||
const shouldShowAvatar = (message: Message, index: number) => {
|
||||
if (message.senderId === currentUserId) return false
|
||||
if (index === 0) return true
|
||||
|
||||
const prevMessage = messages[index - 1]
|
||||
return prevMessage.senderId !== message.senderId
|
||||
}
|
||||
|
||||
const shouldShowName = (message: Message, index: number) => {
|
||||
if (message.senderId === currentUserId) return false
|
||||
if (index === 0) return true
|
||||
|
||||
const prevMessage = messages[index - 1]
|
||||
return prevMessage.senderId !== message.senderId
|
||||
}
|
||||
|
||||
const isConsecutiveMessage = (message: Message, index: number) => {
|
||||
if (index === 0) return false
|
||||
|
||||
const prevMessage = messages[index - 1]
|
||||
const timeDiff = new Date(message.timestamp).getTime() - new Date(prevMessage.timestamp).getTime()
|
||||
|
||||
return prevMessage.senderId === message.senderId && timeDiff < 5 * 60 * 1000 // 5 minutes
|
||||
}
|
||||
|
||||
const groupMessagesByDay = (messages: Message[]) => {
|
||||
const groups: { date: string; messages: Message[] }[] = []
|
||||
|
||||
messages.forEach((message) => {
|
||||
const messageDate = format(new Date(message.timestamp), "yyyy-MM-dd")
|
||||
const lastGroup = groups[groups.length - 1]
|
||||
|
||||
if (lastGroup && lastGroup.date === messageDate) {
|
||||
lastGroup.messages.push(message)
|
||||
} else {
|
||||
groups.push({
|
||||
date: messageDate,
|
||||
messages: [message]
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
const formatDateHeader = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
if (isToday(date)) {
|
||||
return "Today"
|
||||
} else if (isYesterday(date)) {
|
||||
return "Yesterday"
|
||||
} else {
|
||||
return format(date, "EEEE, MMMM d")
|
||||
}
|
||||
}
|
||||
|
||||
const messageGroups = groupMessagesByDay(messages)
|
||||
|
||||
return (
|
||||
<ScrollArea className="flex-1 px-4" ref={scrollAreaRef}>
|
||||
<div className="space-y-4 py-4">
|
||||
{messageGroups.map((group) => (
|
||||
<div key={group.date}>
|
||||
{/* Date separator */}
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<div className="text-xs text-muted-foreground bg-background px-3 py-1 rounded-full border">
|
||||
{formatDateHeader(group.date)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages for this day */}
|
||||
<div className="space-y-1">
|
||||
{group.messages.map((message, messageIndex) => {
|
||||
const user = getUserById(message.senderId)
|
||||
const isOwnMessage = message.senderId === currentUserId
|
||||
const showAvatar = shouldShowAvatar(message, messageIndex)
|
||||
const showName = shouldShowName(message, messageIndex)
|
||||
const isConsecutive = isConsecutiveMessage(message, messageIndex)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"flex gap-3 group",
|
||||
isOwnMessage && "flex-row-reverse",
|
||||
isConsecutive && !isOwnMessage && "ml-12"
|
||||
)}
|
||||
>
|
||||
{/* Avatar */}
|
||||
{!isOwnMessage && (
|
||||
<div className="w-8">
|
||||
{showAvatar && user && (
|
||||
<Avatar className="h-8 w-8 cursor-pointer">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className="text-xs">
|
||||
{user.name.split(' ').map(n => n[0]).join('').slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message content */}
|
||||
<div className={cn("flex-1 max-w-[70%]", isOwnMessage && "flex flex-col items-end")}>
|
||||
{/* Sender name for group messages */}
|
||||
{showName && user && !isOwnMessage && (
|
||||
<div className="text-sm font-medium text-foreground mb-1">
|
||||
{user.name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message bubble */}
|
||||
<div className="relative group/message">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-2 text-sm break-words",
|
||||
isOwnMessage
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted",
|
||||
isConsecutive && "mt-1"
|
||||
)}
|
||||
>
|
||||
<p>{message.content}</p>
|
||||
|
||||
{/* Message reactions */}
|
||||
{message.reactions.length > 0 && (
|
||||
<div className="flex gap-1 mt-2">
|
||||
{message.reactions.map((reaction, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs border cursor-pointer",
|
||||
"bg-background/90 backdrop-blur-sm shadow-sm"
|
||||
)}
|
||||
>
|
||||
<span>{reaction.emoji}</span>
|
||||
<span className="text-muted-foreground">{reaction.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamp and status */}
|
||||
<div className={cn(
|
||||
"flex items-center gap-1 mt-1 text-xs",
|
||||
isOwnMessage
|
||||
? "text-primary-foreground/70 justify-end"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
<span>{formatMessageTime(message.timestamp)}</span>
|
||||
{message.isEdited && (
|
||||
<span className="italic">(edited)</span>
|
||||
)}
|
||||
{isOwnMessage && (
|
||||
<div className="flex">
|
||||
{/* Message status indicators */}
|
||||
<CheckCheck className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message actions */}
|
||||
<div className="absolute top-0 right-0 opacity-0 group-hover/message:opacity-100">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 cursor-pointer"
|
||||
>
|
||||
<MoreHorizontal className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<Reply className="h-4 w-4 mr-2" />
|
||||
Reply
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy
|
||||
</DropdownMenuItem>
|
||||
{isOwnMessage && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="cursor-pointer text-destructive">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Scroll anchor */}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
[
|
||||
{
|
||||
"id": "conv-1",
|
||||
"type": "direct",
|
||||
"participants": ["1"],
|
||||
"name": "Sarah Mitchell",
|
||||
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-7",
|
||||
"lastMessage": {
|
||||
"id": "msg-1-4",
|
||||
"content": "Thanks for the quick update! The dashboard looks amazing 🎉",
|
||||
"timestamp": "2025-08-11T15:30:00Z",
|
||||
"senderId": "1"
|
||||
},
|
||||
"unreadCount": 2,
|
||||
"isPinned": true,
|
||||
"isMuted": false
|
||||
},
|
||||
{
|
||||
"id": "conv-2",
|
||||
"type": "group",
|
||||
"participants": ["2", "3", "5"],
|
||||
"name": "Project Alpha",
|
||||
"lastMessage": {
|
||||
"id": "msg-2-8",
|
||||
"content": "David: Marketing campaign is scheduled for next week",
|
||||
"timestamp": "2025-08-11T08:15:00Z",
|
||||
"senderId": "2"
|
||||
},
|
||||
"unreadCount": 0,
|
||||
"isPinned": false,
|
||||
"isMuted": false
|
||||
},
|
||||
{
|
||||
"id": "conv-3",
|
||||
"type": "group",
|
||||
"participants": ["2", "3", "5"],
|
||||
"name": "Frontend Team",
|
||||
"lastMessage": {
|
||||
"id": "msg-3-6",
|
||||
"content": "Alex: The new component library is ready for testing",
|
||||
"timestamp": "2025-08-11T23:45:00Z",
|
||||
"senderId": "3"
|
||||
},
|
||||
"unreadCount": 1,
|
||||
"isPinned": false,
|
||||
"isMuted": false
|
||||
},
|
||||
{
|
||||
"id": "conv-4",
|
||||
"type": "direct",
|
||||
"participants": ["3"],
|
||||
"name": "Emily Rodriguez",
|
||||
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-2",
|
||||
"lastMessage": {
|
||||
"id": "msg-4-3",
|
||||
"content": "Let's review the wireframes together tomorrow",
|
||||
"timestamp": "2025-08-10T16:30:00Z",
|
||||
"senderId": "3"
|
||||
},
|
||||
"unreadCount": 1,
|
||||
"isPinned": false,
|
||||
"isMuted": false
|
||||
},
|
||||
{
|
||||
"id": "conv-5",
|
||||
"type": "direct",
|
||||
"participants": ["5"],
|
||||
"name": "Lisa Chen",
|
||||
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-4",
|
||||
"lastMessage": {
|
||||
"id": "msg-5-3",
|
||||
"content": "Found a few edge cases in the new feature",
|
||||
"timestamp": "2025-08-06T14:20:00Z",
|
||||
"senderId": "5"
|
||||
},
|
||||
"unreadCount": 0,
|
||||
"isPinned": false,
|
||||
"isMuted": true
|
||||
},
|
||||
{
|
||||
"id": "conv-6",
|
||||
"type": "direct",
|
||||
"participants": ["2"],
|
||||
"name": "Alex Thompson",
|
||||
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=male-1",
|
||||
"lastMessage": {
|
||||
"id": "msg-6-3",
|
||||
"content": "Code review completed, looks good to merge! 👍",
|
||||
"timestamp": "2025-01-15T17:45:00Z",
|
||||
"senderId": "2"
|
||||
},
|
||||
"unreadCount": 0,
|
||||
"isPinned": false,
|
||||
"isMuted": false
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,224 @@
|
||||
{
|
||||
"conv-1": [
|
||||
{
|
||||
"id": "msg-1-1",
|
||||
"content": "Hey! How's the new dashboard coming along?",
|
||||
"timestamp": "2024-01-15T10:15:00Z",
|
||||
"senderId": "1",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-1-2",
|
||||
"content": "It's going great! We've implemented the new design system and it looks fantastic.",
|
||||
"timestamp": "2024-01-15T10:17:00Z",
|
||||
"senderId": "current-user",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [{"emoji": "👍", "users": ["1"], "count": 1}],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-1-3",
|
||||
"content": "That's awesome! Can you share a preview?",
|
||||
"timestamp": "2024-01-15T10:18:00Z",
|
||||
"senderId": "1",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-1-4",
|
||||
"content": "Thanks for the quick update! The dashboard looks amazing 🎉",
|
||||
"timestamp": "2024-01-15T10:30:00Z",
|
||||
"senderId": "1",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [{"emoji": "❤️", "users": ["current-user"], "count": 1}],
|
||||
"replyTo": null
|
||||
}
|
||||
],
|
||||
"conv-2": [
|
||||
{
|
||||
"id": "msg-2-1",
|
||||
"content": "Hey team! The component library update is ready",
|
||||
"timestamp": "2024-01-15T09:00:00Z",
|
||||
"senderId": "2",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-2-2",
|
||||
"content": "Awesome work Alex! 🚀",
|
||||
"timestamp": "2024-01-15T09:05:00Z",
|
||||
"senderId": "3",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-2-3",
|
||||
"content": "I've tested the new Button and Input components, they work perfectly",
|
||||
"timestamp": "2024-01-15T09:10:00Z",
|
||||
"senderId": "5",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [{"emoji": "✅", "users": ["2", "3"], "count": 2}],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-2-4",
|
||||
"content": "Great! I'll start integrating them into the main app",
|
||||
"timestamp": "2024-01-15T09:15:00Z",
|
||||
"senderId": "current-user",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
}
|
||||
],
|
||||
"conv-3": [
|
||||
{
|
||||
"id": "msg-3-1",
|
||||
"content": "Hi! I've completed the wireframes for the new user onboarding flow",
|
||||
"timestamp": "2024-01-15T09:30:00Z",
|
||||
"senderId": "3",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-3-2",
|
||||
"content": "That's fantastic Emily! When can we review them?",
|
||||
"timestamp": "2024-01-15T09:32:00Z",
|
||||
"senderId": "current-user",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-3-3",
|
||||
"content": "How about tomorrow at 2 PM? I'll share my screen and walk through the designs",
|
||||
"timestamp": "2024-01-15T09:35:00Z",
|
||||
"senderId": "3",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [{"emoji": "👍", "users": ["current-user"], "count": 1}],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-3-4",
|
||||
"content": "Perfect! Looking forward to it",
|
||||
"timestamp": "2024-01-15T09:40:00Z",
|
||||
"senderId": "current-user",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
}
|
||||
],
|
||||
"conv-4": [
|
||||
{
|
||||
"id": "msg-4-1",
|
||||
"content": "Hi! I've been working on the wireframes for the new feature",
|
||||
"timestamp": "2025-08-10T14:15:00Z",
|
||||
"senderId": "3",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-4-2",
|
||||
"content": "That's great! I'd love to take a look at them",
|
||||
"timestamp": "2025-08-10T14:18:00Z",
|
||||
"senderId": "current-user",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-4-3",
|
||||
"content": "Let's review the wireframes together tomorrow",
|
||||
"timestamp": "2025-08-10T16:30:00Z",
|
||||
"senderId": "3",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [{"emoji": "👍", "users": ["current-user"], "count": 1}],
|
||||
"replyTo": null
|
||||
}
|
||||
],
|
||||
"conv-5": [
|
||||
{
|
||||
"id": "msg-5-1",
|
||||
"content": "I've been testing the new feature and it looks good overall",
|
||||
"timestamp": "2025-08-06T13:45:00Z",
|
||||
"senderId": "5",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-5-2",
|
||||
"content": "Thanks for testing it! Any issues you found?",
|
||||
"timestamp": "2025-08-06T14:10:00Z",
|
||||
"senderId": "current-user",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-5-3",
|
||||
"content": "Found a few edge cases in the new feature",
|
||||
"timestamp": "2025-08-06T14:20:00Z",
|
||||
"senderId": "5",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
}
|
||||
],
|
||||
"conv-6": [
|
||||
{
|
||||
"id": "msg-6-1",
|
||||
"content": "Hey! I've finished the code review for the latest PR",
|
||||
"timestamp": "2025-01-15T16:30:00Z",
|
||||
"senderId": "2",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-6-2",
|
||||
"content": "Thanks for the quick review! Any feedback?",
|
||||
"timestamp": "2025-01-15T17:15:00Z",
|
||||
"senderId": "current-user",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-6-3",
|
||||
"content": "Code review completed, looks good to merge! 👍",
|
||||
"timestamp": "2025-01-15T17:45:00Z",
|
||||
"senderId": "2",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [{"emoji": "🎉", "users": ["current-user"], "count": 1}],
|
||||
"replyTo": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
[
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Sarah Mitchell",
|
||||
"email": "sarah.mitchell@example.com",
|
||||
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-7",
|
||||
"status": "online",
|
||||
"lastSeen": "2024-01-15T10:30:00Z",
|
||||
"role": "Project Manager",
|
||||
"department": "Product"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "Alex Thompson",
|
||||
"email": "alex.thompson@example.com",
|
||||
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=male-1",
|
||||
"status": "away",
|
||||
"lastSeen": "2024-01-15T09:45:00Z",
|
||||
"role": "Senior Developer",
|
||||
"department": "Engineering"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"name": "Emily Rodriguez",
|
||||
"email": "emily.rodriguez@example.com",
|
||||
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-2",
|
||||
"status": "online",
|
||||
"lastSeen": "2024-01-15T10:25:00Z",
|
||||
"role": "UX Designer",
|
||||
"department": "Design"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"name": "David Kim",
|
||||
"email": "david.kim@example.com",
|
||||
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=male-5",
|
||||
"status": "offline",
|
||||
"lastSeen": "2024-01-14T18:30:00Z",
|
||||
"role": "Marketing Lead",
|
||||
"department": "Marketing"
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"name": "Lisa Chen",
|
||||
"email": "lisa.chen@example.com",
|
||||
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-4",
|
||||
"status": "online",
|
||||
"lastSeen": "2024-01-15T10:20:00Z",
|
||||
"role": "QA Engineer",
|
||||
"department": "Engineering"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { Chat } from "./components/chat"
|
||||
import { type Conversation, type Message, type User } from "./use-chat"
|
||||
|
||||
// Import static data
|
||||
import conversationsData from "./data/conversations.json"
|
||||
import messagesData from "./data/messages.json"
|
||||
import usersData from "./data/users.json"
|
||||
|
||||
export default function ChatPage() {
|
||||
const [conversations, setConversations] = useState<Conversation[]>([])
|
||||
const [messages, setMessages] = useState<Record<string, Message[]>>({})
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// In a real app, these would be API calls
|
||||
setConversations(conversationsData as Conversation[])
|
||||
setMessages(messagesData as Record<string, Message[]>)
|
||||
setUsers(usersData as User[])
|
||||
} catch (error) {
|
||||
console.error("Failed to load chat data:", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-muted-foreground">Loading chat...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 md:px-6">
|
||||
<Chat
|
||||
conversations={conversations}
|
||||
messages={messages}
|
||||
users={users}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client"
|
||||
|
||||
import { create } from "zustand"
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
avatar: string
|
||||
status: "online" | "away" | "offline"
|
||||
lastSeen: string
|
||||
role: string
|
||||
department: string
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
content: string
|
||||
timestamp: string
|
||||
senderId: string
|
||||
type: "text" | "image" | "file"
|
||||
isEdited: boolean
|
||||
reactions: Array<{
|
||||
emoji: string
|
||||
users: string[]
|
||||
count: number
|
||||
}>
|
||||
replyTo: string | null
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string
|
||||
type: "direct" | "group"
|
||||
participants: string[]
|
||||
name: string
|
||||
avatar: string
|
||||
lastMessage: {
|
||||
id: string
|
||||
content: string
|
||||
timestamp: string
|
||||
senderId: string
|
||||
}
|
||||
unreadCount: number
|
||||
isPinned: boolean
|
||||
isMuted: boolean
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
conversations: Conversation[]
|
||||
messages: Record<string, Message[]>
|
||||
users: User[]
|
||||
selectedConversation: string | null
|
||||
searchQuery: string
|
||||
isTyping: Record<string, boolean>
|
||||
onlineUsers: string[]
|
||||
}
|
||||
|
||||
interface ChatActions {
|
||||
setConversations: (conversations: Conversation[]) => void
|
||||
setMessages: (conversationId: string, messages: Message[]) => void
|
||||
setUsers: (users: User[]) => void
|
||||
setSelectedConversation: (conversationId: string | null) => void
|
||||
setSearchQuery: (query: string) => void
|
||||
addMessage: (conversationId: string, message: Message) => void
|
||||
markAsRead: (conversationId: string) => void
|
||||
togglePin: (conversationId: string) => void
|
||||
toggleMute: (conversationId: string) => void
|
||||
setTyping: (conversationId: string, isTyping: boolean) => void
|
||||
setOnlineUsers: (userIds: string[]) => void
|
||||
}
|
||||
|
||||
export const useChat = create<ChatState & ChatActions>((set, get) => ({
|
||||
// State
|
||||
conversations: [],
|
||||
messages: {},
|
||||
users: [],
|
||||
selectedConversation: null,
|
||||
searchQuery: "",
|
||||
isTyping: {},
|
||||
onlineUsers: [],
|
||||
|
||||
// Actions
|
||||
setConversations: (conversations) => set({ conversations }),
|
||||
|
||||
setMessages: (conversationId, messages) =>
|
||||
set((state) => ({
|
||||
messages: { ...state.messages, [conversationId]: messages }
|
||||
})),
|
||||
|
||||
setUsers: (users) => set({ users }),
|
||||
|
||||
setSelectedConversation: (conversationId) => {
|
||||
set({ selectedConversation: conversationId })
|
||||
if (conversationId) {
|
||||
get().markAsRead(conversationId)
|
||||
}
|
||||
},
|
||||
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
|
||||
addMessage: (conversationId, message) =>
|
||||
set((state) => ({
|
||||
messages: {
|
||||
...state.messages,
|
||||
[conversationId]: [...(state.messages[conversationId] || []), message]
|
||||
},
|
||||
conversations: state.conversations.map((conv) =>
|
||||
conv.id === conversationId
|
||||
? {
|
||||
...conv,
|
||||
lastMessage: {
|
||||
id: message.id,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
senderId: message.senderId
|
||||
}
|
||||
}
|
||||
: conv
|
||||
)
|
||||
})),
|
||||
|
||||
markAsRead: (conversationId) =>
|
||||
set((state) => ({
|
||||
conversations: state.conversations.map((conv) =>
|
||||
conv.id === conversationId ? { ...conv, unreadCount: 0 } : conv
|
||||
)
|
||||
})),
|
||||
|
||||
togglePin: (conversationId) =>
|
||||
set((state) => ({
|
||||
conversations: state.conversations.map((conv) =>
|
||||
conv.id === conversationId ? { ...conv, isPinned: !conv.isPinned } : conv
|
||||
)
|
||||
})),
|
||||
|
||||
toggleMute: (conversationId) =>
|
||||
set((state) => ({
|
||||
conversations: state.conversations.map((conv) =>
|
||||
conv.id === conversationId ? { ...conv, isMuted: !conv.isMuted } : conv
|
||||
)
|
||||
})),
|
||||
|
||||
setTyping: (conversationId, isTyping) =>
|
||||
set((state) => ({
|
||||
isTyping: { ...state.isTyping, [conversationId]: isTyping }
|
||||
})),
|
||||
|
||||
setOnlineUsers: (userIds) => set({ onlineUsers: userIds }),
|
||||
}))
|
||||
@@ -0,0 +1,248 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid } from "recharts"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Users, MapPin, TrendingUp, Target, ArrowUpIcon, UserIcon } from "lucide-react"
|
||||
|
||||
const customerGrowthData = [
|
||||
{ month: "Jan", new: 245, returning: 890, churn: 45 },
|
||||
{ month: "Feb", new: 312, returning: 934, churn: 52 },
|
||||
{ month: "Mar", new: 289, returning: 1023, churn: 38 },
|
||||
{ month: "Apr", new: 456, returning: 1156, churn: 61 },
|
||||
{ month: "May", new: 523, returning: 1298, churn: 47 },
|
||||
{ month: "Jun", new: 634, returning: 1445, churn: 55 },
|
||||
]
|
||||
|
||||
const chartConfig = {
|
||||
new: {
|
||||
label: "New Customers",
|
||||
color: "var(--chart-1)",
|
||||
},
|
||||
returning: {
|
||||
label: "Returning",
|
||||
color: "var(--chart-2)",
|
||||
},
|
||||
churn: {
|
||||
label: "Churned",
|
||||
color: "var(--chart-3)",
|
||||
},
|
||||
}
|
||||
|
||||
const demographicsData = [
|
||||
{ ageGroup: "18-24", customers: 2847, percentage: "18.0%", growth: "+15.2%", growthColor: "text-green-600" },
|
||||
{ ageGroup: "25-34", customers: 4521, percentage: "28.5%", growth: "+8.7%", growthColor: "text-green-600" },
|
||||
{ ageGroup: "35-44", customers: 3982, percentage: "25.1%", growth: "+3.4%", growthColor: "text-blue-600" },
|
||||
{ ageGroup: "45-54", customers: 2734, percentage: "17.2%", growth: "+1.2%", growthColor: "text-orange-600" },
|
||||
{ ageGroup: "55+", customers: 1763, percentage: "11.2%", growth: "-2.1%", growthColor: "text-red-600" },
|
||||
]
|
||||
|
||||
const regionsData = [
|
||||
{ region: "North America", customers: 6847, revenue: "$847,523", growth: "+12.3%", growthColor: "text-green-600" },
|
||||
{ region: "Europe", customers: 4521, revenue: "$563,891", growth: "+9.7%", growthColor: "text-green-600" },
|
||||
{ region: "Asia Pacific", customers: 2892, revenue: "$321,456", growth: "+18.4%", growthColor: "text-blue-600" },
|
||||
{ region: "Latin America", customers: 1123, revenue: "$187,234", growth: "+15.8%", growthColor: "text-green-600" },
|
||||
{ region: "Others", customers: 464, revenue: "$67,891", growth: "+5.2%", growthColor: "text-orange-600" },
|
||||
]
|
||||
|
||||
export function CustomerInsights() {
|
||||
const [activeTab, setActiveTab] = useState("growth")
|
||||
|
||||
return (
|
||||
<Card className="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle>Customer Insights</CardTitle>
|
||||
<CardDescription>Growth trends and demographics</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 bg-muted/50 p-1 rounded-lg h-12">
|
||||
<TabsTrigger
|
||||
value="growth"
|
||||
className="cursor-pointer flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-all data-[state=active]:bg-background data-[state=active]:shadow-sm data-[state=active]:text-foreground"
|
||||
>
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Growth</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="demographics"
|
||||
className="cursor-pointer flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-all data-[state=active]:bg-background data-[state=active]:shadow-sm data-[state=active]:text-foreground"
|
||||
>
|
||||
<UserIcon className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Demographics</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="regions"
|
||||
className="cursor-pointer flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-all data-[state=active]:bg-background data-[state=active]:shadow-sm data-[state=active]:text-foreground"
|
||||
>
|
||||
<MapPin className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Regions</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="growth" className="mt-8 space-y-6">
|
||||
<div className="grid gap-6">
|
||||
{/* Chart and Key Metrics Side by Side */}
|
||||
<div className="grid grid-cols-10 gap-6">
|
||||
{/* Chart Area - 70% */}
|
||||
<div className="col-span-10 xl:col-span-7">
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-6">Customer Growth Trends</h3>
|
||||
<ChartContainer config={chartConfig} className="h-[375px] w-full">
|
||||
<BarChart data={customerGrowthData} margin={{ top: 20, right: 20, bottom: 20, left: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
className="text-xs"
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={{ stroke: 'var(--border)' }}
|
||||
axisLine={{ stroke: 'var(--border)' }}
|
||||
/>
|
||||
<YAxis
|
||||
className="text-xs"
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={{ stroke: 'var(--border)' }}
|
||||
axisLine={{ stroke: 'var(--border)' }}
|
||||
domain={[0, 'dataMax']}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Bar dataKey="new" fill="var(--color-new)" radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="returning" fill="var(--color-returning)" radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="churn" fill="var(--color-churn)" radius={[2, 2, 0, 0]} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
|
||||
{/* Key Metrics - 30% */}
|
||||
<div className="col-span-10 xl:col-span-3 space-y-5">
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-6">Key Metrics</h3>
|
||||
<div className="grid grid-cols-3 gap-5">
|
||||
<div className="p-4 rounded-lg max-lg:col-span-3 xl:col-span-3 border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingUp className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm font-medium">Total Customers</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold">15,847</div>
|
||||
<div className="text-xs text-green-600 flex items-center gap-1 mt-1">
|
||||
<ArrowUpIcon className="h-3 w-3" />
|
||||
+12.5% from last month
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg max-lg:col-span-3 xl:col-span-3 border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Retention Rate</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold">92.4%</div>
|
||||
<div className="text-xs text-green-600 flex items-center gap-1 mt-1">
|
||||
<ArrowUpIcon className="h-3 w-3" />
|
||||
+2.1% improvement
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg max-lg:col-span-3 xl:col-span-3 border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Target className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Avg. LTV</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold">$2,847</div>
|
||||
<div className="text-xs text-green-600 flex items-center gap-1 mt-1">
|
||||
<ArrowUpIcon className="h-3 w-3" />
|
||||
+8.3% growth
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="demographics" className="mt-8">
|
||||
<div className="rounded-lg border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-b">
|
||||
<TableHead className="py-5 px-6 font-semibold">Age Group</TableHead>
|
||||
<TableHead className="text-right py-5 px-6 font-semibold">Customers</TableHead>
|
||||
<TableHead className="text-right py-5 px-6 font-semibold">Percentage</TableHead>
|
||||
<TableHead className="text-right py-5 px-6 font-semibold">Growth</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{demographicsData.map((row, index) => (
|
||||
<TableRow key={index} className="hover:bg-muted/30 transition-colors">
|
||||
<TableCell className="font-medium py-5 px-6">{row.ageGroup}</TableCell>
|
||||
<TableCell className="text-right py-5 px-6">{row.customers.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-right py-5 px-6">{row.percentage}</TableCell>
|
||||
<TableCell className="text-right py-5 px-6">
|
||||
<span className={`font-medium ${row.growthColor}`}>{row.growth}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-2 py-6">
|
||||
<div className="text-muted-foreground text-sm hidden sm:block">
|
||||
0 of {demographicsData.length} row(s) selected.
|
||||
</div>
|
||||
<div className="space-x-2 space-y-2">
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
Previous
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
<TabsContent value="regions" className="mt-8">
|
||||
<div className="rounded-lg border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-b">
|
||||
<TableHead className="py-5 px-6 font-semibold">Region</TableHead>
|
||||
<TableHead className="text-right py-5 px-6 font-semibold">Customers</TableHead>
|
||||
<TableHead className="text-right py-5 px-6 font-semibold">Revenue</TableHead>
|
||||
<TableHead className="text-right py-5 px-6 font-semibold">Growth</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{regionsData.map((row, index) => (
|
||||
<TableRow key={index} className="hover:bg-muted/30 transition-colors">
|
||||
<TableCell className="font-medium py-5 px-6">{row.region}</TableCell>
|
||||
<TableCell className="text-right py-5 px-6">{row.customers.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-right py-5 px-6">{row.revenue}</TableCell>
|
||||
<TableCell className="text-right py-5 px-6">
|
||||
<span className={`font-medium ${row.growthColor}`}>{row.growth}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-2 py-6">
|
||||
<div className="text-muted-foreground text-sm hidden sm:block">
|
||||
0 of {regionsData.length} row(s) selected.
|
||||
</div>
|
||||
<div className="space-x-2 space-y-2">
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
Previous
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Calendar, Clock, RefreshCw, Filter } from "lucide-react"
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
export function DashboardHeader() {
|
||||
const [dateRange, setDateRange] = useState("30d")
|
||||
const lastUpdated = new Date().toLocaleString()
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-3xl font-bold">Business Dashboard</CardTitle>
|
||||
<CardDescription className="text-base mt-2">
|
||||
Comprehensive overview of your business performance and key metrics
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline" className="cursor-pointer">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
Live Data
|
||||
</Badge>
|
||||
<Button variant="outline" size="sm" className="cursor-pointer">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Date Range:</span>
|
||||
<Select value={dateRange} onValueChange={setDateRange}>
|
||||
<SelectTrigger className="w-40 cursor-pointer">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7d" className="cursor-pointer">Last 7 days</SelectItem>
|
||||
<SelectItem value="30d" className="cursor-pointer">Last 30 days</SelectItem>
|
||||
<SelectItem value="90d" className="cursor-pointer">Last 90 days</SelectItem>
|
||||
<SelectItem value="1y" className="cursor-pointer">Last year</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="cursor-pointer">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Filters
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Last updated: {lastUpdated}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
DollarSign,
|
||||
Users,
|
||||
ShoppingCart,
|
||||
BarChart3
|
||||
} from "lucide-react"
|
||||
import { Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
const metrics = [
|
||||
{
|
||||
title: "Total Revenue",
|
||||
value: "$54,230",
|
||||
description: "Monthly revenue",
|
||||
change: "+12%",
|
||||
trend: "up",
|
||||
icon: DollarSign,
|
||||
footer: "Trending up this month",
|
||||
subfooter: "Revenue for the last 6 months"
|
||||
},
|
||||
{
|
||||
title: "Active Customers",
|
||||
value: "2,350",
|
||||
description: "Total active users",
|
||||
change: "+5.2%",
|
||||
trend: "up",
|
||||
icon: Users,
|
||||
footer: "Strong user retention",
|
||||
subfooter: "Engagement exceeds targets"
|
||||
},
|
||||
{
|
||||
title: "Total Orders",
|
||||
value: "1,247",
|
||||
description: "Orders this month",
|
||||
change: "-2.1%",
|
||||
trend: "down",
|
||||
icon: ShoppingCart,
|
||||
footer: "Down 2% this period",
|
||||
subfooter: "Order volume needs attention"
|
||||
},
|
||||
{
|
||||
title: "Conversion Rate",
|
||||
value: "3.24%",
|
||||
description: "Average conversion",
|
||||
change: "+8.3%",
|
||||
trend: "up",
|
||||
icon: BarChart3,
|
||||
footer: "Steady performance increase",
|
||||
subfooter: "Meets conversion projections"
|
||||
},
|
||||
]
|
||||
|
||||
export function MetricsOverview() {
|
||||
return (
|
||||
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs grid gap-4 sm:grid-cols-2 @5xl:grid-cols-4">
|
||||
{metrics.map((metric) => {
|
||||
const TrendIcon = metric.trend === "up" ? TrendingUp : TrendingDown
|
||||
|
||||
return (
|
||||
<Card key={metric.title} className=" cursor-pointer">
|
||||
<CardHeader>
|
||||
<CardDescription>{metric.title}</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{metric.value}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<TrendIcon className="h-4 w-4" />
|
||||
{metric.change}
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
{metric.footer} <TrendIcon className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{metric.subfooter}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import { Plus, Settings, FileText, Download } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
|
||||
export function QuickActions() {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button className="cursor-pointer">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Sale
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="cursor-pointer">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Actions
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Generate Report
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export Data
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Dashboard Settings
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client"
|
||||
|
||||
import { Eye, MoreHorizontal } from "lucide-react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
|
||||
const transactions = [
|
||||
{
|
||||
id: "TXN-001",
|
||||
customer: {
|
||||
name: "Olivia Martin",
|
||||
email: "olivia.martin@email.com",
|
||||
avatar: "https://notion-avatars.netlify.app/api/avatar/?preset=female-7",
|
||||
},
|
||||
amount: "$1,999.00",
|
||||
status: "completed",
|
||||
date: "2 hours ago",
|
||||
},
|
||||
{
|
||||
id: "TXN-002",
|
||||
customer: {
|
||||
name: "Jackson Lee",
|
||||
email: "jackson.lee@email.com",
|
||||
avatar: "https://notion-avatars.netlify.app/api/avatar/?preset=male-1",
|
||||
},
|
||||
amount: "$2,999.00",
|
||||
status: "pending",
|
||||
date: "5 hours ago",
|
||||
},
|
||||
{
|
||||
id: "TXN-003",
|
||||
customer: {
|
||||
name: "Isabella Nguyen",
|
||||
email: "isabella.nguyen@email.com",
|
||||
avatar: "https://notion-avatars.netlify.app/api/avatar/?preset=female-2",
|
||||
},
|
||||
amount: "$39.00",
|
||||
status: "completed",
|
||||
date: "1 day ago",
|
||||
},
|
||||
{
|
||||
id: "TXN-004",
|
||||
customer: {
|
||||
name: "William Kim",
|
||||
email: "will@email.com",
|
||||
avatar: "https://notion-avatars.netlify.app/api/avatar/?preset=male-5",
|
||||
},
|
||||
amount: "$299.00",
|
||||
status: "failed",
|
||||
date: "2 days ago",
|
||||
},
|
||||
{
|
||||
id: "TXN-005",
|
||||
customer: {
|
||||
name: "Sofia Davis",
|
||||
email: "sofia.davis@email.com",
|
||||
avatar: "https://notion-avatars.netlify.app/api/avatar/?preset=female-4",
|
||||
},
|
||||
amount: "$99.00",
|
||||
status: "completed",
|
||||
date: "3 days ago",
|
||||
},
|
||||
]
|
||||
|
||||
export function RecentTransactions() {
|
||||
return (
|
||||
<Card className="cursor-pointer">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||
<div>
|
||||
<CardTitle>Recent Transactions</CardTitle>
|
||||
<CardDescription>Latest customer transactions</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="cursor-pointer">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
View All
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{transactions.map((transaction) => (
|
||||
<div key={transaction.id} >
|
||||
<div className="flex p-3 rounded-lg border gap-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={transaction.customer.avatar} alt={transaction.customer.name} />
|
||||
<AvatarFallback>{transaction.customer.name.split(" ").map(n => n[0]).join("")}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-1 items-center flex-wrap justify-between gap-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{transaction.customer.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{transaction.customer.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Badge
|
||||
variant={
|
||||
transaction.status === "completed" ? "default" :
|
||||
transaction.status === "pending" ? "secondary" : "destructive"
|
||||
}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{transaction.status}
|
||||
</Badge>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">{transaction.amount}</p>
|
||||
<p className="text-xs text-muted-foreground">{transaction.date}</p>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 cursor-pointer">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem className="cursor-pointer">View Details</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer">Download Receipt</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer">Contact Customer</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Label, Pie, PieChart, Sector } from "recharts"
|
||||
import type { PieSectorDataItem } from "recharts/types/polar/Pie"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { ChartContainer, ChartStyle, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
const revenueData = [
|
||||
{ category: "subscriptions", value: 45, amount: 24500, fill: "var(--color-subscriptions)" },
|
||||
{ category: "sales", value: 30, amount: 16300, fill: "var(--color-sales)" },
|
||||
{ category: "services", value: 15, amount: 8150, fill: "var(--color-services)" },
|
||||
{ category: "partnerships", value: 10, amount: 5430, fill: "var(--color-partnerships)" },
|
||||
]
|
||||
|
||||
const chartConfig = {
|
||||
revenue: {
|
||||
label: "Revenue",
|
||||
},
|
||||
amount: {
|
||||
label: "Amount",
|
||||
},
|
||||
subscriptions: {
|
||||
label: "Subscriptions",
|
||||
color: "var(--chart-1)",
|
||||
},
|
||||
sales: {
|
||||
label: "One-time Sales",
|
||||
color: "var(--chart-2)",
|
||||
},
|
||||
services: {
|
||||
label: "Services",
|
||||
color: "var(--chart-3)",
|
||||
},
|
||||
partnerships: {
|
||||
label: "Partnerships",
|
||||
color: "var(--chart-4)",
|
||||
},
|
||||
}
|
||||
|
||||
export function RevenueBreakdown() {
|
||||
const id = "revenue-breakdown"
|
||||
const [activeCategory, setActiveCategory] = React.useState("sales")
|
||||
|
||||
const activeIndex = React.useMemo(() => {
|
||||
const index = revenueData.findIndex((item) => item.category === activeCategory)
|
||||
return index === -1 ? 0 : index
|
||||
}, [activeCategory])
|
||||
|
||||
const categories = React.useMemo(() => revenueData.map((item) => item.category), [])
|
||||
|
||||
return (
|
||||
<Card data-chart={id} className="flex flex-col cursor-pointer">
|
||||
<ChartStyle id={id} config={chartConfig} />
|
||||
<CardHeader className="flex flex-col space-y-2 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-2">
|
||||
<div>
|
||||
<CardTitle>Revenue Breakdown</CardTitle>
|
||||
<CardDescription>Revenue distribution by source</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select value={activeCategory} onValueChange={setActiveCategory}>
|
||||
<SelectTrigger
|
||||
className="w-[175px] rounded-lg cursor-pointer"
|
||||
aria-label="Select a category"
|
||||
>
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end" className="rounded-lg">
|
||||
{categories.map((key) => {
|
||||
const config = chartConfig[key as keyof typeof chartConfig]
|
||||
|
||||
if (!config) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectItem
|
||||
key={key}
|
||||
value={key}
|
||||
className="rounded-md [&_span]:flex cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="flex h-3 w-3 shrink-0 "
|
||||
style={{
|
||||
backgroundColor: `var(--color-${key})`,
|
||||
}}
|
||||
/>
|
||||
{config?.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" className="cursor-pointer">
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 justify-center">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 w-full">
|
||||
<div className="flex justify-center">
|
||||
<ChartContainer
|
||||
id={id}
|
||||
config={chartConfig}
|
||||
className="mx-auto aspect-square w-full max-w-[300px]"
|
||||
>
|
||||
<PieChart>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent hideLabel />}
|
||||
/>
|
||||
<Pie
|
||||
data={revenueData}
|
||||
dataKey="amount"
|
||||
nameKey="category"
|
||||
innerRadius={60}
|
||||
strokeWidth={5}
|
||||
activeShape={({
|
||||
outerRadius = 0,
|
||||
...props
|
||||
}: PieSectorDataItem) => (
|
||||
<g>
|
||||
<Sector {...props} outerRadius={outerRadius + 10} />
|
||||
<Sector
|
||||
{...props}
|
||||
outerRadius={outerRadius + 25}
|
||||
innerRadius={outerRadius + 12}
|
||||
/>
|
||||
</g>
|
||||
)}
|
||||
>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
||||
return (
|
||||
<text
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
className="fill-foreground text-3xl font-bold"
|
||||
>
|
||||
${(revenueData[activeIndex].amount / 1000).toFixed(0)}K
|
||||
</tspan>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={(viewBox.cy || 0) + 24}
|
||||
className="fill-muted-foreground"
|
||||
>
|
||||
Revenue
|
||||
</tspan>
|
||||
</text>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center space-y-4">
|
||||
{revenueData.map((item, index) => {
|
||||
const config = chartConfig[item.category as keyof typeof chartConfig]
|
||||
const isActive = index === activeIndex
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.category}
|
||||
className={`flex items-center justify-between p-3 rounded-lg transition-colors cursor-pointer ${
|
||||
isActive ? 'bg-muted' : 'hover:bg-muted/50'
|
||||
}`}
|
||||
onClick={() => setActiveCategory(item.category)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="flex h-3 w-3 shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: `var(--color-${item.category})`,
|
||||
}}
|
||||
/>
|
||||
<span className="font-medium">{config?.label}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-bold">${(item.amount / 1000).toFixed(1)}K</div>
|
||||
<div className="text-sm text-muted-foreground">{item.value}%</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
const salesData = [
|
||||
{ month: "Jan", sales: 12500, target: 15000 },
|
||||
{ month: "Feb", sales: 18200, target: 15000 },
|
||||
{ month: "Mar", sales: 16800, target: 15000 },
|
||||
{ month: "Apr", sales: 22400, target: 20000 },
|
||||
{ month: "May", sales: 24600, target: 20000 },
|
||||
{ month: "Jun", sales: 28200, target: 25000 },
|
||||
{ month: "Jul", sales: 31500, target: 25000 },
|
||||
{ month: "Aug", sales: 29800, target: 25000 },
|
||||
{ month: "Sep", sales: 33200, target: 30000 },
|
||||
{ month: "Oct", sales: 35100, target: 30000 },
|
||||
{ month: "Nov", sales: 38900, target: 35000 },
|
||||
{ month: "Dec", sales: 42300, target: 35000 },
|
||||
]
|
||||
|
||||
const chartConfig = {
|
||||
sales: {
|
||||
label: "Sales",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
target: {
|
||||
label: "Target",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
}
|
||||
|
||||
export function SalesChart() {
|
||||
const [timeRange, setTimeRange] = useState("12m")
|
||||
|
||||
return (
|
||||
<Card className="cursor-pointer">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div>
|
||||
<CardTitle>Sales Performance</CardTitle>
|
||||
<CardDescription>Monthly sales vs targets</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
<SelectTrigger className="w-32 cursor-pointer">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="3m" className="cursor-pointer">Last 3 months</SelectItem>
|
||||
<SelectItem value="6m" className="cursor-pointer">Last 6 months</SelectItem>
|
||||
<SelectItem value="12m" className="cursor-pointer">Last 12 months</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" className="cursor-pointer">
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 pt-6">
|
||||
<div className="px-6 pb-6">
|
||||
<ChartContainer config={chartConfig} className="h-[350px] w-full">
|
||||
<AreaChart data={salesData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorSales" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-sales)" stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor="var(--color-sales)" stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorTarget" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-target)" stopOpacity={0.2} />
|
||||
<stop offset="95%" stopColor="var(--color-target)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted/30" />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
className="text-xs"
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
className="text-xs"
|
||||
tick={{ fontSize: 12 }}
|
||||
tickFormatter={(value) => `$${value.toLocaleString()}`}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="target"
|
||||
stackId="1"
|
||||
stroke="var(--color-target)"
|
||||
fill="url(#colorTarget)"
|
||||
strokeDasharray="5 5"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="sales"
|
||||
stackId="2"
|
||||
stroke="var(--color-sales)"
|
||||
fill="url(#colorSales)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client"
|
||||
|
||||
import { Eye, Star, TrendingUp } from "lucide-react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
|
||||
const products = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Premium Dashboard",
|
||||
sales: 2847,
|
||||
revenue: "$142,350",
|
||||
growth: "+23%",
|
||||
rating: 4.8,
|
||||
stock: 145,
|
||||
category: "Software",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Analytics Pro",
|
||||
sales: 1923,
|
||||
revenue: "$96,150",
|
||||
growth: "+18%",
|
||||
rating: 4.6,
|
||||
stock: 67,
|
||||
category: "Tools",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Mobile App Suite",
|
||||
sales: 1456,
|
||||
revenue: "$72,800",
|
||||
growth: "+12%",
|
||||
rating: 4.9,
|
||||
stock: 234,
|
||||
category: "Mobile",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Enterprise License",
|
||||
sales: 892,
|
||||
revenue: "$178,400",
|
||||
growth: "+8%",
|
||||
rating: 4.7,
|
||||
stock: 12,
|
||||
category: "Enterprise",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "Basic Subscription",
|
||||
sales: 3421,
|
||||
revenue: "$68,420",
|
||||
growth: "+31%",
|
||||
rating: 4.4,
|
||||
stock: 999,
|
||||
category: "Subscription",
|
||||
},
|
||||
]
|
||||
|
||||
export function TopProducts() {
|
||||
return (
|
||||
<Card className="cursor-pointer">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||
<div>
|
||||
<CardTitle>Top Products</CardTitle>
|
||||
<CardDescription>Best performing products this month</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="cursor-pointer">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
View All
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{products.map((product, index) => (
|
||||
<div key={product.id} className="flex items-center p-3 rounded-lg border gap-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary font-semibold text-sm">
|
||||
#{index + 1}
|
||||
</div>
|
||||
<div className="flex gap-2 items-center justify-between space-x-3 flex-1 flex-wrap">
|
||||
<div className="">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-sm font-medium truncate">{product.name}</p>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{product.category}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Star className="h-3 w-3 fill-yellow-400 text-yellow-400" />
|
||||
<span className="text-xs text-muted-foreground">{product.rating}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">•</span>
|
||||
<span className="text-xs text-muted-foreground">{product.sales} sales</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right space-y-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-sm font-medium">{product.revenue}</p>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-green-600 border-green-200 cursor-pointer"
|
||||
>
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
{product.growth}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-muted-foreground">Stock: {product.stock}</span>
|
||||
<Progress
|
||||
value={product.stock > 100 ? 100 : (product.stock / 100) * 100}
|
||||
className="w-12 h-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"totalRevenue": 54231.89,
|
||||
"revenueChange": 12.5,
|
||||
"activeCustomers": 2350,
|
||||
"customerChange": 5.2,
|
||||
"totalOrders": 1247,
|
||||
"orderChange": -2.1,
|
||||
"conversionRate": 3.24,
|
||||
"conversionChange": 8.3,
|
||||
"salesData": [
|
||||
{ "month": "Jan", "sales": 12500, "target": 15000 },
|
||||
{ "month": "Feb", "sales": 18200, "target": 15000 },
|
||||
{ "month": "Mar", "sales": 16800, "target": 15000 },
|
||||
{ "month": "Apr", "sales": 22400, "target": 20000 },
|
||||
{ "month": "May", "sales": 24600, "target": 20000 },
|
||||
{ "month": "Jun", "sales": 28200, "target": 25000 },
|
||||
{ "month": "Jul", "sales": 31500, "target": 25000 },
|
||||
{ "month": "Aug", "sales": 29800, "target": 25000 },
|
||||
{ "month": "Sep", "sales": 33200, "target": 30000 },
|
||||
{ "month": "Oct", "sales": 35100, "target": 30000 },
|
||||
{ "month": "Nov", "sales": 38900, "target": 35000 },
|
||||
{ "month": "Dec", "sales": 42300, "target": 35000 }
|
||||
],
|
||||
"revenueBreakdown": [
|
||||
{ "name": "Subscriptions", "value": 45, "amount": 24500, "color": "hsl(210, 100%, 50%)" },
|
||||
{ "name": "One-time Sales", "value": 30, "amount": 16300, "color": "hsl(280, 100%, 70%)" },
|
||||
{ "name": "Services", "value": 15, "amount": 8150, "color": "hsl(120, 100%, 40%)" },
|
||||
{ "name": "Partnerships", "value": 10, "amount": 5430, "color": "hsl(30, 100%, 50%)" }
|
||||
],
|
||||
"customerGrowth": [
|
||||
{ "month": "Jan", "new": 245, "returning": 890, "churn": 45 },
|
||||
{ "month": "Feb", "new": 312, "returning": 934, "churn": 52 },
|
||||
{ "month": "Mar", "new": 289, "returning": 1023, "churn": 38 },
|
||||
{ "month": "Apr", "new": 456, "returning": 1156, "churn": 61 },
|
||||
{ "month": "May", "new": 523, "returning": 1298, "churn": 47 },
|
||||
{ "month": "Jun", "new": 634, "returning": 1445, "churn": 55 }
|
||||
],
|
||||
"lastUpdated": "2025-08-12T15:30:00Z"
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { MetricsOverview } from "./components/metrics-overview"
|
||||
import { SalesChart } from "./components/sales-chart"
|
||||
import { RecentTransactions } from "./components/recent-transactions"
|
||||
import { TopProducts } from "./components/top-products"
|
||||
import { CustomerInsights } from "./components/customer-insights"
|
||||
import { QuickActions } from "./components/quick-actions"
|
||||
import { RevenueBreakdown } from "./components/revenue-breakdown"
|
||||
|
||||
export default function Dashboard2() {
|
||||
return (
|
||||
<div className="flex-1 space-y-6 px-6 pt-0">
|
||||
{/* Enhanced Header */}
|
||||
|
||||
<div className="flex md:flex-row flex-col md:items-center justify-between gap-4 md:gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Business Dashboard</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Monitor your business performance and key metrics in real-time
|
||||
</p>
|
||||
</div>
|
||||
<QuickActions />
|
||||
</div>
|
||||
|
||||
{/* Main Dashboard Grid */}
|
||||
<div className="@container/main space-y-6">
|
||||
{/* Top Row - Key Metrics */}
|
||||
|
||||
<MetricsOverview />
|
||||
|
||||
{/* Second Row - Charts in 6-6 columns */}
|
||||
<div className="grid gap-6 grid-cols-1 @5xl:grid-cols-2">
|
||||
<SalesChart />
|
||||
<RevenueBreakdown />
|
||||
</div>
|
||||
|
||||
{/* Third Row - Two Column Layout */}
|
||||
<div className="grid gap-6 grid-cols-1 @5xl:grid-cols-2">
|
||||
<RecentTransactions />
|
||||
<TopProducts />
|
||||
</div>
|
||||
|
||||
{/* Fourth Row - Customer Insights and Team Performance */}
|
||||
<CustomerInsights />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from "@/components/ui/toggle-group"
|
||||
|
||||
export const description = "An interactive area chart"
|
||||
|
||||
const chartData = [
|
||||
{ date: "2024-04-01", desktop: 222, mobile: 150 },
|
||||
{ date: "2024-04-02", desktop: 97, mobile: 180 },
|
||||
{ date: "2024-04-03", desktop: 167, mobile: 120 },
|
||||
{ date: "2024-04-04", desktop: 242, mobile: 260 },
|
||||
{ date: "2024-04-05", desktop: 373, mobile: 290 },
|
||||
{ date: "2024-04-06", desktop: 301, mobile: 340 },
|
||||
{ date: "2024-04-07", desktop: 245, mobile: 180 },
|
||||
{ date: "2024-04-08", desktop: 409, mobile: 320 },
|
||||
{ date: "2024-04-09", desktop: 59, mobile: 110 },
|
||||
{ date: "2024-04-10", desktop: 261, mobile: 190 },
|
||||
{ date: "2024-04-11", desktop: 327, mobile: 350 },
|
||||
{ date: "2024-04-12", desktop: 292, mobile: 210 },
|
||||
{ date: "2024-04-13", desktop: 342, mobile: 380 },
|
||||
{ date: "2024-04-14", desktop: 137, mobile: 220 },
|
||||
{ date: "2024-04-15", desktop: 120, mobile: 170 },
|
||||
{ date: "2024-04-16", desktop: 138, mobile: 190 },
|
||||
{ date: "2024-04-17", desktop: 446, mobile: 360 },
|
||||
{ date: "2024-04-18", desktop: 364, mobile: 410 },
|
||||
{ date: "2024-04-19", desktop: 243, mobile: 180 },
|
||||
{ date: "2024-04-20", desktop: 89, mobile: 150 },
|
||||
{ date: "2024-04-21", desktop: 137, mobile: 200 },
|
||||
{ date: "2024-04-22", desktop: 224, mobile: 170 },
|
||||
{ date: "2024-04-23", desktop: 138, mobile: 230 },
|
||||
{ date: "2024-04-24", desktop: 387, mobile: 290 },
|
||||
{ date: "2024-04-25", desktop: 215, mobile: 250 },
|
||||
{ date: "2024-04-26", desktop: 75, mobile: 130 },
|
||||
{ date: "2024-04-27", desktop: 383, mobile: 420 },
|
||||
{ date: "2024-04-28", desktop: 122, mobile: 180 },
|
||||
{ date: "2024-04-29", desktop: 315, mobile: 240 },
|
||||
{ date: "2024-04-30", desktop: 454, mobile: 380 },
|
||||
{ date: "2024-05-01", desktop: 165, mobile: 220 },
|
||||
{ date: "2024-05-02", desktop: 293, mobile: 310 },
|
||||
{ date: "2024-05-03", desktop: 247, mobile: 190 },
|
||||
{ date: "2024-05-04", desktop: 385, mobile: 420 },
|
||||
{ date: "2024-05-05", desktop: 481, mobile: 390 },
|
||||
{ date: "2024-05-06", desktop: 498, mobile: 520 },
|
||||
{ date: "2024-05-07", desktop: 388, mobile: 300 },
|
||||
{ date: "2024-05-08", desktop: 149, mobile: 210 },
|
||||
{ date: "2024-05-09", desktop: 227, mobile: 180 },
|
||||
{ date: "2024-05-10", desktop: 293, mobile: 330 },
|
||||
{ date: "2024-05-11", desktop: 335, mobile: 270 },
|
||||
{ date: "2024-05-12", desktop: 197, mobile: 240 },
|
||||
{ date: "2024-05-13", desktop: 197, mobile: 160 },
|
||||
{ date: "2024-05-14", desktop: 448, mobile: 490 },
|
||||
{ date: "2024-05-15", desktop: 473, mobile: 380 },
|
||||
{ date: "2024-05-16", desktop: 338, mobile: 400 },
|
||||
{ date: "2024-05-17", desktop: 499, mobile: 420 },
|
||||
{ date: "2024-05-18", desktop: 315, mobile: 350 },
|
||||
{ date: "2024-05-19", desktop: 235, mobile: 180 },
|
||||
{ date: "2024-05-20", desktop: 177, mobile: 230 },
|
||||
{ date: "2024-05-21", desktop: 82, mobile: 140 },
|
||||
{ date: "2024-05-22", desktop: 81, mobile: 120 },
|
||||
{ date: "2024-05-23", desktop: 252, mobile: 290 },
|
||||
{ date: "2024-05-24", desktop: 294, mobile: 220 },
|
||||
{ date: "2024-05-25", desktop: 201, mobile: 250 },
|
||||
{ date: "2024-05-26", desktop: 213, mobile: 170 },
|
||||
{ date: "2024-05-27", desktop: 420, mobile: 460 },
|
||||
{ date: "2024-05-28", desktop: 233, mobile: 190 },
|
||||
{ date: "2024-05-29", desktop: 78, mobile: 130 },
|
||||
{ date: "2024-05-30", desktop: 340, mobile: 280 },
|
||||
{ date: "2024-05-31", desktop: 178, mobile: 230 },
|
||||
{ date: "2024-06-01", desktop: 178, mobile: 200 },
|
||||
{ date: "2024-06-02", desktop: 470, mobile: 410 },
|
||||
{ date: "2024-06-03", desktop: 103, mobile: 160 },
|
||||
{ date: "2024-06-04", desktop: 439, mobile: 380 },
|
||||
{ date: "2024-06-05", desktop: 88, mobile: 140 },
|
||||
{ date: "2024-06-06", desktop: 294, mobile: 250 },
|
||||
{ date: "2024-06-07", desktop: 323, mobile: 370 },
|
||||
{ date: "2024-06-08", desktop: 385, mobile: 320 },
|
||||
{ date: "2024-06-09", desktop: 438, mobile: 480 },
|
||||
{ date: "2024-06-10", desktop: 155, mobile: 200 },
|
||||
{ date: "2024-06-11", desktop: 92, mobile: 150 },
|
||||
{ date: "2024-06-12", desktop: 492, mobile: 420 },
|
||||
{ date: "2024-06-13", desktop: 81, mobile: 130 },
|
||||
{ date: "2024-06-14", desktop: 426, mobile: 380 },
|
||||
{ date: "2024-06-15", desktop: 307, mobile: 350 },
|
||||
{ date: "2024-06-16", desktop: 371, mobile: 310 },
|
||||
{ date: "2024-06-17", desktop: 475, mobile: 520 },
|
||||
{ date: "2024-06-18", desktop: 107, mobile: 170 },
|
||||
{ date: "2024-06-19", desktop: 341, mobile: 290 },
|
||||
{ date: "2024-06-20", desktop: 408, mobile: 450 },
|
||||
{ date: "2024-06-21", desktop: 169, mobile: 210 },
|
||||
{ date: "2024-06-22", desktop: 317, mobile: 270 },
|
||||
{ date: "2024-06-23", desktop: 480, mobile: 530 },
|
||||
{ date: "2024-06-24", desktop: 132, mobile: 180 },
|
||||
{ date: "2024-06-25", desktop: 141, mobile: 190 },
|
||||
{ date: "2024-06-26", desktop: 434, mobile: 380 },
|
||||
{ date: "2024-06-27", desktop: 448, mobile: 490 },
|
||||
{ date: "2024-06-28", desktop: 149, mobile: 200 },
|
||||
{ date: "2024-06-29", desktop: 103, mobile: 160 },
|
||||
{ date: "2024-06-30", desktop: 446, mobile: 400 },
|
||||
]
|
||||
|
||||
const chartConfig = {
|
||||
visitors: {
|
||||
label: "Visitors",
|
||||
},
|
||||
desktop: {
|
||||
label: "Desktop",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
mobile: {
|
||||
label: "Mobile",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
export function ChartAreaInteractive() {
|
||||
const isMobile = useIsMobile()
|
||||
const [timeRange, setTimeRange] = React.useState("90d")
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isMobile) {
|
||||
setTimeRange("7d")
|
||||
}
|
||||
}, [isMobile])
|
||||
|
||||
const filteredData = chartData.filter((item) => {
|
||||
const date = new Date(item.date)
|
||||
const referenceDate = new Date("2024-06-30")
|
||||
let daysToSubtract = 90
|
||||
if (timeRange === "30d") {
|
||||
daysToSubtract = 30
|
||||
} else if (timeRange === "7d") {
|
||||
daysToSubtract = 7
|
||||
}
|
||||
const startDate = new Date(referenceDate)
|
||||
startDate.setDate(startDate.getDate() - daysToSubtract)
|
||||
return date >= startDate
|
||||
})
|
||||
|
||||
return (
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardTitle>Total Visitors</CardTitle>
|
||||
<CardDescription>
|
||||
<span className="hidden @[540px]/card:block">
|
||||
Total for the last 3 months
|
||||
</span>
|
||||
<span className="@[540px]/card:hidden">Last 3 months</span>
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={timeRange}
|
||||
onValueChange={setTimeRange}
|
||||
variant="outline"
|
||||
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
|
||||
>
|
||||
<ToggleGroupItem value="90d">Last 3 months</ToggleGroupItem>
|
||||
<ToggleGroupItem value="30d">Last 30 days</ToggleGroupItem>
|
||||
<ToggleGroupItem value="7d">Last 7 days</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
<SelectTrigger
|
||||
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
|
||||
size="sm"
|
||||
aria-label="Select a value"
|
||||
>
|
||||
<SelectValue placeholder="Last 3 months" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="90d" className="rounded-lg">
|
||||
Last 3 months
|
||||
</SelectItem>
|
||||
<SelectItem value="30d" className="rounded-lg">
|
||||
Last 30 days
|
||||
</SelectItem>
|
||||
<SelectItem value="7d" className="rounded-lg">
|
||||
Last 7 days
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[250px] w-full"
|
||||
>
|
||||
<AreaChart data={filteredData}>
|
||||
<defs>
|
||||
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-desktop)"
|
||||
stopOpacity={1.0}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-desktop)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-mobile)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-mobile)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value)
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(value) => {
|
||||
return new Date(value as string | number | Date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}}
|
||||
indicator="dot"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
dataKey="mobile"
|
||||
type="natural"
|
||||
fill="url(#fillMobile)"
|
||||
stroke="var(--color-mobile)"
|
||||
stackId="a"
|
||||
/>
|
||||
<Area
|
||||
dataKey="desktop"
|
||||
type="natural"
|
||||
fill="url(#fillDesktop)"
|
||||
stroke="var(--color-desktop)"
|
||||
stackId="a"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
||||
import { TrendingDown, TrendingUp } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
|
||||
export function SectionCards() {
|
||||
return (
|
||||
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Total Revenue</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
$1,250.00
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<TrendingUp />
|
||||
+12.5%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
Trending up this month <TrendingUp className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Visitors for the last 6 months
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>New Customers</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
1,234
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<TrendingDown />
|
||||
-20%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
Down 20% this period <TrendingDown className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Acquisition needs attention
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Active Accounts</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
45,678
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<TrendingUp />
|
||||
+12.5%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
Strong user retention <TrendingUp className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">Engagement exceed targets</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Growth Rate</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
4.5%
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<TrendingUp />
|
||||
+4.5%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
Steady performance increase <TrendingUp className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">Meets growth projections</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"header": "Cover page",
|
||||
"type": "Cover page",
|
||||
"status": "In Process",
|
||||
"target": "18",
|
||||
"limit": "5",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"header": "Table of contents",
|
||||
"type": "Table of contents",
|
||||
"status": "Done",
|
||||
"target": "29",
|
||||
"limit": "24",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"header": "Executive summary",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "10",
|
||||
"limit": "13",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"header": "Technical approach",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "27",
|
||||
"limit": "23",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"header": "Design",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "2",
|
||||
"limit": "16",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"header": "Capabilities",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "20",
|
||||
"limit": "8",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"header": "Integration with existing systems",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "21",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"header": "Innovation and Advantages",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "25",
|
||||
"limit": "26",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"header": "Overview of EMR's Innovative Solutions",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "7",
|
||||
"limit": "23",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"header": "Advanced Algorithms and Machine Learning",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "28",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"header": "Adaptive Communication Protocols",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "9",
|
||||
"limit": "31",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"header": "Advantages Over Current Technologies",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "12",
|
||||
"limit": "0",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"header": "Past Performance",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "33",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"header": "Customer Feedback and Satisfaction Levels",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "15",
|
||||
"limit": "34",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"header": "Implementation Challenges and Solutions",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "3",
|
||||
"limit": "35",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"header": "Security Measures and Data Protection Policies",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "6",
|
||||
"limit": "36",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"header": "Scalability and Future Proofing",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "4",
|
||||
"limit": "37",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"header": "Cost-Benefit Analysis",
|
||||
"type": "Plain language",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "38",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"header": "User Training and Onboarding Experience",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "17",
|
||||
"limit": "39",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"header": "Future Development Roadmap",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "11",
|
||||
"limit": "40",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"header": "System Architecture Overview",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "24",
|
||||
"limit": "18",
|
||||
"reviewer": "Maya Johnson"
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"header": "Risk Management Plan",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "15",
|
||||
"limit": "22",
|
||||
"reviewer": "Carlos Rodriguez"
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"header": "Compliance Documentation",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "31",
|
||||
"limit": "27",
|
||||
"reviewer": "Sarah Chen"
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"header": "API Documentation",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "8",
|
||||
"limit": "12",
|
||||
"reviewer": "Raj Patel"
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"header": "User Interface Mockups",
|
||||
"type": "Visual",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "25",
|
||||
"reviewer": "Leila Ahmadi"
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"header": "Database Schema",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "20",
|
||||
"reviewer": "Thomas Wilson"
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"header": "Testing Methodology",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "17",
|
||||
"limit": "14",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"header": "Deployment Strategy",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "30",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"header": "Budget Breakdown",
|
||||
"type": "Financial",
|
||||
"status": "In Process",
|
||||
"target": "13",
|
||||
"limit": "16",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 30,
|
||||
"header": "Market Analysis",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "29",
|
||||
"limit": "32",
|
||||
"reviewer": "Sophia Martinez"
|
||||
},
|
||||
{
|
||||
"id": 31,
|
||||
"header": "Competitor Comparison",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "19",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"header": "Maintenance Plan",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "16",
|
||||
"limit": "23",
|
||||
"reviewer": "Alex Thompson"
|
||||
},
|
||||
{
|
||||
"id": 33,
|
||||
"header": "User Personas",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "24",
|
||||
"reviewer": "Nina Patel"
|
||||
},
|
||||
{
|
||||
"id": 34,
|
||||
"header": "Accessibility Compliance",
|
||||
"type": "Legal",
|
||||
"status": "Done",
|
||||
"target": "18",
|
||||
"limit": "21",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 35,
|
||||
"header": "Performance Metrics",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "23",
|
||||
"limit": "26",
|
||||
"reviewer": "David Kim"
|
||||
},
|
||||
{
|
||||
"id": 36,
|
||||
"header": "Disaster Recovery Plan",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "17",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 37,
|
||||
"header": "Third-party Integrations",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "25",
|
||||
"limit": "28",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"header": "User Feedback Summary",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "20",
|
||||
"limit": "15",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"header": "Localization Strategy",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "12",
|
||||
"limit": "19",
|
||||
"reviewer": "Maria Garcia"
|
||||
},
|
||||
{
|
||||
"id": 40,
|
||||
"header": "Mobile Compatibility",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "28",
|
||||
"limit": "31",
|
||||
"reviewer": "James Wilson"
|
||||
},
|
||||
{
|
||||
"id": 41,
|
||||
"header": "Data Migration Plan",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "22",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"header": "Quality Assurance Protocols",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "33",
|
||||
"reviewer": "Priya Singh"
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"header": "Stakeholder Analysis",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "11",
|
||||
"limit": "14",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 44,
|
||||
"header": "Environmental Impact Assessment",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "24",
|
||||
"limit": "27",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 45,
|
||||
"header": "Intellectual Property Rights",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "17",
|
||||
"limit": "20",
|
||||
"reviewer": "Sarah Johnson"
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"header": "Customer Support Framework",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "25",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 47,
|
||||
"header": "Version Control Strategy",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "15",
|
||||
"limit": "18",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 48,
|
||||
"header": "Continuous Integration Pipeline",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "29",
|
||||
"reviewer": "Michael Chen"
|
||||
},
|
||||
{
|
||||
"id": 49,
|
||||
"header": "Regulatory Compliance",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "13",
|
||||
"limit": "16",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 50,
|
||||
"header": "User Authentication System",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "28",
|
||||
"limit": "31",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 51,
|
||||
"header": "Data Analytics Framework",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "24",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 52,
|
||||
"header": "Cloud Infrastructure",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "16",
|
||||
"limit": "19",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 53,
|
||||
"header": "Network Security Measures",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "29",
|
||||
"limit": "32",
|
||||
"reviewer": "Lisa Wong"
|
||||
},
|
||||
{
|
||||
"id": 54,
|
||||
"header": "Project Timeline",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "17",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 55,
|
||||
"header": "Resource Allocation",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "30",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 56,
|
||||
"header": "Team Structure and Roles",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "20",
|
||||
"limit": "23",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 57,
|
||||
"header": "Communication Protocols",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "15",
|
||||
"limit": "18",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"header": "Success Metrics",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "33",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 59,
|
||||
"header": "Internationalization Support",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "23",
|
||||
"limit": "26",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 60,
|
||||
"header": "Backup and Recovery Procedures",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "18",
|
||||
"limit": "21",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 61,
|
||||
"header": "Monitoring and Alerting System",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "25",
|
||||
"limit": "28",
|
||||
"reviewer": "Daniel Park"
|
||||
},
|
||||
{
|
||||
"id": 62,
|
||||
"header": "Code Review Guidelines",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "12",
|
||||
"limit": "15",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 63,
|
||||
"header": "Documentation Standards",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "30",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 64,
|
||||
"header": "Release Management Process",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "25",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 65,
|
||||
"header": "Feature Prioritization Matrix",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "22",
|
||||
"reviewer": "Emma Davis"
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"header": "Technical Debt Assessment",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "24",
|
||||
"limit": "27",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 67,
|
||||
"header": "Capacity Planning",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "24",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 68,
|
||||
"header": "Service Level Agreements",
|
||||
"type": "Legal",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "29",
|
||||
"reviewer": "Assign reviewer"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"header": "Technical Specifications Document v2.1",
|
||||
"type": "Technical Document",
|
||||
"status": "Final",
|
||||
"target": "100%",
|
||||
"limit": "100%",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"header": "Security Compliance Report Q4 2024",
|
||||
"type": "Compliance Document",
|
||||
"status": "Under Review",
|
||||
"target": "95%",
|
||||
"limit": "100%",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"header": "Project Management Plan v3.0",
|
||||
"type": "Management Document",
|
||||
"status": "Final",
|
||||
"target": "100%",
|
||||
"limit": "100%",
|
||||
"reviewer": "Emily Whalen"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"header": "Risk Assessment Matrix 2025",
|
||||
"type": "Risk Document",
|
||||
"status": "Draft",
|
||||
"target": "80%",
|
||||
"limit": "90%",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"header": "Quality Assurance Protocol v1.5",
|
||||
"type": "QA Document",
|
||||
"status": "Final",
|
||||
"target": "100%",
|
||||
"limit": "100%",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"header": "Dr. Sarah Mitchell",
|
||||
"type": "Project Manager",
|
||||
"status": "Active",
|
||||
"target": "15 years",
|
||||
"limit": "20 years",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"header": "James Thompson",
|
||||
"type": "Lead Engineer",
|
||||
"status": "Active",
|
||||
"target": "12 years",
|
||||
"limit": "15 years",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"header": "Maria Rodriguez",
|
||||
"type": "Security Specialist",
|
||||
"status": "Active",
|
||||
"target": "8 years",
|
||||
"limit": "10 years",
|
||||
"reviewer": "Emily Whalen"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"header": "David Chen",
|
||||
"type": "Systems Architect",
|
||||
"status": "Active",
|
||||
"target": "10 years",
|
||||
"limit": "12 years",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"header": "Lisa Johnson",
|
||||
"type": "Quality Assurance Lead",
|
||||
"status": "Active",
|
||||
"target": "6 years",
|
||||
"limit": "8 years",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"header": "Federal Communications Commission - Network Infrastructure Modernization",
|
||||
"type": "Government Contract",
|
||||
"status": "Completed",
|
||||
"target": "95%",
|
||||
"limit": "100%",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"header": "Department of Defense - Cybersecurity Enhancement Program",
|
||||
"type": "Defense Contract",
|
||||
"status": "Completed",
|
||||
"target": "98%",
|
||||
"limit": "100%",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"header": "NASA - Satellite Communication System Upgrade",
|
||||
"type": "Space Technology",
|
||||
"status": "Completed",
|
||||
"target": "92%",
|
||||
"limit": "95%",
|
||||
"reviewer": "Emily Whalen"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"header": "Department of Homeland Security - Border Security Tech",
|
||||
"type": "Security Contract",
|
||||
"status": "In Progress",
|
||||
"target": "85%",
|
||||
"limit": "90%",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"header": "GSA - Cloud Infrastructure Migration",
|
||||
"type": "IT Services",
|
||||
"status": "Completed",
|
||||
"target": "96%",
|
||||
"limit": "98%",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ChartAreaInteractive } from "./components/chart-area-interactive"
|
||||
import { DataTable } from "./components/data-table"
|
||||
import { SectionCards } from "./components/section-cards"
|
||||
|
||||
import data from "./data/data.json"
|
||||
import pastPerformanceData from "./data/past-performance-data.json"
|
||||
import keyPersonnelData from "./data/key-personnel-data.json"
|
||||
import focusDocumentsData from "./data/focus-documents-data.json"
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
{/* Page Title and Description */}
|
||||
<div className="px-4 lg:px-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
|
||||
<p className="text-muted-foreground">Welcome to your admin dashboard</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="@container/main px-4 lg:px-6 space-y-6">
|
||||
<SectionCards />
|
||||
<ChartAreaInteractive />
|
||||
</div>
|
||||
<div className="@container/main">
|
||||
<DataTable
|
||||
data={data}
|
||||
pastPerformanceData={pastPerformanceData}
|
||||
keyPersonnelData={keyPersonnelData}
|
||||
focusDocumentsData={focusDocumentsData}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const schema = z.object({
|
||||
id: z.number(),
|
||||
header: z.string(),
|
||||
type: z.string(),
|
||||
status: z.string(),
|
||||
target: z.string(),
|
||||
limit: z.string(),
|
||||
reviewer: z.string(),
|
||||
})
|
||||
|
||||
export type Task = z.infer<typeof schema>
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Search } from "lucide-react"
|
||||
|
||||
interface FAQ {
|
||||
id: number
|
||||
question: string
|
||||
answer: string
|
||||
category: string
|
||||
}
|
||||
|
||||
interface Category {
|
||||
name: string
|
||||
count: number
|
||||
}
|
||||
|
||||
interface FAQListProps {
|
||||
faqs: FAQ[]
|
||||
categories: Category[]
|
||||
}
|
||||
|
||||
export function FAQList({ faqs, categories }: FAQListProps) {
|
||||
const [selectedCategory, setSelectedCategory] = useState("All")
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
// Filter FAQs based on selected category and search query
|
||||
const filteredFaqs = faqs.filter(faq => {
|
||||
const matchesCategory = selectedCategory === "All" || faq.category === selectedCategory
|
||||
const matchesSearch = searchQuery === "" ||
|
||||
faq.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
faq.answer.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
return matchesCategory && matchesSearch
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-6 xl:grid-cols-4 gap-6">
|
||||
{/* Categories Sidebar */}
|
||||
<Card className="lg:col-span-2 xl:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Categories</CardTitle>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search FAQs..."
|
||||
className="pl-10 cursor-pointer"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{categories.map((category) => (
|
||||
<div
|
||||
key={category.name}
|
||||
className={cn(
|
||||
"flex items-center justify-between px-3 py-2 rounded-lg hover:bg-muted cursor-pointer transition-colors group",
|
||||
selectedCategory === category.name && "bg-muted"
|
||||
)}
|
||||
onClick={() => setSelectedCategory(category.name)}
|
||||
>
|
||||
<span className="font-medium">{category.name}</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
selectedCategory === category.name && "bg-background"
|
||||
)}
|
||||
>
|
||||
{category.name === "All" ? faqs.length : category.count}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* FAQs List */}
|
||||
<div className="lg:col-span-4 xl:col-span-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
{selectedCategory === "All" ? "All FAQs" : `${selectedCategory} FAQs`}
|
||||
<span className="text-sm font-normal text-muted-foreground ml-2">
|
||||
({filteredFaqs.length} {filteredFaqs.length === 1 ? 'question' : 'questions'})
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[570px] pr-4">
|
||||
{filteredFaqs.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>No FAQs found matching your search criteria.</p>
|
||||
</div>
|
||||
) : (
|
||||
<Accordion type='single' className='space-y-4' defaultValue="item-1">
|
||||
{filteredFaqs.map((item) => (
|
||||
<AccordionItem
|
||||
key={item.id}
|
||||
value={`item-${item.id}`}
|
||||
className='rounded-md !border'
|
||||
>
|
||||
<AccordionTrigger className='cursor-pointer px-4 hover:no-underline'>
|
||||
<div className="flex items-start text-left">
|
||||
<span>{item.question}</span>
|
||||
<Badge variant="outline" className="ms-3 mt-0.5 shrink-0 text-xs">
|
||||
{item.category}
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className='text-muted-foreground px-4'>
|
||||
{item.answer}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowRight, Sparkles, Shield, Truck, Clock } from 'lucide-react'
|
||||
|
||||
interface FeatureItem {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
interface FeaturesGridProps {
|
||||
features: FeatureItem[]
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
Sparkles,
|
||||
Shield,
|
||||
Truck,
|
||||
Clock,
|
||||
}
|
||||
|
||||
export function FeaturesGrid({ features }: FeaturesGridProps) {
|
||||
return (
|
||||
<div className='grid gap-4 sm:grid-cols-2 sm:gap-6 xl:grid-cols-4 mt-8'>
|
||||
{features.map(feature => {
|
||||
const IconComponent = iconMap[feature.icon as keyof typeof iconMap]
|
||||
return (
|
||||
<article key={feature.id} className='group'>
|
||||
<Card className='relative h-full overflow-hidden transition-all hover:shadow-md'>
|
||||
<CardContent className='px-6'>
|
||||
<Badge variant='secondary' className='mb-4 inline-flex size-12 items-center justify-center'>
|
||||
<IconComponent className='!size-5' aria-hidden='true' />
|
||||
</Badge>
|
||||
<h3 className='mb-2 text-lg font-semibold'>{feature.title}</h3>
|
||||
<p className='text-muted-foreground mb-4 text-sm'>{feature.description}</p>
|
||||
|
||||
<Button
|
||||
variant='link'
|
||||
size='sm'
|
||||
className='text-muted-foreground hover:text-foreground h-auto cursor-pointer !p-0 text-sm'
|
||||
>
|
||||
Learn more
|
||||
<ArrowRight className='ms-1.5 size-4' />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{ "name": "All", "count": 46 },
|
||||
{ "name": "General", "count": 8 },
|
||||
{ "name": "Account", "count": 6 },
|
||||
{ "name": "Billing", "count": 8 },
|
||||
{ "name": "Technical", "count": 9 },
|
||||
{ "name": "Privacy", "count": 5 },
|
||||
{ "name": "Security", "count": 4 },
|
||||
{ "name": "Support", "count": 6 }
|
||||
]
|
||||
@@ -0,0 +1,278 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"question": "What is ShadcnStore Admin?",
|
||||
"answer": "ShadcnStore Admin is a comprehensive admin dashboard template built with React, TypeScript, and shadcn/ui components. It provides a complete solution for managing your e-commerce store or business operations.",
|
||||
"category": "General"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"question": "How do I get started?",
|
||||
"answer": "You can get started by signing up for an account, choosing a plan that fits your needs, and following our quick setup guide to configure your dashboard.",
|
||||
"category": "General"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"question": "Do you offer a free trial?",
|
||||
"answer": "Yes, we offer a 14-day free trial for all new users. No credit card is required to start the trial, and you can explore all features during this period.",
|
||||
"category": "General"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"question": "What browsers are supported?",
|
||||
"answer": "We support all modern browsers including Chrome, Firefox, Safari, and Edge. For the best experience, we recommend using the latest version of your preferred browser.",
|
||||
"category": "General"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"question": "How do I contact support?",
|
||||
"answer": "You can contact our support team through the support page, by email at support@shadcnstore.com, or through the live chat feature available 24/7.",
|
||||
"category": "General"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"question": "Is there a mobile app available?",
|
||||
"answer": "Currently, we offer a responsive web application that works great on mobile devices. A dedicated mobile app is planned for future release.",
|
||||
"category": "General"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"question": "Can I customize the dashboard?",
|
||||
"answer": "Yes, the dashboard is highly customizable. You can modify themes, layouts, add custom components, and configure various settings to match your brand.",
|
||||
"category": "General"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"question": "What integrations are available?",
|
||||
"answer": "We offer integrations with popular services like Stripe, PayPal, Shopify, WooCommerce, Google Analytics, and many more through our API.",
|
||||
"category": "General"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"question": "How do I reset my password?",
|
||||
"answer": "You can reset your password by clicking on the 'Forgot Password' link on the login page. Enter your email address, and we'll send you instructions to reset your password.",
|
||||
"category": "Account"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"question": "How do I change my email address?",
|
||||
"answer": "You can change your email address in your account settings under the 'User Settings' section. You'll need to verify the new email address before the change takes effect.",
|
||||
"category": "Account"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"question": "Can I have multiple team members?",
|
||||
"answer": "Yes, depending on your plan, you can invite team members and assign different roles and permissions to manage your store collaboratively.",
|
||||
"category": "Account"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"question": "How do I delete my account?",
|
||||
"answer": "To delete your account, go to your account settings and select 'Delete Account'. Please note that this action is irreversible and all data will be permanently removed.",
|
||||
"category": "Account"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"question": "Can I change my username?",
|
||||
"answer": "Yes, you can change your username in the account settings. Keep in mind that some features might reference your old username temporarily.",
|
||||
"category": "Account"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"question": "How do I enable two-factor authentication?",
|
||||
"answer": "You can enable two-factor authentication in your account security settings. We support both SMS and authenticator app methods for added security.",
|
||||
"category": "Account"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"question": "What payment methods do you accept?",
|
||||
"answer": "We accept all major credit cards (Visa, MasterCard, American Express), PayPal, and bank transfers for enterprise customers. All payments are processed securely.",
|
||||
"category": "Billing"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"question": "How can I upgrade my plan?",
|
||||
"answer": "You can upgrade your plan at any time from your account settings. Go to 'Plans & Billing' and select the plan that best fits your needs. Changes take effect immediately.",
|
||||
"category": "Billing"
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"question": "Can I downgrade my plan?",
|
||||
"answer": "Yes, you can downgrade your plan at any time. The change will take effect at the start of your next billing cycle to ensure you don't lose access to premium features.",
|
||||
"category": "Billing"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"question": "Do you offer refunds?",
|
||||
"answer": "We offer a 30-day money-back guarantee for all plans. If you're not satisfied, contact our support team for a full refund within 30 days of purchase.",
|
||||
"category": "Billing"
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"question": "How does billing work?",
|
||||
"answer": "Billing is processed monthly or annually depending on your chosen plan. You'll receive an invoice before each billing cycle, and payment is automatically charged to your selected method.",
|
||||
"category": "Billing"
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"question": "Can I change my billing cycle?",
|
||||
"answer": "Yes, you can switch between monthly and annual billing at any time. Annual billing offers significant savings compared to monthly billing.",
|
||||
"category": "Billing"
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"question": "What happens if payment fails?",
|
||||
"answer": "If a payment fails, we'll attempt to charge your card again after 3 days. You'll receive email notifications, and your account will remain active during this grace period.",
|
||||
"category": "Billing"
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"question": "How do I view my billing history?",
|
||||
"answer": "You can view your complete billing history in the 'Plans & Billing' section of your account settings. All invoices and receipts are available for download.",
|
||||
"category": "Billing"
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"question": "Can I export my data?",
|
||||
"answer": "Yes, you can export your data at any time from your account settings. We provide exports in multiple formats including CSV, JSON, and PDF for different data types.",
|
||||
"category": "Technical"
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"question": "What APIs do you provide?",
|
||||
"answer": "We provide comprehensive REST APIs for all major features including product management, order processing, customer data, and analytics. Full documentation is available.",
|
||||
"category": "Technical"
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"question": "How do I backup my data?",
|
||||
"answer": "We automatically backup all your data daily. You can also create manual backups anytime from your settings, and restore from any backup point within the last 30 days.",
|
||||
"category": "Technical"
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"question": "Is there a rate limit on API calls?",
|
||||
"answer": "Yes, API rate limits vary by plan. Basic plans have 1000 calls/hour, Professional plans have 10,000 calls/hour, and Enterprise plans have unlimited calls.",
|
||||
"category": "Technical"
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"question": "How do I set up webhooks?",
|
||||
"answer": "Webhooks can be configured in the 'Connections' section of your settings. You can set up webhooks for various events like new orders, payment confirmations, and inventory updates.",
|
||||
"category": "Technical"
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"question": "What about system maintenance?",
|
||||
"answer": "We perform maintenance during low-traffic hours (typically Sunday 2-4 AM UTC). You'll be notified at least 48 hours in advance of any scheduled maintenance.",
|
||||
"category": "Technical"
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"question": "How do I troubleshoot connection issues?",
|
||||
"answer": "First, check your internet connection and try refreshing the page. If issues persist, check our status page or contact support with specific error messages.",
|
||||
"category": "Technical"
|
||||
},
|
||||
{
|
||||
"id": 30,
|
||||
"question": "Can I use custom domains?",
|
||||
"answer": "Yes, Professional and Enterprise plans support custom domains. You can configure your custom domain in the 'Connections' section of your account settings.",
|
||||
"category": "Technical"
|
||||
},
|
||||
{
|
||||
"id": 31,
|
||||
"question": "What databases do you support?",
|
||||
"answer": "We support integration with MySQL, PostgreSQL, MongoDB, and other popular databases through our Database Sync feature available in higher-tier plans.",
|
||||
"category": "Technical"
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"question": "How do you handle my personal data?",
|
||||
"answer": "We follow strict data protection policies and comply with GDPR, CCPA, and other privacy regulations. Your personal data is never shared with third parties without your consent.",
|
||||
"category": "Privacy"
|
||||
},
|
||||
{
|
||||
"id": 33,
|
||||
"question": "Can I request my data?",
|
||||
"answer": "Yes, you can request a complete copy of your personal data at any time. We'll provide it in a machine-readable format within 30 days of your request.",
|
||||
"category": "Privacy"
|
||||
},
|
||||
{
|
||||
"id": 34,
|
||||
"question": "How long do you retain data?",
|
||||
"answer": "We retain your data as long as your account is active. After account deletion, personal data is removed within 30 days, though some anonymized analytics may be retained.",
|
||||
"category": "Privacy"
|
||||
},
|
||||
{
|
||||
"id": 35,
|
||||
"question": "Do you use cookies?",
|
||||
"answer": "Yes, we use essential cookies for functionality and optional cookies for analytics and personalization. You can manage your cookie preferences in your account settings.",
|
||||
"category": "Privacy"
|
||||
},
|
||||
{
|
||||
"id": 36,
|
||||
"question": "Is my data encrypted?",
|
||||
"answer": "Yes, all data is encrypted both in transit (using TLS 1.3) and at rest (using AES-256 encryption). We use industry-standard security practices to protect your information.",
|
||||
"category": "Privacy"
|
||||
},
|
||||
{
|
||||
"id": 37,
|
||||
"question": "How secure is my data?",
|
||||
"answer": "We implement bank-level security with end-to-end encryption, regular security audits, and compliance with SOC 2 Type II standards. Your data security is our top priority.",
|
||||
"category": "Security"
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"question": "Do you support SSO?",
|
||||
"answer": "Yes, Enterprise plans include Single Sign-On (SSO) support with popular providers like Google, Microsoft Azure AD, and Okta for seamless team access.",
|
||||
"category": "Security"
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"question": "What about password requirements?",
|
||||
"answer": "We require strong passwords with at least 8 characters, including uppercase, lowercase, numbers, and special characters. We also highly recommend enabling two-factor authentication.",
|
||||
"category": "Security"
|
||||
},
|
||||
{
|
||||
"id": 40,
|
||||
"question": "How do you handle security incidents?",
|
||||
"answer": "We have a comprehensive incident response plan. In case of any security issues, we immediately investigate, contain the issue, and notify affected users within 24 hours.",
|
||||
"category": "Security"
|
||||
},
|
||||
{
|
||||
"id": 41,
|
||||
"question": "What support channels are available?",
|
||||
"answer": "We offer email support, live chat, and phone support (for Enterprise customers). Our knowledge base and community forums are also available 24/7.",
|
||||
"category": "Support"
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"question": "What are your support hours?",
|
||||
"answer": "Email and chat support are available 24/7. Phone support for Enterprise customers is available Monday-Friday, 9 AM-6 PM in your local timezone.",
|
||||
"category": "Support"
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"question": "How quickly will I get a response?",
|
||||
"answer": "Response times vary by plan: Basic (24 hours), Professional (12 hours), Enterprise (2 hours). Critical issues are prioritized and responded to immediately.",
|
||||
"category": "Support"
|
||||
},
|
||||
{
|
||||
"id": 44,
|
||||
"question": "Do you offer training?",
|
||||
"answer": "Yes, we provide comprehensive onboarding for all plans, video tutorials, documentation, and personalized training sessions for Enterprise customers.",
|
||||
"category": "Support"
|
||||
},
|
||||
{
|
||||
"id": 45,
|
||||
"question": "Can you help with custom implementations?",
|
||||
"answer": "Enterprise customers get access to our professional services team for custom implementations, integrations, and consulting services.",
|
||||
"category": "Support"
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"question": "Is there a community forum?",
|
||||
"answer": "Yes, we have an active community forum where users share tips, ask questions, and get help from both our team and other community members.",
|
||||
"category": "Support"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Premium Quality",
|
||||
"description": "Handcrafted with premium materials and meticulous attention to detail.",
|
||||
"icon": "Sparkles"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Secure Shopping",
|
||||
"description": "100% secure payment processing with end-to-end encryption.",
|
||||
"icon": "Shield"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Fast Delivery",
|
||||
"description": "Free worldwide shipping and hassle-free returns within 30 days.",
|
||||
"icon": "Truck"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "24/7 Support",
|
||||
"description": "Round-the-clock customer support to assist you anytime.",
|
||||
"icon": "Clock"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
import { FAQList } from "./components/faq-list"
|
||||
import { FeaturesGrid } from "./components/features-grid"
|
||||
|
||||
// Import data
|
||||
import categoriesData from "./data/categories.json"
|
||||
import faqsData from "./data/faqs.json"
|
||||
import featuresData from "./data/features.json"
|
||||
|
||||
export default function FAQsPage() {
|
||||
return (
|
||||
<div className="px-4 lg:px-6">
|
||||
<FAQList faqs={faqsData} categories={categoriesData} />
|
||||
<FeaturesGrid features={featuresData} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar";
|
||||
import { ThemeCustomizer, ThemeCustomizerTrigger } from "@/components/theme-customizer";
|
||||
import { UpgradeToProButton } from "@/components/upgrade-to-pro-button";
|
||||
import { useSidebarConfig } from "@/hooks/use-sidebar-config";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [themeCustomizerOpen, setThemeCustomizerOpen] = React.useState(false);
|
||||
const { config } = useSidebarConfig();
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
style={{
|
||||
"--sidebar-width": "16rem",
|
||||
"--sidebar-width-icon": "3rem",
|
||||
"--header-height": "calc(var(--spacing) * 14)",
|
||||
} as React.CSSProperties}
|
||||
className={config.collapsible === "none" ? "sidebar-none-mode" : ""}
|
||||
>
|
||||
{config.side === "left" ? (
|
||||
<>
|
||||
<AppSidebar
|
||||
variant={config.variant}
|
||||
collapsible={config.collapsible}
|
||||
side={config.side}
|
||||
/>
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SiteFooter />
|
||||
</SidebarInset>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SiteFooter />
|
||||
</SidebarInset>
|
||||
<AppSidebar
|
||||
variant={config.variant}
|
||||
collapsible={config.collapsible}
|
||||
side={config.side}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Theme Customizer */}
|
||||
<ThemeCustomizerTrigger onClick={() => setThemeCustomizerOpen(true)} />
|
||||
<ThemeCustomizer
|
||||
open={themeCustomizerOpen}
|
||||
onOpenChange={setThemeCustomizerOpen}
|
||||
/>
|
||||
<UpgradeToProButton />
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
interface AccountSwitcherProps {
|
||||
isCollapsed: boolean;
|
||||
accounts: {
|
||||
label: string;
|
||||
email: string;
|
||||
icon: React.ReactNode;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function AccountSwitcher({ isCollapsed, accounts }: AccountSwitcherProps) {
|
||||
const [selectedAccount, setSelectedAccount] = React.useState<string>(
|
||||
accounts[0].email
|
||||
);
|
||||
|
||||
return (
|
||||
<Select defaultValue={selectedAccount} onValueChange={setSelectedAccount}>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full",
|
||||
isCollapsed &&
|
||||
"flex size-9 shrink-0 items-center justify-center p-0 [&>span]:w-auto [&>svg]:hidden"
|
||||
)}
|
||||
aria-label="Select account"
|
||||
>
|
||||
<SelectValue placeholder="Select an account">
|
||||
{accounts.find((account) => account.email === selectedAccount)?.icon}
|
||||
<span className={cn("ml-2", isCollapsed && "hidden")}>
|
||||
{accounts.find((account) => account.email === selectedAccount)?.label}
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" className="w-full">
|
||||
{accounts.map((account) => (
|
||||
<SelectItem key={account.email} value={account.email}>
|
||||
<div className="flex items-center gap-3 [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-foreground">
|
||||
{account.icon}
|
||||
{account.email}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client"
|
||||
|
||||
import { addDays } from "date-fns";
|
||||
import { addHours } from "date-fns";
|
||||
import { format } from "date-fns";
|
||||
import { nextSaturday } from "date-fns";
|
||||
import {
|
||||
Archive,
|
||||
ArchiveX,
|
||||
Clock,
|
||||
Forward,
|
||||
MoreVertical,
|
||||
Reply,
|
||||
ReplyAll,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { DropdownMenu, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { type Mail } from "../data";
|
||||
import { useState } from "react";
|
||||
|
||||
interface MailDisplayProps {
|
||||
mail: Mail | null;
|
||||
}
|
||||
|
||||
export function MailDisplay({ mail }: MailDisplayProps) {
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" disabled={!mail} title="Archive" className="cursor-pointer disabled:cursor-not-allowed">
|
||||
<Archive className="size-4" />
|
||||
<span className="sr-only">Archive</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" disabled={!mail} title="Move to junk" className="cursor-pointer disabled:cursor-not-allowed">
|
||||
<ArchiveX className="size-4" />
|
||||
<span className="sr-only">Move to junk</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" disabled={!mail} title="Move to trash" className="cursor-pointer disabled:cursor-not-allowed">
|
||||
<Trash2 className="size-4" />
|
||||
<span className="sr-only">Move to trash</span>
|
||||
</Button>
|
||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" disabled={!mail} title="Snooze" className="cursor-pointer disabled:cursor-not-allowed">
|
||||
<Clock className="size-4" />
|
||||
<span className="sr-only">Snooze</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex w-auto p-0">
|
||||
<div className="flex flex-col gap-2 border-r px-2 py-4">
|
||||
<div className="px-4 text-sm font-medium">Snooze until</div>
|
||||
<div className="grid min-w-[250px] gap-1">
|
||||
<Button variant="ghost" className="justify-start font-normal cursor-pointer">
|
||||
Later today{" "}
|
||||
<span className="text-muted-foreground ml-auto">
|
||||
{format(addHours(selectedDate, 4), "E, h:mm b")}
|
||||
</span>
|
||||
</Button>
|
||||
<Button variant="ghost" className="justify-start font-normal cursor-pointer">
|
||||
Tomorrow
|
||||
<span className="text-muted-foreground ml-auto">
|
||||
{format(addDays(selectedDate, 1), "E, h:mm b")}
|
||||
</span>
|
||||
</Button>
|
||||
<Button variant="ghost" className="justify-start font-normal cursor-pointer">
|
||||
This weekend
|
||||
<span className="text-muted-foreground ml-auto">
|
||||
{format(nextSaturday(selectedDate), "E, h:mm b")}
|
||||
</span>
|
||||
</Button>
|
||||
<Button variant="ghost" className="justify-start font-normal cursor-pointer">
|
||||
Next week
|
||||
<span className="text-muted-foreground ml-auto">
|
||||
{format(addDays(selectedDate, 7), "E, h:mm b")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={setSelectedDate}
|
||||
classNames={{
|
||||
today: "bg-none",
|
||||
day: "cursor-pointer",
|
||||
day_selected: "cursor-pointer",
|
||||
day_today: "cursor-pointer"
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" disabled={!mail} title="Reply" className="cursor-pointer disabled:cursor-not-allowed">
|
||||
<Reply className="size-4" />
|
||||
<span className="sr-only">Reply</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" disabled={!mail} title="Reply all" className="cursor-pointer disabled:cursor-not-allowed">
|
||||
<ReplyAll className="size-4" />
|
||||
<span className="sr-only">Reply all</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" disabled={!mail} title="Forward" className="cursor-pointer disabled:cursor-not-allowed">
|
||||
<Forward className="size-4" />
|
||||
<span className="sr-only">Forward</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Separator orientation="vertical" className="mx-2 h-6" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" disabled={!mail} className="cursor-pointer disabled:cursor-not-allowed">
|
||||
<MoreVertical className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem className="cursor-pointer">Mark as unread</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer">Star thread</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer">Add label</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer">Mute thread</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<Separator />
|
||||
{mail ? (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex items-start p-4">
|
||||
<div className="flex items-start gap-4 text-sm">
|
||||
<Avatar className="cursor-pointer">
|
||||
<AvatarImage alt={mail.name} />
|
||||
<AvatarFallback>
|
||||
{mail.name
|
||||
.split(" ")
|
||||
.map((chunk) => chunk[0])
|
||||
.join("")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid gap-1">
|
||||
<div className="font-semibold">{mail.name}</div>
|
||||
<div className="line-clamp-1 text-xs">{mail.subject}</div>
|
||||
<div className="line-clamp-1 text-xs">
|
||||
<span className="font-medium">Reply-To:</span> {mail.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{mail.date && (
|
||||
<div className="text-muted-foreground ml-auto text-xs">
|
||||
{format(new Date(mail.date), "PPpp")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex-1 p-4 text-sm whitespace-pre-wrap">{mail.text}</div>
|
||||
<Separator className="mt-auto" />
|
||||
<div className="p-4">
|
||||
<form>
|
||||
<div className="grid gap-4">
|
||||
<Textarea className="p-4 cursor-text" placeholder={`Reply ${mail.name}...`} />
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="mute" className="flex items-center gap-2 text-xs font-normal cursor-pointer">
|
||||
<Switch id="mute" aria-label="Mute thread" /> Mute this thread
|
||||
</Label>
|
||||
<Button onClick={(e) => e.preventDefault()} size="sm" className="ml-auto cursor-pointer">
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground p-8 text-center">No message selected</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client"
|
||||
|
||||
import type { ComponentProps } from "react"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import type { Mail } from "../data"
|
||||
import { useMail } from "../use-mail"
|
||||
|
||||
interface MailListProps {
|
||||
items: Mail[];
|
||||
}
|
||||
|
||||
export function MailList({ items }: MailListProps) {
|
||||
const [mail, setMail] = useMail();
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-[calc(100vh-12rem)]">
|
||||
<div className="flex flex-col gap-2 p-4 pt-0">{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"hover:bg-accent hover:text-accent-foreground flex flex-col items-start gap-2 rounded-lg border p-3 text-left text-sm transition-all cursor-pointer",
|
||||
mail.selected === item.id && "bg-muted"
|
||||
)}
|
||||
onClick={() =>
|
||||
setMail({
|
||||
...mail,
|
||||
selected: item.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-semibold">{item.name}</div>
|
||||
{!item.read && <span className="flex size-2 rounded-full bg-blue-600 cursor-pointer" />}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"ml-auto text-xs",
|
||||
mail.selected === item.id ? "text-foreground" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{formatDistanceToNow(new Date(item.date), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs font-medium">{item.subject}</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground line-clamp-2 text-xs">
|
||||
{item.text.substring(0, 300)}
|
||||
</div>
|
||||
{item.labels.length ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{item.labels.map((label) => (
|
||||
<Badge key={label} variant={getBadgeVariantFromLabel(label)} className="cursor-pointer">
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
function getBadgeVariantFromLabel(label: string): ComponentProps<typeof Badge>["variant"] {
|
||||
if (["work"].includes(label.toLowerCase())) {
|
||||
return "default";
|
||||
}
|
||||
|
||||
if (["personal"].includes(label.toLowerCase())) {
|
||||
return "outline";
|
||||
}
|
||||
|
||||
return "secondary";
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
AlertCircle,
|
||||
Archive,
|
||||
ArchiveX,
|
||||
File,
|
||||
Inbox,
|
||||
MessagesSquare,
|
||||
Search,
|
||||
Send,
|
||||
ShoppingCart,
|
||||
Trash2,
|
||||
Users2,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
import { AccountSwitcher } from "./account-switcher"
|
||||
import { MailDisplay } from "./mail-display"
|
||||
import { MailList } from "./mail-list"
|
||||
import { Nav } from "./nav"
|
||||
import { type Mail } from "../data"
|
||||
import { useMail } from "../use-mail"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface MailProps {
|
||||
accounts: {
|
||||
label: string;
|
||||
email: string;
|
||||
icon: React.ReactNode;
|
||||
}[];
|
||||
mails: Mail[];
|
||||
defaultLayout?: number[];
|
||||
defaultCollapsed?: boolean;
|
||||
navCollapsedSize: number;
|
||||
}
|
||||
|
||||
export function Mail({
|
||||
accounts,
|
||||
mails,
|
||||
defaultLayout = [20, 32, 48],
|
||||
defaultCollapsed = false,
|
||||
navCollapsedSize,
|
||||
}: MailProps) {
|
||||
const [isCollapsed, setIsCollapsed] = React.useState(defaultCollapsed);
|
||||
const [mail] = useMail();
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
onLayout={(sizes: number[]) => {
|
||||
document.cookie = `react-resizable-panels:layout:mail=${JSON.stringify(sizes)}`;
|
||||
}}
|
||||
className="h-full items-stretch rounded-lg border overflow-hidden"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={defaultLayout[0]}
|
||||
collapsedSize={navCollapsedSize}
|
||||
collapsible={true}
|
||||
minSize={15}
|
||||
maxSize={20}
|
||||
onCollapse={() => {
|
||||
setIsCollapsed(true);
|
||||
document.cookie = `react-resizable-panels:collapsed=${JSON.stringify(true)}`;
|
||||
}}
|
||||
onResize={() => {
|
||||
setIsCollapsed(false);
|
||||
document.cookie = `react-resizable-panels:collapsed=${JSON.stringify(false)}`;
|
||||
}}
|
||||
className={cn(isCollapsed && "w-full transition-all duration-300 ease-in-out")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-[52px] items-center justify-center",
|
||||
isCollapsed ? "h-[52px]" : "px-2"
|
||||
)}
|
||||
>
|
||||
<AccountSwitcher isCollapsed={isCollapsed} accounts={accounts} />
|
||||
</div>
|
||||
<Separator className="mx-0" />
|
||||
<div className="m-3">
|
||||
<Button className="w-full cursor-pointer">
|
||||
{isCollapsed ? "" : "Compose"}
|
||||
<Send className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Separator className="mx-0" />
|
||||
<Nav
|
||||
isCollapsed={isCollapsed}
|
||||
links={[
|
||||
{
|
||||
title: "Inbox",
|
||||
label: "128",
|
||||
icon: Inbox,
|
||||
variant: "default",
|
||||
},
|
||||
{
|
||||
title: "Drafts",
|
||||
label: "9",
|
||||
icon: File,
|
||||
variant: "ghost",
|
||||
},
|
||||
{
|
||||
title: "Sent",
|
||||
label: "",
|
||||
icon: Send,
|
||||
variant: "ghost",
|
||||
},
|
||||
{
|
||||
title: "Junk",
|
||||
label: "23",
|
||||
icon: ArchiveX,
|
||||
variant: "ghost",
|
||||
},
|
||||
{
|
||||
title: "Trash",
|
||||
label: "",
|
||||
icon: Trash2,
|
||||
variant: "ghost",
|
||||
},
|
||||
{
|
||||
title: "Archive",
|
||||
label: "",
|
||||
icon: Archive,
|
||||
variant: "ghost",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Separator className="mx-0" />
|
||||
<Nav
|
||||
isCollapsed={isCollapsed}
|
||||
links={[
|
||||
{
|
||||
title: "Social",
|
||||
label: "972",
|
||||
icon: Users2,
|
||||
variant: "ghost",
|
||||
},
|
||||
{
|
||||
title: "Updates",
|
||||
label: "342",
|
||||
icon: AlertCircle,
|
||||
variant: "ghost",
|
||||
},
|
||||
{
|
||||
title: "Forums",
|
||||
label: "128",
|
||||
icon: MessagesSquare,
|
||||
variant: "ghost",
|
||||
},
|
||||
{
|
||||
title: "Shopping",
|
||||
label: "8",
|
||||
icon: ShoppingCart,
|
||||
variant: "ghost",
|
||||
},
|
||||
{
|
||||
title: "Promotions",
|
||||
label: "21",
|
||||
icon: Archive,
|
||||
variant: "ghost",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel defaultSize={defaultLayout[1]} minSize={30}>
|
||||
<Tabs defaultValue="all" className="gap-1">
|
||||
<div className="flex items-center px-4 py-1.5">
|
||||
<h1 className="text-foreground text-xl font-bold">Inbox</h1>
|
||||
<TabsList className="ml-auto">
|
||||
<TabsTrigger value="all" className="cursor-pointer">All mail</TabsTrigger>
|
||||
<TabsTrigger value="unread" className="cursor-pointer">Unread</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="bg-background/95 supports-[backdrop-filter]:bg-background/60 p-4 backdrop-blur">
|
||||
<form>
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-2.5 left-2 size-4 cursor-pointer" />
|
||||
<Input placeholder="Search" className="pl-8 cursor-text" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<TabsContent value="all" className="m-0">
|
||||
<MailList items={mails} />
|
||||
</TabsContent>
|
||||
<TabsContent value="unread" className="m-0">
|
||||
<MailList items={mails.filter((item) => !item.read)} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel defaultSize={defaultLayout[2]} minSize={30}>
|
||||
<MailDisplay mail={mails.find((item) => item.id === mail.selected) || null} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client"
|
||||
|
||||
import { type LucideIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
|
||||
interface NavProps {
|
||||
isCollapsed: boolean;
|
||||
links: {
|
||||
title: string;
|
||||
label?: string;
|
||||
icon: LucideIcon;
|
||||
variant: "default" | "ghost";
|
||||
}[];
|
||||
}
|
||||
|
||||
export function Nav({ links, isCollapsed }: NavProps) {
|
||||
return (
|
||||
<div
|
||||
data-collapsed={isCollapsed}
|
||||
className="group flex flex-col gap-4 py-2 data-[collapsed=true]:py-2"
|
||||
>
|
||||
<nav className="grid gap-1 px-2 group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2">
|
||||
{links.map((link, index) =>
|
||||
isCollapsed ? (
|
||||
<Tooltip key={index} delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
buttonVariants({ variant: link.variant, size: "icon" }),
|
||||
"size-9 cursor-pointer",
|
||||
link.variant === "default" &&
|
||||
"dark:bg-muted dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-white"
|
||||
)}
|
||||
>
|
||||
<link.icon className="size-4" />
|
||||
<span className="sr-only">{link.title}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="flex items-center gap-4">
|
||||
{link.title}
|
||||
{link.label && (
|
||||
<Badge className="ml-auto flex size-5 shrink-0 items-center justify-center rounded-full cursor-pointer">
|
||||
{link.label}
|
||||
</Badge>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<button
|
||||
key={index}
|
||||
className={cn(
|
||||
buttonVariants({ variant: link.variant, size: "sm" }),
|
||||
link.variant === "default" &&
|
||||
"group dark:bg-muted dark:text-foreground dark:hover:bg-muted dark:hover:text-foreground",
|
||||
"justify-start cursor-pointer"
|
||||
)}
|
||||
>
|
||||
<link.icon className="mr-2 size-4" />
|
||||
{link.title}
|
||||
{link.label && (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto",
|
||||
link.variant === "default" &&
|
||||
"text-background dark:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{link.label}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
export const mails = [
|
||||
{
|
||||
id: "6c84fb90-12c4-11e1-840d-7b25c5ee775a",
|
||||
name: "William Smith",
|
||||
email: "williamsmith@example.com",
|
||||
subject: "Meeting Tomorrow",
|
||||
text: "Hi, let's have a meeting tomorrow to discuss the project. I've been reviewing the project details and have some ideas I'd like to share. It's crucial that we align on our next steps to ensure the project's success.\n\nPlease come prepared with any questions or insights you may have. Looking forward to our meeting!\n\nBest regards, William",
|
||||
date: "2023-10-22T09:00:00",
|
||||
read: true,
|
||||
labels: ["meeting", "work", "important"],
|
||||
},
|
||||
{
|
||||
id: "110e8400-e29b-11d4-a716-446655440000",
|
||||
name: "Alice Smith",
|
||||
email: "alicesmith@example.com",
|
||||
subject: "Re: Project Update",
|
||||
text: "Thank you for the project update. It looks great! I've gone through the report, and the progress is impressive. The team has done a fantastic job, and I appreciate the hard work everyone has put in.\n\nI have a few minor suggestions that I'll include in the attached document.\n\nLet's discuss these during our next meeting. Keep up the excellent work!\n\nBest regards, Alice",
|
||||
date: "2023-10-22T10:30:00",
|
||||
read: true,
|
||||
labels: ["work", "important"],
|
||||
},
|
||||
{
|
||||
id: "3e7c3f6d-bdf5-46ae-8d90-171300f27ae2",
|
||||
name: "Bob Johnson",
|
||||
email: "bobjohnson@example.com",
|
||||
subject: "Weekend Plans",
|
||||
text: "Any plans for the weekend? I was thinking of going hiking in the nearby mountains. It's been a while since we had some outdoor fun.\n\nIf you're interested, let me know, and we can plan the details. It'll be a great way to unwind and enjoy nature.\n\nLooking forward to your response!\n\nBest, Bob",
|
||||
date: "2023-04-10T11:45:00",
|
||||
read: true,
|
||||
labels: ["personal"],
|
||||
},
|
||||
{
|
||||
id: "61c35085-72d7-42b4-8d62-738f700d4b92",
|
||||
name: "Emily Davis",
|
||||
email: "emilydavis@example.com",
|
||||
subject: "Re: Question about Budget",
|
||||
text: "I have a question about the budget for the upcoming project. It seems like there's a discrepancy in the allocation of resources.\n\nI've reviewed the budget report and identified a few areas where we might be able to optimize our spending without compromising the project's quality.\n\nI've attached a detailed analysis for your reference. Let's discuss this further in our next meeting.\n\nThanks, Emily",
|
||||
date: "2023-03-25T13:15:00",
|
||||
read: false,
|
||||
labels: ["work", "budget"],
|
||||
},
|
||||
{
|
||||
id: "8f7b5db9-d935-4e42-8e05-1f1d0a3dfb97",
|
||||
name: "Michael Wilson",
|
||||
email: "michaelwilson@example.com",
|
||||
subject: "Important Announcement",
|
||||
text: "I have an important announcement to make during our team meeting. It pertains to a strategic shift in our approach to the upcoming product launch. We've received valuable feedback from our beta testers, and I believe it's time to make some adjustments to better meet our customers' needs.\n\nThis change is crucial to our success, and I look forward to discussing it with the team. Please be prepared to share your insights during the meeting.\n\nRegards, Michael",
|
||||
date: "2023-03-10T15:00:00",
|
||||
read: false,
|
||||
labels: ["meeting", "work", "important"],
|
||||
},
|
||||
{
|
||||
id: "1f0f2c02-e299-40de-9b1d-86ef9e42126b",
|
||||
name: "Sarah Brown",
|
||||
email: "sarahbrown@example.com",
|
||||
subject: "Re: Feedback on Proposal",
|
||||
text: "Thank you for your feedback on the proposal. It looks great! I'm pleased to hear that you found it promising. The team worked diligently to address all the key points you raised, and I believe we now have a strong foundation for the project.\n\nI've attached the revised proposal for your review.\n\nPlease let me know if you have any further comments or suggestions. Looking forward to your response.\n\nBest regards, Sarah",
|
||||
date: "2023-02-15T16:30:00",
|
||||
read: true,
|
||||
labels: ["work"],
|
||||
},
|
||||
{
|
||||
id: "17c0a96d-4415-42b1-8b4f-764efab57f66",
|
||||
name: "David Lee",
|
||||
email: "davidlee@example.com",
|
||||
subject: "New Project Idea",
|
||||
text: "I have an exciting new project idea to discuss with you. It involves expanding our services to target a niche market that has shown considerable growth in recent months.\n\nI've prepared a detailed proposal outlining the potential benefits and the strategy for execution.\n\nThis project has the potential to significantly impact our business positively. Let's set up a meeting to dive into the details and determine if it aligns with our current goals.\n\nBest regards, David",
|
||||
date: "2023-01-28T17:45:00",
|
||||
read: false,
|
||||
labels: ["meeting", "work", "important"],
|
||||
},
|
||||
{
|
||||
id: "2f0130cb-39fc-44c4-bb3c-0a4337edaaab",
|
||||
name: "Olivia Wilson",
|
||||
email: "oliviawilson@example.com",
|
||||
subject: "Vacation Plans",
|
||||
text: "Let's plan our vacation for next month. What do you think? I've been thinking of visiting a tropical paradise, and I've put together some destination options.\n\nI believe it's time for us to unwind and recharge. Please take a look at the options and let me know your preferences.\n\nWe can start making arrangements to ensure a smooth and enjoyable trip.\n\nExcited to hear your thoughts! Olivia",
|
||||
date: "2022-12-20T18:30:00",
|
||||
read: true,
|
||||
labels: ["personal"],
|
||||
},
|
||||
{
|
||||
id: "de305d54-75b4-431b-adb2-eb6b9e546014",
|
||||
name: "James Martin",
|
||||
email: "jamesmartin@example.com",
|
||||
subject: "Re: Conference Registration",
|
||||
text: "I've completed the registration for the conference next month. The event promises to be a great networking opportunity, and I'm looking forward to attending the various sessions and connecting with industry experts.\n\nI've also attached the conference schedule for your reference.\n\nIf there are any specific topics or sessions you'd like me to explore, please let me know. It's an exciting event, and I'll make the most of it.\n\nBest regards, James",
|
||||
date: "2022-11-30T19:15:00",
|
||||
read: true,
|
||||
labels: ["work", "conference"],
|
||||
},
|
||||
{
|
||||
id: "7dd90c63-00f6-40f3-bd87-5060a24e8ee7",
|
||||
name: "Sophia White",
|
||||
email: "sophiawhite@example.com",
|
||||
subject: "Team Dinner",
|
||||
text: "Let's have a team dinner next week to celebrate our success. We've achieved some significant milestones, and it's time to acknowledge our hard work and dedication.\n\nI've made reservations at a lovely restaurant, and I'm sure it'll be an enjoyable evening.\n\nPlease confirm your availability and any dietary preferences. Looking forward to a fun and memorable dinner with the team!\n\nBest, Sophia",
|
||||
date: "2022-11-05T20:30:00",
|
||||
read: false,
|
||||
labels: ["meeting", "work"],
|
||||
},
|
||||
{
|
||||
id: "99a88f78-3eb4-4d87-87b7-7b15a49a0a05",
|
||||
name: "Daniel Johnson",
|
||||
email: "danieljohnson@example.com",
|
||||
subject: "Feedback Request",
|
||||
text: "I'd like your feedback on the latest project deliverables. We've made significant progress, and I value your input to ensure we're on the right track.\n\nI've attached the deliverables for your review, and I'm particularly interested in any areas where you think we can further enhance the quality or efficiency.\n\nYour feedback is invaluable, and I appreciate your time and expertise. Let's work together to make this project a success.\n\nRegards, Daniel",
|
||||
date: "2022-10-22T09:30:00",
|
||||
read: false,
|
||||
labels: ["work"],
|
||||
},
|
||||
{
|
||||
id: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||
name: "Ava Taylor",
|
||||
email: "avataylor@example.com",
|
||||
subject: "Re: Meeting Agenda",
|
||||
text: "Here's the agenda for our meeting next week. I've included all the topics we need to cover, as well as time allocations for each.\n\nIf you have any additional items to discuss or any specific points to address, please let me know, and we can integrate them into the agenda.\n\nIt's essential that our meeting is productive and addresses all relevant matters.\n\nLooking forward to our meeting! Ava",
|
||||
date: "2022-10-10T10:45:00",
|
||||
read: true,
|
||||
labels: ["meeting", "work"],
|
||||
},
|
||||
{
|
||||
id: "c1a0ecb4-2540-49c5-86f8-21e5ce79e4e6",
|
||||
name: "William Anderson",
|
||||
email: "williamanderson@example.com",
|
||||
subject: "Product Launch Update",
|
||||
text: "The product launch is on track. I'll provide an update during our call. We've made substantial progress in the development and marketing of our new product.\n\nI'm excited to share the latest updates with you during our upcoming call. It's crucial that we coordinate our efforts to ensure a successful launch. Please come prepared with any questions or insights you may have.\n\nLet's make this product launch a resounding success!\n\nBest regards, William",
|
||||
date: "2022-09-20T12:00:00",
|
||||
read: false,
|
||||
labels: ["meeting", "work", "important"],
|
||||
},
|
||||
{
|
||||
id: "ba54eefd-4097-4949-99f2-2a9ae4d1a836",
|
||||
name: "Mia Harris",
|
||||
email: "miaharris@example.com",
|
||||
subject: "Re: Travel Itinerary",
|
||||
text: "I've received the travel itinerary. It looks great! Thank you for your prompt assistance in arranging the details. I've reviewed the schedule and the accommodations, and everything seems to be in order. I'm looking forward to the trip, and I'm confident it'll be a smooth and enjoyable experience.\n\nIf there are any specific activities or attractions you recommend at our destination, please feel free to share your suggestions.\n\nExcited for the trip! Mia",
|
||||
date: "2022-09-10T13:15:00",
|
||||
read: true,
|
||||
labels: ["personal", "travel"],
|
||||
},
|
||||
{
|
||||
id: "df09b6ed-28bd-4e0c-85a9-9320ec5179aa",
|
||||
name: "Ethan Clark",
|
||||
email: "ethanclark@example.com",
|
||||
subject: "Team Building Event",
|
||||
text: "Let's plan a team-building event for our department. Team cohesion and morale are vital to our success, and I believe a well-organized team-building event can be incredibly beneficial. I've done some research and have a few ideas for fun and engaging activities.\n\nPlease let me know your thoughts and availability. We want this event to be both enjoyable and productive.\n\nTogether, we'll strengthen our team and boost our performance.\n\nRegards, Ethan",
|
||||
date: "2022-08-25T15:30:00",
|
||||
read: false,
|
||||
labels: ["meeting", "work"],
|
||||
},
|
||||
{
|
||||
id: "d67c1842-7f8b-4b4b-9be1-1b3b1ab4611d",
|
||||
name: "Chloe Hall",
|
||||
email: "chloehall@example.com",
|
||||
subject: "Re: Budget Approval",
|
||||
text: "The budget has been approved. We can proceed with the project. I'm delighted to inform you that our budget proposal has received the green light from the finance department. This is a significant milestone, and it means we can move forward with the project as planned.\n\nI've attached the finalized budget for your reference. Let's ensure that we stay on track and deliver the project on time and within budget.\n\nIt's an exciting time for us! Chloe",
|
||||
date: "2022-08-10T16:45:00",
|
||||
read: true,
|
||||
labels: ["work", "budget"],
|
||||
},
|
||||
{
|
||||
id: "6c9a7f94-8329-4d70-95d3-51f68c186ae1",
|
||||
name: "Samuel Turner",
|
||||
email: "samuelturner@example.com",
|
||||
subject: "Weekend Hike",
|
||||
text: "Who's up for a weekend hike in the mountains? I've been craving some outdoor adventure, and a hike in the mountains sounds like the perfect escape. If you're up for the challenge, we can explore some scenic trails and enjoy the beauty of nature.\n\nI've done some research and have a few routes in mind.\n\nLet me know if you're interested, and we can plan the details.\n\nIt's sure to be a memorable experience! Samuel",
|
||||
date: "2022-07-28T17:30:00",
|
||||
read: false,
|
||||
labels: ["personal"],
|
||||
},
|
||||
]
|
||||
|
||||
export type Mail = (typeof mails)[number]
|
||||
|
||||
export const accounts = [
|
||||
{
|
||||
label: "Alicia Koch",
|
||||
email: "alicia@example.com",
|
||||
icon: (
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Gmail</title>
|
||||
<path
|
||||
d="M24 5.457v13.909c0 .904-.732 1.636-1.636 1.636h-3.819V11.73L12 16.64l-6.545-4.91v9.273H1.636A1.636 1.636 0 0 1 0 19.366V5.457c0-2.023 2.309-3.178 3.927-1.964L5.455 4.64 12 9.548l6.545-4.91 1.528-1.145C21.69 2.28 24 3.434 24 5.457z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Alicia Koch",
|
||||
email: "alicia2@example.com",
|
||||
icon: (
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Vercel</title>
|
||||
<path d="M24 22.525H0l12-21.05 12 21.05z" fill="currentColor" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Alicia Koch",
|
||||
email: "alicia3@example.com",
|
||||
icon: (
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>iCloud</title>
|
||||
<path
|
||||
d="M13.762 4.29a6.51 6.51 0 0 0-5.669 3.332 3.571 3.571 0 0 0-1.558-.36 3.571 3.571 0 0 0-3.516 3A4.918 4.918 0 0 0 0 14.796a4.918 4.918 0 0 0 4.92 4.914 4.93 4.93 0 0 0 .617-.045h14.42c2.305-.272 4.041-2.258 4.043-4.589v-.009a4.594 4.594 0 0 0-3.727-4.508 6.51 6.51 0 0 0-6.511-6.27z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export type Account = (typeof accounts)[number]
|
||||
|
||||
export const contacts = [
|
||||
{
|
||||
name: "Emma Johnson",
|
||||
email: "emma.johnson@example.com",
|
||||
},
|
||||
{
|
||||
name: "Liam Wilson",
|
||||
email: "liam.wilson@example.com",
|
||||
},
|
||||
{
|
||||
name: "Olivia Davis",
|
||||
email: "olivia.davis@example.com",
|
||||
},
|
||||
{
|
||||
name: "Noah Martinez",
|
||||
email: "noah.martinez@example.com",
|
||||
},
|
||||
{
|
||||
name: "Ava Taylor",
|
||||
email: "ava.taylor@example.com",
|
||||
},
|
||||
{
|
||||
name: "Lucas Brown",
|
||||
email: "lucas.brown@example.com",
|
||||
},
|
||||
{
|
||||
name: "Sophia Smith",
|
||||
email: "sophia.smith@example.com",
|
||||
},
|
||||
{
|
||||
name: "Ethan Wilson",
|
||||
email: "ethan.wilson@example.com",
|
||||
},
|
||||
{
|
||||
name: "Isabella Jackson",
|
||||
email: "isabella.jackson@example.com",
|
||||
},
|
||||
{
|
||||
name: "Mia Clark",
|
||||
email: "mia.clark@example.com",
|
||||
},
|
||||
{
|
||||
name: "Mason Lee",
|
||||
email: "mason.lee@example.com",
|
||||
},
|
||||
{
|
||||
name: "Layla Harris",
|
||||
email: "layla.harris@example.com",
|
||||
},
|
||||
{
|
||||
name: "William Anderson",
|
||||
email: "william.anderson@example.com",
|
||||
},
|
||||
{
|
||||
name: "Ella White",
|
||||
email: "ella.white@example.com",
|
||||
},
|
||||
{
|
||||
name: "James Thomas",
|
||||
email: "james.thomas@example.com",
|
||||
},
|
||||
{
|
||||
name: "Harper Lewis",
|
||||
email: "harper.lewis@example.com",
|
||||
},
|
||||
{
|
||||
name: "Benjamin Moore",
|
||||
email: "benjamin.moore@example.com",
|
||||
},
|
||||
{
|
||||
name: "Aria Hall",
|
||||
email: "aria.hall@example.com",
|
||||
},
|
||||
{
|
||||
name: "Henry Turner",
|
||||
email: "henry.turner@example.com",
|
||||
},
|
||||
{
|
||||
name: "Scarlett Adams",
|
||||
email: "scarlett.adams@example.com",
|
||||
},
|
||||
]
|
||||
|
||||
export type Contact = (typeof contacts)[number]
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Mail } from "./components/mail";
|
||||
import { accounts, mails } from "./data";
|
||||
|
||||
export default function MailPage() {
|
||||
return <Mail accounts={accounts} mails={mails} navCollapsedSize={4} />;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Mail } from "./components/mail"
|
||||
import { accounts, mails } from "./data"
|
||||
|
||||
export default function MailPage() {
|
||||
return (
|
||||
<div className="@container/main flex flex-1 flex-col">
|
||||
<div className="h-[calc(100vh-4rem)] px-4 md:px-6">
|
||||
<Mail
|
||||
accounts={accounts}
|
||||
mails={mails}
|
||||
defaultLayout={[20, 32, 48]}
|
||||
defaultCollapsed={false}
|
||||
navCollapsedSize={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { create } from "zustand";
|
||||
import type { Mail } from "./data";
|
||||
import { mails } from "./data";
|
||||
|
||||
interface Config {
|
||||
selected: Mail["id"] | null;
|
||||
}
|
||||
|
||||
const useMailStore = create<
|
||||
Config & { setState: (newState: Partial<Config>) => void }
|
||||
>((set) => ({
|
||||
selected: mails[0].id,
|
||||
setState: (newState) => set((state) => ({ ...state, ...newState })),
|
||||
}));
|
||||
|
||||
export function useMail(): [Config, (newState: Partial<Config>) => void] {
|
||||
const selected = useMailStore((state) => state.selected);
|
||||
const setState = useMailStore((state) => state.setState);
|
||||
return [{ selected }, setState];
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
|
||||
interface FAQ {
|
||||
id: number
|
||||
question: string
|
||||
answer: string
|
||||
}
|
||||
|
||||
interface FAQSectionProps {
|
||||
faqs: FAQ[]
|
||||
}
|
||||
|
||||
export function FAQSection({ faqs }: FAQSectionProps) {
|
||||
return (
|
||||
<Card className="mt-6 sm:mt-8 lg:mt-12">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Frequently Asked Questions</CardTitle>
|
||||
<CardDescription>
|
||||
Get answers to the most common questions about our pricing and plans
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="mt-6 sm:mt-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-6">
|
||||
{/* Left Column */}
|
||||
<div className="space-y-4">
|
||||
<Accordion type='multiple'>
|
||||
{faqs.slice(0, 3).map(item => (
|
||||
<AccordionItem key={item.id} value={`item-${item.id}`} className='rounded-md !border my-3'>
|
||||
<AccordionTrigger className='cursor-pointer px-4'>{item.question}</AccordionTrigger>
|
||||
<AccordionContent className='text-muted-foreground px-4'>{item.answer}</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className="space-y-4">
|
||||
<Accordion type='multiple'>
|
||||
{faqs.slice(3, 6).map(item => (
|
||||
<AccordionItem key={item.id} value={`item-${item.id}`} className='rounded-md !border my-3'>
|
||||
<AccordionTrigger className='cursor-pointer px-4'>{item.question}</AccordionTrigger>
|
||||
<AccordionContent className='text-muted-foreground px-4'>{item.answer}</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Rocket, Shield, Zap, Users, Headphones, Clock } from "lucide-react"
|
||||
|
||||
// Icon mapping
|
||||
const iconMap = {
|
||||
Rocket,
|
||||
Shield,
|
||||
Zap,
|
||||
Users,
|
||||
Headphones,
|
||||
Clock,
|
||||
}
|
||||
|
||||
interface Feature {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
interface FeaturesGridProps {
|
||||
features: Feature[]
|
||||
}
|
||||
|
||||
export function FeaturesGrid({ features }: FeaturesGridProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">All Plans Include</CardTitle>
|
||||
<CardDescription>
|
||||
Every plan comes with these essential features to help your team succeed
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='mx-auto mt-6 sm:mt-8 lg:mt-12'>
|
||||
<dl className='grid grid-cols-1 gap-x-8 gap-y-10 md:grid-cols-2 lg:grid-cols-3 lg:gap-y-16'>
|
||||
{features.map(feature => {
|
||||
const IconComponent = iconMap[feature.icon as keyof typeof iconMap]
|
||||
return (
|
||||
<div key={feature.name} className='relative pl-16'>
|
||||
<div className='text-base leading-7 font-semibold'>
|
||||
<div className='bg-accent absolute start-0 top-0 flex h-10 w-10 items-center justify-center rounded-lg'>
|
||||
<IconComponent className='text-foreground size-6' aria-hidden='true' />
|
||||
</div>
|
||||
<span className='text-lg'>{feature.name}</span>
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-2 text-base leading-relaxed'>{feature.description}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</dl>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"question": "Can I change my plan anytime?",
|
||||
"answer": "Yes, you can upgrade or downgrade your plan at any time. Changes will be reflected in your next billing cycle, and you'll be charged or credited accordingly."
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"question": "Is there a free trial available?",
|
||||
"answer": "Yes, all plans come with a 14-day free trial. No credit card is required to start your trial, and you can explore all features during this period."
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"question": "What payment methods do you accept?",
|
||||
"answer": "We accept all major credit cards (Visa, MasterCard, American Express), PayPal, and bank transfers for enterprise customers. All payments are processed securely."
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"question": "Do you offer discounts for annual plans?",
|
||||
"answer": "Yes, save 20% when you choose annual billing on any plan. You can switch to annual billing from your account settings at any time."
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"question": "What happens if I exceed my plan limits?",
|
||||
"answer": "If you exceed your plan limits, we'll notify you in advance. You can either upgrade your plan or purchase additional resources as needed."
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"question": "Can I cancel my subscription anytime?",
|
||||
"answer": "Yes, you can cancel your subscription at any time from your account settings. You'll continue to have access to all features until the end of your current billing period."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Fast Performance",
|
||||
"description": "Lightning-fast response times and optimized performance for all your business needs.",
|
||||
"icon": "Rocket"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Enterprise Security",
|
||||
"description": "Bank-level security with end-to-end encryption and advanced threat protection.",
|
||||
"icon": "Shield"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Instant Setup",
|
||||
"description": "Get up and running in minutes with our streamlined onboarding process.",
|
||||
"icon": "Zap"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Team Collaboration",
|
||||
"description": "Seamless collaboration tools to keep your team connected and productive.",
|
||||
"icon": "Users"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "24/7 Support",
|
||||
"description": "Round-the-clock expert support whenever you need help or have questions.",
|
||||
"icon": "Headphones"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "Real-time Analytics",
|
||||
"description": "Monitor your business performance with real-time insights and detailed analytics.",
|
||||
"icon": "Clock"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
import { PricingPlans } from "@/components/pricing-plans"
|
||||
import { FeaturesGrid } from "./components/features-grid"
|
||||
import { FAQSection } from "./components/faq-section"
|
||||
|
||||
// Import data
|
||||
import featuresData from "./data/features.json"
|
||||
import faqsData from "./data/faqs.json"
|
||||
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<div className="px-4 lg:px-6">
|
||||
{/* Pricing Cards */}
|
||||
<section className='pb-12' id='pricing'>
|
||||
<PricingPlans mode="pricing" />
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<FeaturesGrid features={featuresData} />
|
||||
|
||||
{/* FAQ Section */}
|
||||
<FAQSection faqs={faqsData} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
const accountFormSchema = z.object({
|
||||
firstName: z.string().min(1, "First name is required"),
|
||||
lastName: z.string().min(1, "Last name is required"),
|
||||
email: z.string().email("Invalid email address"),
|
||||
username: z.string().min(3, "Username must be at least 3 characters"),
|
||||
currentPassword: z.string().optional(),
|
||||
newPassword: z.string().optional(),
|
||||
confirmPassword: z.string().optional(),
|
||||
})
|
||||
|
||||
type AccountFormValues = z.infer<typeof accountFormSchema>
|
||||
|
||||
export default function AccountSettings() {
|
||||
const form = useForm<AccountFormValues>({
|
||||
resolver: zodResolver(accountFormSchema),
|
||||
defaultValues: {
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
email: "",
|
||||
username: "",
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
})
|
||||
|
||||
function onSubmit(data: AccountFormValues) {
|
||||
console.log("Form submitted:", data)
|
||||
// Here you would typically save the data
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-4 lg:px-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Account Settings</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your account settings and preferences.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Personal Information</CardTitle>
|
||||
<CardDescription>
|
||||
Update your personal information that will be displayed on your profile.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="firstName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>First Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter your first name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Last Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter your last name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" placeholder="Enter your email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter your username" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Change Password</CardTitle>
|
||||
<CardDescription>
|
||||
Update your password to keep your account secure.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="currentPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Current Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="Enter current password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="newPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>New Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="Enter new password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm New Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="Confirm new password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Danger Zone</CardTitle>
|
||||
<CardDescription>
|
||||
Irreversible and destructive actions.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Separator />
|
||||
<div className="flex flex-wrap gap-2 items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-semibold">Delete Account</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Permanently delete your account and all associated data.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="destructive" type="button" className="cursor-pointer">
|
||||
Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button type="submit" className="cursor-pointer">Save Changes</Button>
|
||||
<Button variant="outline" type="reset" className="cursor-pointer">Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
|
||||
const appearanceFormSchema = z.object({
|
||||
theme: z.enum(["light", "dark"]),
|
||||
fontFamily: z.string().optional(),
|
||||
fontSize: z.string().optional(),
|
||||
sidebarWidth: z.string().optional(),
|
||||
contentWidth: z.string().optional(),
|
||||
})
|
||||
|
||||
type AppearanceFormValues = z.infer<typeof appearanceFormSchema>
|
||||
|
||||
export default function AppearanceSettings() {
|
||||
const form = useForm<AppearanceFormValues>({
|
||||
resolver: zodResolver(appearanceFormSchema),
|
||||
defaultValues: {
|
||||
theme: "dark",
|
||||
fontFamily: "",
|
||||
fontSize: "",
|
||||
sidebarWidth: "",
|
||||
contentWidth: "",
|
||||
},
|
||||
})
|
||||
|
||||
function onSubmit(data: AppearanceFormValues) {
|
||||
console.log("Form submitted:", data)
|
||||
// Here you would typically save the data
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-4 lg:px-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Appearance</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Customize the appearance of the application.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Theme Section */}
|
||||
<h3 className="text-lg font-medium mb-2">Theme</h3>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="theme"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3">
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel className="[&:has([data-state=checked])>div]:border-primary cursor-pointer">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="light" className="sr-only" />
|
||||
</FormControl>
|
||||
<div className="rounded-md border-2 border-muted p-4 hover:border-accent transition-colors">
|
||||
<div className="space-y-2">
|
||||
<div className="w-20 h-20 bg-white border rounded-md p-3">
|
||||
<div className="space-y-2">
|
||||
<div className="h-2 bg-gray-200 rounded w-3/4"></div>
|
||||
<div className="h-2 bg-gray-200 rounded w-1/2"></div>
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-2 w-2 bg-gray-300 rounded-full"></div>
|
||||
<div className="h-2 bg-gray-200 rounded flex-1"></div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-2 w-2 bg-gray-300 rounded-full"></div>
|
||||
<div className="h-2 bg-gray-200 rounded flex-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-medium">Light</span>
|
||||
</div>
|
||||
</div>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<FormLabel className="[&:has([data-state=checked])>div]:border-primary cursor-pointer">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="dark" className="sr-only" />
|
||||
</FormControl>
|
||||
<div className="rounded-md border-2 border-muted p-4 hover:border-accent transition-colors">
|
||||
<div className="space-y-2">
|
||||
<div className="w-20 h-20 bg-gray-900 border border-gray-700 rounded-md p-3">
|
||||
<div className="space-y-2">
|
||||
<div className="h-2 bg-gray-600 rounded w-3/4"></div>
|
||||
<div className="h-2 bg-gray-600 rounded w-1/2"></div>
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-2 w-2 bg-gray-500 rounded-full"></div>
|
||||
<div className="h-2 bg-gray-600 rounded flex-1"></div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-2 w-2 bg-gray-500 rounded-full"></div>
|
||||
<div className="h-2 bg-gray-600 rounded flex-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-medium">Dark</span>
|
||||
</div>
|
||||
</div>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fontFamily"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Font Family</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="cursor-pointer">
|
||||
<SelectValue placeholder="Select a font" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="inter">Inter</SelectItem>
|
||||
<SelectItem value="roboto">Roboto</SelectItem>
|
||||
<SelectItem value="system">System Default</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fontSize"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Font Size</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="cursor-pointer">
|
||||
<SelectValue placeholder="Select font size" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="small">Small</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="large">Large</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Layout Section */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sidebarWidth"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Sidebar Width</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="cursor-pointer">
|
||||
<SelectValue placeholder="Select sidebar width" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="compact">Compact</SelectItem>
|
||||
<SelectItem value="comfortable">Comfortable</SelectItem>
|
||||
<SelectItem value="spacious">Spacious</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="contentWidth"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Content Width</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="cursor-pointer">
|
||||
<SelectValue placeholder="Select content width" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="fixed">Fixed</SelectItem>
|
||||
<SelectItem value="fluid">Fluid</SelectItem>
|
||||
<SelectItem value="container">Container</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex space-x-2 mt-12">
|
||||
<Button type="submit" className="cursor-pointer">
|
||||
Save Preferences
|
||||
</Button>
|
||||
<Button variant="outline" type="button" className="cursor-pointer">Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
interface BillingHistoryItem {
|
||||
id: number
|
||||
month: string
|
||||
plan: string
|
||||
amount: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface BillingHistoryCardProps {
|
||||
history: BillingHistoryItem[]
|
||||
}
|
||||
|
||||
export function BillingHistoryCard({ history }: BillingHistoryCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Billing History</CardTitle>
|
||||
<CardDescription>
|
||||
View your past invoices and payments.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{history.map((item, index) => (
|
||||
<div key={item.id}>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<div className="font-medium">{item.month}</div>
|
||||
<div className="text-sm text-muted-foreground">{item.plan}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-medium">{item.amount}</div>
|
||||
<Badge variant="secondary">{item.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{index < history.length - 1 && <Separator />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Crown, AlertTriangle } from "lucide-react"
|
||||
|
||||
interface CurrentPlan {
|
||||
planName: string
|
||||
price: string
|
||||
nextBilling: string
|
||||
status: string
|
||||
daysUsed: number
|
||||
totalDays: number
|
||||
progressPercentage: number
|
||||
remainingDays: number
|
||||
needsAttention: boolean
|
||||
attentionMessage: string
|
||||
}
|
||||
|
||||
interface CurrentPlanCardProps {
|
||||
plan: CurrentPlan
|
||||
}
|
||||
|
||||
export function CurrentPlanCard({ plan }: CurrentPlanCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Current Plan</CardTitle>
|
||||
<CardDescription>
|
||||
You are currently on the {plan.planName}.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Crown className="h-5 w-5 text-yellow-500" />
|
||||
<span className="font-semibold">{plan.planName}</span>
|
||||
<Badge variant="secondary">{plan.status}</Badge>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold">{plan.price}</div>
|
||||
<div className="text-sm text-muted-foreground">Next billing: {plan.nextBilling}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{plan.needsAttention && (
|
||||
<Card className="border-neutral-200 bg-neutral-50 dark:border-neutral-600 dark:bg-neutral-800">
|
||||
<CardContent>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-neutral-600 mt-0.5 dark:text-neutral-400" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-neutral-800 dark:text-neutral-400">We need your attention!</p>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-400">{plan.attentionMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Section */}
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground font-medium">Days</span>
|
||||
<span className="text-sm text-muted-foreground font-medium">{plan.daysUsed} of {plan.totalDays} Days</span>
|
||||
</div>
|
||||
<Progress value={plan.progressPercentage} className="h-2" />
|
||||
<p className="text-xs text-muted-foreground">{plan.remainingDays} days remaining until your plan requires update</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"month": "December 2024",
|
||||
"plan": "Professional Plan",
|
||||
"amount": "$79.00",
|
||||
"status": "Paid"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"month": "November 2024",
|
||||
"plan": "Professional Plan",
|
||||
"amount": "$79.00",
|
||||
"status": "Paid"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"month": "October 2024",
|
||||
"plan": "Professional Plan",
|
||||
"amount": "$79.00",
|
||||
"status": "Paid"
|
||||
}
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user