🚀 Deploy ScanMenu - Production-ready SaaS web app for digital restaurant menus & QR ordering
Browse files- .gitignore +41 -0
- Dockerfile +42 -0
- README.md +36 -10
- eslint.config.mjs +18 -0
- next.config.ts +7 -0
- package-lock.json +0 -0
- package.json +52 -0
- postcss.config.mjs +7 -0
- public/file.svg +1 -0
- public/globe.svg +1 -0
- public/next.svg +1 -0
- public/vercel.svg +1 -0
- public/window.svg +1 -0
- src/app/(auth)/login/page.tsx +139 -0
- src/app/(auth)/register/page.tsx +148 -0
- src/app/(dashboard)/analytics/page.tsx +243 -0
- src/app/(dashboard)/billing/page.tsx +210 -0
- src/app/(dashboard)/menu-builder/page.tsx +308 -0
- src/app/(dashboard)/orders/page.tsx +339 -0
- src/app/(dashboard)/overview/page.tsx +244 -0
- src/app/(dashboard)/qr-manager/page.tsx +226 -0
- src/app/(dashboard)/restaurant-setup/page.tsx +200 -0
- src/app/(dashboard)/settings/page.tsx +171 -0
- src/app/(public)/restaurant/[slug]/page.tsx +436 -0
- src/app/favicon.ico +0 -0
- src/app/globals.css +65 -0
- src/app/layout.tsx +26 -0
- src/app/page.tsx +355 -0
- src/app/pricing/page.tsx +271 -0
- src/components/layout/dashboard-header.tsx +49 -0
- src/components/layout/dashboard-layout.tsx +16 -0
- src/components/layout/dashboard-sidebar.tsx +131 -0
- src/components/ui/badge.tsx +33 -0
- src/components/ui/button.tsx +55 -0
- src/components/ui/card.tsx +53 -0
- src/components/ui/empty-state.tsx +29 -0
- src/components/ui/input.tsx +21 -0
- src/components/ui/stat-card.tsx +40 -0
- src/components/ui/textarea.tsx +20 -0
- src/lib/demo-data.ts +227 -0
- src/lib/utils.ts +88 -0
- src/stores/cart-store.ts +116 -0
- src/types/database.ts +201 -0
- tsconfig.json +34 -0
.gitignore
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
+
|
| 13 |
+
# testing
|
| 14 |
+
/coverage
|
| 15 |
+
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
+
# production
|
| 21 |
+
/build
|
| 22 |
+
|
| 23 |
+
# misc
|
| 24 |
+
.DS_Store
|
| 25 |
+
*.pem
|
| 26 |
+
|
| 27 |
+
# debug
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
yarn-debug.log*
|
| 30 |
+
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# env files (can opt-in for committing if needed)
|
| 34 |
+
.env*
|
| 35 |
+
|
| 36 |
+
# vercel
|
| 37 |
+
.vercel
|
| 38 |
+
|
| 39 |
+
# typescript
|
| 40 |
+
*.tsbuildinfo
|
| 41 |
+
next-env.d.ts
|
Dockerfile
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:20-alpine AS base
|
| 2 |
+
|
| 3 |
+
# Install dependencies only when needed
|
| 4 |
+
FROM base AS deps
|
| 5 |
+
RUN apk add --no-cache libc6-compat
|
| 6 |
+
WORKDIR /app
|
| 7 |
+
|
| 8 |
+
COPY package.json package-lock.json* ./
|
| 9 |
+
RUN npm ci
|
| 10 |
+
|
| 11 |
+
# Rebuild the source code only when needed
|
| 12 |
+
FROM base AS builder
|
| 13 |
+
WORKDIR /app
|
| 14 |
+
COPY --from=deps /app/node_modules ./node_modules
|
| 15 |
+
COPY . .
|
| 16 |
+
|
| 17 |
+
ENV NEXT_TELEMETRY_DISABLED=1
|
| 18 |
+
RUN npm run build
|
| 19 |
+
|
| 20 |
+
# Production image, copy all the files and run next
|
| 21 |
+
FROM base AS runner
|
| 22 |
+
WORKDIR /app
|
| 23 |
+
|
| 24 |
+
ENV NODE_ENV=production
|
| 25 |
+
ENV NEXT_TELEMETRY_DISABLED=1
|
| 26 |
+
ENV PORT=7860
|
| 27 |
+
ENV HOSTNAME="0.0.0.0"
|
| 28 |
+
|
| 29 |
+
RUN addgroup --system --gid 1001 nodejs
|
| 30 |
+
RUN adduser --system --uid 1001 nextjs
|
| 31 |
+
|
| 32 |
+
COPY --from=builder /app/public ./public
|
| 33 |
+
|
| 34 |
+
# Automatically leverage output traces to reduce image size
|
| 35 |
+
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
| 36 |
+
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
| 37 |
+
|
| 38 |
+
USER nextjs
|
| 39 |
+
|
| 40 |
+
EXPOSE 7860
|
| 41 |
+
|
| 42 |
+
CMD ["node", "server.js"]
|
README.md
CHANGED
|
@@ -1,10 +1,36 @@
|
|
| 1 |
-
---
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
| 2 |
+
|
| 3 |
+
## Getting Started
|
| 4 |
+
|
| 5 |
+
First, run the development server:
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
npm run dev
|
| 9 |
+
# or
|
| 10 |
+
yarn dev
|
| 11 |
+
# or
|
| 12 |
+
pnpm dev
|
| 13 |
+
# or
|
| 14 |
+
bun dev
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
| 18 |
+
|
| 19 |
+
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
| 20 |
+
|
| 21 |
+
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
| 22 |
+
|
| 23 |
+
## Learn More
|
| 24 |
+
|
| 25 |
+
To learn more about Next.js, take a look at the following resources:
|
| 26 |
+
|
| 27 |
+
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
| 28 |
+
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
| 29 |
+
|
| 30 |
+
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
| 31 |
+
|
| 32 |
+
## Deploy on Vercel
|
| 33 |
+
|
| 34 |
+
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
| 35 |
+
|
| 36 |
+
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
eslint.config.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, globalIgnores } from "eslint/config";
|
| 2 |
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
| 3 |
+
import nextTs from "eslint-config-next/typescript";
|
| 4 |
+
|
| 5 |
+
const eslintConfig = defineConfig([
|
| 6 |
+
...nextVitals,
|
| 7 |
+
...nextTs,
|
| 8 |
+
// Override default ignores of eslint-config-next.
|
| 9 |
+
globalIgnores([
|
| 10 |
+
// Default ignores of eslint-config-next:
|
| 11 |
+
".next/**",
|
| 12 |
+
"out/**",
|
| 13 |
+
"build/**",
|
| 14 |
+
"next-env.d.ts",
|
| 15 |
+
]),
|
| 16 |
+
]);
|
| 17 |
+
|
| 18 |
+
export default eslintConfig;
|
next.config.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from "next";
|
| 2 |
+
|
| 3 |
+
const nextConfig: NextConfig = {
|
| 4 |
+
output: "standalone",
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default nextConfig;
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "scanmenu",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start",
|
| 9 |
+
"lint": "eslint"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"@radix-ui/react-avatar": "^1.1.11",
|
| 13 |
+
"@radix-ui/react-dialog": "^1.1.15",
|
| 14 |
+
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
| 15 |
+
"@radix-ui/react-label": "^2.1.8",
|
| 16 |
+
"@radix-ui/react-popover": "^1.1.15",
|
| 17 |
+
"@radix-ui/react-select": "^2.2.6",
|
| 18 |
+
"@radix-ui/react-separator": "^1.1.8",
|
| 19 |
+
"@radix-ui/react-slot": "^1.2.4",
|
| 20 |
+
"@radix-ui/react-switch": "^1.2.6",
|
| 21 |
+
"@radix-ui/react-tabs": "^1.1.13",
|
| 22 |
+
"@radix-ui/react-tooltip": "^1.2.8",
|
| 23 |
+
"@stripe/stripe-js": "^9.1.0",
|
| 24 |
+
"@supabase/ssr": "^0.10.2",
|
| 25 |
+
"@supabase/supabase-js": "^2.103.0",
|
| 26 |
+
"class-variance-authority": "^0.7.1",
|
| 27 |
+
"clsx": "^2.1.1",
|
| 28 |
+
"date-fns": "^4.1.0",
|
| 29 |
+
"framer-motion": "^12.38.0",
|
| 30 |
+
"lucide-react": "^1.8.0",
|
| 31 |
+
"next": "16.2.3",
|
| 32 |
+
"qrcode": "^1.5.4",
|
| 33 |
+
"react": "19.2.4",
|
| 34 |
+
"react-dom": "19.2.4",
|
| 35 |
+
"react-hot-toast": "^2.6.0",
|
| 36 |
+
"recharts": "^3.8.1",
|
| 37 |
+
"stripe": "^22.0.1",
|
| 38 |
+
"tailwind-merge": "^3.5.0",
|
| 39 |
+
"zustand": "^5.0.12"
|
| 40 |
+
},
|
| 41 |
+
"devDependencies": {
|
| 42 |
+
"@tailwindcss/postcss": "^4",
|
| 43 |
+
"@types/node": "^20",
|
| 44 |
+
"@types/qrcode": "^1.5.6",
|
| 45 |
+
"@types/react": "^19",
|
| 46 |
+
"@types/react-dom": "^19",
|
| 47 |
+
"eslint": "^9",
|
| 48 |
+
"eslint-config-next": "16.2.3",
|
| 49 |
+
"tailwindcss": "^4",
|
| 50 |
+
"typescript": "^5"
|
| 51 |
+
}
|
| 52 |
+
}
|
postcss.config.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const config = {
|
| 2 |
+
plugins: {
|
| 3 |
+
"@tailwindcss/postcss": {},
|
| 4 |
+
},
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default config;
|
public/file.svg
ADDED
|
|
public/globe.svg
ADDED
|
|
public/next.svg
ADDED
|
|
public/vercel.svg
ADDED
|
|
public/window.svg
ADDED
|
|
src/app/(auth)/login/page.tsx
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import Link from 'next/link';
|
| 4 |
+
import { useState } from 'react';
|
| 5 |
+
import { Button } from '@/components/ui/button';
|
| 6 |
+
import { Input } from '@/components/ui/input';
|
| 7 |
+
import { Sparkles, Mail, Lock, ArrowRight, Globe } from 'lucide-react';
|
| 8 |
+
import { useRouter } from 'next/navigation';
|
| 9 |
+
|
| 10 |
+
export default function LoginPage() {
|
| 11 |
+
const [email, setEmail] = useState('demo@scanmenu.app');
|
| 12 |
+
const [password, setPassword] = useState('demo1234');
|
| 13 |
+
const [loading, setLoading] = useState(false);
|
| 14 |
+
const router = useRouter();
|
| 15 |
+
|
| 16 |
+
const handleLogin = (e: React.FormEvent) => {
|
| 17 |
+
e.preventDefault();
|
| 18 |
+
setLoading(true);
|
| 19 |
+
setTimeout(() => {
|
| 20 |
+
router.push('/dashboard/overview');
|
| 21 |
+
}, 800);
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
return (
|
| 25 |
+
<div className="flex min-h-screen">
|
| 26 |
+
{/* Left Panel — Branding */}
|
| 27 |
+
<div className="hidden w-1/2 flex-col justify-between bg-zinc-950 p-12 lg:flex">
|
| 28 |
+
<div className="flex items-center gap-2.5">
|
| 29 |
+
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-emerald-500 to-cyan-500">
|
| 30 |
+
<Sparkles className="h-5 w-5 text-white" />
|
| 31 |
+
</div>
|
| 32 |
+
<span className="text-xl font-bold text-white">
|
| 33 |
+
Scan<span className="text-emerald-400">Menu</span>
|
| 34 |
+
</span>
|
| 35 |
+
</div>
|
| 36 |
+
|
| 37 |
+
<div className="space-y-6">
|
| 38 |
+
<blockquote className="space-y-3">
|
| 39 |
+
<p className="text-2xl font-medium leading-relaxed text-white/90">
|
| 40 |
+
“ScanMenu transformed our restaurant. Orders are faster, errors dropped to zero, and our revenue increased 32% in just 3 months.”
|
| 41 |
+
</p>
|
| 42 |
+
<footer className="flex items-center gap-3">
|
| 43 |
+
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-violet-500 to-fuchsia-500" />
|
| 44 |
+
<div>
|
| 45 |
+
<p className="text-sm font-semibold text-white">Maria Santos</p>
|
| 46 |
+
<p className="text-sm text-zinc-400">Owner, Bella Cucina</p>
|
| 47 |
+
</div>
|
| 48 |
+
</footer>
|
| 49 |
+
</blockquote>
|
| 50 |
+
</div>
|
| 51 |
+
|
| 52 |
+
<p className="text-sm text-zinc-500">© 2025 ScanMenu. All rights reserved.</p>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
{/* Right Panel — Form */}
|
| 56 |
+
<div className="flex w-full items-center justify-center px-4 lg:w-1/2">
|
| 57 |
+
<div className="w-full max-w-[400px] space-y-8">
|
| 58 |
+
<div className="lg:hidden flex items-center gap-2.5 justify-center mb-6">
|
| 59 |
+
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-emerald-500 to-cyan-500">
|
| 60 |
+
<Sparkles className="h-5 w-5 text-white" />
|
| 61 |
+
</div>
|
| 62 |
+
<span className="text-xl font-bold text-zinc-900">
|
| 63 |
+
Scan<span className="text-emerald-600">Menu</span>
|
| 64 |
+
</span>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
<div className="text-center lg:text-left">
|
| 68 |
+
<h1 className="text-2xl font-bold tracking-tight text-zinc-900">Welcome back</h1>
|
| 69 |
+
<p className="mt-2 text-sm text-zinc-500">Sign in to your account to continue</p>
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
<form onSubmit={handleLogin} className="space-y-4">
|
| 73 |
+
<div className="space-y-2">
|
| 74 |
+
<label className="text-sm font-medium text-zinc-700">Email</label>
|
| 75 |
+
<div className="relative">
|
| 76 |
+
<Mail className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
|
| 77 |
+
<Input
|
| 78 |
+
type="email"
|
| 79 |
+
placeholder="name@company.com"
|
| 80 |
+
value={email}
|
| 81 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 82 |
+
className="pl-10"
|
| 83 |
+
/>
|
| 84 |
+
</div>
|
| 85 |
+
</div>
|
| 86 |
+
|
| 87 |
+
<div className="space-y-2">
|
| 88 |
+
<div className="flex items-center justify-between">
|
| 89 |
+
<label className="text-sm font-medium text-zinc-700">Password</label>
|
| 90 |
+
<button type="button" className="text-xs font-medium text-emerald-600 hover:text-emerald-500">
|
| 91 |
+
Forgot password?
|
| 92 |
+
</button>
|
| 93 |
+
</div>
|
| 94 |
+
<div className="relative">
|
| 95 |
+
<Lock className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
|
| 96 |
+
<Input
|
| 97 |
+
type="password"
|
| 98 |
+
placeholder="••••••••"
|
| 99 |
+
value={password}
|
| 100 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 101 |
+
className="pl-10"
|
| 102 |
+
/>
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
<Button type="submit" variant="primary" size="lg" className="w-full" disabled={loading}>
|
| 107 |
+
{loading ? (
|
| 108 |
+
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
| 109 |
+
) : (
|
| 110 |
+
<>Sign in <ArrowRight className="h-4 w-4" /></>
|
| 111 |
+
)}
|
| 112 |
+
</Button>
|
| 113 |
+
</form>
|
| 114 |
+
|
| 115 |
+
<div className="relative">
|
| 116 |
+
<div className="absolute inset-0 flex items-center">
|
| 117 |
+
<div className="w-full border-t border-zinc-200" />
|
| 118 |
+
</div>
|
| 119 |
+
<div className="relative flex justify-center text-xs">
|
| 120 |
+
<span className="bg-white px-4 text-zinc-400">or continue with</span>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
+
<Button variant="outline" size="lg" className="w-full">
|
| 125 |
+
<Globe className="h-4 w-4" />
|
| 126 |
+
Google
|
| 127 |
+
</Button>
|
| 128 |
+
|
| 129 |
+
<p className="text-center text-sm text-zinc-500">
|
| 130 |
+
Don't have an account?{' '}
|
| 131 |
+
<Link href="/register" className="font-semibold text-emerald-600 hover:text-emerald-500">
|
| 132 |
+
Sign up free
|
| 133 |
+
</Link>
|
| 134 |
+
</p>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
);
|
| 139 |
+
}
|
src/app/(auth)/register/page.tsx
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import Link from 'next/link';
|
| 4 |
+
import { useState } from 'react';
|
| 5 |
+
import { Button } from '@/components/ui/button';
|
| 6 |
+
import { Input } from '@/components/ui/input';
|
| 7 |
+
import { Sparkles, Mail, Lock, ArrowRight, Globe, User, Store } from 'lucide-react';
|
| 8 |
+
import { useRouter } from 'next/navigation';
|
| 9 |
+
|
| 10 |
+
export default function RegisterPage() {
|
| 11 |
+
const [loading, setLoading] = useState(false);
|
| 12 |
+
const router = useRouter();
|
| 13 |
+
|
| 14 |
+
const handleRegister = (e: React.FormEvent) => {
|
| 15 |
+
e.preventDefault();
|
| 16 |
+
setLoading(true);
|
| 17 |
+
setTimeout(() => {
|
| 18 |
+
router.push('/dashboard/overview');
|
| 19 |
+
}, 800);
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
return (
|
| 23 |
+
<div className="flex min-h-screen">
|
| 24 |
+
{/* Left Panel */}
|
| 25 |
+
<div className="hidden w-1/2 flex-col justify-between bg-zinc-950 p-12 lg:flex">
|
| 26 |
+
<div className="flex items-center gap-2.5">
|
| 27 |
+
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-emerald-500 to-cyan-500">
|
| 28 |
+
<Sparkles className="h-5 w-5 text-white" />
|
| 29 |
+
</div>
|
| 30 |
+
<span className="text-xl font-bold text-white">
|
| 31 |
+
Scan<span className="text-emerald-400">Menu</span>
|
| 32 |
+
</span>
|
| 33 |
+
</div>
|
| 34 |
+
|
| 35 |
+
<div className="space-y-8">
|
| 36 |
+
<h2 className="text-3xl font-bold leading-tight text-white">
|
| 37 |
+
Launch your digital menu in under 5 minutes
|
| 38 |
+
</h2>
|
| 39 |
+
<div className="space-y-4">
|
| 40 |
+
{[
|
| 41 |
+
'Create your restaurant profile',
|
| 42 |
+
'Build beautiful menus with drag & drop',
|
| 43 |
+
'Generate QR codes for every table',
|
| 44 |
+
'Start receiving orders instantly',
|
| 45 |
+
].map((step, i) => (
|
| 46 |
+
<div key={i} className="flex items-center gap-3">
|
| 47 |
+
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500/20 text-sm font-bold text-emerald-400">
|
| 48 |
+
{i + 1}
|
| 49 |
+
</div>
|
| 50 |
+
<p className="text-sm text-zinc-300">{step}</p>
|
| 51 |
+
</div>
|
| 52 |
+
))}
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
|
| 56 |
+
<p className="text-sm text-zinc-500">© 2025 ScanMenu. All rights reserved.</p>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
{/* Right Panel */}
|
| 60 |
+
<div className="flex w-full items-center justify-center px-4 lg:w-1/2">
|
| 61 |
+
<div className="w-full max-w-[400px] space-y-8">
|
| 62 |
+
<div className="lg:hidden flex items-center gap-2.5 justify-center mb-6">
|
| 63 |
+
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-emerald-500 to-cyan-500">
|
| 64 |
+
<Sparkles className="h-5 w-5 text-white" />
|
| 65 |
+
</div>
|
| 66 |
+
<span className="text-xl font-bold text-zinc-900">
|
| 67 |
+
Scan<span className="text-emerald-600">Menu</span>
|
| 68 |
+
</span>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
<div className="text-center lg:text-left">
|
| 72 |
+
<h1 className="text-2xl font-bold tracking-tight text-zinc-900">Create your account</h1>
|
| 73 |
+
<p className="mt-2 text-sm text-zinc-500">Start your 14-day free trial. No credit card required.</p>
|
| 74 |
+
</div>
|
| 75 |
+
|
| 76 |
+
<form onSubmit={handleRegister} className="space-y-4">
|
| 77 |
+
<div className="grid grid-cols-2 gap-3">
|
| 78 |
+
<div className="space-y-2">
|
| 79 |
+
<label className="text-sm font-medium text-zinc-700">First Name</label>
|
| 80 |
+
<div className="relative">
|
| 81 |
+
<User className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
|
| 82 |
+
<Input placeholder="Alex" className="pl-10" />
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
<div className="space-y-2">
|
| 86 |
+
<label className="text-sm font-medium text-zinc-700">Last Name</label>
|
| 87 |
+
<Input placeholder="Rivera" />
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
<div className="space-y-2">
|
| 92 |
+
<label className="text-sm font-medium text-zinc-700">Restaurant Name</label>
|
| 93 |
+
<div className="relative">
|
| 94 |
+
<Store className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
|
| 95 |
+
<Input placeholder="The Garden Kitchen" className="pl-10" />
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
|
| 99 |
+
<div className="space-y-2">
|
| 100 |
+
<label className="text-sm font-medium text-zinc-700">Email</label>
|
| 101 |
+
<div className="relative">
|
| 102 |
+
<Mail className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
|
| 103 |
+
<Input type="email" placeholder="name@company.com" className="pl-10" />
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
<div className="space-y-2">
|
| 108 |
+
<label className="text-sm font-medium text-zinc-700">Password</label>
|
| 109 |
+
<div className="relative">
|
| 110 |
+
<Lock className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
|
| 111 |
+
<Input type="password" placeholder="Min. 8 characters" className="pl-10" />
|
| 112 |
+
</div>
|
| 113 |
+
</div>
|
| 114 |
+
|
| 115 |
+
<Button type="submit" variant="primary" size="lg" className="w-full" disabled={loading}>
|
| 116 |
+
{loading ? (
|
| 117 |
+
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
| 118 |
+
) : (
|
| 119 |
+
<>Create Account <ArrowRight className="h-4 w-4" /></>
|
| 120 |
+
)}
|
| 121 |
+
</Button>
|
| 122 |
+
</form>
|
| 123 |
+
|
| 124 |
+
<div className="relative">
|
| 125 |
+
<div className="absolute inset-0 flex items-center">
|
| 126 |
+
<div className="w-full border-t border-zinc-200" />
|
| 127 |
+
</div>
|
| 128 |
+
<div className="relative flex justify-center text-xs">
|
| 129 |
+
<span className="bg-white px-4 text-zinc-400">or continue with</span>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
|
| 133 |
+
<Button variant="outline" size="lg" className="w-full">
|
| 134 |
+
<Globe className="h-4 w-4" />
|
| 135 |
+
Google
|
| 136 |
+
</Button>
|
| 137 |
+
|
| 138 |
+
<p className="text-center text-sm text-zinc-500">
|
| 139 |
+
Already have an account?{' '}
|
| 140 |
+
<Link href="/login" className="font-semibold text-emerald-600 hover:text-emerald-500">
|
| 141 |
+
Sign in
|
| 142 |
+
</Link>
|
| 143 |
+
</p>
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
</div>
|
| 147 |
+
);
|
| 148 |
+
}
|
src/app/(dashboard)/analytics/page.tsx
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
| 4 |
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
| 5 |
+
import { StatCard } from '@/components/ui/stat-card';
|
| 6 |
+
import { Badge } from '@/components/ui/badge';
|
| 7 |
+
import { Button } from '@/components/ui/button';
|
| 8 |
+
import {
|
| 9 |
+
DollarSign,
|
| 10 |
+
ShoppingBag,
|
| 11 |
+
TrendingUp,
|
| 12 |
+
Users,
|
| 13 |
+
ArrowUpRight,
|
| 14 |
+
ArrowDownRight,
|
| 15 |
+
QrCode,
|
| 16 |
+
Calendar,
|
| 17 |
+
Download,
|
| 18 |
+
} from 'lucide-react';
|
| 19 |
+
import { generateDailyRevenue, generatePopularItems, generateOrdersByType, generateHourlyOrders } from '@/lib/demo-data';
|
| 20 |
+
import { formatCurrency, cn } from '@/lib/utils';
|
| 21 |
+
import { useState } from 'react';
|
| 22 |
+
|
| 23 |
+
const revenueData = generateDailyRevenue(30);
|
| 24 |
+
const popularItems = generatePopularItems();
|
| 25 |
+
const ordersByType = generateOrdersByType();
|
| 26 |
+
const hourlyOrders = generateHourlyOrders();
|
| 27 |
+
|
| 28 |
+
function BarChart({ data, dataKey, maxHeight = 120 }: { data: { [key: string]: string | number }[]; dataKey: string; maxHeight?: number }) {
|
| 29 |
+
const values = data.map((d) => Number(d[dataKey]));
|
| 30 |
+
const max = Math.max(...values);
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<div className="flex items-end gap-[3px]" style={{ height: maxHeight }}>
|
| 34 |
+
{data.map((d, i) => (
|
| 35 |
+
<div
|
| 36 |
+
key={i}
|
| 37 |
+
className="group relative flex-1 rounded-t-sm bg-emerald-500/80 transition-all hover:bg-emerald-500"
|
| 38 |
+
style={{ height: `${(Number(d[dataKey]) / max) * 100}%` }}
|
| 39 |
+
>
|
| 40 |
+
<div className="absolute -top-8 left-1/2 -translate-x-1/2 hidden rounded-md bg-zinc-900 px-2 py-1 text-[10px] font-medium text-white shadow-lg group-hover:block whitespace-nowrap">
|
| 41 |
+
{typeof d[dataKey] === 'number' && dataKey === 'revenue' ? formatCurrency(Number(d[dataKey])) : d[dataKey]}
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
))}
|
| 45 |
+
</div>
|
| 46 |
+
);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
function HeatmapRow({ label, values, max }: { label: string; values: number[]; max: number }) {
|
| 50 |
+
return (
|
| 51 |
+
<div className="flex items-center gap-1">
|
| 52 |
+
<span className="w-10 text-[10px] text-zinc-400 shrink-0">{label}</span>
|
| 53 |
+
{values.map((v, i) => (
|
| 54 |
+
<div
|
| 55 |
+
key={i}
|
| 56 |
+
className="h-6 flex-1 rounded-sm transition-all hover:ring-1 hover:ring-emerald-500"
|
| 57 |
+
style={{
|
| 58 |
+
backgroundColor: `rgba(16, 185, 129, ${v / max})`,
|
| 59 |
+
}}
|
| 60 |
+
title={`${v} orders`}
|
| 61 |
+
/>
|
| 62 |
+
))}
|
| 63 |
+
</div>
|
| 64 |
+
);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
export default function AnalyticsPage() {
|
| 68 |
+
const [period, setPeriod] = useState('30d');
|
| 69 |
+
const totalRevenue = revenueData.reduce((s, d) => s + d.revenue, 0);
|
| 70 |
+
const totalOrders = revenueData.reduce((s, d) => s + d.orders, 0);
|
| 71 |
+
const avgOrder = totalRevenue / totalOrders;
|
| 72 |
+
|
| 73 |
+
return (
|
| 74 |
+
<DashboardLayout>
|
| 75 |
+
<div className="space-y-6">
|
| 76 |
+
{/* Header */}
|
| 77 |
+
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
| 78 |
+
<div>
|
| 79 |
+
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white sm:text-3xl">Analytics</h1>
|
| 80 |
+
<p className="mt-1 text-sm text-zinc-500">Track performance, revenue, and customer insights.</p>
|
| 81 |
+
</div>
|
| 82 |
+
<div className="flex items-center gap-2">
|
| 83 |
+
<div className="flex gap-1 rounded-xl border border-zinc-200 p-1 dark:border-zinc-700">
|
| 84 |
+
{['7d', '14d', '30d', '90d'].map((p) => (
|
| 85 |
+
<button
|
| 86 |
+
key={p}
|
| 87 |
+
onClick={() => setPeriod(p)}
|
| 88 |
+
className={cn(
|
| 89 |
+
'rounded-lg px-3 py-1.5 text-xs font-medium transition-all',
|
| 90 |
+
period === p ? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900' : 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'
|
| 91 |
+
)}
|
| 92 |
+
>
|
| 93 |
+
{p}
|
| 94 |
+
</button>
|
| 95 |
+
))}
|
| 96 |
+
</div>
|
| 97 |
+
<Button variant="outline" size="sm">
|
| 98 |
+
<Download className="h-4 w-4" /> Export
|
| 99 |
+
</Button>
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
|
| 103 |
+
{/* Stats */}
|
| 104 |
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
| 105 |
+
<StatCard title="Total Revenue" value={formatCurrency(totalRevenue)} change="+12.5% vs prev. period" changeType="positive" icon={DollarSign} iconColor="text-emerald-600" />
|
| 106 |
+
<StatCard title="Total Orders" value={totalOrders.toString()} change="+8.2% vs prev. period" changeType="positive" icon={ShoppingBag} iconColor="text-blue-600" />
|
| 107 |
+
<StatCard title="Avg. Order Value" value={formatCurrency(avgOrder)} change="+3.1% vs prev. period" changeType="positive" icon={TrendingUp} iconColor="text-violet-600" />
|
| 108 |
+
<StatCard title="QR Scans" value="2,011" change="+24.3% vs prev. period" changeType="positive" icon={QrCode} iconColor="text-amber-600" />
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
{/* Revenue Chart */}
|
| 112 |
+
<Card>
|
| 113 |
+
<CardHeader className="flex flex-row items-center justify-between">
|
| 114 |
+
<div>
|
| 115 |
+
<CardTitle>Revenue Over Time</CardTitle>
|
| 116 |
+
<p className="mt-1 text-sm text-zinc-500">Daily revenue for the last 30 days</p>
|
| 117 |
+
</div>
|
| 118 |
+
<div className="flex items-baseline gap-3">
|
| 119 |
+
<span className="text-2xl font-bold">{formatCurrency(totalRevenue)}</span>
|
| 120 |
+
<Badge variant="success" className="gap-1">
|
| 121 |
+
<ArrowUpRight className="h-3 w-3" /> 12.5%
|
| 122 |
+
</Badge>
|
| 123 |
+
</div>
|
| 124 |
+
</CardHeader>
|
| 125 |
+
<CardContent>
|
| 126 |
+
<BarChart data={revenueData} dataKey="revenue" maxHeight={160} />
|
| 127 |
+
<div className="mt-2 flex justify-between text-[10px] text-zinc-400">
|
| 128 |
+
{revenueData.filter((_, i) => i % 5 === 0).map((d, i) => (
|
| 129 |
+
<span key={i}>{new Date(d.date).toLocaleDateString('en', { month: 'short', day: 'numeric' })}</span>
|
| 130 |
+
))}
|
| 131 |
+
</div>
|
| 132 |
+
</CardContent>
|
| 133 |
+
</Card>
|
| 134 |
+
|
| 135 |
+
<div className="grid gap-6 lg:grid-cols-2">
|
| 136 |
+
{/* Popular Items */}
|
| 137 |
+
<Card>
|
| 138 |
+
<CardHeader>
|
| 139 |
+
<CardTitle>Top Selling Items</CardTitle>
|
| 140 |
+
</CardHeader>
|
| 141 |
+
<CardContent>
|
| 142 |
+
<div className="space-y-3">
|
| 143 |
+
{popularItems.map((item, i) => {
|
| 144 |
+
const maxOrders = popularItems[0].orders;
|
| 145 |
+
return (
|
| 146 |
+
<div key={item.name} className="flex items-center gap-3">
|
| 147 |
+
<span className="w-6 text-center text-sm font-bold text-zinc-400">
|
| 148 |
+
{i + 1}
|
| 149 |
+
</span>
|
| 150 |
+
<div className="flex-1 min-w-0">
|
| 151 |
+
<div className="flex items-center justify-between">
|
| 152 |
+
<p className="truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">{item.name}</p>
|
| 153 |
+
<p className="ml-2 shrink-0 text-sm font-semibold text-zinc-900 dark:text-zinc-100">{formatCurrency(item.revenue)}</p>
|
| 154 |
+
</div>
|
| 155 |
+
<div className="mt-1.5 flex items-center gap-2">
|
| 156 |
+
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
|
| 157 |
+
<div
|
| 158 |
+
className="h-full rounded-full bg-gradient-to-r from-emerald-500 to-cyan-500"
|
| 159 |
+
style={{ width: `${(item.orders / maxOrders) * 100}%` }}
|
| 160 |
+
/>
|
| 161 |
+
</div>
|
| 162 |
+
<span className="text-xs text-zinc-400">{item.orders}</span>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
</div>
|
| 166 |
+
);
|
| 167 |
+
})}
|
| 168 |
+
</div>
|
| 169 |
+
</CardContent>
|
| 170 |
+
</Card>
|
| 171 |
+
|
| 172 |
+
{/* Peak Hours */}
|
| 173 |
+
<Card>
|
| 174 |
+
<CardHeader>
|
| 175 |
+
<CardTitle>Peak Hours</CardTitle>
|
| 176 |
+
</CardHeader>
|
| 177 |
+
<CardContent>
|
| 178 |
+
<BarChart data={hourlyOrders} dataKey="orders" maxHeight={140} />
|
| 179 |
+
<div className="mt-2 flex justify-between text-[10px] text-zinc-400">
|
| 180 |
+
{hourlyOrders.map((h, i) => (
|
| 181 |
+
i % 2 === 0 ? <span key={i}>{h.hour}</span> : <span key={i} />
|
| 182 |
+
))}
|
| 183 |
+
</div>
|
| 184 |
+
<div className="mt-4 grid grid-cols-2 gap-3">
|
| 185 |
+
<div className="rounded-xl bg-emerald-50 p-3 dark:bg-emerald-950/30">
|
| 186 |
+
<p className="text-xs text-emerald-600">Lunch Peak</p>
|
| 187 |
+
<p className="text-lg font-bold text-emerald-700 dark:text-emerald-400">12:00 – 14:00</p>
|
| 188 |
+
</div>
|
| 189 |
+
<div className="rounded-xl bg-violet-50 p-3 dark:bg-violet-950/30">
|
| 190 |
+
<p className="text-xs text-violet-600">Dinner Peak</p>
|
| 191 |
+
<p className="text-lg font-bold text-violet-700 dark:text-violet-400">18:00 – 21:00</p>
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
</CardContent>
|
| 195 |
+
</Card>
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
{/* Order Distribution */}
|
| 199 |
+
<div className="grid gap-6 lg:grid-cols-3">
|
| 200 |
+
{ordersByType.map((item) => (
|
| 201 |
+
<Card key={item.type}>
|
| 202 |
+
<CardContent className="pt-6">
|
| 203 |
+
<div className="flex items-center justify-between">
|
| 204 |
+
<div>
|
| 205 |
+
<p className="text-sm text-zinc-500">{item.type}</p>
|
| 206 |
+
<p className="mt-1 text-3xl font-bold">{item.count}</p>
|
| 207 |
+
<p className="mt-0.5 text-xs text-zinc-500">{item.percentage}% of total</p>
|
| 208 |
+
</div>
|
| 209 |
+
<div className="relative h-20 w-20">
|
| 210 |
+
<svg className="h-20 w-20 -rotate-90" viewBox="0 0 36 36">
|
| 211 |
+
<path
|
| 212 |
+
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
| 213 |
+
fill="none"
|
| 214 |
+
stroke="currentColor"
|
| 215 |
+
strokeWidth="3"
|
| 216 |
+
className="text-zinc-100 dark:text-zinc-800"
|
| 217 |
+
/>
|
| 218 |
+
<path
|
| 219 |
+
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
| 220 |
+
fill="none"
|
| 221 |
+
stroke="currentColor"
|
| 222 |
+
strokeWidth="3"
|
| 223 |
+
strokeDasharray={`${item.percentage}, 100`}
|
| 224 |
+
className={cn(
|
| 225 |
+
item.type === 'Dine In' && 'text-emerald-500',
|
| 226 |
+
item.type === 'Takeaway' && 'text-blue-500',
|
| 227 |
+
item.type === 'Delivery' && 'text-violet-500'
|
| 228 |
+
)}
|
| 229 |
+
/>
|
| 230 |
+
</svg>
|
| 231 |
+
<div className="absolute inset-0 flex items-center justify-center text-sm font-bold">
|
| 232 |
+
{item.percentage}%
|
| 233 |
+
</div>
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
</CardContent>
|
| 237 |
+
</Card>
|
| 238 |
+
))}
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
+
</DashboardLayout>
|
| 242 |
+
);
|
| 243 |
+
}
|
src/app/(dashboard)/billing/page.tsx
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
| 4 |
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
| 5 |
+
import { Button } from '@/components/ui/button';
|
| 6 |
+
import { Badge } from '@/components/ui/badge';
|
| 7 |
+
import { Check, Sparkles, CreditCard, Download, ArrowRight, Zap, Crown, Building2 } from 'lucide-react';
|
| 8 |
+
import { cn } from '@/lib/utils';
|
| 9 |
+
import { useState } from 'react';
|
| 10 |
+
|
| 11 |
+
const plans = [
|
| 12 |
+
{
|
| 13 |
+
name: 'Starter',
|
| 14 |
+
price: 29,
|
| 15 |
+
yearlyPrice: 24,
|
| 16 |
+
description: 'Perfect for small restaurants just getting started.',
|
| 17 |
+
icon: Zap,
|
| 18 |
+
features: ['1 restaurant', 'Up to 50 menu items', '5 QR codes', 'Basic analytics', 'Email support', '100 orders/month'],
|
| 19 |
+
popular: false,
|
| 20 |
+
current: false,
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
name: 'Pro',
|
| 24 |
+
price: 79,
|
| 25 |
+
yearlyPrice: 66,
|
| 26 |
+
description: 'For growing restaurants with advanced needs.',
|
| 27 |
+
icon: Crown,
|
| 28 |
+
features: ['Unlimited restaurants', 'Unlimited menu items', 'Unlimited QR codes', 'Advanced analytics', 'Priority support', 'Unlimited orders', 'Custom branding', 'API access', 'Team members'],
|
| 29 |
+
popular: true,
|
| 30 |
+
current: true,
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
name: 'Enterprise',
|
| 34 |
+
price: 199,
|
| 35 |
+
yearlyPrice: 166,
|
| 36 |
+
description: 'For multi-location chains and franchises.',
|
| 37 |
+
icon: Building2,
|
| 38 |
+
features: ['Everything in Pro', 'Multi-location support', 'Dedicated account manager', 'Custom integrations', 'SLA guarantee', 'White-label solution', 'Advanced security', 'Onboarding training'],
|
| 39 |
+
popular: false,
|
| 40 |
+
current: false,
|
| 41 |
+
},
|
| 42 |
+
];
|
| 43 |
+
|
| 44 |
+
const invoices = [
|
| 45 |
+
{ id: 'INV-2024-003', date: 'Mar 1, 2025', amount: 79, status: 'paid' },
|
| 46 |
+
{ id: 'INV-2024-002', date: 'Feb 1, 2025', amount: 79, status: 'paid' },
|
| 47 |
+
{ id: 'INV-2024-001', date: 'Jan 1, 2025', amount: 79, status: 'paid' },
|
| 48 |
+
];
|
| 49 |
+
|
| 50 |
+
export default function BillingPage() {
|
| 51 |
+
const [annual, setAnnual] = useState(false);
|
| 52 |
+
|
| 53 |
+
return (
|
| 54 |
+
<DashboardLayout>
|
| 55 |
+
<div className="space-y-8">
|
| 56 |
+
{/* Header */}
|
| 57 |
+
<div>
|
| 58 |
+
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white sm:text-3xl">Billing & Subscription</h1>
|
| 59 |
+
<p className="mt-1 text-sm text-zinc-500">Manage your plan, payment methods, and invoices.</p>
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
{/* Current Plan Banner */}
|
| 63 |
+
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-r from-emerald-600 via-emerald-500 to-cyan-500 p-6 text-white shadow-lg sm:p-8">
|
| 64 |
+
<div className="relative z-10">
|
| 65 |
+
<Badge className="bg-white/20 text-white border-white/30 mb-3">Current Plan</Badge>
|
| 66 |
+
<h2 className="text-2xl font-bold">Pro Plan</h2>
|
| 67 |
+
<p className="mt-1 text-emerald-100">Your next billing date is April 1, 2025</p>
|
| 68 |
+
<div className="mt-4 flex items-baseline gap-1">
|
| 69 |
+
<span className="text-4xl font-bold">$79</span>
|
| 70 |
+
<span className="text-emerald-200">/month</span>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
<div className="absolute -right-8 -top-8 h-40 w-40 rounded-full bg-white/10" />
|
| 74 |
+
<div className="absolute -bottom-12 -right-12 h-48 w-48 rounded-full bg-white/5" />
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
{/* Billing Toggle */}
|
| 78 |
+
<div className="flex items-center justify-center gap-3">
|
| 79 |
+
<span className={cn('text-sm font-medium', !annual ? 'text-zinc-900' : 'text-zinc-500')}>Monthly</span>
|
| 80 |
+
<button
|
| 81 |
+
onClick={() => setAnnual(!annual)}
|
| 82 |
+
className={cn(
|
| 83 |
+
'relative h-6 w-11 rounded-full transition-colors',
|
| 84 |
+
annual ? 'bg-emerald-500' : 'bg-zinc-300 dark:bg-zinc-600'
|
| 85 |
+
)}
|
| 86 |
+
>
|
| 87 |
+
<span className={cn(
|
| 88 |
+
'absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform',
|
| 89 |
+
annual && 'translate-x-5'
|
| 90 |
+
)} />
|
| 91 |
+
</button>
|
| 92 |
+
<span className={cn('text-sm font-medium', annual ? 'text-zinc-900' : 'text-zinc-500')}>
|
| 93 |
+
Annual <Badge variant="success" className="ml-1 text-[10px]">Save 17%</Badge>
|
| 94 |
+
</span>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
{/* Plans */}
|
| 98 |
+
<div className="grid gap-6 lg:grid-cols-3">
|
| 99 |
+
{plans.map((plan) => (
|
| 100 |
+
<Card key={plan.name} className={cn(
|
| 101 |
+
'relative overflow-hidden transition-all hover:shadow-lg',
|
| 102 |
+
plan.popular && 'border-emerald-500 shadow-md ring-1 ring-emerald-500/20',
|
| 103 |
+
plan.current && 'border-emerald-500'
|
| 104 |
+
)}>
|
| 105 |
+
{plan.popular && (
|
| 106 |
+
<div className="absolute -right-8 top-6 rotate-45 bg-emerald-500 px-10 py-1 text-[10px] font-bold uppercase tracking-wider text-white shadow-sm">
|
| 107 |
+
Popular
|
| 108 |
+
</div>
|
| 109 |
+
)}
|
| 110 |
+
<CardContent className="pt-6 space-y-6">
|
| 111 |
+
<div>
|
| 112 |
+
<div className="flex items-center gap-2">
|
| 113 |
+
<div className={cn(
|
| 114 |
+
'flex h-10 w-10 items-center justify-center rounded-xl',
|
| 115 |
+
plan.popular ? 'bg-emerald-100 text-emerald-600' : 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800'
|
| 116 |
+
)}>
|
| 117 |
+
<plan.icon className="h-5 w-5" />
|
| 118 |
+
</div>
|
| 119 |
+
<div>
|
| 120 |
+
<h3 className="text-lg font-bold text-zinc-900 dark:text-zinc-100">{plan.name}</h3>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
<p className="mt-2 text-sm text-zinc-500">{plan.description}</p>
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
<div className="flex items-baseline gap-1">
|
| 127 |
+
<span className="text-4xl font-bold text-zinc-900 dark:text-zinc-100">
|
| 128 |
+
${annual ? plan.yearlyPrice : plan.price}
|
| 129 |
+
</span>
|
| 130 |
+
<span className="text-zinc-500">/month</span>
|
| 131 |
+
</div>
|
| 132 |
+
|
| 133 |
+
<Button
|
| 134 |
+
variant={plan.current ? 'outline' : plan.popular ? 'primary' : 'default'}
|
| 135 |
+
className="w-full"
|
| 136 |
+
disabled={plan.current}
|
| 137 |
+
>
|
| 138 |
+
{plan.current ? 'Current Plan' : 'Upgrade'}
|
| 139 |
+
{!plan.current && <ArrowRight className="h-4 w-4" />}
|
| 140 |
+
</Button>
|
| 141 |
+
|
| 142 |
+
<div className="space-y-3">
|
| 143 |
+
{plan.features.map((feature) => (
|
| 144 |
+
<div key={feature} className="flex items-center gap-2 text-sm">
|
| 145 |
+
<Check className={cn(
|
| 146 |
+
'h-4 w-4 shrink-0',
|
| 147 |
+
plan.popular ? 'text-emerald-500' : 'text-zinc-400'
|
| 148 |
+
)} />
|
| 149 |
+
<span className="text-zinc-600 dark:text-zinc-400">{feature}</span>
|
| 150 |
+
</div>
|
| 151 |
+
))}
|
| 152 |
+
</div>
|
| 153 |
+
</CardContent>
|
| 154 |
+
</Card>
|
| 155 |
+
))}
|
| 156 |
+
</div>
|
| 157 |
+
|
| 158 |
+
{/* Payment Method & Invoices */}
|
| 159 |
+
<div className="grid gap-6 lg:grid-cols-2">
|
| 160 |
+
{/* Payment Method */}
|
| 161 |
+
<Card>
|
| 162 |
+
<CardHeader className="flex flex-row items-center justify-between">
|
| 163 |
+
<CardTitle>Payment Method</CardTitle>
|
| 164 |
+
<Button variant="outline" size="sm">Update</Button>
|
| 165 |
+
</CardHeader>
|
| 166 |
+
<CardContent>
|
| 167 |
+
<div className="flex items-center gap-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
|
| 168 |
+
<div className="flex h-12 w-16 items-center justify-center rounded-lg bg-gradient-to-br from-blue-600 to-blue-800">
|
| 169 |
+
<CreditCard className="h-6 w-6 text-white" />
|
| 170 |
+
</div>
|
| 171 |
+
<div>
|
| 172 |
+
<p className="text-sm font-medium text-zinc-900 dark:text-zinc-100">Visa ending in 4242</p>
|
| 173 |
+
<p className="text-xs text-zinc-500">Expires 12/2026</p>
|
| 174 |
+
</div>
|
| 175 |
+
<Badge variant="success" className="ml-auto text-[10px]">Default</Badge>
|
| 176 |
+
</div>
|
| 177 |
+
</CardContent>
|
| 178 |
+
</Card>
|
| 179 |
+
|
| 180 |
+
{/* Recent Invoices */}
|
| 181 |
+
<Card>
|
| 182 |
+
<CardHeader className="flex flex-row items-center justify-between">
|
| 183 |
+
<CardTitle>Recent Invoices</CardTitle>
|
| 184 |
+
<Button variant="ghost" size="sm" className="text-xs">View All</Button>
|
| 185 |
+
</CardHeader>
|
| 186 |
+
<CardContent>
|
| 187 |
+
<div className="space-y-3">
|
| 188 |
+
{invoices.map((invoice) => (
|
| 189 |
+
<div key={invoice.id} className="flex items-center justify-between rounded-xl border border-zinc-100 p-3 dark:border-zinc-800">
|
| 190 |
+
<div>
|
| 191 |
+
<p className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{invoice.id}</p>
|
| 192 |
+
<p className="text-xs text-zinc-500">{invoice.date}</p>
|
| 193 |
+
</div>
|
| 194 |
+
<div className="flex items-center gap-3">
|
| 195 |
+
<span className="text-sm font-semibold">${invoice.amount}</span>
|
| 196 |
+
<Badge variant="success" className="text-[10px]">{invoice.status}</Badge>
|
| 197 |
+
<Button variant="ghost" size="icon-sm">
|
| 198 |
+
<Download className="h-3.5 w-3.5" />
|
| 199 |
+
</Button>
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
))}
|
| 203 |
+
</div>
|
| 204 |
+
</CardContent>
|
| 205 |
+
</Card>
|
| 206 |
+
</div>
|
| 207 |
+
</div>
|
| 208 |
+
</DashboardLayout>
|
| 209 |
+
);
|
| 210 |
+
}
|
src/app/(dashboard)/menu-builder/page.tsx
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
| 4 |
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
| 5 |
+
import { Button } from '@/components/ui/button';
|
| 6 |
+
import { Badge } from '@/components/ui/badge';
|
| 7 |
+
import { Input } from '@/components/ui/input';
|
| 8 |
+
import { Textarea } from '@/components/ui/textarea';
|
| 9 |
+
import {
|
| 10 |
+
Plus,
|
| 11 |
+
Search,
|
| 12 |
+
MoreHorizontal,
|
| 13 |
+
GripVertical,
|
| 14 |
+
Pencil,
|
| 15 |
+
Trash2,
|
| 16 |
+
Eye,
|
| 17 |
+
EyeOff,
|
| 18 |
+
Star,
|
| 19 |
+
Clock,
|
| 20 |
+
Flame,
|
| 21 |
+
ChevronDown,
|
| 22 |
+
ChevronRight,
|
| 23 |
+
X,
|
| 24 |
+
Image as ImageIcon,
|
| 25 |
+
DollarSign,
|
| 26 |
+
Tag,
|
| 27 |
+
AlertCircle,
|
| 28 |
+
} from 'lucide-react';
|
| 29 |
+
import { useState } from 'react';
|
| 30 |
+
import { demoCategories, demoProducts } from '@/lib/demo-data';
|
| 31 |
+
import { formatCurrency, cn } from '@/lib/utils';
|
| 32 |
+
import { Product, Category } from '@/types/database';
|
| 33 |
+
|
| 34 |
+
function ProductCard({ product, onEdit }: { product: Product; onEdit: () => void }) {
|
| 35 |
+
return (
|
| 36 |
+
<div className="group flex items-start gap-3 rounded-xl border border-zinc-100 bg-white p-3 transition-all hover:border-zinc-200 hover:shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700">
|
| 37 |
+
<button className="mt-1 cursor-grab text-zinc-300 opacity-0 transition-opacity group-hover:opacity-100 active:cursor-grabbing">
|
| 38 |
+
<GripVertical className="h-4 w-4" />
|
| 39 |
+
</button>
|
| 40 |
+
|
| 41 |
+
{/* Image placeholder */}
|
| 42 |
+
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-zinc-100 to-zinc-50 dark:from-zinc-800 dark:to-zinc-700">
|
| 43 |
+
<ImageIcon className="h-6 w-6 text-zinc-300 dark:text-zinc-500" />
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
<div className="flex-1 min-w-0">
|
| 47 |
+
<div className="flex items-start justify-between gap-2">
|
| 48 |
+
<div className="min-w-0">
|
| 49 |
+
<div className="flex items-center gap-2">
|
| 50 |
+
<h4 className="truncate text-sm font-semibold text-zinc-900 dark:text-zinc-100">{product.name}</h4>
|
| 51 |
+
{product.is_featured && <Star className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400" />}
|
| 52 |
+
</div>
|
| 53 |
+
<p className="mt-0.5 line-clamp-1 text-xs text-zinc-500">{product.description}</p>
|
| 54 |
+
</div>
|
| 55 |
+
<div className="flex items-center gap-1 shrink-0">
|
| 56 |
+
<span className="text-sm font-bold text-zinc-900 dark:text-zinc-100">{formatCurrency(product.price)}</span>
|
| 57 |
+
</div>
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
| 61 |
+
{product.is_available ? (
|
| 62 |
+
<Badge variant="success" className="text-[10px]"><Eye className="h-3 w-3 mr-0.5" />Available</Badge>
|
| 63 |
+
) : (
|
| 64 |
+
<Badge variant="destructive" className="text-[10px]"><EyeOff className="h-3 w-3 mr-0.5" />Hidden</Badge>
|
| 65 |
+
)}
|
| 66 |
+
{product.preparation_time && (
|
| 67 |
+
<Badge variant="secondary" className="text-[10px]"><Clock className="h-3 w-3 mr-0.5" />{product.preparation_time}m</Badge>
|
| 68 |
+
)}
|
| 69 |
+
{product.calories && (
|
| 70 |
+
<Badge variant="secondary" className="text-[10px]"><Flame className="h-3 w-3 mr-0.5" />{product.calories} cal</Badge>
|
| 71 |
+
)}
|
| 72 |
+
{product.tags?.map((tag) => (
|
| 73 |
+
<Badge key={tag} variant="outline" className="text-[10px]">{tag}</Badge>
|
| 74 |
+
))}
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
| 79 |
+
<button onClick={onEdit} className="rounded-lg p-1.5 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800">
|
| 80 |
+
<Pencil className="h-3.5 w-3.5" />
|
| 81 |
+
</button>
|
| 82 |
+
<button className="rounded-lg p-1.5 text-zinc-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950/30">
|
| 83 |
+
<Trash2 className="h-3.5 w-3.5" />
|
| 84 |
+
</button>
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
function ProductModal({ product, onClose }: { product?: Product; onClose: () => void }) {
|
| 91 |
+
return (
|
| 92 |
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
| 93 |
+
<div className="w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl border border-zinc-200 bg-white shadow-xl dark:border-zinc-700 dark:bg-zinc-900">
|
| 94 |
+
<div className="flex items-center justify-between border-b border-zinc-200 p-5 dark:border-zinc-800">
|
| 95 |
+
<h3 className="text-lg font-semibold">{product ? 'Edit Product' : 'Add Product'}</h3>
|
| 96 |
+
<button onClick={onClose} className="rounded-lg p-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
| 97 |
+
<X className="h-4 w-4" />
|
| 98 |
+
</button>
|
| 99 |
+
</div>
|
| 100 |
+
<div className="space-y-4 p-5">
|
| 101 |
+
{/* Image upload */}
|
| 102 |
+
<div className="flex items-center justify-center rounded-xl border-2 border-dashed border-zinc-200 bg-zinc-50 p-8 dark:border-zinc-700 dark:bg-zinc-800/50">
|
| 103 |
+
<div className="text-center">
|
| 104 |
+
<ImageIcon className="mx-auto h-8 w-8 text-zinc-300" />
|
| 105 |
+
<p className="mt-2 text-sm font-medium text-zinc-600">Upload product image</p>
|
| 106 |
+
<p className="text-xs text-zinc-400">PNG, JPG up to 5MB</p>
|
| 107 |
+
<Button variant="outline" size="sm" className="mt-3">Choose File</Button>
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
<div className="space-y-2">
|
| 112 |
+
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Product Name</label>
|
| 113 |
+
<Input placeholder="e.g., Truffle Burrata" defaultValue={product?.name} />
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
<div className="space-y-2">
|
| 117 |
+
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Description</label>
|
| 118 |
+
<Textarea placeholder="Describe your product..." defaultValue={product?.description} />
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
+
<div className="grid grid-cols-2 gap-3">
|
| 122 |
+
<div className="space-y-2">
|
| 123 |
+
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Price</label>
|
| 124 |
+
<div className="relative">
|
| 125 |
+
<DollarSign className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
|
| 126 |
+
<Input type="number" step="0.01" placeholder="0.00" defaultValue={product?.price} className="pl-10" />
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
| 129 |
+
<div className="space-y-2">
|
| 130 |
+
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Prep Time (min)</label>
|
| 131 |
+
<div className="relative">
|
| 132 |
+
<Clock className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
|
| 133 |
+
<Input type="number" placeholder="15" defaultValue={product?.preparation_time ?? undefined} className="pl-10" />
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
|
| 138 |
+
<div className="grid grid-cols-2 gap-3">
|
| 139 |
+
<div className="space-y-2">
|
| 140 |
+
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Calories</label>
|
| 141 |
+
<div className="relative">
|
| 142 |
+
<Flame className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
|
| 143 |
+
<Input type="number" placeholder="350" defaultValue={product?.calories ?? undefined} className="pl-10" />
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
<div className="space-y-2">
|
| 147 |
+
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Tags</label>
|
| 148 |
+
<div className="relative">
|
| 149 |
+
<Tag className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
|
| 150 |
+
<Input placeholder="vegetarian, popular" defaultValue={product?.tags?.join(', ')} className="pl-10" />
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
</div>
|
| 154 |
+
|
| 155 |
+
<div className="space-y-2">
|
| 156 |
+
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Allergens</label>
|
| 157 |
+
<div className="relative">
|
| 158 |
+
<AlertCircle className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
|
| 159 |
+
<Input placeholder="dairy, gluten, nuts" defaultValue={product?.allergens?.join(', ')} className="pl-10" />
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
<div className="flex items-center gap-6 rounded-xl bg-zinc-50 p-4 dark:bg-zinc-800/50">
|
| 164 |
+
<label className="flex items-center gap-2 text-sm">
|
| 165 |
+
<input type="checkbox" defaultChecked={product?.is_available ?? true} className="h-4 w-4 rounded border-zinc-300 text-emerald-600 focus:ring-emerald-500" />
|
| 166 |
+
<span className="font-medium text-zinc-700 dark:text-zinc-300">Available</span>
|
| 167 |
+
</label>
|
| 168 |
+
<label className="flex items-center gap-2 text-sm">
|
| 169 |
+
<input type="checkbox" defaultChecked={product?.is_featured ?? false} className="h-4 w-4 rounded border-zinc-300 text-emerald-600 focus:ring-emerald-500" />
|
| 170 |
+
<span className="font-medium text-zinc-700 dark:text-zinc-300">Featured</span>
|
| 171 |
+
</label>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
<div className="flex justify-end gap-2 border-t border-zinc-200 p-5 dark:border-zinc-800">
|
| 175 |
+
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
| 176 |
+
<Button variant="primary" onClick={onClose}>{product ? 'Save Changes' : 'Add Product'}</Button>
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
export default function MenuBuilderPage() {
|
| 184 |
+
const [search, setSearch] = useState('');
|
| 185 |
+
const [expandedCategories, setExpandedCategories] = useState<string[]>(demoCategories.map((c) => c.id));
|
| 186 |
+
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
| 187 |
+
const [showAddProduct, setShowAddProduct] = useState(false);
|
| 188 |
+
|
| 189 |
+
const toggleCategory = (id: string) => {
|
| 190 |
+
setExpandedCategories((prev) =>
|
| 191 |
+
prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]
|
| 192 |
+
);
|
| 193 |
+
};
|
| 194 |
+
|
| 195 |
+
const filteredProducts = search
|
| 196 |
+
? demoProducts.filter((p) => p.name.toLowerCase().includes(search.toLowerCase()) || p.description?.toLowerCase().includes(search.toLowerCase()))
|
| 197 |
+
: demoProducts;
|
| 198 |
+
|
| 199 |
+
return (
|
| 200 |
+
<DashboardLayout>
|
| 201 |
+
<div className="space-y-6">
|
| 202 |
+
{/* Header */}
|
| 203 |
+
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
| 204 |
+
<div>
|
| 205 |
+
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white sm:text-3xl">Menu Builder</h1>
|
| 206 |
+
<p className="mt-1 text-sm text-zinc-500">Manage your menus, categories, and products.</p>
|
| 207 |
+
</div>
|
| 208 |
+
<div className="flex gap-2">
|
| 209 |
+
<Button variant="outline" size="sm">
|
| 210 |
+
<Plus className="h-4 w-4" /> Add Category
|
| 211 |
+
</Button>
|
| 212 |
+
<Button variant="primary" size="sm" onClick={() => setShowAddProduct(true)}>
|
| 213 |
+
<Plus className="h-4 w-4" /> Add Product
|
| 214 |
+
</Button>
|
| 215 |
+
</div>
|
| 216 |
+
</div>
|
| 217 |
+
|
| 218 |
+
{/* Search */}
|
| 219 |
+
<div className="relative">
|
| 220 |
+
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
|
| 221 |
+
<Input
|
| 222 |
+
placeholder="Search products..."
|
| 223 |
+
value={search}
|
| 224 |
+
onChange={(e) => setSearch(e.target.value)}
|
| 225 |
+
className="pl-10 max-w-sm"
|
| 226 |
+
/>
|
| 227 |
+
</div>
|
| 228 |
+
|
| 229 |
+
{/* Categories & Products */}
|
| 230 |
+
<div className="space-y-4">
|
| 231 |
+
{demoCategories.map((category) => {
|
| 232 |
+
const categoryProducts = filteredProducts.filter((p) => p.category_id === category.id);
|
| 233 |
+
const isExpanded = expandedCategories.includes(category.id);
|
| 234 |
+
|
| 235 |
+
return (
|
| 236 |
+
<Card key={category.id}>
|
| 237 |
+
<div
|
| 238 |
+
className="flex cursor-pointer items-center justify-between p-4"
|
| 239 |
+
onClick={() => toggleCategory(category.id)}
|
| 240 |
+
>
|
| 241 |
+
<div className="flex items-center gap-3">
|
| 242 |
+
<button className="cursor-grab text-zinc-300 active:cursor-grabbing">
|
| 243 |
+
<GripVertical className="h-4 w-4" />
|
| 244 |
+
</button>
|
| 245 |
+
{isExpanded ? (
|
| 246 |
+
<ChevronDown className="h-4 w-4 text-zinc-400" />
|
| 247 |
+
) : (
|
| 248 |
+
<ChevronRight className="h-4 w-4 text-zinc-400" />
|
| 249 |
+
)}
|
| 250 |
+
<div>
|
| 251 |
+
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">{category.name}</h3>
|
| 252 |
+
<p className="text-xs text-zinc-500">{categoryProducts.length} items{category.description && ` • ${category.description}`}</p>
|
| 253 |
+
</div>
|
| 254 |
+
</div>
|
| 255 |
+
<div className="flex items-center gap-2">
|
| 256 |
+
<Badge variant={category.is_active ? 'success' : 'secondary'} className="text-[10px]">
|
| 257 |
+
{category.is_active ? 'Active' : 'Hidden'}
|
| 258 |
+
</Badge>
|
| 259 |
+
<button className="rounded-lg p-1.5 text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800" onClick={(e) => e.stopPropagation()}>
|
| 260 |
+
<MoreHorizontal className="h-4 w-4" />
|
| 261 |
+
</button>
|
| 262 |
+
</div>
|
| 263 |
+
</div>
|
| 264 |
+
|
| 265 |
+
{isExpanded && (
|
| 266 |
+
<CardContent className="border-t border-zinc-100 pt-3 dark:border-zinc-800">
|
| 267 |
+
{categoryProducts.length > 0 ? (
|
| 268 |
+
<div className="space-y-2">
|
| 269 |
+
{categoryProducts.map((product) => (
|
| 270 |
+
<ProductCard
|
| 271 |
+
key={product.id}
|
| 272 |
+
product={product}
|
| 273 |
+
onEdit={() => setEditingProduct(product)}
|
| 274 |
+
/>
|
| 275 |
+
))}
|
| 276 |
+
</div>
|
| 277 |
+
) : (
|
| 278 |
+
<div className="py-8 text-center text-sm text-zinc-400">
|
| 279 |
+
No products in this category
|
| 280 |
+
</div>
|
| 281 |
+
)}
|
| 282 |
+
<button
|
| 283 |
+
onClick={() => setShowAddProduct(true)}
|
| 284 |
+
className="mt-3 flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-zinc-200 py-3 text-sm font-medium text-zinc-400 transition-all hover:border-emerald-300 hover:text-emerald-600 dark:border-zinc-700 dark:hover:border-emerald-700"
|
| 285 |
+
>
|
| 286 |
+
<Plus className="h-4 w-4" /> Add product
|
| 287 |
+
</button>
|
| 288 |
+
</CardContent>
|
| 289 |
+
)}
|
| 290 |
+
</Card>
|
| 291 |
+
);
|
| 292 |
+
})}
|
| 293 |
+
</div>
|
| 294 |
+
</div>
|
| 295 |
+
|
| 296 |
+
{/* Modals */}
|
| 297 |
+
{(editingProduct || showAddProduct) && (
|
| 298 |
+
<ProductModal
|
| 299 |
+
product={editingProduct ?? undefined}
|
| 300 |
+
onClose={() => {
|
| 301 |
+
setEditingProduct(null);
|
| 302 |
+
setShowAddProduct(false);
|
| 303 |
+
}}
|
| 304 |
+
/>
|
| 305 |
+
)}
|
| 306 |
+
</DashboardLayout>
|
| 307 |
+
);
|
| 308 |
+
}
|
src/app/(dashboard)/orders/page.tsx
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
| 4 |
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
| 5 |
+
import { Button } from '@/components/ui/button';
|
| 6 |
+
import { Badge } from '@/components/ui/badge';
|
| 7 |
+
import {
|
| 8 |
+
Clock,
|
| 9 |
+
MapPin,
|
| 10 |
+
ShoppingBag,
|
| 11 |
+
Truck,
|
| 12 |
+
Phone,
|
| 13 |
+
Mail,
|
| 14 |
+
ChevronRight,
|
| 15 |
+
CheckCircle2,
|
| 16 |
+
XCircle,
|
| 17 |
+
ChefHat,
|
| 18 |
+
Bell,
|
| 19 |
+
ArrowRight,
|
| 20 |
+
Filter,
|
| 21 |
+
Search,
|
| 22 |
+
} from 'lucide-react';
|
| 23 |
+
import { demoOrders, demoOrderItems } from '@/lib/demo-data';
|
| 24 |
+
import { formatCurrency, formatRelativeTime, getOrderStatusColor, getOrderTypeLabel, cn } from '@/lib/utils';
|
| 25 |
+
import { useState } from 'react';
|
| 26 |
+
import { Order, OrderStatus } from '@/types/database';
|
| 27 |
+
import { Input } from '@/components/ui/input';
|
| 28 |
+
|
| 29 |
+
const statusFlow: OrderStatus[] = ['pending', 'confirmed', 'preparing', 'ready', 'delivered'];
|
| 30 |
+
|
| 31 |
+
function OrderCard({ order, isSelected, onClick }: { order: Order; isSelected: boolean; onClick: () => void }) {
|
| 32 |
+
const items = demoOrderItems.filter((i) => i.order_id === order.id);
|
| 33 |
+
const typeIcons = {
|
| 34 |
+
dine_in: MapPin,
|
| 35 |
+
takeaway: ShoppingBag,
|
| 36 |
+
delivery: Truck,
|
| 37 |
+
};
|
| 38 |
+
const TypeIcon = typeIcons[order.order_type];
|
| 39 |
+
|
| 40 |
+
return (
|
| 41 |
+
<div
|
| 42 |
+
onClick={onClick}
|
| 43 |
+
className={cn(
|
| 44 |
+
'cursor-pointer rounded-xl border p-4 transition-all',
|
| 45 |
+
isSelected
|
| 46 |
+
? 'border-emerald-500 bg-emerald-50/50 shadow-sm ring-1 ring-emerald-500/20 dark:bg-emerald-950/20'
|
| 47 |
+
: 'border-zinc-200/60 bg-white hover:border-zinc-300 hover:shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700'
|
| 48 |
+
)}
|
| 49 |
+
>
|
| 50 |
+
<div className="flex items-start justify-between">
|
| 51 |
+
<div className="flex items-center gap-2">
|
| 52 |
+
<div className={cn('flex h-9 w-9 items-center justify-center rounded-lg', getOrderStatusColor(order.status))}>
|
| 53 |
+
<TypeIcon className="h-4 w-4" />
|
| 54 |
+
</div>
|
| 55 |
+
<div>
|
| 56 |
+
<div className="flex items-center gap-2">
|
| 57 |
+
<span className="text-sm font-bold text-zinc-900 dark:text-zinc-100">#{order.id.split('-')[1]}</span>
|
| 58 |
+
<Badge className={cn('text-[10px]', getOrderStatusColor(order.status))} variant="outline">
|
| 59 |
+
{order.status}
|
| 60 |
+
</Badge>
|
| 61 |
+
</div>
|
| 62 |
+
<p className="text-xs text-zinc-500">{order.customer_name}</p>
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
<div className="text-right">
|
| 66 |
+
<p className="text-sm font-bold text-zinc-900 dark:text-zinc-100">{formatCurrency(order.total)}</p>
|
| 67 |
+
<p className="text-[10px] text-zinc-400">{formatRelativeTime(order.created_at)}</p>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
<div className="mt-3 flex items-center justify-between">
|
| 72 |
+
<div className="flex items-center gap-2 text-xs text-zinc-500">
|
| 73 |
+
<span>{getOrderTypeLabel(order.order_type)}</span>
|
| 74 |
+
{order.table_number && <span>• Table {order.table_number}</span>}
|
| 75 |
+
<span>• {items.length} items</span>
|
| 76 |
+
</div>
|
| 77 |
+
<ChevronRight className="h-4 w-4 text-zinc-300" />
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function OrderDetail({ order }: { order: Order }) {
|
| 84 |
+
const items = demoOrderItems.filter((i) => i.order_id === order.id);
|
| 85 |
+
const currentStatusIndex = statusFlow.indexOf(order.status);
|
| 86 |
+
|
| 87 |
+
return (
|
| 88 |
+
<div className="space-y-6">
|
| 89 |
+
{/* Header */}
|
| 90 |
+
<div className="flex items-start justify-between">
|
| 91 |
+
<div>
|
| 92 |
+
<div className="flex items-center gap-3">
|
| 93 |
+
<h2 className="text-xl font-bold text-zinc-900 dark:text-zinc-100">Order #{order.id.split('-')[1]}</h2>
|
| 94 |
+
<Badge className={cn('text-xs', getOrderStatusColor(order.status))} variant="outline">
|
| 95 |
+
{order.status}
|
| 96 |
+
</Badge>
|
| 97 |
+
</div>
|
| 98 |
+
<p className="mt-1 text-sm text-zinc-500">{formatRelativeTime(order.created_at)}</p>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
{/* Status Timeline */}
|
| 103 |
+
{order.status !== 'cancelled' && (
|
| 104 |
+
<div className="rounded-xl border border-zinc-200/60 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
|
| 105 |
+
<div className="flex items-center justify-between">
|
| 106 |
+
{statusFlow.map((status, index) => {
|
| 107 |
+
const isComplete = index <= currentStatusIndex;
|
| 108 |
+
const isCurrent = index === currentStatusIndex;
|
| 109 |
+
return (
|
| 110 |
+
<div key={status} className="flex flex-1 items-center">
|
| 111 |
+
<div className="flex flex-col items-center gap-1">
|
| 112 |
+
<div className={cn(
|
| 113 |
+
'flex h-8 w-8 items-center justify-center rounded-full border-2 transition-all',
|
| 114 |
+
isComplete ? 'border-emerald-500 bg-emerald-500 text-white' : 'border-zinc-200 text-zinc-400 dark:border-zinc-700',
|
| 115 |
+
isCurrent && 'ring-4 ring-emerald-100 dark:ring-emerald-900/30'
|
| 116 |
+
)}>
|
| 117 |
+
{isComplete ? <CheckCircle2 className="h-4 w-4" /> : <span className="text-xs">{index + 1}</span>}
|
| 118 |
+
</div>
|
| 119 |
+
<span className={cn('text-[10px] font-medium capitalize', isComplete ? 'text-emerald-600' : 'text-zinc-400')}>
|
| 120 |
+
{status}
|
| 121 |
+
</span>
|
| 122 |
+
</div>
|
| 123 |
+
{index < statusFlow.length - 1 && (
|
| 124 |
+
<div className={cn('mx-1 h-0.5 flex-1', index < currentStatusIndex ? 'bg-emerald-500' : 'bg-zinc-200 dark:bg-zinc-700')} />
|
| 125 |
+
)}
|
| 126 |
+
</div>
|
| 127 |
+
);
|
| 128 |
+
})}
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
)}
|
| 132 |
+
|
| 133 |
+
{/* Customer Info */}
|
| 134 |
+
<div className="rounded-xl border border-zinc-200/60 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
|
| 135 |
+
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Customer</h3>
|
| 136 |
+
<div className="mt-3 space-y-2">
|
| 137 |
+
<div className="flex items-center gap-2 text-sm text-zinc-600">
|
| 138 |
+
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-zinc-100 dark:bg-zinc-800">
|
| 139 |
+
<span className="text-xs font-bold">{order.customer_name?.split(' ').map(n => n[0]).join('')}</span>
|
| 140 |
+
</div>
|
| 141 |
+
<span className="font-medium">{order.customer_name}</span>
|
| 142 |
+
</div>
|
| 143 |
+
{order.customer_phone && (
|
| 144 |
+
<div className="flex items-center gap-2 text-sm text-zinc-500">
|
| 145 |
+
<Phone className="h-4 w-4" /> {order.customer_phone}
|
| 146 |
+
</div>
|
| 147 |
+
)}
|
| 148 |
+
{order.customer_email && (
|
| 149 |
+
<div className="flex items-center gap-2 text-sm text-zinc-500">
|
| 150 |
+
<Mail className="h-4 w-4" /> {order.customer_email}
|
| 151 |
+
</div>
|
| 152 |
+
)}
|
| 153 |
+
{order.delivery_address && (
|
| 154 |
+
<div className="flex items-center gap-2 text-sm text-zinc-500">
|
| 155 |
+
<MapPin className="h-4 w-4" /> {order.delivery_address}
|
| 156 |
+
</div>
|
| 157 |
+
)}
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
| 160 |
+
|
| 161 |
+
{/* Items */}
|
| 162 |
+
<div className="rounded-xl border border-zinc-200/60 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
|
| 163 |
+
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Items</h3>
|
| 164 |
+
<div className="mt-3 space-y-3">
|
| 165 |
+
{items.map((item) => (
|
| 166 |
+
<div key={item.id} className="flex items-center justify-between">
|
| 167 |
+
<div className="flex items-center gap-3">
|
| 168 |
+
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-50 text-sm font-bold text-emerald-600 dark:bg-emerald-900/30">
|
| 169 |
+
{item.quantity}x
|
| 170 |
+
</div>
|
| 171 |
+
<div>
|
| 172 |
+
<p className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{item.product_name}</p>
|
| 173 |
+
{item.options && Object.entries(item.options).map(([key, value]) => (
|
| 174 |
+
<p key={key} className="text-xs text-zinc-500">{key}: {String(value)}</p>
|
| 175 |
+
))}
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
<p className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">{formatCurrency(item.total_price)}</p>
|
| 179 |
+
</div>
|
| 180 |
+
))}
|
| 181 |
+
</div>
|
| 182 |
+
|
| 183 |
+
<div className="mt-4 space-y-1.5 border-t border-zinc-100 pt-4 dark:border-zinc-800">
|
| 184 |
+
<div className="flex justify-between text-sm text-zinc-500">
|
| 185 |
+
<span>Subtotal</span>
|
| 186 |
+
<span>{formatCurrency(order.subtotal)}</span>
|
| 187 |
+
</div>
|
| 188 |
+
<div className="flex justify-between text-sm text-zinc-500">
|
| 189 |
+
<span>Tax</span>
|
| 190 |
+
<span>{formatCurrency(order.tax)}</span>
|
| 191 |
+
</div>
|
| 192 |
+
<div className="flex justify-between text-base font-bold text-zinc-900 dark:text-zinc-100">
|
| 193 |
+
<span>Total</span>
|
| 194 |
+
<span>{formatCurrency(order.total)}</span>
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
</div>
|
| 198 |
+
|
| 199 |
+
{/* Action Buttons */}
|
| 200 |
+
{order.status !== 'delivered' && order.status !== 'cancelled' && (
|
| 201 |
+
<div className="flex gap-2">
|
| 202 |
+
{order.status === 'pending' && (
|
| 203 |
+
<>
|
| 204 |
+
<Button variant="primary" className="flex-1">
|
| 205 |
+
<CheckCircle2 className="h-4 w-4" /> Confirm Order
|
| 206 |
+
</Button>
|
| 207 |
+
<Button variant="destructive" className="flex-1">
|
| 208 |
+
<XCircle className="h-4 w-4" /> Cancel
|
| 209 |
+
</Button>
|
| 210 |
+
</>
|
| 211 |
+
)}
|
| 212 |
+
{order.status === 'confirmed' && (
|
| 213 |
+
<Button variant="primary" className="flex-1">
|
| 214 |
+
<ChefHat className="h-4 w-4" /> Start Preparing
|
| 215 |
+
</Button>
|
| 216 |
+
)}
|
| 217 |
+
{order.status === 'preparing' && (
|
| 218 |
+
<Button variant="primary" className="flex-1">
|
| 219 |
+
<Bell className="h-4 w-4" /> Mark Ready
|
| 220 |
+
</Button>
|
| 221 |
+
)}
|
| 222 |
+
{order.status === 'ready' && (
|
| 223 |
+
<Button variant="primary" className="flex-1">
|
| 224 |
+
<CheckCircle2 className="h-4 w-4" /> Mark Delivered
|
| 225 |
+
</Button>
|
| 226 |
+
)}
|
| 227 |
+
</div>
|
| 228 |
+
)}
|
| 229 |
+
</div>
|
| 230 |
+
);
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
export default function OrdersPage() {
|
| 234 |
+
const [selectedOrder, setSelectedOrder] = useState<Order>(demoOrders[0]);
|
| 235 |
+
const [statusFilter, setStatusFilter] = useState<string>('all');
|
| 236 |
+
const [search, setSearch] = useState('');
|
| 237 |
+
|
| 238 |
+
const filteredOrders = demoOrders.filter((o) => {
|
| 239 |
+
if (statusFilter !== 'all' && o.status !== statusFilter) return false;
|
| 240 |
+
if (search && !o.customer_name?.toLowerCase().includes(search.toLowerCase()) && !o.id.includes(search)) return false;
|
| 241 |
+
return true;
|
| 242 |
+
});
|
| 243 |
+
|
| 244 |
+
const statusCounts = {
|
| 245 |
+
all: demoOrders.length,
|
| 246 |
+
pending: demoOrders.filter((o) => o.status === 'pending').length,
|
| 247 |
+
confirmed: demoOrders.filter((o) => o.status === 'confirmed').length,
|
| 248 |
+
preparing: demoOrders.filter((o) => o.status === 'preparing').length,
|
| 249 |
+
ready: demoOrders.filter((o) => o.status === 'ready').length,
|
| 250 |
+
delivered: demoOrders.filter((o) => o.status === 'delivered').length,
|
| 251 |
+
};
|
| 252 |
+
|
| 253 |
+
return (
|
| 254 |
+
<DashboardLayout>
|
| 255 |
+
<div className="space-y-6">
|
| 256 |
+
{/* Header */}
|
| 257 |
+
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
| 258 |
+
<div>
|
| 259 |
+
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white sm:text-3xl">Orders</h1>
|
| 260 |
+
<p className="mt-1 text-sm text-zinc-500">Manage and track customer orders in real-time.</p>
|
| 261 |
+
</div>
|
| 262 |
+
<div className="flex items-center gap-2">
|
| 263 |
+
<div className="flex items-center gap-1.5 rounded-full bg-emerald-50 px-3 py-1.5 text-xs font-medium text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400">
|
| 264 |
+
<span className="relative flex h-2 w-2">
|
| 265 |
+
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
| 266 |
+
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
|
| 267 |
+
</span>
|
| 268 |
+
Live
|
| 269 |
+
</div>
|
| 270 |
+
</div>
|
| 271 |
+
</div>
|
| 272 |
+
|
| 273 |
+
{/* Status Filter Tabs */}
|
| 274 |
+
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-none">
|
| 275 |
+
{Object.entries(statusCounts).map(([status, count]) => (
|
| 276 |
+
<button
|
| 277 |
+
key={status}
|
| 278 |
+
onClick={() => setStatusFilter(status)}
|
| 279 |
+
className={cn(
|
| 280 |
+
'flex items-center gap-1.5 whitespace-nowrap rounded-xl px-4 py-2 text-sm font-medium transition-all',
|
| 281 |
+
statusFilter === status
|
| 282 |
+
? 'bg-zinc-900 text-white shadow-sm dark:bg-white dark:text-zinc-900'
|
| 283 |
+
: 'bg-white text-zinc-600 hover:bg-zinc-50 border border-zinc-200 dark:bg-zinc-900 dark:border-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-800'
|
| 284 |
+
)}
|
| 285 |
+
>
|
| 286 |
+
<span className="capitalize">{status}</span>
|
| 287 |
+
<span className={cn(
|
| 288 |
+
'rounded-full px-1.5 py-0.5 text-[10px] font-bold',
|
| 289 |
+
statusFilter === status ? 'bg-white/20 text-white dark:bg-zinc-900/30 dark:text-zinc-900' : 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800'
|
| 290 |
+
)}>
|
| 291 |
+
{count}
|
| 292 |
+
</span>
|
| 293 |
+
</button>
|
| 294 |
+
))}
|
| 295 |
+
</div>
|
| 296 |
+
|
| 297 |
+
{/* Two Column Layout */}
|
| 298 |
+
<div className="grid gap-6 lg:grid-cols-5">
|
| 299 |
+
{/* Order List */}
|
| 300 |
+
<div className="space-y-3 lg:col-span-2">
|
| 301 |
+
<div className="relative">
|
| 302 |
+
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
|
| 303 |
+
<Input
|
| 304 |
+
placeholder="Search orders..."
|
| 305 |
+
value={search}
|
| 306 |
+
onChange={(e) => setSearch(e.target.value)}
|
| 307 |
+
className="pl-10"
|
| 308 |
+
/>
|
| 309 |
+
</div>
|
| 310 |
+
<div className="space-y-2 max-h-[calc(100vh-320px)] overflow-y-auto pr-1 scrollbar-thin">
|
| 311 |
+
{filteredOrders.map((order) => (
|
| 312 |
+
<OrderCard
|
| 313 |
+
key={order.id}
|
| 314 |
+
order={order}
|
| 315 |
+
isSelected={selectedOrder?.id === order.id}
|
| 316 |
+
onClick={() => setSelectedOrder(order)}
|
| 317 |
+
/>
|
| 318 |
+
))}
|
| 319 |
+
</div>
|
| 320 |
+
</div>
|
| 321 |
+
|
| 322 |
+
{/* Order Detail */}
|
| 323 |
+
<div className="lg:col-span-3">
|
| 324 |
+
<div className="sticky top-24">
|
| 325 |
+
{selectedOrder ? (
|
| 326 |
+
<OrderDetail order={selectedOrder} />
|
| 327 |
+
) : (
|
| 328 |
+
<div className="flex flex-col items-center justify-center py-20 text-center">
|
| 329 |
+
<ShoppingBag className="h-12 w-12 text-zinc-200" />
|
| 330 |
+
<p className="mt-4 text-sm text-zinc-500">Select an order to view details</p>
|
| 331 |
+
</div>
|
| 332 |
+
)}
|
| 333 |
+
</div>
|
| 334 |
+
</div>
|
| 335 |
+
</div>
|
| 336 |
+
</div>
|
| 337 |
+
</DashboardLayout>
|
| 338 |
+
);
|
| 339 |
+
}
|
src/app/(dashboard)/overview/page.tsx
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
| 4 |
+
import { StatCard } from '@/components/ui/stat-card';
|
| 5 |
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
| 6 |
+
import { Badge } from '@/components/ui/badge';
|
| 7 |
+
import { Button } from '@/components/ui/button';
|
| 8 |
+
import {
|
| 9 |
+
DollarSign,
|
| 10 |
+
ShoppingBag,
|
| 11 |
+
Users,
|
| 12 |
+
TrendingUp,
|
| 13 |
+
ArrowUpRight,
|
| 14 |
+
Clock,
|
| 15 |
+
MapPin,
|
| 16 |
+
Truck,
|
| 17 |
+
Eye,
|
| 18 |
+
} from 'lucide-react';
|
| 19 |
+
import { demoOrders, demoOrderItems, generateDailyRevenue, generatePopularItems, generateOrdersByType } from '@/lib/demo-data';
|
| 20 |
+
import { formatCurrency, formatRelativeTime, getOrderStatusColor, getOrderTypeLabel, cn } from '@/lib/utils';
|
| 21 |
+
import Link from 'next/link';
|
| 22 |
+
|
| 23 |
+
const revenueData = generateDailyRevenue(7);
|
| 24 |
+
const popularItems = generatePopularItems().slice(0, 5);
|
| 25 |
+
const ordersByType = generateOrdersByType();
|
| 26 |
+
|
| 27 |
+
function MiniChart() {
|
| 28 |
+
const data = generateDailyRevenue(14);
|
| 29 |
+
const max = Math.max(...data.map((d) => d.revenue));
|
| 30 |
+
return (
|
| 31 |
+
<div className="flex h-16 items-end gap-1">
|
| 32 |
+
{data.map((d, i) => (
|
| 33 |
+
<div
|
| 34 |
+
key={i}
|
| 35 |
+
className="flex-1 rounded-t-sm bg-emerald-500/80 transition-all hover:bg-emerald-500"
|
| 36 |
+
style={{ height: `${(d.revenue / max) * 100}%` }}
|
| 37 |
+
/>
|
| 38 |
+
))}
|
| 39 |
+
</div>
|
| 40 |
+
);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export default function OverviewPage() {
|
| 44 |
+
const totalRevenue = revenueData.reduce((s, d) => s + d.revenue, 0);
|
| 45 |
+
const totalOrders = revenueData.reduce((s, d) => s + d.orders, 0);
|
| 46 |
+
|
| 47 |
+
return (
|
| 48 |
+
<DashboardLayout>
|
| 49 |
+
<div className="space-y-8">
|
| 50 |
+
{/* Header */}
|
| 51 |
+
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
| 52 |
+
<div>
|
| 53 |
+
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white sm:text-3xl">
|
| 54 |
+
Dashboard
|
| 55 |
+
</h1>
|
| 56 |
+
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
| 57 |
+
Welcome back, Alex. Here's what's happening today.
|
| 58 |
+
</p>
|
| 59 |
+
</div>
|
| 60 |
+
<div className="flex gap-2">
|
| 61 |
+
<Button variant="outline" size="sm">
|
| 62 |
+
<Eye className="h-4 w-4" />
|
| 63 |
+
View Menu
|
| 64 |
+
</Button>
|
| 65 |
+
<Link href="/dashboard/orders">
|
| 66 |
+
<Button variant="primary" size="sm">
|
| 67 |
+
<ShoppingBag className="h-4 w-4" />
|
| 68 |
+
Live Orders
|
| 69 |
+
</Button>
|
| 70 |
+
</Link>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
{/* Stats Grid */}
|
| 75 |
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
| 76 |
+
<StatCard
|
| 77 |
+
title="Total Revenue"
|
| 78 |
+
value={formatCurrency(totalRevenue)}
|
| 79 |
+
change="+12.5% from last week"
|
| 80 |
+
changeType="positive"
|
| 81 |
+
icon={DollarSign}
|
| 82 |
+
iconColor="text-emerald-600"
|
| 83 |
+
/>
|
| 84 |
+
<StatCard
|
| 85 |
+
title="Orders"
|
| 86 |
+
value={totalOrders.toString()}
|
| 87 |
+
change="+8.2% from last week"
|
| 88 |
+
changeType="positive"
|
| 89 |
+
icon={ShoppingBag}
|
| 90 |
+
iconColor="text-blue-600"
|
| 91 |
+
/>
|
| 92 |
+
<StatCard
|
| 93 |
+
title="Avg. Order Value"
|
| 94 |
+
value={formatCurrency(totalRevenue / totalOrders)}
|
| 95 |
+
change="+3.1% from last week"
|
| 96 |
+
changeType="positive"
|
| 97 |
+
icon={TrendingUp}
|
| 98 |
+
iconColor="text-violet-600"
|
| 99 |
+
/>
|
| 100 |
+
<StatCard
|
| 101 |
+
title="Active Tables"
|
| 102 |
+
value="5 / 8"
|
| 103 |
+
change="3 available"
|
| 104 |
+
changeType="neutral"
|
| 105 |
+
icon={Users}
|
| 106 |
+
iconColor="text-amber-600"
|
| 107 |
+
/>
|
| 108 |
+
</div>
|
| 109 |
+
|
| 110 |
+
<div className="grid gap-6 lg:grid-cols-3">
|
| 111 |
+
{/* Revenue Chart */}
|
| 112 |
+
<Card className="lg:col-span-2">
|
| 113 |
+
<CardHeader className="flex flex-row items-center justify-between">
|
| 114 |
+
<CardTitle>Revenue Overview</CardTitle>
|
| 115 |
+
<div className="flex gap-1 rounded-lg border border-zinc-200 p-1 dark:border-zinc-700">
|
| 116 |
+
<button className="rounded-md bg-zinc-900 px-3 py-1 text-xs font-medium text-white dark:bg-white dark:text-zinc-900">7D</button>
|
| 117 |
+
<button className="rounded-md px-3 py-1 text-xs font-medium text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800">14D</button>
|
| 118 |
+
<button className="rounded-md px-3 py-1 text-xs font-medium text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800">30D</button>
|
| 119 |
+
</div>
|
| 120 |
+
</CardHeader>
|
| 121 |
+
<CardContent>
|
| 122 |
+
<div className="mb-4 flex items-baseline gap-3">
|
| 123 |
+
<span className="text-3xl font-bold tracking-tight">{formatCurrency(totalRevenue)}</span>
|
| 124 |
+
<Badge variant="success" className="gap-1">
|
| 125 |
+
<ArrowUpRight className="h-3 w-3" /> 12.5%
|
| 126 |
+
</Badge>
|
| 127 |
+
</div>
|
| 128 |
+
<MiniChart />
|
| 129 |
+
<div className="mt-3 flex justify-between text-xs text-zinc-400">
|
| 130 |
+
{revenueData.map((d, i) => (
|
| 131 |
+
<span key={i}>{new Date(d.date).toLocaleDateString('en', { weekday: 'short' })}</span>
|
| 132 |
+
))}
|
| 133 |
+
</div>
|
| 134 |
+
</CardContent>
|
| 135 |
+
</Card>
|
| 136 |
+
|
| 137 |
+
{/* Orders by Type */}
|
| 138 |
+
<Card>
|
| 139 |
+
<CardHeader>
|
| 140 |
+
<CardTitle>Orders by Type</CardTitle>
|
| 141 |
+
</CardHeader>
|
| 142 |
+
<CardContent className="space-y-4">
|
| 143 |
+
{ordersByType.map((item) => (
|
| 144 |
+
<div key={item.type} className="space-y-2">
|
| 145 |
+
<div className="flex items-center justify-between text-sm">
|
| 146 |
+
<div className="flex items-center gap-2">
|
| 147 |
+
{item.type === 'Dine In' && <MapPin className="h-4 w-4 text-emerald-500" />}
|
| 148 |
+
{item.type === 'Takeaway' && <ShoppingBag className="h-4 w-4 text-blue-500" />}
|
| 149 |
+
{item.type === 'Delivery' && <Truck className="h-4 w-4 text-violet-500" />}
|
| 150 |
+
<span className="font-medium">{item.type}</span>
|
| 151 |
+
</div>
|
| 152 |
+
<span className="text-zinc-500">{item.count} ({item.percentage}%)</span>
|
| 153 |
+
</div>
|
| 154 |
+
<div className="h-2 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
|
| 155 |
+
<div
|
| 156 |
+
className={cn(
|
| 157 |
+
'h-full rounded-full transition-all',
|
| 158 |
+
item.type === 'Dine In' && 'bg-emerald-500',
|
| 159 |
+
item.type === 'Takeaway' && 'bg-blue-500',
|
| 160 |
+
item.type === 'Delivery' && 'bg-violet-500'
|
| 161 |
+
)}
|
| 162 |
+
style={{ width: `${item.percentage}%` }}
|
| 163 |
+
/>
|
| 164 |
+
</div>
|
| 165 |
+
</div>
|
| 166 |
+
))}
|
| 167 |
+
</CardContent>
|
| 168 |
+
</Card>
|
| 169 |
+
</div>
|
| 170 |
+
|
| 171 |
+
<div className="grid gap-6 lg:grid-cols-2">
|
| 172 |
+
{/* Recent Orders */}
|
| 173 |
+
<Card>
|
| 174 |
+
<CardHeader className="flex flex-row items-center justify-between">
|
| 175 |
+
<CardTitle>Recent Orders</CardTitle>
|
| 176 |
+
<Link href="/dashboard/orders">
|
| 177 |
+
<Button variant="ghost" size="sm" className="text-xs">
|
| 178 |
+
View All <ArrowUpRight className="h-3 w-3" />
|
| 179 |
+
</Button>
|
| 180 |
+
</Link>
|
| 181 |
+
</CardHeader>
|
| 182 |
+
<CardContent>
|
| 183 |
+
<div className="space-y-3">
|
| 184 |
+
{demoOrders.slice(0, 5).map((order) => (
|
| 185 |
+
<div key={order.id} className="flex items-center justify-between rounded-xl border border-zinc-100 p-3 transition-all hover:bg-zinc-50 dark:border-zinc-800 dark:hover:bg-zinc-800/50">
|
| 186 |
+
<div className="flex items-center gap-3">
|
| 187 |
+
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-zinc-100 text-sm font-bold text-zinc-600 dark:bg-zinc-800">
|
| 188 |
+
#{order.id.split('-')[1]}
|
| 189 |
+
</div>
|
| 190 |
+
<div>
|
| 191 |
+
<p className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{order.customer_name}</p>
|
| 192 |
+
<div className="flex items-center gap-2 text-xs text-zinc-500">
|
| 193 |
+
<span>{getOrderTypeLabel(order.order_type)}</span>
|
| 194 |
+
{order.table_number && <span>• Table {order.table_number}</span>}
|
| 195 |
+
<span>• <Clock className="inline h-3 w-3" /> {formatRelativeTime(order.created_at)}</span>
|
| 196 |
+
</div>
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
| 199 |
+
<div className="text-right">
|
| 200 |
+
<p className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">{formatCurrency(order.total)}</p>
|
| 201 |
+
<Badge className={cn('mt-1 text-[10px]', getOrderStatusColor(order.status))} variant="outline">
|
| 202 |
+
{order.status}
|
| 203 |
+
</Badge>
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
+
))}
|
| 207 |
+
</div>
|
| 208 |
+
</CardContent>
|
| 209 |
+
</Card>
|
| 210 |
+
|
| 211 |
+
{/* Popular Items */}
|
| 212 |
+
<Card>
|
| 213 |
+
<CardHeader className="flex flex-row items-center justify-between">
|
| 214 |
+
<CardTitle>Popular Items</CardTitle>
|
| 215 |
+
<Link href="/dashboard/analytics">
|
| 216 |
+
<Button variant="ghost" size="sm" className="text-xs">
|
| 217 |
+
Analytics <ArrowUpRight className="h-3 w-3" />
|
| 218 |
+
</Button>
|
| 219 |
+
</Link>
|
| 220 |
+
</CardHeader>
|
| 221 |
+
<CardContent>
|
| 222 |
+
<div className="space-y-3">
|
| 223 |
+
{popularItems.map((item, index) => (
|
| 224 |
+
<div key={item.name} className="flex items-center justify-between rounded-xl border border-zinc-100 p-3 transition-all hover:bg-zinc-50 dark:border-zinc-800 dark:hover:bg-zinc-800/50">
|
| 225 |
+
<div className="flex items-center gap-3">
|
| 226 |
+
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-50 to-cyan-50 text-sm font-bold text-emerald-600 dark:from-emerald-950/30 dark:to-cyan-950/30">
|
| 227 |
+
{index + 1}
|
| 228 |
+
</div>
|
| 229 |
+
<div>
|
| 230 |
+
<p className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{item.name}</p>
|
| 231 |
+
<p className="text-xs text-zinc-500">{item.orders} orders</p>
|
| 232 |
+
</div>
|
| 233 |
+
</div>
|
| 234 |
+
<p className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">{formatCurrency(item.revenue)}</p>
|
| 235 |
+
</div>
|
| 236 |
+
))}
|
| 237 |
+
</div>
|
| 238 |
+
</CardContent>
|
| 239 |
+
</Card>
|
| 240 |
+
</div>
|
| 241 |
+
</div>
|
| 242 |
+
</DashboardLayout>
|
| 243 |
+
);
|
| 244 |
+
}
|
src/app/(dashboard)/qr-manager/page.tsx
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
| 4 |
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
| 5 |
+
import { Button } from '@/components/ui/button';
|
| 6 |
+
import { Badge } from '@/components/ui/badge';
|
| 7 |
+
import { Input } from '@/components/ui/input';
|
| 8 |
+
import {
|
| 9 |
+
Plus,
|
| 10 |
+
Download,
|
| 11 |
+
QrCode,
|
| 12 |
+
Eye,
|
| 13 |
+
Copy,
|
| 14 |
+
Printer,
|
| 15 |
+
Trash2,
|
| 16 |
+
ScanLine,
|
| 17 |
+
Link as LinkIcon,
|
| 18 |
+
ExternalLink,
|
| 19 |
+
Table2,
|
| 20 |
+
} from 'lucide-react';
|
| 21 |
+
import { demoQRCodes, demoTables } from '@/lib/demo-data';
|
| 22 |
+
import { cn } from '@/lib/utils';
|
| 23 |
+
import { useState } from 'react';
|
| 24 |
+
|
| 25 |
+
function QRCodePreview({ label, scans, isActive }: { label: string; scans: number; isActive: boolean }) {
|
| 26 |
+
return (
|
| 27 |
+
<div className="flex flex-col items-center gap-3 rounded-2xl border border-zinc-200/60 bg-white p-6 transition-all hover:shadow-md hover:border-zinc-300 dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700">
|
| 28 |
+
{/* QR Code Visual */}
|
| 29 |
+
<div className="relative">
|
| 30 |
+
<div className="flex h-40 w-40 items-center justify-center rounded-2xl bg-white p-3 shadow-inner ring-1 ring-zinc-100">
|
| 31 |
+
{/* Stylized QR code */}
|
| 32 |
+
<div className="grid h-full w-full grid-cols-7 grid-rows-7 gap-[2px]">
|
| 33 |
+
{Array.from({ length: 49 }).map((_, i) => {
|
| 34 |
+
const row = Math.floor(i / 7);
|
| 35 |
+
const col = i % 7;
|
| 36 |
+
const isCorner = (row < 3 && col < 3) || (row < 3 && col > 3) || (row > 3 && col < 3);
|
| 37 |
+
const isFilled = isCorner || Math.random() > 0.4;
|
| 38 |
+
return (
|
| 39 |
+
<div
|
| 40 |
+
key={i}
|
| 41 |
+
className={cn(
|
| 42 |
+
'rounded-[2px] transition-colors',
|
| 43 |
+
isFilled ? 'bg-zinc-900' : 'bg-transparent'
|
| 44 |
+
)}
|
| 45 |
+
/>
|
| 46 |
+
);
|
| 47 |
+
})}
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
{!isActive && (
|
| 51 |
+
<div className="absolute inset-0 flex items-center justify-center rounded-2xl bg-white/80 backdrop-blur-sm">
|
| 52 |
+
<Badge variant="destructive">Disabled</Badge>
|
| 53 |
+
</div>
|
| 54 |
+
)}
|
| 55 |
+
</div>
|
| 56 |
+
|
| 57 |
+
{/* Info */}
|
| 58 |
+
<div className="text-center">
|
| 59 |
+
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">{label}</h3>
|
| 60 |
+
<div className="mt-1 flex items-center justify-center gap-1 text-xs text-zinc-500">
|
| 61 |
+
<ScanLine className="h-3 w-3" />
|
| 62 |
+
{scans} scans
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
{/* Actions */}
|
| 67 |
+
<div className="flex gap-1.5">
|
| 68 |
+
<Button variant="outline" size="icon-sm">
|
| 69 |
+
<Download className="h-3.5 w-3.5" />
|
| 70 |
+
</Button>
|
| 71 |
+
<Button variant="outline" size="icon-sm">
|
| 72 |
+
<Copy className="h-3.5 w-3.5" />
|
| 73 |
+
</Button>
|
| 74 |
+
<Button variant="outline" size="icon-sm">
|
| 75 |
+
<Printer className="h-3.5 w-3.5" />
|
| 76 |
+
</Button>
|
| 77 |
+
<Button variant="outline" size="icon-sm">
|
| 78 |
+
<Eye className="h-3.5 w-3.5" />
|
| 79 |
+
</Button>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
export default function QRManagerPage() {
|
| 86 |
+
const [activeTab, setActiveTab] = useState<'qr' | 'tables'>('qr');
|
| 87 |
+
|
| 88 |
+
return (
|
| 89 |
+
<DashboardLayout>
|
| 90 |
+
<div className="space-y-6">
|
| 91 |
+
{/* Header */}
|
| 92 |
+
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
| 93 |
+
<div>
|
| 94 |
+
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white sm:text-3xl">QR Codes</h1>
|
| 95 |
+
<p className="mt-1 text-sm text-zinc-500">Generate and manage QR codes for your restaurant.</p>
|
| 96 |
+
</div>
|
| 97 |
+
<div className="flex gap-2">
|
| 98 |
+
<Button variant="outline" size="sm">
|
| 99 |
+
<Download className="h-4 w-4" /> Download All
|
| 100 |
+
</Button>
|
| 101 |
+
<Button variant="primary" size="sm">
|
| 102 |
+
<Plus className="h-4 w-4" /> New QR Code
|
| 103 |
+
</Button>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
{/* Tabs */}
|
| 108 |
+
<div className="flex gap-1 rounded-xl border border-zinc-200 bg-zinc-50 p-1 dark:border-zinc-700 dark:bg-zinc-900">
|
| 109 |
+
<button
|
| 110 |
+
onClick={() => setActiveTab('qr')}
|
| 111 |
+
className={cn(
|
| 112 |
+
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
| 113 |
+
activeTab === 'qr' ? 'bg-white text-zinc-900 shadow-sm dark:bg-zinc-800 dark:text-white' : 'text-zinc-500 hover:text-zinc-700'
|
| 114 |
+
)}
|
| 115 |
+
>
|
| 116 |
+
<QrCode className="h-4 w-4" /> QR Codes
|
| 117 |
+
<Badge variant="secondary" className="ml-1 text-[10px]">{demoQRCodes.length}</Badge>
|
| 118 |
+
</button>
|
| 119 |
+
<button
|
| 120 |
+
onClick={() => setActiveTab('tables')}
|
| 121 |
+
className={cn(
|
| 122 |
+
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
| 123 |
+
activeTab === 'tables' ? 'bg-white text-zinc-900 shadow-sm dark:bg-zinc-800 dark:text-white' : 'text-zinc-500 hover:text-zinc-700'
|
| 124 |
+
)}
|
| 125 |
+
>
|
| 126 |
+
<Table2 className="h-4 w-4" /> Tables
|
| 127 |
+
<Badge variant="secondary" className="ml-1 text-[10px]">{demoTables.length}</Badge>
|
| 128 |
+
</button>
|
| 129 |
+
</div>
|
| 130 |
+
|
| 131 |
+
{activeTab === 'qr' ? (
|
| 132 |
+
<>
|
| 133 |
+
{/* Quick Stats */}
|
| 134 |
+
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
| 135 |
+
<div className="rounded-xl border border-zinc-200/60 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
|
| 136 |
+
<p className="text-xs font-medium text-zinc-500">Total QR Codes</p>
|
| 137 |
+
<p className="mt-1 text-2xl font-bold">{demoQRCodes.length}</p>
|
| 138 |
+
</div>
|
| 139 |
+
<div className="rounded-xl border border-zinc-200/60 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
|
| 140 |
+
<p className="text-xs font-medium text-zinc-500">Total Scans</p>
|
| 141 |
+
<p className="mt-1 text-2xl font-bold">{demoQRCodes.reduce((s, q) => s + q.scans, 0).toLocaleString()}</p>
|
| 142 |
+
</div>
|
| 143 |
+
<div className="rounded-xl border border-zinc-200/60 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
|
| 144 |
+
<p className="text-xs font-medium text-zinc-500">Active</p>
|
| 145 |
+
<p className="mt-1 text-2xl font-bold text-emerald-600">{demoQRCodes.filter((q) => q.is_active).length}</p>
|
| 146 |
+
</div>
|
| 147 |
+
<div className="rounded-xl border border-zinc-200/60 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
|
| 148 |
+
<p className="text-xs font-medium text-zinc-500">Avg. Scans/Code</p>
|
| 149 |
+
<p className="mt-1 text-2xl font-bold">{Math.round(demoQRCodes.reduce((s, q) => s + q.scans, 0) / demoQRCodes.length)}</p>
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
|
| 153 |
+
{/* QR Code Grid */}
|
| 154 |
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
| 155 |
+
{demoQRCodes.map((qr) => (
|
| 156 |
+
<QRCodePreview key={qr.id} label={qr.label} scans={qr.scans} isActive={qr.is_active} />
|
| 157 |
+
))}
|
| 158 |
+
{/* Add New Card */}
|
| 159 |
+
<button className="flex flex-col items-center justify-center gap-3 rounded-2xl border-2 border-dashed border-zinc-200 p-6 text-zinc-400 transition-all hover:border-emerald-300 hover:text-emerald-600 dark:border-zinc-700 dark:hover:border-emerald-700">
|
| 160 |
+
<div className="rounded-xl bg-zinc-100 p-3 dark:bg-zinc-800">
|
| 161 |
+
<Plus className="h-6 w-6" />
|
| 162 |
+
</div>
|
| 163 |
+
<span className="text-sm font-medium">Create New QR Code</span>
|
| 164 |
+
</button>
|
| 165 |
+
</div>
|
| 166 |
+
</>
|
| 167 |
+
) : (
|
| 168 |
+
/* Tables View */
|
| 169 |
+
<Card>
|
| 170 |
+
<CardHeader className="flex flex-row items-center justify-between">
|
| 171 |
+
<CardTitle>Table Management</CardTitle>
|
| 172 |
+
<Button variant="primary" size="sm">
|
| 173 |
+
<Plus className="h-4 w-4" /> Add Table
|
| 174 |
+
</Button>
|
| 175 |
+
</CardHeader>
|
| 176 |
+
<CardContent>
|
| 177 |
+
<div className="overflow-x-auto">
|
| 178 |
+
<table className="w-full">
|
| 179 |
+
<thead>
|
| 180 |
+
<tr className="border-b border-zinc-100 text-left dark:border-zinc-800">
|
| 181 |
+
<th className="pb-3 text-xs font-medium text-zinc-500">Table #</th>
|
| 182 |
+
<th className="pb-3 text-xs font-medium text-zinc-500">Name</th>
|
| 183 |
+
<th className="pb-3 text-xs font-medium text-zinc-500">Capacity</th>
|
| 184 |
+
<th className="pb-3 text-xs font-medium text-zinc-500">Status</th>
|
| 185 |
+
<th className="pb-3 text-xs font-medium text-zinc-500">QR Code</th>
|
| 186 |
+
<th className="pb-3 text-xs font-medium text-zinc-500 text-right">Actions</th>
|
| 187 |
+
</tr>
|
| 188 |
+
</thead>
|
| 189 |
+
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
|
| 190 |
+
{demoTables.map((table) => (
|
| 191 |
+
<tr key={table.id} className="group">
|
| 192 |
+
<td className="py-3">
|
| 193 |
+
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-zinc-100 text-sm font-bold text-zinc-600 dark:bg-zinc-800">
|
| 194 |
+
{table.number}
|
| 195 |
+
</div>
|
| 196 |
+
</td>
|
| 197 |
+
<td className="py-3 text-sm font-medium text-zinc-900 dark:text-zinc-100">{table.name || '—'}</td>
|
| 198 |
+
<td className="py-3 text-sm text-zinc-500">{table.capacity} seats</td>
|
| 199 |
+
<td className="py-3">
|
| 200 |
+
<Badge variant={table.is_active ? 'success' : 'secondary'} className="text-[10px]">
|
| 201 |
+
{table.is_active ? 'Active' : 'Inactive'}
|
| 202 |
+
</Badge>
|
| 203 |
+
</td>
|
| 204 |
+
<td className="py-3">
|
| 205 |
+
<Button variant="ghost" size="sm" className="text-xs">
|
| 206 |
+
<QrCode className="h-3 w-3" /> View QR
|
| 207 |
+
</Button>
|
| 208 |
+
</td>
|
| 209 |
+
<td className="py-3 text-right">
|
| 210 |
+
<div className="flex justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
| 211 |
+
<Button variant="ghost" size="icon-sm"><LinkIcon className="h-3.5 w-3.5" /></Button>
|
| 212 |
+
<Button variant="ghost" size="icon-sm"><Trash2 className="h-3.5 w-3.5 text-red-500" /></Button>
|
| 213 |
+
</div>
|
| 214 |
+
</td>
|
| 215 |
+
</tr>
|
| 216 |
+
))}
|
| 217 |
+
</tbody>
|
| 218 |
+
</table>
|
| 219 |
+
</div>
|
| 220 |
+
</CardContent>
|
| 221 |
+
</Card>
|
| 222 |
+
)}
|
| 223 |
+
</div>
|
| 224 |
+
</DashboardLayout>
|
| 225 |
+
);
|
| 226 |
+
}
|
src/app/(dashboard)/restaurant-setup/page.tsx
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
| 4 |
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
| 5 |
+
import { Button } from '@/components/ui/button';
|
| 6 |
+
import { Input } from '@/components/ui/input';
|
| 7 |
+
import { Textarea } from '@/components/ui/textarea';
|
| 8 |
+
import { Badge } from '@/components/ui/badge';
|
| 9 |
+
import { Store, MapPin, Phone, Mail, Globe, Clock, Image as ImageIcon, Palette, Save, ExternalLink } from 'lucide-react';
|
| 10 |
+
import { demoRestaurant } from '@/lib/demo-data';
|
| 11 |
+
import { cn } from '@/lib/utils';
|
| 12 |
+
|
| 13 |
+
export default function RestaurantSetupPage() {
|
| 14 |
+
return (
|
| 15 |
+
<DashboardLayout>
|
| 16 |
+
<div className="space-y-6">
|
| 17 |
+
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
| 18 |
+
<div>
|
| 19 |
+
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white sm:text-3xl">Restaurant Profile</h1>
|
| 20 |
+
<p className="mt-1 text-sm text-zinc-500">Manage your restaurant's public information and branding.</p>
|
| 21 |
+
</div>
|
| 22 |
+
<div className="flex gap-2">
|
| 23 |
+
<Button variant="outline" size="sm">
|
| 24 |
+
<ExternalLink className="h-4 w-4" /> Preview
|
| 25 |
+
</Button>
|
| 26 |
+
<Button variant="primary" size="sm">
|
| 27 |
+
<Save className="h-4 w-4" /> Save Changes
|
| 28 |
+
</Button>
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
|
| 32 |
+
<div className="grid gap-6 lg:grid-cols-3">
|
| 33 |
+
<div className="space-y-6 lg:col-span-2">
|
| 34 |
+
{/* Basic Info */}
|
| 35 |
+
<Card>
|
| 36 |
+
<CardHeader>
|
| 37 |
+
<CardTitle className="flex items-center gap-2"><Store className="h-4 w-4" /> Basic Information</CardTitle>
|
| 38 |
+
</CardHeader>
|
| 39 |
+
<CardContent className="space-y-4">
|
| 40 |
+
<div className="space-y-2">
|
| 41 |
+
<label className="text-sm font-medium text-zinc-700">Restaurant Name</label>
|
| 42 |
+
<Input defaultValue={demoRestaurant.name} />
|
| 43 |
+
</div>
|
| 44 |
+
<div className="space-y-2">
|
| 45 |
+
<label className="text-sm font-medium text-zinc-700">URL Slug</label>
|
| 46 |
+
<div className="flex items-center gap-2">
|
| 47 |
+
<span className="text-sm text-zinc-400">scanmenu.app/</span>
|
| 48 |
+
<Input defaultValue={demoRestaurant.slug} className="flex-1" />
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
<div className="space-y-2">
|
| 52 |
+
<label className="text-sm font-medium text-zinc-700">Description</label>
|
| 53 |
+
<Textarea defaultValue={demoRestaurant.description} rows={3} />
|
| 54 |
+
</div>
|
| 55 |
+
<div className="grid grid-cols-2 gap-4">
|
| 56 |
+
<div className="space-y-2">
|
| 57 |
+
<label className="text-sm font-medium text-zinc-700">Currency</label>
|
| 58 |
+
<Input defaultValue={demoRestaurant.currency} />
|
| 59 |
+
</div>
|
| 60 |
+
<div className="space-y-2">
|
| 61 |
+
<label className="text-sm font-medium text-zinc-700">Timezone</label>
|
| 62 |
+
<Input defaultValue={demoRestaurant.timezone} />
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
</CardContent>
|
| 66 |
+
</Card>
|
| 67 |
+
|
| 68 |
+
{/* Contact */}
|
| 69 |
+
<Card>
|
| 70 |
+
<CardHeader>
|
| 71 |
+
<CardTitle className="flex items-center gap-2"><Phone className="h-4 w-4" /> Contact Information</CardTitle>
|
| 72 |
+
</CardHeader>
|
| 73 |
+
<CardContent className="space-y-4">
|
| 74 |
+
<div className="space-y-2">
|
| 75 |
+
<label className="text-sm font-medium text-zinc-700">Address</label>
|
| 76 |
+
<div className="relative">
|
| 77 |
+
<MapPin className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
|
| 78 |
+
<Input defaultValue={demoRestaurant.address} className="pl-10" />
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
<div className="grid grid-cols-2 gap-4">
|
| 82 |
+
<div className="space-y-2">
|
| 83 |
+
<label className="text-sm font-medium text-zinc-700">Phone</label>
|
| 84 |
+
<div className="relative">
|
| 85 |
+
<Phone className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
|
| 86 |
+
<Input defaultValue={demoRestaurant.phone} className="pl-10" />
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
<div className="space-y-2">
|
| 90 |
+
<label className="text-sm font-medium text-zinc-700">Email</label>
|
| 91 |
+
<div className="relative">
|
| 92 |
+
<Mail className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
|
| 93 |
+
<Input defaultValue={demoRestaurant.email} className="pl-10" />
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
<div className="space-y-2">
|
| 98 |
+
<label className="text-sm font-medium text-zinc-700">Website</label>
|
| 99 |
+
<div className="relative">
|
| 100 |
+
<Globe className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
|
| 101 |
+
<Input defaultValue={demoRestaurant.website} className="pl-10" />
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
</CardContent>
|
| 105 |
+
</Card>
|
| 106 |
+
|
| 107 |
+
{/* Opening Hours */}
|
| 108 |
+
<Card>
|
| 109 |
+
<CardHeader>
|
| 110 |
+
<CardTitle className="flex items-center gap-2"><Clock className="h-4 w-4" /> Opening Hours</CardTitle>
|
| 111 |
+
</CardHeader>
|
| 112 |
+
<CardContent>
|
| 113 |
+
<div className="space-y-3">
|
| 114 |
+
{Object.entries(demoRestaurant.opening_hours || {}).map(([day, hours]) => (
|
| 115 |
+
<div key={day} className="flex items-center gap-4">
|
| 116 |
+
<span className="w-24 text-sm font-medium capitalize text-zinc-700">{day}</span>
|
| 117 |
+
<div className="flex items-center gap-2 flex-1">
|
| 118 |
+
<Input type="time" defaultValue={hours.open} className="w-32" />
|
| 119 |
+
<span className="text-zinc-400">to</span>
|
| 120 |
+
<Input type="time" defaultValue={hours.close} className="w-32" />
|
| 121 |
+
</div>
|
| 122 |
+
<label className="flex items-center gap-2">
|
| 123 |
+
<input type="checkbox" defaultChecked={!hours.closed} className="h-4 w-4 rounded border-zinc-300 text-emerald-600 focus:ring-emerald-500" />
|
| 124 |
+
<span className="text-xs text-zinc-500">Open</span>
|
| 125 |
+
</label>
|
| 126 |
+
</div>
|
| 127 |
+
))}
|
| 128 |
+
</div>
|
| 129 |
+
</CardContent>
|
| 130 |
+
</Card>
|
| 131 |
+
</div>
|
| 132 |
+
|
| 133 |
+
{/* Sidebar */}
|
| 134 |
+
<div className="space-y-6">
|
| 135 |
+
{/* Logo */}
|
| 136 |
+
<Card>
|
| 137 |
+
<CardHeader>
|
| 138 |
+
<CardTitle className="flex items-center gap-2"><ImageIcon className="h-4 w-4" /> Logo</CardTitle>
|
| 139 |
+
</CardHeader>
|
| 140 |
+
<CardContent>
|
| 141 |
+
<div className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-zinc-200 bg-zinc-50 p-8 dark:border-zinc-700 dark:bg-zinc-800/50">
|
| 142 |
+
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 to-cyan-500 text-2xl font-bold text-white shadow-lg">
|
| 143 |
+
GK
|
| 144 |
+
</div>
|
| 145 |
+
<Button variant="outline" size="sm" className="mt-4">Upload Logo</Button>
|
| 146 |
+
</div>
|
| 147 |
+
</CardContent>
|
| 148 |
+
</Card>
|
| 149 |
+
|
| 150 |
+
{/* Cover Image */}
|
| 151 |
+
<Card>
|
| 152 |
+
<CardHeader>
|
| 153 |
+
<CardTitle className="flex items-center gap-2"><ImageIcon className="h-4 w-4" /> Cover Image</CardTitle>
|
| 154 |
+
</CardHeader>
|
| 155 |
+
<CardContent>
|
| 156 |
+
<div className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-zinc-200 bg-zinc-50 p-8 dark:border-zinc-700 dark:bg-zinc-800/50">
|
| 157 |
+
<ImageIcon className="h-10 w-10 text-zinc-300" />
|
| 158 |
+
<p className="mt-2 text-xs text-zinc-400">Recommended: 1200 x 400</p>
|
| 159 |
+
<Button variant="outline" size="sm" className="mt-3">Upload Cover</Button>
|
| 160 |
+
</div>
|
| 161 |
+
</CardContent>
|
| 162 |
+
</Card>
|
| 163 |
+
|
| 164 |
+
{/* Theme */}
|
| 165 |
+
<Card>
|
| 166 |
+
<CardHeader>
|
| 167 |
+
<CardTitle className="flex items-center gap-2"><Palette className="h-4 w-4" /> Theme Color</CardTitle>
|
| 168 |
+
</CardHeader>
|
| 169 |
+
<CardContent>
|
| 170 |
+
<div className="flex gap-2">
|
| 171 |
+
{['#10b981', '#3b82f6', '#8b5cf6', '#f59e0b', '#ef4444', '#ec4899', '#14b8a6', '#6366f1'].map((color) => (
|
| 172 |
+
<button
|
| 173 |
+
key={color}
|
| 174 |
+
className={cn(
|
| 175 |
+
"h-8 w-8 rounded-lg ring-offset-2 transition-all hover:scale-110",
|
| 176 |
+
color === demoRestaurant.theme_color ? "ring-2 ring-emerald-500" : "ring-2 ring-transparent"
|
| 177 |
+
)}
|
| 178 |
+
style={{ backgroundColor: color }}
|
| 179 |
+
/>
|
| 180 |
+
))}
|
| 181 |
+
</div>
|
| 182 |
+
</CardContent>
|
| 183 |
+
</Card>
|
| 184 |
+
|
| 185 |
+
{/* Status */}
|
| 186 |
+
<Card>
|
| 187 |
+
<CardContent className="pt-6">
|
| 188 |
+
<div className="flex items-center justify-between">
|
| 189 |
+
<span className="text-sm font-medium text-zinc-700">Restaurant Status</span>
|
| 190 |
+
<Badge variant="success">Active</Badge>
|
| 191 |
+
</div>
|
| 192 |
+
<p className="mt-2 text-xs text-zinc-500">Your restaurant is visible to customers</p>
|
| 193 |
+
</CardContent>
|
| 194 |
+
</Card>
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
</div>
|
| 198 |
+
</DashboardLayout>
|
| 199 |
+
);
|
| 200 |
+
}
|
src/app/(dashboard)/settings/page.tsx
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
| 4 |
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
| 5 |
+
import { Button } from '@/components/ui/button';
|
| 6 |
+
import { Input } from '@/components/ui/input';
|
| 7 |
+
import { Badge } from '@/components/ui/badge';
|
| 8 |
+
import { User, Bell, Shield, Key, Globe, Trash2, Save, UserPlus, Mail } from 'lucide-react';
|
| 9 |
+
import { demoUser } from '@/lib/demo-data';
|
| 10 |
+
|
| 11 |
+
export default function SettingsPage() {
|
| 12 |
+
return (
|
| 13 |
+
<DashboardLayout>
|
| 14 |
+
<div className="space-y-6">
|
| 15 |
+
<div>
|
| 16 |
+
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white sm:text-3xl">Settings</h1>
|
| 17 |
+
<p className="mt-1 text-sm text-zinc-500">Manage your account, notifications, and security.</p>
|
| 18 |
+
</div>
|
| 19 |
+
|
| 20 |
+
<div className="grid gap-6 lg:grid-cols-3">
|
| 21 |
+
<div className="space-y-6 lg:col-span-2">
|
| 22 |
+
{/* Profile */}
|
| 23 |
+
<Card>
|
| 24 |
+
<CardHeader>
|
| 25 |
+
<CardTitle className="flex items-center gap-2"><User className="h-4 w-4" /> Profile</CardTitle>
|
| 26 |
+
</CardHeader>
|
| 27 |
+
<CardContent className="space-y-4">
|
| 28 |
+
<div className="flex items-center gap-4">
|
| 29 |
+
<div className="flex h-16 w-16 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-fuchsia-500 text-lg font-bold text-white">
|
| 30 |
+
AR
|
| 31 |
+
</div>
|
| 32 |
+
<div>
|
| 33 |
+
<Button variant="outline" size="sm">Change Avatar</Button>
|
| 34 |
+
<p className="mt-1 text-xs text-zinc-400">JPG, PNG. Max 2MB</p>
|
| 35 |
+
</div>
|
| 36 |
+
</div>
|
| 37 |
+
<div className="grid grid-cols-2 gap-4">
|
| 38 |
+
<div className="space-y-2">
|
| 39 |
+
<label className="text-sm font-medium text-zinc-700">Full Name</label>
|
| 40 |
+
<Input defaultValue={demoUser.full_name} />
|
| 41 |
+
</div>
|
| 42 |
+
<div className="space-y-2">
|
| 43 |
+
<label className="text-sm font-medium text-zinc-700">Email</label>
|
| 44 |
+
<Input defaultValue={demoUser.email} type="email" />
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|
| 47 |
+
<div className="flex justify-end">
|
| 48 |
+
<Button variant="primary" size="sm"><Save className="h-4 w-4" /> Save</Button>
|
| 49 |
+
</div>
|
| 50 |
+
</CardContent>
|
| 51 |
+
</Card>
|
| 52 |
+
|
| 53 |
+
{/* Security */}
|
| 54 |
+
<Card>
|
| 55 |
+
<CardHeader>
|
| 56 |
+
<CardTitle className="flex items-center gap-2"><Shield className="h-4 w-4" /> Security</CardTitle>
|
| 57 |
+
</CardHeader>
|
| 58 |
+
<CardContent className="space-y-4">
|
| 59 |
+
<div className="space-y-2">
|
| 60 |
+
<label className="text-sm font-medium text-zinc-700">Current Password</label>
|
| 61 |
+
<Input type="password" placeholder="••••••••" />
|
| 62 |
+
</div>
|
| 63 |
+
<div className="grid grid-cols-2 gap-4">
|
| 64 |
+
<div className="space-y-2">
|
| 65 |
+
<label className="text-sm font-medium text-zinc-700">New Password</label>
|
| 66 |
+
<Input type="password" placeholder="Min. 8 characters" />
|
| 67 |
+
</div>
|
| 68 |
+
<div className="space-y-2">
|
| 69 |
+
<label className="text-sm font-medium text-zinc-700">Confirm Password</label>
|
| 70 |
+
<Input type="password" placeholder="Confirm new password" />
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
<div className="flex justify-end">
|
| 74 |
+
<Button variant="primary" size="sm"><Key className="h-4 w-4" /> Update Password</Button>
|
| 75 |
+
</div>
|
| 76 |
+
</CardContent>
|
| 77 |
+
</Card>
|
| 78 |
+
|
| 79 |
+
{/* Notifications */}
|
| 80 |
+
<Card>
|
| 81 |
+
<CardHeader>
|
| 82 |
+
<CardTitle className="flex items-center gap-2"><Bell className="h-4 w-4" /> Notifications</CardTitle>
|
| 83 |
+
</CardHeader>
|
| 84 |
+
<CardContent>
|
| 85 |
+
<div className="space-y-4">
|
| 86 |
+
{[
|
| 87 |
+
{ label: 'New orders', description: 'Get notified when a new order is placed', enabled: true },
|
| 88 |
+
{ label: 'Order status updates', description: 'Notifications when order status changes', enabled: true },
|
| 89 |
+
{ label: 'Daily summary', description: 'Receive a daily summary of your sales', enabled: false },
|
| 90 |
+
{ label: 'Weekly reports', description: 'Weekly analytics and performance report', enabled: true },
|
| 91 |
+
{ label: 'Marketing emails', description: 'Tips, product updates, and promotions', enabled: false },
|
| 92 |
+
].map((setting) => (
|
| 93 |
+
<div key={setting.label} className="flex items-center justify-between rounded-xl border border-zinc-100 p-3 dark:border-zinc-800">
|
| 94 |
+
<div>
|
| 95 |
+
<p className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{setting.label}</p>
|
| 96 |
+
<p className="text-xs text-zinc-500">{setting.description}</p>
|
| 97 |
+
</div>
|
| 98 |
+
<button className={`relative h-6 w-11 rounded-full transition-colors ${setting.enabled ? 'bg-emerald-500' : 'bg-zinc-300 dark:bg-zinc-600'}`}>
|
| 99 |
+
<span className={`absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${setting.enabled ? 'translate-x-5' : ''}`} />
|
| 100 |
+
</button>
|
| 101 |
+
</div>
|
| 102 |
+
))}
|
| 103 |
+
</div>
|
| 104 |
+
</CardContent>
|
| 105 |
+
</Card>
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
{/* Sidebar */}
|
| 109 |
+
<div className="space-y-6">
|
| 110 |
+
{/* Team */}
|
| 111 |
+
<Card>
|
| 112 |
+
<CardHeader className="flex flex-row items-center justify-between">
|
| 113 |
+
<CardTitle className="text-base">Team Members</CardTitle>
|
| 114 |
+
<Button variant="outline" size="sm"><UserPlus className="h-3.5 w-3.5" /> Invite</Button>
|
| 115 |
+
</CardHeader>
|
| 116 |
+
<CardContent>
|
| 117 |
+
<div className="space-y-3">
|
| 118 |
+
{[
|
| 119 |
+
{ name: 'Alex Rivera', email: 'alex@scanmenu.app', role: 'Owner' },
|
| 120 |
+
{ name: 'Jordan Kim', email: 'jordan@scanmenu.app', role: 'Staff' },
|
| 121 |
+
{ name: 'Sam Patel', email: 'sam@scanmenu.app', role: 'Staff' },
|
| 122 |
+
].map((member) => (
|
| 123 |
+
<div key={member.email} className="flex items-center gap-3">
|
| 124 |
+
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-zinc-100 text-xs font-bold text-zinc-600 dark:bg-zinc-800">
|
| 125 |
+
{member.name.split(' ').map(n => n[0]).join('')}
|
| 126 |
+
</div>
|
| 127 |
+
<div className="flex-1 min-w-0">
|
| 128 |
+
<p className="text-sm font-medium truncate">{member.name}</p>
|
| 129 |
+
<p className="text-xs text-zinc-500 truncate">{member.email}</p>
|
| 130 |
+
</div>
|
| 131 |
+
<Badge variant={member.role === 'Owner' ? 'success' : 'secondary'} className="text-[10px]">
|
| 132 |
+
{member.role}
|
| 133 |
+
</Badge>
|
| 134 |
+
</div>
|
| 135 |
+
))}
|
| 136 |
+
</div>
|
| 137 |
+
</CardContent>
|
| 138 |
+
</Card>
|
| 139 |
+
|
| 140 |
+
{/* API Keys */}
|
| 141 |
+
<Card>
|
| 142 |
+
<CardHeader>
|
| 143 |
+
<CardTitle className="text-base flex items-center gap-2"><Key className="h-4 w-4" /> API Keys</CardTitle>
|
| 144 |
+
</CardHeader>
|
| 145 |
+
<CardContent className="space-y-3">
|
| 146 |
+
<div className="rounded-xl bg-zinc-50 p-3 dark:bg-zinc-800/50">
|
| 147 |
+
<p className="text-xs font-medium text-zinc-500">Live Key</p>
|
| 148 |
+
<p className="mt-1 font-mono text-xs text-zinc-700 dark:text-zinc-300">sk_live_•••••••••••4xK2</p>
|
| 149 |
+
</div>
|
| 150 |
+
<Button variant="outline" size="sm" className="w-full">Generate New Key</Button>
|
| 151 |
+
</CardContent>
|
| 152 |
+
</Card>
|
| 153 |
+
|
| 154 |
+
{/* Danger Zone */}
|
| 155 |
+
<Card className="border-red-200 dark:border-red-900/50">
|
| 156 |
+
<CardHeader>
|
| 157 |
+
<CardTitle className="text-base text-red-600">Danger Zone</CardTitle>
|
| 158 |
+
</CardHeader>
|
| 159 |
+
<CardContent className="space-y-3">
|
| 160 |
+
<p className="text-xs text-zinc-500">Permanently delete your restaurant and all associated data.</p>
|
| 161 |
+
<Button variant="destructive" size="sm" className="w-full">
|
| 162 |
+
<Trash2 className="h-4 w-4" /> Delete Restaurant
|
| 163 |
+
</Button>
|
| 164 |
+
</CardContent>
|
| 165 |
+
</Card>
|
| 166 |
+
</div>
|
| 167 |
+
</div>
|
| 168 |
+
</div>
|
| 169 |
+
</DashboardLayout>
|
| 170 |
+
);
|
| 171 |
+
}
|
src/app/(public)/restaurant/[slug]/page.tsx
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from 'react';
|
| 4 |
+
import { Button } from '@/components/ui/button';
|
| 5 |
+
import { Badge } from '@/components/ui/badge';
|
| 6 |
+
import { Input } from '@/components/ui/input';
|
| 7 |
+
import { Textarea } from '@/components/ui/textarea';
|
| 8 |
+
import {
|
| 9 |
+
ShoppingBag,
|
| 10 |
+
MapPin,
|
| 11 |
+
Phone,
|
| 12 |
+
Clock,
|
| 13 |
+
Star,
|
| 14 |
+
Minus,
|
| 15 |
+
Plus,
|
| 16 |
+
X,
|
| 17 |
+
Search,
|
| 18 |
+
ChevronRight,
|
| 19 |
+
Flame,
|
| 20 |
+
Leaf,
|
| 21 |
+
AlertCircle,
|
| 22 |
+
Truck,
|
| 23 |
+
UtensilsCrossed,
|
| 24 |
+
ArrowLeft,
|
| 25 |
+
CheckCircle2,
|
| 26 |
+
Sparkles,
|
| 27 |
+
ExternalLink,
|
| 28 |
+
} from 'lucide-react';
|
| 29 |
+
import { demoRestaurant, demoCategories, demoProducts } from '@/lib/demo-data';
|
| 30 |
+
import { formatCurrency, cn } from '@/lib/utils';
|
| 31 |
+
import { Product, OrderType, CartItem } from '@/types/database';
|
| 32 |
+
import { useCartStore } from '@/stores/cart-store';
|
| 33 |
+
import Link from 'next/link';
|
| 34 |
+
|
| 35 |
+
function ProductDetailSheet({ product, onClose, onAdd }: { product: Product; onClose: () => void; onAdd: (qty: number) => void }) {
|
| 36 |
+
const [qty, setQty] = useState(1);
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center bg-black/40 backdrop-blur-sm" onClick={onClose}>
|
| 40 |
+
<div className="w-full max-w-md max-h-[85vh] overflow-y-auto rounded-t-3xl sm:rounded-3xl bg-white shadow-2xl animate-in slide-in-from-bottom-4 dark:bg-zinc-900" onClick={(e) => e.stopPropagation()}>
|
| 41 |
+
{/* Product Image Placeholder */}
|
| 42 |
+
<div className="relative h-48 bg-gradient-to-br from-emerald-100 to-cyan-100 dark:from-emerald-900/30 dark:to-cyan-900/30">
|
| 43 |
+
<div className="absolute inset-0 flex items-center justify-center">
|
| 44 |
+
<UtensilsCrossed className="h-16 w-16 text-emerald-300/50" />
|
| 45 |
+
</div>
|
| 46 |
+
<button onClick={onClose} className="absolute right-3 top-3 rounded-full bg-white/80 p-2 backdrop-blur-sm">
|
| 47 |
+
<X className="h-4 w-4" />
|
| 48 |
+
</button>
|
| 49 |
+
{product.is_featured && (
|
| 50 |
+
<div className="absolute left-3 top-3">
|
| 51 |
+
<Badge className="bg-amber-500 text-white border-0">
|
| 52 |
+
<Star className="h-3 w-3 mr-0.5 fill-white" /> Featured
|
| 53 |
+
</Badge>
|
| 54 |
+
</div>
|
| 55 |
+
)}
|
| 56 |
+
</div>
|
| 57 |
+
|
| 58 |
+
<div className="p-5 space-y-4">
|
| 59 |
+
<div>
|
| 60 |
+
<h2 className="text-xl font-bold text-zinc-900 dark:text-zinc-100">{product.name}</h2>
|
| 61 |
+
<p className="mt-1 text-sm text-zinc-500">{product.description}</p>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
{/* Meta */}
|
| 65 |
+
<div className="flex flex-wrap gap-2">
|
| 66 |
+
{product.preparation_time && (
|
| 67 |
+
<Badge variant="secondary"><Clock className="h-3 w-3 mr-0.5" />{product.preparation_time} min</Badge>
|
| 68 |
+
)}
|
| 69 |
+
{product.calories && (
|
| 70 |
+
<Badge variant="secondary"><Flame className="h-3 w-3 mr-0.5" />{product.calories} cal</Badge>
|
| 71 |
+
)}
|
| 72 |
+
{product.tags?.map((tag) => (
|
| 73 |
+
<Badge key={tag} variant="outline" className="capitalize">{tag}</Badge>
|
| 74 |
+
))}
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
{/* Allergens */}
|
| 78 |
+
{product.allergens && product.allergens.length > 0 && (
|
| 79 |
+
<div className="flex items-start gap-2 rounded-xl bg-amber-50 p-3 text-sm dark:bg-amber-950/20">
|
| 80 |
+
<AlertCircle className="h-4 w-4 shrink-0 text-amber-600 mt-0.5" />
|
| 81 |
+
<div>
|
| 82 |
+
<p className="font-medium text-amber-800 dark:text-amber-400">Allergens</p>
|
| 83 |
+
<p className="text-amber-700/70 dark:text-amber-500/70 capitalize">{product.allergens.join(', ')}</p>
|
| 84 |
+
</div>
|
| 85 |
+
</div>
|
| 86 |
+
)}
|
| 87 |
+
|
| 88 |
+
{/* Price & Qty */}
|
| 89 |
+
<div className="flex items-center justify-between rounded-xl bg-zinc-50 p-4 dark:bg-zinc-800/50">
|
| 90 |
+
<span className="text-2xl font-bold text-zinc-900 dark:text-zinc-100">{formatCurrency(product.price)}</span>
|
| 91 |
+
<div className="flex items-center gap-3">
|
| 92 |
+
<button
|
| 93 |
+
onClick={() => setQty(Math.max(1, qty - 1))}
|
| 94 |
+
className="flex h-9 w-9 items-center justify-center rounded-xl border border-zinc-200 text-zinc-600 hover:bg-zinc-100 dark:border-zinc-700 dark:hover:bg-zinc-800"
|
| 95 |
+
>
|
| 96 |
+
<Minus className="h-4 w-4" />
|
| 97 |
+
</button>
|
| 98 |
+
<span className="w-6 text-center text-lg font-bold">{qty}</span>
|
| 99 |
+
<button
|
| 100 |
+
onClick={() => setQty(qty + 1)}
|
| 101 |
+
className="flex h-9 w-9 items-center justify-center rounded-xl bg-emerald-600 text-white hover:bg-emerald-500"
|
| 102 |
+
>
|
| 103 |
+
<Plus className="h-4 w-4" />
|
| 104 |
+
</button>
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
<Button
|
| 109 |
+
variant="primary"
|
| 110 |
+
size="lg"
|
| 111 |
+
className="w-full"
|
| 112 |
+
onClick={() => { onAdd(qty); onClose(); }}
|
| 113 |
+
>
|
| 114 |
+
<ShoppingBag className="h-4 w-4" />
|
| 115 |
+
Add to Order — {formatCurrency(product.price * qty)}
|
| 116 |
+
</Button>
|
| 117 |
+
</div>
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
function CartSheet({ onClose }: { onClose: () => void }) {
|
| 124 |
+
const { items, removeItem, updateQuantity, getSubtotal, getTax, getTotal, orderType, setOrderType, tableNumber, setTableNumber, customerName, setCustomerName, customerPhone, setCustomerPhone, deliveryAddress, setDeliveryAddress, notes, setNotes, clearCart } = useCartStore();
|
| 125 |
+
const [step, setStep] = useState<'cart' | 'details' | 'success'>('cart');
|
| 126 |
+
|
| 127 |
+
if (step === 'success') {
|
| 128 |
+
return (
|
| 129 |
+
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center bg-black/40 backdrop-blur-sm">
|
| 130 |
+
<div className="w-full max-w-md rounded-t-3xl sm:rounded-3xl bg-white p-8 text-center shadow-2xl dark:bg-zinc-900">
|
| 131 |
+
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/30">
|
| 132 |
+
<CheckCircle2 className="h-8 w-8 text-emerald-600" />
|
| 133 |
+
</div>
|
| 134 |
+
<h2 className="mt-4 text-xl font-bold text-zinc-900 dark:text-zinc-100">Order Placed!</h2>
|
| 135 |
+
<p className="mt-2 text-sm text-zinc-500">Your order has been received and is being prepared. Estimated wait time: 15-20 minutes.</p>
|
| 136 |
+
<p className="mt-4 text-sm font-medium text-zinc-700 dark:text-zinc-300">Order #1234</p>
|
| 137 |
+
<Button variant="primary" className="mt-6 w-full" onClick={() => { clearCart(); onClose(); }}>
|
| 138 |
+
Done
|
| 139 |
+
</Button>
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
return (
|
| 146 |
+
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center bg-black/40 backdrop-blur-sm" onClick={onClose}>
|
| 147 |
+
<div className="w-full max-w-md max-h-[90vh] flex flex-col rounded-t-3xl sm:rounded-3xl bg-white shadow-2xl dark:bg-zinc-900" onClick={(e) => e.stopPropagation()}>
|
| 148 |
+
{/* Header */}
|
| 149 |
+
<div className="flex items-center justify-between border-b border-zinc-100 p-5 dark:border-zinc-800">
|
| 150 |
+
<div className="flex items-center gap-2">
|
| 151 |
+
{step === 'details' && (
|
| 152 |
+
<button onClick={() => setStep('cart')} className="rounded-lg p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
| 153 |
+
<ArrowLeft className="h-4 w-4" />
|
| 154 |
+
</button>
|
| 155 |
+
)}
|
| 156 |
+
<h2 className="text-lg font-bold">{step === 'cart' ? 'Your Order' : 'Order Details'}</h2>
|
| 157 |
+
</div>
|
| 158 |
+
<button onClick={onClose} className="rounded-lg p-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
| 159 |
+
<X className="h-4 w-4" />
|
| 160 |
+
</button>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
<div className="flex-1 overflow-y-auto p-5 space-y-4">
|
| 164 |
+
{step === 'cart' ? (
|
| 165 |
+
<>
|
| 166 |
+
{/* Order Type */}
|
| 167 |
+
<div className="grid grid-cols-3 gap-2">
|
| 168 |
+
{[
|
| 169 |
+
{ type: 'dine_in' as OrderType, label: 'Dine In', icon: UtensilsCrossed },
|
| 170 |
+
{ type: 'takeaway' as OrderType, label: 'Takeaway', icon: ShoppingBag },
|
| 171 |
+
{ type: 'delivery' as OrderType, label: 'Delivery', icon: Truck },
|
| 172 |
+
].map(({ type, label, icon: Icon }) => (
|
| 173 |
+
<button
|
| 174 |
+
key={type}
|
| 175 |
+
onClick={() => setOrderType(type)}
|
| 176 |
+
className={cn(
|
| 177 |
+
'flex flex-col items-center gap-1.5 rounded-xl border p-3 text-xs font-medium transition-all',
|
| 178 |
+
orderType === type
|
| 179 |
+
? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30'
|
| 180 |
+
: 'border-zinc-200 text-zinc-500 hover:border-zinc-300 dark:border-zinc-700'
|
| 181 |
+
)}
|
| 182 |
+
>
|
| 183 |
+
<Icon className="h-4 w-4" />
|
| 184 |
+
{label}
|
| 185 |
+
</button>
|
| 186 |
+
))}
|
| 187 |
+
</div>
|
| 188 |
+
|
| 189 |
+
{orderType === 'dine_in' && (
|
| 190 |
+
<Input placeholder="Table number" value={tableNumber || ''} onChange={(e) => setTableNumber(e.target.value)} />
|
| 191 |
+
)}
|
| 192 |
+
|
| 193 |
+
{/* Cart Items */}
|
| 194 |
+
{items.length === 0 ? (
|
| 195 |
+
<div className="py-12 text-center">
|
| 196 |
+
<ShoppingBag className="mx-auto h-10 w-10 text-zinc-200" />
|
| 197 |
+
<p className="mt-3 text-sm text-zinc-500">Your cart is empty</p>
|
| 198 |
+
</div>
|
| 199 |
+
) : (
|
| 200 |
+
<div className="space-y-3">
|
| 201 |
+
{items.map((item) => (
|
| 202 |
+
<div key={item.id} className="flex items-center gap-3 rounded-xl border border-zinc-100 p-3 dark:border-zinc-800">
|
| 203 |
+
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-emerald-50 dark:bg-emerald-900/30">
|
| 204 |
+
<UtensilsCrossed className="h-5 w-5 text-emerald-500/50" />
|
| 205 |
+
</div>
|
| 206 |
+
<div className="flex-1 min-w-0">
|
| 207 |
+
<p className="text-sm font-medium truncate">{item.product.name}</p>
|
| 208 |
+
<p className="text-xs text-zinc-500">{formatCurrency(item.product.price)} each</p>
|
| 209 |
+
</div>
|
| 210 |
+
<div className="flex items-center gap-2">
|
| 211 |
+
<button onClick={() => updateQuantity(item.id, item.quantity - 1)} className="flex h-7 w-7 items-center justify-center rounded-lg border border-zinc-200 text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700">
|
| 212 |
+
<Minus className="h-3 w-3" />
|
| 213 |
+
</button>
|
| 214 |
+
<span className="w-5 text-center text-sm font-bold">{item.quantity}</span>
|
| 215 |
+
<button onClick={() => updateQuantity(item.id, item.quantity + 1)} className="flex h-7 w-7 items-center justify-center rounded-lg bg-emerald-600 text-white hover:bg-emerald-500">
|
| 216 |
+
<Plus className="h-3 w-3" />
|
| 217 |
+
</button>
|
| 218 |
+
</div>
|
| 219 |
+
<span className="w-16 text-right text-sm font-semibold">{formatCurrency(item.total)}</span>
|
| 220 |
+
</div>
|
| 221 |
+
))}
|
| 222 |
+
</div>
|
| 223 |
+
)}
|
| 224 |
+
</>
|
| 225 |
+
) : (
|
| 226 |
+
/* Details Step */
|
| 227 |
+
<div className="space-y-4">
|
| 228 |
+
<div className="space-y-2">
|
| 229 |
+
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Your Name</label>
|
| 230 |
+
<Input placeholder="Full name" value={customerName} onChange={(e) => setCustomerName(e.target.value)} />
|
| 231 |
+
</div>
|
| 232 |
+
<div className="space-y-2">
|
| 233 |
+
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Phone Number</label>
|
| 234 |
+
<Input placeholder="+1 (555) 000-0000" value={customerPhone} onChange={(e) => setCustomerPhone(e.target.value)} />
|
| 235 |
+
</div>
|
| 236 |
+
{orderType === 'delivery' && (
|
| 237 |
+
<div className="space-y-2">
|
| 238 |
+
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Delivery Address</label>
|
| 239 |
+
<Textarea placeholder="Full delivery address" value={deliveryAddress} onChange={(e) => setDeliveryAddress(e.target.value)} />
|
| 240 |
+
</div>
|
| 241 |
+
)}
|
| 242 |
+
<div className="space-y-2">
|
| 243 |
+
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Special Instructions</label>
|
| 244 |
+
<Textarea placeholder="Allergies, preferences, etc." value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} />
|
| 245 |
+
</div>
|
| 246 |
+
</div>
|
| 247 |
+
)}
|
| 248 |
+
</div>
|
| 249 |
+
|
| 250 |
+
{/* Footer */}
|
| 251 |
+
{items.length > 0 && (
|
| 252 |
+
<div className="border-t border-zinc-100 p-5 dark:border-zinc-800">
|
| 253 |
+
<div className="space-y-1.5 mb-4">
|
| 254 |
+
<div className="flex justify-between text-sm text-zinc-500">
|
| 255 |
+
<span>Subtotal</span>
|
| 256 |
+
<span>{formatCurrency(getSubtotal())}</span>
|
| 257 |
+
</div>
|
| 258 |
+
<div className="flex justify-between text-sm text-zinc-500">
|
| 259 |
+
<span>Tax</span>
|
| 260 |
+
<span>{formatCurrency(getTax())}</span>
|
| 261 |
+
</div>
|
| 262 |
+
<div className="flex justify-between text-base font-bold text-zinc-900 dark:text-zinc-100">
|
| 263 |
+
<span>Total</span>
|
| 264 |
+
<span>{formatCurrency(getTotal())}</span>
|
| 265 |
+
</div>
|
| 266 |
+
</div>
|
| 267 |
+
<Button
|
| 268 |
+
variant="primary"
|
| 269 |
+
size="lg"
|
| 270 |
+
className="w-full"
|
| 271 |
+
onClick={() => step === 'cart' ? setStep('details') : setStep('success')}
|
| 272 |
+
>
|
| 273 |
+
{step === 'cart' ? (
|
| 274 |
+
<>Continue <ChevronRight className="h-4 w-4" /></>
|
| 275 |
+
) : (
|
| 276 |
+
<>Place Order — {formatCurrency(getTotal())}</>
|
| 277 |
+
)}
|
| 278 |
+
</Button>
|
| 279 |
+
</div>
|
| 280 |
+
)}
|
| 281 |
+
</div>
|
| 282 |
+
</div>
|
| 283 |
+
);
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
export default function PublicMenuPage() {
|
| 287 |
+
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
| 288 |
+
const [showCart, setShowCart] = useState(false);
|
| 289 |
+
const [activeCategory, setActiveCategory] = useState(demoCategories[0]?.id);
|
| 290 |
+
const [search, setSearch] = useState('');
|
| 291 |
+
const { addItem, getItemCount, setRestaurant } = useCartStore();
|
| 292 |
+
|
| 293 |
+
useEffect(() => {
|
| 294 |
+
setRestaurant(demoRestaurant.id, demoRestaurant.name);
|
| 295 |
+
}, [setRestaurant]);
|
| 296 |
+
|
| 297 |
+
const itemCount = getItemCount();
|
| 298 |
+
|
| 299 |
+
const filteredProducts = search
|
| 300 |
+
? demoProducts.filter((p) => p.name.toLowerCase().includes(search.toLowerCase()) || p.description?.toLowerCase().includes(search.toLowerCase()))
|
| 301 |
+
: demoProducts;
|
| 302 |
+
|
| 303 |
+
return (
|
| 304 |
+
<div className="min-h-screen bg-white dark:bg-zinc-950">
|
| 305 |
+
{/* Header */}
|
| 306 |
+
<div className="relative overflow-hidden bg-gradient-to-br from-emerald-600 via-emerald-500 to-cyan-500 text-white">
|
| 307 |
+
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PHBhdHRlcm4gaWQ9ImEiIHBhdHRlcm5Vbml0cz0idXNlclNwYWNlT25Vc2UiIHdpZHRoPSI2MCIgaGVpZ2h0PSI2MCI+PHBhdGggZD0iTTAgMGg2MHY2MEgweiIgZmlsbD0ibm9uZSIvPjxwYXRoIGQ9Ik0wIDBoMXYxSDB6IiBmaWxsPSJyZ2JhKDI1NSwyNTUsMjU1LDAuMDUpIi8+PC9wYXR0ZXJuPjwvZGVmcz48cmVjdCBmaWxsPSJ1cmwoI2EpIiB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIi8+PC9zdmc+')] opacity-30" />
|
| 308 |
+
<div className="relative px-4 pt-12 pb-6 sm:px-6">
|
| 309 |
+
<Link href="/" className="inline-flex items-center gap-1.5 text-white/70 hover:text-white text-sm mb-4">
|
| 310 |
+
<ArrowLeft className="h-4 w-4" /> Back
|
| 311 |
+
</Link>
|
| 312 |
+
<div className="flex items-center gap-4">
|
| 313 |
+
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/20 backdrop-blur-sm text-xl font-bold shadow-lg">
|
| 314 |
+
GK
|
| 315 |
+
</div>
|
| 316 |
+
<div>
|
| 317 |
+
<h1 className="text-2xl font-bold">{demoRestaurant.name}</h1>
|
| 318 |
+
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-white/80">
|
| 319 |
+
<span className="flex items-center gap-1"><MapPin className="h-3.5 w-3.5" /> San Francisco</span>
|
| 320 |
+
<span>•</span>
|
| 321 |
+
<span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5" /> Open until 10 PM</span>
|
| 322 |
+
<span>•</span>
|
| 323 |
+
<span className="flex items-center gap-1"><Star className="h-3.5 w-3.5 fill-white" /> 4.8</span>
|
| 324 |
+
</div>
|
| 325 |
+
</div>
|
| 326 |
+
</div>
|
| 327 |
+
</div>
|
| 328 |
+
|
| 329 |
+
{/* Category Nav */}
|
| 330 |
+
<div className="relative px-4 pb-3 sm:px-6">
|
| 331 |
+
<div className="flex gap-2 overflow-x-auto scrollbar-none pb-1">
|
| 332 |
+
{demoCategories.map((cat) => (
|
| 333 |
+
<button
|
| 334 |
+
key={cat.id}
|
| 335 |
+
onClick={() => { setActiveCategory(cat.id); setSearch(''); }}
|
| 336 |
+
className={cn(
|
| 337 |
+
'whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition-all',
|
| 338 |
+
activeCategory === cat.id
|
| 339 |
+
? 'bg-white text-emerald-700 shadow-sm'
|
| 340 |
+
: 'bg-white/15 text-white hover:bg-white/25'
|
| 341 |
+
)}
|
| 342 |
+
>
|
| 343 |
+
{cat.name}
|
| 344 |
+
</button>
|
| 345 |
+
))}
|
| 346 |
+
</div>
|
| 347 |
+
</div>
|
| 348 |
+
</div>
|
| 349 |
+
|
| 350 |
+
{/* Search */}
|
| 351 |
+
<div className="sticky top-0 z-20 border-b border-zinc-100 bg-white/80 px-4 py-3 backdrop-blur-xl sm:px-6 dark:border-zinc-800 dark:bg-zinc-950/80">
|
| 352 |
+
<div className="relative">
|
| 353 |
+
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
|
| 354 |
+
<Input
|
| 355 |
+
placeholder="Search the menu..."
|
| 356 |
+
value={search}
|
| 357 |
+
onChange={(e) => setSearch(e.target.value)}
|
| 358 |
+
className="pl-10 bg-zinc-50 border-zinc-100 dark:bg-zinc-900"
|
| 359 |
+
/>
|
| 360 |
+
</div>
|
| 361 |
+
</div>
|
| 362 |
+
|
| 363 |
+
{/* Products */}
|
| 364 |
+
<div className="px-4 py-4 pb-32 sm:px-6">
|
| 365 |
+
{(search ? [{ id: 'search', name: 'Search Results' }] : demoCategories.filter((c) => !activeCategory || c.id === activeCategory)).map((cat) => {
|
| 366 |
+
const catProducts = filteredProducts.filter((p) =>
|
| 367 |
+
search ? true : p.category_id === cat.id
|
| 368 |
+
);
|
| 369 |
+
if (catProducts.length === 0) return null;
|
| 370 |
+
|
| 371 |
+
return (
|
| 372 |
+
<div key={cat.id} className="mb-8">
|
| 373 |
+
<h2 className="mb-4 text-lg font-bold text-zinc-900 dark:text-zinc-100">{cat.name}</h2>
|
| 374 |
+
<div className="grid gap-3 sm:grid-cols-2">
|
| 375 |
+
{catProducts.map((product) => (
|
| 376 |
+
<button
|
| 377 |
+
key={product.id}
|
| 378 |
+
onClick={() => setSelectedProduct(product)}
|
| 379 |
+
className="flex gap-3 rounded-2xl border border-zinc-100 bg-white p-3 text-left transition-all hover:border-zinc-200 hover:shadow-sm active:scale-[0.99] dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700"
|
| 380 |
+
>
|
| 381 |
+
{/* Image placeholder */}
|
| 382 |
+
<div className="flex h-20 w-20 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-zinc-50 to-zinc-100 dark:from-zinc-800 dark:to-zinc-700">
|
| 383 |
+
<UtensilsCrossed className="h-6 w-6 text-zinc-300 dark:text-zinc-600" />
|
| 384 |
+
</div>
|
| 385 |
+
<div className="flex-1 min-w-0">
|
| 386 |
+
<div className="flex items-center gap-1.5">
|
| 387 |
+
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 truncate">{product.name}</h3>
|
| 388 |
+
{product.is_featured && <Star className="h-3 w-3 shrink-0 fill-amber-400 text-amber-400" />}
|
| 389 |
+
</div>
|
| 390 |
+
<p className="mt-0.5 line-clamp-2 text-xs text-zinc-500">{product.description}</p>
|
| 391 |
+
<div className="mt-2 flex items-center justify-between">
|
| 392 |
+
<span className="text-sm font-bold text-emerald-600">{formatCurrency(product.price)}</span>
|
| 393 |
+
<div className="flex gap-1">
|
| 394 |
+
{product.tags?.includes('vegetarian') && <Leaf className="h-3.5 w-3.5 text-green-500" />}
|
| 395 |
+
{product.tags?.includes('popular') && <Flame className="h-3.5 w-3.5 text-orange-500" />}
|
| 396 |
+
</div>
|
| 397 |
+
</div>
|
| 398 |
+
</div>
|
| 399 |
+
</button>
|
| 400 |
+
))}
|
| 401 |
+
</div>
|
| 402 |
+
</div>
|
| 403 |
+
);
|
| 404 |
+
})}
|
| 405 |
+
</div>
|
| 406 |
+
|
| 407 |
+
{/* Floating Cart Button */}
|
| 408 |
+
{itemCount > 0 && (
|
| 409 |
+
<div className="fixed bottom-6 left-4 right-4 z-30 sm:left-auto sm:right-6 sm:w-80">
|
| 410 |
+
<Button
|
| 411 |
+
variant="primary"
|
| 412 |
+
size="xl"
|
| 413 |
+
className="w-full shadow-2xl shadow-emerald-500/30"
|
| 414 |
+
onClick={() => setShowCart(true)}
|
| 415 |
+
>
|
| 416 |
+
<ShoppingBag className="h-5 w-5" />
|
| 417 |
+
View Order ({itemCount})
|
| 418 |
+
<span className="ml-auto">{formatCurrency(useCartStore.getState().getTotal())}</span>
|
| 419 |
+
</Button>
|
| 420 |
+
</div>
|
| 421 |
+
)}
|
| 422 |
+
|
| 423 |
+
{/* Product Detail Sheet */}
|
| 424 |
+
{selectedProduct && (
|
| 425 |
+
<ProductDetailSheet
|
| 426 |
+
product={selectedProduct}
|
| 427 |
+
onClose={() => setSelectedProduct(null)}
|
| 428 |
+
onAdd={(qty) => addItem(selectedProduct, qty)}
|
| 429 |
+
/>
|
| 430 |
+
)}
|
| 431 |
+
|
| 432 |
+
{/* Cart Sheet */}
|
| 433 |
+
{showCart && <CartSheet onClose={() => setShowCart(false)} />}
|
| 434 |
+
</div>
|
| 435 |
+
);
|
| 436 |
+
}
|
src/app/favicon.ico
ADDED
|
|
src/app/globals.css
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
--background: #ffffff;
|
| 5 |
+
--foreground: #09090b;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
@theme inline {
|
| 9 |
+
--color-background: var(--background);
|
| 10 |
+
--color-foreground: var(--foreground);
|
| 11 |
+
--font-sans: var(--font-inter);
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
@media (prefers-color-scheme: dark) {
|
| 15 |
+
:root {
|
| 16 |
+
--background: #09090b;
|
| 17 |
+
--foreground: #fafafa;
|
| 18 |
+
}
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
body {
|
| 22 |
+
background: var(--background);
|
| 23 |
+
color: var(--foreground);
|
| 24 |
+
font-family: var(--font-inter), system-ui, -apple-system, sans-serif;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
/* Scrollbar utilities */
|
| 28 |
+
.scrollbar-none::-webkit-scrollbar {
|
| 29 |
+
display: none;
|
| 30 |
+
}
|
| 31 |
+
.scrollbar-none {
|
| 32 |
+
-ms-overflow-style: none;
|
| 33 |
+
scrollbar-width: none;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.scrollbar-thin::-webkit-scrollbar {
|
| 37 |
+
width: 4px;
|
| 38 |
+
}
|
| 39 |
+
.scrollbar-thin::-webkit-scrollbar-track {
|
| 40 |
+
background: transparent;
|
| 41 |
+
}
|
| 42 |
+
.scrollbar-thin::-webkit-scrollbar-thumb {
|
| 43 |
+
background-color: #d4d4d8;
|
| 44 |
+
border-radius: 9999px;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/* Animation utilities */
|
| 48 |
+
@keyframes slide-in-from-bottom {
|
| 49 |
+
from {
|
| 50 |
+
transform: translateY(16px);
|
| 51 |
+
opacity: 0;
|
| 52 |
+
}
|
| 53 |
+
to {
|
| 54 |
+
transform: translateY(0);
|
| 55 |
+
opacity: 1;
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
.animate-in {
|
| 60 |
+
animation: slide-in-from-bottom 0.3s ease-out;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.slide-in-from-bottom-4 {
|
| 64 |
+
animation: slide-in-from-bottom 0.3s ease-out;
|
| 65 |
+
}
|
src/app/layout.tsx
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { Inter } from "next/font/google";
|
| 3 |
+
import "./globals.css";
|
| 4 |
+
|
| 5 |
+
const inter = Inter({
|
| 6 |
+
variable: "--font-inter",
|
| 7 |
+
subsets: ["latin"],
|
| 8 |
+
});
|
| 9 |
+
|
| 10 |
+
export const metadata: Metadata = {
|
| 11 |
+
title: "ScanMenu — Digital Menus & QR Ordering for Restaurants",
|
| 12 |
+
description: "Create stunning digital menus, generate QR codes, and let customers order instantly. The modern way to run your restaurant.",
|
| 13 |
+
keywords: ["restaurant", "digital menu", "QR code", "online ordering", "SaaS"],
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
export default function RootLayout({
|
| 17 |
+
children,
|
| 18 |
+
}: Readonly<{
|
| 19 |
+
children: React.ReactNode;
|
| 20 |
+
}>) {
|
| 21 |
+
return (
|
| 22 |
+
<html lang="en" className={`${inter.variable} h-full antialiased`}>
|
| 23 |
+
<body className="min-h-full flex flex-col font-sans">{children}</body>
|
| 24 |
+
</html>
|
| 25 |
+
);
|
| 26 |
+
}
|
src/app/page.tsx
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import Link from 'next/link';
|
| 4 |
+
import { Button } from '@/components/ui/button';
|
| 5 |
+
import { Badge } from '@/components/ui/badge';
|
| 6 |
+
import {
|
| 7 |
+
Sparkles,
|
| 8 |
+
ArrowRight,
|
| 9 |
+
QrCode,
|
| 10 |
+
Smartphone,
|
| 11 |
+
BarChart3,
|
| 12 |
+
CreditCard,
|
| 13 |
+
ChefHat,
|
| 14 |
+
Zap,
|
| 15 |
+
Star,
|
| 16 |
+
Menu,
|
| 17 |
+
X,
|
| 18 |
+
ShoppingBag,
|
| 19 |
+
UtensilsCrossed,
|
| 20 |
+
} from 'lucide-react';
|
| 21 |
+
import { useState } from 'react';
|
| 22 |
+
import { cn } from '@/lib/utils';
|
| 23 |
+
|
| 24 |
+
function Navbar() {
|
| 25 |
+
const [open, setOpen] = useState(false);
|
| 26 |
+
return (
|
| 27 |
+
<nav className="fixed top-0 left-0 right-0 z-50 border-b border-zinc-200/50 bg-white/80 backdrop-blur-xl dark:border-zinc-800/50 dark:bg-zinc-950/80">
|
| 28 |
+
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-4 sm:px-6">
|
| 29 |
+
<Link href="/" className="flex items-center gap-2.5">
|
| 30 |
+
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-emerald-500 to-cyan-500 shadow-sm">
|
| 31 |
+
<Sparkles className="h-4 w-4 text-white" />
|
| 32 |
+
</div>
|
| 33 |
+
<span className="text-lg font-bold tracking-tight">
|
| 34 |
+
Scan<span className="text-emerald-600">Menu</span>
|
| 35 |
+
</span>
|
| 36 |
+
</Link>
|
| 37 |
+
|
| 38 |
+
<div className="hidden items-center gap-8 md:flex">
|
| 39 |
+
<a href="#features" className="text-sm font-medium text-zinc-600 hover:text-zinc-900 transition-colors">Features</a>
|
| 40 |
+
<Link href="/pricing" className="text-sm font-medium text-zinc-600 hover:text-zinc-900 transition-colors">Pricing</Link>
|
| 41 |
+
<Link href="/restaurant/the-garden-kitchen" className="text-sm font-medium text-zinc-600 hover:text-zinc-900 transition-colors">Demo Menu</Link>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
<div className="hidden items-center gap-3 md:flex">
|
| 45 |
+
<Link href="/login"><Button variant="ghost" size="sm">Sign In</Button></Link>
|
| 46 |
+
<Link href="/register"><Button variant="primary" size="sm">Get Started Free <ArrowRight className="h-3.5 w-3.5" /></Button></Link>
|
| 47 |
+
</div>
|
| 48 |
+
|
| 49 |
+
<button onClick={() => setOpen(!open)} className="md:hidden rounded-lg p-2 hover:bg-zinc-100">
|
| 50 |
+
{open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
| 51 |
+
</button>
|
| 52 |
+
</div>
|
| 53 |
+
|
| 54 |
+
{open && (
|
| 55 |
+
<div className="border-t border-zinc-200 bg-white p-4 md:hidden dark:border-zinc-800 dark:bg-zinc-950">
|
| 56 |
+
<div className="space-y-2">
|
| 57 |
+
<a href="#features" className="block rounded-lg px-3 py-2 text-sm font-medium text-zinc-600 hover:bg-zinc-50">Features</a>
|
| 58 |
+
<Link href="/pricing" className="block rounded-lg px-3 py-2 text-sm font-medium text-zinc-600 hover:bg-zinc-50">Pricing</Link>
|
| 59 |
+
<Link href="/restaurant/the-garden-kitchen" className="block rounded-lg px-3 py-2 text-sm font-medium text-zinc-600 hover:bg-zinc-50">Demo Menu</Link>
|
| 60 |
+
<div className="pt-2 space-y-2 border-t border-zinc-100">
|
| 61 |
+
<Link href="/login"><Button variant="outline" className="w-full">Sign In</Button></Link>
|
| 62 |
+
<Link href="/register"><Button variant="primary" className="w-full">Get Started Free</Button></Link>
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
)}
|
| 67 |
+
</nav>
|
| 68 |
+
);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
const features = [
|
| 72 |
+
{ icon: QrCode, title: 'QR Code Menus', description: 'Generate unique QR codes for each table. Customers scan and browse your menu instantly — no app needed.' },
|
| 73 |
+
{ icon: Smartphone, title: 'Mobile-First Design', description: 'Beautiful, responsive menus optimized for every screen size. Your menu looks amazing on any device.' },
|
| 74 |
+
{ icon: ShoppingBag, title: 'Online Ordering', description: 'Accept dine-in, takeaway, and delivery orders directly. No commissions, no middlemen.' },
|
| 75 |
+
{ icon: ChefHat, title: 'Kitchen Dashboard', description: 'Real-time order management for your staff. Track, prepare, and fulfill orders efficiently.' },
|
| 76 |
+
{ icon: BarChart3, title: 'Smart Analytics', description: 'Understand your business with detailed insights on revenue, popular items, and peak hours.' },
|
| 77 |
+
{ icon: CreditCard, title: 'Integrated Payments', description: 'Accept payments seamlessly with Stripe. Subscriptions, invoicing, and billing made simple.' },
|
| 78 |
+
];
|
| 79 |
+
|
| 80 |
+
const stats = [
|
| 81 |
+
{ value: '10K+', label: 'Restaurants' },
|
| 82 |
+
{ value: '2.5M', label: 'Orders Processed' },
|
| 83 |
+
{ value: '4.9★', label: 'Average Rating' },
|
| 84 |
+
{ value: '32%', label: 'Revenue Increase' },
|
| 85 |
+
];
|
| 86 |
+
|
| 87 |
+
const testimonials = [
|
| 88 |
+
{ name: 'Maria Santos', role: 'Owner, Bella Cucina', text: 'ScanMenu completely transformed how we handle orders. Our customers love the simplicity, and we\'ve seen a 32% increase in average order value.' },
|
| 89 |
+
{ name: 'David Park', role: 'Manager, Seoul Kitchen', text: 'The QR code ordering eliminated wait times. We now serve 40% more tables during peak hours. The analytics are incredibly useful.' },
|
| 90 |
+
{ name: 'Sophie Laurent', role: 'Owner, Le Petit Bistro', text: 'Setting up was incredibly easy. Within an hour, we had our entire menu digitized with beautiful product pages and QR codes printed.' },
|
| 91 |
+
];
|
| 92 |
+
|
| 93 |
+
export default function LandingPage() {
|
| 94 |
+
return (
|
| 95 |
+
<div className="min-h-screen bg-white dark:bg-zinc-950">
|
| 96 |
+
<Navbar />
|
| 97 |
+
|
| 98 |
+
{/* Hero */}
|
| 99 |
+
<section className="relative overflow-hidden pt-32 pb-20 sm:pt-40 sm:pb-28">
|
| 100 |
+
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-emerald-100/50 via-transparent to-transparent dark:from-emerald-900/10" />
|
| 101 |
+
<div className="relative mx-auto max-w-6xl px-4 sm:px-6">
|
| 102 |
+
<div className="text-center">
|
| 103 |
+
<Badge variant="outline" className="mb-6 gap-1.5 rounded-full py-1.5 pl-1.5 pr-3 text-sm">
|
| 104 |
+
<span className="rounded-full bg-emerald-500 px-2 py-0.5 text-[10px] font-bold text-white">NEW</span>
|
| 105 |
+
Real-time order tracking is here
|
| 106 |
+
</Badge>
|
| 107 |
+
|
| 108 |
+
<h1 className="mx-auto max-w-4xl text-4xl font-bold tracking-tight text-zinc-900 sm:text-6xl lg:text-7xl dark:text-white">
|
| 109 |
+
Your restaurant menu,{' '}
|
| 110 |
+
<span className="bg-gradient-to-r from-emerald-600 to-cyan-500 bg-clip-text text-transparent">
|
| 111 |
+
reinvented
|
| 112 |
+
</span>
|
| 113 |
+
</h1>
|
| 114 |
+
|
| 115 |
+
<p className="mx-auto mt-6 max-w-2xl text-lg text-zinc-600 sm:text-xl dark:text-zinc-400">
|
| 116 |
+
Create stunning digital menus, generate QR codes, and let customers order instantly —
|
| 117 |
+
all without installing an app. Start in under 5 minutes.
|
| 118 |
+
</p>
|
| 119 |
+
|
| 120 |
+
<div className="mt-10 flex flex-col items-center justify-center gap-4 sm:flex-row">
|
| 121 |
+
<Link href="/register">
|
| 122 |
+
<Button variant="primary" size="xl" className="w-full sm:w-auto shadow-lg shadow-emerald-500/20">
|
| 123 |
+
Start Free Trial <ArrowRight className="h-4 w-4" />
|
| 124 |
+
</Button>
|
| 125 |
+
</Link>
|
| 126 |
+
<Link href="/restaurant/the-garden-kitchen">
|
| 127 |
+
<Button variant="outline" size="xl" className="w-full sm:w-auto">
|
| 128 |
+
<Smartphone className="h-4 w-4" /> View Demo Menu
|
| 129 |
+
</Button>
|
| 130 |
+
</Link>
|
| 131 |
+
</div>
|
| 132 |
+
|
| 133 |
+
<p className="mt-4 text-sm text-zinc-500">No credit card required • Free 14-day trial • Cancel anytime</p>
|
| 134 |
+
</div>
|
| 135 |
+
|
| 136 |
+
{/* Dashboard Preview */}
|
| 137 |
+
<div className="mt-16 sm:mt-20">
|
| 138 |
+
<div className="relative rounded-2xl border border-zinc-200/60 bg-zinc-900 p-2 shadow-2xl shadow-zinc-900/20 ring-1 ring-zinc-900/5 sm:rounded-3xl sm:p-3">
|
| 139 |
+
<div className="flex items-center gap-1.5 px-3 pb-2">
|
| 140 |
+
<div className="h-3 w-3 rounded-full bg-red-400" />
|
| 141 |
+
<div className="h-3 w-3 rounded-full bg-amber-400" />
|
| 142 |
+
<div className="h-3 w-3 rounded-full bg-emerald-400" />
|
| 143 |
+
<span className="ml-3 text-xs text-zinc-500">scanmenu.app/dashboard</span>
|
| 144 |
+
</div>
|
| 145 |
+
<div className="overflow-hidden rounded-xl bg-zinc-50 dark:bg-zinc-800">
|
| 146 |
+
<div className="grid grid-cols-4 gap-3 p-4">
|
| 147 |
+
{[
|
| 148 |
+
{ label: 'Revenue', value: '$12,845', change: '+12.5%', color: 'text-emerald-600' },
|
| 149 |
+
{ label: 'Orders', value: '347', change: '+8.2%', color: 'text-blue-600' },
|
| 150 |
+
{ label: 'Avg. Order', value: '$37.02', change: '+3.1%', color: 'text-violet-600' },
|
| 151 |
+
{ label: 'Active Tables', value: '5/8', change: '3 free', color: 'text-amber-600' },
|
| 152 |
+
].map((stat) => (
|
| 153 |
+
<div key={stat.label} className="rounded-xl bg-white p-3 shadow-sm dark:bg-zinc-700">
|
| 154 |
+
<p className="text-[10px] font-medium text-zinc-500">{stat.label}</p>
|
| 155 |
+
<p className="mt-1 text-lg font-bold text-zinc-900 dark:text-white">{stat.value}</p>
|
| 156 |
+
<p className={cn('text-[10px] font-medium', stat.color)}>{stat.change}</p>
|
| 157 |
+
</div>
|
| 158 |
+
))}
|
| 159 |
+
</div>
|
| 160 |
+
<div className="px-4 pb-4">
|
| 161 |
+
<div className="flex h-32 items-end gap-[3px] rounded-xl bg-white p-3 shadow-sm dark:bg-zinc-700">
|
| 162 |
+
{Array.from({ length: 30 }).map((_, i) => (
|
| 163 |
+
<div
|
| 164 |
+
key={i}
|
| 165 |
+
className="flex-1 rounded-t-sm bg-emerald-500/80"
|
| 166 |
+
style={{ height: `${30 + Math.random() * 70}%` }}
|
| 167 |
+
/>
|
| 168 |
+
))}
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
</section>
|
| 176 |
+
|
| 177 |
+
{/* Stats */}
|
| 178 |
+
<section className="border-y border-zinc-200 bg-zinc-50 py-12 dark:border-zinc-800 dark:bg-zinc-900/50">
|
| 179 |
+
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
| 180 |
+
<div className="grid grid-cols-2 gap-8 sm:grid-cols-4">
|
| 181 |
+
{stats.map((stat) => (
|
| 182 |
+
<div key={stat.label} className="text-center">
|
| 183 |
+
<p className="text-3xl font-bold tracking-tight text-zinc-900 sm:text-4xl dark:text-white">{stat.value}</p>
|
| 184 |
+
<p className="mt-1 text-sm font-medium text-zinc-500">{stat.label}</p>
|
| 185 |
+
</div>
|
| 186 |
+
))}
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
+
</section>
|
| 190 |
+
|
| 191 |
+
{/* Features */}
|
| 192 |
+
<section id="features" className="py-20 sm:py-28">
|
| 193 |
+
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
| 194 |
+
<div className="text-center">
|
| 195 |
+
<Badge variant="outline" className="mb-4">Features</Badge>
|
| 196 |
+
<h2 className="text-3xl font-bold tracking-tight text-zinc-900 sm:text-4xl dark:text-white">
|
| 197 |
+
Everything you need to go digital
|
| 198 |
+
</h2>
|
| 199 |
+
<p className="mx-auto mt-4 max-w-2xl text-lg text-zinc-600 dark:text-zinc-400">
|
| 200 |
+
From menu creation to order management, ScanMenu has you covered with powerful tools designed for modern restaurants.
|
| 201 |
+
</p>
|
| 202 |
+
</div>
|
| 203 |
+
|
| 204 |
+
<div className="mt-16 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
| 205 |
+
{features.map((feature) => (
|
| 206 |
+
<div key={feature.title} className="group rounded-2xl border border-zinc-200/60 bg-white p-6 transition-all hover:border-zinc-300 hover:shadow-lg dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700">
|
| 207 |
+
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-50 text-emerald-600 transition-colors group-hover:bg-emerald-100 dark:bg-emerald-900/30 dark:group-hover:bg-emerald-900/50">
|
| 208 |
+
<feature.icon className="h-6 w-6" />
|
| 209 |
+
</div>
|
| 210 |
+
<h3 className="mt-4 text-lg font-semibold text-zinc-900 dark:text-zinc-100">{feature.title}</h3>
|
| 211 |
+
<p className="mt-2 text-sm leading-relaxed text-zinc-500">{feature.description}</p>
|
| 212 |
+
</div>
|
| 213 |
+
))}
|
| 214 |
+
</div>
|
| 215 |
+
</div>
|
| 216 |
+
</section>
|
| 217 |
+
|
| 218 |
+
{/* How It Works */}
|
| 219 |
+
<section className="border-y border-zinc-200 bg-zinc-50 py-20 sm:py-28 dark:border-zinc-800 dark:bg-zinc-900/50">
|
| 220 |
+
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
| 221 |
+
<div className="text-center">
|
| 222 |
+
<Badge variant="outline" className="mb-4">How It Works</Badge>
|
| 223 |
+
<h2 className="text-3xl font-bold tracking-tight text-zinc-900 sm:text-4xl dark:text-white">
|
| 224 |
+
Up and running in minutes
|
| 225 |
+
</h2>
|
| 226 |
+
</div>
|
| 227 |
+
|
| 228 |
+
<div className="mt-16 grid gap-8 sm:grid-cols-3">
|
| 229 |
+
{[
|
| 230 |
+
{ step: 1, icon: ChefHat, title: 'Create Your Menu', description: 'Add your restaurant, create categories, and upload your products with images, prices, and options.' },
|
| 231 |
+
{ step: 2, icon: QrCode, title: 'Generate QR Codes', description: 'Create unique QR codes for each table or for takeaway/delivery. Print and display them in your restaurant.' },
|
| 232 |
+
{ step: 3, icon: Zap, title: 'Start Receiving Orders', description: 'Customers scan, browse, and order instantly. Manage everything from your real-time dashboard.' },
|
| 233 |
+
].map((item) => (
|
| 234 |
+
<div key={item.step} className="relative text-center">
|
| 235 |
+
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 to-cyan-500 text-white shadow-lg shadow-emerald-500/30">
|
| 236 |
+
<item.icon className="h-7 w-7" />
|
| 237 |
+
</div>
|
| 238 |
+
<h3 className="mt-6 text-lg font-semibold text-zinc-900 dark:text-zinc-100">{item.title}</h3>
|
| 239 |
+
<p className="mt-2 text-sm text-zinc-500">{item.description}</p>
|
| 240 |
+
</div>
|
| 241 |
+
))}
|
| 242 |
+
</div>
|
| 243 |
+
</div>
|
| 244 |
+
</section>
|
| 245 |
+
|
| 246 |
+
{/* Testimonials */}
|
| 247 |
+
<section className="py-20 sm:py-28">
|
| 248 |
+
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
| 249 |
+
<div className="text-center">
|
| 250 |
+
<Badge variant="outline" className="mb-4">Testimonials</Badge>
|
| 251 |
+
<h2 className="text-3xl font-bold tracking-tight text-zinc-900 sm:text-4xl dark:text-white">
|
| 252 |
+
Loved by restaurant owners
|
| 253 |
+
</h2>
|
| 254 |
+
</div>
|
| 255 |
+
|
| 256 |
+
<div className="mt-16 grid gap-6 sm:grid-cols-3">
|
| 257 |
+
{testimonials.map((t) => (
|
| 258 |
+
<div key={t.name} className="rounded-2xl border border-zinc-200/60 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-900">
|
| 259 |
+
<div className="flex gap-0.5">
|
| 260 |
+
{[1, 2, 3, 4, 5].map((i) => (
|
| 261 |
+
<Star key={i} className="h-4 w-4 fill-amber-400 text-amber-400" />
|
| 262 |
+
))}
|
| 263 |
+
</div>
|
| 264 |
+
<p className="mt-4 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">“{t.text}”</p>
|
| 265 |
+
<div className="mt-4 flex items-center gap-3">
|
| 266 |
+
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-violet-500 to-fuchsia-500 text-xs font-bold text-white">
|
| 267 |
+
{t.name.split(' ').map((n) => n[0]).join('')}
|
| 268 |
+
</div>
|
| 269 |
+
<div>
|
| 270 |
+
<p className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">{t.name}</p>
|
| 271 |
+
<p className="text-xs text-zinc-500">{t.role}</p>
|
| 272 |
+
</div>
|
| 273 |
+
</div>
|
| 274 |
+
</div>
|
| 275 |
+
))}
|
| 276 |
+
</div>
|
| 277 |
+
</div>
|
| 278 |
+
</section>
|
| 279 |
+
|
| 280 |
+
{/* CTA */}
|
| 281 |
+
<section className="border-t border-zinc-200 dark:border-zinc-800">
|
| 282 |
+
<div className="mx-auto max-w-6xl px-4 py-20 sm:px-6 sm:py-28">
|
| 283 |
+
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-br from-zinc-900 via-zinc-800 to-zinc-900 px-8 py-16 text-center shadow-2xl sm:px-16 dark:from-zinc-800 dark:via-zinc-900 dark:to-zinc-800">
|
| 284 |
+
<div className="relative">
|
| 285 |
+
<h2 className="text-3xl font-bold text-white sm:text-4xl">
|
| 286 |
+
Ready to transform your restaurant?
|
| 287 |
+
</h2>
|
| 288 |
+
<p className="mx-auto mt-4 max-w-xl text-lg text-zinc-400">
|
| 289 |
+
Join thousands of restaurants using ScanMenu to increase revenue, reduce wait times, and delight customers.
|
| 290 |
+
</p>
|
| 291 |
+
<div className="mt-8 flex flex-col items-center justify-center gap-4 sm:flex-row">
|
| 292 |
+
<Link href="/register">
|
| 293 |
+
<Button variant="primary" size="xl" className="w-full sm:w-auto">
|
| 294 |
+
Start Free Trial <ArrowRight className="h-4 w-4" />
|
| 295 |
+
</Button>
|
| 296 |
+
</Link>
|
| 297 |
+
<Link href="/restaurant/the-garden-kitchen">
|
| 298 |
+
<Button variant="outline" size="xl" className="w-full border-zinc-700 text-white hover:bg-zinc-800 hover:text-white sm:w-auto">
|
| 299 |
+
View Live Demo
|
| 300 |
+
</Button>
|
| 301 |
+
</Link>
|
| 302 |
+
</div>
|
| 303 |
+
</div>
|
| 304 |
+
</div>
|
| 305 |
+
</div>
|
| 306 |
+
</section>
|
| 307 |
+
|
| 308 |
+
{/* Footer */}
|
| 309 |
+
<footer className="border-t border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900/50">
|
| 310 |
+
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
|
| 311 |
+
<div className="grid gap-8 sm:grid-cols-4">
|
| 312 |
+
<div>
|
| 313 |
+
<Link href="/" className="flex items-center gap-2">
|
| 314 |
+
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-emerald-500 to-cyan-500">
|
| 315 |
+
<Sparkles className="h-3.5 w-3.5 text-white" />
|
| 316 |
+
</div>
|
| 317 |
+
<span className="text-base font-bold">Scan<span className="text-emerald-600">Menu</span></span>
|
| 318 |
+
</Link>
|
| 319 |
+
<p className="mt-3 text-sm text-zinc-500">Digital menus and QR ordering for modern restaurants.</p>
|
| 320 |
+
</div>
|
| 321 |
+
<div>
|
| 322 |
+
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Product</h4>
|
| 323 |
+
<div className="mt-3 space-y-2 text-sm text-zinc-500">
|
| 324 |
+
<p className="hover:text-zinc-700 cursor-pointer">Features</p>
|
| 325 |
+
<p className="hover:text-zinc-700 cursor-pointer">Pricing</p>
|
| 326 |
+
<p className="hover:text-zinc-700 cursor-pointer">Demo</p>
|
| 327 |
+
<p className="hover:text-zinc-700 cursor-pointer">Changelog</p>
|
| 328 |
+
</div>
|
| 329 |
+
</div>
|
| 330 |
+
<div>
|
| 331 |
+
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Company</h4>
|
| 332 |
+
<div className="mt-3 space-y-2 text-sm text-zinc-500">
|
| 333 |
+
<p className="hover:text-zinc-700 cursor-pointer">About</p>
|
| 334 |
+
<p className="hover:text-zinc-700 cursor-pointer">Blog</p>
|
| 335 |
+
<p className="hover:text-zinc-700 cursor-pointer">Careers</p>
|
| 336 |
+
<p className="hover:text-zinc-700 cursor-pointer">Contact</p>
|
| 337 |
+
</div>
|
| 338 |
+
</div>
|
| 339 |
+
<div>
|
| 340 |
+
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Legal</h4>
|
| 341 |
+
<div className="mt-3 space-y-2 text-sm text-zinc-500">
|
| 342 |
+
<p className="hover:text-zinc-700 cursor-pointer">Privacy</p>
|
| 343 |
+
<p className="hover:text-zinc-700 cursor-pointer">Terms</p>
|
| 344 |
+
<p className="hover:text-zinc-700 cursor-pointer">Security</p>
|
| 345 |
+
</div>
|
| 346 |
+
</div>
|
| 347 |
+
</div>
|
| 348 |
+
<div className="mt-8 border-t border-zinc-200 pt-8 text-center text-sm text-zinc-400 dark:border-zinc-800">
|
| 349 |
+
© 2025 ScanMenu. All rights reserved.
|
| 350 |
+
</div>
|
| 351 |
+
</div>
|
| 352 |
+
</footer>
|
| 353 |
+
</div>
|
| 354 |
+
);
|
| 355 |
+
}
|
src/app/pricing/page.tsx
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import Link from 'next/link';
|
| 4 |
+
import { Button } from '@/components/ui/button';
|
| 5 |
+
import { Badge } from '@/components/ui/badge';
|
| 6 |
+
import {
|
| 7 |
+
Sparkles,
|
| 8 |
+
ArrowRight,
|
| 9 |
+
ArrowLeft,
|
| 10 |
+
Check,
|
| 11 |
+
Zap,
|
| 12 |
+
Crown,
|
| 13 |
+
Building2,
|
| 14 |
+
HelpCircle,
|
| 15 |
+
} from 'lucide-react';
|
| 16 |
+
import { useState } from 'react';
|
| 17 |
+
import { cn } from '@/lib/utils';
|
| 18 |
+
|
| 19 |
+
const plans = [
|
| 20 |
+
{
|
| 21 |
+
name: 'Free',
|
| 22 |
+
price: 0,
|
| 23 |
+
yearlyPrice: 0,
|
| 24 |
+
description: 'Try ScanMenu for free with basic features.',
|
| 25 |
+
icon: Sparkles,
|
| 26 |
+
features: [
|
| 27 |
+
'1 restaurant',
|
| 28 |
+
'Up to 15 menu items',
|
| 29 |
+
'2 QR codes',
|
| 30 |
+
'Basic ordering',
|
| 31 |
+
'50 orders/month',
|
| 32 |
+
'Community support',
|
| 33 |
+
],
|
| 34 |
+
cta: 'Get Started',
|
| 35 |
+
popular: false,
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
name: 'Starter',
|
| 39 |
+
price: 29,
|
| 40 |
+
yearlyPrice: 24,
|
| 41 |
+
description: 'Perfect for small restaurants just getting started.',
|
| 42 |
+
icon: Zap,
|
| 43 |
+
features: [
|
| 44 |
+
'1 restaurant',
|
| 45 |
+
'Up to 50 menu items',
|
| 46 |
+
'10 QR codes',
|
| 47 |
+
'Dine-in + Takeaway',
|
| 48 |
+
'Basic analytics',
|
| 49 |
+
'Email support',
|
| 50 |
+
'500 orders/month',
|
| 51 |
+
'Custom branding',
|
| 52 |
+
],
|
| 53 |
+
cta: 'Start Trial',
|
| 54 |
+
popular: false,
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
name: 'Pro',
|
| 58 |
+
price: 79,
|
| 59 |
+
yearlyPrice: 66,
|
| 60 |
+
description: 'For growing restaurants with advanced needs.',
|
| 61 |
+
icon: Crown,
|
| 62 |
+
features: [
|
| 63 |
+
'Unlimited restaurants',
|
| 64 |
+
'Unlimited menu items',
|
| 65 |
+
'Unlimited QR codes',
|
| 66 |
+
'Dine-in + Takeaway + Delivery',
|
| 67 |
+
'Advanced analytics',
|
| 68 |
+
'Priority support',
|
| 69 |
+
'Unlimited orders',
|
| 70 |
+
'Custom branding',
|
| 71 |
+
'API access',
|
| 72 |
+
'Team members (up to 10)',
|
| 73 |
+
'Real-time order tracking',
|
| 74 |
+
'Product options & extras',
|
| 75 |
+
],
|
| 76 |
+
cta: 'Start Trial',
|
| 77 |
+
popular: true,
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
name: 'Enterprise',
|
| 81 |
+
price: 199,
|
| 82 |
+
yearlyPrice: 166,
|
| 83 |
+
description: 'For multi-location chains and franchises.',
|
| 84 |
+
icon: Building2,
|
| 85 |
+
features: [
|
| 86 |
+
'Everything in Pro',
|
| 87 |
+
'Multi-location dashboard',
|
| 88 |
+
'Unlimited team members',
|
| 89 |
+
'Dedicated account manager',
|
| 90 |
+
'Custom integrations',
|
| 91 |
+
'99.9% SLA guarantee',
|
| 92 |
+
'White-label solution',
|
| 93 |
+
'Onboarding training',
|
| 94 |
+
'Advanced security',
|
| 95 |
+
'Custom API limits',
|
| 96 |
+
'Phone support',
|
| 97 |
+
'Invoice billing',
|
| 98 |
+
],
|
| 99 |
+
cta: 'Contact Sales',
|
| 100 |
+
popular: false,
|
| 101 |
+
},
|
| 102 |
+
];
|
| 103 |
+
|
| 104 |
+
const faq = [
|
| 105 |
+
{ q: 'Can I try ScanMenu for free?', a: 'Yes! Our Free plan lets you try ScanMenu with basic features at no cost. We also offer a 14-day free trial on all paid plans — no credit card required.' },
|
| 106 |
+
{ q: 'How do QR codes work?', a: 'Each QR code is linked to a specific table or order type (takeaway/delivery). Customers scan with their phone camera and instantly see your menu — no app download needed.' },
|
| 107 |
+
{ q: 'Can I switch plans later?', a: 'Absolutely! You can upgrade or downgrade your plan at any time. Changes take effect at the start of your next billing cycle, and we prorate any differences.' },
|
| 108 |
+
{ q: 'Do customers need to install an app?', a: 'No! That\'s the beauty of ScanMenu. Customers simply scan a QR code and your menu opens in their phone\'s browser. Zero friction, zero downloads.' },
|
| 109 |
+
{ q: 'What payment methods do you accept?', a: 'We accept all major credit cards (Visa, Mastercard, Amex, Discover) through Stripe. Enterprise customers can also pay via invoice.' },
|
| 110 |
+
{ q: 'Is my data secure?', a: 'Yes. We use Supabase (built on PostgreSQL) with row-level security, SSL encryption, and regular backups. Your data is safe and compliant.' },
|
| 111 |
+
];
|
| 112 |
+
|
| 113 |
+
export default function PricingPage() {
|
| 114 |
+
const [annual, setAnnual] = useState(false);
|
| 115 |
+
const [openFaq, setOpenFaq] = useState<number | null>(0);
|
| 116 |
+
|
| 117 |
+
return (
|
| 118 |
+
<div className="min-h-screen bg-white dark:bg-zinc-950">
|
| 119 |
+
{/* Nav */}
|
| 120 |
+
<nav className="border-b border-zinc-200/50 bg-white/80 backdrop-blur-xl dark:border-zinc-800/50 dark:bg-zinc-950/80">
|
| 121 |
+
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-4 sm:px-6">
|
| 122 |
+
<Link href="/" className="flex items-center gap-2.5">
|
| 123 |
+
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-emerald-500 to-cyan-500 shadow-sm">
|
| 124 |
+
<Sparkles className="h-4 w-4 text-white" />
|
| 125 |
+
</div>
|
| 126 |
+
<span className="text-lg font-bold tracking-tight">Scan<span className="text-emerald-600">Menu</span></span>
|
| 127 |
+
</Link>
|
| 128 |
+
<div className="flex items-center gap-3">
|
| 129 |
+
<Link href="/login"><Button variant="ghost" size="sm">Sign In</Button></Link>
|
| 130 |
+
<Link href="/register"><Button variant="primary" size="sm">Get Started</Button></Link>
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
</nav>
|
| 134 |
+
|
| 135 |
+
{/* Header */}
|
| 136 |
+
<section className="pt-16 pb-8 text-center">
|
| 137 |
+
<div className="mx-auto max-w-3xl px-4">
|
| 138 |
+
<Badge variant="outline" className="mb-4">Pricing</Badge>
|
| 139 |
+
<h1 className="text-4xl font-bold tracking-tight text-zinc-900 sm:text-5xl dark:text-white">
|
| 140 |
+
Simple, transparent pricing
|
| 141 |
+
</h1>
|
| 142 |
+
<p className="mt-4 text-lg text-zinc-600 dark:text-zinc-400">
|
| 143 |
+
Choose the plan that fits your restaurant. Upgrade, downgrade, or cancel anytime.
|
| 144 |
+
</p>
|
| 145 |
+
</div>
|
| 146 |
+
</section>
|
| 147 |
+
|
| 148 |
+
{/* Toggle */}
|
| 149 |
+
<div className="flex items-center justify-center gap-3 pb-12">
|
| 150 |
+
<span className={cn('text-sm font-medium transition-colors', !annual ? 'text-zinc-900' : 'text-zinc-500')}>Monthly</span>
|
| 151 |
+
<button
|
| 152 |
+
onClick={() => setAnnual(!annual)}
|
| 153 |
+
className={cn('relative h-6 w-11 rounded-full transition-colors', annual ? 'bg-emerald-500' : 'bg-zinc-300')}
|
| 154 |
+
>
|
| 155 |
+
<span className={cn('absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform', annual && 'translate-x-5')} />
|
| 156 |
+
</button>
|
| 157 |
+
<span className={cn('text-sm font-medium transition-colors', annual ? 'text-zinc-900' : 'text-zinc-500')}>
|
| 158 |
+
Annual <Badge variant="success" className="ml-1 text-[10px]">Save 17%</Badge>
|
| 159 |
+
</span>
|
| 160 |
+
</div>
|
| 161 |
+
|
| 162 |
+
{/* Plans */}
|
| 163 |
+
<section className="pb-20">
|
| 164 |
+
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
| 165 |
+
<div className="grid gap-6 lg:grid-cols-4">
|
| 166 |
+
{plans.map((plan) => (
|
| 167 |
+
<div
|
| 168 |
+
key={plan.name}
|
| 169 |
+
className={cn(
|
| 170 |
+
'relative flex flex-col rounded-2xl border bg-white p-6 transition-all hover:shadow-lg dark:bg-zinc-900',
|
| 171 |
+
plan.popular
|
| 172 |
+
? 'border-emerald-500 shadow-lg ring-1 ring-emerald-500/20'
|
| 173 |
+
: 'border-zinc-200 dark:border-zinc-800'
|
| 174 |
+
)}
|
| 175 |
+
>
|
| 176 |
+
{plan.popular && (
|
| 177 |
+
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
| 178 |
+
<Badge className="bg-emerald-500 text-white border-0 px-3 py-1">Most Popular</Badge>
|
| 179 |
+
</div>
|
| 180 |
+
)}
|
| 181 |
+
|
| 182 |
+
<div className="mb-6">
|
| 183 |
+
<div className="flex items-center gap-2 mb-3">
|
| 184 |
+
<div className={cn(
|
| 185 |
+
'flex h-9 w-9 items-center justify-center rounded-xl',
|
| 186 |
+
plan.popular ? 'bg-emerald-100 text-emerald-600' : 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800'
|
| 187 |
+
)}>
|
| 188 |
+
<plan.icon className="h-4 w-4" />
|
| 189 |
+
</div>
|
| 190 |
+
<h3 className="text-lg font-bold">{plan.name}</h3>
|
| 191 |
+
</div>
|
| 192 |
+
<p className="text-sm text-zinc-500 mb-4">{plan.description}</p>
|
| 193 |
+
<div className="flex items-baseline gap-1">
|
| 194 |
+
<span className="text-4xl font-bold text-zinc-900 dark:text-white">
|
| 195 |
+
${annual ? plan.yearlyPrice : plan.price}
|
| 196 |
+
</span>
|
| 197 |
+
{plan.price > 0 && <span className="text-zinc-500">/month</span>}
|
| 198 |
+
</div>
|
| 199 |
+
{annual && plan.price > 0 && (
|
| 200 |
+
<p className="mt-1 text-xs text-emerald-600">
|
| 201 |
+
${plan.yearlyPrice * 12}/year (save ${(plan.price - plan.yearlyPrice) * 12})
|
| 202 |
+
</p>
|
| 203 |
+
)}
|
| 204 |
+
</div>
|
| 205 |
+
|
| 206 |
+
<Link href="/register" className="block mb-6">
|
| 207 |
+
<Button
|
| 208 |
+
variant={plan.popular ? 'primary' : 'outline'}
|
| 209 |
+
className="w-full"
|
| 210 |
+
>
|
| 211 |
+
{plan.cta} {plan.price > 0 && <ArrowRight className="h-4 w-4" />}
|
| 212 |
+
</Button>
|
| 213 |
+
</Link>
|
| 214 |
+
|
| 215 |
+
<div className="flex-1 space-y-3">
|
| 216 |
+
{plan.features.map((feature) => (
|
| 217 |
+
<div key={feature} className="flex items-start gap-2 text-sm">
|
| 218 |
+
<Check className={cn('h-4 w-4 shrink-0 mt-0.5', plan.popular ? 'text-emerald-500' : 'text-zinc-400')} />
|
| 219 |
+
<span className="text-zinc-600 dark:text-zinc-400">{feature}</span>
|
| 220 |
+
</div>
|
| 221 |
+
))}
|
| 222 |
+
</div>
|
| 223 |
+
</div>
|
| 224 |
+
))}
|
| 225 |
+
</div>
|
| 226 |
+
</div>
|
| 227 |
+
</section>
|
| 228 |
+
|
| 229 |
+
{/* FAQ */}
|
| 230 |
+
<section className="border-t border-zinc-200 bg-zinc-50 py-20 dark:border-zinc-800 dark:bg-zinc-900/50">
|
| 231 |
+
<div className="mx-auto max-w-3xl px-4 sm:px-6">
|
| 232 |
+
<div className="text-center mb-12">
|
| 233 |
+
<Badge variant="outline" className="mb-4">FAQ</Badge>
|
| 234 |
+
<h2 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-white">Frequently asked questions</h2>
|
| 235 |
+
</div>
|
| 236 |
+
|
| 237 |
+
<div className="space-y-3">
|
| 238 |
+
{faq.map((item, i) => (
|
| 239 |
+
<div key={i} className="rounded-2xl border border-zinc-200 bg-white overflow-hidden dark:border-zinc-800 dark:bg-zinc-900">
|
| 240 |
+
<button
|
| 241 |
+
onClick={() => setOpenFaq(openFaq === i ? null : i)}
|
| 242 |
+
className="flex w-full items-center justify-between p-5 text-left"
|
| 243 |
+
>
|
| 244 |
+
<span className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">{item.q}</span>
|
| 245 |
+
<HelpCircle className={cn('h-4 w-4 shrink-0 text-zinc-400 transition-transform', openFaq === i && 'rotate-180')} />
|
| 246 |
+
</button>
|
| 247 |
+
{openFaq === i && (
|
| 248 |
+
<div className="px-5 pb-5 pt-0">
|
| 249 |
+
<p className="text-sm leading-relaxed text-zinc-500">{item.a}</p>
|
| 250 |
+
</div>
|
| 251 |
+
)}
|
| 252 |
+
</div>
|
| 253 |
+
))}
|
| 254 |
+
</div>
|
| 255 |
+
</div>
|
| 256 |
+
</section>
|
| 257 |
+
|
| 258 |
+
{/* Footer CTA */}
|
| 259 |
+
<section className="py-16 text-center">
|
| 260 |
+
<div className="mx-auto max-w-2xl px-4">
|
| 261 |
+
<h2 className="text-2xl font-bold text-zinc-900 dark:text-white">Still have questions?</h2>
|
| 262 |
+
<p className="mt-2 text-zinc-500">We're here to help. Contact our team for a personalized demo.</p>
|
| 263 |
+
<div className="mt-6 flex justify-center gap-3">
|
| 264 |
+
<Link href="/register"><Button variant="primary">Start Free Trial</Button></Link>
|
| 265 |
+
<Button variant="outline">Contact Sales</Button>
|
| 266 |
+
</div>
|
| 267 |
+
</div>
|
| 268 |
+
</section>
|
| 269 |
+
</div>
|
| 270 |
+
);
|
| 271 |
+
}
|
src/components/layout/dashboard-header.tsx
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { Bell, Search, ChevronDown } from 'lucide-react';
|
| 4 |
+
import { getInitials } from '@/lib/utils';
|
| 5 |
+
import { demoUser, demoRestaurant } from '@/lib/demo-data';
|
| 6 |
+
|
| 7 |
+
export function DashboardHeader() {
|
| 8 |
+
return (
|
| 9 |
+
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b border-zinc-200/60 bg-white/80 px-4 backdrop-blur-xl sm:px-6 dark:border-zinc-800 dark:bg-zinc-950/80">
|
| 10 |
+
{/* Search */}
|
| 11 |
+
<div className="flex items-center gap-3">
|
| 12 |
+
<div className="relative hidden sm:block">
|
| 13 |
+
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
|
| 14 |
+
<input
|
| 15 |
+
type="text"
|
| 16 |
+
placeholder="Search menus, orders, products..."
|
| 17 |
+
className="h-9 w-72 rounded-xl border border-zinc-200 bg-zinc-50 pl-9 pr-4 text-sm placeholder:text-zinc-400 focus:border-emerald-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all dark:border-zinc-700 dark:bg-zinc-900"
|
| 18 |
+
/>
|
| 19 |
+
<kbd className="absolute right-3 top-1/2 -translate-y-1/2 hidden rounded-md border border-zinc-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-zinc-400 sm:inline-block dark:border-zinc-700 dark:bg-zinc-800">
|
| 20 |
+
⌘K
|
| 21 |
+
</kbd>
|
| 22 |
+
</div>
|
| 23 |
+
</div>
|
| 24 |
+
|
| 25 |
+
{/* Right side */}
|
| 26 |
+
<div className="flex items-center gap-2">
|
| 27 |
+
{/* Restaurant switcher */}
|
| 28 |
+
<button className="hidden items-center gap-2 rounded-xl border border-zinc-200 px-3 py-2 text-sm font-medium text-zinc-700 transition-all hover:bg-zinc-50 sm:flex dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800">
|
| 29 |
+
<div className="h-5 w-5 rounded-md bg-gradient-to-br from-emerald-500 to-cyan-500" />
|
| 30 |
+
<span className="max-w-[120px] truncate">{demoRestaurant.name}</span>
|
| 31 |
+
<ChevronDown className="h-3.5 w-3.5 text-zinc-400" />
|
| 32 |
+
</button>
|
| 33 |
+
|
| 34 |
+
{/* Notifications */}
|
| 35 |
+
<button className="relative rounded-xl p-2.5 text-zinc-500 transition-all hover:bg-zinc-100 hover:text-zinc-700 dark:hover:bg-zinc-800">
|
| 36 |
+
<Bell className="h-[18px] w-[18px]" />
|
| 37 |
+
<span className="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-emerald-500 ring-2 ring-white dark:ring-zinc-950" />
|
| 38 |
+
</button>
|
| 39 |
+
|
| 40 |
+
{/* Avatar */}
|
| 41 |
+
<button className="flex items-center gap-2 rounded-xl p-1.5 transition-all hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
| 42 |
+
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500 to-fuchsia-500 text-xs font-bold text-white shadow-sm">
|
| 43 |
+
{getInitials(demoUser.full_name)}
|
| 44 |
+
</div>
|
| 45 |
+
</button>
|
| 46 |
+
</div>
|
| 47 |
+
</header>
|
| 48 |
+
);
|
| 49 |
+
}
|
src/components/layout/dashboard-layout.tsx
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { DashboardSidebar } from './dashboard-sidebar';
|
| 4 |
+
import { DashboardHeader } from './dashboard-header';
|
| 5 |
+
|
| 6 |
+
export function DashboardLayout({ children }: { children: React.ReactNode }) {
|
| 7 |
+
return (
|
| 8 |
+
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
| 9 |
+
<DashboardSidebar />
|
| 10 |
+
<div className="lg:pl-[260px] transition-all duration-300">
|
| 11 |
+
<DashboardHeader />
|
| 12 |
+
<main className="p-4 sm:p-6 lg:p-8">{children}</main>
|
| 13 |
+
</div>
|
| 14 |
+
</div>
|
| 15 |
+
);
|
| 16 |
+
}
|
src/components/layout/dashboard-sidebar.tsx
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import Link from 'next/link';
|
| 4 |
+
import { usePathname } from 'next/navigation';
|
| 5 |
+
import { cn } from '@/lib/utils';
|
| 6 |
+
import {
|
| 7 |
+
LayoutDashboard,
|
| 8 |
+
UtensilsCrossed,
|
| 9 |
+
QrCode,
|
| 10 |
+
ShoppingBag,
|
| 11 |
+
BarChart3,
|
| 12 |
+
CreditCard,
|
| 13 |
+
Settings,
|
| 14 |
+
Store,
|
| 15 |
+
ChevronLeft,
|
| 16 |
+
Menu,
|
| 17 |
+
LogOut,
|
| 18 |
+
Sparkles,
|
| 19 |
+
} from 'lucide-react';
|
| 20 |
+
import { useState } from 'react';
|
| 21 |
+
|
| 22 |
+
const navigation = [
|
| 23 |
+
{ name: 'Overview', href: '/dashboard/overview', icon: LayoutDashboard },
|
| 24 |
+
{ name: 'Menu Builder', href: '/dashboard/menu-builder', icon: UtensilsCrossed },
|
| 25 |
+
{ name: 'QR Codes', href: '/dashboard/qr-manager', icon: QrCode },
|
| 26 |
+
{ name: 'Orders', href: '/dashboard/orders', icon: ShoppingBag },
|
| 27 |
+
{ name: 'Analytics', href: '/dashboard/analytics', icon: BarChart3 },
|
| 28 |
+
{ name: 'Billing', href: '/dashboard/billing', icon: CreditCard },
|
| 29 |
+
{ name: 'Restaurant', href: '/dashboard/restaurant-setup', icon: Store },
|
| 30 |
+
{ name: 'Settings', href: '/dashboard/settings', icon: Settings },
|
| 31 |
+
];
|
| 32 |
+
|
| 33 |
+
export function DashboardSidebar() {
|
| 34 |
+
const pathname = usePathname();
|
| 35 |
+
const [collapsed, setCollapsed] = useState(false);
|
| 36 |
+
const [mobileOpen, setMobileOpen] = useState(false);
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<>
|
| 40 |
+
{/* Mobile menu button */}
|
| 41 |
+
<button
|
| 42 |
+
onClick={() => setMobileOpen(true)}
|
| 43 |
+
className="fixed left-4 top-4 z-50 rounded-xl border border-zinc-200 bg-white p-2.5 shadow-sm lg:hidden dark:border-zinc-700 dark:bg-zinc-900"
|
| 44 |
+
>
|
| 45 |
+
<Menu className="h-5 w-5 text-zinc-600" />
|
| 46 |
+
</button>
|
| 47 |
+
|
| 48 |
+
{/* Mobile overlay */}
|
| 49 |
+
{mobileOpen && (
|
| 50 |
+
<div
|
| 51 |
+
className="fixed inset-0 z-40 bg-black/30 backdrop-blur-sm lg:hidden"
|
| 52 |
+
onClick={() => setMobileOpen(false)}
|
| 53 |
+
/>
|
| 54 |
+
)}
|
| 55 |
+
|
| 56 |
+
{/* Sidebar */}
|
| 57 |
+
<aside
|
| 58 |
+
className={cn(
|
| 59 |
+
'fixed inset-y-0 left-0 z-50 flex flex-col border-r border-zinc-200/60 bg-white transition-all duration-300 dark:border-zinc-800 dark:bg-zinc-950',
|
| 60 |
+
collapsed ? 'w-[72px]' : 'w-[260px]',
|
| 61 |
+
mobileOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
| 62 |
+
)}
|
| 63 |
+
>
|
| 64 |
+
{/* Logo */}
|
| 65 |
+
<div className={cn('flex h-16 items-center border-b border-zinc-200/60 px-4 dark:border-zinc-800', collapsed && 'justify-center')}>
|
| 66 |
+
<Link href="/dashboard/overview" className="flex items-center gap-2.5">
|
| 67 |
+
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-emerald-500 to-cyan-500 shadow-sm">
|
| 68 |
+
<Sparkles className="h-4 w-4 text-white" />
|
| 69 |
+
</div>
|
| 70 |
+
{!collapsed && (
|
| 71 |
+
<span className="text-lg font-bold tracking-tight text-zinc-900 dark:text-white">
|
| 72 |
+
Scan<span className="text-emerald-600">Menu</span>
|
| 73 |
+
</span>
|
| 74 |
+
)}
|
| 75 |
+
</Link>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
{/* Navigation */}
|
| 79 |
+
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-4">
|
| 80 |
+
{navigation.map((item) => {
|
| 81 |
+
const isActive = pathname === item.href || pathname?.startsWith(item.href + '/');
|
| 82 |
+
return (
|
| 83 |
+
<Link
|
| 84 |
+
key={item.name}
|
| 85 |
+
href={item.href}
|
| 86 |
+
onClick={() => setMobileOpen(false)}
|
| 87 |
+
className={cn(
|
| 88 |
+
'group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-all duration-150',
|
| 89 |
+
isActive
|
| 90 |
+
? 'bg-zinc-900 text-white shadow-sm dark:bg-white dark:text-zinc-900'
|
| 91 |
+
: 'text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-white',
|
| 92 |
+
collapsed && 'justify-center px-2'
|
| 93 |
+
)}
|
| 94 |
+
>
|
| 95 |
+
<item.icon className={cn('h-[18px] w-[18px] shrink-0', isActive ? 'text-current' : 'text-zinc-400 group-hover:text-zinc-600 dark:group-hover:text-zinc-300')} />
|
| 96 |
+
{!collapsed && <span>{item.name}</span>}
|
| 97 |
+
</Link>
|
| 98 |
+
);
|
| 99 |
+
})}
|
| 100 |
+
</nav>
|
| 101 |
+
|
| 102 |
+
{/* Footer */}
|
| 103 |
+
<div className="border-t border-zinc-200/60 p-3 dark:border-zinc-800">
|
| 104 |
+
{!collapsed && (
|
| 105 |
+
<div className="mb-3 rounded-xl bg-gradient-to-br from-emerald-50 to-cyan-50 p-3 dark:from-emerald-950/30 dark:to-cyan-950/30">
|
| 106 |
+
<p className="text-xs font-semibold text-emerald-700 dark:text-emerald-400">Pro Plan</p>
|
| 107 |
+
<p className="mt-0.5 text-[11px] text-emerald-600/70 dark:text-emerald-500/70">Unlimited menus & orders</p>
|
| 108 |
+
</div>
|
| 109 |
+
)}
|
| 110 |
+
<button
|
| 111 |
+
className={cn(
|
| 112 |
+
'flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-zinc-500 transition-all hover:bg-zinc-100 hover:text-zinc-700 dark:hover:bg-zinc-800',
|
| 113 |
+
collapsed && 'justify-center px-2'
|
| 114 |
+
)}
|
| 115 |
+
>
|
| 116 |
+
<LogOut className="h-[18px] w-[18px]" />
|
| 117 |
+
{!collapsed && <span>Sign Out</span>}
|
| 118 |
+
</button>
|
| 119 |
+
|
| 120 |
+
{/* Collapse toggle — desktop only */}
|
| 121 |
+
<button
|
| 122 |
+
onClick={() => setCollapsed(!collapsed)}
|
| 123 |
+
className="mt-2 hidden w-full items-center justify-center rounded-xl p-2 text-zinc-400 transition-all hover:bg-zinc-100 hover:text-zinc-600 lg:flex dark:hover:bg-zinc-800"
|
| 124 |
+
>
|
| 125 |
+
<ChevronLeft className={cn('h-4 w-4 transition-transform', collapsed && 'rotate-180')} />
|
| 126 |
+
</button>
|
| 127 |
+
</div>
|
| 128 |
+
</aside>
|
| 129 |
+
</>
|
| 130 |
+
);
|
| 131 |
+
}
|
src/components/ui/badge.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from 'react';
|
| 2 |
+
import { cva, type VariantProps } from 'class-variance-authority';
|
| 3 |
+
import { cn } from '@/lib/utils';
|
| 4 |
+
|
| 5 |
+
const badgeVariants = cva(
|
| 6 |
+
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors',
|
| 7 |
+
{
|
| 8 |
+
variants: {
|
| 9 |
+
variant: {
|
| 10 |
+
default: 'border-transparent bg-zinc-900 text-white dark:bg-zinc-50 dark:text-zinc-900',
|
| 11 |
+
secondary: 'border-transparent bg-zinc-100 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-100',
|
| 12 |
+
success: 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-400',
|
| 13 |
+
warning: 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-400',
|
| 14 |
+
destructive: 'border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-400',
|
| 15 |
+
info: 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-400',
|
| 16 |
+
outline: 'border-zinc-200 text-zinc-700 dark:border-zinc-700 dark:text-zinc-300',
|
| 17 |
+
},
|
| 18 |
+
},
|
| 19 |
+
defaultVariants: {
|
| 20 |
+
variant: 'default',
|
| 21 |
+
},
|
| 22 |
+
}
|
| 23 |
+
);
|
| 24 |
+
|
| 25 |
+
export interface BadgeProps
|
| 26 |
+
extends React.HTMLAttributes<HTMLDivElement>,
|
| 27 |
+
VariantProps<typeof badgeVariants> {}
|
| 28 |
+
|
| 29 |
+
function Badge({ className, variant, ...props }: BadgeProps) {
|
| 30 |
+
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export { Badge, badgeVariants };
|
src/components/ui/button.tsx
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from 'react';
|
| 2 |
+
import { Slot } from '@radix-ui/react-slot';
|
| 3 |
+
import { cva, type VariantProps } from 'class-variance-authority';
|
| 4 |
+
import { cn } from '@/lib/utils';
|
| 5 |
+
|
| 6 |
+
const buttonVariants = cva(
|
| 7 |
+
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-semibold transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 active:scale-[0.98]',
|
| 8 |
+
{
|
| 9 |
+
variants: {
|
| 10 |
+
variant: {
|
| 11 |
+
default: 'bg-zinc-900 text-white shadow-sm hover:bg-zinc-800 focus-visible:ring-zinc-500 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-100',
|
| 12 |
+
primary: 'bg-emerald-600 text-white shadow-sm hover:bg-emerald-500 focus-visible:ring-emerald-500',
|
| 13 |
+
destructive: 'bg-red-600 text-white shadow-sm hover:bg-red-500 focus-visible:ring-red-500',
|
| 14 |
+
outline: 'border border-zinc-200 bg-white text-zinc-900 shadow-sm hover:bg-zinc-50 hover:border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800',
|
| 15 |
+
secondary: 'bg-zinc-100 text-zinc-900 shadow-sm hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700',
|
| 16 |
+
ghost: 'text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100',
|
| 17 |
+
link: 'text-emerald-600 underline-offset-4 hover:underline dark:text-emerald-400',
|
| 18 |
+
},
|
| 19 |
+
size: {
|
| 20 |
+
default: 'h-10 px-4 py-2',
|
| 21 |
+
sm: 'h-8 rounded-lg px-3 text-xs',
|
| 22 |
+
lg: 'h-12 rounded-xl px-6 text-base',
|
| 23 |
+
xl: 'h-14 rounded-2xl px-8 text-base',
|
| 24 |
+
icon: 'h-10 w-10',
|
| 25 |
+
'icon-sm': 'h-8 w-8 rounded-lg',
|
| 26 |
+
},
|
| 27 |
+
},
|
| 28 |
+
defaultVariants: {
|
| 29 |
+
variant: 'default',
|
| 30 |
+
size: 'default',
|
| 31 |
+
},
|
| 32 |
+
}
|
| 33 |
+
);
|
| 34 |
+
|
| 35 |
+
export interface ButtonProps
|
| 36 |
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
| 37 |
+
VariantProps<typeof buttonVariants> {
|
| 38 |
+
asChild?: boolean;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
| 42 |
+
({ className, variant, size, asChild = false, ...props }, ref) => {
|
| 43 |
+
const Comp = asChild ? Slot : 'button';
|
| 44 |
+
return (
|
| 45 |
+
<Comp
|
| 46 |
+
className={cn(buttonVariants({ variant, size, className }))}
|
| 47 |
+
ref={ref}
|
| 48 |
+
{...props}
|
| 49 |
+
/>
|
| 50 |
+
);
|
| 51 |
+
}
|
| 52 |
+
);
|
| 53 |
+
Button.displayName = 'Button';
|
| 54 |
+
|
| 55 |
+
export { Button, buttonVariants };
|
src/components/ui/card.tsx
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from 'react';
|
| 2 |
+
import { cn } from '@/lib/utils';
|
| 3 |
+
|
| 4 |
+
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
| 5 |
+
({ className, ...props }, ref) => (
|
| 6 |
+
<div
|
| 7 |
+
ref={ref}
|
| 8 |
+
className={cn(
|
| 9 |
+
'rounded-2xl border border-zinc-200/60 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900',
|
| 10 |
+
className
|
| 11 |
+
)}
|
| 12 |
+
{...props}
|
| 13 |
+
/>
|
| 14 |
+
)
|
| 15 |
+
);
|
| 16 |
+
Card.displayName = 'Card';
|
| 17 |
+
|
| 18 |
+
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
| 19 |
+
({ className, ...props }, ref) => (
|
| 20 |
+
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
| 21 |
+
)
|
| 22 |
+
);
|
| 23 |
+
CardHeader.displayName = 'CardHeader';
|
| 24 |
+
|
| 25 |
+
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
| 26 |
+
({ className, ...props }, ref) => (
|
| 27 |
+
<div ref={ref} className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
|
| 28 |
+
)
|
| 29 |
+
);
|
| 30 |
+
CardTitle.displayName = 'CardTitle';
|
| 31 |
+
|
| 32 |
+
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
| 33 |
+
({ className, ...props }, ref) => (
|
| 34 |
+
<div ref={ref} className={cn('text-sm text-zinc-500 dark:text-zinc-400', className)} {...props} />
|
| 35 |
+
)
|
| 36 |
+
);
|
| 37 |
+
CardDescription.displayName = 'CardDescription';
|
| 38 |
+
|
| 39 |
+
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
| 40 |
+
({ className, ...props }, ref) => (
|
| 41 |
+
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
| 42 |
+
)
|
| 43 |
+
);
|
| 44 |
+
CardContent.displayName = 'CardContent';
|
| 45 |
+
|
| 46 |
+
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
| 47 |
+
({ className, ...props }, ref) => (
|
| 48 |
+
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
| 49 |
+
)
|
| 50 |
+
);
|
| 51 |
+
CardFooter.displayName = 'CardFooter';
|
| 52 |
+
|
| 53 |
+
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
src/components/ui/empty-state.tsx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { LucideIcon } from 'lucide-react';
|
| 2 |
+
import { Button } from './button';
|
| 3 |
+
|
| 4 |
+
interface EmptyStateProps {
|
| 5 |
+
icon: LucideIcon;
|
| 6 |
+
title: string;
|
| 7 |
+
description: string;
|
| 8 |
+
action?: {
|
| 9 |
+
label: string;
|
| 10 |
+
onClick: () => void;
|
| 11 |
+
};
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
|
| 15 |
+
return (
|
| 16 |
+
<div className="flex flex-col items-center justify-center py-16 px-4">
|
| 17 |
+
<div className="rounded-2xl bg-zinc-100 p-4 dark:bg-zinc-800">
|
| 18 |
+
<Icon className="h-8 w-8 text-zinc-400" />
|
| 19 |
+
</div>
|
| 20 |
+
<h3 className="mt-4 text-lg font-semibold text-zinc-900 dark:text-zinc-100">{title}</h3>
|
| 21 |
+
<p className="mt-2 max-w-sm text-center text-sm text-zinc-500 dark:text-zinc-400">{description}</p>
|
| 22 |
+
{action && (
|
| 23 |
+
<Button variant="primary" className="mt-6" onClick={action.onClick}>
|
| 24 |
+
{action.label}
|
| 25 |
+
</Button>
|
| 26 |
+
)}
|
| 27 |
+
</div>
|
| 28 |
+
);
|
| 29 |
+
}
|
src/components/ui/input.tsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from 'react';
|
| 2 |
+
import { cn } from '@/lib/utils';
|
| 3 |
+
|
| 4 |
+
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
| 5 |
+
({ className, type, ...props }, ref) => {
|
| 6 |
+
return (
|
| 7 |
+
<input
|
| 8 |
+
type={type}
|
| 9 |
+
className={cn(
|
| 10 |
+
'flex h-10 w-full rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-0 focus-visible:border-emerald-500 disabled:cursor-not-allowed disabled:opacity-50 transition-all dark:border-zinc-700 dark:bg-zinc-900 dark:ring-offset-zinc-950 dark:placeholder:text-zinc-500',
|
| 11 |
+
className
|
| 12 |
+
)}
|
| 13 |
+
ref={ref}
|
| 14 |
+
{...props}
|
| 15 |
+
/>
|
| 16 |
+
);
|
| 17 |
+
}
|
| 18 |
+
);
|
| 19 |
+
Input.displayName = 'Input';
|
| 20 |
+
|
| 21 |
+
export { Input };
|
src/components/ui/stat-card.tsx
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { cn } from '@/lib/utils';
|
| 4 |
+
import { LucideIcon } from 'lucide-react';
|
| 5 |
+
|
| 6 |
+
interface StatCardProps {
|
| 7 |
+
title: string;
|
| 8 |
+
value: string;
|
| 9 |
+
change?: string;
|
| 10 |
+
changeType?: 'positive' | 'negative' | 'neutral';
|
| 11 |
+
icon: LucideIcon;
|
| 12 |
+
iconColor?: string;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export function StatCard({ title, value, change, changeType = 'neutral', icon: Icon, iconColor = 'text-emerald-600' }: StatCardProps) {
|
| 16 |
+
return (
|
| 17 |
+
<div className="group relative overflow-hidden rounded-2xl border border-zinc-200/60 bg-white p-6 shadow-sm transition-all hover:shadow-md hover:border-zinc-300/60 dark:border-zinc-800 dark:bg-zinc-900">
|
| 18 |
+
<div className="flex items-start justify-between">
|
| 19 |
+
<div className="space-y-2">
|
| 20 |
+
<p className="text-sm font-medium text-zinc-500 dark:text-zinc-400">{title}</p>
|
| 21 |
+
<p className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100">{value}</p>
|
| 22 |
+
{change && (
|
| 23 |
+
<p className={cn(
|
| 24 |
+
'text-sm font-medium',
|
| 25 |
+
changeType === 'positive' && 'text-emerald-600',
|
| 26 |
+
changeType === 'negative' && 'text-red-600',
|
| 27 |
+
changeType === 'neutral' && 'text-zinc-500'
|
| 28 |
+
)}>
|
| 29 |
+
{change}
|
| 30 |
+
</p>
|
| 31 |
+
)}
|
| 32 |
+
</div>
|
| 33 |
+
<div className={cn('rounded-xl bg-zinc-50 p-3 dark:bg-zinc-800', iconColor)}>
|
| 34 |
+
<Icon className="h-5 w-5" />
|
| 35 |
+
</div>
|
| 36 |
+
</div>
|
| 37 |
+
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-gradient-to-r from-emerald-500 to-cyan-500 opacity-0 transition-opacity group-hover:opacity-100" />
|
| 38 |
+
</div>
|
| 39 |
+
);
|
| 40 |
+
}
|
src/components/ui/textarea.tsx
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from 'react';
|
| 2 |
+
import { cn } from '@/lib/utils';
|
| 3 |
+
|
| 4 |
+
const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
|
| 5 |
+
({ className, ...props }, ref) => {
|
| 6 |
+
return (
|
| 7 |
+
<textarea
|
| 8 |
+
className={cn(
|
| 9 |
+
'flex min-h-[80px] w-full rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-0 focus-visible:border-emerald-500 disabled:cursor-not-allowed disabled:opacity-50 transition-all dark:border-zinc-700 dark:bg-zinc-900',
|
| 10 |
+
className
|
| 11 |
+
)}
|
| 12 |
+
ref={ref}
|
| 13 |
+
{...props}
|
| 14 |
+
/>
|
| 15 |
+
);
|
| 16 |
+
}
|
| 17 |
+
);
|
| 18 |
+
Textarea.displayName = 'Textarea';
|
| 19 |
+
|
| 20 |
+
export { Textarea };
|
src/lib/demo-data.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ============================================================
|
| 2 |
+
// ScanMenu – Demo Data Store
|
| 3 |
+
// Simulates Supabase backend for the demo
|
| 4 |
+
// ============================================================
|
| 5 |
+
|
| 6 |
+
import { Restaurant, Menu, Category, Product, ProductOption, ProductOptionItem, Table, QRCode, Order, OrderItem, Subscription, User } from '@/types/database';
|
| 7 |
+
|
| 8 |
+
export const demoUser: User = {
|
| 9 |
+
id: 'user-1',
|
| 10 |
+
email: 'demo@scanmenu.app',
|
| 11 |
+
full_name: 'Alex Rivera',
|
| 12 |
+
avatar_url: undefined,
|
| 13 |
+
role: 'owner',
|
| 14 |
+
created_at: '2025-01-15T10:00:00Z',
|
| 15 |
+
updated_at: '2025-03-20T14:30:00Z',
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
export const demoRestaurant: Restaurant = {
|
| 19 |
+
id: 'rest-1',
|
| 20 |
+
owner_id: 'user-1',
|
| 21 |
+
name: 'The Garden Kitchen',
|
| 22 |
+
slug: 'the-garden-kitchen',
|
| 23 |
+
description: 'Farm-to-table dining with seasonal menus, craft cocktails, and a lush garden terrace. Experience the freshest ingredients in every dish.',
|
| 24 |
+
logo_url: undefined,
|
| 25 |
+
cover_image_url: undefined,
|
| 26 |
+
address: '742 Evergreen Terrace, San Francisco, CA 94102',
|
| 27 |
+
phone: '+1 (415) 555-0123',
|
| 28 |
+
email: 'hello@gardenkitchen.com',
|
| 29 |
+
website: 'https://gardenkitchen.com',
|
| 30 |
+
currency: 'USD',
|
| 31 |
+
timezone: 'America/Los_Angeles',
|
| 32 |
+
is_active: true,
|
| 33 |
+
opening_hours: {
|
| 34 |
+
monday: { open: '11:00', close: '22:00' },
|
| 35 |
+
tuesday: { open: '11:00', close: '22:00' },
|
| 36 |
+
wednesday: { open: '11:00', close: '22:00' },
|
| 37 |
+
thursday: { open: '11:00', close: '23:00' },
|
| 38 |
+
friday: { open: '11:00', close: '23:30' },
|
| 39 |
+
saturday: { open: '10:00', close: '23:30' },
|
| 40 |
+
sunday: { open: '10:00', close: '21:00' },
|
| 41 |
+
},
|
| 42 |
+
theme_color: '#10b981',
|
| 43 |
+
created_at: '2025-01-15T10:00:00Z',
|
| 44 |
+
updated_at: '2025-03-20T14:30:00Z',
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
export const demoMenu: Menu = {
|
| 48 |
+
id: 'menu-1',
|
| 49 |
+
restaurant_id: 'rest-1',
|
| 50 |
+
name: 'Main Menu',
|
| 51 |
+
description: 'Our full dining menu',
|
| 52 |
+
is_active: true,
|
| 53 |
+
sort_order: 0,
|
| 54 |
+
created_at: '2025-01-15T10:00:00Z',
|
| 55 |
+
updated_at: '2025-03-20T14:30:00Z',
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
export const demoCategories: Category[] = [
|
| 59 |
+
{ id: 'cat-1', menu_id: 'menu-1', restaurant_id: 'rest-1', name: 'Starters', description: 'Light bites to begin your meal', image_url: undefined, is_active: true, sort_order: 0, created_at: '2025-01-15T10:00:00Z', updated_at: '2025-01-15T10:00:00Z' },
|
| 60 |
+
{ id: 'cat-2', menu_id: 'menu-1', restaurant_id: 'rest-1', name: 'Mains', description: 'Hearty entrees and signature dishes', image_url: undefined, is_active: true, sort_order: 1, created_at: '2025-01-15T10:00:00Z', updated_at: '2025-01-15T10:00:00Z' },
|
| 61 |
+
{ id: 'cat-3', menu_id: 'menu-1', restaurant_id: 'rest-1', name: 'Pasta & Risotto', description: 'Fresh handmade pasta and creamy risottos', image_url: undefined, is_active: true, sort_order: 2, created_at: '2025-01-15T10:00:00Z', updated_at: '2025-01-15T10:00:00Z' },
|
| 62 |
+
{ id: 'cat-4', menu_id: 'menu-1', restaurant_id: 'rest-1', name: 'Burgers & Sandwiches', description: 'Gourmet burgers and artisan sandwiches', image_url: undefined, is_active: true, sort_order: 3, created_at: '2025-01-15T10:00:00Z', updated_at: '2025-01-15T10:00:00Z' },
|
| 63 |
+
{ id: 'cat-5', menu_id: 'menu-1', restaurant_id: 'rest-1', name: 'Desserts', description: 'Sweet endings to your meal', image_url: undefined, is_active: true, sort_order: 4, created_at: '2025-01-15T10:00:00Z', updated_at: '2025-01-15T10:00:00Z' },
|
| 64 |
+
{ id: 'cat-6', menu_id: 'menu-1', restaurant_id: 'rest-1', name: 'Drinks', description: 'Cocktails, wines, and refreshments', image_url: undefined, is_active: true, sort_order: 5, created_at: '2025-01-15T10:00:00Z', updated_at: '2025-01-15T10:00:00Z' },
|
| 65 |
+
];
|
| 66 |
+
|
| 67 |
+
export const demoProducts: Product[] = [
|
| 68 |
+
// Starters
|
| 69 |
+
{ id: 'prod-1', category_id: 'cat-1', restaurant_id: 'rest-1', name: 'Truffle Burrata', description: 'Creamy burrata with black truffle, heirloom tomatoes, basil oil & sourdough crostini', price: 16.50, image_url: undefined, is_available: true, is_featured: true, preparation_time: 10, calories: 320, allergens: ['dairy', 'gluten'], tags: ['vegetarian', 'popular'], sort_order: 0, created_at: '2025-01-15T10:00:00Z', updated_at: '2025-01-15T10:00:00Z' },
|
| 70 |
+
{ id: 'prod-2', category_id: 'cat-1', restaurant_id: 'rest-1', name: 'Tuna Tartare', description: 'Sashimi-grade tuna, avocado mousse, yuzu ponzu, sesame tuile', price: 18.00, image_url: undefined, is_available: true, is_featured: false, preparation_time: 12, calories: 280, allergens: ['fish', 'sesame'], tags: ['gluten-free'], sort_order: 1, created_at: '2025-01-15T10:00:00Z', updated_at: '2025-01-15T10:00:00Z' },
|
| 71 |
+
{ id: 'prod-3', category_id: 'cat-1', restaurant_id: 'rest-1', name: 'Crispy Calamari', description: 'Lightly breaded calamari, lemon aioli, marinara, fresh herbs', price: 14.00, image_url: undefined, is_available: true, is_featured: false, preparation_time: 8, calories: 410, allergens: ['shellfish', 'gluten'], tags: [], sort_order: 2, created_at: '2025-01-15T10:00:00Z', updated_at: '2025-01-15T10:00:00Z' },
|
| 72 |
+
{ id: 'prod-4', category_id: 'cat-1', restaurant_id: 'rest-1', name: 'Garden Soup', description: 'Seasonal vegetable soup with herbs from our garden, served with warm bread', price: 9.50, image_url: undefined, is_available: true, is_featured: false, preparation_time: 5, calories: 180, allergens: ['gluten'], tags: ['vegan', 'healthy'], sort_order: 3, created_at: '2025-01-15T10:00:00Z', updated_at: '2025-01-15T10:00:00Z' },
|
| 73 |
+
// Mains
|
| 74 |
+
{ id: 'prod-5', category_id: 'cat-2', restaurant_id: 'rest-1', name: 'Grilled Ribeye Steak', description: '12oz prime ribeye, roasted garlic butter, truffle fries, grilled asparagus', price: 42.00, image_url: undefined, is_available: true, is_featured: true, preparation_time: 25, calories: 780, allergens: ['dairy'], tags: ['signature', 'popular'], sort_order: 0, created_at: '2025-01-15T10:00:00Z', updated_at: '2025-01-15T10:00:00Z' },
|
| 75 |
+
{ id: 'prod-6', category_id: 'cat-2', restaurant_id: 'rest-1', name: 'Pan-Seared Salmon', description: 'Atlantic salmon, lemon dill sauce, quinoa pilaf, roasted vegetables', price: 32.00, image_url: undefined, is_available: true, is_featured: true, preparation_time: 20, calories: 520, allergens: ['fish'], tags: ['healthy', 'gluten-free'], sort_order: 1, created_at: '2025-01-15T10:00:00Z', updated_at: '2025-01-15T10:00:00Z' },
|
| 76 |
+
{ id: 'prod-7', category_id: 'cat-2', restaurant_id: 'rest-1', name: 'Herb-Roasted Chicken', description: 'Free-range chicken, herb jus, mashed potatoes, seasonal greens', price: 28.00, image_url: undefined, is_available: true, is_featured: false, preparation_time: 22, calories: 620, allergens: ['dairy'], tags: [], sort_order: 2, created_at: '2025-01-15T10:00:00Z', updated_at: '2025-01-15T10:00:00Z' },
|
| 77 |
+
{ id: 'prod-8', category_id: 'cat-2', restaurant_id: 'rest-1', name: 'Mushroom Wellington', description: 'Wild mushroom & spinach Wellington, red wine reduction, roasted roots', price: 26.00, image_url: undefined, is_available: true, is_featured: false, preparation_time: 25, calories: 480, allergens: ['gluten', 'dairy'], tags: ['vegetarian'], sort_order: 3, created_at: '2025-01-15T10:00:00Z', updated_at: '2025-01-15T10:00:00Z' },
|
| 78 |
+
// Pasta & Risotto
|
| 79 |
+
{ id: 'prod-9', category_id: 'cat-3', restaurant_id: 'rest-1', name: 'Lobster Linguine', description: 'Fresh linguine, butter-poached lobster, cherry tomatoes, white wine & chili', price: 36.00, image_url: undefined, is_available: true, is_featured: true, preparation_time: 18, calories: 580, allergens: ['shellfish', 'gluten', 'dairy'], tags: ['signature'], sort_order: 0, created_at: '2025-01-15T10:00:00Z', updated_at: '2025-01-15T10:00:00Z' },
|
| 80 |
+
{ id: 'prod-10', category_id: 'cat-3', restaurant_id: 'rest-1', name: 'Wild Mushroom Risotto', description: 'Arborio rice, porcini & chanterelle mushrooms, truffle oil, parmesan', price: 24.00, image_url: undefined, is_available: true, is_featured: false, preparation_time: 20, calories: 460, allergens: ['dairy'], tags: ['vegetarian', 'gluten-free'], sort_order: 1, created_at: '2025-01-15T10:00:00Z', updated_at: '2025-01-15T10:00:00Z' },
|
| 81 |
+
{ id: 'prod-11', category_id: 'cat-3', restaurant_id: 'rest-1', name: 'Carbonara', description: 'Spaghetti, guanciale, pecorino romano, black pepper, egg yolk', price: 20.00, image_url: undefined, is_available: true, is_featured: false, preparation_time: 15, calories: 520, allergens: ['gluten', 'dairy', 'egg'], tags: ['classic'], sort_order: 2, created_at: '2025-01-15T10:00:00Z', updated_at: '2025-01-15T10:00:00Z' },
|
| 82 |
+
// Burgers & Sandwiches
|
| 83 |
+
{ id: 'prod-12', category_id: 'cat-4', restaurant_id: 'rest-1', name: 'Garden Smash Burger', description: 'Double smashed patty, aged cheddar, caramelized onions, secret sauce, brioche bun', price: 19.00, image_url: undefined, is_available: true, is_featured: true, preparation_time: 15, calories: 720, allergens: ['gluten', 'dairy'], tags: ['popular', 'signature'], sort_order: 0, created_at: '2025-01-15T10:00:00Z', updated_at: '2025-01-15T10:00:00Z' },
|
| 84 |
+
{ id: 'prod-13', category_id: 'cat-4', restaurant_id: 'rest-1', name: 'Crispy Chicken Sandwich', description: 'Buttermilk fried chicken, slaw, pickles, spicy mayo, potato bun', price: 17.00, image_url: undefined, is_available: true, is_featured: false, preparation_time: 12, calories: 650, allergens: ['gluten', 'dairy', 'egg'], tags: [], sort_order: 1, created_at: '2025-01-15T10:00:00Z', updated_at: '2025-01-15T10:00:00Z' },
|
| 85 |
+
{ id: 'prod-14', category_id: 'cat-4', restaurant_id: 'rest-1', name: 'Grilled Veggie Wrap', description: 'Roasted vegetables, hummus, feta, arugula, sun-dried tomato wrap', price: 15.00, image_url: undefined, is_available: true, is_featured: false, preparation_time: 10, calories: 380, allergens: ['gluten', 'dairy'], tags: ['vegetarian'], sort_order: 2, created_at: '2025-01-15T10:00:00Z', updated_at: '2025-01-15T10:00:00Z' },
|
| 86 |
+
// Desserts
|
| 87 |
+
{ id: 'prod-15', category_id: 'cat-5', restaurant_id: 'rest-1', name: 'Chocolate Lava Cake', description: 'Warm molten chocolate cake, vanilla bean ice cream, raspberry coulis', price: 14.00, image_url: undefined, is_available: true, is_featured: true, preparation_time: 15, calories: 480, allergens: ['gluten', 'dairy', 'egg'], tags: ['popular'], sort_order: 0, created_at: '2025-01-15T10:00:00Z', updated_at: '2025-01-15T10:00:00Z' },
|
| 88 |
+
{ id: 'prod-16', category_id: 'cat-5', restaurant_id: 'rest-1', name: 'Crème Brûlée', description: 'Classic vanilla custard, caramelized sugar, fresh berries', price: 12.00, image_url: undefined, is_available: true, is_featured: false, preparation_time: 5, calories: 340, allergens: ['dairy', 'egg'], tags: ['classic'], sort_order: 1, created_at: '2025-01-15T10:00:00Z', updated_at: '2025-01-15T10:00:00Z' },
|
| 89 |
+
{ id: 'prod-17', category_id: 'cat-5', restaurant_id: 'rest-1', name: 'Tiramisu', description: 'Espresso-soaked ladyfingers, mascarpone cream, cocoa dusting', price: 13.00, image_url: undefined, is_available: true, is_featured: false, preparation_time: 5, calories: 380, allergens: ['gluten', 'dairy', 'egg'], tags: [], sort_order: 2, created_at: '2025-01-15T10:00:00Z', updated_at: '2025-01-15T10:00:00Z' },
|
| 90 |
+
// Drinks
|
| 91 |
+
{ id: 'prod-18', category_id: 'cat-6', restaurant_id: 'rest-1', name: 'Garden Spritz', description: 'Aperol, elderflower, prosecco, fresh herbs, soda', price: 14.00, image_url: undefined, is_available: true, is_featured: true, preparation_time: 3, calories: 180, allergens: [], tags: ['signature', 'cocktail'], sort_order: 0, created_at: '2025-01-15T10:00:00Z', updated_at: '2025-01-15T10:00:00Z' },
|
| 92 |
+
{ id: 'prod-19', category_id: 'cat-6', restaurant_id: 'rest-1', name: 'Espresso Martini', description: 'Vodka, Kahlúa, fresh espresso, coffee beans', price: 15.00, image_url: undefined, is_available: true, is_featured: false, preparation_time: 3, calories: 220, allergens: [], tags: ['cocktail'], sort_order: 1, created_at: '2025-01-15T10:00:00Z', updated_at: '2025-01-15T10:00:00Z' },
|
| 93 |
+
{ id: 'prod-20', category_id: 'cat-6', restaurant_id: 'rest-1', name: 'Fresh Lemonade', description: 'House-made lemonade with mint, ginger, and a hint of lavender', price: 6.00, image_url: undefined, is_available: true, is_featured: false, preparation_time: 2, calories: 120, allergens: [], tags: ['non-alcoholic'], sort_order: 2, created_at: '2025-01-15T10:00:00Z', updated_at: '2025-01-15T10:00:00Z' },
|
| 94 |
+
{ id: 'prod-21', category_id: 'cat-6', restaurant_id: 'rest-1', name: 'House Red Wine', description: 'Cabernet Sauvignon, Napa Valley — rich, full-bodied with dark fruit notes', price: 12.00, image_url: undefined, is_available: true, is_featured: false, preparation_time: 1, calories: 125, allergens: ['sulfites'], tags: ['wine'], sort_order: 3, created_at: '2025-01-15T10:00:00Z', updated_at: '2025-01-15T10:00:00Z' },
|
| 95 |
+
];
|
| 96 |
+
|
| 97 |
+
export const demoProductOptions: ProductOption[] = [
|
| 98 |
+
{ id: 'opt-1', product_id: 'prod-5', name: 'Doneness', type: 'single', required: true, sort_order: 0 },
|
| 99 |
+
{ id: 'opt-2', product_id: 'prod-12', name: 'Side', type: 'single', required: true, sort_order: 0 },
|
| 100 |
+
{ id: 'opt-3', product_id: 'prod-12', name: 'Add-ons', type: 'multiple', required: false, max_selections: 3, sort_order: 1 },
|
| 101 |
+
];
|
| 102 |
+
|
| 103 |
+
export const demoOptionItems: ProductOptionItem[] = [
|
| 104 |
+
// Steak doneness
|
| 105 |
+
{ id: 'optitem-1', option_id: 'opt-1', name: 'Rare', price_modifier: 0, is_default: false, sort_order: 0 },
|
| 106 |
+
{ id: 'optitem-2', option_id: 'opt-1', name: 'Medium Rare', price_modifier: 0, is_default: true, sort_order: 1 },
|
| 107 |
+
{ id: 'optitem-3', option_id: 'opt-1', name: 'Medium', price_modifier: 0, is_default: false, sort_order: 2 },
|
| 108 |
+
{ id: 'optitem-4', option_id: 'opt-1', name: 'Medium Well', price_modifier: 0, is_default: false, sort_order: 3 },
|
| 109 |
+
{ id: 'optitem-5', option_id: 'opt-1', name: 'Well Done', price_modifier: 0, is_default: false, sort_order: 4 },
|
| 110 |
+
// Burger sides
|
| 111 |
+
{ id: 'optitem-6', option_id: 'opt-2', name: 'Regular Fries', price_modifier: 0, is_default: true, sort_order: 0 },
|
| 112 |
+
{ id: 'optitem-7', option_id: 'opt-2', name: 'Sweet Potato Fries', price_modifier: 2.00, is_default: false, sort_order: 1 },
|
| 113 |
+
{ id: 'optitem-8', option_id: 'opt-2', name: 'Side Salad', price_modifier: 1.50, is_default: false, sort_order: 2 },
|
| 114 |
+
{ id: 'optitem-9', option_id: 'opt-2', name: 'Onion Rings', price_modifier: 2.50, is_default: false, sort_order: 3 },
|
| 115 |
+
// Burger add-ons
|
| 116 |
+
{ id: 'optitem-10', option_id: 'opt-3', name: 'Extra Patty', price_modifier: 5.00, is_default: false, sort_order: 0 },
|
| 117 |
+
{ id: 'optitem-11', option_id: 'opt-3', name: 'Bacon', price_modifier: 3.00, is_default: false, sort_order: 1 },
|
| 118 |
+
{ id: 'optitem-12', option_id: 'opt-3', name: 'Avocado', price_modifier: 2.50, is_default: false, sort_order: 2 },
|
| 119 |
+
{ id: 'optitem-13', option_id: 'opt-3', name: 'Fried Egg', price_modifier: 2.00, is_default: false, sort_order: 3 },
|
| 120 |
+
];
|
| 121 |
+
|
| 122 |
+
export const demoTables: Table[] = [
|
| 123 |
+
{ id: 'table-1', restaurant_id: 'rest-1', number: '1', name: 'Window Seat', capacity: 2, is_active: true, created_at: '2025-01-15T10:00:00Z' },
|
| 124 |
+
{ id: 'table-2', restaurant_id: 'rest-1', number: '2', name: 'Corner Booth', capacity: 4, is_active: true, created_at: '2025-01-15T10:00:00Z' },
|
| 125 |
+
{ id: 'table-3', restaurant_id: 'rest-1', number: '3', name: 'Garden Table', capacity: 6, is_active: true, created_at: '2025-01-15T10:00:00Z' },
|
| 126 |
+
{ id: 'table-4', restaurant_id: 'rest-1', number: '4', name: 'Bar Counter', capacity: 2, is_active: true, created_at: '2025-01-15T10:00:00Z' },
|
| 127 |
+
{ id: 'table-5', restaurant_id: 'rest-1', number: '5', name: 'Private Room', capacity: 8, is_active: true, created_at: '2025-01-15T10:00:00Z' },
|
| 128 |
+
{ id: 'table-6', restaurant_id: 'rest-1', number: '6', name: 'Terrace A', capacity: 4, is_active: true, created_at: '2025-01-15T10:00:00Z' },
|
| 129 |
+
{ id: 'table-7', restaurant_id: 'rest-1', number: '7', name: 'Terrace B', capacity: 4, is_active: true, created_at: '2025-01-15T10:00:00Z' },
|
| 130 |
+
{ id: 'table-8', restaurant_id: 'rest-1', number: '8', name: 'High Top', capacity: 2, is_active: false, created_at: '2025-01-15T10:00:00Z' },
|
| 131 |
+
];
|
| 132 |
+
|
| 133 |
+
export const demoQRCodes: QRCode[] = [
|
| 134 |
+
{ id: 'qr-1', restaurant_id: 'rest-1', table_id: 'table-1', label: 'Table 1 - Window Seat', url: '/restaurant/the-garden-kitchen?table=1', scans: 142, is_active: true, created_at: '2025-01-15T10:00:00Z' },
|
| 135 |
+
{ id: 'qr-2', restaurant_id: 'rest-1', table_id: 'table-2', label: 'Table 2 - Corner Booth', url: '/restaurant/the-garden-kitchen?table=2', scans: 98, is_active: true, created_at: '2025-01-15T10:00:00Z' },
|
| 136 |
+
{ id: 'qr-3', restaurant_id: 'rest-1', table_id: 'table-3', label: 'Table 3 - Garden Table', url: '/restaurant/the-garden-kitchen?table=3', scans: 256, is_active: true, created_at: '2025-01-15T10:00:00Z' },
|
| 137 |
+
{ id: 'qr-4', restaurant_id: 'rest-1', table_id: 'table-4', label: 'Table 4 - Bar Counter', url: '/restaurant/the-garden-kitchen?table=4', scans: 67, is_active: true, created_at: '2025-01-15T10:00:00Z' },
|
| 138 |
+
{ id: 'qr-5', restaurant_id: 'rest-1', table_id: 'table-5', label: 'Table 5 - Private Room', url: '/restaurant/the-garden-kitchen?table=5', scans: 34, is_active: true, created_at: '2025-01-15T10:00:00Z' },
|
| 139 |
+
{ id: 'qr-6', restaurant_id: 'rest-1', label: 'General - Takeaway', url: '/restaurant/the-garden-kitchen?type=takeaway', scans: 523, is_active: true, created_at: '2025-01-15T10:00:00Z' },
|
| 140 |
+
{ id: 'qr-7', restaurant_id: 'rest-1', label: 'General - Delivery', url: '/restaurant/the-garden-kitchen?type=delivery', scans: 891, is_active: true, created_at: '2025-01-15T10:00:00Z' },
|
| 141 |
+
];
|
| 142 |
+
|
| 143 |
+
export const demoOrders: Order[] = [
|
| 144 |
+
{ id: 'ord-1', restaurant_id: 'rest-1', customer_name: 'Sarah Chen', customer_phone: '+1 555-0101', order_type: 'dine_in', table_number: '3', status: 'preparing', subtotal: 68.50, tax: 5.48, total: 73.98, notes: 'No peanuts please', created_at: new Date(Date.now() - 15 * 60000).toISOString(), updated_at: new Date(Date.now() - 5 * 60000).toISOString() },
|
| 145 |
+
{ id: 'ord-2', restaurant_id: 'rest-1', customer_name: 'Mike Johnson', customer_phone: '+1 555-0102', order_type: 'takeaway', status: 'ready', subtotal: 38.00, tax: 3.04, total: 41.04, created_at: new Date(Date.now() - 30 * 60000).toISOString(), updated_at: new Date(Date.now() - 2 * 60000).toISOString() },
|
| 146 |
+
{ id: 'ord-3', restaurant_id: 'rest-1', customer_name: 'Emily Davis', customer_phone: '+1 555-0103', customer_email: 'emily@email.com', order_type: 'delivery', status: 'confirmed', subtotal: 52.00, tax: 4.16, total: 56.16, delivery_address: '123 Oak Street, Apt 4B', created_at: new Date(Date.now() - 8 * 60000).toISOString(), updated_at: new Date(Date.now() - 6 * 60000).toISOString() },
|
| 147 |
+
{ id: 'ord-4', restaurant_id: 'rest-1', customer_name: 'James Wilson', order_type: 'dine_in', table_number: '1', status: 'pending', subtotal: 94.00, tax: 7.52, total: 101.52, created_at: new Date(Date.now() - 2 * 60000).toISOString(), updated_at: new Date(Date.now() - 2 * 60000).toISOString() },
|
| 148 |
+
{ id: 'ord-5', restaurant_id: 'rest-1', customer_name: 'Lisa Park', order_type: 'dine_in', table_number: '5', status: 'delivered', subtotal: 126.00, tax: 10.08, total: 136.08, created_at: new Date(Date.now() - 90 * 60000).toISOString(), updated_at: new Date(Date.now() - 45 * 60000).toISOString() },
|
| 149 |
+
{ id: 'ord-6', restaurant_id: 'rest-1', customer_name: 'Tom Baker', order_type: 'takeaway', status: 'delivered', subtotal: 32.00, tax: 2.56, total: 34.56, created_at: new Date(Date.now() - 120 * 60000).toISOString(), updated_at: new Date(Date.now() - 100 * 60000).toISOString() },
|
| 150 |
+
];
|
| 151 |
+
|
| 152 |
+
export const demoOrderItems: OrderItem[] = [
|
| 153 |
+
{ id: 'oi-1', order_id: 'ord-1', product_id: 'prod-1', product_name: 'Truffle Burrata', quantity: 1, unit_price: 16.50, total_price: 16.50 },
|
| 154 |
+
{ id: 'oi-2', order_id: 'ord-1', product_id: 'prod-5', product_name: 'Grilled Ribeye Steak', quantity: 1, unit_price: 42.00, total_price: 42.00, options: { Doneness: 'Medium Rare' } },
|
| 155 |
+
{ id: 'oi-3', order_id: 'ord-1', product_id: 'prod-20', product_name: 'Fresh Lemonade', quantity: 2, unit_price: 6.00, total_price: 12.00 },
|
| 156 |
+
{ id: 'oi-4', order_id: 'ord-2', product_id: 'prod-12', product_name: 'Garden Smash Burger', quantity: 2, unit_price: 19.00, total_price: 38.00, options: { Side: 'Regular Fries' } },
|
| 157 |
+
{ id: 'oi-5', order_id: 'ord-3', product_id: 'prod-6', product_name: 'Pan-Seared Salmon', quantity: 1, unit_price: 32.00, total_price: 32.00 },
|
| 158 |
+
{ id: 'oi-6', order_id: 'ord-3', product_id: 'prod-11', product_name: 'Carbonara', quantity: 1, unit_price: 20.00, total_price: 20.00 },
|
| 159 |
+
{ id: 'oi-7', order_id: 'ord-4', product_id: 'prod-5', product_name: 'Grilled Ribeye Steak', quantity: 2, unit_price: 42.00, total_price: 84.00 },
|
| 160 |
+
{ id: 'oi-8', order_id: 'ord-4', product_id: 'prod-20', product_name: 'Fresh Lemonade', quantity: 1, unit_price: 6.00, total_price: 6.00 },
|
| 161 |
+
{ id: 'oi-9', order_id: 'ord-4', product_id: 'prod-16', product_name: 'Crème Brûlée', quantity: 1, unit_price: 12.00, total_price: 12.00 },
|
| 162 |
+
];
|
| 163 |
+
|
| 164 |
+
export const demoSubscription: Subscription = {
|
| 165 |
+
id: 'sub-1',
|
| 166 |
+
restaurant_id: 'rest-1',
|
| 167 |
+
stripe_customer_id: 'cus_demo123',
|
| 168 |
+
stripe_subscription_id: 'sub_demo123',
|
| 169 |
+
tier: 'pro',
|
| 170 |
+
status: 'active',
|
| 171 |
+
current_period_start: '2025-03-01T00:00:00Z',
|
| 172 |
+
current_period_end: '2025-04-01T00:00:00Z',
|
| 173 |
+
created_at: '2025-01-15T10:00:00Z',
|
| 174 |
+
updated_at: '2025-03-01T00:00:00Z',
|
| 175 |
+
};
|
| 176 |
+
|
| 177 |
+
// Analytics data generators
|
| 178 |
+
export function generateDailyRevenue(days: number = 30) {
|
| 179 |
+
const data = [];
|
| 180 |
+
for (let i = days - 1; i >= 0; i--) {
|
| 181 |
+
const date = new Date();
|
| 182 |
+
date.setDate(date.getDate() - i);
|
| 183 |
+
const base = 800 + Math.random() * 1200;
|
| 184 |
+
const dayOfWeek = date.getDay();
|
| 185 |
+
const weekendBoost = (dayOfWeek === 5 || dayOfWeek === 6) ? 1.4 : 1;
|
| 186 |
+
data.push({
|
| 187 |
+
date: date.toISOString().split('T')[0],
|
| 188 |
+
revenue: Math.round(base * weekendBoost * 100) / 100,
|
| 189 |
+
orders: Math.round((base * weekendBoost) / 35),
|
| 190 |
+
});
|
| 191 |
+
}
|
| 192 |
+
return data;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
export function generatePopularItems() {
|
| 196 |
+
return [
|
| 197 |
+
{ name: 'Garden Smash Burger', orders: 186, revenue: 3534 },
|
| 198 |
+
{ name: 'Grilled Ribeye Steak', orders: 142, revenue: 5964 },
|
| 199 |
+
{ name: 'Lobster Linguine', orders: 128, revenue: 4608 },
|
| 200 |
+
{ name: 'Pan-Seared Salmon', orders: 119, revenue: 3808 },
|
| 201 |
+
{ name: 'Chocolate Lava Cake', orders: 98, revenue: 1372 },
|
| 202 |
+
{ name: 'Truffle Burrata', orders: 94, revenue: 1551 },
|
| 203 |
+
{ name: 'Garden Spritz', orders: 87, revenue: 1218 },
|
| 204 |
+
{ name: 'Carbonara', orders: 82, revenue: 1640 },
|
| 205 |
+
];
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
export function generateOrdersByType() {
|
| 209 |
+
return [
|
| 210 |
+
{ type: 'Dine In', count: 456, percentage: 52 },
|
| 211 |
+
{ type: 'Takeaway', count: 234, percentage: 27 },
|
| 212 |
+
{ type: 'Delivery', count: 183, percentage: 21 },
|
| 213 |
+
];
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
export function generateHourlyOrders() {
|
| 217 |
+
const hours = [];
|
| 218 |
+
for (let h = 10; h <= 22; h++) {
|
| 219 |
+
const lunchPeak = h >= 12 && h <= 14 ? 2.2 : 1;
|
| 220 |
+
const dinnerPeak = h >= 18 && h <= 21 ? 2.8 : 1;
|
| 221 |
+
hours.push({
|
| 222 |
+
hour: `${h}:00`,
|
| 223 |
+
orders: Math.round((3 + Math.random() * 5) * Math.max(lunchPeak, dinnerPeak)),
|
| 224 |
+
});
|
| 225 |
+
}
|
| 226 |
+
return hours;
|
| 227 |
+
}
|
src/lib/utils.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { clsx, type ClassValue } from 'clsx';
|
| 2 |
+
import { twMerge } from 'tailwind-merge';
|
| 3 |
+
|
| 4 |
+
export function cn(...inputs: ClassValue[]) {
|
| 5 |
+
return twMerge(clsx(inputs));
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export function formatCurrency(amount: number, currency = 'USD') {
|
| 9 |
+
return new Intl.NumberFormat('en-US', {
|
| 10 |
+
style: 'currency',
|
| 11 |
+
currency,
|
| 12 |
+
}).format(amount);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export function formatDate(date: string | Date) {
|
| 16 |
+
return new Intl.DateTimeFormat('en-US', {
|
| 17 |
+
month: 'short',
|
| 18 |
+
day: 'numeric',
|
| 19 |
+
year: 'numeric',
|
| 20 |
+
}).format(new Date(date));
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export function formatTime(date: string | Date) {
|
| 24 |
+
return new Intl.DateTimeFormat('en-US', {
|
| 25 |
+
hour: 'numeric',
|
| 26 |
+
minute: '2-digit',
|
| 27 |
+
hour12: true,
|
| 28 |
+
}).format(new Date(date));
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export function formatRelativeTime(date: string | Date) {
|
| 32 |
+
const now = new Date();
|
| 33 |
+
const then = new Date(date);
|
| 34 |
+
const diffMs = now.getTime() - then.getTime();
|
| 35 |
+
const diffMins = Math.floor(diffMs / 60000);
|
| 36 |
+
if (diffMins < 1) return 'Just now';
|
| 37 |
+
if (diffMins < 60) return `${diffMins}m ago`;
|
| 38 |
+
const diffHours = Math.floor(diffMins / 60);
|
| 39 |
+
if (diffHours < 24) return `${diffHours}h ago`;
|
| 40 |
+
const diffDays = Math.floor(diffHours / 24);
|
| 41 |
+
return `${diffDays}d ago`;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export function generateSlug(name: string) {
|
| 45 |
+
return name
|
| 46 |
+
.toLowerCase()
|
| 47 |
+
.replace(/[^a-z0-9]+/g, '-')
|
| 48 |
+
.replace(/^-|-$/g, '');
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export function generateId() {
|
| 52 |
+
return crypto.randomUUID();
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export function truncate(str: string, length: number) {
|
| 56 |
+
if (str.length <= length) return str;
|
| 57 |
+
return str.slice(0, length) + '...';
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
export function getInitials(name: string) {
|
| 61 |
+
return name
|
| 62 |
+
.split(' ')
|
| 63 |
+
.map((n) => n[0])
|
| 64 |
+
.join('')
|
| 65 |
+
.toUpperCase()
|
| 66 |
+
.slice(0, 2);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
export function getOrderStatusColor(status: string) {
|
| 70 |
+
const colors: Record<string, string> = {
|
| 71 |
+
pending: 'bg-amber-100 text-amber-700 border-amber-200',
|
| 72 |
+
confirmed: 'bg-blue-100 text-blue-700 border-blue-200',
|
| 73 |
+
preparing: 'bg-purple-100 text-purple-700 border-purple-200',
|
| 74 |
+
ready: 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
| 75 |
+
delivered: 'bg-gray-100 text-gray-700 border-gray-200',
|
| 76 |
+
cancelled: 'bg-red-100 text-red-700 border-red-200',
|
| 77 |
+
};
|
| 78 |
+
return colors[status] || 'bg-gray-100 text-gray-700 border-gray-200';
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
export function getOrderTypeLabel(type: string) {
|
| 82 |
+
const labels: Record<string, string> = {
|
| 83 |
+
dine_in: 'Dine In',
|
| 84 |
+
takeaway: 'Takeaway',
|
| 85 |
+
delivery: 'Delivery',
|
| 86 |
+
};
|
| 87 |
+
return labels[type] || type;
|
| 88 |
+
}
|
src/stores/cart-store.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { create } from 'zustand';
|
| 4 |
+
import { persist } from 'zustand/middleware';
|
| 5 |
+
import { CartItem, Product, OrderType } from '@/types/database';
|
| 6 |
+
import { generateId } from '@/lib/utils';
|
| 7 |
+
|
| 8 |
+
interface CartStore {
|
| 9 |
+
items: CartItem[];
|
| 10 |
+
restaurantId: string | null;
|
| 11 |
+
restaurantName: string | null;
|
| 12 |
+
orderType: OrderType;
|
| 13 |
+
tableNumber: string | null;
|
| 14 |
+
customerName: string;
|
| 15 |
+
customerPhone: string;
|
| 16 |
+
customerEmail: string;
|
| 17 |
+
deliveryAddress: string;
|
| 18 |
+
notes: string;
|
| 19 |
+
|
| 20 |
+
addItem: (product: Product, quantity: number, options?: Record<string, string | string[]>, notes?: string) => void;
|
| 21 |
+
removeItem: (itemId: string) => void;
|
| 22 |
+
updateQuantity: (itemId: string, quantity: number) => void;
|
| 23 |
+
clearCart: () => void;
|
| 24 |
+
setOrderType: (type: OrderType) => void;
|
| 25 |
+
setTableNumber: (number: string | null) => void;
|
| 26 |
+
setRestaurant: (id: string, name: string) => void;
|
| 27 |
+
setCustomerName: (name: string) => void;
|
| 28 |
+
setCustomerPhone: (phone: string) => void;
|
| 29 |
+
setCustomerEmail: (email: string) => void;
|
| 30 |
+
setDeliveryAddress: (address: string) => void;
|
| 31 |
+
setNotes: (notes: string) => void;
|
| 32 |
+
getSubtotal: () => number;
|
| 33 |
+
getTax: () => number;
|
| 34 |
+
getTotal: () => number;
|
| 35 |
+
getItemCount: () => number;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export const useCartStore = create<CartStore>()(
|
| 39 |
+
persist(
|
| 40 |
+
(set, get) => ({
|
| 41 |
+
items: [],
|
| 42 |
+
restaurantId: null,
|
| 43 |
+
restaurantName: null,
|
| 44 |
+
orderType: 'dine_in',
|
| 45 |
+
tableNumber: null,
|
| 46 |
+
customerName: '',
|
| 47 |
+
customerPhone: '',
|
| 48 |
+
customerEmail: '',
|
| 49 |
+
deliveryAddress: '',
|
| 50 |
+
notes: '',
|
| 51 |
+
|
| 52 |
+
addItem: (product, quantity, options = {}, notes = '') => {
|
| 53 |
+
const total = product.price * quantity;
|
| 54 |
+
const newItem: CartItem = {
|
| 55 |
+
id: generateId(),
|
| 56 |
+
product,
|
| 57 |
+
quantity,
|
| 58 |
+
options,
|
| 59 |
+
notes,
|
| 60 |
+
total,
|
| 61 |
+
};
|
| 62 |
+
set((state) => ({ items: [...state.items, newItem] }));
|
| 63 |
+
},
|
| 64 |
+
|
| 65 |
+
removeItem: (itemId) => {
|
| 66 |
+
set((state) => ({ items: state.items.filter((i) => i.id !== itemId) }));
|
| 67 |
+
},
|
| 68 |
+
|
| 69 |
+
updateQuantity: (itemId, quantity) => {
|
| 70 |
+
if (quantity <= 0) {
|
| 71 |
+
get().removeItem(itemId);
|
| 72 |
+
return;
|
| 73 |
+
}
|
| 74 |
+
set((state) => ({
|
| 75 |
+
items: state.items.map((item) =>
|
| 76 |
+
item.id === itemId
|
| 77 |
+
? { ...item, quantity, total: item.product.price * quantity }
|
| 78 |
+
: item
|
| 79 |
+
),
|
| 80 |
+
}));
|
| 81 |
+
},
|
| 82 |
+
|
| 83 |
+
clearCart: () => {
|
| 84 |
+
set({ items: [], notes: '' });
|
| 85 |
+
},
|
| 86 |
+
|
| 87 |
+
setOrderType: (type) => set({ orderType: type }),
|
| 88 |
+
setTableNumber: (number) => set({ tableNumber: number }),
|
| 89 |
+
setRestaurant: (id, name) => set({ restaurantId: id, restaurantName: name }),
|
| 90 |
+
setCustomerName: (name) => set({ customerName: name }),
|
| 91 |
+
setCustomerPhone: (phone) => set({ customerPhone: phone }),
|
| 92 |
+
setCustomerEmail: (email) => set({ customerEmail: email }),
|
| 93 |
+
setDeliveryAddress: (address) => set({ deliveryAddress: address }),
|
| 94 |
+
setNotes: (notes) => set({ notes }),
|
| 95 |
+
|
| 96 |
+
getSubtotal: () => {
|
| 97 |
+
return get().items.reduce((sum, item) => sum + item.total, 0);
|
| 98 |
+
},
|
| 99 |
+
|
| 100 |
+
getTax: () => {
|
| 101 |
+
return get().getSubtotal() * 0.08;
|
| 102 |
+
},
|
| 103 |
+
|
| 104 |
+
getTotal: () => {
|
| 105 |
+
return get().getSubtotal() + get().getTax();
|
| 106 |
+
},
|
| 107 |
+
|
| 108 |
+
getItemCount: () => {
|
| 109 |
+
return get().items.reduce((sum, item) => sum + item.quantity, 0);
|
| 110 |
+
},
|
| 111 |
+
}),
|
| 112 |
+
{
|
| 113 |
+
name: 'scanmenu-cart',
|
| 114 |
+
}
|
| 115 |
+
)
|
| 116 |
+
);
|
src/types/database.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ============================================================
|
| 2 |
+
// ScanMenu – Complete Database Types
|
| 3 |
+
// ============================================================
|
| 4 |
+
|
| 5 |
+
export type UserRole = 'admin' | 'owner' | 'staff' | 'customer';
|
| 6 |
+
export type OrderStatus = 'pending' | 'confirmed' | 'preparing' | 'ready' | 'delivered' | 'cancelled';
|
| 7 |
+
export type OrderType = 'dine_in' | 'takeaway' | 'delivery';
|
| 8 |
+
export type SubscriptionTier = 'free' | 'starter' | 'pro' | 'enterprise';
|
| 9 |
+
export type SubscriptionStatus = 'active' | 'past_due' | 'cancelled' | 'trialing';
|
| 10 |
+
|
| 11 |
+
export interface User {
|
| 12 |
+
id: string;
|
| 13 |
+
email: string;
|
| 14 |
+
full_name: string;
|
| 15 |
+
avatar_url?: string;
|
| 16 |
+
role: UserRole;
|
| 17 |
+
created_at: string;
|
| 18 |
+
updated_at: string;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export interface Restaurant {
|
| 22 |
+
id: string;
|
| 23 |
+
owner_id: string;
|
| 24 |
+
name: string;
|
| 25 |
+
slug: string;
|
| 26 |
+
description?: string;
|
| 27 |
+
logo_url?: string;
|
| 28 |
+
cover_image_url?: string;
|
| 29 |
+
address?: string;
|
| 30 |
+
phone?: string;
|
| 31 |
+
email?: string;
|
| 32 |
+
website?: string;
|
| 33 |
+
currency: string;
|
| 34 |
+
timezone: string;
|
| 35 |
+
is_active: boolean;
|
| 36 |
+
opening_hours?: Record<string, { open: string; close: string; closed?: boolean }>;
|
| 37 |
+
theme_color: string;
|
| 38 |
+
created_at: string;
|
| 39 |
+
updated_at: string;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export interface Menu {
|
| 43 |
+
id: string;
|
| 44 |
+
restaurant_id: string;
|
| 45 |
+
name: string;
|
| 46 |
+
description?: string;
|
| 47 |
+
is_active: boolean;
|
| 48 |
+
sort_order: number;
|
| 49 |
+
created_at: string;
|
| 50 |
+
updated_at: string;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export interface Category {
|
| 54 |
+
id: string;
|
| 55 |
+
menu_id: string;
|
| 56 |
+
restaurant_id: string;
|
| 57 |
+
name: string;
|
| 58 |
+
description?: string;
|
| 59 |
+
image_url?: string;
|
| 60 |
+
is_active: boolean;
|
| 61 |
+
sort_order: number;
|
| 62 |
+
created_at: string;
|
| 63 |
+
updated_at: string;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export interface Product {
|
| 67 |
+
id: string;
|
| 68 |
+
category_id: string;
|
| 69 |
+
restaurant_id: string;
|
| 70 |
+
name: string;
|
| 71 |
+
description?: string;
|
| 72 |
+
price: number;
|
| 73 |
+
image_url?: string;
|
| 74 |
+
is_available: boolean;
|
| 75 |
+
is_featured: boolean;
|
| 76 |
+
preparation_time?: number; // minutes
|
| 77 |
+
calories?: number;
|
| 78 |
+
allergens?: string[];
|
| 79 |
+
tags?: string[];
|
| 80 |
+
sort_order: number;
|
| 81 |
+
created_at: string;
|
| 82 |
+
updated_at: string;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
export interface ProductOption {
|
| 86 |
+
id: string;
|
| 87 |
+
product_id: string;
|
| 88 |
+
name: string; // e.g. "Size", "Spice Level"
|
| 89 |
+
type: 'single' | 'multiple';
|
| 90 |
+
required: boolean;
|
| 91 |
+
min_selections?: number;
|
| 92 |
+
max_selections?: number;
|
| 93 |
+
sort_order: number;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
export interface ProductOptionItem {
|
| 97 |
+
id: string;
|
| 98 |
+
option_id: string;
|
| 99 |
+
name: string; // e.g. "Small", "Medium", "Large"
|
| 100 |
+
price_modifier: number;
|
| 101 |
+
is_default: boolean;
|
| 102 |
+
sort_order: number;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
export interface Table {
|
| 106 |
+
id: string;
|
| 107 |
+
restaurant_id: string;
|
| 108 |
+
number: string;
|
| 109 |
+
name?: string;
|
| 110 |
+
capacity?: number;
|
| 111 |
+
is_active: boolean;
|
| 112 |
+
qr_code_id?: string;
|
| 113 |
+
created_at: string;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
export interface QRCode {
|
| 117 |
+
id: string;
|
| 118 |
+
restaurant_id: string;
|
| 119 |
+
table_id?: string;
|
| 120 |
+
label: string;
|
| 121 |
+
url: string;
|
| 122 |
+
scans: number;
|
| 123 |
+
is_active: boolean;
|
| 124 |
+
created_at: string;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
export interface Order {
|
| 128 |
+
id: string;
|
| 129 |
+
restaurant_id: string;
|
| 130 |
+
customer_name?: string;
|
| 131 |
+
customer_phone?: string;
|
| 132 |
+
customer_email?: string;
|
| 133 |
+
order_type: OrderType;
|
| 134 |
+
table_number?: string;
|
| 135 |
+
status: OrderStatus;
|
| 136 |
+
subtotal: number;
|
| 137 |
+
tax: number;
|
| 138 |
+
total: number;
|
| 139 |
+
notes?: string;
|
| 140 |
+
delivery_address?: string;
|
| 141 |
+
estimated_time?: number;
|
| 142 |
+
created_at: string;
|
| 143 |
+
updated_at: string;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
export interface OrderItem {
|
| 147 |
+
id: string;
|
| 148 |
+
order_id: string;
|
| 149 |
+
product_id: string;
|
| 150 |
+
product_name: string;
|
| 151 |
+
quantity: number;
|
| 152 |
+
unit_price: number;
|
| 153 |
+
total_price: number;
|
| 154 |
+
options?: Record<string, string | string[]>;
|
| 155 |
+
notes?: string;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
export interface Subscription {
|
| 159 |
+
id: string;
|
| 160 |
+
restaurant_id: string;
|
| 161 |
+
stripe_customer_id?: string;
|
| 162 |
+
stripe_subscription_id?: string;
|
| 163 |
+
tier: SubscriptionTier;
|
| 164 |
+
status: SubscriptionStatus;
|
| 165 |
+
current_period_start?: string;
|
| 166 |
+
current_period_end?: string;
|
| 167 |
+
created_at: string;
|
| 168 |
+
updated_at: string;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
export interface AnalyticsEvent {
|
| 172 |
+
id: string;
|
| 173 |
+
restaurant_id: string;
|
| 174 |
+
event_type: string;
|
| 175 |
+
event_data?: Record<string, unknown>;
|
| 176 |
+
created_at: string;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
// ============================================================
|
| 180 |
+
// Cart Types (Client-side)
|
| 181 |
+
// ============================================================
|
| 182 |
+
|
| 183 |
+
export interface CartItem {
|
| 184 |
+
id: string;
|
| 185 |
+
product: Product;
|
| 186 |
+
quantity: number;
|
| 187 |
+
options: Record<string, string | string[]>;
|
| 188 |
+
notes?: string;
|
| 189 |
+
total: number;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
export interface Cart {
|
| 193 |
+
restaurant_id: string;
|
| 194 |
+
restaurant_name: string;
|
| 195 |
+
items: CartItem[];
|
| 196 |
+
order_type: OrderType;
|
| 197 |
+
table_number?: string;
|
| 198 |
+
subtotal: number;
|
| 199 |
+
tax: number;
|
| 200 |
+
total: number;
|
| 201 |
+
}
|
tsconfig.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2017",
|
| 4 |
+
"lib": ["dom", "dom.iterable", "esnext"],
|
| 5 |
+
"allowJs": true,
|
| 6 |
+
"skipLibCheck": true,
|
| 7 |
+
"strict": true,
|
| 8 |
+
"noEmit": true,
|
| 9 |
+
"esModuleInterop": true,
|
| 10 |
+
"module": "esnext",
|
| 11 |
+
"moduleResolution": "bundler",
|
| 12 |
+
"resolveJsonModule": true,
|
| 13 |
+
"isolatedModules": true,
|
| 14 |
+
"jsx": "react-jsx",
|
| 15 |
+
"incremental": true,
|
| 16 |
+
"plugins": [
|
| 17 |
+
{
|
| 18 |
+
"name": "next"
|
| 19 |
+
}
|
| 20 |
+
],
|
| 21 |
+
"paths": {
|
| 22 |
+
"@/*": ["./src/*"]
|
| 23 |
+
}
|
| 24 |
+
},
|
| 25 |
+
"include": [
|
| 26 |
+
"next-env.d.ts",
|
| 27 |
+
"**/*.ts",
|
| 28 |
+
"**/*.tsx",
|
| 29 |
+
".next/types/**/*.ts",
|
| 30 |
+
".next/dev/types/**/*.ts",
|
| 31 |
+
"**/*.mts"
|
| 32 |
+
],
|
| 33 |
+
"exclude": ["node_modules"]
|
| 34 |
+
}
|