diff --git a/apps/admin/.env.example b/apps/admin/.env.example new file mode 100644 index 0000000..d23eede --- /dev/null +++ b/apps/admin/.env.example @@ -0,0 +1,4 @@ +NEXT_PUBLIC_CONVEX_URL=https://your-project.convex.cloud +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_... +CLERK_SECRET_KEY=sk_test_... +CLERK_WEBHOOK_SECRET=whsec_... diff --git a/apps/admin/components.json b/apps/admin/components.json new file mode 100644 index 0000000..03909d9 --- /dev/null +++ b/apps/admin/components.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/apps/admin/next-env.d.ts b/apps/admin/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/apps/admin/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/admin/next.config.js b/apps/admin/next.config.js new file mode 100644 index 0000000..64d2ed9 --- /dev/null +++ b/apps/admin/next.config.js @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + transpilePackages: ["@repo/convex", "@repo/types", "@repo/utils"], +}; + +module.exports = nextConfig; diff --git a/apps/admin/package.json b/apps/admin/package.json new file mode 100644 index 0000000..7103ade --- /dev/null +++ b/apps/admin/package.json @@ -0,0 +1,30 @@ +{ + "name": "admin", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "next dev --port 3001", + "build": "next build", + "start": "next start", + "lint": "next lint", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@clerk/nextjs": "^6.38.2", + "@repo/convex": "*", + "@repo/types": "*", + "@repo/utils": "*", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.400.0", + "radix-ui": "^1.4.3", + "tailwind-merge": "^2.6.1" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.2.0", + "postcss": "^8.4.0", + "shadcn": "^3.8.5", + "tailwindcss": "^4.2.0", + "tw-animate-css": "^1.4.0" + } +} diff --git a/apps/admin/postcss.config.js b/apps/admin/postcss.config.js new file mode 100644 index 0000000..e295a2c --- /dev/null +++ b/apps/admin/postcss.config.js @@ -0,0 +1,5 @@ +module.exports = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; \ No newline at end of file diff --git a/apps/admin/public/illustrations/404_not_found.svg b/apps/admin/public/illustrations/404_not_found.svg new file mode 100644 index 0000000..9908dd8 --- /dev/null +++ b/apps/admin/public/illustrations/404_not_found.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/admin/public/illustrations/still_building.svg b/apps/admin/public/illustrations/still_building.svg new file mode 100644 index 0000000..7e135f2 --- /dev/null +++ b/apps/admin/public/illustrations/still_building.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/admin/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx b/apps/admin/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx new file mode 100644 index 0000000..2c65c08 --- /dev/null +++ b/apps/admin/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx @@ -0,0 +1,9 @@ +import { SignIn } from "@clerk/nextjs"; + +export default function SignInPage() { + return ( +
+ +
+ ); +} diff --git a/apps/admin/src/app/(dashboard)/layout.tsx b/apps/admin/src/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..bbc250e --- /dev/null +++ b/apps/admin/src/app/(dashboard)/layout.tsx @@ -0,0 +1,40 @@ +import { AdminUserSync } from "../../components/auth/AdminUserSync"; +import { AdminAuthGate } from "../../components/auth/AdminAuthGate"; +import { AppSidebar } from "../../components/layout/sidebar/app-sidebar"; +import { + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from "@/components/ui/sidebar"; +import { Separator } from "@/components/ui/separator"; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + <> + + + + + +
+
+ + +
+
+
+ {children} +
+
+
+
+ + ); +} diff --git a/apps/admin/src/app/(dashboard)/orders/page.tsx b/apps/admin/src/app/(dashboard)/orders/page.tsx new file mode 100644 index 0000000..7eb25c6 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/orders/page.tsx @@ -0,0 +1,5 @@ +import StillBuildingPlaceholder from "../../../components/shared/still_building_placeholder"; + +export default function OrdersPage() { + return ; +} diff --git a/apps/admin/src/app/(dashboard)/page.tsx b/apps/admin/src/app/(dashboard)/page.tsx new file mode 100644 index 0000000..fa62daf --- /dev/null +++ b/apps/admin/src/app/(dashboard)/page.tsx @@ -0,0 +1,7 @@ +export default function DashboardPage() { + return ( +
+

Admin Dashboard

+
+ ); +} diff --git a/apps/admin/src/app/(dashboard)/products/page.tsx b/apps/admin/src/app/(dashboard)/products/page.tsx new file mode 100644 index 0000000..2a7cc0c --- /dev/null +++ b/apps/admin/src/app/(dashboard)/products/page.tsx @@ -0,0 +1,5 @@ +import StillBuildingPlaceholder from "../../../components/shared/still_building_placeholder"; + +export default function ProductsPage() { + return ; +} diff --git a/apps/admin/src/app/globals.css b/apps/admin/src/app/globals.css new file mode 100644 index 0000000..db50c17 --- /dev/null +++ b/apps/admin/src/app/globals.css @@ -0,0 +1,124 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} \ No newline at end of file diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx new file mode 100644 index 0000000..3b52c39 --- /dev/null +++ b/apps/admin/src/app/layout.tsx @@ -0,0 +1,33 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import { ClerkProvider } from "@clerk/nextjs"; +import { ConvexClientProvider } from "@repo/convex"; +import "./globals.css"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: { + template: "%s | Admin", + default: "Admin Dashboard", + }, + description: "Store administration", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + + {children} + + + + + ); +} diff --git a/apps/admin/src/app/not-found.tsx b/apps/admin/src/app/not-found.tsx new file mode 100644 index 0000000..c906c06 --- /dev/null +++ b/apps/admin/src/app/not-found.tsx @@ -0,0 +1,48 @@ +import Image from "next/image"; +import Link from "next/link"; + +export default function NotFound() { + return ( +
+
+
+

+ Page + not found +

+ +

+ Sorry, we couldn't find the page you're looking for. It + may have been moved, renamed, or no longer exists. +

+ + + Back to Home + +
+ + +
+
+ ); +} diff --git a/apps/admin/src/components/auth/AccessDenied.tsx b/apps/admin/src/components/auth/AccessDenied.tsx new file mode 100644 index 0000000..7540204 --- /dev/null +++ b/apps/admin/src/components/auth/AccessDenied.tsx @@ -0,0 +1,33 @@ +"use client"; + +interface AccessDeniedProps { + reason: "not_authenticated" | "not_admin" | "no_user_record"; + onSignOut: () => void; +} + +export function AccessDenied({ onSignOut }: AccessDeniedProps) { + return ( +
+
+

Access Denied

+

+ You don't have permission to access the admin dashboard. +

+
+ + + Go to storefront + +
+
+
+ ); +} diff --git a/apps/admin/src/components/auth/AdminAuthGate.tsx b/apps/admin/src/components/auth/AdminAuthGate.tsx new file mode 100644 index 0000000..a8ea7ed --- /dev/null +++ b/apps/admin/src/components/auth/AdminAuthGate.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useAdminAuth } from "../../hooks/useAdminAuth"; +import { useClerk } from "@clerk/nextjs"; +import { LoadingSkeleton } from "./LoadingSkeleton"; +import { AccessDenied } from "./AccessDenied"; + +export function AdminAuthGate({ children }: { children: React.ReactNode }) { + const auth = useAdminAuth(); + const { signOut } = useClerk(); + + if (auth.status === "loading") { + return ; + } + + if (auth.status === "denied") { + return signOut()} />; + } + + return <>{children}; +} diff --git a/apps/admin/src/components/auth/AdminUserSync.tsx b/apps/admin/src/components/auth/AdminUserSync.tsx new file mode 100644 index 0000000..6bc5788 --- /dev/null +++ b/apps/admin/src/components/auth/AdminUserSync.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { useStoreUserEffect } from "../../hooks/useStoreUserEffect"; + + +export function AdminUserSync() { + useStoreUserEffect(); + return null; +} diff --git a/apps/admin/src/components/auth/LoadingSkeleton.tsx b/apps/admin/src/components/auth/LoadingSkeleton.tsx new file mode 100644 index 0000000..7cbcaa0 --- /dev/null +++ b/apps/admin/src/components/auth/LoadingSkeleton.tsx @@ -0,0 +1,12 @@ +"use client"; + +export function LoadingSkeleton() { + return ( +
+
+
+

Verifying access...

+
+
+ ); +} diff --git a/apps/admin/src/components/auth/RequireRole.tsx b/apps/admin/src/components/auth/RequireRole.tsx new file mode 100644 index 0000000..2c338f4 --- /dev/null +++ b/apps/admin/src/components/auth/RequireRole.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { useAdminAuth } from "../../hooks/useAdminAuth"; + +interface RequireRoleProps { + role: "super_admin"; + fallback?: React.ReactNode; + children: React.ReactNode; +} + +export function RequireRole({ role, fallback = null, children }: RequireRoleProps) { + const auth = useAdminAuth(); + if (auth.status !== "authorized") return null; + if (auth.role !== role) return <>{fallback}; + return <>{children}; +} diff --git a/apps/admin/src/components/layout/sidebar/app-sidebar.tsx b/apps/admin/src/components/layout/sidebar/app-sidebar.tsx new file mode 100644 index 0000000..b7c7b86 --- /dev/null +++ b/apps/admin/src/components/layout/sidebar/app-sidebar.tsx @@ -0,0 +1,71 @@ +"use client"; + +import * as React from "react"; +import { + LayoutDashboard, + ShoppingCart, + Package, + Users, + Tag, + Star, + Settings, + PawPrint, +} from "lucide-react"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenu, + SidebarRail, +} from "@/components/ui/sidebar"; +import { NavMain } from "./nav-main"; +import { NavUser } from "./nav-user"; + +const navItems = [ + { title: "Dashboard", url: "/", icon: LayoutDashboard }, + { title: "Orders", url: "/orders", icon: ShoppingCart }, + { title: "Products", url: "/products", icon: Package }, + { title: "Customers", url: "/customers", icon: Users }, + { title: "Categories", url: "/categories", icon: Tag }, + { title: "Reviews", url: "/reviews", icon: Star }, +]; + +const settingsItems = [ + { title: "Settings", url: "/settings", icon: Settings }, +]; + +export function AppSidebar({ ...props }: React.ComponentProps) { + return ( + + + + + +
+ +
+
+ The Pet Loft + Admin +
+
+
+
+
+ + + + + + + + + + + +
+ ); +} diff --git a/apps/admin/src/components/layout/sidebar/nav-main.tsx b/apps/admin/src/components/layout/sidebar/nav-main.tsx new file mode 100644 index 0000000..c861409 --- /dev/null +++ b/apps/admin/src/components/layout/sidebar/nav-main.tsx @@ -0,0 +1,40 @@ +"use client"; + +import Link from "next/link"; +import { type LucideIcon } from "lucide-react"; +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; + +export function NavMain({ + items, +}: { + items: { + title: string; + url: string; + icon: LucideIcon; + isActive?: boolean; + }[]; +}) { + return ( + + Platform + + {items.map((item) => ( + + + + + {item.title} + + + + ))} + + + ); +} diff --git a/apps/admin/src/components/layout/sidebar/nav-user.tsx b/apps/admin/src/components/layout/sidebar/nav-user.tsx new file mode 100644 index 0000000..d7a3b31 --- /dev/null +++ b/apps/admin/src/components/layout/sidebar/nav-user.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useUser } from "@clerk/nextjs"; +import { UserButton } from "@clerk/nextjs"; +import { useSidebar } from "@/components/ui/sidebar"; + +export function NavUser() { + const { user } = useUser(); + const { state } = useSidebar(); + const isCollapsed = state === "collapsed"; + + return ( +
+ + {!isCollapsed && ( +
+ {user?.fullName} + + {user?.primaryEmailAddress?.emailAddress} + +
+ )} +
+ ); +} diff --git a/apps/admin/src/components/shared/still_building_placeholder.tsx b/apps/admin/src/components/shared/still_building_placeholder.tsx new file mode 100644 index 0000000..2023f4a --- /dev/null +++ b/apps/admin/src/components/shared/still_building_placeholder.tsx @@ -0,0 +1,40 @@ +import Image from "next/image"; +import Link from "next/link"; + +export default function StillBuildingPlaceholder() { + return ( +
+
+
+

+ Building + in progress +

+ +

+ Something pawsome is coming to this page! We're still crafting + it. Try poking around on other pages in the meantime. +

+ + + Back to Dashboard + +
+ +
+ Page still being built illustration +
+
+
+ ); +} diff --git a/apps/admin/src/components/ui/avatar.tsx b/apps/admin/src/components/ui/avatar.tsx new file mode 100644 index 0000000..ea65850 --- /dev/null +++ b/apps/admin/src/components/ui/avatar.tsx @@ -0,0 +1,109 @@ +"use client" + +import * as React from "react" +import { Avatar as AvatarPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" | "lg" +}) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className + )} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarBadge, + AvatarGroup, + AvatarGroupCount, +} diff --git a/apps/admin/src/components/ui/breadcrumb.tsx b/apps/admin/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..004bb63 --- /dev/null +++ b/apps/admin/src/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from "react" +import { ChevronRight, MoreHorizontal } from "lucide-react" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return