-
-
Save shricodev/6b734610378fbbed6e524f137618d15f to your computer and use it in GitHub Desktop.
Admin Dashboard - Kombai 1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| From bca15e18aa4cd161bcd5d0bd29a2d3157414e002 Mon Sep 17 00:00:00 2001 | |
| From: Shrijal Acharya <shrijal.acharya@gmail.com> | |
| Date: Sat, 28 Mar 2026 16:39:10 +0545 | |
| Subject: [PATCH 1/2] test kombai 1 | |
| --- | |
| bun.lock | 7 + | |
| env.example.txt | 101 -------- | |
| package.json | 1 + | |
| src/app/dashboard/overview/layout.tsx | 222 +++++++++-------- | |
| src/components/icons.tsx | 6 + | |
| src/components/ui/carousel.tsx | 230 ++++++++++++++++++ | |
| .../overview/components/area-graph.tsx | 207 +++++++++------- | |
| .../overview/components/bar-graph.tsx | 184 ++++++-------- | |
| .../overview/components/customer-reviews.tsx | 174 +++++++++++++ | |
| .../overview/components/pie-graph.tsx | 149 ++++++------ | |
| .../overview/components/recent-sales.tsx | 174 +++++++++---- | |
| src/styles/globals.css | 17 ++ | |
| 12 files changed, 940 insertions(+), 532 deletions(-) | |
| delete mode 100644 env.example.txt | |
| create mode 100644 src/components/ui/carousel.tsx | |
| create mode 100644 src/features/overview/components/customer-reviews.tsx | |
| diff --git a/bun.lock b/bun.lock | |
| index 9a96765..61acd0d 100644 | |
| --- a/bun.lock | |
| +++ b/bun.lock | |
| @@ -50,6 +50,7 @@ | |
| "clsx": "^2.1.1", | |
| "cmdk": "^1.1.1", | |
| "date-fns": "^4.1.0", | |
| + "embla-carousel-react": "^8.6.0", | |
| "input-otp": "^1.4.2", | |
| "kbar": "^0.1.0-beta.48", | |
| "match-sorter": "^8.2.0", | |
| @@ -881,6 +882,12 @@ | |
| "electron-to-chromium": ["electron-to-chromium@1.5.244", "", {}, "sha512-OszpBN7xZX4vWMPJwB9illkN/znA8M36GQqQxi6MNy9axWxhOfJyZZJtSLQCpEFLHP2xK33BiWx9aIuIEXVCcw=="], | |
| + "embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="], | |
| + | |
| + "embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="], | |
| + | |
| + "embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="], | |
| + | |
| "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], | |
| "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], | |
| diff --git a/env.example.txt b/env.example.txt | |
| deleted file mode 100644 | |
| index 2263d06..0000000 | |
| --- a/env.example.txt | |
| +++ /dev/null | |
| @@ -1,101 +0,0 @@ | |
| -# ================================================================= | |
| -# Authentication Configuration (Clerk) | |
| -# ================================================================= | |
| -# IMPORTANT: This template supports Clerk's keyless mode! | |
| -# You can start using the app immediately without any configuration. | |
| -# When you're ready to claim your application, simply click the Clerk | |
| -# popup at the bottom of the screen to get your API keys. | |
| - | |
| -# Required: Clerk API Keys (Leave empty for keyless mode) | |
| - | |
| -NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= | |
| -CLERK_SECRET_KEY= | |
| - | |
| - | |
| -# Authentication Redirect URLs | |
| -# These control where users are directed after authentication actions | |
| - | |
| -NEXT_PUBLIC_CLERK_SIGN_IN_URL="/auth/sign-in" | |
| -NEXT_PUBLIC_CLERK_SIGN_UP_URL="/auth/sign-up" | |
| -NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL="/dashboard/overview" | |
| -NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL="/dashboard/overview" | |
| - | |
| - | |
| -# ================================================================= | |
| -# Clerk Organizations Configuration | |
| -# ================================================================= | |
| -# To enable Organizations in your Clerk Dashboard: | |
| -# 1. Go to https://dashboard.clerk.com | |
| -# 2. Navigate to Organizations Settings | |
| -# 3. Enable "Organizations" | |
| -# 4. Configure default roles (admin, member, viewer) if needed | |
| -# 5. Set membership limits (default: 5 members on Free plan) | |
| -# | |
| -# Development Plan Features: | |
| -# - Up to 50 Monthly Active Organizations (MAOs) in development | |
| -# - Up to 5 members per organization on Free plan | |
| -# - Unlimited members on Pro plan | |
| -# | |
| -# For more information, see: https://clerk.com/docs/organizations/overview | |
| - | |
| - | |
| -# ================================================================= | |
| -# Clerk Webhooks (Optional - for production billing) | |
| -# ================================================================= | |
| -# If you're using Clerk Billing or webhooks, add your webhook secret here. | |
| -# You can find this in Clerk Dashboard > Webhooks | |
| - | |
| -WEBHOOK_SECRET= # Example: whsec_... | |
| - | |
| - | |
| -# ================================================================= | |
| -# Build Configuration | |
| -# ================================================================= | |
| -# Set to 'true' when deploying with Docker or self-hosting on a VPS. | |
| -# This enables Next.js standalone output mode for smaller deployments. | |
| - | |
| -BUILD_STANDALONE= # Example: "true" | |
| - | |
| - | |
| -# ================================================================= | |
| -# Error Tracking Configuration (Sentry) | |
| -# ================================================================= | |
| -# To set up Sentry error tracking: | |
| -# 1. Create an account at https://sentry.io | |
| -# 2. Create a new project for Next.js | |
| -# 3. Follow the setup instructions below | |
| - | |
| -# Step 1: Sentry DSN (Required) | |
| -# Found at: Settings > Projects > [Your Project] > Client Keys (DSN) | |
| - | |
| -NEXT_PUBLIC_SENTRY_DSN= #Example: https://****@****.ingest.sentry.io/**** | |
| - | |
| - | |
| -# Step 2: Organization & Project Details | |
| -# Found at: Settings > Organization > General Settings | |
| - | |
| -NEXT_PUBLIC_SENTRY_ORG= # Example: acme-corp | |
| -NEXT_PUBLIC_SENTRY_PROJECT= # Example: nextjs-dashboard | |
| - | |
| - | |
| -# Step 3: Sentry Auth Token | |
| - | |
| -# Sentry can automatically provide readable stack traces for errors using source maps, requiring a Sentry auth token. | |
| -# More info: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#step-4-add-readable-stack-traces-with-source-maps-optional | |
| - | |
| -SENTRY_AUTH_TOKEN= #Example: sntrys_************************************ | |
| - | |
| - | |
| -# Step 4: Environment Control (Optional) | |
| -# Set to 'true' to disable Sentry in development | |
| - | |
| -NEXT_PUBLIC_SENTRY_DISABLED= "false" | |
| - | |
| - | |
| -# ================================================================= | |
| -# Important Notes: | |
| -# ================================================================= | |
| -# 1. Rename this file to '.env' for local development | |
| -# 2. Never commit the actual '.env' file to version control | |
| -# 3. Make sure to replace all placeholder values with real ones | |
| -# 4. Keep your secret keys private and never share them | |
| diff --git a/package.json b/package.json | |
| index ebce2ed..b3f6d90 100644 | |
| --- a/package.json | |
| +++ b/package.json | |
| @@ -63,6 +63,7 @@ | |
| "clsx": "^2.1.1", | |
| "cmdk": "^1.1.1", | |
| "date-fns": "^4.1.0", | |
| + "embla-carousel-react": "^8.6.0", | |
| "input-otp": "^1.4.2", | |
| "kbar": "^0.1.0-beta.48", | |
| "match-sorter": "^8.2.0", | |
| diff --git a/src/app/dashboard/overview/layout.tsx b/src/app/dashboard/overview/layout.tsx | |
| index 0d6393d..0db614c 100644 | |
| --- a/src/app/dashboard/overview/layout.tsx | |
| +++ b/src/app/dashboard/overview/layout.tsx | |
| @@ -1,15 +1,61 @@ | |
| import PageContainer from '@/components/layout/page-container'; | |
| -import { Badge } from '@/components/ui/badge'; | |
| -import { | |
| - Card, | |
| - CardHeader, | |
| - CardTitle, | |
| - CardDescription, | |
| - CardAction, | |
| - CardFooter | |
| -} from '@/components/ui/card'; | |
| import { Icons } from '@/components/icons'; | |
| +import { CustomerReviews } from '@/features/overview/components/customer-reviews'; | |
| import React from 'react'; | |
| +import { cn } from '@/lib/utils'; | |
| + | |
| +interface MetricCardProps { | |
| + label: string; | |
| + value: string; | |
| + trend: string; | |
| + trendUp: boolean; | |
| + icon: React.ReactNode; | |
| + trendPeriod?: string; | |
| +} | |
| + | |
| +function MetricCard({ | |
| + label, | |
| + value, | |
| + trend, | |
| + trendUp, | |
| + icon, | |
| + trendPeriod = '30 days' | |
| +}: MetricCardProps) { | |
| + return ( | |
| + <div className='bg-card border-border flex items-center gap-5 rounded-2xl border p-6 shadow-sm transition-shadow hover:shadow-md'> | |
| + {/* Icon circle */} | |
| + <div className='bg-(--overview-accent-muted) flex h-20 w-20 shrink-0 items-center justify-center rounded-full'> | |
| + {icon} | |
| + </div> | |
| + | |
| + {/* Content */} | |
| + <div className='min-w-0'> | |
| + <p className='text-foreground text-4xl font-bold tabular-nums leading-tight'>{value}</p> | |
| + <p className='text-muted-foreground mt-1 text-sm'>{label}</p> | |
| + <div className='mt-2 flex items-center gap-1'> | |
| + {trendUp ? ( | |
| + <Icons.trendingUp | |
| + className={cn( | |
| + 'h-4 w-4', | |
| + trendUp ? 'text-(--overview-accent)' : 'text-(--overview-red)' | |
| + )} | |
| + /> | |
| + ) : ( | |
| + <Icons.trendingDown className='text-(--overview-red) h-4 w-4' /> | |
| + )} | |
| + <span | |
| + className={cn( | |
| + 'text-xs font-medium', | |
| + trendUp ? 'text-(--overview-accent)' : 'text-(--overview-red)' | |
| + )} | |
| + > | |
| + {trend} ({trendPeriod}) | |
| + </span> | |
| + </div> | |
| + </div> | |
| + </div> | |
| + ); | |
| +} | |
| export default function OverViewLayout({ | |
| sales, | |
| @@ -24,102 +70,76 @@ export default function OverViewLayout({ | |
| }) { | |
| return ( | |
| <PageContainer> | |
| - <div className='flex flex-1 flex-col space-y-2'> | |
| - <div className='flex items-center justify-between'> | |
| - <h2 className='text-2xl font-bold tracking-tight'>Hi, Welcome back 👋</h2> | |
| + <div className='flex flex-1 flex-col gap-6'> | |
| + {/* Page heading */} | |
| + <div> | |
| + <h1 className='text-foreground text-2xl font-semibold'>Dashboard</h1> | |
| + <p className='text-muted-foreground mt-1 text-sm'>Hi, Welcome back to the Admin!</p> | |
| </div> | |
| - <div className='*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs md:grid-cols-2 lg: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'> | |
| - <Icons.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 <Icons.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'> | |
| - <Icons.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 <Icons.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'> | |
| - <Icons.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 <Icons.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'> | |
| - <Icons.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 <Icons.trendingUp className='size-4' /> | |
| - </div> | |
| - <div className='text-muted-foreground'>Meets growth projections</div> | |
| - </CardFooter> | |
| - </Card> | |
| + {/* Metric cards */} | |
| + <div className='grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4'> | |
| + <MetricCard | |
| + label='Total Revenue' | |
| + value='$1,250' | |
| + trend='+12.5%' | |
| + trendUp={true} | |
| + icon={ | |
| + <Icons.billing | |
| + className='text-(--overview-accent) h-9 w-9' | |
| + aria-label='Total Revenue' | |
| + /> | |
| + } | |
| + /> | |
| + <MetricCard | |
| + label='New Users' | |
| + value='1,234' | |
| + trend='-20%' | |
| + trendUp={false} | |
| + icon={ | |
| + <Icons.userPlus className='text-(--overview-accent) h-9 w-9' aria-label='New Users' /> | |
| + } | |
| + /> | |
| + <MetricCard | |
| + label='Active Accounts' | |
| + value='45,678' | |
| + trend='+12.5%' | |
| + trendUp={true} | |
| + icon={ | |
| + <Icons.teams | |
| + className='text-(--overview-accent) h-9 w-9' | |
| + aria-label='Active Accounts' | |
| + /> | |
| + } | |
| + /> | |
| + <MetricCard | |
| + label='Growth Rate' | |
| + value='4.5%' | |
| + trend='+4.5%' | |
| + trendUp={true} | |
| + icon={ | |
| + <Icons.trendingUp | |
| + className='text-(--overview-accent) h-9 w-9' | |
| + aria-label='Growth Rate' | |
| + /> | |
| + } | |
| + /> | |
| </div> | |
| - <div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-7'> | |
| - <div className='col-span-4'>{bar_stats}</div> | |
| - <div className='col-span-4 md:col-span-3'> | |
| - {/* sales arallel routes */} | |
| - {sales} | |
| - </div> | |
| - <div className='col-span-4'>{area_stats}</div> | |
| - <div className='col-span-4 min-h-0 md:col-span-3'>{pie_stats}</div> | |
| + | |
| + {/* Charts row 1: Pie charts + Activity overview */} | |
| + <div className='grid grid-cols-1 gap-4 lg:grid-cols-2'> | |
| + <div>{pie_stats}</div> | |
| + <div>{bar_stats}</div> | |
| </div> | |
| + | |
| + {/* Charts row 2: Revenue comparison (wider) + User distribution */} | |
| + <div className='grid grid-cols-1 gap-4 lg:grid-cols-7'> | |
| + <div className='lg:col-span-5'>{area_stats}</div> | |
| + <div className='lg:col-span-2'>{sales}</div> | |
| + </div> | |
| + | |
| + {/* Customer feedback section */} | |
| + <CustomerReviews /> | |
| </div> | |
| </PageContainer> | |
| ); | |
| diff --git a/src/components/icons.tsx b/src/components/icons.tsx | |
| index 14be643..7f72618 100644 | |
| --- a/src/components/icons.tsx | |
| +++ b/src/components/icons.tsx | |
| @@ -31,6 +31,7 @@ import { | |
| IconDeviceLaptop, | |
| IconDots, | |
| IconDotsVertical, | |
| + IconDownload, | |
| IconEdit, | |
| IconExternalLink, | |
| IconEyeOff, | |
| @@ -73,6 +74,7 @@ import { | |
| IconSparkles, | |
| IconStack2, | |
| IconStar, | |
| + IconStarFilled, | |
| IconSun, | |
| IconTrash, | |
| IconTrendingDown, | |
| @@ -83,6 +85,7 @@ import { | |
| IconUser, | |
| IconUserCircle, | |
| IconUserEdit, | |
| + IconUserPlus, | |
| IconUserX, | |
| IconUsers, | |
| IconVideo, | |
| @@ -164,10 +167,12 @@ export const Icons = { | |
| add: IconPlus, | |
| edit: IconEdit, | |
| upload: IconUpload, | |
| + download: IconDownload, | |
| share: IconShare, | |
| login: IconLogin, | |
| logout: IconLogout, | |
| gripVertical: IconGripVertical, | |
| + userPlus: IconUserPlus, | |
| // Shapes / Indicators | |
| circle: IconCircle, | |
| @@ -189,6 +194,7 @@ export const Icons = { | |
| product: IconBox, | |
| pro: IconCrown, | |
| exclusive: IconStar, | |
| + starFilled: IconStarFilled, | |
| sparkles: IconSparkles, | |
| badgeCheck: IconRosetteDiscountCheck, | |
| lock: IconLock, | |
| diff --git a/src/components/ui/carousel.tsx b/src/components/ui/carousel.tsx | |
| new file mode 100644 | |
| index 0000000..632bf44 | |
| --- /dev/null | |
| +++ b/src/components/ui/carousel.tsx | |
| @@ -0,0 +1,230 @@ | |
| +'use client'; | |
| + | |
| +import * as React from 'react'; | |
| +import useEmblaCarousel, { type UseEmblaCarouselType } from 'embla-carousel-react'; | |
| +import { IconArrowLeft, IconArrowRight } from '@tabler/icons-react'; | |
| + | |
| +import { cn } from '@/lib/utils'; | |
| +import { Button } from '@/components/ui/button'; | |
| + | |
| +type CarouselApi = UseEmblaCarouselType[1]; | |
| +type UseCarouselParameters = Parameters<typeof useEmblaCarousel>; | |
| +type CarouselOptions = UseCarouselParameters[0]; | |
| +type CarouselPlugin = UseCarouselParameters[1]; | |
| + | |
| +type CarouselProps = { | |
| + opts?: CarouselOptions; | |
| + plugins?: CarouselPlugin; | |
| + orientation?: 'horizontal' | 'vertical'; | |
| + setApi?: (api: CarouselApi) => void; | |
| +}; | |
| + | |
| +type CarouselContextProps = { | |
| + carouselRef: ReturnType<typeof useEmblaCarousel>[0]; | |
| + api: ReturnType<typeof useEmblaCarousel>[1]; | |
| + scrollPrev: () => void; | |
| + scrollNext: () => void; | |
| + canScrollPrev: boolean; | |
| + canScrollNext: boolean; | |
| +} & CarouselProps; | |
| + | |
| +const CarouselContext = React.createContext<CarouselContextProps | null>(null); | |
| + | |
| +function useCarousel() { | |
| + const context = React.useContext(CarouselContext); | |
| + | |
| + if (!context) { | |
| + throw new Error('useCarousel must be used within a <Carousel />'); | |
| + } | |
| + | |
| + return context; | |
| +} | |
| + | |
| +function Carousel({ | |
| + orientation = 'horizontal', | |
| + opts, | |
| + setApi, | |
| + plugins, | |
| + className, | |
| + children, | |
| + ...props | |
| +}: React.ComponentProps<'div'> & CarouselProps) { | |
| + const [carouselRef, api] = useEmblaCarousel( | |
| + { | |
| + ...opts, | |
| + axis: orientation === 'horizontal' ? 'x' : 'y' | |
| + }, | |
| + plugins | |
| + ); | |
| + const [canScrollPrev, setCanScrollPrev] = React.useState(false); | |
| + const [canScrollNext, setCanScrollNext] = React.useState(false); | |
| + | |
| + const onSelect = React.useCallback((api: CarouselApi) => { | |
| + if (!api) return; | |
| + setCanScrollPrev(api.canScrollPrev()); | |
| + setCanScrollNext(api.canScrollNext()); | |
| + }, []); | |
| + | |
| + const scrollPrev = React.useCallback(() => { | |
| + api?.scrollPrev(); | |
| + }, [api]); | |
| + | |
| + const scrollNext = React.useCallback(() => { | |
| + api?.scrollNext(); | |
| + }, [api]); | |
| + | |
| + const handleKeyDown = React.useCallback( | |
| + (event: React.KeyboardEvent<HTMLDivElement>) => { | |
| + if (event.key === 'ArrowLeft') { | |
| + event.preventDefault(); | |
| + scrollPrev(); | |
| + } else if (event.key === 'ArrowRight') { | |
| + event.preventDefault(); | |
| + scrollNext(); | |
| + } | |
| + }, | |
| + [scrollPrev, scrollNext] | |
| + ); | |
| + | |
| + React.useEffect(() => { | |
| + if (!api || !setApi) return; | |
| + setApi(api); | |
| + }, [api, setApi]); | |
| + | |
| + React.useEffect(() => { | |
| + if (!api) return; | |
| + onSelect(api); | |
| + api.on('reInit', onSelect); | |
| + api.on('select', onSelect); | |
| + | |
| + return () => { | |
| + api?.off('select', onSelect); | |
| + }; | |
| + }, [api, onSelect]); | |
| + | |
| + return ( | |
| + <CarouselContext.Provider | |
| + value={{ | |
| + carouselRef, | |
| + api: api, | |
| + opts, | |
| + orientation: orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'), | |
| + scrollPrev, | |
| + scrollNext, | |
| + canScrollPrev, | |
| + canScrollNext | |
| + }} | |
| + > | |
| + <div | |
| + onKeyDownCapture={handleKeyDown} | |
| + className={cn('relative', className)} | |
| + role='region' | |
| + aria-roledescription='carousel' | |
| + data-slot='carousel' | |
| + {...props} | |
| + > | |
| + {children} | |
| + </div> | |
| + </CarouselContext.Provider> | |
| + ); | |
| +} | |
| + | |
| +function CarouselContent({ className, ...props }: React.ComponentProps<'div'>) { | |
| + const { carouselRef, orientation } = useCarousel(); | |
| + | |
| + return ( | |
| + <div ref={carouselRef} className='overflow-hidden' data-slot='carousel-content'> | |
| + <div | |
| + className={cn('flex', orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col', className)} | |
| + {...props} | |
| + /> | |
| + </div> | |
| + ); | |
| +} | |
| + | |
| +function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) { | |
| + const { orientation } = useCarousel(); | |
| + | |
| + return ( | |
| + <div | |
| + role='group' | |
| + aria-roledescription='slide' | |
| + data-slot='carousel-item' | |
| + className={cn( | |
| + 'min-w-0 shrink-0 grow-0 basis-full', | |
| + orientation === 'horizontal' ? 'pl-4' : 'pt-4', | |
| + className | |
| + )} | |
| + {...props} | |
| + /> | |
| + ); | |
| +} | |
| + | |
| +function CarouselPrevious({ | |
| + className, | |
| + variant = 'outline', | |
| + size = 'icon', | |
| + ...props | |
| +}: React.ComponentProps<typeof Button>) { | |
| + const { orientation, scrollPrev, canScrollPrev } = useCarousel(); | |
| + | |
| + return ( | |
| + <Button | |
| + data-slot='carousel-previous' | |
| + variant={variant} | |
| + size={size} | |
| + className={cn( | |
| + 'absolute size-8 rounded-full', | |
| + orientation === 'horizontal' | |
| + ? 'top-1/2 -left-12 -translate-y-1/2' | |
| + : '-top-12 left-1/2 -translate-x-1/2 rotate-90', | |
| + className | |
| + )} | |
| + disabled={!canScrollPrev} | |
| + onClick={scrollPrev} | |
| + {...props} | |
| + > | |
| + <IconArrowLeft /> | |
| + <span className='sr-only'>Previous slide</span> | |
| + </Button> | |
| + ); | |
| +} | |
| + | |
| +function CarouselNext({ | |
| + className, | |
| + variant = 'outline', | |
| + size = 'icon', | |
| + ...props | |
| +}: React.ComponentProps<typeof Button>) { | |
| + const { orientation, scrollNext, canScrollNext } = useCarousel(); | |
| + | |
| + return ( | |
| + <Button | |
| + data-slot='carousel-next' | |
| + variant={variant} | |
| + size={size} | |
| + className={cn( | |
| + 'absolute size-8 rounded-full', | |
| + orientation === 'horizontal' | |
| + ? 'top-1/2 -right-12 -translate-y-1/2' | |
| + : '-bottom-12 left-1/2 -translate-x-1/2 rotate-90', | |
| + className | |
| + )} | |
| + disabled={!canScrollNext} | |
| + onClick={scrollNext} | |
| + {...props} | |
| + > | |
| + <IconArrowRight /> | |
| + <span className='sr-only'>Next slide</span> | |
| + </Button> | |
| + ); | |
| +} | |
| + | |
| +export { | |
| + type CarouselApi, | |
| + Carousel, | |
| + CarouselContent, | |
| + CarouselItem, | |
| + CarouselPrevious, | |
| + CarouselNext | |
| +}; | |
| diff --git a/src/features/overview/components/area-graph.tsx b/src/features/overview/components/area-graph.tsx | |
| index ea75fd8..c1cf716 100644 | |
| --- a/src/features/overview/components/area-graph.tsx | |
| +++ b/src/features/overview/components/area-graph.tsx | |
| @@ -1,116 +1,149 @@ | |
| 'use client'; | |
| -import { Area, AreaChart, CartesianGrid, XAxis } from 'recharts'; | |
| - | |
| -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; | |
| -import { | |
| - ChartConfig, | |
| - ChartContainer, | |
| - ChartTooltip, | |
| - ChartTooltipContent | |
| -} from '@/components/ui/chart'; | |
| -import { Badge } from '@/components/ui/badge'; | |
| -import { Icons } from '@/components/icons'; | |
| +import { Line, LineChart, CartesianGrid, XAxis, YAxis, Tooltip, ReferenceDot } from 'recharts'; | |
| +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; | |
| +import { ChartConfig, ChartContainer } from '@/components/ui/chart'; | |
| import React from 'react'; | |
| const chartData = [ | |
| - { month: 'January', desktop: 342, mobile: 245 }, | |
| - { month: 'February', desktop: 876, mobile: 654 }, | |
| - { month: 'March', desktop: 512, mobile: 387 }, | |
| - { month: 'April', desktop: 629, mobile: 521 }, | |
| - { month: 'May', desktop: 458, mobile: 412 }, | |
| - { month: 'June', desktop: 781, mobile: 598 }, | |
| - { month: 'July', desktop: 394, mobile: 312 }, | |
| - { month: 'August', desktop: 925, mobile: 743 }, | |
| - { month: 'September', desktop: 647, mobile: 489 }, | |
| - { month: 'October', desktop: 532, mobile: 476 }, | |
| - { month: 'November', desktop: 803, mobile: 687 }, | |
| - { month: 'December', desktop: 271, mobile: 198 } | |
| + { month: 'Jan', year2023: 15000, year2024: 20000 }, | |
| + { month: 'Feb', year2023: 25000, year2024: 30000 }, | |
| + { month: 'Mar', year2023: 18000, year2024: 22000 }, | |
| + { month: 'Apr', year2023: 28000, year2024: 32000 }, | |
| + { month: 'May', year2023: 22000, year2024: 28000 }, | |
| + { month: 'Jun', year2023: 38753, year2024: 25000 }, | |
| + { month: 'Jul', year2023: 32000, year2024: 38000 }, | |
| + { month: 'Aug', year2023: 20000, year2024: 28000 }, | |
| + { month: 'Sep', year2023: 26000, year2024: 22000 }, | |
| + { month: 'Oct', year2023: 12657, year2024: 29000 }, | |
| + { month: 'Nov', year2023: 35000, year2024: 32000 }, | |
| + { month: 'Dec', year2023: 30000, year2024: 38000 } | |
| ]; | |
| const chartConfig = { | |
| - desktop: { | |
| - label: 'Desktop', | |
| - color: 'var(--chart-1)' | |
| + year2023: { | |
| + label: '2023', | |
| + color: 'var(--overview-blue)' | |
| }, | |
| - mobile: { | |
| - label: 'Mobile', | |
| - color: 'var(--chart-2)' | |
| + year2024: { | |
| + label: '2024', | |
| + color: 'var(--overview-red)' | |
| } | |
| } satisfies ChartConfig; | |
| +function formatValue(value: number): string { | |
| + if (value >= 1000) return `$${(value / 1000).toFixed(0)}k`; | |
| + return `$${value}`; | |
| +} | |
| + | |
| +interface CustomTooltipProps { | |
| + active?: boolean; | |
| + payload?: Array<{ value: number; color: string; name: string }>; | |
| + label?: string; | |
| +} | |
| + | |
| +function CustomTooltip({ active, payload, label }: CustomTooltipProps) { | |
| + if (active && payload && payload.length) { | |
| + return ( | |
| + <div className='bg-background border-border rounded-xl border px-3 py-2 shadow-md'> | |
| + <p className='text-muted-foreground mb-1 text-xs'>{label}</p> | |
| + {payload.map((entry) => ( | |
| + <p key={entry.name} className='text-sm font-semibold' style={{ color: entry.color }}> | |
| + ${entry.value.toLocaleString()} | |
| + </p> | |
| + ))} | |
| + </div> | |
| + ); | |
| + } | |
| + return null; | |
| +} | |
| + | |
| export function AreaGraph() { | |
| return ( | |
| - <Card> | |
| - <CardHeader> | |
| - <CardTitle> | |
| - Dotted Area Chart | |
| - <Badge variant='outline'> | |
| - <Icons.trendingUp /> | |
| - -5.2% | |
| - </Badge> | |
| - </CardTitle> | |
| - <CardDescription>Showing total visitors for the last 6 months</CardDescription> | |
| + <Card className='h-full'> | |
| + <CardHeader className='pb-2'> | |
| + <div className='flex items-center justify-between'> | |
| + <CardTitle className='text-lg font-bold'>Revenue Comparison</CardTitle> | |
| + <div className='flex items-center gap-4'> | |
| + <div className='flex items-center gap-1.5'> | |
| + <span className='bg-(--overview-blue) h-3 w-3 rounded-full' /> | |
| + <span className='text-muted-foreground text-sm'>2023</span> | |
| + </div> | |
| + <div className='flex items-center gap-1.5'> | |
| + <span className='bg-(--overview-red) h-3 w-3 rounded-full' /> | |
| + <span className='text-muted-foreground text-sm'>2024</span> | |
| + </div> | |
| + </div> | |
| + </div> | |
| </CardHeader> | |
| <CardContent> | |
| - <ChartContainer config={chartConfig}> | |
| - <AreaChart accessibilityLayer data={chartData}> | |
| - <CartesianGrid vertical={false} strokeDasharray='3 3' /> | |
| + <ChartContainer config={chartConfig} className='h-[280px] w-full'> | |
| + <LineChart data={chartData} margin={{ top: 16, right: 10, left: 0, bottom: 0 }}> | |
| + <CartesianGrid vertical={false} stroke='var(--border)' strokeOpacity={0.5} /> | |
| <XAxis | |
| dataKey='month' | |
| tickLine={false} | |
| axisLine={false} | |
| tickMargin={8} | |
| - tickFormatter={(value) => value.slice(0, 3)} | |
| + tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }} | |
| + /> | |
| + <YAxis | |
| + tickLine={false} | |
| + axisLine={false} | |
| + tickMargin={8} | |
| + tickFormatter={formatValue} | |
| + tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }} | |
| + width={45} | |
| + /> | |
| + <Tooltip content={<CustomTooltip />} /> | |
| + <Line | |
| + dataKey='year2023' | |
| + type='monotone' | |
| + stroke='var(--overview-blue)' | |
| + strokeWidth={2.5} | |
| + dot={{ r: 3.5, fill: 'var(--overview-blue)', strokeWidth: 0 }} | |
| + activeDot={{ r: 5, fill: 'var(--overview-blue)', stroke: '#fff', strokeWidth: 2 }} | |
| + /> | |
| + <Line | |
| + dataKey='year2024' | |
| + type='monotone' | |
| + stroke='var(--overview-red)' | |
| + strokeWidth={2.5} | |
| + dot={{ r: 3.5, fill: 'var(--overview-red)', strokeWidth: 0 }} | |
| + activeDot={{ r: 5, fill: 'var(--overview-red)', stroke: '#fff', strokeWidth: 2 }} | |
| /> | |
| - <ChartTooltip cursor={false} content={<ChartTooltipContent />} /> | |
| - <defs> | |
| - <DottedBackgroundPattern config={chartConfig} /> | |
| - </defs> | |
| - <Area | |
| - dataKey='mobile' | |
| - type='natural' | |
| - fill='url(#dotted-background-pattern-mobile)' | |
| - fillOpacity={0.4} | |
| - stroke='var(--color-mobile)' | |
| - stackId='a' | |
| - strokeWidth={0.8} | |
| + {/* Highlighted reference points */} | |
| + <ReferenceDot | |
| + x='Jun' | |
| + y={38753} | |
| + r={6} | |
| + fill='var(--overview-blue)' | |
| + stroke='#fff' | |
| + strokeWidth={2} | |
| + label={{ | |
| + value: '$38,753', | |
| + position: 'top', | |
| + fontSize: 11, | |
| + fill: 'var(--overview-blue)' | |
| + }} | |
| /> | |
| - <Area | |
| - dataKey='desktop' | |
| - type='natural' | |
| - fill='url(#dotted-background-pattern-desktop)' | |
| - fillOpacity={0.4} | |
| - stroke='var(--color-desktop)' | |
| - stackId='a' | |
| - strokeWidth={0.8} | |
| + <ReferenceDot | |
| + x='Oct' | |
| + y={12657} | |
| + r={6} | |
| + fill='var(--overview-red)' | |
| + stroke='#fff' | |
| + strokeWidth={2} | |
| + label={{ | |
| + value: '$12,657', | |
| + position: 'bottom', | |
| + fontSize: 11, | |
| + fill: 'var(--overview-red)' | |
| + }} | |
| /> | |
| - </AreaChart> | |
| + </LineChart> | |
| </ChartContainer> | |
| </CardContent> | |
| </Card> | |
| ); | |
| } | |
| - | |
| -const DottedBackgroundPattern = ({ config }: { config: ChartConfig }) => { | |
| - const items = Object.fromEntries( | |
| - Object.entries(config).map(([key, value]) => [key, value.color]) | |
| - ); | |
| - return ( | |
| - <> | |
| - {Object.entries(items).map(([key, value]) => ( | |
| - <pattern | |
| - key={key} | |
| - id={`dotted-background-pattern-${key}`} | |
| - x='0' | |
| - y='0' | |
| - width='7' | |
| - height='7' | |
| - patternUnits='userSpaceOnUse' | |
| - > | |
| - <circle cx='5' cy='5' r='1.5' fill={value} opacity={0.5}></circle> | |
| - </pattern> | |
| - ))} | |
| - </> | |
| - ); | |
| -}; | |
| diff --git a/src/features/overview/components/bar-graph.tsx b/src/features/overview/components/bar-graph.tsx | |
| index b961c73..1e6221c 100644 | |
| --- a/src/features/overview/components/bar-graph.tsx | |
| +++ b/src/features/overview/components/bar-graph.tsx | |
| @@ -1,144 +1,100 @@ | |
| 'use client'; | |
| -import { Bar, BarChart, XAxis } from 'recharts'; | |
| - | |
| +import { Area, AreaChart, CartesianGrid, XAxis, YAxis, Tooltip } from 'recharts'; | |
| import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; | |
| -import { | |
| - ChartConfig, | |
| - ChartContainer, | |
| - ChartTooltip, | |
| - ChartTooltipContent | |
| -} from '@/components/ui/chart'; | |
| -import { Badge } from '@/components/ui/badge'; | |
| +import { ChartConfig, ChartContainer } from '@/components/ui/chart'; | |
| +import { Button } from '@/components/ui/button'; | |
| import { Icons } from '@/components/icons'; | |
| +import React from 'react'; | |
| const chartData = [ | |
| - { month: 'January', desktop: 186, mobile: 80 }, | |
| - { month: 'February', desktop: 305, mobile: 200 }, | |
| - { month: 'March', desktop: 237, mobile: 120 }, | |
| - { month: 'April', desktop: 73, mobile: 190 }, | |
| - { month: 'May', desktop: 209, mobile: 130 }, | |
| - { month: 'June', desktop: 214, mobile: 140 } | |
| + { day: 'Sun', activity: 280 }, | |
| + { day: 'Mon', activity: 456 }, | |
| + { day: 'Tue', activity: 380 }, | |
| + { day: 'Wed', activity: 510 }, | |
| + { day: 'Thu', activity: 440 }, | |
| + { day: 'Fri', activity: 380 }, | |
| + { day: 'Sat', activity: 295 } | |
| ]; | |
| const chartConfig = { | |
| - desktop: { | |
| - label: 'Desktop', | |
| - color: 'var(--chart-1)' | |
| - }, | |
| - mobile: { | |
| - label: 'Mobile', | |
| - color: 'var(--chart-2)' | |
| + activity: { | |
| + label: 'Activity', | |
| + color: 'var(--overview-blue)' | |
| } | |
| } satisfies ChartConfig; | |
| +interface CustomTooltipProps { | |
| + active?: boolean; | |
| + payload?: Array<{ value: number }>; | |
| + label?: string; | |
| +} | |
| + | |
| +function CustomTooltip({ active, payload, label }: CustomTooltipProps) { | |
| + if (active && payload && payload.length) { | |
| + return ( | |
| + <div className='bg-background border-border rounded-xl border px-3 py-2 shadow-md'> | |
| + <p className='text-foreground text-sm font-semibold'>{payload[0].value} Events</p> | |
| + <p className='text-muted-foreground text-xs'>{label}</p> | |
| + </div> | |
| + ); | |
| + } | |
| + return null; | |
| +} | |
| + | |
| export function BarGraph() { | |
| return ( | |
| - <Card> | |
| + <Card className='h-full'> | |
| <CardHeader> | |
| - <CardTitle> | |
| - Bar Chart - Multiple | |
| - <Badge variant='outline'> | |
| - <Icons.trendingDown /> | |
| - -5.2% | |
| - </Badge> | |
| - </CardTitle> | |
| - <CardDescription>January - June 2025</CardDescription> | |
| + <div className='flex items-start justify-between gap-2'> | |
| + <div> | |
| + <CardTitle className='text-lg font-bold'>Activity Overview</CardTitle> | |
| + <CardDescription className='mt-1'>Daily user activity across the week</CardDescription> | |
| + </div> | |
| + <Button | |
| + variant='outline' | |
| + size='sm' | |
| + className='shrink-0 border-(--overview-blue) text-(--overview-blue) hover:bg-(--overview-blue)/10 hover:text-(--overview-blue) gap-1.5' | |
| + > | |
| + <Icons.download className='h-4 w-4' /> | |
| + Save Report | |
| + </Button> | |
| + </div> | |
| </CardHeader> | |
| <CardContent> | |
| - <ChartContainer config={chartConfig}> | |
| - <BarChart accessibilityLayer data={chartData}> | |
| - <rect | |
| - x='0' | |
| - y='0' | |
| - width='100%' | |
| - height='85%' | |
| - fill='url(#default-multiple-pattern-dots)' | |
| - /> | |
| + <ChartContainer config={chartConfig} className='h-[220px] w-full'> | |
| + <AreaChart data={chartData} margin={{ top: 10, right: 10, left: -10, bottom: 0 }}> | |
| <defs> | |
| - <DottedBackgroundPattern /> | |
| + <linearGradient id='activityGradient' x1='0' y1='0' x2='0' y2='1'> | |
| + <stop offset='5%' stopColor='var(--overview-blue)' stopOpacity={0.3} /> | |
| + <stop offset='95%' stopColor='var(--overview-blue)' stopOpacity={0.02} /> | |
| + </linearGradient> | |
| </defs> | |
| + <CartesianGrid vertical={false} stroke='var(--border)' strokeOpacity={0.5} /> | |
| <XAxis | |
| - dataKey='month' | |
| + dataKey='day' | |
| tickLine={false} | |
| - tickMargin={10} | |
| axisLine={false} | |
| - tickFormatter={(value) => value.slice(0, 3)} | |
| - /> | |
| - <ChartTooltip | |
| - cursor={false} | |
| - content={<ChartTooltipContent indicator='dashed' hideLabel />} | |
| + tickMargin={8} | |
| + tick={{ fill: 'var(--muted-foreground)', fontSize: 13 }} | |
| /> | |
| - <Bar | |
| - dataKey='desktop' | |
| - color='var(--chart-1)' | |
| - fill='var(--color-desktop)' | |
| - shape={<CustomHatchedBar isHatched={false} />} | |
| - radius={4} | |
| + <YAxis hide /> | |
| + <Tooltip | |
| + cursor={{ stroke: 'var(--overview-blue)', strokeWidth: 1, strokeDasharray: '4 4' }} | |
| + content={<CustomTooltip />} | |
| /> | |
| - <Bar | |
| - dataKey='mobile' | |
| - fill='var(--color-mobile)' | |
| - shape={<CustomHatchedBar />} | |
| - radius={4} | |
| + <Area | |
| + dataKey='activity' | |
| + type='monotone' | |
| + fill='url(#activityGradient)' | |
| + stroke='var(--overview-blue)' | |
| + strokeWidth={2.5} | |
| + dot={{ r: 4, fill: 'var(--overview-blue)', strokeWidth: 0 }} | |
| + activeDot={{ r: 6, fill: 'var(--overview-blue)', stroke: '#ffffff', strokeWidth: 3 }} | |
| /> | |
| - </BarChart> | |
| + </AreaChart> | |
| </ChartContainer> | |
| </CardContent> | |
| </Card> | |
| ); | |
| } | |
| - | |
| -const CustomHatchedBar = ( | |
| - props: React.SVGProps<SVGRectElement> & { | |
| - dataKey?: string; | |
| - isHatched?: boolean; | |
| - } | |
| -) => { | |
| - const { fill, x, y, width, height, dataKey } = props; | |
| - | |
| - const isHatched = props.isHatched ?? true; | |
| - | |
| - return ( | |
| - <> | |
| - <rect | |
| - rx={4} | |
| - x={x} | |
| - y={y} | |
| - width={width} | |
| - height={height} | |
| - stroke='none' | |
| - fill={isHatched ? `url(#hatched-bar-pattern-${dataKey})` : fill} | |
| - /> | |
| - <defs> | |
| - <pattern | |
| - key={dataKey} | |
| - id={`hatched-bar-pattern-${dataKey}`} | |
| - x='0' | |
| - y='0' | |
| - width='5' | |
| - height='5' | |
| - patternUnits='userSpaceOnUse' | |
| - patternTransform='rotate(-45)' | |
| - > | |
| - <rect width='10' height='10' opacity={0.5} fill={fill}></rect> | |
| - <rect width='1' height='10' fill={fill}></rect> | |
| - </pattern> | |
| - </defs> | |
| - </> | |
| - ); | |
| -}; | |
| -const DottedBackgroundPattern = () => { | |
| - return ( | |
| - <pattern | |
| - id='default-multiple-pattern-dots' | |
| - x='0' | |
| - y='0' | |
| - width='10' | |
| - height='10' | |
| - patternUnits='userSpaceOnUse' | |
| - > | |
| - <circle className='dark:text-muted/40 text-muted' cx='2' cy='2' r='1' fill='currentColor' /> | |
| - </pattern> | |
| - ); | |
| -}; | |
| diff --git a/src/features/overview/components/customer-reviews.tsx b/src/features/overview/components/customer-reviews.tsx | |
| new file mode 100644 | |
| index 0000000..18aecfd | |
| --- /dev/null | |
| +++ b/src/features/overview/components/customer-reviews.tsx | |
| @@ -0,0 +1,174 @@ | |
| +'use client'; | |
| + | |
| +import React from 'react'; | |
| +import { | |
| + Carousel, | |
| + CarouselContent, | |
| + CarouselItem, | |
| + CarouselNext, | |
| + CarouselPrevious | |
| +} from '@/components/ui/carousel'; | |
| +import { Icons } from '@/components/icons'; | |
| +import { cn } from '@/lib/utils'; | |
| + | |
| +interface Review { | |
| + id: number; | |
| + name: string; | |
| + avatar: string; | |
| + timeAgo: string; | |
| + text: string; | |
| + rating: number; | |
| + productImage: string; | |
| +} | |
| + | |
| +const reviews: Review[] = [ | |
| + { | |
| + id: 1, | |
| + name: 'James Wilson', | |
| + avatar: 'https://i.pravatar.cc/54?u=james-wilson', | |
| + timeAgo: '2 days ago', | |
| + text: "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text.", | |
| + rating: 4.5, | |
| + productImage: 'https://picsum.photos/223/223?random=10' | |
| + }, | |
| + { | |
| + id: 2, | |
| + name: 'Sofia Chen', | |
| + avatar: 'https://i.pravatar.cc/54?u=sofia-chen', | |
| + timeAgo: '2 days ago', | |
| + text: "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text.", | |
| + rating: 4.0, | |
| + productImage: 'https://picsum.photos/223/223?random=11' | |
| + }, | |
| + { | |
| + id: 3, | |
| + name: 'Anand Sharma', | |
| + avatar: 'https://i.pravatar.cc/54?u=anand-sharma', | |
| + timeAgo: '2 days ago', | |
| + text: "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text.", | |
| + rating: 4.5, | |
| + productImage: 'https://picsum.photos/223/223?random=12' | |
| + }, | |
| + { | |
| + id: 4, | |
| + name: 'Maria Garcia', | |
| + avatar: 'https://i.pravatar.cc/54?u=maria-garcia', | |
| + timeAgo: '3 days ago', | |
| + text: "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text.", | |
| + rating: 5.0, | |
| + productImage: 'https://picsum.photos/223/223?random=13' | |
| + }, | |
| + { | |
| + id: 5, | |
| + name: 'David Park', | |
| + avatar: 'https://i.pravatar.cc/54?u=david-park', | |
| + timeAgo: '4 days ago', | |
| + text: "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text.", | |
| + rating: 3.5, | |
| + productImage: 'https://picsum.photos/223/223?random=14' | |
| + } | |
| +]; | |
| + | |
| +function StarRating({ rating }: { rating: number }) { | |
| + const fullStars = Math.floor(rating); | |
| + const hasHalf = rating % 1 >= 0.5; | |
| + const emptyStars = 5 - fullStars - (hasHalf ? 1 : 0); | |
| + | |
| + return ( | |
| + <div className='flex items-center gap-1'> | |
| + {Array.from({ length: fullStars }).map((_, i) => ( | |
| + <Icons.starFilled | |
| + key={`full-${i}`} | |
| + className='h-4 w-4 text-(--overview-star)' | |
| + aria-hidden='true' | |
| + /> | |
| + ))} | |
| + {hasHalf && ( | |
| + <Icons.starFilled | |
| + className='text-(--overview-star) h-4 w-4 opacity-50' | |
| + aria-hidden='true' | |
| + /> | |
| + )} | |
| + {Array.from({ length: emptyStars }).map((_, i) => ( | |
| + <Icons.exclusive | |
| + key={`empty-${i}`} | |
| + className='text-muted-foreground/40 h-4 w-4' | |
| + aria-hidden='true' | |
| + /> | |
| + ))} | |
| + <span className='text-muted-foreground ml-1 text-sm font-medium'>{rating}</span> | |
| + </div> | |
| + ); | |
| +} | |
| + | |
| +function ReviewCard({ review }: { review: Review }) { | |
| + return ( | |
| + <div className='bg-card border-border relative flex h-full min-h-[260px] flex-col justify-between overflow-hidden rounded-2xl border p-6 shadow-sm'> | |
| + {/* Profile */} | |
| + <div className='flex items-center gap-3'> | |
| + <img | |
| + src={review.avatar} | |
| + alt={review.name} | |
| + width={48} | |
| + height={48} | |
| + className='h-12 w-12 rounded-full object-cover' | |
| + /> | |
| + <div> | |
| + <p className='text-foreground text-base font-semibold leading-tight'>{review.name}</p> | |
| + <p className='text-muted-foreground text-sm'>{review.timeAgo}</p> | |
| + </div> | |
| + </div> | |
| + | |
| + {/* Review text */} | |
| + <p className='text-foreground/80 mt-4 line-clamp-4 text-sm leading-relaxed'>{review.text}</p> | |
| + | |
| + {/* Rating */} | |
| + <div className='mt-4'> | |
| + <StarRating rating={review.rating} /> | |
| + </div> | |
| + | |
| + {/* Decorative product image */} | |
| + <div | |
| + className={cn( | |
| + 'pointer-events-none absolute -right-6 -bottom-6', | |
| + 'h-32 w-32 overflow-hidden rounded-full shadow-lg' | |
| + )} | |
| + aria-hidden='true' | |
| + > | |
| + <img | |
| + src={review.productImage} | |
| + alt='' | |
| + width={128} | |
| + height={128} | |
| + className='h-full w-full object-cover' | |
| + /> | |
| + </div> | |
| + </div> | |
| + ); | |
| +} | |
| + | |
| +export function CustomerReviews() { | |
| + return ( | |
| + <section className='mt-6'> | |
| + <div className='mb-4 flex items-start justify-between'> | |
| + <div> | |
| + <h2 className='text-foreground text-2xl font-semibold'>Customer Feedback</h2> | |
| + <p className='text-muted-foreground mt-1 text-sm'>See what users are saying about us.</p> | |
| + </div> | |
| + </div> | |
| + <Carousel opts={{ align: 'start', loop: false }} className='w-full'> | |
| + <div className='mb-4 flex justify-end gap-2'> | |
| + <CarouselPrevious className='static translate-y-0 rounded-full' /> | |
| + <CarouselNext className='static translate-y-0 rounded-full' /> | |
| + </div> | |
| + <CarouselContent className='-ml-4'> | |
| + {reviews.map((review) => ( | |
| + <CarouselItem key={review.id} className='pl-4 md:basis-1/2 lg:basis-1/3'> | |
| + <ReviewCard review={review} /> | |
| + </CarouselItem> | |
| + ))} | |
| + </CarouselContent> | |
| + </Carousel> | |
| + </section> | |
| + ); | |
| +} | |
| diff --git a/src/features/overview/components/pie-graph.tsx b/src/features/overview/components/pie-graph.tsx | |
| index b28e5ea..7bd1d0f 100644 | |
| --- a/src/features/overview/components/pie-graph.tsx | |
| +++ b/src/features/overview/components/pie-graph.tsx | |
| @@ -1,90 +1,83 @@ | |
| 'use client'; | |
| -import { LabelList, Pie, PieChart } from 'recharts'; | |
| - | |
| -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; | |
| -import { | |
| - ChartConfig, | |
| - ChartContainer, | |
| - ChartTooltip, | |
| - ChartTooltipContent | |
| -} from '@/components/ui/chart'; | |
| -import { Badge } from '@/components/ui/badge'; | |
| +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; | |
| import { Icons } from '@/components/icons'; | |
| +import React from 'react'; | |
| +import { PieChart, Pie, Cell } from 'recharts'; | |
| + | |
| +interface DonutChartProps { | |
| + value: number; | |
| + color: string; | |
| + label: string; | |
| +} | |
| -const chartData = [ | |
| - { browser: 'chrome', visitors: 275, fill: 'var(--color-chrome)' }, | |
| - { browser: 'safari', visitors: 200, fill: 'var(--color-safari)' }, | |
| - { browser: 'firefox', visitors: 187, fill: 'var(--color-firefox)' }, | |
| - { browser: 'edge', visitors: 173, fill: 'var(--color-edge)' }, | |
| - { browser: 'other', visitors: 90, fill: 'var(--color-other)' } | |
| -]; | |
| +function DonutChart({ value, color, label }: DonutChartProps) { | |
| + const remainder = 100 - value; | |
| + const data = [ | |
| + { name: 'value', value }, | |
| + { name: 'rest', value: remainder } | |
| + ]; | |
| -const chartConfig = { | |
| - visitors: { | |
| - label: 'Visitors' | |
| - }, | |
| - chrome: { | |
| - label: 'Chrome', | |
| - color: 'var(--chart-1)' | |
| - }, | |
| - safari: { | |
| - label: 'Safari', | |
| - color: 'var(--chart-2)' | |
| - }, | |
| - firefox: { | |
| - label: 'Firefox', | |
| - color: 'var(--chart-3)' | |
| - }, | |
| - edge: { | |
| - label: 'Edge', | |
| - color: 'var(--chart-4)' | |
| - }, | |
| - other: { | |
| - label: 'Other', | |
| - color: 'var(--chart-5)' | |
| - } | |
| -} satisfies ChartConfig; | |
| + return ( | |
| + <div className='flex flex-col items-center gap-3'> | |
| + <div className='relative'> | |
| + <PieChart width={160} height={160}> | |
| + <Pie | |
| + data={data} | |
| + cx={75} | |
| + cy={75} | |
| + innerRadius={52} | |
| + outerRadius={72} | |
| + startAngle={90} | |
| + endAngle={-270} | |
| + dataKey='value' | |
| + strokeWidth={0} | |
| + > | |
| + <Cell fill={color} /> | |
| + <Cell fill='#f0f0f0' /> | |
| + </Pie> | |
| + </PieChart> | |
| + <div className='pointer-events-none absolute inset-0 flex items-center justify-center'> | |
| + <span className='text-xl font-semibold' style={{ color }}> | |
| + {value}% | |
| + </span> | |
| + </div> | |
| + </div> | |
| + <span className='text-muted-foreground text-sm font-medium'>{label}</span> | |
| + </div> | |
| + ); | |
| +} | |
| export function PieGraph() { | |
| return ( | |
| - <Card className='flex h-full flex-col'> | |
| - <CardHeader className='items-center pb-0'> | |
| - <CardTitle> | |
| - Pie Chart | |
| - <Badge variant='outline'> | |
| - <Icons.trendingUp /> | |
| - +5.2% | |
| - </Badge> | |
| - </CardTitle> | |
| - <CardDescription>January - June 2024</CardDescription> | |
| - </CardHeader> | |
| - <CardContent className='flex flex-1 items-center justify-center pb-0'> | |
| - <ChartContainer | |
| - config={chartConfig} | |
| - className='[&_.recharts-text]:fill-background mx-auto aspect-square max-h-[300px] min-h-[250px]' | |
| - > | |
| - <PieChart> | |
| - <ChartTooltip content={<ChartTooltipContent nameKey='visitors' hideLabel />} /> | |
| - <Pie | |
| - data={chartData} | |
| - innerRadius={30} | |
| - dataKey='visitors' | |
| - radius={10} | |
| - cornerRadius={8} | |
| - paddingAngle={4} | |
| + <Card className='h-full'> | |
| + <CardHeader className='pb-2'> | |
| + <div className='flex items-center justify-between'> | |
| + <CardTitle className='text-lg font-bold'>Distribution Overview</CardTitle> | |
| + <div className='flex items-center gap-4'> | |
| + <label className='flex cursor-pointer items-center gap-1.5 text-sm'> | |
| + <input type='checkbox' className='accent-(--overview-accent) h-4 w-4 rounded' /> | |
| + <span className='text-muted-foreground'>Chart</span> | |
| + </label> | |
| + <label className='flex cursor-pointer items-center gap-1.5 text-sm'> | |
| + <span className='bg-(--overview-red) inline-block h-3.5 w-3.5 rounded-xs' /> | |
| + <span className='text-muted-foreground'>Show Value</span> | |
| + </label> | |
| + <button | |
| + className='text-muted-foreground hover:text-foreground transition-colors' | |
| + aria-label='More options' | |
| > | |
| - <LabelList | |
| - dataKey='visitors' | |
| - stroke='none' | |
| - fontSize={12} | |
| - fontWeight={500} | |
| - fill='currentColor' | |
| - formatter={(value: number) => value.toString()} | |
| - /> | |
| - </Pie> | |
| - </PieChart> | |
| - </ChartContainer> | |
| + <Icons.ellipsis className='h-5 w-5' /> | |
| + </button> | |
| + </div> | |
| + </div> | |
| + </CardHeader> | |
| + <CardContent> | |
| + <div className='flex items-center justify-around py-4'> | |
| + <DonutChart value={81} color='#ff5b5b' label='Total Users' /> | |
| + <DonutChart value={22} color='#00b074' label='User Growth' /> | |
| + <DonutChart value={62} color='#2d9cdb' label='Revenue Share' /> | |
| + </div> | |
| </CardContent> | |
| </Card> | |
| ); | |
| diff --git a/src/features/overview/components/recent-sales.tsx b/src/features/overview/components/recent-sales.tsx | |
| index 7ceff1f..4d8c257 100644 | |
| --- a/src/features/overview/components/recent-sales.tsx | |
| +++ b/src/features/overview/components/recent-sales.tsx | |
| @@ -1,64 +1,136 @@ | |
| -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; | |
| -import { Card, CardHeader, CardContent, CardTitle, CardDescription } from '@/components/ui/card'; | |
| +'use client'; | |
| -const salesData = [ | |
| - { | |
| - name: 'Olivia Martin', | |
| - email: 'olivia.martin@email.com', | |
| - avatar: 'https://api.slingacademy.com/public/sample-users/1.png', | |
| - fallback: 'OM', | |
| - amount: '+$1,999.00' | |
| - }, | |
| - { | |
| - name: 'Jackson Lee', | |
| - email: 'jackson.lee@email.com', | |
| - avatar: 'https://api.slingacademy.com/public/sample-users/2.png', | |
| - fallback: 'JL', | |
| - amount: '+$39.00' | |
| - }, | |
| - { | |
| - name: 'Isabella Nguyen', | |
| - email: 'isabella.nguyen@email.com', | |
| - avatar: 'https://api.slingacademy.com/public/sample-users/3.png', | |
| - fallback: 'IN', | |
| - amount: '+$299.00' | |
| +import { Bar, BarChart, XAxis, YAxis, CartesianGrid, Tooltip } from 'recharts'; | |
| +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; | |
| +import { ChartConfig, ChartContainer } from '@/components/ui/chart'; | |
| +import { | |
| + Select, | |
| + SelectContent, | |
| + SelectItem, | |
| + SelectTrigger, | |
| + SelectValue | |
| +} from '@/components/ui/select'; | |
| +import { Icons } from '@/components/icons'; | |
| +import React, { useState } from 'react'; | |
| + | |
| +const weeklyData = [ | |
| + { day: 'Sun', enterprise: 60, growth: 25, starter: 30 }, | |
| + { day: 'Mon', enterprise: 80, growth: 45, starter: 15 }, | |
| + { day: 'Tue', enterprise: 40, growth: 55, starter: 20 }, | |
| + { day: 'Wed', enterprise: 65, growth: 30, starter: 40 }, | |
| + { day: 'Thu', enterprise: 35, growth: 60, starter: 15 }, | |
| + { day: 'Fri', enterprise: 20, growth: 10, starter: 45 }, | |
| + { day: 'Sat', enterprise: 50, growth: 25, starter: 20 } | |
| +]; | |
| + | |
| +const monthlyData = [ | |
| + { day: 'Jan', enterprise: 240, growth: 180, starter: 120 }, | |
| + { day: 'Feb', enterprise: 310, growth: 220, starter: 160 }, | |
| + { day: 'Mar', enterprise: 280, growth: 195, starter: 145 }, | |
| + { day: 'Apr', enterprise: 350, growth: 260, starter: 200 } | |
| +]; | |
| + | |
| +const dailyData = [ | |
| + { day: '6am', enterprise: 12, growth: 8, starter: 5 }, | |
| + { day: '9am', enterprise: 35, growth: 28, starter: 18 }, | |
| + { day: '12pm', enterprise: 55, growth: 42, starter: 30 }, | |
| + { day: '3pm', enterprise: 48, growth: 35, starter: 22 }, | |
| + { day: '6pm', enterprise: 30, growth: 22, starter: 14 }, | |
| + { day: '9pm', enterprise: 15, growth: 10, starter: 7 } | |
| +]; | |
| + | |
| +const chartConfig = { | |
| + enterprise: { | |
| + label: 'Enterprise', | |
| + color: '#ff5b5b' | |
| }, | |
| - { | |
| - name: 'William Kim', | |
| - email: 'will@email.com', | |
| - avatar: 'https://api.slingacademy.com/public/sample-users/4.png', | |
| - fallback: 'WK', | |
| - amount: '+$99.00' | |
| + growth: { | |
| + label: 'Growth', | |
| + color: '#f7c604' | |
| }, | |
| - { | |
| - name: 'Sofia Davis', | |
| - email: 'sofia.davis@email.com', | |
| - avatar: 'https://api.slingacademy.com/public/sample-users/5.png', | |
| - fallback: 'SD', | |
| - amount: '+$39.00' | |
| + starter: { | |
| + label: 'Starter', | |
| + color: '#00b074' | |
| } | |
| -]; | |
| +} satisfies ChartConfig; | |
| + | |
| +type FilterType = 'weekly' | 'monthly' | 'daily'; | |
| + | |
| +const dataMap: Record<FilterType, typeof weeklyData> = { | |
| + weekly: weeklyData, | |
| + monthly: monthlyData, | |
| + daily: dailyData | |
| +}; | |
| export function RecentSales() { | |
| + const [filter, setFilter] = useState<FilterType>('weekly'); | |
| + const data = dataMap[filter]; | |
| + | |
| return ( | |
| <Card className='h-full'> | |
| - <CardHeader> | |
| - <CardTitle>Recent Sales</CardTitle> | |
| - <CardDescription>You made 265 sales this month.</CardDescription> | |
| + <CardHeader className='pb-2'> | |
| + <div className='flex items-center justify-between'> | |
| + <CardTitle className='text-lg font-bold'>User Distribution</CardTitle> | |
| + <div className='flex items-center gap-2'> | |
| + <Select value={filter} onValueChange={(v) => setFilter(v as FilterType)}> | |
| + <SelectTrigger className='h-8 w-28 text-sm'> | |
| + <SelectValue /> | |
| + </SelectTrigger> | |
| + <SelectContent> | |
| + <SelectItem value='weekly'>Weekly</SelectItem> | |
| + <SelectItem value='monthly'>Monthly</SelectItem> | |
| + <SelectItem value='daily'>Daily</SelectItem> | |
| + </SelectContent> | |
| + </Select> | |
| + <button | |
| + className='text-muted-foreground hover:text-foreground transition-colors' | |
| + aria-label='More options' | |
| + > | |
| + <Icons.ellipsis className='h-5 w-5' /> | |
| + </button> | |
| + </div> | |
| + </div> | |
| </CardHeader> | |
| <CardContent> | |
| - <div className='space-y-8'> | |
| - {salesData.map((sale, index) => ( | |
| - <div key={index} className='flex items-center'> | |
| - <Avatar className='h-9 w-9'> | |
| - <AvatarImage src={sale.avatar} alt='Avatar' /> | |
| - <AvatarFallback>{sale.fallback}</AvatarFallback> | |
| - </Avatar> | |
| - <div className='ml-4 space-y-1'> | |
| - <p className='text-sm leading-none font-medium'>{sale.name}</p> | |
| - <p className='text-muted-foreground text-sm'>{sale.email}</p> | |
| - </div> | |
| - <div className='ml-auto font-medium'>{sale.amount}</div> | |
| + <ChartContainer config={chartConfig} className='h-[280px] w-full'> | |
| + <BarChart | |
| + data={data} | |
| + barGap={3} | |
| + barCategoryGap='25%' | |
| + margin={{ top: 8, right: 4, left: -16, bottom: 0 }} | |
| + > | |
| + <CartesianGrid vertical={false} stroke='var(--border)' strokeOpacity={0.5} /> | |
| + <XAxis | |
| + dataKey='day' | |
| + tickLine={false} | |
| + axisLine={false} | |
| + tickMargin={8} | |
| + tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }} | |
| + /> | |
| + <YAxis | |
| + tickLine={false} | |
| + axisLine={false} | |
| + tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }} | |
| + /> | |
| + <Tooltip | |
| + cursor={{ fill: 'var(--muted)', opacity: 0.4 }} | |
| + contentStyle={{ | |
| + background: 'var(--background)', | |
| + border: '1px solid var(--border)', | |
| + borderRadius: '12px', | |
| + fontSize: '13px' | |
| + }} | |
| + /> | |
| + <Bar dataKey='enterprise' fill='#ff5b5b' radius={[4, 4, 0, 0]} maxBarSize={18} /> | |
| + <Bar dataKey='growth' fill='#f7c604' radius={[4, 4, 0, 0]} maxBarSize={18} /> | |
| + <Bar dataKey='starter' fill='#00b074' radius={[4, 4, 0, 0]} maxBarSize={18} /> | |
| + </BarChart> | |
| + </ChartContainer> | |
| + <div className='mt-3 flex items-center justify-center gap-4'> | |
| + {Object.entries(chartConfig).map(([key, config]) => ( | |
| + <div key={key} className='flex items-center gap-1.5'> | |
| + <span className='h-2.5 w-2.5 rounded-full' style={{ background: config.color }} /> | |
| + <span className='text-muted-foreground text-xs'>{config.label}</span> | |
| </div> | |
| ))} | |
| </div> | |
| diff --git a/src/styles/globals.css b/src/styles/globals.css | |
| index 443a990..7a12b33 100644 | |
| --- a/src/styles/globals.css | |
| +++ b/src/styles/globals.css | |
| @@ -4,6 +4,23 @@ | |
| @custom-variant dark (&:is(.dark *)); | |
| +/* Overview dashboard accent colors - additive, do not replace theme tokens */ | |
| +:root { | |
| + --overview-accent: #00b074; | |
| + --overview-accent-muted: #e6f7f2; | |
| + --overview-blue: #2d9cdb; | |
| + --overview-red: #ff5b5b; | |
| + --overview-star: #f7c604; | |
| +} | |
| + | |
| +@theme inline { | |
| + --color-overview-accent: var(--overview-accent); | |
| + --color-overview-accent-muted: var(--overview-accent-muted); | |
| + --color-overview-blue: var(--overview-blue); | |
| + --color-overview-red: var(--overview-red); | |
| + --color-overview-star: var(--overview-star); | |
| +} | |
| + | |
| @import './theme.css'; | |
| @layer base { | |
| -- | |
| 2.53.0 | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment