Compare commits
64 Commits
0fba981bca
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9acd18955e | ||
|
|
318927cd0c | ||
|
|
a6b7286b45 | ||
|
|
d78e6c0725 | ||
|
|
3f9d28bf1b | ||
|
|
064cd7a486 | ||
|
|
6cd658c4cb | ||
|
|
6997838bf7 | ||
|
|
e369741b2d | ||
|
|
7ddcd7afb6 | ||
|
|
3ffb64cf9a | ||
|
|
e9737c8b24 | ||
|
|
244014f33a | ||
|
|
1186952ed8 | ||
|
|
0bb1497db6 | ||
|
|
688f519fd6 | ||
|
|
73adbe5d07 | ||
|
|
63c833b510 | ||
|
|
bfd31f9d35 | ||
|
|
f7e768f6d6 | ||
|
|
7c7db0fc09 | ||
|
|
211cdc71f9 | ||
|
|
7b95589867 | ||
|
|
c209dd60fc | ||
|
|
a05787a091 | ||
|
|
a0605e47de | ||
|
|
1933ef4007 | ||
|
|
f7af5b933d | ||
|
|
198b9a82ff | ||
|
|
6eeed4267a | ||
|
|
5d9f8fa07b | ||
|
|
02ba4b0e66 | ||
|
|
624bfa5485 | ||
|
|
b10e8f8939 | ||
|
|
4f55629f4b | ||
|
|
bd4683d91e | ||
|
|
c0f4b05ef4 | ||
|
|
a8fdac532c | ||
|
|
506b4b2f04 | ||
|
|
397177fe36 | ||
|
|
660547e901 | ||
|
|
5ceadb7cbe | ||
|
|
a88c1422e8 | ||
|
|
9fc3f12a44 | ||
|
|
9bf2e22fe1 | ||
|
|
120d09f63c | ||
|
|
8211ed4d8e | ||
|
|
dec1358b49 | ||
|
|
31158c50d3 | ||
|
|
4fff74a351 | ||
|
|
bbd8334dbd | ||
|
|
66489f8e77 | ||
|
|
653083a618 | ||
|
|
078391334a | ||
|
|
a05fbfb85b | ||
|
|
c62ad7feac | ||
|
|
be31df404e | ||
|
|
862f4ac874 | ||
|
|
acc8c2aa09 | ||
|
|
2477e9bb0f | ||
|
|
db757d0107 | ||
|
|
6f62414888 | ||
|
|
4d9f205277 | ||
|
|
86b812f42b |
22
.gitea/workflows/build.yml
Normal file
22
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
name: Build Frontend
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t ember-market-frontend .
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login https://runner.thaiboydigit.al -u g --password-stdin
|
||||
|
||||
- name: Tag Docker image
|
||||
run: docker tag ember-market-frontend runner.thaiboydigit.al/g/ember-market-frontend:latest
|
||||
|
||||
- name: Push Docker image
|
||||
run: docker push runner.thaiboydigit.al/g/ember-market-frontend:latest
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -46,3 +46,8 @@ public/git-info.json
|
||||
public/git-info.json
|
||||
public/git-info.json
|
||||
/.next - Copy
|
||||
|
||||
# Docker Gitea
|
||||
docker-compose.gitea.yml
|
||||
runner/
|
||||
nginx/
|
||||
|
||||
37
Dockerfile
37
Dockerfile
@@ -1,3 +1,5 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Use official Node.js image as base
|
||||
# Next.js 16 requires Node 18.17+ or Node 20+
|
||||
# Using node:20-alpine for better compatibility with Next.js 16 and Turbopack
|
||||
@@ -7,45 +9,52 @@ WORKDIR /app
|
||||
# Install git early for commit hash
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm
|
||||
# Install pnpm properly for Docker
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Copy package files for dependency installation
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
# Install dependencies with pnpm
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source code after dependencies are installed (for better caching)
|
||||
COPY . .
|
||||
|
||||
# Get commit hash (fallback to "unknown" if not in git repo)
|
||||
RUN git rev-parse --short HEAD > /app/git_commit_sha 2>/dev/null || echo "unknown" > /app/git_commit_sha
|
||||
# This is done after copying source to avoid cache invalidation
|
||||
# Test build
|
||||
|
||||
ENV NEXT_PUBLIC_API_URL=/api
|
||||
ENV SERVER_API_URL=https://internal-api.inboxi.ng/api
|
||||
ENV API_BASE_URL=https://internal-api.inboxi.ng
|
||||
ENV API_HOSTNAME=internal-api.inboxi.ng
|
||||
|
||||
# Build the Next.js application with increased memory for Turbopack
|
||||
# Ensure .next directory exists and has proper permissions
|
||||
RUN mkdir -p /app/.next && chmod -R 755 /app/.next
|
||||
|
||||
# Build the Next.js application with reduced memory for Turbopack
|
||||
# Next.js 16 uses Turbopack by default which may need more memory
|
||||
RUN echo "Building with GIT_COMMIT_SHA=$(cat /app/git_commit_sha)" && \
|
||||
NODE_OPTIONS='--max_old_space_size=4096' NEXT_TELEMETRY_DISABLED=1 pnpm run build
|
||||
RUN echo "invalidate cache" && echo $(git rev-parse --short HEAD 2>/dev/null || echo "unknown") > /app/git_commit_sha && \
|
||||
NODE_OPTIONS='--max_old_space_size=2048' NEXT_TELEMETRY_DISABLED=1 pnpm run build
|
||||
|
||||
# ---- Production Stage ----
|
||||
# Use Node 20 for production as well to match builder
|
||||
# Use a smaller base image for production
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
# Set working directory inside the container
|
||||
WORKDIR /app
|
||||
|
||||
RUN mkdir -p /app/public
|
||||
# Copy the standalone build from builder
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
|
||||
# Copy only necessary files from builder
|
||||
COPY --from=builder /app/package.json /app/pnpm-lock.yaml ./
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
# Copy static files
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
# Copy public files
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Copy commit hash file from builder stage
|
||||
# Copy commit hash file from builder
|
||||
COPY --from=builder /app/git_commit_sha /app/git_commit_sha
|
||||
|
||||
EXPOSE 3000
|
||||
@@ -60,4 +69,4 @@ ENV API_HOSTNAME=internal-api.inboxi.ng
|
||||
# The file is available at /app/git_commit_sha if needed
|
||||
|
||||
# Start Next.js server
|
||||
CMD ["pnpm", "run", "start"]
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
@@ -7,7 +8,8 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Loader2, ArrowRight } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export default function LoginForm() {
|
||||
const [username, setUsername] = useState("");
|
||||
@@ -45,7 +47,6 @@ export default function LoginForm() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Using fetch directly with the proxy path
|
||||
const response = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -65,76 +66,81 @@ export default function LoginForm() {
|
||||
}
|
||||
|
||||
if (response.ok && data.token) {
|
||||
// Store the token in both cookie and localStorage for redundancy
|
||||
document.cookie = `Authorization=${data.token}; path=/; Secure; SameSite=Strict; max-age=10800`;
|
||||
localStorage.setItem("Authorization", data.token);
|
||||
|
||||
// Show success notification
|
||||
toast.success("Login successful");
|
||||
|
||||
// Set success state for animation
|
||||
toast.success("Welcome back!", { duration: 2000 });
|
||||
setLoginSuccess(true);
|
||||
|
||||
// Try Next.js router navigation
|
||||
router.push(redirectUrl);
|
||||
|
||||
// Set up a fallback manual redirect if Next.js navigation doesn't work
|
||||
redirectTimeoutRef.current = setTimeout(() => {
|
||||
window.location.href = redirectUrl;
|
||||
}, 1500); // Wait 1.5 seconds before trying manual redirect
|
||||
}, 1500);
|
||||
} else {
|
||||
// Handle HTTP error responses
|
||||
const errorMessage = data.error || data.message || data.details || "Invalid credentials";
|
||||
toast.error("Login Failed", {
|
||||
toast.error("Access Denied", {
|
||||
description: errorMessage,
|
||||
});
|
||||
console.error("Login error response:", { status: response.status, data });
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Connection Error", {
|
||||
description: "Unable to connect to the server. Please check your internet connection and try again.",
|
||||
description: "Unable to connect to server.",
|
||||
});
|
||||
console.error("Login network error:", error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-center min-h-screen bg-gray-100 dark:bg-[#0F0F12] transition-opacity duration-300 ${loginSuccess ? 'opacity-0' : 'opacity-100'}`}>
|
||||
<div className={`w-full max-w-md p-8 space-y-8 bg-white dark:bg-[#1F1F23] rounded-xl shadow-lg transition-all duration-300 ${loginSuccess ? 'scale-95 opacity-0' : 'scale-100 opacity-100'} ${isLoading && !loginSuccess ? 'animate-pulse' : ''}`}>
|
||||
<div className="text-center">
|
||||
<h2 className="mt-6 text-3xl font-bold text-gray-900 dark:text-white">Welcome back</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">Please sign in to your account</p>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="w-full max-w-md relative z-10"
|
||||
>
|
||||
<div className={`
|
||||
overflow-hidden rounded-2xl border border-white/10 shadow-2xl transition-all duration-300
|
||||
bg-black/40 backdrop-blur-xl
|
||||
p-8
|
||||
${loginSuccess ? 'scale-[0.98] opacity-80' : 'scale-100 opacity-100'}
|
||||
`}>
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-white tracking-tight">Welcome back</h2>
|
||||
<p className="mt-2 text-sm text-zinc-400">Enter your credentials to access the dashboard</p>
|
||||
</div>
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleLogin}>
|
||||
<form className="space-y-6" onSubmit={handleLogin}>
|
||||
<div className="space-y-4">
|
||||
<div className="animate-in fade-in duration-500">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username" className="text-zinc-300">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="mt-1"
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-zinc-500 focus:border-indigo-500/50 focus:ring-indigo-500/20 transition-all duration-300"
|
||||
placeholder="Enter your username"
|
||||
disabled={isLoading || loginSuccess}
|
||||
/>
|
||||
</div>
|
||||
<div className="animate-in fade-in duration-500 delay-150">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password" className="text-zinc-300">Password</Label>
|
||||
<Link href="/auth/reset-password" className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1"
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-zinc-500 focus:border-indigo-500/50 focus:ring-indigo-500/20 transition-all duration-300"
|
||||
placeholder="Enter your password"
|
||||
disabled={isLoading || loginSuccess}
|
||||
/>
|
||||
</div>
|
||||
@@ -142,16 +148,22 @@ export default function LoginForm() {
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className={`w-full animate-in fade-in-50 duration-500 delay-300 ${loginSuccess ? 'bg-green-600 hover:bg-green-700' : ''}`}
|
||||
className={`
|
||||
w-full h-11 font-medium text-sm transition-all duration-300
|
||||
${loginSuccess
|
||||
? 'bg-green-500/90 hover:bg-green-500 text-white'
|
||||
: 'bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 text-white shadow-lg shadow-indigo-500/25'}
|
||||
`}
|
||||
disabled={isLoading || loginSuccess}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Signing in...
|
||||
</span>
|
||||
) : loginSuccess ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
Redirecting...
|
||||
</span>
|
||||
) : (
|
||||
@@ -160,13 +172,18 @@ export default function LoginForm() {
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-10 text-sm text-center text-gray-600 dark:text-gray-400 animate-in fade-in duration-500 delay-500">
|
||||
<div className="mt-8 pt-6 border-t border-white/10 text-center">
|
||||
<p className="text-sm text-zinc-400">
|
||||
Don't have an account?{" "}
|
||||
<Link href="/auth/register" className="text-blue-600 hover:underline dark:text-blue-400">
|
||||
Sign up
|
||||
<Link
|
||||
href="/auth/register"
|
||||
className="text-indigo-400 hover:text-indigo-300 font-medium transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
Sign up <ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -2,28 +2,42 @@
|
||||
|
||||
import React, { Suspense, lazy } from "react";
|
||||
|
||||
|
||||
// Use lazy loading for the form component
|
||||
const LoginForm = lazy(() => import('./components/LoginForm'));
|
||||
|
||||
// Simple loading state for the Suspense boundary
|
||||
// Background Component
|
||||
const AuthBackground = () => (
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute inset-0 bg-black" />
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-gradient-to-br from-indigo-500/20 via-purple-500/10 to-transparent" />
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-blue-500/20 rounded-full blur-3xl opacity-50 animate-pulse" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-purple-500/20 rounded-full blur-3xl opacity-50 animate-pulse delay-1000" />
|
||||
</div>
|
||||
);
|
||||
|
||||
// Loading State
|
||||
function LoginLoading() {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-[#0F0F12]">
|
||||
<div className="w-full max-w-md p-8 space-y-8 bg-white dark:bg-[#1F1F23] rounded-xl shadow-lg text-center">
|
||||
<div className="w-full max-w-md p-8 space-y-8 bg-black/40 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl text-center relative z-10">
|
||||
<div className="mt-6 flex flex-col items-center justify-center">
|
||||
<div className="w-12 h-12 border-4 border-t-blue-500 border-b-transparent border-l-transparent border-r-transparent rounded-full animate-spin"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading login form...</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 border-4 border-t-indigo-500 border-b-transparent border-l-transparent border-r-transparent rounded-full animate-spin"></div>
|
||||
<p className="mt-4 text-zinc-400">Loading secure login...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main page component that uses Suspense
|
||||
// Main page component
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="relative flex items-center justify-center min-h-screen overflow-hidden">
|
||||
<AuthBackground />
|
||||
<div className="flex flex-col items-center w-full px-4">
|
||||
<Suspense fallback={<LoginLoading />}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { fetchData } from "@/lib/api";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
@@ -7,59 +7,86 @@ import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2, ArrowRight } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
|
||||
// Matches LoginPage background
|
||||
const AuthBackground = () => (
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute inset-0 bg-black" />
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-gradient-to-br from-indigo-500/20 via-purple-500/10 to-transparent" />
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-blue-500/20 rounded-full blur-3xl opacity-50 animate-pulse" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-purple-500/20 rounded-full blur-3xl opacity-50 animate-pulse delay-1000" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [invitationCode, setInvitationCode] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
async function handleRegister(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
const res = await fetchData(
|
||||
`/api/auth/register`,
|
||||
{
|
||||
try {
|
||||
const res = await fetch(`/api/auth/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password, invitationCode }),
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const data = await res;
|
||||
const data = await res.json();
|
||||
|
||||
if (res) {
|
||||
console.log("Registered successfully:", data);
|
||||
router.push("/auth/login");
|
||||
if (res.ok) {
|
||||
toast({
|
||||
title: "Account Created! 🎉",
|
||||
description: "Welcome to Ember Market. Redirecting to login...",
|
||||
variant: "default",
|
||||
});
|
||||
setTimeout(() => router.push("/auth/login"), 1500);
|
||||
} else {
|
||||
setError(data.error || "Registration failed");
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Registration Failed",
|
||||
description: data.error || "Please check your details.",
|
||||
variant: "destructive",
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Something went wrong. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-[#0F0F12]">
|
||||
<div className="w-full max-w-md p-8 space-y-8 bg-white dark:bg-[#1F1F23] rounded-xl shadow-lg">
|
||||
<div className="text-center">
|
||||
<h2 className="mt-6 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Create an Account
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Sign up to start selling
|
||||
</p>
|
||||
<div className="relative flex items-center justify-center min-h-screen overflow-hidden">
|
||||
<AuthBackground />
|
||||
|
||||
<div className="flex flex-col items-center w-full px-4 text-center z-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="w-full max-w-md"
|
||||
>
|
||||
<div className="overflow-hidden rounded-2xl border border-white/10 shadow-2xl bg-black/40 backdrop-blur-xl p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-xl font-semibold text-white">Create your account</h2>
|
||||
<p className="mt-2 text-sm text-zinc-400">Start managing your store today</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-500 text-sm text-center">{error}</p>}
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleRegister}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<form className="space-y-5" onSubmit={handleRegister}>
|
||||
<div className="space-y-4 text-left">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username" className="text-zinc-300">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
name="username"
|
||||
@@ -68,11 +95,13 @@ export default function RegisterPage() {
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="mt-1"
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-zinc-500 focus:border-indigo-500/50 focus:ring-indigo-500/20 transition-all duration-300"
|
||||
placeholder="Choose a username"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-zinc-300">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
@@ -81,11 +110,13 @@ export default function RegisterPage() {
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1"
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-zinc-500 focus:border-indigo-500/50 focus:ring-indigo-500/20 transition-all duration-300"
|
||||
placeholder="Create a strong password"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="invitationCode">Invitation Code</Label>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invitationCode" className="text-zinc-300">Invitation Code</Label>
|
||||
<Input
|
||||
id="invitationCode"
|
||||
name="invitationCode"
|
||||
@@ -93,26 +124,43 @@ export default function RegisterPage() {
|
||||
required
|
||||
value={invitationCode}
|
||||
onChange={(e) => setInvitationCode(e.target.value)}
|
||||
className="mt-1"
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-zinc-500 focus:border-indigo-500/50 focus:ring-indigo-500/20 transition-all duration-300"
|
||||
placeholder="Enter your invite code"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Registering..." : "Sign Up"}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-11 font-medium bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 text-white shadow-lg shadow-indigo-500/25 transition-all duration-300"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Creating account...
|
||||
</span>
|
||||
) : (
|
||||
"Create Account"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-sm text-center text-gray-600 dark:text-gray-400">
|
||||
<div className="mt-8 pt-6 border-t border-white/10 text-center">
|
||||
<p className="text-sm text-zinc-400">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
href="/auth/login"
|
||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||
className="text-indigo-400 hover:text-indigo-300 font-medium transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
Sign in
|
||||
Sign in <ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Package, AlertTriangle } from "lucide-react";
|
||||
import { Package, AlertTriangle, CheckCircle2, XCircle, DollarSign } from "lucide-react";
|
||||
import { fetchServer } from "@/lib/api";
|
||||
import OrdersTable from "@/components/admin/OrdersTable";
|
||||
import { MotionWrapper } from "@/components/ui/motion-wrapper";
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -26,7 +27,6 @@ interface SystemStats {
|
||||
chats: number;
|
||||
}
|
||||
|
||||
|
||||
export default async function AdminOrdersPage() {
|
||||
let orders: Order[] = [];
|
||||
let systemStats: SystemStats | null = null;
|
||||
@@ -46,14 +46,14 @@ export default async function AdminOrdersPage() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 p-1">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Recent Orders</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Monitor and manage platform orders</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Recent Orders</h1>
|
||||
<p className="text-muted-foreground mt-2">Monitor and manage platform orders</p>
|
||||
</div>
|
||||
<Card>
|
||||
<Card className="border-destructive/50 bg-destructive/10">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-red-500">
|
||||
<div className="text-center text-destructive">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -62,146 +62,182 @@ export default async function AdminOrdersPage() {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const acknowledgedOrders = orders.filter(o => o.status === 'acknowledged');
|
||||
const paidOrders = orders.filter(o => o.status === 'paid');
|
||||
const completedOrders = orders.filter(o => o.status === 'completed');
|
||||
const cancelledOrders = orders.filter(o => o.status === 'cancelled');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8 p-1">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Recent Orders</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Monitor and manage platform orders</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">
|
||||
Recent Orders
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2 text-lg">
|
||||
Monitor and manage platform transaction activity
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MotionWrapper>
|
||||
<div className="space-y-8">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm hover:shadow-md transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Orders</CardTitle>
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total Orders</CardTitle>
|
||||
<div className="p-2 rounded-lg bg-primary/10">
|
||||
<Package className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{systemStats?.orders || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">All platform orders</p>
|
||||
<div className="flex items-center mt-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-primary mr-2" />
|
||||
<p className="text-xs text-muted-foreground">Lifetime volume</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
|
||||
<Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm hover:shadow-md transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Acknowledged</CardTitle>
|
||||
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Acknowledged</CardTitle>
|
||||
<div className="p-2 rounded-lg bg-purple-500/10">
|
||||
<AlertTriangle className="h-4 w-4 text-purple-500" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{acknowledgedOrders.length}</div>
|
||||
<p className="text-xs text-muted-foreground">Vendor accepted</p>
|
||||
<div className="flex items-center mt-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-purple-500 mr-2" />
|
||||
<p className="text-xs text-muted-foreground">Vendor pending</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
|
||||
<Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm hover:shadow-md transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Paid</CardTitle>
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Paid</CardTitle>
|
||||
<div className="p-2 rounded-lg bg-emerald-500/10">
|
||||
<DollarSign className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{paidOrders.length}</div>
|
||||
<p className="text-xs text-muted-foreground">Payment received</p>
|
||||
<div className="flex items-center mt-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-emerald-500 mr-2" />
|
||||
<p className="text-xs text-muted-foreground">Processing</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
|
||||
<Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm hover:shadow-md transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Completed</CardTitle>
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Completed</CardTitle>
|
||||
<div className="p-2 rounded-lg bg-blue-500/10">
|
||||
<CheckCircle2 className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{completedOrders.length}</div>
|
||||
<p className="text-xs text-muted-foreground">Successfully delivered</p>
|
||||
<div className="flex items-center mt-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-blue-500 mr-2" />
|
||||
<p className="text-xs text-muted-foreground">Delivered</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Orders Table with Pagination */}
|
||||
<div className="bg-background/50 backdrop-blur-sm rounded-xl border border-border/40 overflow-hidden shadow-sm">
|
||||
<OrdersTable orders={orders} />
|
||||
</div>
|
||||
|
||||
{/* Order Analytics */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Order Status Distribution</CardTitle>
|
||||
<CardDescription>Breakdown of recent orders by status</CardDescription>
|
||||
<CardTitle>Status Distribution</CardTitle>
|
||||
<CardDescription>Breakdown of active orders</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-3 h-3 bg-purple-500 rounded-full"></div>
|
||||
<span className="text-sm">Acknowledged</span>
|
||||
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-2 h-12 bg-purple-500 rounded-full"></div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">Acknowledged</p>
|
||||
<p className="text-xs text-muted-foreground">Waiting for shipment</p>
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
</div>
|
||||
<span className="font-bold">
|
||||
{orders.length > 0 ? Math.round((acknowledgedOrders.length / orders.length) * 100) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-3 h-3 bg-emerald-500 rounded-full"></div>
|
||||
<span className="text-sm">Paid</span>
|
||||
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-2 h-12 bg-emerald-500 rounded-full"></div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">Paid</p>
|
||||
<p className="text-xs text-muted-foreground">Payment confirmed</p>
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
</div>
|
||||
<span className="font-bold">
|
||||
{orders.length > 0 ? Math.round((paidOrders.length / orders.length) * 100) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||
<span className="text-sm">Completed</span>
|
||||
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-2 h-12 bg-blue-500 rounded-full"></div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">Completed</p>
|
||||
<p className="text-xs text-muted-foreground">Successfully concluded</p>
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
</div>
|
||||
<span className="font-bold">
|
||||
{orders.length > 0 ? Math.round((completedOrders.length / orders.length) * 100) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
|
||||
<span className="text-sm">Cancelled</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{orders.length > 0 ? Math.round((cancelledOrders.length / orders.length) * 100) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Order Summary</CardTitle>
|
||||
<CardDescription>Recent order activity breakdown</CardDescription>
|
||||
<CardTitle>Activity Summary</CardTitle>
|
||||
<CardDescription>Recent volume breakdown</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Total Recent Orders</span>
|
||||
<span className="text-sm font-medium">{orders.length}</span>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between pb-4 border-b border-border/40">
|
||||
<span className="text-sm text-muted-foreground">Total Displayed Orders</span>
|
||||
<span className="text-xl font-bold">{orders.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Acknowledged</span>
|
||||
<span className="text-sm font-medium">{acknowledgedOrders.length}</span>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs uppercase text-muted-foreground tracking-wider">Active</span>
|
||||
<p className="text-lg font-semibold">{acknowledgedOrders.length + paidOrders.length}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Paid</span>
|
||||
<span className="text-sm font-medium">{paidOrders.length}</span>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs uppercase text-muted-foreground tracking-wider">Finished</span>
|
||||
<p className="text-lg font-semibold">{completedOrders.length}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Completed</span>
|
||||
<span className="text-sm font-medium">{completedOrders.length}</span>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs uppercase text-muted-foreground tracking-wider">Voided</span>
|
||||
<p className="text-lg font-semibold text-destructive">{cancelledOrders.length}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs uppercase text-muted-foreground tracking-wider">Success Rate</span>
|
||||
<p className="text-lg font-semibold text-green-500">
|
||||
{orders.length > 0 ? Math.round((completedOrders.length / (orders.length - (acknowledgedOrders.length + paidOrders.length))) * 100) || 100 : 0}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Cancelled</span>
|
||||
<span className="text-sm font-medium">{cancelledOrders.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</MotionWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ function AdminComponentSkeleton({ showSlowWarning = false }: { showSlowWarning?:
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
|
||||
<Card
|
||||
key={i}
|
||||
className="overflow-hidden animate-in fade-in slide-in-from-bottom-4"
|
||||
className="overflow-hidden animate-in fade-in slide-in-from-bottom-4 border-border/40 bg-background/50 backdrop-blur-sm shadow-sm"
|
||||
style={{
|
||||
animationDelay: `${i * 50}ms`,
|
||||
animationDuration: '400ms',
|
||||
@@ -288,7 +288,7 @@ function ManagementCardsSkeleton({ showSlowWarning = false }: { showSlowWarning?
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card
|
||||
key={i}
|
||||
className="overflow-hidden animate-in fade-in slide-in-from-bottom-4"
|
||||
className="overflow-hidden animate-in fade-in slide-in-from-bottom-4 border-border/40 bg-background/50 backdrop-blur-sm shadow-sm"
|
||||
style={{
|
||||
animationDelay: `${i * 75}ms`,
|
||||
animationDuration: '400ms',
|
||||
@@ -355,7 +355,7 @@ export default function AdminPage() {
|
||||
const loadTime = performance.now() - startTime;
|
||||
console.log(`[Performance] AdminAnalytics prefetched in ${loadTime.toFixed(2)}ms`);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => { });
|
||||
} else if (tab === "management") {
|
||||
// Prefetch management components
|
||||
Promise.all([
|
||||
@@ -368,7 +368,7 @@ export default function AdminPage() {
|
||||
const loadTime = performance.now() - startTime;
|
||||
console.log(`[Performance] Management components prefetched in ${loadTime.toFixed(2)}ms`);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => { });
|
||||
}
|
||||
|
||||
setPrefetchedTabs(prev => new Set(prev).add(tab));
|
||||
@@ -392,22 +392,23 @@ export default function AdminPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Admin Dashboard</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Admin Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Platform analytics and vendor management</p>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Button asChild variant="outline" size="sm" className="border-border/50 bg-background/50 backdrop-blur-sm hover:bg-background/80 transition-all">
|
||||
<Link href="/dashboard">Back to Dashboard</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsList className="bg-muted/20 p-1 border border-border/40 backdrop-blur-sm h-auto">
|
||||
<TabsTrigger
|
||||
value="analytics"
|
||||
onMouseEnter={() => handleTabHover("analytics")}
|
||||
onFocus={() => handleTabFocus("analytics")}
|
||||
className="data-[state=active]:bg-background/80 data-[state=active]:backdrop-blur-sm data-[state=active]:text-foreground data-[state=active]:shadow-sm px-6 py-2 transition-all"
|
||||
>
|
||||
Analytics
|
||||
</TabsTrigger>
|
||||
@@ -415,6 +416,7 @@ export default function AdminPage() {
|
||||
value="management"
|
||||
onMouseEnter={() => handleTabHover("management")}
|
||||
onFocus={() => handleTabFocus("management")}
|
||||
className="data-[state=active]:bg-background/80 data-[state=active]:backdrop-blur-sm data-[state=active]:text-foreground data-[state=active]:shadow-sm px-6 py-2 transition-all"
|
||||
>
|
||||
Management
|
||||
</TabsTrigger>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Server, Database, Cpu, HardDrive, Activity } from "lucide-react";
|
||||
import { Server, Database, Cpu, HardDrive, Activity, Zap } from "lucide-react";
|
||||
import { fetchServer } from "@/lib/api";
|
||||
import SystemStatusCard from "@/components/admin/SystemStatusCard";
|
||||
import { MotionWrapper } from "@/components/ui/motion-wrapper";
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -35,17 +36,19 @@ export default async function AdminStatusPage() {
|
||||
console.error("Failed to fetch system status:", err);
|
||||
error = "Failed to load system status";
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8 p-1">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">System Status</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Monitor system health and performance metrics</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight">System Status</h1>
|
||||
<p className="text-muted-foreground mt-2">Monitor system health and real-time performance metrics</p>
|
||||
</div>
|
||||
<Card>
|
||||
<Card className="border-destructive/50 bg-destructive/10">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-red-500">
|
||||
<p>{error}</p>
|
||||
<div className="flex items-center gap-3 text-destructive">
|
||||
<Activity className="h-5 w-5" />
|
||||
<p className="font-medium">{error}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -71,112 +74,172 @@ export default async function AdminStatusPage() {
|
||||
Math.round((systemStatus.memory.heapUsed / systemStatus.memory.heapTotal) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8 p-1">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">System Status</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Monitor system health and performance metrics</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">
|
||||
System Status
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2 text-lg">
|
||||
Monitor system health and real-time performance metrics
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MotionWrapper>
|
||||
<div className="space-y-8">
|
||||
<SystemStatusCard />
|
||||
|
||||
<div className="grid gap-4 lg:gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{/* Server Status */}
|
||||
<Card>
|
||||
<Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm hover:shadow-md transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Server Status</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Server Uptime</CardTitle>
|
||||
<div className="p-2 rounded-lg bg-green-500/10">
|
||||
<Server className="h-4 w-4 text-green-500" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="default" className="bg-green-500">Online</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-2xl font-bold">
|
||||
{systemStatus ? formatUptime(systemStatus.uptimeSeconds) : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Last checked: {new Date().toLocaleTimeString()}
|
||||
</p>
|
||||
<div className="flex items-center mt-2 space-x-2">
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
Online
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Checked: {new Date().toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Database Status */}
|
||||
<Card>
|
||||
<Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm hover:shadow-md transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Database</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Database Health</CardTitle>
|
||||
<div className="p-2 rounded-lg bg-blue-500/10">
|
||||
<Database className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="default" className="bg-green-500">Connected</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{systemStatus ? `${systemStatus.counts.vendors + systemStatus.counts.orders + systemStatus.counts.products} records` : 'N/A'}
|
||||
<span className="text-2xl font-bold">
|
||||
{systemStatus ? `${(systemStatus.counts.vendors + systemStatus.counts.orders + systemStatus.counts.products).toLocaleString()}` : '0'}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground self-end mb-1">records</span>
|
||||
</div>
|
||||
<div className="flex items-center mt-2 space-x-2">
|
||||
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">
|
||||
Connected
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
4 active collections
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Total collections: 4
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Memory Usage */}
|
||||
<Card>
|
||||
<Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm hover:shadow-md transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Memory</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Memory Usage</CardTitle>
|
||||
<div className="p-2 rounded-lg bg-purple-500/10">
|
||||
<HardDrive className="h-4 w-4 text-purple-500" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant={memoryUsagePercent > 80 ? "destructive" : memoryUsagePercent > 60 ? "secondary" : "outline"}>
|
||||
{memoryUsagePercent}%
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-2xl font-bold">
|
||||
{systemStatus ? formatBytes(systemStatus.memory.heapUsed) : 'N/A'}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground self-end mb-1">used</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
<div className="flex items-center mt-2 space-x-2">
|
||||
<Badge variant="outline" className={`
|
||||
${memoryUsagePercent > 80 ? 'bg-red-500/10 text-red-500 border-red-500/20' :
|
||||
memoryUsagePercent > 60 ? 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20' :
|
||||
'bg-purple-500/10 text-purple-500 border-purple-500/20'}
|
||||
`}>
|
||||
{memoryUsagePercent}% Load
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Total: {systemStatus ? formatBytes(systemStatus.memory.heapTotal) : 'N/A'}
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Platform Stats */}
|
||||
<Card>
|
||||
<Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm hover:shadow-md transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Platform Stats</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Platform Activity</CardTitle>
|
||||
<div className="p-2 rounded-lg bg-orange-500/10">
|
||||
<Activity className="h-4 w-4 text-orange-500" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="default" className="bg-green-500">Active</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{systemStatus ? `${systemStatus.counts.vendors} vendors` : 'N/A'}
|
||||
<span className="text-2xl font-bold">
|
||||
{systemStatus ? systemStatus.counts.vendors : '0'}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground self-end mb-1">Active Vendors</span>
|
||||
</div>
|
||||
<div className="flex items-center mt-2 space-x-2">
|
||||
<Badge variant="outline" className="bg-orange-500/10 text-orange-500 border-orange-500/20">
|
||||
Live
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{systemStatus ? `${systemStatus.counts.orders} orders` : '0 orders'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{systemStatus ? `${systemStatus.counts.orders} orders, ${systemStatus.counts.products} products` : 'N/A'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Node.js Version */}
|
||||
<Card>
|
||||
{/* Runtime Info */}
|
||||
<Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm hover:shadow-md transition-all duration-300 md:col-span-2 lg:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Runtime</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Runtime Environment</CardTitle>
|
||||
<div className="p-2 rounded-lg bg-zinc-500/10">
|
||||
<Cpu className="h-4 w-4 text-zinc-500" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline">
|
||||
{systemStatus ? `Node ${systemStatus.versions.node}` : 'N/A'}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">Runtime</span>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<p className="text-2xl font-bold">Node.js</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Runtime</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-sm h-7">
|
||||
{systemStatus ? `v${systemStatus.versions.node}` : 'N/A'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="h-8 w-px bg-border/50 hidden sm:block" />
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<p className="text-2xl font-bold">V8</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Engine</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-sm h-7">
|
||||
{systemStatus ? systemStatus.versions.v8 : 'N/A'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="h-8 w-px bg-border/50 hidden sm:block" />
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/30 px-3 py-1.5 rounded-md">
|
||||
<Zap className="h-3.5 w-3.5 text-yellow-500" />
|
||||
<span>Performance Optimized</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{systemStatus ? `V8: ${systemStatus.versions.v8}` : 'N/A'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</MotionWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Search, Ban, UserCheck, Package, DollarSign, Loader2, Repeat } from "lucide-react";
|
||||
import { Search, Ban, UserCheck, Package, DollarSign, Loader2, Repeat, Users, ShoppingBag, CreditCard, UserX } from "lucide-react";
|
||||
import { fetchClient } from "@/lib/api-client";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
interface TelegramUser {
|
||||
telegramUserId: string;
|
||||
@@ -49,6 +50,14 @@ export default function AdminUsersPage() {
|
||||
const { toast } = useToast();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [users, setUsers] = useState<TelegramUser[]>([]);
|
||||
// State for browser detection
|
||||
// Browser detection
|
||||
const [isFirefox, setIsFirefox] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
setIsFirefox(ua.includes("firefox") && !ua.includes("chrome"));
|
||||
}, []);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pagination, setPagination] = useState<PaginationResponse['pagination'] | null>(null);
|
||||
@@ -88,123 +97,86 @@ export default function AdminUsersPage() {
|
||||
const totalSpent = users.reduce((sum, u) => sum + u.totalSpent, 0);
|
||||
const totalOrders = users.reduce((sum, u) => sum + u.totalOrders, 0);
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: "Total Users",
|
||||
value: users.length,
|
||||
description: "Registered users",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
title: "Users with Orders",
|
||||
value: usersWithOrders.length,
|
||||
description: `${users.length > 0 ? Math.round((usersWithOrders.length / users.length) * 100) : 0}% conversion rate`,
|
||||
icon: ShoppingBag,
|
||||
},
|
||||
{
|
||||
title: "Total Revenue",
|
||||
value: formatCurrency(totalSpent),
|
||||
description: `${totalOrders} total orders`,
|
||||
icon: DollarSign,
|
||||
},
|
||||
{
|
||||
title: "Returning",
|
||||
value: returningCustomers.length,
|
||||
description: `${usersWithOrders.length > 0 ? Math.round((returningCustomers.length / usersWithOrders.length) * 100) : 0}% of customers`,
|
||||
icon: Repeat,
|
||||
},
|
||||
{
|
||||
title: "Blocked",
|
||||
value: blockedUsers.length,
|
||||
description: `${users.length > 0 ? Math.round((blockedUsers.length / users.length) * 100) : 0}% blocked rate`,
|
||||
icon: UserX,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 animate-in fade-in duration-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Telegram Users</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Telegram Users</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Manage Telegram user accounts and view statistics</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-5">
|
||||
<Card>
|
||||
{stats.map((stat, i) => (
|
||||
<Card key={i} className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
|
||||
<stat.icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<div className="h-12 flex items-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground/50" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{users.length}</div>
|
||||
<p className="text-xs text-muted-foreground">Registered users</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Users with Orders</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{usersWithOrders.length}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{users.length > 0 ? Math.round((usersWithOrders.length / users.length) * 100) : 0}% conversion rate
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{formatCurrency(totalSpent)}</div>
|
||||
<p className="text-xs text-muted-foreground">{totalOrders} total orders</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Returning Customers</CardTitle>
|
||||
<Repeat className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{returningCustomers.length}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{usersWithOrders.length > 0 ? Math.round((returningCustomers.length / usersWithOrders.length) * 100) : 0}% of customers
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Blocked Users</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{blockedUsers.length}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{users.length > 0 ? Math.round((blockedUsers.length / users.length) * 100) : 0}% blocked rate
|
||||
</p>
|
||||
</>
|
||||
<div className="animate-in fade-in slide-in-from-bottom-2 duration-500" style={{ animationDelay: `${i * 100}ms` }}>
|
||||
<div className="text-2xl font-bold">{stat.value}</div>
|
||||
<p className="text-xs text-muted-foreground">{stat.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>User Management</CardTitle>
|
||||
<CardTitle className="text-lg font-medium">User Management</CardTitle>
|
||||
<CardDescription>View and manage all Telegram user accounts</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search users..."
|
||||
className="pl-8 w-64"
|
||||
className="pl-9 w-64 bg-background/50 border-border/50 focus:bg-background transition-colors"
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
@@ -216,19 +188,11 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{searchQuery ? "No users found matching your search" : "No users found"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-border/50 overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User ID</TableHead>
|
||||
<TableHeader className="bg-muted/30">
|
||||
<TableRow className="border-border/50 hover:bg-transparent">
|
||||
<TableHead className="w-[100px]">User ID</TableHead>
|
||||
<TableHead>Username</TableHead>
|
||||
<TableHead>Orders</TableHead>
|
||||
<TableHead>Total Spent</TableHead>
|
||||
@@ -239,88 +203,103 @@ export default function AdminUsersPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.telegramUserId}>
|
||||
<TableCell>
|
||||
<div className="font-mono text-sm">{user.telegramUserId}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium">
|
||||
{user.telegramUsername !== "Unknown" ? `@${user.telegramUsername}` : "Unknown"}
|
||||
<AnimatePresence mode="popLayout">
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="h-24 text-center">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading users...
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : users.length > 0 ? (
|
||||
users.map((user, index) => (
|
||||
<motion.tr
|
||||
key={user.telegramUserId}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.03 }}
|
||||
className={`group border-b border-border/50 transition-colors ${user.isBlocked ? "bg-destructive/5 hover:bg-destructive/10" : "hover:bg-muted/40"}`}
|
||||
>
|
||||
<TableCell className="font-mono text-xs">{user.telegramUserId}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{user.totalOrders}</span>
|
||||
{user.completedOrders > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{user.completedOrders} completed
|
||||
</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">@{user.telegramUsername || "Unknown"}</span>
|
||||
{user.isBlocked && (
|
||||
<Badge variant="destructive" className="h-5 px-1.5 text-[10px]">Blocked</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{user.totalOrders}</TableCell>
|
||||
<TableCell>{formatCurrency(user.totalSpent)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-1">
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{formatCurrency(user.totalSpent)}</span>
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
<span className="text-emerald-500">{user.completedOrders} Completed</span>
|
||||
<span className="text-muted-foreground">{user.paidOrders - user.completedOrders} Pending</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{user.firstOrderDate ? new Date(user.firstOrderDate).toLocaleDateString() : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{user.lastOrderDate ? new Date(user.lastOrderDate).toLocaleDateString() : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
{user.isBlocked ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="destructive">
|
||||
<Ban className="h-3 w-3 mr-1" />
|
||||
Blocked
|
||||
</Badge>
|
||||
<Button size="sm" variant="outline" className="h-8 border-emerald-500/20 text-emerald-500 hover:bg-emerald-500/10 hover:text-emerald-400">
|
||||
<UserCheck className="h-4 w-4 mr-1" />
|
||||
Unblock
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{user.blockedReason && (
|
||||
<TooltipContent>
|
||||
<p className="max-w-xs">{user.blockedReason}</p>
|
||||
<p>Unblock this user</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : user.totalOrders > 0 ? (
|
||||
<Badge variant="default">Active</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">No Orders</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.firstOrderDate
|
||||
? new Date(user.firstOrderDate).toLocaleDateString()
|
||||
: 'N/A'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.lastOrderDate
|
||||
? new Date(user.lastOrderDate).toLocaleDateString()
|
||||
: 'N/A'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
{!user.isBlocked ? (
|
||||
<Button variant="outline" size="sm">
|
||||
<Ban className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm">
|
||||
<UserCheck className="h-4 w-4" />
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="sm" variant="outline" className="h-8 border-destructive/20 text-destructive hover:bg-destructive/10 hover:text-destructive">
|
||||
<Ban className="h-4 w-4 mr-1" />
|
||||
Block
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Block access to the store</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</motion.tr>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="h-32 text-center text-muted-foreground">
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<Users className="h-8 w-8 opacity-20" />
|
||||
<p>No users found</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="mt-4 flex items-center justify-between border-t border-border/50 pt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing page {pagination.page} of {pagination.totalPages} ({pagination.total} total users)
|
||||
Showing page <span className="font-medium text-foreground">{pagination.page}</span> of {pagination.totalPages}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
@@ -328,6 +307,7 @@ export default function AdminUsersPage() {
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={!pagination.hasPrevPage}
|
||||
className="h-8"
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
@@ -336,6 +316,7 @@ export default function AdminUsersPage() {
|
||||
size="sm"
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={!pagination.hasNextPage}
|
||||
className="h-8"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
|
||||
480
app/dashboard/admin/vendors/page.tsx
vendored
480
app/dashboard/admin/vendors/page.tsx
vendored
@@ -6,9 +6,20 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Search, MoreHorizontal, UserCheck, UserX, Mail, Loader2 } from "lucide-react";
|
||||
import { Search, MoreHorizontal, UserCheck, UserX, Mail, Loader2, Store, Shield, ShieldAlert, Clock, Calendar, Pencil, Plus } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { fetchClient } from "@/lib/api-client";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface Vendor {
|
||||
_id: string;
|
||||
@@ -37,9 +48,73 @@ export default function AdminVendorsPage() {
|
||||
const { toast } = useToast();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [vendors, setVendors] = useState<Vendor[]>([]);
|
||||
// State for browser detection
|
||||
const [isFirefox, setIsFirefox] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
setIsFirefox(ua.includes("firefox") && !ua.includes("chrome"));
|
||||
}, []);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pagination, setPagination] = useState<PaginationResponse['pagination'] | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isEditStoreOpen, setIsEditStoreOpen] = useState(false);
|
||||
const [editingVendor, setEditingVendor] = useState<Vendor | null>(null);
|
||||
const [newStoreId, setNewStoreId] = useState("");
|
||||
const [updating, setUpdating] = useState(false);
|
||||
|
||||
const handleToggleStatus = async (vendor: Vendor) => {
|
||||
try {
|
||||
await fetchClient(`/admin/vendors/${vendor._id}/status`, {
|
||||
method: 'PATCH',
|
||||
body: { isActive: !vendor.isActive }
|
||||
});
|
||||
toast({
|
||||
title: "Success",
|
||||
description: `Vendor ${vendor.isActive ? 'suspended' : 'activated'} successfully`,
|
||||
});
|
||||
fetchVendors();
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message || "Failed to update vendor status",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditStore = (vendor: Vendor) => {
|
||||
setEditingVendor(vendor);
|
||||
setNewStoreId(vendor.storeId || "");
|
||||
setIsEditStoreOpen(true);
|
||||
};
|
||||
|
||||
const saveStoreId = async () => {
|
||||
if (!editingVendor) return;
|
||||
|
||||
try {
|
||||
setUpdating(true);
|
||||
await fetchClient(`/admin/vendors/${editingVendor._id}/store-id`, {
|
||||
method: 'PUT',
|
||||
body: { storeId: newStoreId }
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Store ID updated successfully",
|
||||
});
|
||||
setIsEditStoreOpen(false);
|
||||
fetchVendors(); // Refresh list
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message || "Failed to update store ID",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchVendors = useCallback(async () => {
|
||||
try {
|
||||
@@ -79,96 +154,90 @@ export default function AdminVendorsPage() {
|
||||
const adminVendors = vendors.filter(v => v.isAdmin);
|
||||
const totalVendors = pagination?.total || vendors.length;
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: "Total Vendors",
|
||||
value: totalVendors,
|
||||
description: "Registered vendors",
|
||||
icon: Store,
|
||||
},
|
||||
{
|
||||
title: "Active Vendors",
|
||||
value: activeVendors.length,
|
||||
description: `${vendors.length > 0 ? Math.round((activeVendors.length / vendors.length) * 100) : 0}% active rate`,
|
||||
icon: UserCheck,
|
||||
},
|
||||
{
|
||||
title: "Suspended",
|
||||
value: suspendedVendors.length,
|
||||
description: `${vendors.length > 0 ? Math.round((suspendedVendors.length / vendors.length) * 100) : 0}% suspension rate`,
|
||||
icon: UserX,
|
||||
},
|
||||
{
|
||||
title: "Admin Users",
|
||||
value: adminVendors.length,
|
||||
description: "Administrative access",
|
||||
icon: ShieldAlert,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 animate-in fade-in duration-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">All Vendors</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">All Vendors</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Manage vendor accounts and permissions</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
{stats.map((stat, i) => (
|
||||
<Card key={i} className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Vendors</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
|
||||
<stat.icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalVendors}</div>
|
||||
<p className="text-xs text-muted-foreground">Registered vendors</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active Vendors</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{activeVendors.length}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{vendors.length > 0 ? Math.round((activeVendors.length / vendors.length) * 100) : 0}% active rate
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Suspended</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{suspendedVendors.length}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{vendors.length > 0 ? Math.round((suspendedVendors.length / vendors.length) * 100) : 0}% suspension rate
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Admin Users</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{adminVendors.length}</div>
|
||||
<p className="text-xs text-muted-foreground">Administrative access</p>
|
||||
<div className="animate-in fade-in slide-in-from-bottom-2 duration-500" style={{ animationDelay: `${i * 100}ms` }}>
|
||||
<div className="text-2xl font-bold">{stat.value}</div>
|
||||
<p className="text-xs text-muted-foreground">{stat.description}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Vendor Management</CardTitle>
|
||||
<CardTitle className="text-lg font-medium">Vendor Management</CardTitle>
|
||||
<CardDescription>View and manage all vendor accounts</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search vendors..."
|
||||
className="pl-8 w-64"
|
||||
className="pl-9 w-64 bg-background/50 border-border/50 focus:bg-background transition-colors"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
<Button variant="outline" size="sm" className="bg-background/50 border-border/50 hover:bg-background transition-colors">
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
Send Message
|
||||
Message
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredVendors.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{searchQuery.trim() ? "No vendors found matching your search" : "No vendors found"}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-md border border-border/50 overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeader className="bg-muted/30">
|
||||
<TableRow className="border-border/50 hover:bg-transparent">
|
||||
<TableHead>Vendor</TableHead>
|
||||
<TableHead>Store</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
@@ -178,53 +247,269 @@ export default function AdminVendorsPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredVendors.map((vendor) => (
|
||||
<TableRow key={vendor._id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{vendor.username}</div>
|
||||
{isFirefox ? (
|
||||
loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-32 text-center">
|
||||
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin opacity-25" />
|
||||
<p>Loading vendors...</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{vendor.storeId || 'No store'}</TableCell>
|
||||
</TableRow>
|
||||
) : filteredVendors.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
|
||||
{searchQuery.trim() ? "No vendors found matching your search" : "No vendors found"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredVendors.map((vendor, index) => (
|
||||
<motion.tr
|
||||
key={vendor._id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.03 }}
|
||||
className="group hover:bg-muted/40 border-b border-border/50 transition-colors"
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<div className="font-medium flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
|
||||
{vendor.username.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
{vendor.username}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{vendor.storeId ? (
|
||||
<div className="flex items-center gap-2 group/store">
|
||||
<span className="font-mono text-xs">{vendor.storeId}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-0 group-hover/store:opacity-100 transition-opacity"
|
||||
onClick={() => handleEditStore(vendor)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground italic text-xs">No store</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-primary"
|
||||
onClick={() => handleEditStore(vendor)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={vendor.isActive ? "default" : "destructive"}
|
||||
className={vendor.isActive ? "bg-green-600 hover:bg-green-700" : ""}
|
||||
>
|
||||
{vendor.isActive ? "active" : "suspended"}
|
||||
{vendor.isActive ? "Active" : "Suspended"}
|
||||
</Badge>
|
||||
{vendor.isAdmin && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Badge variant="outline" className="border-blue-200 text-blue-700 bg-blue-50 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-900">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Admin
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5 opacity-70" />
|
||||
{vendor.createdAt ? new Date(vendor.createdAt).toLocaleDateString() : 'N/A'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5 opacity-70" />
|
||||
{vendor.lastLogin ? new Date(vendor.lastLogin).toLocaleDateString() : 'Never'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<UserCheck className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<UserX className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(vendor._id)}
|
||||
>
|
||||
Copy Vendor ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className={vendor.isActive ? "text-red-600" : "text-green-600"}
|
||||
onClick={() => handleToggleStatus(vendor)}
|
||||
>
|
||||
{vendor.isActive ? (
|
||||
<>
|
||||
<UserX className="mr-2 h-4 w-4" />
|
||||
Suspend Vendor
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserCheck className="mr-2 h-4 w-4" />
|
||||
Activate Vendor
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</motion.tr>
|
||||
))
|
||||
)
|
||||
) : (
|
||||
<AnimatePresence mode="popLayout">
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-32 text-center">
|
||||
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin opacity-25" />
|
||||
<p>Loading vendors...</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
) : filteredVendors.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
|
||||
{searchQuery.trim() ? "No vendors found matching your search" : "No vendors found"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredVendors.map((vendor, index) => (
|
||||
<motion.tr
|
||||
key={vendor._id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.03 }}
|
||||
className="group hover:bg-muted/40 border-b border-border/50 transition-colors"
|
||||
>
|
||||
<TableCell>
|
||||
<div className="font-medium flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
|
||||
{vendor.username.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
{vendor.username}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{vendor.storeId ? (
|
||||
<div className="flex items-center gap-2 group/store">
|
||||
<span className="font-mono text-xs">{vendor.storeId}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-0 group-hover/store:opacity-100 transition-opacity"
|
||||
onClick={() => handleEditStore(vendor)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground italic text-xs">No store</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-primary"
|
||||
onClick={() => handleEditStore(vendor)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={vendor.isActive ? "default" : "destructive"}
|
||||
className={vendor.isActive ? "bg-green-600 hover:bg-green-700" : ""}
|
||||
>
|
||||
{vendor.isActive ? "Active" : "Suspended"}
|
||||
</Badge>
|
||||
{vendor.isAdmin && (
|
||||
<Badge variant="outline" className="border-blue-200 text-blue-700 bg-blue-50 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-900">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Admin
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5 opacity-70" />
|
||||
{vendor.createdAt ? new Date(vendor.createdAt).toLocaleDateString() : 'N/A'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5 opacity-70" />
|
||||
{vendor.lastLogin ? new Date(vendor.lastLogin).toLocaleDateString() : 'Never'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(vendor._id)}
|
||||
>
|
||||
Copy Vendor ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className={vendor.isActive ? "text-red-600" : "text-green-600"}
|
||||
onClick={() => handleToggleStatus(vendor)}
|
||||
>
|
||||
{vendor.isActive ? (
|
||||
<>
|
||||
<UserX className="mr-2 h-4 w-4" />
|
||||
Suspend Vendor
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserCheck className="mr-2 h-4 w-4" />
|
||||
Activate Vendor
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</motion.tr>
|
||||
))
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div className="mt-4 flex items-center justify-between border-t border-border/50 pt-4 text-sm text-muted-foreground">
|
||||
<span>
|
||||
Page {pagination.page} of {pagination.totalPages} ({pagination.total} total)
|
||||
Page <span className="font-medium text-foreground">{pagination.page}</span> of {pagination.totalPages}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
@@ -232,6 +517,7 @@ export default function AdminVendorsPage() {
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={!pagination.hasPrevPage || loading}
|
||||
className="h-8"
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
@@ -240,16 +526,48 @@ export default function AdminVendorsPage() {
|
||||
size="sm"
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={!pagination.hasNextPage || loading}
|
||||
className="h-8"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={isEditStoreOpen} onOpenChange={setIsEditStoreOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update Vendor Store</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter the Store ID to assign to vendor <span className="font-semibold text-foreground">{editingVendor?.username}</span>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="storeId">Store ID</Label>
|
||||
<Input
|
||||
id="storeId"
|
||||
value={newStoreId}
|
||||
onChange={(e) => setNewStoreId(e.target.value)}
|
||||
placeholder="Enter 24-character Store ID"
|
||||
className="col-span-3 font-mono"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ensure the Store ID corresponds to an existing store in the system.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsEditStoreOpen(false)} disabled={updating}>Cancel</Button>
|
||||
<Button onClick={saveStoreId} disabled={updating || !newStoreId || newStoreId.length < 24}>
|
||||
{updating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { apiRequest } from "@/lib/api";
|
||||
import type { Category } from "@/models/categories";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
// Drag and Drop imports
|
||||
import { DndProvider, useDrag, useDrop, DropTargetMonitor } from 'react-dnd';
|
||||
@@ -49,6 +50,7 @@ export default function CategoriesPage() {
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
||||
const [categoryToDelete, setCategoryToDelete] = useState<Category | null>(null);
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Get root categories sorted by order
|
||||
const rootCategories = categories
|
||||
@@ -67,10 +69,13 @@ export default function CategoriesPage() {
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const fetchedCategories = await apiRequest("/categories", "GET");
|
||||
setCategories(fetchedCategories);
|
||||
} catch (error) {
|
||||
toast.error("Failed to fetch categories");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -249,30 +254,38 @@ export default function CategoriesPage() {
|
||||
drag(drop(ref));
|
||||
|
||||
return (
|
||||
<div key={category._id} className="space-y-1">
|
||||
<motion.div
|
||||
key={category._id}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="space-y-1"
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
className={`group flex items-center p-2 rounded-md transition-colors
|
||||
${isEditing ? 'bg-gray-100 dark:bg-gray-800' : ''}
|
||||
${isOver ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-100 dark:hover:bg-gray-800'}
|
||||
${isDragging ? 'opacity-50' : 'opacity-100'}`}
|
||||
className={`group flex items-center p-3 rounded-xl transition-all duration-200 border mb-2
|
||||
${isEditing ? 'bg-indigo-500/10 border-indigo-500/30' : ''}
|
||||
${isOver ? 'bg-indigo-500/20 border-indigo-500/50 scale-[1.02]' : 'bg-black/40 border-white/5 hover:bg-black/60 hover:border-white/10 hover:shadow-lg'}
|
||||
${isDragging ? 'opacity-30' : 'opacity-100'} backdrop-blur-sm`}
|
||||
style={{ marginLeft: `${level * 24}px` }}
|
||||
data-handler-id={handlerId}
|
||||
>
|
||||
<div className="cursor-grab mr-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<div className="cursor-grab mr-2 text-muted-foreground/40 hover:text-muted-foreground transition-colors">
|
||||
<MoveVertical className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
{hasSubcategories && (
|
||||
{hasSubcategories ? (
|
||||
<button
|
||||
onClick={() => toggleExpand(category._id)}
|
||||
className="mr-1 focus:outline-none"
|
||||
className="mr-1 focus:outline-none p-0.5 rounded-sm hover:bg-muted text-muted-foreground transition-colors"
|
||||
>
|
||||
{isExpanded ?
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" /> :
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
<ChevronDown className="h-4 w-4" /> :
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-6 h-5" /> // Spacer
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex items-center space-x-2">
|
||||
@@ -280,7 +293,7 @@ export default function CategoriesPage() {
|
||||
<Input
|
||||
value={editingCategory?.name || ""}
|
||||
onChange={(e) => setEditingCategory(prev => prev ? { ...prev, name: e.target.value } : prev)}
|
||||
className="h-8 max-w-[200px]"
|
||||
className="h-8 max-w-[200px] border-primary/30 focus-visible:ring-primary/20"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && editingCategory) {
|
||||
@@ -300,7 +313,7 @@ export default function CategoriesPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 px-2 text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||
className="h-8 px-2 text-green-600 hover:text-green-700 hover:bg-green-500/10"
|
||||
onClick={() => editingCategory && handleUpdateCategory(category._id, editingCategory.name)}
|
||||
>
|
||||
Save
|
||||
@@ -317,83 +330,107 @@ export default function CategoriesPage() {
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 text-blue-500 hover:text-blue-600 hover:bg-blue-50"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
onClick={() => setEditingCategory(category)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
onClick={() => setCategoryToDelete(category)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && subcategories.map(subcat =>
|
||||
<AnimatePresence>
|
||||
{isExpanded && hasSubcategories && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{subcategories.map(subcat =>
|
||||
<CategoryItem key={subcat._id} category={subcat} level={level + 1} />
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8 animate-in fade-in duration-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<FolderTree className="mr-2 h-6 w-6" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground flex items-center">
|
||||
<FolderTree className="mr-3 h-6 w-6 text-primary" />
|
||||
Categories
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
Manage your product categories and hierarchy
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 xl:grid-cols-5 gap-4 lg:gap-6">
|
||||
{/* Add Category Card - Takes up 2 columns */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-medium">Add New Category</CardTitle>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 xl:grid-cols-5 gap-6 lg:gap-8">
|
||||
{/* Add Category Card */}
|
||||
<Card className="lg:col-span-2 border-white/10 bg-black/40 backdrop-blur-xl shadow-xl h-fit sticky top-6 rounded-xl overflow-hidden">
|
||||
<CardHeader className="bg-white/[0.02] border-b border-white/5 pb-4">
|
||||
<CardTitle className="text-lg font-bold flex items-center text-white">
|
||||
<div className="p-2 mr-3 rounded-lg bg-indigo-500/10 border border-indigo-500/20">
|
||||
<Plus className="h-4 w-4 text-indigo-400" />
|
||||
</div>
|
||||
Add New Category
|
||||
</CardTitle>
|
||||
<CardDescription className="text-zinc-400">
|
||||
Create a new category or subcategory
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
<label className="text-sm font-medium leading-none text-zinc-300">
|
||||
Category Name
|
||||
</label>
|
||||
<Input
|
||||
value={newCategoryName}
|
||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||
placeholder="Enter category name"
|
||||
className="h-9"
|
||||
placeholder="e.g. Electronics, Clothing..."
|
||||
className="h-10 border-white/10 bg-black/20 focus:bg-black/40 transition-colors text-white placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
<label className="text-sm font-medium leading-none text-zinc-300">
|
||||
Parent Category
|
||||
</label>
|
||||
<Select
|
||||
value={selectedParentId || "none"}
|
||||
onValueChange={setSelectedParentId}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectTrigger className="h-10 border-white/10 bg-black/20 focus:bg-black/40 transition-colors text-white">
|
||||
<SelectValue placeholder="Select parent category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No parent (root category)</SelectItem>
|
||||
<SelectContent className="bg-zinc-900 border-white/10 text-white">
|
||||
<SelectItem value="none" className="focus:bg-zinc-800">No parent (root category)</SelectItem>
|
||||
{categories.map((cat) => (
|
||||
<SelectItem key={cat._id} value={cat._id}>
|
||||
<SelectItem key={cat._id} value={cat._id} className="focus:bg-zinc-800">
|
||||
{cat.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={handleAddCategory} className="w-full">
|
||||
<Button onClick={handleAddCategory} className="w-full mt-2 bg-indigo-600 hover:bg-indigo-700 text-white border-0" size="lg">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Category
|
||||
</Button>
|
||||
@@ -401,22 +438,34 @@ export default function CategoriesPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Category List Card - Takes up 3 columns */}
|
||||
<Card className="lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-medium">Category List</CardTitle>
|
||||
{/* Category List Card */}
|
||||
<Card className="lg:col-span-3 border-none bg-transparent shadow-none">
|
||||
<CardHeader className="pl-0 pt-0 pb-4">
|
||||
<CardTitle className="text-lg font-bold text-white">Structure</CardTitle>
|
||||
<CardDescription className="text-zinc-400">
|
||||
Drag and drop to reorder categories
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-0">
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<div className="space-y-1">
|
||||
{rootCategories.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No categories yet. Add your first category above.
|
||||
</p>
|
||||
<div className="space-y-2 min-h-[300px]">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground animate-pulse">
|
||||
<FolderTree className="h-10 w-10 mb-3 opacity-20" />
|
||||
<p>Loading categories...</p>
|
||||
</div>
|
||||
) : rootCategories.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground border-2 border-dashed border-border/40 rounded-xl bg-muted/20">
|
||||
<FolderTree className="h-10 w-10 mb-3 opacity-20" />
|
||||
<p>No categories yet</p>
|
||||
<p className="text-xs opacity-60 mt-1">Add your first category to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
rootCategories.map(category => (
|
||||
<div className="space-y-1">
|
||||
{rootCategories.map(category => (
|
||||
<CategoryItem key={category._id} category={category} />
|
||||
))
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DndProvider>
|
||||
@@ -425,23 +474,23 @@ export default function CategoriesPage() {
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
{categoryToDelete && (
|
||||
<AlertDialog open={!!categoryToDelete} onOpenChange={() => setCategoryToDelete(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete the category "{categoryToDelete.name}".
|
||||
This will permanently delete the category <span className="font-medium text-foreground">"{categoryToDelete?.name}"</span>.
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm}>Delete</AlertDialogAction>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Delete Category
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import Layout from "@/components/layout/layout";
|
||||
import { cacheUtils } from '@/lib/api-client';
|
||||
import OrderTimeline from "@/components/orders/order-timeline";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
interface Order {
|
||||
orderId: string;
|
||||
@@ -432,7 +434,7 @@ export default function OrderDetailsPage() {
|
||||
|
||||
setTimeout(() => {
|
||||
setProductNames(prev => {
|
||||
const newMap = {...prev};
|
||||
const newMap = { ...prev };
|
||||
productIds.forEach(id => {
|
||||
if (!newMap[id] || newMap[id] === "Loading...") {
|
||||
newMap[id] = "Unknown Product (Deleted)";
|
||||
@@ -791,10 +793,26 @@ export default function OrderDetailsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{/* Order Timeline */}
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Order Lifecycle</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<OrderTimeline
|
||||
status={order?.status || ''}
|
||||
orderDate={order?.orderDate || ''}
|
||||
paidAt={order?.paidAt}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="grid grid-cols-3 gap-6"
|
||||
>
|
||||
{/* Left Column - Order Details */}
|
||||
<div className="col-span-2 space-y-6">
|
||||
{/* Products Card */}
|
||||
@@ -1168,8 +1186,7 @@ export default function OrderDetailsPage() {
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<svg
|
||||
key={i}
|
||||
className={`w-4 h-4 ${
|
||||
i < (order?.review?.stars || 0)
|
||||
className={`w-4 h-4 ${i < (order?.review?.stars || 0)
|
||||
? "text-yellow-400"
|
||||
: "text-zinc-600"
|
||||
}`}
|
||||
@@ -1198,10 +1215,10 @@ export default function OrderDetailsPage() {
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Shipping Dialog removed; use inline tracking input above */}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -45,7 +45,7 @@ const ProfitAnalysisModal = dynamic(() => import("@/components/modals/profit-ana
|
||||
|
||||
function ProductTableSkeleton() {
|
||||
return (
|
||||
<Card className="animate-in fade-in duration-500">
|
||||
<Card className="animate-in fade-in duration-500 border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
@@ -152,7 +152,7 @@ export default function ProductsPage() {
|
||||
const [importModalOpen, setImportModalOpen] = useState(false);
|
||||
const [addProductOpen, setAddProductOpen] = useState(false);
|
||||
const [profitAnalysisOpen, setProfitAnalysisOpen] = useState(false);
|
||||
const [selectedProductForAnalysis, setSelectedProductForAnalysis] = useState<{id: string, name: string} | null>(null);
|
||||
const [selectedProductForAnalysis, setSelectedProductForAnalysis] = useState<{ id: string, name: string } | null>(null);
|
||||
|
||||
// Fetch products and categories
|
||||
useEffect(() => {
|
||||
|
||||
@@ -54,7 +54,7 @@ const ShippingTable = dynamic(() => import("@/components/tables/shipping-table")
|
||||
// Loading skeleton for shipping table
|
||||
function ShippingTableSkeleton() {
|
||||
return (
|
||||
<Card className="animate-in fade-in duration-500 relative">
|
||||
<Card className="animate-in fade-in duration-500 relative border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||
{/* Subtle loading indicator */}
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-muted overflow-hidden rounded-t-lg">
|
||||
<div className="h-full bg-primary w-1/3"
|
||||
|
||||
@@ -7,11 +7,15 @@ import { Button } from "@/components/ui/button";
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Popover,
|
||||
@@ -30,12 +34,13 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Product } from "@/models/products";
|
||||
import { Package, RefreshCw, ChevronDown, CheckSquare, XSquare, Boxes, Download, Calendar } from "lucide-react";
|
||||
import { Package, RefreshCw, ChevronDown, CheckSquare, XSquare, Boxes, Download, Calendar, Search, Filter, Save, X, Edit2 } from "lucide-react";
|
||||
import { clientFetch } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { DatePicker, DateRangePicker, DateRangeDisplay, MonthPicker } from "@/components/ui/date-picker";
|
||||
import { DateRange } from "react-day-picker";
|
||||
import { addDays, startOfDay, endOfDay, format, isSameDay } from "date-fns";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
interface StockData {
|
||||
currentStock: number;
|
||||
@@ -379,6 +384,26 @@ export default function StockManagementPage() {
|
||||
return 'In stock';
|
||||
};
|
||||
|
||||
const getStatusBadgeVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Out of stock': return 'destructive';
|
||||
case 'Low stock': return 'warning'; // Custom variant or use secondary/outline
|
||||
case 'In stock': return 'default'; // often maps to primary which might be blue/black
|
||||
default: return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
// Helper for badging - if your Badge component doesn't support 'warning' directly, use className overrides
|
||||
const StatusBadge = ({ status }: { status: string }) => {
|
||||
let styles = "font-medium border-transparent shadow-none";
|
||||
if (status === 'Out of stock') styles += " bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-400";
|
||||
else if (status === 'Low stock') styles += " bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400";
|
||||
else if (status === 'In stock') styles += " bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400";
|
||||
else styles += " bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400";
|
||||
|
||||
return <Badge className={styles} variant="outline">{status}</Badge>;
|
||||
};
|
||||
|
||||
const filteredProducts = products.filter(product => {
|
||||
if (!searchTerm) return true;
|
||||
|
||||
@@ -392,31 +417,39 @@ export default function StockManagementPage() {
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<Boxes className="mr-2 h-6 w-6" />
|
||||
<div className="space-y-6 animate-in fade-in duration-500">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||
<Boxes className="h-6 w-6 text-primary" />
|
||||
Stock Management
|
||||
</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
Track inventory levels and manage stock status
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search products..."
|
||||
className="w-64"
|
||||
className="pl-9 w-full sm:w-64 bg-background/50 border-border/50 focus:bg-background transition-colors"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Report Type Selector */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{reportType.charAt(0).toUpperCase() + reportType.slice(1)} Report
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
<Button variant="outline" size="icon" className="h-10 w-10 border-border/50 bg-background/50">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Filter Reports</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setReportType('daily')}>
|
||||
Daily Report
|
||||
</DropdownMenuItem>
|
||||
@@ -433,12 +466,13 @@ export default function StockManagementPage() {
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Date Selection based on report type */}
|
||||
<div className="hidden sm:block">
|
||||
{reportType === 'daily' && (
|
||||
<DatePicker
|
||||
date={exportDate ? new Date(exportDate) : undefined}
|
||||
onDateChange={(date) => setExportDate(date ? date.toISOString().split('T')[0] : '')}
|
||||
placeholder="Select export date"
|
||||
className="w-auto"
|
||||
className="w-auto border-border/50 bg-background/50"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -447,7 +481,7 @@ export default function StockManagementPage() {
|
||||
dateRange={exportDateRange}
|
||||
onDateRangeChange={setExportDateRange}
|
||||
placeholder="Select date range"
|
||||
className="w-auto"
|
||||
className="w-auto border-border/50 bg-background/50"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -456,28 +490,29 @@ export default function StockManagementPage() {
|
||||
selectedMonth={selectedMonth}
|
||||
onMonthChange={(date) => setSelectedMonth(date || new Date())}
|
||||
placeholder="Select month"
|
||||
className="w-auto"
|
||||
className="w-auto border-border/50 bg-background/50"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleExportStock}
|
||||
disabled={isExporting}
|
||||
className="gap-2"
|
||||
className="gap-2 border-border/50 bg-background/50 hover:bg-background transition-colors"
|
||||
>
|
||||
{isExporting ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
{isExporting ? 'Exporting...' : 'Export CSV'}
|
||||
Export
|
||||
</Button>
|
||||
|
||||
{selectedProducts.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Button variant="default" className="gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
Bulk Actions
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
@@ -486,11 +521,11 @@ export default function StockManagementPage() {
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleBulkAction('enable')}>
|
||||
<CheckSquare className="h-4 w-4 mr-2" />
|
||||
Enable Stock Tracking
|
||||
Enable Tracking
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleBulkAction('disable')}>
|
||||
<XSquare className="h-4 w-4 mr-2" />
|
||||
Disable Stock Tracking
|
||||
Disable Tracking
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -498,52 +533,77 @@ export default function StockManagementPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
||||
<CardHeader className="py-4 px-6 border-b border-border/50 bg-muted/30 flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg font-medium">Inventory Data</CardTitle>
|
||||
<CardDescription>Manage stock levels and tracking for {products.length} products</CardDescription>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground bg-background/50 px-3 py-1 rounded-full border border-border/50">
|
||||
{filteredProducts.length} items
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow className="border-border/50 hover:bg-transparent">
|
||||
<TableHead className="w-12 pl-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedProducts.length === products.length}
|
||||
checked={selectedProducts.length === products.length && products.length > 0}
|
||||
onChange={toggleSelectAll}
|
||||
className="rounded border-gray-300"
|
||||
className="rounded border-gray-300 dark:border-zinc-700 bg-background focus:ring-primary/20"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead>Stock Status</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Current Stock</TableHead>
|
||||
<TableHead>Track Stock</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
<TableHead>Tracking</TableHead>
|
||||
<TableHead className="text-right pr-6">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8">
|
||||
<RefreshCw className="h-6 w-6 animate-spin inline-block" />
|
||||
<span className="ml-2">Loading products...</span>
|
||||
<TableCell colSpan={6} className="text-center py-12">
|
||||
<div className="flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<RefreshCw className="h-8 w-8 animate-spin opacity-20" />
|
||||
<p>Loading products...</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredProducts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8">
|
||||
No products found
|
||||
<TableCell colSpan={6} className="text-center py-12">
|
||||
<div className="flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<Boxes className="h-10 w-10 opacity-20" />
|
||||
<p>No products found matching your search</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredProducts.map((product) => (
|
||||
<TableRow key={product._id}>
|
||||
<TableCell>
|
||||
filteredProducts.map((product, index) => (
|
||||
<motion.tr
|
||||
key={product._id}
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.03 }}
|
||||
className="group hover:bg-muted/40 border-b border-border/50 transition-colors"
|
||||
>
|
||||
<TableCell className="pl-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedProducts.includes(product._id || '')}
|
||||
onChange={() => toggleSelectProduct(product._id || '')}
|
||||
className="rounded border-gray-300"
|
||||
className="rounded border-gray-300 dark:border-zinc-700 bg-background focus:ring-primary/20"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{product.name}</TableCell>
|
||||
<TableCell>{getStockStatus(product)}</TableCell>
|
||||
<TableCell className="font-medium">{product.name}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={getStockStatus(product)} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{editingStock[product._id || ''] ? (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -551,37 +611,62 @@ export default function StockManagementPage() {
|
||||
type="number"
|
||||
value={stockValues[product._id || ''] || 0}
|
||||
onChange={(e) => handleStockChange(product._id || '', parseInt(e.target.value) || 0)}
|
||||
className="w-24"
|
||||
className="w-20 h-8 font-mono bg-background"
|
||||
/>
|
||||
<Button size="sm" onClick={() => handleSaveStock(product)}>Save</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span>{product.currentStock || 0}</span>
|
||||
<span className="font-mono text-sm">{product.currentStock || 0}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={product.stockTracking || false}
|
||||
onCheckedChange={() => handleToggleStockTracking(product)}
|
||||
className="data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{!editingStock[product._id || ''] && (
|
||||
<TableCell className="text-right pr-6">
|
||||
<div className="flex justify-end gap-1">
|
||||
{editingStock[product._id || ''] ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-100 dark:hover:bg-green-900/20"
|
||||
onClick={() => handleSaveStock(product)}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => setEditingStock({ ...editingStock, [product._id || '']: false })}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-primary hover:bg-primary/10"
|
||||
onClick={() => handleEditStock(product._id || '')}
|
||||
>
|
||||
Edit Stock
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</motion.tr>
|
||||
))
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={isConfirmDialogOpen} onOpenChange={setIsConfirmDialogOpen}>
|
||||
@@ -589,12 +674,14 @@ export default function StockManagementPage() {
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirm Bulk Action</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to {bulkAction} stock tracking for {selectedProducts.length} selected products?
|
||||
Are you sure you want to {bulkAction} stock tracking for <span className="font-medium text-foreground">{selectedProducts.length}</span> selected products?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setBulkAction(null)}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={executeBulkAction}>Continue</AlertDialogAction>
|
||||
<AlertDialogAction onClick={executeBulkAction} className="bg-primary text-primary-foreground">
|
||||
Continue
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
@@ -40,11 +40,18 @@ import {
|
||||
UserPlus,
|
||||
MoreHorizontal,
|
||||
Search,
|
||||
X
|
||||
X,
|
||||
CreditCard,
|
||||
Calendar,
|
||||
ShoppingBag,
|
||||
Truck,
|
||||
CheckCircle,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
@@ -55,6 +62,13 @@ import {
|
||||
export default function CustomerManagementPage() {
|
||||
const router = useRouter();
|
||||
const [customers, setCustomers] = useState<CustomerStats[]>([]);
|
||||
// State for browser detection
|
||||
const [isFirefox, setIsFirefox] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
setIsFirefox(ua.includes("firefox") && !ua.includes("chrome"));
|
||||
}, []);
|
||||
const [filteredCustomers, setFilteredCustomers] = useState<CustomerStats[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -184,13 +198,13 @@ export default function CustomerManagementPage() {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-black/40 border border-zinc-800 rounded-md overflow-hidden">
|
||||
<div className="p-4 border-b border-zinc-800 bg-black/60 flex flex-col md:flex-row md:justify-between md:items-center gap-4">
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
||||
<div className="p-4 border-b border-border/50 bg-muted/30 flex flex-col md:flex-row md:justify-between md:items-center gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm font-medium text-gray-400">Show:</div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Show:</div>
|
||||
<Select value={itemsPerPage.toString()} onValueChange={handleItemsPerPageChange}>
|
||||
<SelectTrigger className="w-[70px]">
|
||||
<SelectTrigger className="w-[70px] bg-background/50 border-border/50">
|
||||
<SelectValue placeholder="25" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -204,26 +218,26 @@ export default function CustomerManagementPage() {
|
||||
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<Search className="h-4 w-4 text-gray-400" />
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by username or Telegram ID..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 pr-10 py-2 w-full bg-black/40 border-zinc-700 text-white"
|
||||
className="pl-10 pr-10 py-2 w-full bg-background/50 border-border/50 focus:ring-primary/20 transition-all duration-300"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3"
|
||||
onClick={clearSearch}
|
||||
>
|
||||
<X className="h-4 w-4 text-gray-400 hover:text-gray-200" />
|
||||
<X className="h-4 w-4 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-400 whitespace-nowrap">
|
||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{loading
|
||||
? "Loading..."
|
||||
: searchQuery
|
||||
@@ -232,31 +246,25 @@ export default function CustomerManagementPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-8 bg-black/60">
|
||||
<div className="p-8">
|
||||
{/* Loading indicator */}
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-muted overflow-hidden">
|
||||
<div className="h-full bg-primary w-1/3"
|
||||
<div className="absolute top-[69px] left-0 right-0 h-0.5 bg-muted overflow-hidden">
|
||||
<div className="h-full bg-primary w-1/3 animate-shimmer"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, transparent, hsl(var(--primary)), transparent)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'shimmer 2s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table skeleton */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4 pb-4 border-b border-zinc-800">
|
||||
<div className="flex items-center gap-4 pb-4 border-b border-border/50">
|
||||
{['Customer', 'Orders', 'Total Spent', 'Last Order', 'Status'].map((header, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className="h-4 w-20 flex-1 animate-in fade-in"
|
||||
style={{
|
||||
animationDelay: `${i * 50}ms`,
|
||||
animationDuration: '300ms',
|
||||
animationFillMode: 'both',
|
||||
}}
|
||||
className="h-4 w-20 flex-1"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -264,12 +272,7 @@ export default function CustomerManagementPage() {
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-4 pb-4 border-b border-zinc-800 last:border-b-0 animate-in fade-in"
|
||||
style={{
|
||||
animationDelay: `${250 + i * 50}ms`,
|
||||
animationDuration: '300ms',
|
||||
animationFillMode: 'both',
|
||||
}}
|
||||
className="flex items-center gap-4 pb-4 border-b border-border/50 last:border-b-0"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
@@ -287,114 +290,219 @@ export default function CustomerManagementPage() {
|
||||
</div>
|
||||
</div>
|
||||
) : filteredCustomers.length === 0 ? (
|
||||
<div className="p-8 text-center bg-black/60">
|
||||
<Users className="h-12 w-12 mx-auto text-gray-500 mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2 text-white">
|
||||
{searchQuery ? "No customers matching your search" : "No customers found"}
|
||||
<div className="p-12 text-center">
|
||||
<div className="w-16 h-16 bg-muted/50 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Users className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-2 text-foreground">
|
||||
{searchQuery ? "No matching customers" : "No customers yet"}
|
||||
</h3>
|
||||
<p className="text-gray-500">
|
||||
<p className="text-muted-foreground max-w-sm mx-auto mb-6">
|
||||
{searchQuery
|
||||
? "Try a different search term or clear the search"
|
||||
? "We couldn't find any customers matching your search criteria."
|
||||
: "Once you have customers placing orders, they will appear here."}
|
||||
</p>
|
||||
{searchQuery && (
|
||||
<Button variant="outline" size="sm" onClick={clearSearch} className="mt-4">
|
||||
Clear search
|
||||
<Button variant="outline" size="sm" onClick={clearSearch}>
|
||||
Clear Search
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="[&_tr]:border-b [&_tr]:border-zinc-800 [&_tr:last-child]:border-b-0 [&_td]:border-r [&_td]:border-zinc-800 [&_td:last-child]:border-r-0 [&_th]:border-r [&_th]:border-zinc-800 [&_th:last-child]:border-r-0 [&_tr:hover]:bg-zinc-900/70">
|
||||
<TableHeader className="bg-black/60 sticky top-0 z-10">
|
||||
<TableRow>
|
||||
<TableHead className="w-[180px] text-gray-300">Customer</TableHead>
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow className="hover:bg-transparent border-border/50">
|
||||
<TableHead className="w-[200px]">Customer</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer w-[100px] text-gray-300 text-center"
|
||||
className="cursor-pointer w-[100px] text-center hover:text-primary transition-colors"
|
||||
onClick={() => handleSort("totalOrders")}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
Orders
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
<ArrowUpDown className="h-3 w-3" />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer w-[150px] text-gray-300 text-center"
|
||||
className="cursor-pointer w-[150px] text-center hover:text-primary transition-colors"
|
||||
onClick={() => handleSort("totalSpent")}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
Total Spent
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
<ArrowUpDown className="h-3 w-3" />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer w-[180px] text-gray-300 text-center"
|
||||
className="cursor-pointer w-[180px] text-center hover:text-primary transition-colors"
|
||||
onClick={() => handleSort("lastOrderDate")}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
Last Order
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
<ArrowUpDown className="h-3 w-3" />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="w-[250px] text-gray-300 text-center">Status</TableHead>
|
||||
<TableHead className="w-[250px] text-center">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredCustomers.map((customer) => (
|
||||
<TableRow
|
||||
{isFirefox ? (
|
||||
filteredCustomers.map((customer, index) => (
|
||||
<motion.tr
|
||||
key={customer.userId}
|
||||
className={`cursor-pointer ${!customer.hasOrders ? "bg-black/30" : ""}`}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.03 }}
|
||||
className={`cursor-pointer group hover:bg-muted/50 border-b border-border/50 transition-colors ${!customer.hasOrders ? "bg-primary/5 hover:bg-primary/10" : ""}`}
|
||||
onClick={() => setSelectedCustomer(customer)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="font-medium text-gray-100">
|
||||
<TableCell className="py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`h-9 w-9 rounded-full flex items-center justify-center text-xs font-medium ${!customer.hasOrders ? 'bg-primary/20 text-primary' : 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
{customer.telegramUsername ? customer.telegramUsername.substring(0, 2).toUpperCase() : 'ID'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium flex items-center gap-2">
|
||||
@{customer.telegramUsername || "Unknown"}
|
||||
{!customer.hasOrders && (
|
||||
<Badge variant="outline" className="ml-2 bg-purple-900/30 text-purple-300 border-purple-700">
|
||||
<UserPlus className="h-3 w-3 mr-1" />
|
||||
<Badge variant="outline" className="h-5 px-1.5 text-[10px] bg-primary/10 text-primary border-primary/20">
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">ID: {customer.telegramUserId}</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center font-mono mt-0.5">
|
||||
<span className="opacity-50 select-none">ID:</span>
|
||||
<span className="ml-1">{customer.telegramUserId}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className="bg-gray-700 text-white hover:bg-gray-600">{customer.totalOrders}</Badge>
|
||||
<Badge variant="secondary" className="font-mono font-normal">
|
||||
{customer.totalOrders}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium text-gray-100 text-center">
|
||||
<TableCell className="text-center font-mono text-sm">
|
||||
{formatCurrency(customer.totalSpent)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-100 text-center">
|
||||
{customer.lastOrderDate ? formatDate(customer.lastOrderDate) : "N/A"}
|
||||
<TableCell className="text-center text-sm text-muted-foreground">
|
||||
{customer.lastOrderDate ? (
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<Calendar className="h-3 w-3 opacity-70" />
|
||||
{formatDate(customer.lastOrderDate).split(",")[0]}
|
||||
</div>
|
||||
) : "Never"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{customer.hasOrders ? (
|
||||
<div className="flex justify-center space-x-1">
|
||||
<Badge className="bg-blue-500 text-white hover:bg-blue-600">
|
||||
<div className="flex justify-center flex-wrap gap-1">
|
||||
{customer.ordersByStatus.paid > 0 && (
|
||||
<Badge className="bg-blue-500/15 text-blue-500 hover:bg-blue-500/25 border-blue-500/20 h-5 px-1.5 text-[10px]">
|
||||
{customer.ordersByStatus.paid} Paid
|
||||
</Badge>
|
||||
<Badge className="bg-green-500 text-white hover:bg-green-600">
|
||||
{customer.ordersByStatus.completed} Completed
|
||||
</Badge>
|
||||
<Badge className="bg-amber-500 text-white hover:bg-amber-600">
|
||||
{customer.ordersByStatus.shipped} Shipped
|
||||
</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<Badge variant="outline" className="bg-gray-800 text-gray-300 border-gray-700">
|
||||
No orders yet
|
||||
)}
|
||||
{customer.ordersByStatus.completed > 0 && (
|
||||
<Badge className="bg-emerald-500/15 text-emerald-500 hover:bg-emerald-500/25 border-emerald-500/20 h-5 px-1.5 text-[10px]">
|
||||
{customer.ordersByStatus.completed} Done
|
||||
</Badge>
|
||||
)}
|
||||
{customer.ordersByStatus.shipped > 0 && (
|
||||
<Badge className="bg-amber-500/15 text-amber-500 hover:bg-amber-500/25 border-amber-500/20 h-5 px-1.5 text-[10px]">
|
||||
{customer.ordersByStatus.shipped} Ship
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground italic">No activity</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</motion.tr>
|
||||
))
|
||||
) : (
|
||||
<AnimatePresence mode="popLayout">
|
||||
{filteredCustomers.map((customer, index) => (
|
||||
<motion.tr
|
||||
key={customer.userId}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.03 }}
|
||||
className={`cursor-pointer group hover:bg-muted/50 border-b border-border/50 transition-colors ${!customer.hasOrders ? "bg-primary/5 hover:bg-primary/10" : ""}`}
|
||||
onClick={() => setSelectedCustomer(customer)}
|
||||
>
|
||||
<TableCell className="py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`h-9 w-9 rounded-full flex items-center justify-center text-xs font-medium ${!customer.hasOrders ? 'bg-primary/20 text-primary' : 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
{customer.telegramUsername ? customer.telegramUsername.substring(0, 2).toUpperCase() : 'ID'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium flex items-center gap-2">
|
||||
@{customer.telegramUsername || "Unknown"}
|
||||
{!customer.hasOrders && (
|
||||
<Badge variant="outline" className="h-5 px-1.5 text-[10px] bg-primary/10 text-primary border-primary/20">
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center font-mono mt-0.5">
|
||||
<span className="opacity-50 select-none">ID:</span>
|
||||
<span className="ml-1">{customer.telegramUserId}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="secondary" className="font-mono font-normal">
|
||||
{customer.totalOrders}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center font-mono text-sm">
|
||||
{formatCurrency(customer.totalSpent)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm text-muted-foreground">
|
||||
{customer.lastOrderDate ? (
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<Calendar className="h-3 w-3 opacity-70" />
|
||||
{formatDate(customer.lastOrderDate).split(",")[0]}
|
||||
</div>
|
||||
) : "Never"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{customer.hasOrders ? (
|
||||
<div className="flex justify-center flex-wrap gap-1">
|
||||
{customer.ordersByStatus.paid > 0 && (
|
||||
<Badge className="bg-blue-500/15 text-blue-500 hover:bg-blue-500/25 border-blue-500/20 h-5 px-1.5 text-[10px]">
|
||||
{customer.ordersByStatus.paid} Paid
|
||||
</Badge>
|
||||
)}
|
||||
{customer.ordersByStatus.completed > 0 && (
|
||||
<Badge className="bg-emerald-500/15 text-emerald-500 hover:bg-emerald-500/25 border-emerald-500/20 h-5 px-1.5 text-[10px]">
|
||||
{customer.ordersByStatus.completed} Done
|
||||
</Badge>
|
||||
)}
|
||||
{customer.ordersByStatus.shipped > 0 && (
|
||||
<Badge className="bg-amber-500/15 text-amber-500 hover:bg-amber-500/25 border-amber-500/20 h-5 px-1.5 text-[10px]">
|
||||
{customer.ordersByStatus.shipped} Ship
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground italic">No activity</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</motion.tr>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<div className="p-4 border-t border-zinc-800 bg-black/40 flex justify-between items-center">
|
||||
<div className="text-sm text-gray-400">
|
||||
<div className="p-4 border-t border-border/50 bg-background/50 flex justify-between items-center">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Page {page} of {totalPages}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -403,74 +511,100 @@ export default function CustomerManagementPage() {
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1 || loading}
|
||||
className="h-8"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
<ChevronLeft className="h-3 w-3 mr-1" />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
{totalPages > 2 && (
|
||||
{totalPages > 2 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<span className="sr-only">Go to page</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<Button variant="outline" size="sm" className="h-8 px-2">
|
||||
<MoreHorizontal className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="bg-black/90 border-zinc-800 max-h-60 overflow-y-auto">
|
||||
<DropdownMenuContent align="center" className="max-h-60 overflow-y-auto">
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNum) => (
|
||||
<DropdownMenuItem
|
||||
key={pageNum}
|
||||
onClick={() => handlePageChange(pageNum)}
|
||||
className={`cursor-pointer ${pageNum === page ? 'bg-zinc-800 text-white' : 'text-gray-300'}`}
|
||||
className={pageNum === page ? 'bg-primary/10 text-primary' : ''}
|
||||
>
|
||||
Page {pageNum}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(Math.min(totalPages, page + 1))}
|
||||
disabled={page === totalPages || loading}
|
||||
className="h-8"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
<ChevronRight className="h-3 w-3 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Customer Details Dialog */}
|
||||
<AnimatePresence>
|
||||
{selectedCustomer && (
|
||||
<Dialog open={!!selectedCustomer} onOpenChange={(open) => !open && setSelectedCustomer(null)}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-2xl w-full overflow-y-auto max-h-[90vh] z-[80]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base">
|
||||
Customer Details
|
||||
<DialogTitle className="text-lg flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-lg">
|
||||
{selectedCustomer.telegramUsername ? selectedCustomer.telegramUsername.substring(0, 1).toUpperCase() : 'U'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold">Customer Details</div>
|
||||
<div className="text-sm font-normal text-muted-foreground flex items-center gap-2">
|
||||
@{selectedCustomer.telegramUsername || "Unknown"}
|
||||
<span className="w-1 h-1 rounded-full bg-primary" />
|
||||
<span className="font-mono text-xs opacity-70">ID: {selectedCustomer.telegramUserId}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 py-4">
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Customer Information */}
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-medium mb-2">Customer Information</h3>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider pl-1">Contact Info</h3>
|
||||
<div className="rounded-xl border border-border p-4 space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<div className="text-muted-foreground">Username:</div>
|
||||
<div className="font-medium">@{selectedCustomer.telegramUsername || "Unknown"}</div>
|
||||
<div className="flex justify-between items-center text-sm group">
|
||||
<div className="text-muted-foreground flex items-center gap-2">
|
||||
<Users className="h-4 w-4 opacity-50" />
|
||||
Username
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<div className="text-muted-foreground">Telegram ID:</div>
|
||||
<div className="font-medium">{selectedCustomer.telegramUserId}</div>
|
||||
<div className="font-medium group-hover:text-primary transition-colors">@{selectedCustomer.telegramUsername || "Unknown"}</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<div className="text-muted-foreground">Chat ID:</div>
|
||||
<div className="font-medium">{selectedCustomer.chatId}</div>
|
||||
<div className="flex justify-between items-center text-sm group">
|
||||
<div className="text-muted-foreground flex items-center gap-2">
|
||||
<CreditCard className="h-4 w-4 opacity-50" />
|
||||
User ID
|
||||
</div>
|
||||
<div className="font-medium font-mono">{selectedCustomer.telegramUserId}</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm group">
|
||||
<div className="text-muted-foreground flex items-center gap-2">
|
||||
<MessageCircle className="h-4 w-4 opacity-50" />
|
||||
Chat ID
|
||||
</div>
|
||||
<div className="font-medium font-mono">{selectedCustomer.chatId}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -486,76 +620,100 @@ export default function CustomerManagementPage() {
|
||||
Open Telegram Chat
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Order Statistics */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Order Statistics</h3>
|
||||
<div className="bg-background rounded-lg border border-border p-4 space-y-3">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<div className="text-muted-foreground">Total Orders:</div>
|
||||
<div className="font-medium">{selectedCustomer.totalOrders}</div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider pl-1">Lifetime Stats</h3>
|
||||
<div className="rounded-xl border border-border p-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-emerald-500/10 rounded-lg p-3 border border-emerald-500/20 min-w-0">
|
||||
<div className="text-xs text-emerald-400/70 uppercase font-medium mb-1 whitespace-nowrap">Total Spent</div>
|
||||
<div className="text-xl font-bold text-emerald-400 truncate">{formatCurrency(selectedCustomer.totalSpent)}</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<div className="text-muted-foreground">Total Spent:</div>
|
||||
<div className="font-medium">{formatCurrency(selectedCustomer.totalSpent)}</div>
|
||||
<div className="bg-blue-500/10 rounded-lg p-3 border border-blue-500/20 min-w-0">
|
||||
<div className="text-xs text-blue-400/70 uppercase font-medium mb-1 whitespace-nowrap">Total Orders</div>
|
||||
<div className="text-xl font-bold text-blue-400">{selectedCustomer.totalOrders}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2 border-t border-white/5">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<div className="text-muted-foreground">First Order:</div>
|
||||
<div className="font-medium">
|
||||
<div className="text-muted-foreground text-xs">First Order</div>
|
||||
<div className="font-medium text-white/70 text-xs bg-white/5 px-2 py-1 rounded">
|
||||
{formatDate(selectedCustomer.firstOrderDate)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<div className="text-muted-foreground">Last Order:</div>
|
||||
<div className="font-medium">
|
||||
<div className="text-muted-foreground text-xs">Last Activity</div>
|
||||
<div className="font-medium text-white/70 text-xs bg-white/5 px-2 py-1 rounded">
|
||||
{formatDate(selectedCustomer.lastOrderDate)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Order Status Breakdown */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-medium mb-2">Order Status Breakdown</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div className="bg-blue-500/10 rounded-lg border border-blue-500/20 p-3">
|
||||
<p className="text-sm text-muted-foreground">Paid</p>
|
||||
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.paid}</p>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider pl-1">Order History Breakdown</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="bg-blue-500/5 hover:bg-blue-500/10 transition-colors rounded-xl border border-blue-500/20 p-4 text-center group">
|
||||
<ShoppingBag className="h-5 w-5 text-blue-500 mx-auto mb-2 opacity-70 group-hover:opacity-100 transition-opacity" />
|
||||
<p className="text-2xl font-bold text-white mb-1">{selectedCustomer.ordersByStatus.paid}</p>
|
||||
<p className="text-xs font-medium text-blue-400/70 uppercase">Paid</p>
|
||||
</div>
|
||||
<div className="bg-purple-500/10 rounded-lg border border-purple-500/20 p-3">
|
||||
<p className="text-sm text-muted-foreground">Acknowledged</p>
|
||||
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.acknowledged}</p>
|
||||
<div className="bg-purple-500/5 hover:bg-purple-500/10 transition-colors rounded-xl border border-purple-500/20 p-4 text-center group">
|
||||
<Loader2 className="h-5 w-5 text-purple-500 mx-auto mb-2 opacity-70 group-hover:opacity-100 transition-opacity" />
|
||||
<p className="text-2xl font-bold text-white mb-1">{selectedCustomer.ordersByStatus.acknowledged}</p>
|
||||
<p className="text-xs font-medium text-purple-400/70 uppercase">Processing</p>
|
||||
</div>
|
||||
<div className="bg-amber-500/10 rounded-lg border border-amber-500/20 p-3">
|
||||
<p className="text-sm text-muted-foreground">Shipped</p>
|
||||
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.shipped}</p>
|
||||
<div className="bg-amber-500/5 hover:bg-amber-500/10 transition-colors rounded-xl border border-amber-500/20 p-4 text-center group">
|
||||
<Truck className="h-5 w-5 text-amber-500 mx-auto mb-2 opacity-70 group-hover:opacity-100 transition-opacity" />
|
||||
<p className="text-2xl font-bold text-white mb-1">{selectedCustomer.ordersByStatus.shipped}</p>
|
||||
<p className="text-xs font-medium text-amber-400/70 uppercase">Shipped</p>
|
||||
</div>
|
||||
<div className="bg-green-500/10 rounded-lg border border-green-500/20 p-3">
|
||||
<p className="text-sm text-muted-foreground">Completed</p>
|
||||
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.completed}</p>
|
||||
<div className="bg-emerald-500/5 hover:bg-emerald-500/10 transition-colors rounded-xl border border-emerald-500/20 p-4 text-center group">
|
||||
<CheckCircle className="h-5 w-5 text-emerald-500 mx-auto mb-2 opacity-70 group-hover:opacity-100 transition-opacity" />
|
||||
<p className="text-2xl font-bold text-white mb-1">{selectedCustomer.ordersByStatus.completed}</p>
|
||||
<p className="text-xs font-medium text-emerald-400/70 uppercase">Completed</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogFooter className="pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
onClick={() => setSelectedCustomer(null)}
|
||||
className=""
|
||||
>
|
||||
Close
|
||||
Close Profile
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => router.push(`/dashboard/storefront/chat/new?userId=${selectedCustomer.telegramUserId}`)}
|
||||
className=""
|
||||
>
|
||||
<MessageCircle className="h-4 w-4 mr-2" />
|
||||
Start Chat
|
||||
Message Customer
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,15 @@ import Layout from "@/components/layout/layout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Save, Send, Key, MessageSquare, Shield, Globe, Wallet } from "lucide-react";
|
||||
import { Save, Send, Key, MessageSquare, Shield, Globe, Wallet, RefreshCw } from "lucide-react";
|
||||
import { apiRequest } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
import BroadcastDialog from "@/components/modals/broadcast-dialog";
|
||||
import Dashboard from "@/components/dashboard/dashboard";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -166,17 +170,61 @@ export default function StorefrontPage() {
|
||||
return (
|
||||
<Dashboard>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-6">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<Globe className="mr-2 h-6 w-6" />
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 rounded-xl bg-primary/10 text-primary">
|
||||
<Globe className="h-8 w-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">
|
||||
Storefront Settings
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-muted-foreground">
|
||||
Manage your shop's appearance, policies, and configuration
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setBroadcastOpen(true)}
|
||||
className="gap-2 h-10"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
Broadcast
|
||||
</Button>
|
||||
<Button
|
||||
onClick={saveStorefront}
|
||||
disabled={saving}
|
||||
className="gap-2 h-10 min-w-[120px]"
|
||||
>
|
||||
{saving ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Column */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
|
||||
{/* Store Status Card */}
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden relative">
|
||||
<div className={`absolute top-0 left-0 w-1 h-full ${storefront.isEnabled ? 'bg-emerald-500' : 'bg-destructive'}`} />
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle>Store Status</CardTitle>
|
||||
<CardDescription>Control your store's visibility to customers</CardDescription>
|
||||
</div>
|
||||
<Badge variant={storefront.isEnabled ? "default" : "destructive"} className="h-6">
|
||||
{storefront.isEnabled ? "Open for Business" : "Store Closed"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-6">
|
||||
<div className="flex items-center gap-4 p-4 rounded-lg bg-card border border-border/50">
|
||||
<Switch
|
||||
checked={storefront.isEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
@@ -185,183 +233,190 @@ export default function StorefrontPage() {
|
||||
isEnabled: checked,
|
||||
}))
|
||||
}
|
||||
className="data-[state=checked]:bg-emerald-500"
|
||||
/>
|
||||
<span className={`text-sm font-medium ${storefront.isEnabled ? 'text-emerald-400' : 'text-zinc-400'}`}>
|
||||
{storefront.isEnabled ? 'Store Open' : 'Store Closed'}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{storefront.isEnabled ? 'Click to close store' : 'Click to open store'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div>
|
||||
<h4 className="font-medium text-sm">
|
||||
{storefront.isEnabled ? 'Your store is currently online' : 'Your store is currently offline'}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{storefront.isEnabled
|
||||
? 'Customers can browse listings and place orders normally.'
|
||||
: 'Customers will see a maintenance page. No new orders can be placed.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setBroadcastOpen(true)}
|
||||
className="gap-2"
|
||||
size="sm"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
Broadcast
|
||||
</Button>
|
||||
<Button
|
||||
onClick={saveStorefront}
|
||||
disabled={saving}
|
||||
className="gap-2"
|
||||
size="sm"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Welcome & Policy */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }}>
|
||||
<Card className="h-full border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<MessageSquare className="h-4 w-4 text-primary" />
|
||||
Welcome Message
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
value={storefront.welcomeMessage}
|
||||
onChange={(e) => setStorefront(prev => ({ ...prev, welcomeMessage: e.target.value }))}
|
||||
placeholder="Enter the welcome message for new customers..."
|
||||
className="min-h-[180px] bg-background/50 border-border/50 resize-none focus:ring-primary/20"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}>
|
||||
<Card className="h-full border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Shield className="h-4 w-4 text-orange-400" />
|
||||
Store Policy
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
value={storefront.storePolicy}
|
||||
onChange={(e) => setStorefront(prev => ({ ...prev, storePolicy: e.target.value }))}
|
||||
placeholder="Enter your store's policies, terms, and conditions..."
|
||||
className="min-h-[180px] bg-background/50 border-border/50 resize-none focus:ring-primary/20"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 lg:gap-6">
|
||||
{/* Security Settings */}
|
||||
<div className="space-y-3">
|
||||
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Shield className="h-4 w-4 text-purple-400" />
|
||||
<h2 className="text-base font-medium text-zinc-100">
|
||||
Security
|
||||
</h2>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block text-zinc-400">PGP Public Key</label>
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }}>
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5 text-purple-400" />
|
||||
Security Configuration
|
||||
</CardTitle>
|
||||
<CardDescription>Manage keys and access tokens for your store security</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">PGP Public Key</Label>
|
||||
<Textarea
|
||||
value={storefront.pgpKey}
|
||||
onChange={(e) => setStorefront(prev => ({ ...prev, pgpKey: e.target.value }))}
|
||||
placeholder="Enter your PGP public key"
|
||||
className="font-mono text-sm h-24 bg-[#1C1C1C] border-zinc-800 resize-none"
|
||||
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----..."
|
||||
className="font-mono text-xs h-32 bg-zinc-950/50 border-zinc-800/50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block text-zinc-400">Telegram Bot Token</label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Telegram Bot Token</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="password"
|
||||
value={storefront.telegramToken}
|
||||
onChange={(e) => setStorefront(prev => ({ ...prev, telegramToken: e.target.value }))}
|
||||
placeholder="Enter your Telegram bot token"
|
||||
className="bg-[#1C1C1C] border-zinc-800 h-8 text-sm"
|
||||
placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||
className="bg-background/50 border-border/50 font-mono text-sm pl-10"
|
||||
/>
|
||||
<div className="absolute left-3 top-2.5 text-muted-foreground">
|
||||
<Shield className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">Used for notifications and bot integration.</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Column */}
|
||||
<div className="space-y-6">
|
||||
{/* Shipping Settings */}
|
||||
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<motion.div initial={{ opacity: 0, x: 10 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.4 }}>
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Globe className="h-4 w-4 text-blue-400" />
|
||||
<h2 className="text-base font-medium text-zinc-100">
|
||||
Shipping
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block text-zinc-400">Ships From</label>
|
||||
Shipping & Logistics
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Ships From</Label>
|
||||
<Select
|
||||
value={storefront.shipsFrom}
|
||||
onValueChange={(value) =>
|
||||
setStorefront((prev) => ({ ...prev, shipsFrom: value as any }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="bg-[#1C1C1C] border-zinc-800 h-8 text-sm">
|
||||
<SelectTrigger className="bg-background/50 border-border/50">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SHIPPING_REGIONS.map((region) => (
|
||||
<SelectItem key={region.value} value={region.value}>
|
||||
{region.emoji} {region.label}
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-lg">{region.emoji}</span>
|
||||
{region.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block text-zinc-400">Ships To</label>
|
||||
<div className="space-y-2">
|
||||
<Label>Ships To</Label>
|
||||
<Select
|
||||
value={storefront.shipsTo}
|
||||
onValueChange={(value) =>
|
||||
setStorefront((prev) => ({ ...prev, shipsTo: value as any }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="bg-[#1C1C1C] border-zinc-800 h-8 text-sm">
|
||||
<SelectTrigger className="bg-background/50 border-border/50">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SHIPPING_REGIONS.map((region) => (
|
||||
<SelectItem key={region.value} value={region.value}>
|
||||
{region.emoji} {region.label}
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-lg">{region.emoji}</span>
|
||||
{region.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Messaging and Payments */}
|
||||
<div className="space-y-3">
|
||||
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<MessageSquare className="h-4 w-4 text-emerald-400" />
|
||||
<h2 className="text-base font-medium text-zinc-100">
|
||||
Welcome Message
|
||||
</h2>
|
||||
</div>
|
||||
<Textarea
|
||||
value={storefront.welcomeMessage}
|
||||
onChange={(e) => setStorefront(prev => ({ ...prev, welcomeMessage: e.target.value }))}
|
||||
placeholder="Enter the welcome message for new customers"
|
||||
className="h-36 bg-[#1C1C1C] border-zinc-800 text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Shield className="h-4 w-4 text-orange-400" />
|
||||
<h2 className="text-base font-medium text-zinc-100">
|
||||
Store Policy
|
||||
</h2>
|
||||
</div>
|
||||
<Textarea
|
||||
value={storefront.storePolicy}
|
||||
onChange={(e) => setStorefront(prev => ({ ...prev, storePolicy: e.target.value }))}
|
||||
placeholder="Enter your store's policies, terms, and conditions"
|
||||
className="h-48 bg-[#1C1C1C] border-zinc-800 text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Wallet className="h-4 w-4 text-yellow-400" />
|
||||
<h2 className="text-base font-medium text-zinc-100">
|
||||
Payment Methods
|
||||
</h2>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{/* Payment Methods */}
|
||||
<motion.div initial={{ opacity: 0, x: 10 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.5 }}>
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Wallet className="h-4 w-4 text-yellow-500" />
|
||||
Crypto Wallets
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{WALLET_OPTIONS.map((wallet) => (
|
||||
<div key={wallet.id} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium flex items-center gap-2 text-zinc-400">
|
||||
<span>{wallet.emoji}</span>
|
||||
<div key={wallet.id} className="p-3 rounded-lg border border-border/50 bg-card/30 hover:bg-card/50 transition-colors">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="text-sm font-medium flex items-center gap-2">
|
||||
<span className="text-lg">{wallet.emoji}</span>
|
||||
{wallet.name}
|
||||
{wallet.comingSoon && (
|
||||
<span className="text-[10px] bg-purple-900/50 text-purple-400 px-1.5 py-0.5 rounded">
|
||||
Coming Soon
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
{wallet.comingSoon && (
|
||||
<Badge variant="secondary" className="text-[10px] h-5">Soon</Badge>
|
||||
)}
|
||||
<Switch
|
||||
checked={storefront.enabledWallets[wallet.id]}
|
||||
onCheckedChange={(checked) =>
|
||||
@@ -374,18 +429,12 @@ export default function StorefrontPage() {
|
||||
}))
|
||||
}
|
||||
disabled={wallet.disabled}
|
||||
className="scale-90"
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{wallet.disabled && (
|
||||
<TooltipContent>
|
||||
<p>Coming soon</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
{storefront.enabledWallets[wallet.id] && !wallet.disabled && (
|
||||
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }}>
|
||||
<Input
|
||||
value={storefront.wallets[wallet.id]}
|
||||
onChange={(e) =>
|
||||
@@ -398,19 +447,21 @@ export default function StorefrontPage() {
|
||||
}))
|
||||
}
|
||||
placeholder={wallet.placeholder}
|
||||
className="font-mono text-sm h-8 bg-[#1C1C1C] border-zinc-800"
|
||||
className="font-mono text-xs h-9 bg-background/50"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BroadcastDialog open={broadcastOpen} setOpen={setBroadcastOpen} />
|
||||
|
||||
</Dashboard>
|
||||
</Dashboard >
|
||||
);
|
||||
}
|
||||
|
||||
119
app/globals.css
119
app/globals.css
@@ -16,6 +16,7 @@ body {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
@@ -61,7 +62,10 @@ body {
|
||||
}
|
||||
|
||||
/* Larger touch targets for interactive elements */
|
||||
button, input, textarea, [role="button"] {
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
[role="button"] {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
@@ -90,7 +94,9 @@ body {
|
||||
}
|
||||
|
||||
/* Improved focus visibility */
|
||||
input:focus, textarea:focus, button:focus {
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
button:focus {
|
||||
outline: 2px solid hsl(var(--ring));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -102,6 +108,7 @@ body {
|
||||
|
||||
/* Chromebook-specific optimizations */
|
||||
@media screen and (max-width: 1366px) and (min-resolution: 1.5dppx) {
|
||||
|
||||
/* Chromebook display optimizations */
|
||||
.text-sm {
|
||||
font-size: 0.875rem;
|
||||
@@ -114,23 +121,28 @@ body {
|
||||
}
|
||||
|
||||
/* Better touch targets for Chromebooks */
|
||||
button, input, textarea, [role="button"], [role="tab"] {
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
[role="button"],
|
||||
[role="tab"] {
|
||||
min-height: 48px;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
/* Improved spacing for Chromebook screens */
|
||||
.space-y-2 > * + * {
|
||||
.space-y-2>*+* {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.space-y-4 > * + * {
|
||||
.space-y-4>*+* {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Chromebook touch screen optimizations */
|
||||
@media (pointer: coarse) and (hover: none) {
|
||||
|
||||
/* Larger touch targets */
|
||||
.touch-target {
|
||||
min-height: 52px;
|
||||
@@ -138,7 +150,7 @@ body {
|
||||
}
|
||||
|
||||
/* Better spacing for touch interactions */
|
||||
.space-y-2 > * + * {
|
||||
.space-y-2>*+* {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
@@ -148,13 +160,16 @@ body {
|
||||
}
|
||||
|
||||
/* Better input field sizing */
|
||||
input, textarea {
|
||||
input,
|
||||
textarea {
|
||||
padding: 0.875rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Enhanced focus states for touch */
|
||||
button:focus-visible, input:focus-visible, textarea:focus-visible {
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 3px solid hsl(var(--ring));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -162,6 +177,7 @@ body {
|
||||
|
||||
/* Chromebook keyboard navigation improvements */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
|
||||
/* Better hover states for mouse/trackpad */
|
||||
button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
@@ -169,7 +185,9 @@ body {
|
||||
}
|
||||
|
||||
/* Improved focus indicators */
|
||||
button:focus-visible, input:focus-visible, textarea:focus-visible {
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 2px solid hsl(var(--ring));
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 4px hsl(var(--ring) / 0.2);
|
||||
@@ -178,6 +196,7 @@ body {
|
||||
|
||||
/* Chromebook display scaling fixes */
|
||||
@media screen and (min-resolution: 1.5dppx) {
|
||||
|
||||
/* Prevent text from being too small on high-DPI displays */
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
@@ -254,8 +273,17 @@ body {
|
||||
|
||||
/* Christmas-themed animations */
|
||||
@keyframes twinkle {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.3; transform: scale(0.8); }
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes snowflake {
|
||||
@@ -263,6 +291,7 @@ body {
|
||||
transform: translateY(-100vh) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(100vh) rotate(360deg);
|
||||
opacity: 0;
|
||||
@@ -270,10 +299,13 @@ body {
|
||||
}
|
||||
|
||||
@keyframes sparkle {
|
||||
0%, 100% {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0) rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(180deg);
|
||||
@@ -281,9 +313,12 @@ body {
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
0%, 100% {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 5px hsl(var(--christmas-red)), 0 0 10px hsl(var(--christmas-red)), 0 0 15px hsl(var(--christmas-red));
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 0 10px hsl(var(--christmas-green)), 0 0 20px hsl(var(--christmas-green)), 0 0 30px hsl(var(--christmas-green));
|
||||
}
|
||||
@@ -376,6 +411,42 @@ body {
|
||||
.christmas-theme *:focus-visible {
|
||||
outline-color: hsl(var(--christmas-red));
|
||||
}
|
||||
|
||||
/* Premium UI Utilities */
|
||||
.glass-morphism {
|
||||
@apply bg-background/60 backdrop-blur-md border border-border/50;
|
||||
}
|
||||
|
||||
.dark .glass-morphism {
|
||||
@apply bg-black/40 backdrop-blur-xl border-white/5;
|
||||
}
|
||||
|
||||
.premium-card {
|
||||
@apply transition-all duration-300;
|
||||
}
|
||||
|
||||
.premium-card:hover {
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
|
||||
border-color: hsl(var(--primary) / 0.2);
|
||||
}
|
||||
|
||||
.dark .premium-card {
|
||||
@apply bg-card;
|
||||
}
|
||||
|
||||
.dark .premium-card:hover {
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6);
|
||||
border-color: hsl(var(--primary) / 0.2);
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary to-primary/60;
|
||||
}
|
||||
|
||||
.bg-gradient-premium {
|
||||
background: radial-gradient(circle at top left, hsl(var(--primary) / 0.05), transparent),
|
||||
radial-gradient(circle at bottom right, hsl(var(--primary) / 0.02), transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -410,26 +481,27 @@ body {
|
||||
--christmas-green: 142 76% 36%;
|
||||
--christmas-gold: 43 96% 56%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--background: 240 10% 2%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card: 240 10% 3%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover: 240 10% 2%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary: 240 4% 10%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--muted: 240 4% 10%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 4% 10%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--border: 240 4% 12%;
|
||||
--input: 240 4% 12%;
|
||||
--ring: 240 5% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
@@ -464,6 +536,7 @@ body {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
94
app/page.tsx
94
app/page.tsx
@@ -1,79 +1,43 @@
|
||||
import { getPlatformStatsServer } from "@/lib/server-api";
|
||||
import { HomeNavbar } from "@/components/home-navbar";
|
||||
import { Suspense } from "react";
|
||||
import { Shield, LineChart, Zap, ArrowRight, CheckCircle2, Sparkles } from "lucide-react";
|
||||
import { Shield, LineChart, Zap, ArrowRight, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { AnimatedStatsSection } from "@/components/animated-stats-section";
|
||||
import { isDecember } from "@/lib/utils/christmas";
|
||||
import { MotionWrapper } from "@/components/ui/motion-wrapper";
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const PY_20 = 20;
|
||||
const PY_32 = 32;
|
||||
const PX_6 = 6;
|
||||
const PX_10 = 10;
|
||||
|
||||
function formatNumberValue(num: number): string {
|
||||
return new Intl.NumberFormat().format(Math.round(num));
|
||||
}
|
||||
|
||||
// Format currency
|
||||
function formatCurrencyValue(amount: number): string {
|
||||
return new Intl.NumberFormat('en-GB', {
|
||||
style: 'currency',
|
||||
currency: 'GBP',
|
||||
maximumFractionDigits: 0
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
// This is a server component
|
||||
export default async function Home() {
|
||||
try {
|
||||
const stats = await getPlatformStatsServer();
|
||||
const isDec = isDecember();
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col min-h-screen bg-black text-white ${isDec ? 'christmas-theme' : ''}`}>
|
||||
<div className={`absolute inset-0 bg-gradient-to-br pointer-events-none scale-100 ${
|
||||
isDec
|
||||
? 'from-red-500/10 via-green-500/5 to-transparent'
|
||||
: 'from-[#D53F8C]/10 via-[#D53F8C]/3 to-transparent'
|
||||
}`} />
|
||||
<div className="relative flex flex-col min-h-screen bg-black text-white">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-indigo-500/10 via-purple-500/5 to-transparent pointer-events-none scale-100" />
|
||||
<HomeNavbar />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative overflow-hidden">
|
||||
<div className="relative flex flex-col items-center px-4 py-20 md:py-32 mx-auto max-w-7xl">
|
||||
<div className="flex flex-col items-center text-center space-y-6 max-w-3xl">
|
||||
<div className={`inline-flex items-center px-4 py-2 rounded-full border mb-4 ${
|
||||
isDec
|
||||
? 'bg-red-500/10 border-red-500/20'
|
||||
: 'bg-[#D53F8C]/10 border-[#D53F8C]/20'
|
||||
}`}>
|
||||
<Sparkles className={`h-4 w-4 mr-2 ${isDec ? 'text-red-400' : 'text-[#D53F8C]'}`} />
|
||||
<span className={`text-sm font-medium ${isDec ? 'text-red-400' : 'text-[#D53F8C]'}`}>
|
||||
<div className="inline-flex items-center px-4 py-2 rounded-full border border-indigo-500/20 bg-indigo-500/10 mb-4">
|
||||
<Sparkles className="h-4 w-4 mr-2 text-indigo-400" />
|
||||
<span className="text-sm font-medium text-indigo-400">
|
||||
Secure Crypto Payments
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-6xl font-bold tracking-tight">
|
||||
The Future of <span className={isDec ? 'text-red-400' : 'text-[#D53F8C]'}>E-commerce</span> Management
|
||||
The Future of <span className="text-indigo-500">E-commerce</span> Management
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl text-zinc-400 max-w-2xl">
|
||||
{isDec
|
||||
? 'Spread joy this holiday season with our all-in-one platform. Secure payments, order tracking, and analytics wrapped up in one beautiful package. 🎄'
|
||||
: 'Streamline your online business with our all-in-one platform. Secure payments, order tracking, and analytics in one place.'
|
||||
}
|
||||
Streamline your online business with our all-in-one platform. Secure payments, order tracking, and analytics in one place.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 mt-4">
|
||||
<Link href="/dashboard">
|
||||
<Button
|
||||
size="lg"
|
||||
className={`gap-2 text-white border-0 h-12 px-8 ${
|
||||
isDec
|
||||
? 'bg-gradient-to-r from-red-500 to-green-500 hover:from-red-600 hover:to-green-600'
|
||||
: 'bg-[#D53F8C] hover:bg-[#B83280]'
|
||||
}`}
|
||||
className="gap-2 text-white border-0 h-12 px-8 bg-indigo-600 hover:bg-indigo-700"
|
||||
>
|
||||
Get Started
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
@@ -87,6 +51,7 @@ export default async function Home() {
|
||||
{/* Features Grid */}
|
||||
<section className="relative py-20 px-4">
|
||||
<div className="max-w-7xl mx-auto space-y-20">
|
||||
<MotionWrapper>
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{[
|
||||
{
|
||||
@@ -104,39 +69,23 @@ export default async function Home() {
|
||||
title: "Lightning Fast",
|
||||
description: "Optimized for speed with real-time updates and instant notifications."
|
||||
}
|
||||
].map((feature, i) => {
|
||||
const christmasColors = ['from-red-500/5', 'from-green-500/5', 'from-yellow-500/5'];
|
||||
const christmasBorders = ['border-red-500/30', 'border-green-500/30', 'border-yellow-500/30'];
|
||||
const christmasIcons = ['text-red-400', 'text-green-400', 'text-yellow-400'];
|
||||
const christmasBgs = ['bg-red-500/10', 'bg-green-500/10', 'bg-yellow-500/10'];
|
||||
|
||||
return (
|
||||
].map((feature, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`group relative overflow-hidden rounded-xl bg-gradient-to-b p-6 border ${
|
||||
isDec
|
||||
? `from-zinc-800/30 to-transparent ${christmasBorders[i % 3]}`
|
||||
: 'from-zinc-800/30 to-transparent border-zinc-800'
|
||||
}`}
|
||||
className="group relative overflow-hidden rounded-xl bg-gradient-to-b from-zinc-800/30 to-transparent p-6 border border-zinc-800 transition-all duration-300 hover:scale-[1.02] hover:shadow-lg"
|
||||
>
|
||||
<div
|
||||
className={`absolute inset-0 bg-gradient-to-b to-transparent opacity-0 group-hover:opacity-100 transition-opacity ${
|
||||
isDec ? christmasColors[i % 3] : 'from-[#D53F8C]/5'
|
||||
}`}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-indigo-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="relative">
|
||||
<div className={`h-12 w-12 flex items-center justify-center rounded-lg mb-4 ${
|
||||
isDec ? christmasBgs[i % 3] : 'bg-[#D53F8C]/10'
|
||||
}`}>
|
||||
<feature.icon className={`h-6 w-6 ${isDec ? christmasIcons[i % 3] : 'text-[#D53F8C]'}`} />
|
||||
<div className="h-12 w-12 flex items-center justify-center rounded-lg mb-4 bg-indigo-500/10">
|
||||
<feature.icon className="h-6 w-6 text-indigo-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">{feature.title}</h3>
|
||||
<p className="text-sm text-zinc-400">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</MotionWrapper>
|
||||
|
||||
{/* Stats Section */}
|
||||
<div className="relative">
|
||||
@@ -148,13 +97,6 @@ export default async function Home() {
|
||||
{/* Footer */}
|
||||
<footer className="relative py-12 px-4 mt-auto">
|
||||
<div className="max-w-7xl mx-auto flex flex-col items-center">
|
||||
{isDec && (
|
||||
<div className="flex items-center gap-2 mb-4 text-red-400 animate-twinkle">
|
||||
<span className="text-xl">🎄</span>
|
||||
<span className="text-sm font-medium">Happy Holidays from da ember team!</span>
|
||||
<span className="text-xl">🎄</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm text-zinc-500">
|
||||
© {new Date().getFullYear()} Ember. All rights reserved.
|
||||
</div>
|
||||
|
||||
@@ -263,7 +263,7 @@ export default function AdminAnalytics() {
|
||||
|
||||
// Helper to transform data for recharts
|
||||
const transformChartData = (
|
||||
data: Array<{ date: string; [key: string]: any }>,
|
||||
data: Array<{ date: string;[key: string]: any }>,
|
||||
valueKey: string = "count",
|
||||
) => {
|
||||
if (!data || data.length === 0) return [];
|
||||
@@ -519,39 +519,39 @@ export default function AdminAnalytics() {
|
||||
const bestMonth = calculateBestMonth();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8 animate-in fade-in duration-500">
|
||||
{errorMessage && (
|
||||
<Alert variant="destructive">
|
||||
<Alert variant="destructive" className="animate-in slide-in-from-top-2 border-destructive/50 bg-destructive/10 text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">
|
||||
<h2 className="text-3xl font-bold tracking-tight bg-gradient-to-r from-foreground to-foreground/70 bg-clip-text text-transparent">
|
||||
Dashboard Analytics
|
||||
{!isViewingCurrentYear && (
|
||||
<span className="ml-2 text-lg font-normal text-muted-foreground">
|
||||
<span className="ml-2 text-xl font-normal text-muted-foreground/60">
|
||||
({selectedYear})
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{isViewingCurrentYear
|
||||
? "Overview of your marketplace performance"
|
||||
: `Historical data for ${selectedYear}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 bg-background/40 p-1 rounded-lg border border-border/40 backdrop-blur-md">
|
||||
{/* Year selector */}
|
||||
<Select
|
||||
value={selectedYear.toString()}
|
||||
onValueChange={(value) => setSelectedYear(parseInt(value, 10))}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectTrigger className="w-[100px] border-0 bg-transparent focus:ring-0">
|
||||
<SelectValue placeholder="Year" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -563,13 +563,15 @@ export default function AdminAnalytics() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="h-4 w-px bg-border/40" />
|
||||
|
||||
{/* Date range selector - only show options for current year */}
|
||||
<Select
|
||||
value={isViewingCurrentYear ? dateRange : "year"}
|
||||
onValueChange={setDateRange}
|
||||
disabled={!isViewingCurrentYear}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectTrigger className="w-[140px] border-0 bg-transparent focus:ring-0">
|
||||
<SelectValue placeholder="Last 7 days" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -578,6 +580,8 @@ export default function AdminAnalytics() {
|
||||
<SelectItem value="24hours">Last 24 hours</SelectItem>
|
||||
<SelectItem value="7days">Last 7 days</SelectItem>
|
||||
<SelectItem value="30days">Last 30 days</SelectItem>
|
||||
<SelectItem value="90days">Last 90 days</SelectItem>
|
||||
<SelectItem value="180days">Last 180 days</SelectItem>
|
||||
<SelectItem value="ytd">Year to Date</SelectItem>
|
||||
<SelectItem value="year">Full Year</SelectItem>
|
||||
</>
|
||||
@@ -587,11 +591,14 @@ export default function AdminAnalytics() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="h-4 w-px bg-border/40" />
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="h-8 w-8 hover:bg-background/60"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
|
||||
@@ -599,9 +606,10 @@ export default function AdminAnalytics() {
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowDebug(!showDebug)}
|
||||
className="px-2 text-xs hover:bg-background/60"
|
||||
>
|
||||
{showDebug ? "Hide" : "Show"} Debug
|
||||
</Button>
|
||||
@@ -609,9 +617,9 @@ export default function AdminAnalytics() {
|
||||
</div>
|
||||
|
||||
{showDebug && analyticsData && (
|
||||
<Card className="mt-4">
|
||||
<Card className="mt-4 border-yellow-500/20 bg-yellow-500/5 backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Debug: Raw Data</CardTitle>
|
||||
<CardTitle className="text-yellow-600">Debug: Raw Data</CardTitle>
|
||||
<CardDescription>Date Range: {dateRange}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -625,8 +633,9 @@ export default function AdminAnalytics() {
|
||||
Daily Orders Array Length:{" "}
|
||||
{analyticsData?.orders?.dailyOrders?.length || 0}
|
||||
</div>
|
||||
{/* ... Existing Debug details kept for brevity ... */}
|
||||
<div>First 3 Daily Orders:</div>
|
||||
<pre className="pl-4 bg-muted p-2 rounded overflow-auto max-h-32">
|
||||
<pre className="pl-4 bg-muted/50 p-2 rounded overflow-auto max-h-32">
|
||||
{JSON.stringify(
|
||||
analyticsData?.orders?.dailyOrders?.slice(0, 3),
|
||||
null,
|
||||
@@ -635,51 +644,12 @@ export default function AdminAnalytics() {
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="font-semibold mb-2">Revenue:</div>
|
||||
<div className="pl-4 space-y-1">
|
||||
<div>Total: {analyticsData?.revenue?.total || "N/A"}</div>
|
||||
<div>Today: {analyticsData?.revenue?.today || "N/A"}</div>
|
||||
<div>
|
||||
Daily Revenue Array Length:{" "}
|
||||
{analyticsData?.revenue?.dailyRevenue?.length || 0}
|
||||
</div>
|
||||
<div>First 3 Daily Revenue:</div>
|
||||
<pre className="pl-4 bg-muted p-2 rounded overflow-auto max-h-32">
|
||||
{JSON.stringify(
|
||||
analyticsData?.revenue?.dailyRevenue?.slice(0, 3),
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="font-semibold mb-2">Vendors:</div>
|
||||
<div className="pl-4 space-y-1">
|
||||
<div>Total: {analyticsData?.vendors?.total || "N/A"}</div>
|
||||
<div>
|
||||
Daily Growth Array Length:{" "}
|
||||
{analyticsData?.vendors?.dailyGrowth?.length || 0}
|
||||
</div>
|
||||
<div>First 3 Daily Growth:</div>
|
||||
<pre className="pl-4 bg-muted p-2 rounded overflow-auto max-h-32">
|
||||
{JSON.stringify(
|
||||
analyticsData?.vendors?.dailyGrowth?.slice(0, 3),
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Simplified debug view for code brevity in replacement, focusing on style changes */}
|
||||
<details className="mt-4">
|
||||
<summary className="font-semibold cursor-pointer">
|
||||
<summary className="font-semibold cursor-pointer hover:text-primary transition-colors">
|
||||
Full JSON Response
|
||||
</summary>
|
||||
<pre className="mt-2 bg-muted p-4 rounded overflow-auto max-h-96 text-[10px]">
|
||||
<pre className="mt-2 bg-muted/50 p-4 rounded overflow-auto max-h-96 text-[10px] backdrop-blur-sm">
|
||||
{JSON.stringify(analyticsData, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
@@ -688,14 +658,16 @@ export default function AdminAnalytics() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Best Month Card (show for YTD, full year, or previous years) */}
|
||||
{/* Best Month Card (show for YTD, full year, or previous years) */}
|
||||
{bestMonth && (
|
||||
<Card className="border-green-500/50 bg-green-500/5">
|
||||
<CardContent className="pt-6">
|
||||
<Card className="border-green-500/20 bg-green-500/5 backdrop-blur-sm overflow-hidden relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-green-500/10 to-transparent opacity-50" />
|
||||
<CardContent className="pt-6 relative">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-full bg-green-500/20">
|
||||
<Trophy className="h-5 w-5 text-green-600" />
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 rounded-full bg-green-500/20 border border-green-500/20 shadow-[0_0_15px_rgba(34,197,94,0.2)]">
|
||||
<Trophy className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
@@ -706,7 +678,7 @@ export default function AdminAnalytics() {
|
||||
? "(Full Year)"
|
||||
: "(YTD)"}
|
||||
</div>
|
||||
<div className="text-xl font-bold text-green-600">
|
||||
<div className="text-2xl font-bold text-green-600 mt-1">
|
||||
{bestMonth.month}
|
||||
</div>
|
||||
</div>
|
||||
@@ -715,10 +687,10 @@ export default function AdminAnalytics() {
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
Revenue
|
||||
</div>
|
||||
<div className="text-xl font-bold">
|
||||
<div className="text-2xl font-bold bg-gradient-to-r from-green-600 to-green-400 bg-clip-text text-transparent">
|
||||
{formatCurrency(bestMonth.revenue)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
{bestMonth.orders.toLocaleString()} orders
|
||||
</div>
|
||||
</div>
|
||||
@@ -727,15 +699,17 @@ export default function AdminAnalytics() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Orders Card */}
|
||||
<Card>
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden hover:shadow-md transition-shadow duration-300">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Total Orders
|
||||
</CardTitle>
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="p-2 bg-blue-500/10 rounded-md">
|
||||
<ShoppingCart className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -743,20 +717,22 @@ export default function AdminAnalytics() {
|
||||
{analyticsData?.orders?.total?.toLocaleString() || "0"}
|
||||
</div>
|
||||
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
||||
<span>Today: {analyticsData?.orders?.totalToday || 0}</span>
|
||||
<span className="bg-muted/50 px-1.5 py-0.5 rounded">Today: {analyticsData?.orders?.totalToday || 0}</span>
|
||||
<div className="ml-auto">
|
||||
<TrendIndicator
|
||||
current={analyticsData?.orders?.totalToday || 0}
|
||||
previous={(analyticsData?.orders?.total || 0) / 30}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading || refreshing ? (
|
||||
<div className="mt-3 h-12 flex items-center justify-center">
|
||||
<div className="mt-4 h-14 flex items-center justify-center">
|
||||
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full"></div>
|
||||
</div>
|
||||
) : analyticsData?.orders?.dailyOrders &&
|
||||
analyticsData.orders.dailyOrders.length > 0 ? (
|
||||
<div className="mt-3 h-12">
|
||||
<div className="mt-4 h-14 -mx-2">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsBarChart
|
||||
data={transformChartData(
|
||||
@@ -765,27 +741,29 @@ export default function AdminAnalytics() {
|
||||
)}
|
||||
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
||||
>
|
||||
<Bar dataKey="value" fill="#3b82f6" radius={[2, 2, 0, 0]} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar dataKey="value" fill="#3b82f6" fillOpacity={0.8} radius={[2, 2, 0, 0]} />
|
||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'transparent' }} />
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 text-xs text-muted-foreground">
|
||||
No chart data available
|
||||
<div className="mt-4 h-14 flex items-center justify-center text-xs text-muted-foreground bg-muted/20 rounded">
|
||||
No chart data
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Revenue Card */}
|
||||
<Card>
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden hover:shadow-md transition-shadow duration-300">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Total Revenue
|
||||
</CardTitle>
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="p-2 bg-green-500/10 rounded-md">
|
||||
<DollarSign className="h-4 w-4 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -793,22 +771,22 @@ export default function AdminAnalytics() {
|
||||
{formatCurrency(analyticsData?.revenue?.total || 0)}
|
||||
</div>
|
||||
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
||||
<span>
|
||||
Today: {formatCurrency(analyticsData?.revenue?.today || 0)}
|
||||
</span>
|
||||
<span className="bg-muted/50 px-1.5 py-0.5 rounded">Today: {formatCurrency(analyticsData?.revenue?.today || 0)}</span>
|
||||
<div className="ml-auto">
|
||||
<TrendIndicator
|
||||
current={analyticsData?.revenue?.today || 0}
|
||||
previous={(analyticsData?.revenue?.total || 0) / 30} // Rough estimate
|
||||
previous={(analyticsData?.revenue?.total || 0) / 30}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading || refreshing ? (
|
||||
<div className="mt-3 h-12 flex items-center justify-center">
|
||||
<div className="mt-4 h-14 flex items-center justify-center">
|
||||
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full"></div>
|
||||
</div>
|
||||
) : analyticsData?.revenue?.dailyRevenue &&
|
||||
analyticsData.revenue.dailyRevenue.length > 0 ? (
|
||||
<div className="mt-3 h-12">
|
||||
<div className="mt-4 h-14 -mx-2">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsBarChart
|
||||
data={transformChartData(
|
||||
@@ -817,52 +795,54 @@ export default function AdminAnalytics() {
|
||||
)}
|
||||
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
||||
>
|
||||
<Bar dataKey="value" fill="#10b981" radius={[2, 2, 0, 0]} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar dataKey="value" fill="#10b981" fillOpacity={0.8} radius={[2, 2, 0, 0]} />
|
||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'transparent' }} />
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 text-xs text-muted-foreground">
|
||||
No chart data available
|
||||
<div className="mt-4 h-14 flex items-center justify-center text-xs text-muted-foreground bg-muted/20 rounded">
|
||||
No chart data
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Vendors Card */}
|
||||
<Card>
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden hover:shadow-md transition-shadow duration-300">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-sm font-medium">Vendors</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Vendors</CardTitle>
|
||||
<div className="p-2 bg-purple-500/10 rounded-md">
|
||||
<Users className="h-4 w-4 text-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{analyticsData?.vendors?.total?.toLocaleString() || "0"}
|
||||
</div>
|
||||
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
||||
<span>Active: {analyticsData?.vendors?.active || 0}</span>
|
||||
<span className="ml-2">
|
||||
Stores: {analyticsData?.vendors?.activeStores || 0}
|
||||
</span>
|
||||
<div className="flex items-center text-xs text-muted-foreground mt-1 gap-2">
|
||||
<span className="bg-muted/50 px-1.5 py-0.5 rounded">Active: {analyticsData?.vendors?.active || 0}</span>
|
||||
<span className="bg-muted/50 px-1.5 py-0.5 rounded">Stores: {analyticsData?.vendors?.activeStores || 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
||||
<span>New Today: {analyticsData?.vendors?.newToday || 0}</span>
|
||||
<div className="flex items-center text-xs text-muted-foreground mt-2">
|
||||
<span>New: {analyticsData?.vendors?.newToday || 0}</span>
|
||||
<div className="ml-auto">
|
||||
<TrendIndicator
|
||||
current={analyticsData?.vendors?.newToday || 0}
|
||||
previous={(analyticsData?.vendors?.newThisWeek || 0) / 7} // Average per day
|
||||
previous={(analyticsData?.vendors?.newThisWeek || 0) / 7}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading || refreshing ? (
|
||||
<div className="mt-3 h-12 flex items-center justify-center">
|
||||
<div className="mt-2 h-12 flex items-center justify-center">
|
||||
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full"></div>
|
||||
</div>
|
||||
) : analyticsData?.vendors?.dailyGrowth &&
|
||||
analyticsData.vendors.dailyGrowth.length > 0 ? (
|
||||
<div className="mt-3 h-12">
|
||||
<div className="mt-2 h-12 -mx-2">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsBarChart
|
||||
data={transformChartData(
|
||||
@@ -871,25 +851,27 @@ export default function AdminAnalytics() {
|
||||
)}
|
||||
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
||||
>
|
||||
<Bar dataKey="value" fill="#8b5cf6" radius={[2, 2, 0, 0]} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar dataKey="value" fill="#8b5cf6" fillOpacity={0.8} radius={[2, 2, 0, 0]} />
|
||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'transparent' }} />
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 text-xs text-muted-foreground">
|
||||
No chart data available
|
||||
<div className="mt-2 h-12 flex items-center justify-center text-xs text-muted-foreground bg-muted/20 rounded">
|
||||
No chart data
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Products Card */}
|
||||
<Card>
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden hover:shadow-md transition-shadow duration-300">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-sm font-medium">Products</CardTitle>
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Products</CardTitle>
|
||||
<div className="p-2 bg-amber-500/10 rounded-md">
|
||||
<Package className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -897,26 +879,44 @@ export default function AdminAnalytics() {
|
||||
{analyticsData?.products?.total?.toLocaleString() || "0"}
|
||||
</div>
|
||||
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
||||
<span>New This Week: {analyticsData?.products?.recent || 0}</span>
|
||||
<span className="bg-muted/50 px-1.5 py-0.5 rounded">New This Week: {analyticsData?.products?.recent || 0}</span>
|
||||
</div>
|
||||
{/* Visual spacer since no chart here */}
|
||||
<div className="mt-4 h-14 w-full bg-gradient-to-r from-amber-500/5 to-transparent rounded-md flex items-center justify-center">
|
||||
<span className="text-xs text-muted-foreground/50 italic">Inventory Overview</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="orders" className="mt-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="orders">Orders</TabsTrigger>
|
||||
<TabsTrigger value="vendors">Vendors</TabsTrigger>
|
||||
<TabsTrigger value="growth">Growth Since Launch</TabsTrigger>
|
||||
<Tabs defaultValue="orders" className="mt-8">
|
||||
<TabsList className="bg-background/40 backdrop-blur-md border border-border/40 p-1 w-full sm:w-auto h-auto grid grid-cols-3 sm:flex">
|
||||
<TabsTrigger
|
||||
value="orders"
|
||||
className="data-[state=active]:bg-background/60 data-[state=active]:shadow-sm data-[state=active]:text-primary transition-all duration-300"
|
||||
>
|
||||
Orders
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="vendors"
|
||||
className="data-[state=active]:bg-background/60 data-[state=active]:shadow-sm data-[state=active]:text-primary transition-all duration-300"
|
||||
>
|
||||
Vendors
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="growth"
|
||||
className="data-[state=active]:bg-background/60 data-[state=active]:shadow-sm data-[state=active]:text-primary transition-all duration-300"
|
||||
>
|
||||
Growth Since Launch
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="orders" className="mt-4">
|
||||
<Card>
|
||||
<TabsContent value="orders" className="mt-4 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
||||
<CardHeader>
|
||||
<CardTitle>Order Trends</CardTitle>
|
||||
<CardTitle className="bg-gradient-to-r from-blue-600 to-cyan-500 bg-clip-text text-transparent w-fit">Order Trends</CardTitle>
|
||||
<CardDescription>
|
||||
Daily order volume and revenue processed over the selected time
|
||||
period
|
||||
Daily order volume and revenue processed over the selected time period
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -1039,10 +1039,10 @@ export default function AdminAnalytics() {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="vendors" className="mt-4">
|
||||
<Card>
|
||||
<TabsContent value="vendors" className="mt-4 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
||||
<CardHeader>
|
||||
<CardTitle>Vendor Growth</CardTitle>
|
||||
<CardTitle className="bg-gradient-to-r from-purple-600 to-pink-500 bg-clip-text text-transparent w-fit">Vendor Growth</CardTitle>
|
||||
<CardDescription>
|
||||
New vendor registrations over time
|
||||
</CardDescription>
|
||||
@@ -1198,74 +1198,51 @@ export default function AdminAnalytics() {
|
||||
|
||||
{/* Cumulative Stats Cards */}
|
||||
{growthData?.cumulative && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
Total Orders
|
||||
</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{growthData.cumulative.orders.toLocaleString()}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-6 gap-4">
|
||||
<Card className="col-span-1 border-border/40 bg-background/50 backdrop-blur-sm hover:bg-background/60 transition-colors">
|
||||
<CardContent className="pt-4 flex flex-col items-center justify-center text-center">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">Total Orders</div>
|
||||
<div className="text-2xl font-bold">{growthData.cumulative.orders.toLocaleString()}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
Total Revenue
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{formatCurrency(growthData.cumulative.revenue)}
|
||||
</div>
|
||||
<Card className="col-span-1 border-green-500/20 bg-green-500/5 backdrop-blur-sm hover:bg-green-500/10 transition-colors">
|
||||
<CardContent className="pt-4 flex flex-col items-center justify-center text-center">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">Total Revenue</div>
|
||||
<div className="text-2xl font-bold text-green-600">{formatCurrency(growthData.cumulative.revenue)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
Customers
|
||||
</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{growthData.cumulative.customers.toLocaleString()}
|
||||
</div>
|
||||
<Card className="col-span-1 border-border/40 bg-background/50 backdrop-blur-sm hover:bg-background/60 transition-colors">
|
||||
<CardContent className="pt-4 flex flex-col items-center justify-center text-center">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">Customers</div>
|
||||
<div className="text-2xl font-bold">{growthData.cumulative.customers.toLocaleString()}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
Vendors
|
||||
</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{growthData.cumulative.vendors.toLocaleString()}
|
||||
</div>
|
||||
<Card className="col-span-1 border-border/40 bg-background/50 backdrop-blur-sm hover:bg-background/60 transition-colors">
|
||||
<CardContent className="pt-4 flex flex-col items-center justify-center text-center">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">Vendors</div>
|
||||
<div className="text-2xl font-bold">{growthData.cumulative.vendors.toLocaleString()}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
Products
|
||||
</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{growthData.cumulative.products.toLocaleString()}
|
||||
</div>
|
||||
<Card className="col-span-1 border-border/40 bg-background/50 backdrop-blur-sm hover:bg-background/60 transition-colors">
|
||||
<CardContent className="pt-4 flex flex-col items-center justify-center text-center">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">Products</div>
|
||||
<div className="text-2xl font-bold">{growthData.cumulative.products.toLocaleString()}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
Avg Order Value
|
||||
</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(growthData.cumulative.avgOrderValue)}
|
||||
</div>
|
||||
<Card className="col-span-1 border-purple-500/20 bg-purple-500/5 backdrop-blur-sm hover:bg-purple-500/10 transition-colors">
|
||||
<CardContent className="pt-4 flex flex-col items-center justify-center text-center">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">Avg Order Value</div>
|
||||
<div className="text-2xl font-bold text-purple-600">{formatCurrency(growthData.cumulative.avgOrderValue)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Monthly Revenue & Orders Chart */}
|
||||
<Card>
|
||||
<Card className="lg:col-span-2 border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Monthly Revenue & Orders</CardTitle>
|
||||
<CardTitle className="bg-gradient-to-r from-green-600 to-emerald-500 bg-clip-text text-transparent w-fit">Monthly Revenue & Orders</CardTitle>
|
||||
<CardDescription>
|
||||
Platform performance by month since launch
|
||||
</CardDescription>
|
||||
@@ -1290,36 +1267,52 @@ export default function AdminAnalytics() {
|
||||
}))}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="formattedMonth" tick={{ fontSize: 12 }} />
|
||||
<YAxis yAxisId="left" tick={{ fontSize: 12 }} />
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" opacity={0.4} />
|
||||
<XAxis dataKey="formattedMonth" tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }} axisLine={false} tickLine={false} />
|
||||
<YAxis yAxisId="left" tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }} axisLine={false} tickLine={false} />
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={{ fontSize: 12 }}
|
||||
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
|
||||
axisLine={false} tickLine={false}
|
||||
tickFormatter={(value) =>
|
||||
`£${(value / 1000).toFixed(0)}k`
|
||||
}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: 'hsl(var(--muted)/0.4)' }}
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload?.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-background border border-border p-3 rounded-lg shadow-lg">
|
||||
<p className="font-medium mb-2">{data.month}</p>
|
||||
<p className="text-sm text-blue-600">
|
||||
Orders: {data.orders.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-green-600">
|
||||
Revenue: {formatCurrency(data.revenue)}
|
||||
</p>
|
||||
<p className="text-sm text-purple-600">
|
||||
Customers: {data.customers.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-amber-600">
|
||||
New Vendors: {data.newVendors}
|
||||
</p>
|
||||
<div className="bg-background/95 border border-border/50 p-4 rounded-xl shadow-xl backdrop-blur-md">
|
||||
<p className="font-semibold mb-3 border-b border-border/50 pb-2">{data.month}</p>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500" /> Orders
|
||||
</span>
|
||||
<span className="font-medium">{data.orders.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" /> Revenue
|
||||
</span>
|
||||
<span className="font-medium text-green-600">{formatCurrency(data.revenue)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-purple-500" /> Customers
|
||||
</span>
|
||||
<span className="font-medium">{data.customers.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-amber-500" /> New Vendors
|
||||
</span>
|
||||
<span className="font-medium">{data.newVendors}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1331,7 +1324,9 @@ export default function AdminAnalytics() {
|
||||
dataKey="orders"
|
||||
fill="#3b82f6"
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={50}
|
||||
name="Orders"
|
||||
fillOpacity={0.8}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
@@ -1339,14 +1334,15 @@ export default function AdminAnalytics() {
|
||||
dataKey="revenue"
|
||||
stroke="#10b981"
|
||||
strokeWidth={3}
|
||||
dot={{ fill: "#10b981", r: 4 }}
|
||||
dot={{ fill: "#10b981", r: 4, strokeWidth: 2, stroke: "hsl(var(--background))" }}
|
||||
activeDot={{ r: 6, strokeWidth: 0 }}
|
||||
name="Revenue"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-80 text-muted-foreground">
|
||||
<div className="flex items-center justify-center h-80 text-muted-foreground bg-muted/20 rounded-lg border border-dashed border-border/50">
|
||||
No growth data available
|
||||
</div>
|
||||
)}
|
||||
@@ -1354,19 +1350,20 @@ export default function AdminAnalytics() {
|
||||
</Card>
|
||||
|
||||
{/* Customer Segments Pie Chart */}
|
||||
<Card>
|
||||
<Card className="lg:col-span-1 border-border/40 bg-background/50 backdrop-blur-sm shadow-sm h-full flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle>Customer Segments</CardTitle>
|
||||
<CardDescription>Breakdown by purchase behavior</CardDescription>
|
||||
<CardTitle className="bg-gradient-to-r from-amber-600 to-orange-500 bg-clip-text text-transparent w-fit">Customer Segments</CardTitle>
|
||||
<CardDescription>By purchase behavior</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="flex-1 flex flex-col justify-center">
|
||||
{growthLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div>
|
||||
</div>
|
||||
) : growthData?.customers ? (
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<>
|
||||
<div className="h-64 min-w-0">
|
||||
<ResponsiveContainer key={growthData?.customers ? 'ready' : 'loading'} width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={[
|
||||
@@ -1393,14 +1390,13 @@ export default function AdminAnalytics() {
|
||||
]}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
innerRadius={60}
|
||||
outerRadius={90}
|
||||
paddingAngle={3}
|
||||
dataKey="value"
|
||||
label={({ name, percent }) =>
|
||||
`${name}: ${(percent * 100).toFixed(0)}%`
|
||||
}
|
||||
label={({ percent }) => `${(percent * 100).toFixed(0)}%`}
|
||||
labelLine={false}
|
||||
stroke="none"
|
||||
>
|
||||
{[
|
||||
{ color: SEGMENT_COLORS.new },
|
||||
@@ -1420,18 +1416,18 @@ export default function AdminAnalytics() {
|
||||
data.name.split(" ")[0].toLowerCase()
|
||||
];
|
||||
return (
|
||||
<div className="bg-background border border-border p-3 rounded-lg shadow-lg">
|
||||
<p className="font-medium">{data.name}</p>
|
||||
<div className="bg-background/95 border border-border/50 p-3 rounded-lg shadow-xl backdrop-blur-md">
|
||||
<p className="font-semibold mb-1">{data.name}</p>
|
||||
<p className="text-sm">
|
||||
Count: {data.value.toLocaleString()}
|
||||
Count: <span className="font-mono">{data.value.toLocaleString()}</span>
|
||||
</p>
|
||||
{details && (
|
||||
<>
|
||||
<p className="text-sm text-green-600">
|
||||
<p className="text-sm text-green-600 font-medium">
|
||||
Revenue:{" "}
|
||||
{formatCurrency(details.totalRevenue)}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Avg Orders: {details.avgOrderCount}
|
||||
</p>
|
||||
</>
|
||||
@@ -1445,101 +1441,104 @@ export default function AdminAnalytics() {
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||
No customer data available
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Segment Stats */}
|
||||
{growthData?.customers && (
|
||||
<div className="grid grid-cols-2 gap-2 mt-4">
|
||||
<div className="p-2 rounded bg-blue-500/10 text-center">
|
||||
<div className="text-lg font-bold text-blue-600">
|
||||
<div className="grid grid-cols-2 gap-3 mt-4">
|
||||
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-500/10 text-center hover:bg-blue-500/15 transition-colors">
|
||||
<div className="text-xl font-bold text-blue-600">
|
||||
{growthData.customers.segments.new}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">New</div>
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">New</div>
|
||||
</div>
|
||||
<div className="p-2 rounded bg-green-500/10 text-center">
|
||||
<div className="text-lg font-bold text-green-600">
|
||||
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/10 text-center hover:bg-green-500/15 transition-colors">
|
||||
<div className="text-xl font-bold text-green-600">
|
||||
{growthData.customers.segments.returning}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Returning
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 rounded bg-amber-500/10 text-center">
|
||||
<div className="text-lg font-bold text-amber-600">
|
||||
<div className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/10 text-center hover:bg-amber-500/15 transition-colors">
|
||||
<div className="text-xl font-bold text-amber-600">
|
||||
{growthData.customers.segments.loyal}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Loyal</div>
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Loyal</div>
|
||||
</div>
|
||||
<div className="p-2 rounded bg-purple-500/10 text-center">
|
||||
<div className="text-lg font-bold text-purple-600">
|
||||
<div className="p-3 rounded-lg bg-purple-500/10 border border-purple-500/10 text-center hover:bg-purple-500/15 transition-colors">
|
||||
<div className="text-xl font-bold text-purple-600">
|
||||
{growthData.customers.segments.vip}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">VIP</div>
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">VIP</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground bg-muted/20 rounded-lg border border-dashed border-border/50">
|
||||
No customer data available
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Monthly Growth Table */}
|
||||
{growthData?.monthly && growthData.monthly.length > 0 && (
|
||||
<Card>
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
||||
<CardHeader>
|
||||
<CardTitle>Monthly Breakdown</CardTitle>
|
||||
<CardTitle className="bg-gradient-to-r from-foreground to-foreground/70 bg-clip-text text-transparent w-fit">Monthly Breakdown</CardTitle>
|
||||
<CardDescription>Detailed metrics by month</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="rounded-md border border-border/40 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left p-2 font-medium">Month</th>
|
||||
<th className="text-right p-2 font-medium">Orders</th>
|
||||
<th className="text-right p-2 font-medium">Revenue</th>
|
||||
<th className="text-right p-2 font-medium">
|
||||
<tr className="bg-muted/40 border-b border-border/40">
|
||||
<th className="text-left p-3 font-medium text-muted-foreground">Month</th>
|
||||
<th className="text-right p-3 font-medium text-muted-foreground">Orders</th>
|
||||
<th className="text-right p-3 font-medium text-muted-foreground">Revenue</th>
|
||||
<th className="text-right p-3 font-medium text-muted-foreground">
|
||||
Customers
|
||||
</th>
|
||||
<th className="text-right p-2 font-medium">
|
||||
<th className="text-right p-3 font-medium text-muted-foreground">
|
||||
Avg Order
|
||||
</th>
|
||||
<th className="text-right p-2 font-medium">
|
||||
<th className="text-right p-3 font-medium text-muted-foreground">
|
||||
New Vendors
|
||||
</th>
|
||||
<th className="text-right p-2 font-medium">
|
||||
<th className="text-right p-3 font-medium text-muted-foreground">
|
||||
New Customers
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{growthData.monthly.map((month) => (
|
||||
<tbody className="divide-y divide-border/30">
|
||||
{growthData.monthly.map((month, i) => (
|
||||
<tr
|
||||
key={month.month}
|
||||
className="border-b hover:bg-muted/50"
|
||||
className="hover:bg-muted/30 transition-colors animate-in fade-in slide-in-from-bottom-2 duration-500 fill-mode-backwards"
|
||||
style={{ animationDelay: `${i * 50}ms` }}
|
||||
>
|
||||
<td className="p-2 font-medium">
|
||||
<td className="p-3 font-medium">
|
||||
{new Date(month.month + "-01").toLocaleDateString(
|
||||
"en-GB",
|
||||
{ month: "long", year: "numeric" },
|
||||
)}
|
||||
</td>
|
||||
<td className="text-right p-2">
|
||||
<td className="text-right p-3">
|
||||
<div className="inline-flex items-center px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-600 text-xs font-medium">
|
||||
{month.orders.toLocaleString()}
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-right p-2 text-green-600">
|
||||
<td className="text-right p-3 text-green-600 font-semibold">
|
||||
{formatCurrency(month.revenue)}
|
||||
</td>
|
||||
<td className="text-right p-2">
|
||||
<td className="text-right p-3 text-muted-foreground">
|
||||
{month.customers.toLocaleString()}
|
||||
</td>
|
||||
<td className="text-right p-2">
|
||||
<td className="text-right p-3 text-muted-foreground">
|
||||
{formatCurrency(month.avgOrderValue)}
|
||||
</td>
|
||||
<td className="text-right p-2">{month.newVendors}</td>
|
||||
<td className="text-right p-2">
|
||||
<td className="text-right p-3 text-muted-foreground">{month.newVendors}</td>
|
||||
<td className="text-right p-3 text-muted-foreground">
|
||||
{month.newCustomers}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,47 +1,104 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { fetchClient } from "@/lib/api-client";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Copy, Check, Ticket, Loader2, RefreshCw } from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
export default function InviteVendorCard() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [code, setCode] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
async function handleInvite() {
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
setCode(null);
|
||||
setCopied(false);
|
||||
try {
|
||||
const res = await fetchClient<{ code: string }>("/admin/invitations", { method: "POST" });
|
||||
setMessage("Invitation created");
|
||||
setCode(res.code);
|
||||
toast({
|
||||
title: "Invitation Created",
|
||||
description: "New vendor invitation code generated successfully.",
|
||||
});
|
||||
} catch (e: any) {
|
||||
setMessage(e?.message || "Failed to send invitation");
|
||||
toast({
|
||||
title: "Error",
|
||||
description: e?.message || "Failed to generate invitation",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const copyToClipboard = () => {
|
||||
if (!code) return;
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
toast({
|
||||
title: "Copied",
|
||||
description: "Invitation code copied to clipboard",
|
||||
});
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-background p-4 h-full min-h-[200px]">
|
||||
<h2 className="font-medium">Invite Vendor</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">Generate a new invitation code</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<button
|
||||
onClick={handleInvite}
|
||||
className="inline-flex items-center rounded-md bg-primary px-3 py-2 text-sm text-primary-foreground disabled:opacity-60"
|
||||
disabled={loading}
|
||||
<Card className="h-full border-border/40 bg-background/50 backdrop-blur-sm shadow-sm flex flex-col">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-medium flex items-center gap-2">
|
||||
<Ticket className="h-4 w-4 text-primary" />
|
||||
Invite Vendor
|
||||
</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Generate a one-time invitation code.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col justify-center gap-4">
|
||||
{code ? (
|
||||
<div className="space-y-3 animate-in fade-in zoom-in-95 duration-300">
|
||||
<div className="p-3 rounded-md bg-muted/50 border border-border/50 text-center relative group">
|
||||
<span className="font-mono text-xl font-bold tracking-widest text-primary">{code}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1 h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={copyToClipboard}
|
||||
>
|
||||
{loading ? "Generating..." : "Generate Invite Code"}
|
||||
</button>
|
||||
{message && <p className="text-xs text-muted-foreground">{message}</p>}
|
||||
{code && (
|
||||
<div className="text-sm">
|
||||
Code: <span className="font-mono px-1.5 py-0.5 rounded bg-muted">{code}</span>
|
||||
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
Share this code with the new vendor. It expires in 7 days.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-2 text-sm text-muted-foreground/80">
|
||||
Click generate to create a new code.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="pt-0">
|
||||
<Button
|
||||
onClick={handleInvite}
|
||||
disabled={loading}
|
||||
className="w-full bg-primary/90 hover:bg-primary shadow-sm"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{code ? <RefreshCw className="mr-2 h-4 w-4" /> : <Ticket className="mr-2 h-4 w-4" />}
|
||||
{code ? "Generate Another" : "Generate Code"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Search, Filter, Eye, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
import { List } from 'react-window';
|
||||
import OrderDetailsModal from "./OrderDetailsModal";
|
||||
|
||||
interface Order {
|
||||
@@ -215,7 +215,52 @@ export default function OrdersTable({ orders, enableModal = true }: OrdersTableP
|
||||
itemSize={60}
|
||||
className="border"
|
||||
>
|
||||
{Row}
|
||||
{({ index, style }: { index: number; style: React.CSSProperties }) => {
|
||||
const order = currentOrders[index];
|
||||
if (!order) return null;
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">{order.orderId}</TableCell>
|
||||
<TableCell>{order.userId}</TableCell>
|
||||
<TableCell>{order.vendorUsername || 'N/A'}</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">
|
||||
{order.items.length > 0 ? order.items[0].name : 'No items'}
|
||||
</TableCell>
|
||||
<TableCell>£{order.total.toFixed(2)}</TableCell>
|
||||
<TableCell>
|
||||
<div className={`px-3 py-1 rounded-full border ${getStatusStyle(order.status)}`}>
|
||||
{order.status.toUpperCase()}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>N/A</TableCell>
|
||||
<TableCell>{new Date(order.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{enableModal ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleViewOrder(order.orderId)}
|
||||
title="View order details (Admin only)"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled
|
||||
title="Order details modal disabled"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</List>
|
||||
) : (
|
||||
<TableBody>
|
||||
|
||||
@@ -46,8 +46,8 @@ import { DateRangePicker } from "@/components/ui/date-picker";
|
||||
import { DateRange } from "react-day-picker";
|
||||
import { addDays, startOfDay, endOfDay } from "date-fns";
|
||||
import type { DateRange as ProfitDateRange } from "@/lib/services/profit-analytics-service";
|
||||
|
||||
// Lazy load chart components - already handled individually below
|
||||
import { MotionWrapper } from "@/components/ui/motion-wrapper";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
const RevenueChart = dynamic(() => import("./RevenueChart"), {
|
||||
loading: () => <ChartSkeleton />,
|
||||
@@ -195,53 +195,116 @@ export default function AnalyticsDashboard({
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with Privacy Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-10 pb-20">
|
||||
{/* Header with Integrated Toolbar */}
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
Analytics Dashboard
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Overview of your store's performance and metrics.
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Real-time performance metrics and AI-driven insights.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
<div className="flex items-center gap-2 p-1.5 glass-morphism rounded-2xl border border-white/5 shadow-2xl backdrop-blur-xl ring-1 ring-white/5">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setHideNumbers(!hideNumbers)}
|
||||
className="flex items-center gap-2"
|
||||
className={`flex items-center gap-2 rounded-xl transition-all font-medium px-4 ${hideNumbers ? 'bg-primary text-primary-foreground shadow-lg' : 'hover:bg-white/5'}`}
|
||||
>
|
||||
{hideNumbers ? (
|
||||
<>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
Show Numbers
|
||||
<span className="hidden sm:inline">Numbers Hidden</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="h-4 w-4" />
|
||||
Hide Numbers
|
||||
<Eye className="h-4 w-4 text-primary/70" />
|
||||
<span className="hidden sm:inline">Hide Numbers</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="w-px h-5 bg-white/10 mx-1" />
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={refreshData}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2"
|
||||
className="flex items-center gap-2 rounded-xl hover:bg-white/5 font-medium px-4"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
|
||||
className={`h-4 w-4 ${isLoading ? "animate-spin text-primary" : "text-primary/70"}`}
|
||||
/>
|
||||
Refresh
|
||||
<span className="hidden sm:inline">Refresh Data</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MotionWrapper className="space-y-12">
|
||||
{/* Analytics Tabs Setup */}
|
||||
<Tabs defaultValue="overview" className="space-y-10">
|
||||
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-6 pb-2">
|
||||
<TabsList className="bg-transparent h-auto p-0 flex flex-wrap gap-2 lg:gap-4">
|
||||
<TabsTrigger
|
||||
value="overview"
|
||||
className="px-4 py-2 rounded-xl data-[state=active]:bg-primary/5 data-[state=active]:text-primary data-[state=active]:shadow-none border border-transparent data-[state=active]:border-primary/20 flex items-center gap-2 transition-all"
|
||||
>
|
||||
<Activity className="h-4 w-4" />
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="financials"
|
||||
className="px-4 py-2 rounded-xl data-[state=active]:bg-primary/5 data-[state=active]:text-primary data-[state=active]:shadow-none border border-transparent data-[state=active]:border-primary/20 flex items-center gap-2 transition-all"
|
||||
>
|
||||
<DollarSign className="h-4 w-4" />
|
||||
Financials
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="performance"
|
||||
className="px-4 py-2 rounded-xl data-[state=active]:bg-primary/5 data-[state=active]:text-primary data-[state=active]:shadow-none border border-transparent data-[state=active]:border-primary/20 flex items-center gap-2 transition-all"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
Performance
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="ai"
|
||||
className="px-4 py-2 rounded-xl data-[state=active]:bg-primary/5 data-[state=active]:text-primary data-[state=active]:shadow-none border border-transparent data-[state=active]:border-primary/20 flex items-center gap-2 transition-all"
|
||||
>
|
||||
<Calculator className="h-4 w-4" />
|
||||
AI Insights
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Contextual Time Range Selector */}
|
||||
<div className="flex items-center gap-3 bg-muted/30 p-1 rounded-xl border border-border/20">
|
||||
<span className="text-xs font-semibold text-muted-foreground px-2">Range</span>
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
<SelectTrigger className="w-[130px] h-8 border-none bg-transparent shadow-none focus:ring-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl border-border/40">
|
||||
<SelectItem value="7">Last 7 days</SelectItem>
|
||||
<SelectItem value="30">Last 30 days</SelectItem>
|
||||
<SelectItem value="90">Last 90 days</SelectItem>
|
||||
<SelectItem value="180">Last 180 days</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent value="overview" className="space-y-10 outline-none">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||
className="space-y-10"
|
||||
>
|
||||
{/* Key Metrics Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 lg:gap-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{isLoading
|
||||
? [...Array(4)].map((_, i) => <MetricsCardSkeleton key={i} />)
|
||||
: metrics.map((metric) => (
|
||||
@@ -249,149 +312,88 @@ export default function AnalyticsDashboard({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Completion Rate Card */}
|
||||
<Card>
|
||||
<Card className="lg:col-span-1 glass-morphism premium-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
Order Completion Rate
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Activity className="h-5 w-5 text-emerald-500" />
|
||||
Order Completion
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Percentage of orders that have been successfully completed
|
||||
Successfully processed orders
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-12 w-16 bg-muted/20 rounded animate-pulse" />
|
||||
<div className="flex-1">
|
||||
<div className="w-full bg-muted/20 rounded-full h-2 animate-pulse" />
|
||||
</div>
|
||||
<div className="h-6 w-16 bg-muted/20 rounded animate-pulse" />
|
||||
</div>
|
||||
<Skeleton className="h-24 w-full rounded-2xl" />
|
||||
) : (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-3xl font-bold">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-4xl font-extrabold tracking-tight">
|
||||
{hideNumbers ? "**%" : `${data.orders.completionRate}%`}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="w-full bg-secondary rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: hideNumbers
|
||||
? "0%"
|
||||
: `${data.orders.completionRate}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
<Badge variant="outline" className="bg-emerald-500/10 text-emerald-600 border-emerald-500/20 px-3 py-1 text-xs font-bold">
|
||||
{hideNumbers
|
||||
? "** / **"
|
||||
: `${data.orders.completed} / ${data.orders.total}`}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="w-full bg-secondary/50 rounded-full h-3 overflow-hidden border border-border/20">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: hideNumbers ? "0%" : `${data.orders.completionRate}%` }}
|
||||
transition={{ duration: 1, ease: "circOut" }}
|
||||
className="bg-gradient-to-r from-emerald-500 to-teal-400 h-full rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Time Period Selector */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Time Period</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Revenue, Profit, and Orders tabs use time filtering. Products and
|
||||
Customers show all-time data.
|
||||
</p>
|
||||
</div>
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7">Last 7 days</SelectItem>
|
||||
<SelectItem value="30">Last 30 days</SelectItem>
|
||||
<SelectItem value="90">Last 90 days</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Analytics Tabs */}
|
||||
<div className="space-y-6">
|
||||
<Tabs defaultValue="growth" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-3 lg:grid-cols-7">
|
||||
<TabsTrigger value="growth" className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
Growth
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="revenue" className="flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Revenue
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="profit" className="flex items-center gap-2">
|
||||
<Calculator className="h-4 w-4" />
|
||||
Profit
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="products" className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
Products
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="customers" className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Customers
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="orders" className="flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
Orders
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="predictions" className="flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Predictions
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="growth" className="space-y-6">
|
||||
{/* Growth Chart Snippet (Simplified) */}
|
||||
<div className="lg:col-span-2 min-w-0">
|
||||
<Suspense fallback={<ChartSkeleton />}>
|
||||
<GrowthAnalyticsChart hideNumbers={hideNumbers} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="revenue" className="space-y-6">
|
||||
<TabsContent value="financials" className="space-y-8 outline-none">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="grid grid-cols-1 xl:grid-cols-2 gap-8"
|
||||
>
|
||||
<Suspense fallback={<ChartSkeleton />}>
|
||||
<div className="min-w-0">
|
||||
<RevenueChart timeRange={timeRange} hideNumbers={hideNumbers} />
|
||||
</div>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="profit" className="space-y-6">
|
||||
{/* Date Range Selector for Profit Calculator */}
|
||||
<Card>
|
||||
<div className="space-y-8">
|
||||
<Card className="glass-morphism">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Date Range</CardTitle>
|
||||
<CardTitle className="text-lg">Profit Range</CardTitle>
|
||||
<CardDescription>
|
||||
Select a custom date range for profit calculations
|
||||
Custom date selection for analysis
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:items-center">
|
||||
<DateRangePicker
|
||||
dateRange={profitDateRange}
|
||||
onDateRangeChange={setProfitDateRange}
|
||||
placeholder="Select date range"
|
||||
showPresets={true}
|
||||
className="w-full sm:w-auto"
|
||||
className="w-full"
|
||||
/>
|
||||
{profitDateRange?.from && profitDateRange?.to && (
|
||||
<div className="text-sm text-muted-foreground flex items-center">
|
||||
<span>
|
||||
{profitDateRange.from.toLocaleDateString()} -{" "}
|
||||
{profitDateRange.to.toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Suspense fallback={<ChartSkeleton />}>
|
||||
<ProfitAnalyticsChart
|
||||
dateRange={
|
||||
@@ -405,33 +407,47 @@ export default function AnalyticsDashboard({
|
||||
hideNumbers={hideNumbers}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</motion.div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="products" className="space-y-6">
|
||||
<TabsContent value="performance" className="space-y-8 outline-none">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="grid grid-cols-1 lg:grid-cols-2 gap-8"
|
||||
>
|
||||
<Suspense fallback={<ChartSkeleton />}>
|
||||
<div className="min-w-0">
|
||||
<ProductPerformanceChart />
|
||||
</div>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="customers" className="space-y-6">
|
||||
<Suspense fallback={<ChartSkeleton />}>
|
||||
<CustomerInsightsChart />
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="orders" className="space-y-6">
|
||||
<div className="space-y-8 min-w-0">
|
||||
<Suspense fallback={<ChartSkeleton />}>
|
||||
<OrderAnalyticsChart timeRange={timeRange} />
|
||||
</Suspense>
|
||||
<Suspense fallback={<ChartSkeleton />}>
|
||||
<CustomerInsightsChart />
|
||||
</Suspense>
|
||||
</div>
|
||||
</motion.div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="predictions" className="space-y-6">
|
||||
<TabsContent value="ai" className="space-y-8 outline-none">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="min-w-0"
|
||||
>
|
||||
<Suspense fallback={<ChartSkeleton />}>
|
||||
<PredictionsChart timeRange={parseInt(timeRange)} />
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</MotionWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Area,
|
||||
} from "recharts";
|
||||
|
||||
interface GrowthAnalyticsChartProps {
|
||||
@@ -182,7 +183,7 @@ export default function GrowthAnalyticsChart({
|
||||
</div>
|
||||
) : growthData?.monthly && growthData.monthly.length > 0 ? (
|
||||
<div className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ResponsiveContainer key={growthData?.monthly?.length || 0} width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={growthData.monthly.map((m) => ({
|
||||
...m,
|
||||
@@ -195,18 +196,36 @@ export default function GrowthAnalyticsChart({
|
||||
}))}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="formattedMonth" tick={{ fontSize: 12 }} />
|
||||
<YAxis yAxisId="left" tick={{ fontSize: 12 }} />
|
||||
<defs>
|
||||
<linearGradient id="colorRevenueGrowth" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorOrdersGrowth" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.6} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
|
||||
<XAxis
|
||||
dataKey="formattedMonth"
|
||||
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis yAxisId="left" tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} />
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={{ fontSize: 12 }}
|
||||
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(value) =>
|
||||
hideNumbers ? "***" : `£${(value / 1000).toFixed(0)}k`
|
||||
}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "transparent", stroke: "hsl(var(--muted-foreground))", strokeDasharray: "3 3" }}
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload?.length) {
|
||||
const data = payload[0].payload;
|
||||
@@ -240,21 +259,27 @@ export default function GrowthAnalyticsChart({
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
<Area
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="orders"
|
||||
fill="#3b82f6"
|
||||
radius={[4, 4, 0, 0]}
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, strokeWidth: 0 }}
|
||||
name="Orders"
|
||||
fill="url(#colorOrdersGrowth)"
|
||||
/>
|
||||
<Line
|
||||
<Area
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
stroke="#10b981"
|
||||
strokeWidth={3}
|
||||
dot={{ fill: "#10b981", r: 4 }}
|
||||
dot={false}
|
||||
activeDot={{ r: 5, strokeWidth: 0 }}
|
||||
name="Revenue"
|
||||
fill="url(#colorRevenueGrowth)"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
interface MetricsCardProps {
|
||||
title: string;
|
||||
@@ -24,43 +25,91 @@ export default function MetricsCard({
|
||||
const getTrendIcon = () => {
|
||||
switch (trend) {
|
||||
case "up":
|
||||
return <TrendingUp className="h-4 w-4 text-green-500" />;
|
||||
return <TrendingUp className="h-4 w-4 text-emerald-500" />;
|
||||
case "down":
|
||||
return <TrendingDown className="h-4 w-4 text-red-500" />;
|
||||
return <TrendingDown className="h-4 w-4 text-rose-500" />;
|
||||
default:
|
||||
return <Minus className="h-4 w-4 text-gray-500" />;
|
||||
return <Minus className="h-4 w-4 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendColor = () => {
|
||||
switch (trend) {
|
||||
case "up":
|
||||
return "text-green-600";
|
||||
return "text-emerald-400 bg-emerald-500/10 border-emerald-500/10";
|
||||
case "down":
|
||||
return "text-red-600";
|
||||
return "text-rose-400 bg-rose-500/10 border-rose-500/10";
|
||||
default:
|
||||
return "text-gray-600";
|
||||
return "text-blue-400 bg-blue-500/10 border-blue-500/10";
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryColor = () => {
|
||||
const t = title.toLowerCase();
|
||||
if (t.includes("revenue") || t.includes("profit")) return "amber";
|
||||
if (t.includes("order")) return "blue";
|
||||
if (t.includes("customer")) return "indigo";
|
||||
if (t.includes("product") || t.includes("inventory")) return "purple";
|
||||
return "primary";
|
||||
}
|
||||
|
||||
const categoryColor = getCategoryColor();
|
||||
|
||||
const getIconContainerColor = () => {
|
||||
switch (categoryColor) {
|
||||
case "amber": return "bg-amber-500/15 text-amber-500 border-amber-500/20";
|
||||
case "blue": return "bg-blue-500/15 text-blue-500 border-blue-500/20";
|
||||
case "indigo": return "bg-indigo-500/15 text-indigo-500 border-indigo-500/20";
|
||||
case "purple": return "bg-purple-500/15 text-purple-500 border-purple-500/20";
|
||||
default: return "bg-primary/15 text-primary border-primary/20";
|
||||
}
|
||||
}
|
||||
|
||||
const getBadgeColor = () => {
|
||||
switch (categoryColor) {
|
||||
case "amber": return "bg-amber-500/10 text-amber-400/80 border-amber-500/20";
|
||||
case "blue": return "bg-blue-500/10 text-blue-400/80 border-blue-500/20";
|
||||
case "indigo": return "bg-indigo-500/10 text-indigo-400/80 border-indigo-500/20";
|
||||
case "purple": return "bg-purple-500/10 text-purple-400/80 border-purple-500/20";
|
||||
default: return "bg-primary/10 text-primary/60 border-primary/20";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
<motion.div
|
||||
whileHover={{ y: -4 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 20 }}
|
||||
>
|
||||
<Card className="glass-morphism premium-card relative overflow-hidden group border-white/5">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4 relative z-10">
|
||||
<CardTitle className="text-[10px] font-bold tracking-wider text-white/40 uppercase">
|
||||
{title}
|
||||
</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<div className={`p-2.5 rounded-2xl border ${getIconContainerColor()} transition-all duration-300 group-hover:scale-105`}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{description}</p>
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
|
||||
<CardContent className="relative z-10">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-3xl font-bold text-white">
|
||||
{value}
|
||||
</div>
|
||||
<p className="text-[11px] text-white/40">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-6 pt-5">
|
||||
<div className={`flex items-center gap-1.5 px-3 py-1 rounded-full border ${getTrendColor()} transition-all duration-300`}>
|
||||
{getTrendIcon()}
|
||||
<span className={`text-xs ${getTrendColor()}`}>
|
||||
{trendValue}
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide">
|
||||
{trend === "up" ? "+" : ""}{trendValue}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -26,10 +26,15 @@ import {
|
||||
RefreshCw,
|
||||
Calendar,
|
||||
BarChart3,
|
||||
Sparkles,
|
||||
Brain,
|
||||
Layers,
|
||||
Zap,
|
||||
Info,
|
||||
Download,
|
||||
} from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import CountUp from "react-countup";
|
||||
import {
|
||||
getPredictionsOverviewWithStore,
|
||||
getStockPredictionsWithStore,
|
||||
@@ -46,6 +51,23 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartsTooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
|
||||
interface PredictionsChartProps {
|
||||
timeRange?: number;
|
||||
@@ -57,21 +79,52 @@ export default function PredictionsChart({
|
||||
const [predictions, setPredictions] = useState<PredictionsOverview | null>(
|
||||
null,
|
||||
);
|
||||
const [baselinePredictions, setBaselinePredictions] = useState<PredictionsOverview | null>(
|
||||
null,
|
||||
);
|
||||
// Batch data holds all pre-cached predictions for instant switching
|
||||
const [batchData, setBatchData] = useState<{
|
||||
[horizon: string]: {
|
||||
[simulationFactor: string]: PredictionsOverview;
|
||||
};
|
||||
} | null>(null);
|
||||
const [stockPredictions, setStockPredictions] =
|
||||
useState<StockPredictionsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isSimulating, setIsSimulating] = useState(false);
|
||||
const [daysAhead, setDaysAhead] = useState(7);
|
||||
const [activeTab, setActiveTab] = useState<"overview" | "stock">("overview");
|
||||
const [simulationFactor, setSimulationFactor] = useState(0);
|
||||
const [committedSimulationFactor, setCommittedSimulationFactor] = useState(0);
|
||||
const { toast } = useToast();
|
||||
|
||||
const fetchPredictions = async () => {
|
||||
// Fetch all predictions in batch (for instant client-side switching)
|
||||
const fetchBatchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [overview, stock] = await Promise.all([
|
||||
getPredictionsOverviewWithStore(daysAhead, timeRange),
|
||||
const { getBatchPredictionsWithStore } = await import("@/lib/services/analytics-service");
|
||||
const [batchResponse, stock] = await Promise.all([
|
||||
getBatchPredictionsWithStore(timeRange),
|
||||
getStockPredictionsWithStore(timeRange),
|
||||
]);
|
||||
|
||||
if (batchResponse.success && batchResponse.predictions) {
|
||||
setBatchData(batchResponse.predictions);
|
||||
// Set initial predictions from batch
|
||||
const horizonData = batchResponse.predictions[daysAhead.toString()];
|
||||
if (horizonData) {
|
||||
const baseline = horizonData["0"];
|
||||
if (baseline) {
|
||||
setBaselinePredictions(baseline);
|
||||
setPredictions(baseline);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback to single request if batch not available
|
||||
const overview = await getPredictionsOverviewWithStore(daysAhead, timeRange, 0);
|
||||
setBaselinePredictions(overview);
|
||||
setPredictions(overview);
|
||||
}
|
||||
setStockPredictions(stock);
|
||||
} catch (error) {
|
||||
console.error("Error fetching predictions:", error);
|
||||
@@ -85,9 +138,53 @@ export default function PredictionsChart({
|
||||
}
|
||||
};
|
||||
|
||||
// Switch predictions from batch data (no API call!)
|
||||
const switchPredictions = useCallback((horizon: number, simFactor: number) => {
|
||||
if (!batchData) return;
|
||||
|
||||
const horizonData = batchData[horizon.toString()];
|
||||
if (!horizonData) return;
|
||||
|
||||
// Simulation factor is stored as decimal (e.g., 0.1 for 10%)
|
||||
const simKey = (simFactor / 100).toString();
|
||||
const newPrediction = horizonData[simKey];
|
||||
|
||||
if (newPrediction) {
|
||||
setPredictions(newPrediction);
|
||||
if (simFactor === 0) {
|
||||
setBaselinePredictions(newPrediction);
|
||||
}
|
||||
}
|
||||
}, [batchData]);
|
||||
|
||||
// Fetch batch data on initial load or when timeRange changes
|
||||
useEffect(() => {
|
||||
fetchPredictions();
|
||||
}, [daysAhead, timeRange]);
|
||||
fetchBatchData();
|
||||
setCommittedSimulationFactor(0);
|
||||
setSimulationFactor(0);
|
||||
}, [timeRange]);
|
||||
|
||||
// Auto-adjust daysAhead if it exceeds historical timeRange
|
||||
useEffect(() => {
|
||||
if (daysAhead > timeRange) {
|
||||
setDaysAhead(timeRange);
|
||||
}
|
||||
}, [timeRange, daysAhead]);
|
||||
|
||||
// Switch predictions when daysAhead changes (instant, from batch)
|
||||
useEffect(() => {
|
||||
if (batchData) {
|
||||
switchPredictions(daysAhead, committedSimulationFactor);
|
||||
}
|
||||
}, [daysAhead, batchData, switchPredictions]);
|
||||
|
||||
// Switch predictions when simulation factor changes (instant, from batch)
|
||||
useEffect(() => {
|
||||
if (batchData) {
|
||||
switchPredictions(daysAhead, committedSimulationFactor);
|
||||
}
|
||||
}, [committedSimulationFactor, batchData, switchPredictions]);
|
||||
|
||||
|
||||
const getConfidenceColor = (confidence: string) => {
|
||||
switch (confidence) {
|
||||
@@ -119,6 +216,58 @@ export default function PredictionsChart({
|
||||
}
|
||||
};
|
||||
|
||||
// Combine baseline and simulated data for overlay chart
|
||||
const chartData = useMemo(() => {
|
||||
if (!predictions?.sales?.dailyPredictions) return [];
|
||||
|
||||
const baselineData = baselinePredictions?.sales?.dailyPredictions || [];
|
||||
const simulatedDailyData = predictions?.sales?.dailyPredictions || [];
|
||||
|
||||
return simulatedDailyData.map((d: any, idx: number) => ({
|
||||
...d,
|
||||
formattedDate: format(new Date(d.date), "MMM d"),
|
||||
simulated: d.predicted,
|
||||
baseline: baselineData[idx]?.predicted ?? d.predicted,
|
||||
orders: d.predictedOrders || 0,
|
||||
}));
|
||||
}, [predictions, baselinePredictions]);
|
||||
|
||||
// Keep simulatedData for export compatibility
|
||||
const simulatedData = chartData;
|
||||
|
||||
const handleExportCSV = () => {
|
||||
if (!simulatedData.length) return;
|
||||
|
||||
// Create CSV headers
|
||||
const headers = ["Date", "Baseline Revenue", "Simulated Revenue", "Orders", "Confidence"];
|
||||
|
||||
// Create CSV rows
|
||||
const rows = simulatedData.map(d => [
|
||||
format(new Date(d.date), "yyyy-MM-dd"),
|
||||
d.baseline?.toFixed(2) || "",
|
||||
d.simulated?.toFixed(2) || "",
|
||||
d.orders || "",
|
||||
predictions?.sales?.confidence || "unknown"
|
||||
]);
|
||||
|
||||
// Combine headers and rows
|
||||
const csvContent = [
|
||||
headers.join(","),
|
||||
...rows.map(row => row.join(","))
|
||||
].join("\n");
|
||||
|
||||
// Create download link
|
||||
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.setAttribute("href", url);
|
||||
link.setAttribute("download", `predictions_export_${format(new Date(), "yyyyMMdd")}.csv`);
|
||||
link.style.visibility = "hidden";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -163,12 +312,9 @@ export default function PredictionsChart({
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
Predictions & Forecasting
|
||||
{predictions.sales.aiModel?.used && (
|
||||
<Sparkles className="h-4 w-4 text-purple-500" />
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{predictions.sales.aiModel?.used
|
||||
{predictions?.sales?.aiModel?.used
|
||||
? "AI neural network + statistical models for sales, demand, and inventory"
|
||||
: "AI-powered predictions for sales, demand, and inventory"}
|
||||
</CardDescription>
|
||||
@@ -183,18 +329,31 @@ export default function PredictionsChart({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7">7 days</SelectItem>
|
||||
<SelectItem value="14">14 days</SelectItem>
|
||||
<SelectItem value="30">30 days</SelectItem>
|
||||
<SelectItem value="14" disabled={timeRange < 14}>
|
||||
14 days {timeRange < 14 && "(Needs 14d history)"}
|
||||
</SelectItem>
|
||||
<SelectItem value="30" disabled={timeRange < 30}>
|
||||
30 days {timeRange < 30 && "(Needs 30d history)"}
|
||||
</SelectItem>
|
||||
<SelectItem value="60" disabled={timeRange < 60}>
|
||||
60 days {timeRange < 60 && "(Needs 60d history)"}
|
||||
</SelectItem>
|
||||
<SelectItem value="90" disabled={timeRange < 90}>
|
||||
90 days {timeRange < 90 && "(Needs 90d history)"}
|
||||
</SelectItem>
|
||||
<SelectItem value="180" disabled={timeRange < 180}>
|
||||
180 days {timeRange < 180 && "(Needs 180d history)"}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={fetchPredictions}
|
||||
disabled={loading}
|
||||
onClick={fetchBatchData}
|
||||
disabled={loading || isSimulating}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}
|
||||
className={`h-4 w-4 ${loading || isSimulating ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -222,6 +381,7 @@ export default function PredictionsChart({
|
||||
<div className="space-y-6">
|
||||
{/* Sales Predictions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
@@ -230,197 +390,408 @@ export default function PredictionsChart({
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{predictions.sales.predicted !== null ? (
|
||||
{predictions?.sales?.predicted !== null && predictions?.sales?.predicted !== undefined ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-2xl font-bold">
|
||||
{formatGBP(predictions.sales.predicted)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="text-2xl font-bold w-fit cursor-help">
|
||||
<CountUp
|
||||
end={predictions?.sales?.predicted || 0}
|
||||
duration={1.5}
|
||||
separator=","
|
||||
decimals={2}
|
||||
prefix="£"
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="z-[100]">
|
||||
<p>Predicted daily average revenue for the next {daysAhead} days</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex cursor-help">
|
||||
<Badge
|
||||
className={getConfidenceColor(
|
||||
predictions.sales.confidence,
|
||||
predictions?.sales?.confidence || "low",
|
||||
)}
|
||||
>
|
||||
{getConfidenceLabel(predictions.sales.confidence)} Confidence
|
||||
{predictions.sales.confidenceScore !== undefined && (
|
||||
{getConfidenceLabel(predictions?.sales?.confidence || "low")} Confidence
|
||||
{predictions?.sales?.confidenceScore !== undefined && (
|
||||
<span className="ml-1 opacity-75">
|
||||
({Math.round(predictions.sales.confidenceScore * 100)}%)
|
||||
({Math.round((predictions?.sales?.confidenceScore || 0) * 100)}%)
|
||||
</span>
|
||||
)}
|
||||
</Badge>
|
||||
{predictions.sales.aiModel?.used && (
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="z-[100]">
|
||||
<p>Based on data consistency, historical accuracy, and model agreement</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{predictions?.sales?.aiModel?.used && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex cursor-help">
|
||||
<Badge variant="outline" className="bg-purple-500/10 text-purple-700 dark:text-purple-400 border-purple-500/30">
|
||||
🤖 AI Powered
|
||||
{predictions.sales.aiModel.modelAccuracy !== undefined && (
|
||||
{predictions?.sales?.aiModel?.modelAccuracy !== undefined && (
|
||||
<span className="ml-1 opacity-75">
|
||||
({Math.round(predictions.sales.aiModel.modelAccuracy * 100)}%)
|
||||
({Math.round((predictions?.sales?.aiModel?.modelAccuracy || 0) * 100)}%)
|
||||
</span>
|
||||
)}
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="z-[100] max-w-xs">
|
||||
<div className="space-y-1.5">
|
||||
<p className="font-semibold">Deep Learning Ensemble Model</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This percentage indicates how well the AI has learned your specific sales patterns.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground border-t pt-1.5">
|
||||
Scores above 90% are optimal—100% is avoided to prevent "memorizing" the past and ensure the model remains flexible for future shifts.
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{predictions.sales.trend && (
|
||||
{predictions?.sales?.trend && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex cursor-help">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
predictions.sales.trend.direction === "up"
|
||||
predictions?.sales?.trend?.direction === "up"
|
||||
? "text-green-600 border-green-600"
|
||||
: predictions.sales.trend.direction === "down"
|
||||
: predictions?.sales?.trend?.direction === "down"
|
||||
? "text-red-600 border-red-600"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{predictions.sales.trend.direction === "up" && (
|
||||
{predictions?.sales?.trend?.direction === "up" && (
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
{predictions.sales.trend.direction === "down" && (
|
||||
{predictions?.sales?.trend?.direction === "down" && (
|
||||
<TrendingDown className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
{predictions.sales.trend.direction === "up"
|
||||
{predictions?.sales?.trend?.direction === "up"
|
||||
? "Trending Up"
|
||||
: predictions.sales.trend.direction === "down"
|
||||
: predictions?.sales?.trend?.direction === "down"
|
||||
? "Trending Down"
|
||||
: "Stable"}
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="z-[100]">
|
||||
<p>Direction of the recent sales trend (slope analysis)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Next {daysAhead} days
|
||||
</span>
|
||||
</div>
|
||||
{predictions.sales.predictedOrders && (
|
||||
{predictions?.sales?.predictedOrders && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
~{Math.round(predictions.sales.predictedOrders)}{" "}
|
||||
~{Math.round(predictions?.sales?.predictedOrders || 0)}{" "}
|
||||
orders
|
||||
</div>
|
||||
)}
|
||||
{predictions.sales.confidenceIntervals && (
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div>
|
||||
Range: {formatGBP(predictions.sales.confidenceIntervals.lower)} -{" "}
|
||||
{formatGBP(predictions.sales.confidenceIntervals.upper)}
|
||||
</div>
|
||||
<div className="text-xs opacity-75">
|
||||
95% confidence interval
|
||||
</div>
|
||||
{predictions.sales.confidenceIntervals.confidenceScore !== undefined && (
|
||||
<div className="flex gap-3 text-xs opacity-75 mt-2 pt-2 border-t">
|
||||
{predictions.sales.confidenceIntervals.avgModelAccuracy !== undefined && (
|
||||
<div>
|
||||
Model Accuracy: {Math.round(predictions.sales.confidenceIntervals.avgModelAccuracy * 100)}%
|
||||
</div>
|
||||
)}
|
||||
{predictions.sales.confidenceIntervals.modelAgreement !== undefined && (
|
||||
<div>
|
||||
Agreement: {Math.round(predictions.sales.confidenceIntervals.modelAgreement * 100)}%
|
||||
</div>
|
||||
)}
|
||||
{predictions.sales.confidenceIntervals.dataConsistency !== undefined && (
|
||||
<div>
|
||||
Data Quality: {Math.round(predictions.sales.confidenceIntervals.dataConsistency * 100)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!predictions.sales.confidenceIntervals &&
|
||||
predictions.sales.minPrediction &&
|
||||
predictions.sales.maxPrediction && (
|
||||
{!predictions?.sales?.confidenceIntervals &&
|
||||
predictions?.sales?.minPrediction &&
|
||||
predictions?.sales?.maxPrediction && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Range: {formatGBP(predictions.sales.minPrediction)} -{" "}
|
||||
{formatGBP(predictions.sales.maxPrediction)}
|
||||
Range: {formatGBP(predictions?.sales?.minPrediction || 0)} -{" "}
|
||||
{formatGBP(predictions?.sales?.maxPrediction || 0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{predictions.sales.message ||
|
||||
{predictions?.sales?.message ||
|
||||
"Insufficient data for prediction"}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Model Intelligence Card */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
Demand Prediction
|
||||
<Brain className="h-4 w-4" />
|
||||
Model Intelligence
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="h-3 w-3 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="z-[100]">
|
||||
<p>Technical details about the active prediction model</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{predictions.demand.predictedDaily !== null ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-2xl font-bold">
|
||||
{predictions.demand.predictedDaily.toFixed(1)} units/day
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Architecture</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-sm font-medium flex items-center gap-1 cursor-help">
|
||||
<Layers className="h-3 w-3 text-purple-500" />
|
||||
Hybrid Ensemble (Deep Learning)
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="z-[100]">
|
||||
<p>Combines LSTM Neural Networks with Statistical Methods (Holt-Winters, ARIMA)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
className={getConfidenceColor(
|
||||
predictions.demand.confidence,
|
||||
)}
|
||||
>
|
||||
{getConfidenceLabel(predictions.demand.confidence)} Confidence
|
||||
</Badge>
|
||||
</div>
|
||||
{predictions.demand.predictedWeekly && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
~{predictions.demand.predictedWeekly.toFixed(0)} units/week
|
||||
{stockPredictions?.predictions && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Features</span>
|
||||
<span className="text-xs font-medium bg-secondary px-2 py-1 rounded-md">
|
||||
Multi-Feature Enabled
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{predictions.demand.predictedMonthly && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
~{predictions.demand.predictedMonthly.toFixed(0)} units/month
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Optimization</span>
|
||||
<span className="text-sm font-medium flex items-center gap-1">
|
||||
<Zap className="h-3 w-3 text-amber-500" />
|
||||
Performance Tuned
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{predictions.demand.confidenceIntervals && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Range: {predictions.demand.confidenceIntervals.lower.toFixed(1)} -{" "}
|
||||
{predictions.demand.confidenceIntervals.upper.toFixed(1)} units/day
|
||||
<div className="pt-2 border-t text-xs text-muted-foreground">
|
||||
Model automatically retrains with new sales data.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{predictions.demand.message ||
|
||||
"Insufficient data for prediction"}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<Alert className="bg-yellow-500/10 border-yellow-500/30 text-yellow-700 dark:text-yellow-400">
|
||||
<AlertTriangle className="h-4 w-4 stroke-yellow-700 dark:stroke-yellow-400" />
|
||||
<AlertTitle>Prediction Accuracy Warning</AlertTitle>
|
||||
<AlertDescription>
|
||||
These predictions are estimates based on historical sales data. Actual results may vary due to external factors, market conditions, and unforeseen events. Use these insights as a guide, not a guarantee.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Daily Predictions Chart */}
|
||||
{predictions.sales.dailyPredictions &&
|
||||
predictions.sales.dailyPredictions.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Daily Revenue Forecast
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{predictions.sales.dailyPredictions.map((day) => (
|
||||
<div
|
||||
key={day.day}
|
||||
className="flex items-center justify-between p-2 rounded-lg border"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
{predictions?.sales?.dailyPredictions &&
|
||||
predictions?.sales?.dailyPredictions.length > 0 && (
|
||||
<Card className="glass-morphism border-primary/10 overflow-hidden">
|
||||
<CardHeader className="pb-6 bg-muted/5">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
Day {day.day}
|
||||
<CardTitle className="text-xl font-bold flex items-center gap-2 tracking-tight">
|
||||
<Zap className="h-5 w-5 text-amber-500 fill-amber-500/20" />
|
||||
Scenario Lab
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground/80 font-medium">
|
||||
Adjust variables to see how traffic shifts impact your bottom line.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{format(new Date(day.date), "MMM d, yyyy")}
|
||||
|
||||
<div className="flex items-center gap-4 bg-black/40 p-2.5 rounded-2xl border border-white/5 shadow-2xl backdrop-blur-md">
|
||||
<div className="flex flex-col items-start min-w-[150px]">
|
||||
<div className="flex items-center gap-1.5 mb-1 ml-1">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-primary/40">
|
||||
Traffic Simulation
|
||||
</span>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3 w-3 text-primary/30 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[200px] z-[110] bg-black border-white/10 text-white p-2">
|
||||
<p className="text-[11px] leading-relaxed">
|
||||
Simulate traffic growth or decline to see how it might impact your future revenue and order volume.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<Slider
|
||||
value={[simulationFactor]}
|
||||
min={-50}
|
||||
max={50}
|
||||
step={10}
|
||||
onValueChange={(val) => setSimulationFactor(val[0])}
|
||||
onValueCommit={(val) => setCommittedSimulationFactor(val[0])}
|
||||
className="w-full flex-1"
|
||||
/>
|
||||
<Badge variant="outline" className={`ml-2 min-w-[50px] text-center font-bold border-2 ${simulationFactor > 0 ? "text-emerald-400 border-emerald-500/30 bg-emerald-500/10" : simulationFactor < 0 ? "text-rose-400 border-rose-500/30 bg-rose-500/10" : "text-primary/60"}`}>
|
||||
{simulationFactor > 0 ? "+" : ""}{simulationFactor}%
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(simulationFactor !== 0 || committedSimulationFactor !== 0) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 hover:bg-white/10 rounded-xl transition-all"
|
||||
onClick={() => {
|
||||
setSimulationFactor(0);
|
||||
setCommittedSimulationFactor(0);
|
||||
}}
|
||||
title="Reset Scenario"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 text-primary/70" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={handleExportCSV} className="rounded-xl border-white/10 hover:bg-white/5 font-bold px-4">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export Forecast
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-8">
|
||||
{/* Legend / Key */}
|
||||
<div className="flex items-center gap-8 mb-8 text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-[#8884d8]" />
|
||||
Baseline Forecast
|
||||
</div>
|
||||
{committedSimulationFactor !== 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-[#10b981]" />
|
||||
Simulated Scenario
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-80 w-full relative">
|
||||
{isSimulating && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/60 backdrop-blur-sm z-20 transition-all rounded-xl">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="relative">
|
||||
<RefreshCw className="h-10 w-10 animate-spin text-primary" />
|
||||
<Zap className="h-4 w-4 text-amber-500 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-primary animate-pulse">Running Neural Simulation...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ResponsiveContainer key={`${daysAhead}-${timeRange}`} width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={chartData}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="colorBaseline" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="#8884d8"
|
||||
stopOpacity={0.3}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="#8884d8"
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id="colorSimulated" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="#10b981"
|
||||
stopOpacity={0.5}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="#10b981"
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border) / 0.4)" />
|
||||
<XAxis
|
||||
dataKey="formattedDate"
|
||||
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
dy={15}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `£${value}`}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
cursor={{ fill: "transparent", stroke: "hsl(var(--primary) / 0.05)", strokeWidth: 40 }}
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload?.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-[#050505] p-5 rounded-2xl shadow-2xl border border-white/10 backdrop-blur-2xl ring-1 ring-white/5">
|
||||
<p className="font-bold text-[10px] uppercase tracking-wide text-muted-foreground mb-4 border-b border-white/5 pb-3 px-1">{data.formattedDate}</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-10">
|
||||
<span className="text-[10px] font-bold text-muted-foreground/60 uppercase tracking-wide">Baseline:</span>
|
||||
<span className="text-sm font-bold text-[#8884d8] tabular-nums">{formatGBP(data.baseline)}</span>
|
||||
</div>
|
||||
{committedSimulationFactor !== 0 && (
|
||||
<div className="flex items-center justify-between gap-10">
|
||||
<span className="text-[10px] font-bold text-muted-foreground/60 uppercase tracking-wide">Simulated:</span>
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-sm font-bold text-emerald-400 tabular-nums">{formatGBP(data.simulated)}</span>
|
||||
<span className={`text-[10px] font-bold mt-0.5 ${data.simulated > data.baseline ? 'text-emerald-500' : 'text-rose-500'}`}>
|
||||
{data.simulated > data.baseline ? '▴' : '▾'} {Math.abs(((data.simulated / data.baseline - 1) * 100)).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-10 pt-3 border-t border-white/5">
|
||||
<span className="text-[10px] font-bold text-muted-foreground/60 uppercase tracking-widest">Est. Orders:</span>
|
||||
<span className="text-sm font-bold tabular-nums">
|
||||
{Math.round(data.orders)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-semibold">
|
||||
{formatGBP(day.predicted)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
{/* Always show baseline */}
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="baseline"
|
||||
stroke="#8884d8"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorBaseline)"
|
||||
strokeWidth={3}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, strokeWidth: 0, fill: "#8884d8" }}
|
||||
/>
|
||||
{/* Show simulated line when simulation is active */}
|
||||
{committedSimulationFactor !== 0 && (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="simulated"
|
||||
stroke="#10b981"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorSimulated)"
|
||||
strokeWidth={3}
|
||||
strokeDasharray="5 5"
|
||||
dot={false}
|
||||
activeDot={{ r: 6, strokeWidth: 3, stroke: "#fff", fill: "#10b981" }}
|
||||
/>
|
||||
)}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -559,6 +930,6 @@ export default function PredictionsChart({
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Card >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
PieChart,
|
||||
Calculator,
|
||||
Info,
|
||||
AlertTriangle
|
||||
AlertTriangle,
|
||||
Package
|
||||
} from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { formatGBP } from "@/utils/format";
|
||||
@@ -28,6 +29,7 @@ export default function ProfitAnalyticsChart({ timeRange, dateRange, hideNumbers
|
||||
const [data, setData] = useState<ProfitOverview | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [imageErrors, setImageErrors] = useState<Record<string, boolean>>({});
|
||||
const { toast } = useToast();
|
||||
|
||||
const maskValue = (value: string): string => {
|
||||
@@ -237,8 +239,7 @@ export default function ProfitAnalyticsChart({ timeRange, dateRange, hideNumbers
|
||||
<CardTitle className="text-sm font-medium">Total Profit</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className={`text-2xl font-bold flex items-center gap-2 ${
|
||||
profitDirection ? 'text-green-600' : 'text-red-600'
|
||||
<div className={`text-2xl font-bold flex items-center gap-2 ${profitDirection ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{profitDirection ? (
|
||||
<TrendingUp className="h-5 w-5" />
|
||||
@@ -327,15 +328,34 @@ export default function ProfitAnalyticsChart({ timeRange, dateRange, hideNumbers
|
||||
return (
|
||||
<div
|
||||
key={product.productId}
|
||||
className="flex items-center justify-between p-4 border rounded-lg"
|
||||
className="flex items-center justify-between p-4 border rounded-lg transition-colors hover:bg-muted/30"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary font-semibold text-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="w-12 h-12 rounded-full overflow-hidden border-2 border-background shadow-sm bg-muted flex items-center justify-center">
|
||||
{product.image && !imageErrors[product.productId] ? (
|
||||
<img
|
||||
src={`/api/products/${product.productId}/image`}
|
||||
alt={product.productName}
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => {
|
||||
setImageErrors(prev => ({ ...prev, [product.productId]: true }));
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-full h-full bg-primary/10 text-primary font-bold text-lg">
|
||||
{product.productName.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute -top-1 -left-1 w-5 h-5 bg-primary text-[10px] text-primary-foreground flex items-center justify-center rounded-full font-bold border-2 border-background shadow-sm">
|
||||
{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{product.productName}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="font-semibold">{product.productName}</p>
|
||||
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Package className="h-3 w-3" />
|
||||
{product.totalQuantitySold} units sold
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TrendingUp, DollarSign } from "lucide-react";
|
||||
import { getRevenueTrendsWithStore, type RevenueData } from "@/lib/services/analytics-service";
|
||||
import { formatGBP } from "@/utils/format";
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, AreaChart, Area } from 'recharts';
|
||||
import { ChartSkeleton } from './SkeletonLoaders';
|
||||
|
||||
interface RevenueChartProps {
|
||||
@@ -175,29 +175,42 @@ export default function RevenueChart({ timeRange, hideNumbers = false }: Revenue
|
||||
<div className="space-y-6">
|
||||
{/* Chart */}
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<ResponsiveContainer key={timeRange} width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#2563eb" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#2563eb" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
|
||||
<XAxis
|
||||
dataKey="formattedDate"
|
||||
tick={{ fontSize: 12 }}
|
||||
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
minTickGap={30}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(value) => hideNumbers ? '***' : `£${(value / 1000).toFixed(0)}k`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar
|
||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: "transparent", stroke: "hsl(var(--muted-foreground))", strokeDasharray: "3 3" }} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
fill="#2563eb"
|
||||
stroke="#1d4ed8"
|
||||
strokeWidth={1}
|
||||
radius={[2, 2, 0, 0]}
|
||||
stroke="#2563eb"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorRevenue)"
|
||||
strokeWidth={2}
|
||||
activeDot={{ r: 4, strokeWidth: 0 }}
|
||||
/>
|
||||
</BarChart>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,9 +20,9 @@ export function AnimatedStatsSection({ stats }: AnimatedStatsProps) {
|
||||
return (
|
||||
<div className="mx-auto grid max-w-5xl grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<div className="group relative overflow-hidden flex flex-col items-center text-center rounded-xl bg-gradient-to-b from-zinc-900/50 to-black border border-[#1C1C1C] p-6">
|
||||
<div className="absolute inset-0 bg-[#D53F8C]/5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="absolute inset-0 bg-indigo-500/5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="relative flex flex-col items-center">
|
||||
<Package className="h-6 w-6 text-[#D53F8C]" />
|
||||
<Package className="h-6 w-6 text-indigo-500" />
|
||||
<div className="mt-4 text-3xl font-bold text-white">
|
||||
<AnimatedCounter
|
||||
value={stats.orders.completed}
|
||||
@@ -40,9 +40,9 @@ export function AnimatedStatsSection({ stats }: AnimatedStatsProps) {
|
||||
</div>
|
||||
|
||||
<div className="group relative overflow-hidden flex flex-col items-center text-center rounded-xl bg-gradient-to-b from-zinc-900/50 to-black border border-[#1C1C1C] p-6">
|
||||
<div className="absolute inset-0 bg-[#D53F8C]/5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="absolute inset-0 bg-indigo-500/5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="relative flex flex-col items-center">
|
||||
<Users className="h-6 w-6 text-[#D53F8C]" />
|
||||
<Users className="h-6 w-6 text-indigo-500" />
|
||||
<div className="mt-4 text-3xl font-bold text-white">
|
||||
<AnimatedCounter
|
||||
value={stats.vendors.total}
|
||||
@@ -60,9 +60,9 @@ export function AnimatedStatsSection({ stats }: AnimatedStatsProps) {
|
||||
</div>
|
||||
|
||||
<div className="group relative overflow-hidden flex flex-col items-center text-center rounded-xl bg-gradient-to-b from-zinc-900/50 to-black border border-[#1C1C1C] p-6">
|
||||
<div className="absolute inset-0 bg-[#D53F8C]/5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="absolute inset-0 bg-indigo-500/5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="relative flex flex-col items-center">
|
||||
<CreditCard className="h-6 w-6 text-[#D53F8C]" />
|
||||
<CreditCard className="h-6 w-6 text-indigo-500" />
|
||||
<div className="mt-4 text-3xl font-bold text-white">
|
||||
<AnimatedCounter
|
||||
value={stats.transactions.volume}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { toast } from "sonner";
|
||||
import { ArrowLeft, Send, RefreshCw, File, FileText, Image as ImageIcon, Download } from "lucide-react";
|
||||
import { getCookie, clientFetch } from "@/lib/api";
|
||||
import { ImageViewerModal } from "@/components/modals/image-viewer-modal";
|
||||
import Image from "next/image";
|
||||
import BuyerOrderInfo from "./BuyerOrderInfo";
|
||||
import { useIsTouchDevice } from "@/hooks/use-mobile";
|
||||
import { useChromebookScroll, useSmoothScrollToBottom } from "@/hooks/use-chromebook-scroll";
|
||||
@@ -55,7 +56,6 @@ const getFileNameFromUrl = (url: string): string => {
|
||||
return 'attachment';
|
||||
}
|
||||
|
||||
// URL decode the filename (handle spaces and special characters)
|
||||
try {
|
||||
fileName = decodeURIComponent(fileName);
|
||||
} catch (e) {
|
||||
@@ -608,8 +608,8 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
return (
|
||||
<div className="flex flex-col h-screen w-full relative">
|
||||
<div className={cn(
|
||||
"border-b bg-card z-10 flex items-center justify-between",
|
||||
isTouchDevice ? "h-20 px-3" : "h-16 px-4"
|
||||
"border-b bg-background/80 backdrop-blur-md z-10 flex items-center justify-between sticky top-0",
|
||||
isTouchDevice ? "h-16 px-4" : "h-16 px-6"
|
||||
)}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
@@ -680,11 +680,10 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[90%] rounded-lg chat-message",
|
||||
isTouchDevice ? "p-4" : "p-3",
|
||||
"max-w-[85%] rounded-2xl p-4 shadow-sm",
|
||||
msg.sender === "vendor"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted"
|
||||
? "bg-primary text-primary-foreground rounded-tr-none"
|
||||
: "bg-muted text-muted-foreground rounded-tl-none border border-border/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
@@ -742,13 +741,23 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
<Download className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
<img
|
||||
<Image
|
||||
src={attachment}
|
||||
alt={fileName}
|
||||
width={400}
|
||||
height={300}
|
||||
placeholder="blur"
|
||||
blurDataURL="/placeholder-image.svg"
|
||||
quality={85}
|
||||
className="max-w-full max-h-60 object-contain cursor-pointer hover:opacity-90 transition-opacity rounded"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = "/placeholder-image.svg";
|
||||
// Fallback to placeholder on error
|
||||
const target = e.target as any;
|
||||
if (target && target.src !== "/placeholder-image.svg") {
|
||||
target.src = "/placeholder-image.svg";
|
||||
}
|
||||
}}
|
||||
onClick={() => handleImageClick(attachment, messageIndex, attachmentIndex)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -785,47 +794,36 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"absolute bottom-0 left-0 right-0 border-t border-border bg-background",
|
||||
isTouchDevice ? "p-3" : "p-4",
|
||||
"pb-[env(safe-area-inset-bottom)]"
|
||||
"absolute bottom-0 left-0 right-0 px-4 pt-10 bg-gradient-to-t from-background via-background/95 to-transparent",
|
||||
"pb-[calc(1.5rem+env(safe-area-inset-bottom))]"
|
||||
)}>
|
||||
<form onSubmit={handleSendMessage} className="flex space-x-2">
|
||||
<form onSubmit={handleSendMessage} className="flex space-x-2 max-w-4xl mx-auto items-end">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Type your message..."
|
||||
disabled={sending}
|
||||
className={cn(
|
||||
"flex-1 text-base transition-all duration-200 form-input",
|
||||
isTouchDevice ? "min-h-[52px] text-lg" : "min-h-[48px]"
|
||||
"w-full pl-4 pr-12 py-3 bg-background/50 border-border/50 backdrop-blur-sm shadow-sm focus:ring-primary/20 transition-all duration-200 rounded-full",
|
||||
isTouchDevice ? "min-h-[52px] text-lg" : "min-h-[48px] text-base"
|
||||
)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
aria-label="Message input"
|
||||
aria-describedby="message-help"
|
||||
role="textbox"
|
||||
autoComplete="off"
|
||||
spellCheck="true"
|
||||
maxLength={2000}
|
||||
style={{
|
||||
WebkitAppearance: 'none',
|
||||
borderRadius: '0.5rem'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={sending || !message.trim()}
|
||||
aria-label={sending ? "Sending message" : "Send message"}
|
||||
className={cn(
|
||||
"transition-all duration-200 btn-chromebook",
|
||||
isTouchDevice ? "min-h-[52px] min-w-[52px]" : "min-h-[48px] min-w-[48px]"
|
||||
"rounded-full shadow-md transition-all duration-200 bg-primary hover:bg-primary/90 text-primary-foreground",
|
||||
isTouchDevice ? "h-[52px] w-[52px]" : "h-[48px] w-[48px]"
|
||||
)}
|
||||
style={{
|
||||
WebkitAppearance: 'none',
|
||||
touchAction: 'manipulation'
|
||||
}}
|
||||
>
|
||||
{sending ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
{sending ? <RefreshCw className="h-5 w-5 animate-spin" /> : <Send className="h-5 w-5 ml-0.5" />}
|
||||
</Button>
|
||||
</form>
|
||||
<div id="message-help" className="sr-only">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
@@ -30,7 +32,8 @@ import {
|
||||
CheckCheck,
|
||||
Search,
|
||||
Volume2,
|
||||
VolumeX
|
||||
VolumeX,
|
||||
MoreHorizontal
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
@@ -261,117 +264,167 @@ export default function ChatTable() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Messages</h2>
|
||||
<p className="text-muted-foreground">Manage your customer conversations</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="h-9"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
<span className="ml-2">Refresh</span>
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleCreateChat} size="sm">
|
||||
<Button onClick={handleCreateChat} size="sm" className="h-9">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">Customer</TableHead>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[300px] pl-6">Customer</TableHead>
|
||||
<TableHead>Last Activity</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
<TableHead className="text-right pr-6">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<AnimatePresence>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="h-24 text-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
|
||||
<TableCell colSpan={4} className="h-32 text-center">
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="text-sm text-muted-foreground">Loading conversations...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : chats.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="h-24 text-center">
|
||||
<TableCell colSpan={4} className="h-32 text-center">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<MessageCircle className="h-8 w-8 text-muted-foreground mb-2" />
|
||||
<p className="text-muted-foreground">No chats found</p>
|
||||
<MessageCircle className="h-10 w-10 text-muted-foreground/50 mb-3" />
|
||||
<p className="text-muted-foreground font-medium">No chats found</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Start a new conversation to communicate with customers</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
chats.map((chat) => (
|
||||
<TableRow
|
||||
chats.map((chat, index) => (
|
||||
<motion.tr
|
||||
key={chat._id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="group cursor-pointer hover:bg-muted/30 transition-colors border-b border-border/50 last:border-0"
|
||||
onClick={() => handleChatClick(chat._id)}
|
||||
style={{ display: 'table-row' }} // Essential for table layout
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Avatar>
|
||||
<AvatarFallback>
|
||||
<User className="h-4t w-4" />
|
||||
<TableCell className="pl-6 py-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="relative">
|
||||
<Avatar className="h-10 w-10 border-2 border-background shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<AvatarFallback className={cn(
|
||||
"font-medium text-xs",
|
||||
unreadCounts.chatCounts[chat._id] > 0 ? "bg-primary/20 text-primary" : "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{chat.buyerId.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{chat.telegramUsername ? `@${chat.telegramUsername}` : 'Customer'}
|
||||
{unreadCounts.chatCounts[chat._id] > 0 && (
|
||||
<span className="absolute -top-1 -right-1 h-3 w-3 bg-primary rounded-full ring-2 ring-background animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div>
|
||||
<div className="font-semibold text-sm flex items-center gap-2">
|
||||
{chat.telegramUsername ? (
|
||||
<span className="text-blue-400">@{chat.telegramUsername}</span>
|
||||
) : (
|
||||
<span className="text-foreground">Customer {chat.buyerId.slice(0, 6)}...</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5 font-mono">
|
||||
ID: {chat.buyerId}
|
||||
</div>
|
||||
{chat.orderId && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-[10px] text-muted-foreground mt-1 flex items-center gap-1 bg-muted/50 px-1.5 py-0.5 rounded w-fit">
|
||||
<span className="w-1 h-1 rounded-full bg-zinc-400" />
|
||||
Order #{chat.orderId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="py-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
{formatDistanceToNow(new Date(chat.lastUpdated), { addSuffix: true })}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(chat.lastUpdated).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="py-4">
|
||||
{unreadCounts.chatCounts[chat._id] > 0 ? (
|
||||
<Badge variant="destructive" className="ml-1">
|
||||
{unreadCounts.chatCounts[chat._id]} new
|
||||
</Badge>
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-primary/10 text-primary text-xs font-medium border border-primary/20 shadow-[0_0_10px_rgba(var(--primary),0.1)]">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
|
||||
</span>
|
||||
{unreadCounts.chatCounts[chat._id]} new message{unreadCounts.chatCounts[chat._id] !== 1 ? 's' : ''}
|
||||
</div>
|
||||
) : (
|
||||
<Badge variant="outline">Read</Badge>
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-muted text-muted-foreground text-xs font-medium border border-border">
|
||||
<CheckCheck className="h-3 w-3" />
|
||||
All caught up
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<TableCell className="text-right pr-6 py-4">
|
||||
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleChatClick(chat._id);
|
||||
}}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
<ArrowRightCircle className="h-4 w-4" />
|
||||
<span className="sr-only">View</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</motion.tr>
|
||||
))
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pagination controls */}
|
||||
{!loading && chats.length > 0 && (
|
||||
{
|
||||
!loading && chats.length > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {chats.length} of {totalChats} chats
|
||||
|
||||
@@ -2,16 +2,29 @@
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import OrderStats from "./order-stats"
|
||||
import QuickActions from "./quick-actions"
|
||||
import RecentActivity from "./recent-activity"
|
||||
import { WidgetSettings } from "./widget-settings"
|
||||
import { WidgetSettingsModal } from "./widget-settings-modal"
|
||||
import { DashboardEditor, EditDashboardButton } from "./dashboard-editor"
|
||||
import { DraggableWidget } from "./draggable-widget"
|
||||
import RevenueWidget from "./revenue-widget"
|
||||
import LowStockWidget from "./low-stock-widget"
|
||||
import RecentCustomersWidget from "./recent-customers-widget"
|
||||
import PendingChatsWidget from "./pending-chats-widget"
|
||||
import { getGreeting } from "@/lib/utils/general"
|
||||
import { statsConfig } from "@/config/dashboard"
|
||||
import { getRandomQuote } from "@/config/quotes"
|
||||
import type { OrderStatsData } from "@/lib/types"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { ShoppingCart, RefreshCcw } from "lucide-react"
|
||||
import { ShoppingCart, RefreshCcw, ArrowRight } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { clientFetch } from "@/lib/api"
|
||||
import { motion } from "framer-motion"
|
||||
import Link from "next/link"
|
||||
import { useWidgetLayout, WidgetConfig } from "@/hooks/useWidgetLayout"
|
||||
|
||||
interface ContentProps {
|
||||
username: string
|
||||
@@ -21,7 +34,7 @@ interface ContentProps {
|
||||
interface TopProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
price: number | number[];
|
||||
image: string;
|
||||
count: number;
|
||||
revenue: number;
|
||||
@@ -33,71 +46,78 @@ export default function Content({ username, orderStats }: ContentProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
const { widgets, toggleWidget, moveWidget, reorderWidgets, resetLayout, isWidgetVisible, updateWidgetSettings, updateWidgetColSpan } = useWidgetLayout();
|
||||
const [configuredWidget, setConfiguredWidget] = useState<WidgetConfig | null>(null);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
|
||||
// Initialize with a random quote from the quotes config
|
||||
const [randomQuote, setRandomQuote] = useState(getRandomQuote());
|
||||
// Initialize with a default quote to match server-side rendering, then randomize on client
|
||||
const [randomQuote, setRandomQuote] = useState({ text: "Loading wisdom...", author: "..." });
|
||||
|
||||
useEffect(() => {
|
||||
// Determine quote on client-side to avoid hydration mismatch
|
||||
setRandomQuote(getRandomQuote());
|
||||
}, []);
|
||||
|
||||
// Fetch top-selling products data
|
||||
const fetchTopProducts = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const data = await clientFetch('/orders/top-products');
|
||||
setTopProducts(data);
|
||||
} catch (err) {
|
||||
console.error("Error fetching top products:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch top products");
|
||||
toast({
|
||||
title: "Error loading top products",
|
||||
description: "Please try refreshing the page",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize greeting and fetch data on component mount
|
||||
useEffect(() => {
|
||||
setGreeting(getGreeting());
|
||||
fetchTopProducts();
|
||||
}, []);
|
||||
|
||||
// Retry fetching top products data
|
||||
const handleRetry = () => {
|
||||
fetchTopProducts();
|
||||
};
|
||||
|
||||
const renderWidget = (widget: WidgetConfig) => {
|
||||
switch (widget.id) {
|
||||
case "quick-actions":
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">
|
||||
{greeting}, {username}!
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 italic text-sm">
|
||||
"{randomQuote.text}" — <span className="font-medium">{randomQuote.author}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Order Statistics */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 lg:gap-6">
|
||||
{statsConfig.map((stat) => (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Quick Actions</h2>
|
||||
<QuickActions />
|
||||
</section>
|
||||
);
|
||||
case "overview":
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Overview</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{statsConfig.map((stat, index) => (
|
||||
<OrderStats
|
||||
key={stat.title}
|
||||
title={stat.title}
|
||||
value={orderStats[stat.key as keyof OrderStatsData].toLocaleString()}
|
||||
value={orderStats[stat.key as keyof OrderStatsData]?.toLocaleString() || "0"}
|
||||
icon={stat.icon}
|
||||
index={index}
|
||||
filterStatus={stat.filterStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Best Selling Products Section */}
|
||||
<div className="mt-8">
|
||||
<Card>
|
||||
</section>
|
||||
);
|
||||
case "recent-activity":
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Recent Activity</h2>
|
||||
<RecentActivity />
|
||||
</section>
|
||||
);
|
||||
case "top-products":
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Top Performing Listings</h2>
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div>
|
||||
<CardTitle>Your Best Selling Products</CardTitle>
|
||||
<CardDescription>Products with the highest sales from your store</CardDescription>
|
||||
<CardTitle>Top Performing Listings</CardTitle>
|
||||
<CardDescription>Your products with the highest sales volume</CardDescription>
|
||||
</div>
|
||||
{error && (
|
||||
<Button
|
||||
@@ -111,46 +131,44 @@ export default function Content({ username, orderStats }: ContentProps) {
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
// Loading skeleton
|
||||
<div className="space-y-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-12 w-12 rounded-md" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<div className="ml-auto text-right">
|
||||
<Skeleton className="h-4 w-16 ml-auto" />
|
||||
<Skeleton className="h-4 w-16 ml-auto mt-2" />
|
||||
<Skeleton className="h-14 w-14 rounded-xl" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-3 w-1/4" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
// Error state
|
||||
<div className="py-8 text-center">
|
||||
<div className="text-muted-foreground mb-4">Failed to load products</div>
|
||||
<div className="py-12 text-center">
|
||||
<div className="text-muted-foreground mb-4">Failed to load product insights</div>
|
||||
</div>
|
||||
) : topProducts.length === 0 ? (
|
||||
// Empty state
|
||||
<div className="py-8 text-center">
|
||||
<ShoppingCart className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No products sold yet</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Your best-selling products will appear here after you make some sales.
|
||||
<div className="py-12 text-center">
|
||||
<ShoppingCart className="h-12 w-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-lg font-medium mb-1">Begin your sales journey</h3>
|
||||
<p className="text-muted-foreground text-sm max-w-xs mx-auto">
|
||||
Your top performing listings will materialize here as you receive orders.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
// Data view
|
||||
<div className="space-y-4">
|
||||
{topProducts.map((product) => (
|
||||
<div key={product.id} className="flex items-center gap-4 py-2 border-b last:border-0">
|
||||
<div className="space-y-1">
|
||||
{topProducts.map((product, index) => (
|
||||
<motion.div
|
||||
key={product.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 + index * 0.05 }}
|
||||
className="flex items-center gap-4 p-3 rounded-xl hover:bg-muted/50 transition-colors group"
|
||||
>
|
||||
<div
|
||||
className="h-12 w-12 bg-cover bg-center rounded-md border flex-shrink-0 flex items-center justify-center overflow-hidden"
|
||||
className="h-14 w-14 bg-muted bg-cover bg-center rounded-xl border flex-shrink-0 flex items-center justify-center overflow-hidden group-hover:scale-105 transition-transform"
|
||||
style={{
|
||||
backgroundImage: product.image
|
||||
? `url(/api/products/${product.id}/image)`
|
||||
@@ -158,22 +176,118 @@ export default function Content({ username, orderStats }: ContentProps) {
|
||||
}}
|
||||
>
|
||||
{!product.image && (
|
||||
<ShoppingCart className="h-6 w-6 text-muted-foreground" />
|
||||
<ShoppingCart className="h-6 w-6 text-muted-foreground/50" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-grow min-w-0">
|
||||
<h4 className="font-medium truncate">{product.name}</h4>
|
||||
<h4 className="font-semibold text-lg truncate group-hover:text-primary transition-colors">{product.name}</h4>
|
||||
<div className="flex items-center gap-3 mt-0.5">
|
||||
<span className="text-sm text-muted-foreground font-medium">£{(Number(Array.isArray(product.price) ? product.price[0] : product.price) || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-medium">{product.count} sold</div>
|
||||
</div>
|
||||
<div className="text-xl font-bold">{product.count}</div>
|
||||
<div className="text-xs text-muted-foreground font-medium uppercase tracking-tighter mb-1">Units Sold</div>
|
||||
<div className="text-sm font-semibold text-primary">£{product.revenue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
case "revenue-chart":
|
||||
return <RevenueWidget settings={widget.settings} />;
|
||||
case "low-stock":
|
||||
return <LowStockWidget settings={widget.settings} />;
|
||||
case "recent-customers":
|
||||
return <RecentCustomersWidget settings={widget.settings} />;
|
||||
case "pending-chats":
|
||||
return <PendingChatsWidget settings={widget.settings} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setGreeting(getGreeting());
|
||||
fetchTopProducts();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-10 pb-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col md:flex-row md:items-end md:justify-between gap-4"
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-foreground">
|
||||
{greeting}, <span className="text-primary">{username}</span>!
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2 text-lg">
|
||||
"{randomQuote.text}" — <span className="font-medium">{randomQuote.author}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<EditDashboardButton
|
||||
isEditMode={isEditMode}
|
||||
onToggle={() => setIsEditMode(!isEditMode)}
|
||||
/>
|
||||
<WidgetSettings
|
||||
widgets={widgets}
|
||||
onToggle={toggleWidget}
|
||||
onMove={moveWidget}
|
||||
onReset={resetLayout}
|
||||
onConfigure={(widget) => setConfiguredWidget(widget)}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<DashboardEditor
|
||||
widgets={widgets}
|
||||
isEditMode={isEditMode}
|
||||
onToggleEditMode={() => setIsEditMode(false)}
|
||||
onReorder={reorderWidgets}
|
||||
onReset={resetLayout}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 auto-rows-min">
|
||||
{widgets.map((widget) => {
|
||||
if (!widget.visible && !isEditMode) return null;
|
||||
|
||||
return (
|
||||
<DraggableWidget
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
isEditMode={isEditMode}
|
||||
onConfigure={() => setConfiguredWidget(widget)}
|
||||
onToggleVisibility={() => toggleWidget(widget.id)}
|
||||
>
|
||||
{!widget.visible && isEditMode ? (
|
||||
<div className="opacity-40 grayscale pointer-events-none h-full">
|
||||
{renderWidget(widget)}
|
||||
</div>
|
||||
) : (
|
||||
renderWidget(widget)
|
||||
)}
|
||||
</DraggableWidget>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DashboardEditor>
|
||||
|
||||
{/* Widget Settings Modal */}
|
||||
<WidgetSettingsModal
|
||||
widget={configuredWidget}
|
||||
open={!!configuredWidget}
|
||||
onOpenChange={(open) => !open && setConfiguredWidget(null)}
|
||||
onSave={(widgetId, settings, colSpan) => {
|
||||
updateWidgetSettings(widgetId, settings);
|
||||
if (colSpan !== undefined) updateWidgetColSpan(widgetId, colSpan);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
157
components/dashboard/dashboard-editor.tsx
Normal file
157
components/dashboard/dashboard-editor.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState } from "react"
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
rectSortingStrategy,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Edit3, X, Check, RotateCcw } from "lucide-react"
|
||||
import { WidgetConfig } from "@/hooks/useWidgetLayout"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
|
||||
interface DashboardEditorProps {
|
||||
widgets: WidgetConfig[]
|
||||
isEditMode: boolean
|
||||
onToggleEditMode: () => void
|
||||
onReorder: (activeId: string, overId: string) => void
|
||||
onReset: () => void
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function DashboardEditor({
|
||||
widgets,
|
||||
isEditMode,
|
||||
onToggleEditMode,
|
||||
onReorder,
|
||||
onReset,
|
||||
children
|
||||
}: DashboardEditorProps) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string)
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
onReorder(active.id as string, over.id as string)
|
||||
}
|
||||
|
||||
setActiveId(null)
|
||||
}
|
||||
|
||||
const handleDragCancel = () => {
|
||||
setActiveId(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<SortableContext
|
||||
items={widgets.map(w => w.id)}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
{children}
|
||||
</SortableContext>
|
||||
|
||||
{/* Edit Mode Banner */}
|
||||
<AnimatePresence>
|
||||
{isEditMode && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 50 }}
|
||||
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50"
|
||||
>
|
||||
<div className="flex items-center gap-3 bg-primary text-primary-foreground px-4 py-2.5 rounded-full shadow-lg border border-primary-foreground/20">
|
||||
<span className="text-sm font-medium">
|
||||
Editing Dashboard • Drag widgets to reorder
|
||||
</span>
|
||||
<div className="h-4 w-px bg-primary-foreground/30" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 hover:bg-primary-foreground/20"
|
||||
onClick={onReset}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 px-3"
|
||||
onClick={onToggleEditMode}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5 mr-1" />
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
// Edit button component to add to the header
|
||||
export function EditDashboardButton({
|
||||
isEditMode,
|
||||
onToggle
|
||||
}: {
|
||||
isEditMode: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant={isEditMode ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-8 gap-2"
|
||||
onClick={onToggle}
|
||||
>
|
||||
{isEditMode ? (
|
||||
<>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Cancel</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Edit Layout</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
121
components/dashboard/draggable-widget.tsx
Normal file
121
components/dashboard/draggable-widget.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { useSortable } from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { GripVertical, Settings, X, Eye, EyeOff } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils/styles"
|
||||
import { WidgetConfig } from "@/hooks/useWidgetLayout"
|
||||
|
||||
interface DraggableWidgetProps {
|
||||
widget: WidgetConfig
|
||||
children: React.ReactNode
|
||||
isEditMode: boolean
|
||||
onRemove?: () => void
|
||||
onConfigure?: () => void
|
||||
onToggleVisibility?: () => void
|
||||
}
|
||||
|
||||
export function DraggableWidget({
|
||||
widget,
|
||||
children,
|
||||
isEditMode,
|
||||
onRemove,
|
||||
onConfigure,
|
||||
onToggleVisibility
|
||||
}: DraggableWidgetProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: widget.id })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
zIndex: isDragging ? 1000 : 1,
|
||||
}
|
||||
|
||||
const colSpanClasses = {
|
||||
1: "lg:col-span-1",
|
||||
2: "lg:col-span-2",
|
||||
3: "lg:col-span-3",
|
||||
4: "lg:col-span-4",
|
||||
}[widget.colSpan || 4] || "lg:col-span-4"
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"relative group",
|
||||
colSpanClasses,
|
||||
"md:col-span-2", // Default to 2 columns on tablet
|
||||
isEditMode && "ring-2 ring-primary ring-offset-2 ring-offset-background rounded-lg",
|
||||
isDragging && "z-50 shadow-2xl"
|
||||
)}
|
||||
>
|
||||
{isEditMode && (
|
||||
<>
|
||||
{/* Edit Mode Overlay */}
|
||||
<div className="absolute inset-0 rounded-lg border-2 border-dashed border-primary/30 pointer-events-none z-10 group-hover:border-primary/60 transition-colors" />
|
||||
|
||||
{/* Drag Handle */}
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="absolute top-2 left-2 z-20 cursor-grab active:cursor-grabbing bg-background/90 backdrop-blur-sm rounded-md p-1.5 shadow-md border border-border opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Widget Title Badge */}
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-20 bg-primary text-primary-foreground text-xs font-medium px-2 py-1 rounded-full shadow-md opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{widget.title}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="absolute top-2 right-2 z-20 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{onConfigure && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-7 w-7 shadow-md"
|
||||
onClick={onConfigure}
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{onToggleVisibility && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-7 w-7 shadow-md"
|
||||
onClick={onToggleVisibility}
|
||||
>
|
||||
{widget.visible ? (
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Widget Content */}
|
||||
<div className={cn(
|
||||
"h-full transition-transform",
|
||||
isDragging && "scale-[1.02]"
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
167
components/dashboard/low-stock-widget.tsx
Normal file
167
components/dashboard/low-stock-widget.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { AlertCircle, Package, ArrowRight, ShoppingCart } from "lucide-react"
|
||||
import { clientFetch } from "@/lib/api"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
|
||||
interface LowStockWidgetProps {
|
||||
settings?: {
|
||||
threshold?: number
|
||||
itemCount?: number
|
||||
}
|
||||
}
|
||||
|
||||
interface LowStockProduct {
|
||||
id: string
|
||||
name: string
|
||||
currentStock: number
|
||||
unitType: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
export default function LowStockWidget({ settings }: LowStockWidgetProps) {
|
||||
const threshold = settings?.threshold || 5
|
||||
const itemCount = settings?.itemCount || 5
|
||||
const [products, setProducts] = useState<LowStockProduct[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchLowStock = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
// Implementation: We'll use the product-performance API and filter locally
|
||||
// or a dedicated stock-report API if available.
|
||||
// For now, let's use the product-performance endpoint which has stock info.
|
||||
const response = await clientFetch('/analytics/product-performance')
|
||||
|
||||
const lowStockProducts = response
|
||||
.filter((p: any) => p.currentStock <= threshold)
|
||||
.sort((a: any, b: any) => a.currentStock - b.currentStock)
|
||||
.slice(0, itemCount)
|
||||
.map((p: any) => ({
|
||||
id: p.productId,
|
||||
name: p.name,
|
||||
currentStock: p.currentStock,
|
||||
unitType: p.unitType,
|
||||
image: p.image
|
||||
}))
|
||||
|
||||
setProducts(lowStockProducts)
|
||||
} catch (err) {
|
||||
console.error("Error fetching low stock data:", err)
|
||||
setError("Failed to load inventory data")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchLowStock()
|
||||
}, [threshold, itemCount])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-5 w-32 mb-1" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-12 w-12 rounded-lg" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/4" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm overflow-hidden flex flex-col h-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-amber-500" />
|
||||
Low Stock Alerts
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Inventory checks (Threshold: {threshold})
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Link href="/dashboard/stock">
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs">
|
||||
Manage
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4 flex-grow">
|
||||
{error ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-center">
|
||||
<Package className="h-10 w-10 text-muted-foreground/20 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-green-500/10 flex items-center justify-center mb-4">
|
||||
<Package className="h-6 w-6 text-green-500" />
|
||||
</div>
|
||||
<h3 className="font-medium">All systems go</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-xs">
|
||||
No products currently under your threshold of {threshold} units.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{products.map((product) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className="flex items-center gap-4 p-3 rounded-xl hover:bg-muted/50 transition-colors group"
|
||||
>
|
||||
<div className="h-12 w-12 relative rounded-lg border bg-muted overflow-hidden flex-shrink-0">
|
||||
{product.image ? (
|
||||
<Image
|
||||
src={`/api/products/${product.id}/image`}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-cover group-hover:scale-110 transition-transform"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<ShoppingCart className="h-5 w-5 text-muted-foreground/40" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-grow min-w-0">
|
||||
<h4 className="font-semibold text-sm truncate">{product.name}</h4>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-[10px] uppercase font-mono tracking-wider text-muted-foreground bg-muted-foreground/10 px-1.5 py-0.5 rounded">
|
||||
ID: {product.id.slice(-6)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={`text-sm font-bold ${product.currentStock === 0 ? 'text-destructive' : 'text-amber-500'}`}>
|
||||
{product.currentStock} {product.unitType}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground font-medium uppercase tracking-tighter">Remaining</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +1,61 @@
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { motion } from "framer-motion"
|
||||
import Link from "next/link"
|
||||
|
||||
interface OrderStatsProps {
|
||||
title: string
|
||||
value: string
|
||||
icon: LucideIcon
|
||||
index?: number
|
||||
/** Status to filter by when clicking (e.g., "paid", "shipped") */
|
||||
filterStatus?: string
|
||||
/** Custom href if not using filterStatus */
|
||||
href?: string
|
||||
}
|
||||
|
||||
export default function OrderStats({ title, value, icon: Icon }: OrderStatsProps) {
|
||||
export default function OrderStats({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
index = 0,
|
||||
filterStatus,
|
||||
href
|
||||
}: OrderStatsProps) {
|
||||
const linkHref = href || (filterStatus ? `/dashboard/orders?status=${filterStatus}` : undefined)
|
||||
|
||||
const CardWrapper = linkHref ? Link : "div"
|
||||
const wrapperProps = linkHref ? { href: linkHref } : {}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
>
|
||||
<CardWrapper {...wrapperProps as any}>
|
||||
<Card className={`relative overflow-hidden group border-border/40 bg-background/50 backdrop-blur-sm hover:bg-background/80 transition-all duration-300 ${linkHref ? "cursor-pointer hover:border-primary/30" : ""}`}>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium tracking-tight text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
{title}
|
||||
</CardTitle>
|
||||
<div className="p-2 rounded-lg bg-muted group-hover:bg-primary/10 group-hover:text-primary transition-all duration-300">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
<CardContent className="relative z-10">
|
||||
<div className="text-3xl font-bold tracking-tight">{value}</div>
|
||||
<div className="mt-1 h-1 w-0 bg-primary/20 group-hover:w-full transition-all duration-500 rounded-full" />
|
||||
{linkHref && (
|
||||
<div className="mt-2 text-xs text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
Click to view →
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardWrapper>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
172
components/dashboard/pending-chats-widget.tsx
Normal file
172
components/dashboard/pending-chats-widget.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { MessageSquare, MessageCircle, ArrowRight, Clock } from "lucide-react"
|
||||
import { clientFetch, getCookie } from "@/lib/api"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import Link from "next/link"
|
||||
import { RelativeTime } from "@/components/ui/relative-time"
|
||||
|
||||
interface PendingChatsWidgetProps {
|
||||
settings?: {
|
||||
showPreview?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
interface Chat {
|
||||
id: string
|
||||
buyerId: string
|
||||
telegramUsername?: string
|
||||
lastUpdated: string
|
||||
unreadCount: number
|
||||
}
|
||||
|
||||
export default function PendingChatsWidget({ settings }: PendingChatsWidgetProps) {
|
||||
const showPreview = settings?.showPreview !== false
|
||||
const [chats, setChats] = useState<Chat[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const getVendorIdFromToken = () => {
|
||||
const authToken = getCookie("Authorization") || ""
|
||||
if (!authToken) return null
|
||||
try {
|
||||
const payload = JSON.parse(atob(authToken.split(".")[1]))
|
||||
return payload.id
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const fetchChats = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
const vendorId = getVendorIdFromToken()
|
||||
if (!vendorId) {
|
||||
setError("Please login to view chats")
|
||||
return
|
||||
}
|
||||
|
||||
const response = await clientFetch(`/chats/vendor/${vendorId}/batch?page=1&limit=5`)
|
||||
|
||||
const chatCounts = response.unreadCounts?.chatCounts || {}
|
||||
const pendingChats = (response.chats || [])
|
||||
.filter((c: any) => chatCounts[c._id] > 0)
|
||||
.map((c: any) => ({
|
||||
id: c._id,
|
||||
buyerId: c.buyerId,
|
||||
telegramUsername: c.telegramUsername,
|
||||
lastUpdated: c.lastUpdated,
|
||||
unreadCount: chatCounts[c._id] || 0
|
||||
}))
|
||||
|
||||
setChats(pendingChats)
|
||||
} catch (err) {
|
||||
console.error("Error fetching chats:", err)
|
||||
setError("Failed to load chats")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchChats()
|
||||
}, [])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-5 w-32 mb-1" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/4" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm overflow-hidden flex flex-col h-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5 text-emerald-500" />
|
||||
Pending Chats
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Unanswered customer messages
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Link href="/dashboard/chats">
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs">
|
||||
Inbox
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4 flex-grow">
|
||||
{error ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-center">
|
||||
<MessageCircle className="h-10 w-10 text-muted-foreground/20 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
) : chats.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-emerald-500/10 flex items-center justify-center mb-4">
|
||||
<MessageCircle className="h-6 w-6 text-emerald-500" />
|
||||
</div>
|
||||
<h3 className="font-medium">All caught up!</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-xs">
|
||||
No pending customer chats that require your attention.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{chats.map((chat) => (
|
||||
<Link
|
||||
key={chat.id}
|
||||
href={`/dashboard/chats/${chat.id}`}
|
||||
className="flex items-center gap-4 p-3 rounded-xl hover:bg-muted/50 transition-colors group"
|
||||
>
|
||||
<div className="relative">
|
||||
<Avatar className="h-10 w-10 border shadow-sm group-hover:scale-105 transition-transform">
|
||||
<AvatarFallback className="bg-emerald-500/10 text-emerald-600 text-xs font-bold">
|
||||
{(chat.telegramUsername || chat.buyerId).slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="absolute -top-0.5 -right-0.5 h-3 w-3 bg-emerald-500 rounded-full ring-2 ring-background border border-background shadow-sm" />
|
||||
</div>
|
||||
<div className="flex-grow min-w-0">
|
||||
<h4 className="font-semibold text-sm truncate group-hover:text-primary transition-colors">
|
||||
{chat.telegramUsername ? `@${chat.telegramUsername}` : `Customer ${chat.buyerId.slice(-6)}`}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
<RelativeTime date={new Date(chat.lastUpdated)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-emerald-500 text-emerald-foreground text-[10px] font-bold px-1.5 py-0.5 rounded-full shadow-sm ring-2 ring-emerald-500/10">
|
||||
{chat.unreadCount}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
219
components/dashboard/quick-actions.tsx
Normal file
219
components/dashboard/quick-actions.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, ChangeEvent } from "react"
|
||||
import Link from "next/link"
|
||||
import { motion } from "framer-motion"
|
||||
import {
|
||||
PlusCircle,
|
||||
Truck,
|
||||
BarChart3,
|
||||
MessageSquare,
|
||||
} from "lucide-react"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import dynamic from "next/dynamic"
|
||||
import { Product } from "@/models/products"
|
||||
import { Category } from "@/models/categories"
|
||||
import { clientFetch } from "@/lib/api"
|
||||
import { toast } from "sonner"
|
||||
|
||||
const ProductModal = dynamic(() => import("@/components/modals/product-modal").then(mod => ({ default: mod.ProductModal })), {
|
||||
loading: () => null
|
||||
});
|
||||
|
||||
const actions = [
|
||||
{
|
||||
title: "Add Product",
|
||||
icon: PlusCircle,
|
||||
href: "/dashboard/products/new", // Fallback text
|
||||
color: "bg-blue-500/10 text-blue-500",
|
||||
description: "Create a new listing",
|
||||
action: "modal"
|
||||
},
|
||||
{
|
||||
title: "Process Orders",
|
||||
icon: Truck,
|
||||
href: "/dashboard/orders?status=paid",
|
||||
color: "bg-emerald-500/10 text-emerald-500",
|
||||
description: "Ship pending orders"
|
||||
},
|
||||
{
|
||||
title: "Analytics",
|
||||
icon: BarChart3,
|
||||
href: "/dashboard/analytics",
|
||||
color: "bg-purple-500/10 text-purple-500",
|
||||
description: "View sales performance"
|
||||
},
|
||||
{
|
||||
title: "Messages",
|
||||
icon: MessageSquare,
|
||||
href: "/dashboard/chats",
|
||||
color: "bg-amber-500/10 text-amber-500",
|
||||
description: "Chat with customers"
|
||||
}
|
||||
]
|
||||
|
||||
export default function QuickActions() {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [productData, setProductData] = useState<Product>({
|
||||
name: "",
|
||||
description: "",
|
||||
unitType: "pcs",
|
||||
category: "",
|
||||
pricing: [{ minQuantity: 1, pricePerUnit: 0 }],
|
||||
image: null,
|
||||
costPerUnit: 0,
|
||||
});
|
||||
|
||||
// Fetch categories on mount
|
||||
useEffect(() => {
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const data = await clientFetch('/categories');
|
||||
setCategories(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch categories:", error);
|
||||
}
|
||||
};
|
||||
fetchCategories();
|
||||
}, []);
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setProductData({ ...productData, [e.target.name]: e.target.value });
|
||||
};
|
||||
|
||||
const handleTieredPricingChange = (e: ChangeEvent<HTMLInputElement>, index: number) => {
|
||||
const updatedPricing = [...productData.pricing];
|
||||
const name = e.target.name as "minQuantity" | "pricePerUnit";
|
||||
updatedPricing[index][name] = e.target.valueAsNumber || 0;
|
||||
setProductData({ ...productData, pricing: updatedPricing });
|
||||
};
|
||||
|
||||
const handleAddTier = () => {
|
||||
setProductData((prev) => ({
|
||||
...prev,
|
||||
pricing: [...prev.pricing, { minQuantity: 1, pricePerUnit: 0 }],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRemoveTier = (index: number) => {
|
||||
setProductData((prev) => ({
|
||||
...prev,
|
||||
pricing: prev.pricing.filter((_, i) => i !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSaveProduct = async (data: Product, file?: File | null) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Prepare the product data
|
||||
const payload = {
|
||||
...data,
|
||||
stockTracking: data.stockTracking ?? true,
|
||||
currentStock: data.currentStock ?? 0,
|
||||
lowStockThreshold: data.lowStockThreshold ?? 10,
|
||||
stockStatus: data.stockStatus ?? 'out_of_stock'
|
||||
};
|
||||
|
||||
const productResponse = await clientFetch("/products", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (file) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/products/${productResponse._id}/image`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${document.cookie.split("; ").find((row) => row.startsWith("Authorization="))?.split("=")[1]}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
setModalOpen(false);
|
||||
setProductData({
|
||||
name: "",
|
||||
description: "",
|
||||
unitType: "pcs",
|
||||
category: "",
|
||||
pricing: [{ minQuantity: 1, pricePerUnit: 0 }],
|
||||
image: null,
|
||||
costPerUnit: 0,
|
||||
});
|
||||
toast.success("Product added successfully");
|
||||
|
||||
// Optional: trigger a refresh of products or stats if needed
|
||||
// currently just closing modal
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to save product");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{actions.map((action, index) => {
|
||||
const isModalAction = action.action === "modal";
|
||||
|
||||
const CardContentWrapper = () => (
|
||||
<Card className="h-full border-none bg-black/40 backdrop-blur-xl hover:bg-black/60 transition-all duration-300 group overflow-hidden relative">
|
||||
<div className="absolute inset-0 border border-white/10 rounded-xl pointer-events-none group-hover:border-white/20 transition-colors" />
|
||||
<div className={`absolute inset-0 bg-gradient-to-br ${action.color.split(' ')[0].replace('/10', '/5')} opacity-0 group-hover:opacity-100 transition-opacity duration-500`} />
|
||||
|
||||
<CardContent className="p-6 flex flex-col items-center text-center relative z-10">
|
||||
<div className={`p-4 rounded-2xl ${action.color.replace('bg-', 'bg-opacity-10 bg-')} mb-4 group-hover:scale-110 transition-transform duration-300 shadow-lg shadow-black/20`}>
|
||||
<action.icon className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="font-bold text-lg text-white group-hover:text-primary transition-colors">{action.title}</h3>
|
||||
<p className="text-sm text-zinc-400 mt-1">{action.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={action.title}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
whileHover={{ y: -5 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
{isModalAction ? (
|
||||
<div onClick={() => setModalOpen(true)} className="cursor-pointer h-full">
|
||||
<CardContentWrapper />
|
||||
</div>
|
||||
) : (
|
||||
<Link href={action.href} className="h-full block">
|
||||
<CardContentWrapper />
|
||||
</Link>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ProductModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSave={handleSaveProduct}
|
||||
productData={productData}
|
||||
categories={categories}
|
||||
editing={false}
|
||||
handleChange={handleChange}
|
||||
handleTieredPricingChange={handleTieredPricingChange}
|
||||
handleAddTier={handleAddTier}
|
||||
handleRemoveTier={handleRemoveTier}
|
||||
setProductData={setProductData}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
119
components/dashboard/recent-activity.tsx
Normal file
119
components/dashboard/recent-activity.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { ShoppingBag, CreditCard, Truck, MessageSquare, AlertCircle } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { clientFetch } from "@/lib/api"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { RelativeTime } from "@/components/ui/relative-time"
|
||||
import Link from "next/link"
|
||||
|
||||
interface ActivityItem {
|
||||
_id: string;
|
||||
orderId: string;
|
||||
status: string;
|
||||
totalPrice: number;
|
||||
orderDate: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export default function RecentActivity() {
|
||||
const [activities, setActivities] = useState<ActivityItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchRecentOrders() {
|
||||
try {
|
||||
const data = await clientFetch("/orders?limit=10&sortBy=orderDate&sortOrder=desc");
|
||||
setActivities(data.orders || []);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch recent activity:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchRecentOrders();
|
||||
}, []);
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "paid": return <CreditCard className="h-4 w-4" />;
|
||||
case "shipped": return <Truck className="h-4 w-4" />;
|
||||
case "unpaid": return <ShoppingBag className="h-4 w-4" />;
|
||||
default: return <AlertCircle className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "paid": return "bg-emerald-500/10 text-emerald-500";
|
||||
case "shipped": return "bg-blue-500/10 text-blue-500";
|
||||
case "unpaid": return "bg-amber-500/10 text-amber-500";
|
||||
default: return "bg-gray-500/10 text-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
<CardDescription>Latest updates from your store</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/4" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : activities.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
No recent activity
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{activities.map((item, index) => (
|
||||
<motion.div
|
||||
key={item._id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="flex items-start gap-4 relative"
|
||||
>
|
||||
{index !== activities.length - 1 && (
|
||||
<div className="absolute left-[15px] top-8 bottom-[-24px] w-[2px] bg-border/50" />
|
||||
)}
|
||||
<div className={`mt-1 p-2 rounded-full z-10 ${getStatusColor(item.status)}`}>
|
||||
{getStatusIcon(item.status)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href={`/dashboard/orders/${item._id}`} className="font-medium hover:underline">
|
||||
Order #{item.orderId}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<RelativeTime date={item.orderDate} />
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.status === "paid" ? "Payment received" :
|
||||
item.status === "shipped" ? "Order marked as shipped" :
|
||||
`Order status: ${item.status}`} for £{item.totalPrice.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
154
components/dashboard/recent-customers-widget.tsx
Normal file
154
components/dashboard/recent-customers-widget.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Users, User, ArrowRight, DollarSign } from "lucide-react"
|
||||
import { getCustomerInsightsWithStore, formatGBP } from "@/lib/api"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import Link from "next/link"
|
||||
|
||||
interface RecentCustomersWidgetProps {
|
||||
settings?: {
|
||||
itemCount?: number
|
||||
showSpent?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
interface Customer {
|
||||
id: string
|
||||
name: string
|
||||
username?: string
|
||||
orderCount: number
|
||||
totalSpent: number
|
||||
}
|
||||
|
||||
export default function RecentCustomersWidget({ settings }: RecentCustomersWidgetProps) {
|
||||
const itemCount = settings?.itemCount || 5
|
||||
const showSpent = settings?.showSpent !== false
|
||||
const [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchCustomers = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
// The API returns topCustomers, but we'll use 'recent' sorting to show new engagement
|
||||
const response = await getCustomerInsightsWithStore(1, itemCount, "recent")
|
||||
|
||||
const mappedCustomers = (response.topCustomers || []).map((c: any) => ({
|
||||
id: c._id,
|
||||
name: c.displayName || c.username || `Customer ${c._id.slice(-4)}`,
|
||||
username: c.username,
|
||||
orderCount: c.orderCount || 0,
|
||||
totalSpent: c.totalSpent || 0
|
||||
}))
|
||||
|
||||
setCustomers(mappedCustomers)
|
||||
} catch (err) {
|
||||
console.error("Error fetching customers:", err)
|
||||
setError("Failed to load customer data")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomers()
|
||||
}, [itemCount])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-5 w-32 mb-1" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/4" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm overflow-hidden flex flex-col h-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-indigo-500" />
|
||||
Recent Customers
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Latest and newest connections
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Link href="/dashboard/customers">
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs">
|
||||
View All
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4 flex-grow">
|
||||
{error ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-center">
|
||||
<User className="h-10 w-10 text-muted-foreground/20 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
) : customers.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-indigo-500/10 flex items-center justify-center mb-4">
|
||||
<Users className="h-6 w-6 text-indigo-500" />
|
||||
</div>
|
||||
<h3 className="font-medium">No customers yet</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-xs">
|
||||
This widget will populate once people start browsing and buying.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{customers.map((customer) => (
|
||||
<div
|
||||
key={customer.id}
|
||||
className="flex items-center gap-4 p-3 rounded-xl hover:bg-muted/50 transition-colors group"
|
||||
>
|
||||
<Avatar className="h-10 w-10 border shadow-sm group-hover:scale-105 transition-transform">
|
||||
<AvatarFallback className="bg-indigo-500/10 text-indigo-600 text-xs font-bold">
|
||||
{customer.name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-grow min-w-0">
|
||||
<h4 className="font-semibold text-sm truncate group-hover:text-primary transition-colors">{customer.name}</h4>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{customer.orderCount} order{customer.orderCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{showSpent && (
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-bold text-foreground">
|
||||
{formatGBP(customer.totalSpent)}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground font-medium uppercase tracking-tighter">Total Spent</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
190
components/dashboard/revenue-widget.tsx
Normal file
190
components/dashboard/revenue-widget.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { TrendingUp, DollarSign, RefreshCcw } from "lucide-react"
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
|
||||
import { getRevenueTrendsWithStore, type RevenueData, formatGBP } from "@/lib/api"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
interface RevenueWidgetProps {
|
||||
settings?: {
|
||||
days?: number
|
||||
showComparison?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
interface ChartDataPoint {
|
||||
date: string
|
||||
revenue: number
|
||||
orders: number
|
||||
formattedDate: string
|
||||
}
|
||||
|
||||
export default function RevenueWidget({ settings }: RevenueWidgetProps) {
|
||||
const days = settings?.days || 7
|
||||
const [data, setData] = useState<RevenueData[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { toast } = useToast()
|
||||
|
||||
const fetchRevenueData = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
const response = await getRevenueTrendsWithStore(days.toString())
|
||||
setData(Array.isArray(response) ? response : [])
|
||||
} catch (err) {
|
||||
console.error("Error fetching revenue data:", err)
|
||||
setError("Failed to load revenue data")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchRevenueData()
|
||||
}, [days])
|
||||
|
||||
const chartData: ChartDataPoint[] = data.map(item => {
|
||||
const date = new Date(Date.UTC(item._id.year, item._id.month - 1, item._id.day))
|
||||
return {
|
||||
date: date.toISOString().split('T')[0],
|
||||
revenue: item.revenue || 0,
|
||||
orders: item.orders || 0,
|
||||
formattedDate: date.toLocaleDateString('en-GB', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Summary stats
|
||||
const totalRevenue = data.reduce((sum, item) => sum + (item.revenue || 0), 0)
|
||||
const totalOrders = data.reduce((sum, item) => sum + (item.orders || 0), 0)
|
||||
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload
|
||||
return (
|
||||
<div className="bg-background/95 backdrop-blur-md p-3 border border-border shadow-xl rounded-xl">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1">{data.formattedDate}</p>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-bold text-primary">
|
||||
Revenue: {formatGBP(data.revenue)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Orders: <span className="font-medium text-foreground">{data.orders}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-5 w-32 mb-1" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-[250px] w-full rounded-xl" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm overflow-hidden flex flex-col h-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5 text-primary" />
|
||||
Revenue Insights
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Performance over the last {days} days
|
||||
</CardDescription>
|
||||
</div>
|
||||
{error && (
|
||||
<Button variant="ghost" size="icon" onClick={fetchRevenueData} className="h-8 w-8">
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-grow pt-4">
|
||||
{error ? (
|
||||
<div className="h-[300px] flex flex-col items-center justify-center text-center p-6">
|
||||
<DollarSign className="h-12 w-12 text-muted-foreground/20 mb-4" />
|
||||
<p className="text-sm text-muted-foreground mb-4">Could not load revenue trends</p>
|
||||
<Button variant="outline" size="sm" onClick={fetchRevenueData}>Retry</Button>
|
||||
</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-[300px] flex flex-col items-center justify-center text-center p-6">
|
||||
<DollarSign className="h-12 w-12 text-muted-foreground/20 mb-4" />
|
||||
<h3 className="text-lg font-medium">No revenue data</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-xs mt-2">
|
||||
Start making sales to see your revenue trends here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="h-[300px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorRevenueWidget" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(var(--primary))" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(var(--primary))" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" opacity={0.5} />
|
||||
<XAxis
|
||||
dataKey="formattedDate"
|
||||
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
minTickGap={30}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(value) => `£${value >= 1000 ? (value / 1000).toFixed(1) + 'k' : value}`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
stroke="hsl(var(--primary))"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorRevenueWidget)"
|
||||
strokeWidth={2.5}
|
||||
activeDot={{ r: 6, strokeWidth: 0, fill: "hsl(var(--primary))" }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pb-2">
|
||||
<div className="p-4 rounded-2xl bg-primary/5 border border-primary/10">
|
||||
<div className="text-sm text-muted-foreground font-medium mb-1">Total Revenue</div>
|
||||
<div className="text-2xl font-bold text-primary">{formatGBP(totalRevenue)}</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-2xl bg-muted/50 border border-border">
|
||||
<div className="text-sm text-muted-foreground font-medium mb-1">Total Orders</div>
|
||||
<div className="text-2xl font-bold">{totalOrders}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
294
components/dashboard/widget-settings-modal.tsx
Normal file
294
components/dashboard/widget-settings-modal.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { WidgetConfig } from "@/hooks/useWidgetLayout"
|
||||
import { Settings2 } from "lucide-react"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
|
||||
interface WidgetSettingsModalProps {
|
||||
widget: WidgetConfig | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: (widgetId: string, settings: Record<string, any>, colSpan: number) => void
|
||||
}
|
||||
|
||||
export function WidgetSettingsModal({ widget, open, onOpenChange, onSave }: WidgetSettingsModalProps) {
|
||||
const [localSettings, setLocalSettings] = useState<Record<string, any>>({})
|
||||
const [localColSpan, setLocalColSpan] = useState<number>(4)
|
||||
|
||||
// Initialize local settings when widget changes
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (isOpen && widget) {
|
||||
setLocalSettings({ ...widget.settings })
|
||||
setLocalColSpan(widget.colSpan || 4)
|
||||
}
|
||||
onOpenChange(isOpen)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (widget) {
|
||||
onSave(widget.id, localSettings, localColSpan)
|
||||
onOpenChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateSetting = (key: string, value: any) => {
|
||||
setLocalSettings(prev => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
if (!widget) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Settings2 className="h-5 w-5" />
|
||||
{widget.title} Settings
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Customize how this widget displays on your dashboard.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[60vh] -mr-4 pr-4">
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Resize Selection */}
|
||||
<div className="space-y-3 pb-6 border-b border-border/40">
|
||||
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Widget Display</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="colSpan" className="text-sm font-medium">Widget Width</Label>
|
||||
<Select
|
||||
value={String(localColSpan)}
|
||||
onValueChange={(v) => setLocalColSpan(parseInt(v))}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">Small (1/4)</SelectItem>
|
||||
<SelectItem value="2">Medium (1/2)</SelectItem>
|
||||
<SelectItem value="3">Large (3/4)</SelectItem>
|
||||
<SelectItem value="4">Full Width</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Recent Activity Settings */}
|
||||
{widget.id === "recent-activity" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="itemCount">Number of items</Label>
|
||||
<Select
|
||||
value={String(localSettings.itemCount || 10)}
|
||||
onValueChange={(v) => updateSetting("itemCount", parseInt(v))}
|
||||
>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="15">15</SelectItem>
|
||||
<SelectItem value="20">20</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Products Settings */}
|
||||
{widget.id === "top-products" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="itemCount">Number of products</Label>
|
||||
<Select
|
||||
value={String(localSettings.itemCount || 5)}
|
||||
onValueChange={(v) => updateSetting("itemCount", parseInt(v))}
|
||||
>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="3">3</SelectItem>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showRevenue">Show revenue</Label>
|
||||
<Switch
|
||||
id="showRevenue"
|
||||
checked={localSettings.showRevenue ?? true}
|
||||
onCheckedChange={(checked) => updateSetting("showRevenue", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Revenue Chart Settings */}
|
||||
{widget.id === "revenue-chart" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="days">Time period</Label>
|
||||
<Select
|
||||
value={String(localSettings.days || 7)}
|
||||
onValueChange={(v) => updateSetting("days", parseInt(v))}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7">Last 7 days</SelectItem>
|
||||
<SelectItem value="14">Last 14 days</SelectItem>
|
||||
<SelectItem value="30">Last 30 days</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showComparison">Show comparison</Label>
|
||||
<Switch
|
||||
id="showComparison"
|
||||
checked={localSettings.showComparison ?? false}
|
||||
onCheckedChange={(checked) => updateSetting("showComparison", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Low Stock Settings */}
|
||||
{widget.id === "low-stock" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="threshold">Stock threshold</Label>
|
||||
<Input
|
||||
id="threshold"
|
||||
type="number"
|
||||
className="w-24"
|
||||
value={localSettings.threshold || 5}
|
||||
onChange={(e) => updateSetting("threshold", parseInt(e.target.value) || 5)}
|
||||
min={1}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="itemCount">Max items to show</Label>
|
||||
<Select
|
||||
value={String(localSettings.itemCount || 5)}
|
||||
onValueChange={(v) => updateSetting("itemCount", parseInt(v))}
|
||||
>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="3">3</SelectItem>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Customers Settings */}
|
||||
{widget.id === "recent-customers" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="itemCount">Number of customers</Label>
|
||||
<Select
|
||||
value={String(localSettings.itemCount || 5)}
|
||||
onValueChange={(v) => updateSetting("itemCount", parseInt(v))}
|
||||
>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="3">3</SelectItem>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showSpent">Show amount spent</Label>
|
||||
<Switch
|
||||
id="showSpent"
|
||||
checked={localSettings.showSpent ?? true}
|
||||
onCheckedChange={(checked) => updateSetting("showSpent", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending Chats Settings */}
|
||||
{widget.id === "pending-chats" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showPreview">Show message preview</Label>
|
||||
<Switch
|
||||
id="showPreview"
|
||||
checked={localSettings.showPreview ?? true}
|
||||
onCheckedChange={(checked) => updateSetting("showPreview", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overview Settings */}
|
||||
{widget.id === "overview" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showChange">Show percentage change</Label>
|
||||
<Switch
|
||||
id="showChange"
|
||||
checked={localSettings.showChange ?? false}
|
||||
onCheckedChange={(checked) => updateSetting("showChange", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Actions - no settings */}
|
||||
{widget.id === "quick-actions" && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
This widget has no customizable settings.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
101
components/dashboard/widget-settings.tsx
Normal file
101
components/dashboard/widget-settings.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuCheckboxItem,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Settings2, ChevronUp, ChevronDown, RotateCcw, Eye, EyeOff, Cog } from "lucide-react"
|
||||
import { WidgetConfig } from "@/hooks/useWidgetLayout"
|
||||
|
||||
interface WidgetSettingsProps {
|
||||
widgets: WidgetConfig[]
|
||||
onToggle: (id: string) => void
|
||||
onMove: (id: string, direction: "up" | "down") => void
|
||||
onReset: () => void
|
||||
onConfigure?: (widget: WidgetConfig) => void
|
||||
}
|
||||
|
||||
export function WidgetSettings({ widgets, onToggle, onMove, onReset, onConfigure }: WidgetSettingsProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8 gap-2">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Customize</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<DropdownMenuLabel className="flex items-center justify-between">
|
||||
Dashboard Widgets
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={onReset}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{widgets.map((widget, index) => (
|
||||
<div key={widget.id} className="flex items-center gap-1 px-2 py-1.5 hover:bg-muted/50 rounded-sm">
|
||||
<button
|
||||
onClick={() => onToggle(widget.id)}
|
||||
className="flex-1 flex items-center gap-2 text-sm hover:text-foreground transition-colors"
|
||||
>
|
||||
{widget.visible ? (
|
||||
<Eye className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className={widget.visible ? "" : "text-muted-foreground line-through"}>
|
||||
{widget.title}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{widget.settings && onConfigure && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onConfigure(widget)
|
||||
}}
|
||||
title="Configure widget"
|
||||
>
|
||||
<Cog className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => onMove(widget.id, "up")}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => onMove(widget.id, "down")}
|
||||
disabled={index === widgets.length - 1}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { ChangeEvent } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import Image from "next/image";
|
||||
|
||||
interface ImageUploadProps {
|
||||
imagePreview: string | null;
|
||||
@@ -21,9 +22,15 @@ export const ImageUpload = ({
|
||||
style={{ width: imageDimensions.width, height: imageDimensions.height }}
|
||||
>
|
||||
{imagePreview ? (
|
||||
<img
|
||||
<Image
|
||||
src={imagePreview}
|
||||
alt="Preview"
|
||||
width={imageDimensions.width}
|
||||
height={imageDimensions.height}
|
||||
priority // Load immediately since it's a preview
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjMwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PC9zdmc+"
|
||||
quality={85}
|
||||
className="object-contain w-full h-full rounded-md"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LogIn } from "lucide-react";
|
||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||
import { useState } from "react";
|
||||
|
||||
export function HomeNavbar() {
|
||||
@@ -27,8 +26,8 @@ export function HomeNavbar() {
|
||||
Log In
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/auth/login">
|
||||
<Button className="bg-[#D53F8C] hover:bg-[#B83280] text-white border-0">Get Started</Button>
|
||||
<Link href="/dashboard">
|
||||
<Button className="bg-indigo-600 hover:bg-indigo-700 text-white border-0">Get Started</Button>
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="md:hidden">
|
||||
@@ -78,11 +77,11 @@ export function HomeNavbar() {
|
||||
Log In
|
||||
</Link>
|
||||
<Link
|
||||
href="/auth/register"
|
||||
href="/dashboard"
|
||||
className="text-sm p-2 hover:bg-gray-900 rounded-md text-gray-300"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
Create Account
|
||||
Get Started
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Send, Bold, Italic, Code, Link as LinkIcon, Image as ImageIcon, X, Eye,
|
||||
import { toast } from "sonner";
|
||||
import { clientFetch } from "@/lib/api";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import Image from "next/image";
|
||||
import ProductSelector from "./product-selector";
|
||||
|
||||
const ReactMarkdown = lazy(() => import('react-markdown'));
|
||||
@@ -275,9 +276,15 @@ __italic text__
|
||||
|
||||
{imagePreview && (
|
||||
<div className="relative">
|
||||
<img
|
||||
<Image
|
||||
src={imagePreview}
|
||||
alt="Preview"
|
||||
alt="Broadcast preview"
|
||||
width={400}
|
||||
height={200}
|
||||
priority // Load immediately since it's a preview
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PC9zdmc+"
|
||||
quality={85}
|
||||
className="max-h-[200px] rounded-md object-contain"
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -22,6 +22,7 @@ import type React from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { apiRequest } from "@/lib/api";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
type CategorySelectProps = {
|
||||
categories: { _id: string; name: string; parentId?: string }[];
|
||||
@@ -213,9 +214,10 @@ const ProductBasicInfo: React.FC<{
|
||||
setProductData: React.Dispatch<React.SetStateAction<ProductData>>;
|
||||
onAddCategory: (newCategory: { _id: string; name: string; parentId?: string }) => void;
|
||||
}> = ({ productData, handleChange, categories, setProductData, onAddCategory }) => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="text-sm font-medium">
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="name" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
Product Name
|
||||
</label>
|
||||
<Input
|
||||
@@ -223,12 +225,13 @@ const ProductBasicInfo: React.FC<{
|
||||
name="name"
|
||||
value={productData.name}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter product name"
|
||||
placeholder="e.g. Premium Wireless Headphones"
|
||||
className="border-border/50 bg-background/50 focus:bg-background transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="description" className="text-sm font-medium">
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="description" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
@@ -236,73 +239,61 @@ const ProductBasicInfo: React.FC<{
|
||||
name="description"
|
||||
value={productData.description}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter product description"
|
||||
className="w-full min-h-[100px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Describe your product features and benefits..."
|
||||
className="flex w-full rounded-md border border-border/50 bg-background/50 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 min-h-[120px] resize-y focus:bg-background transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-background rounded-lg border border-border p-4">
|
||||
<h3 className="text-sm font-medium mb-4">Product Status</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium leading-none">Category</label>
|
||||
<CategorySelect
|
||||
categories={categories}
|
||||
value={productData.category}
|
||||
setProductData={setProductData}
|
||||
onAddCategory={onAddCategory}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium leading-none">Unit Type</label>
|
||||
<UnitTypeSelect
|
||||
value={productData.unitType}
|
||||
setProductData={setProductData}
|
||||
categories={categories}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="bg-muted/20 rounded-xl border border-border/40 overflow-hidden">
|
||||
<div className="p-4 border-b border-border/40 bg-muted/30">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
Inventory Management
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-5 space-y-6">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg border border-border/40 bg-background/40">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="stockTracking" className="text-sm font-medium cursor-pointer">Track Stock Quantity</label>
|
||||
<p className="text-xs text-muted-foreground">Automatically update stock when orders are placed</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enabled"
|
||||
checked={productData.enabled !== false}
|
||||
id="stockTracking"
|
||||
checked={productData.stockTracking !== false}
|
||||
onCheckedChange={(checked) => {
|
||||
setProductData({
|
||||
...productData,
|
||||
enabled: checked
|
||||
stockTracking: checked
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="enabled" className="text-sm">
|
||||
Enable Product
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-background rounded-lg border border-border p-4">
|
||||
<h3 className="text-sm font-medium mb-4">Stock Management</h3>
|
||||
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<input
|
||||
id="stockTracking"
|
||||
name="stockTracking"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
checked={productData.stockTracking !== false}
|
||||
onChange={(e) => {
|
||||
setProductData({
|
||||
...productData,
|
||||
stockTracking: e.target.checked
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="stockTracking" className="text-sm">
|
||||
Enable Stock Tracking
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{productData.stockTracking !== false && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="lowStockThreshold" className="text-sm font-medium">
|
||||
Low Stock Threshold
|
||||
</label>
|
||||
<Input
|
||||
id="lowStockThreshold"
|
||||
name="lowStockThreshold"
|
||||
type="number"
|
||||
min="1"
|
||||
step={productData.unitType === 'gr' || productData.unitType === 'ml' ? '0.1' : '1'}
|
||||
value={productData.lowStockThreshold || 10}
|
||||
onChange={handleChange}
|
||||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="currentStock" className="text-sm font-medium">
|
||||
Current Stock
|
||||
<div className="grid grid-cols-2 gap-5 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="currentStock" className="text-sm font-medium text-muted-foreground">
|
||||
Current Quantity
|
||||
</label>
|
||||
<Input
|
||||
id="currentStock"
|
||||
@@ -313,25 +304,42 @@ const ProductBasicInfo: React.FC<{
|
||||
value={productData.currentStock || 0}
|
||||
onChange={handleChange}
|
||||
placeholder="0"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="lowStockThreshold" className="text-sm font-medium text-muted-foreground">
|
||||
Low Stock Alert At
|
||||
</label>
|
||||
<Input
|
||||
id="lowStockThreshold"
|
||||
name="lowStockThreshold"
|
||||
type="number"
|
||||
min="1"
|
||||
step={productData.unitType === 'gr' || productData.unitType === 'ml' ? '0.1' : '1'}
|
||||
value={productData.lowStockThreshold || 10}
|
||||
onChange={handleChange}
|
||||
placeholder="10"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-background rounded-lg border border-border p-4">
|
||||
<h3 className="text-sm font-medium mb-4">💰 Cost & Profit Tracking</h3>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Track your costs to automatically calculate profit margins and markup percentages.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<div className="bg-muted/20 rounded-xl border border-border/40 overflow-hidden">
|
||||
<div className="p-4 border-b border-border/40 bg-muted/30 flex justify-between items-center">
|
||||
<h3 className="text-sm font-semibold">Cost Analysis</h3>
|
||||
<Badge variant="outline" className="text-[10px] font-normal">Optional</Badge>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="costPerUnit" className="text-sm font-medium">
|
||||
Cost Per Unit (Optional)
|
||||
Cost Per Unit
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
How much you paid for each unit of this product
|
||||
</p>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-2.5 text-muted-foreground text-sm">$</span>
|
||||
<Input
|
||||
id="costPerUnit"
|
||||
name="costPerUnit"
|
||||
@@ -341,30 +349,33 @@ const ProductBasicInfo: React.FC<{
|
||||
value={productData.costPerUnit || ''}
|
||||
onChange={handleChange}
|
||||
placeholder="0.00"
|
||||
className="pl-7 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-1.5">
|
||||
Enter your cost to calculate profit margins automatically. This is never shown to customers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Category</label>
|
||||
<CategorySelect
|
||||
categories={categories}
|
||||
value={productData.category}
|
||||
setProductData={setProductData}
|
||||
onAddCategory={onAddCategory}
|
||||
/>
|
||||
<div className="flex items-center justify-between p-4 rounded-xl border border-border/40 bg-blue-500/5">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="enabled" className="text-sm font-medium text-foreground">Product Visibility</label>
|
||||
<p className="text-xs text-muted-foreground">Make this product visible in your store</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Unit Type</label>
|
||||
<UnitTypeSelect
|
||||
value={productData.unitType}
|
||||
setProductData={setProductData}
|
||||
categories={categories}
|
||||
<Switch
|
||||
id="enabled"
|
||||
checked={productData.enabled !== false}
|
||||
onCheckedChange={(checked) => {
|
||||
setProductData({
|
||||
...productData,
|
||||
enabled: checked
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CategorySelect: React.FC<CategorySelectProps> = ({
|
||||
|
||||
@@ -5,9 +5,10 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { TrendingUp, TrendingDown, Calculator, DollarSign } from "lucide-react";
|
||||
import { TrendingUp, TrendingDown, Calculator, DollarSign, Loader2, Info } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { apiRequest } from "@/lib/api";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
interface ProfitAnalysisModalProps {
|
||||
open: boolean;
|
||||
@@ -69,7 +70,11 @@ export const ProfitAnalysisModal: React.FC<ProfitAnalysisModalProps> = ({
|
||||
|
||||
const formatCurrency = (amount: number | null) => {
|
||||
if (amount === null) return "N/A";
|
||||
return `£${amount.toFixed(2)}`;
|
||||
return new Intl.NumberFormat('en-GB', {
|
||||
style: 'currency',
|
||||
currency: 'GBP',
|
||||
minimumFractionDigits: 2
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatPercentage = (percentage: number | null) => {
|
||||
@@ -79,7 +84,7 @@ export const ProfitAnalysisModal: React.FC<ProfitAnalysisModalProps> = ({
|
||||
|
||||
const getProfitColor = (profit: number | null) => {
|
||||
if (profit === null) return "text-muted-foreground";
|
||||
return profit >= 0 ? "text-green-600" : "text-red-600";
|
||||
return profit >= 0 ? "text-emerald-500" : "text-rose-500";
|
||||
};
|
||||
|
||||
const getProfitIcon = (profit: number | null) => {
|
||||
@@ -87,17 +92,33 @@ export const ProfitAnalysisModal: React.FC<ProfitAnalysisModalProps> = ({
|
||||
return profit >= 0 ? TrendingUp : TrendingDown;
|
||||
};
|
||||
|
||||
// Variants for staggered animations
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0 }
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogContent className="max-w-4xl bg-background/80 backdrop-blur-md border-border/50">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Profit Analysis - {productName}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading profit analysis...</p>
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto mb-2" />
|
||||
<p className="text-muted-foreground">Calculating metrics...</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -108,7 +129,7 @@ export const ProfitAnalysisModal: React.FC<ProfitAnalysisModalProps> = ({
|
||||
if (!profitData) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogContent className="max-w-4xl bg-background/80 backdrop-blur-md border-border/50">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Profit Analysis - {productName}</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -122,89 +143,107 @@ export const ProfitAnalysisModal: React.FC<ProfitAnalysisModalProps> = ({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto bg-black/80 backdrop-blur-xl border-white/10 shadow-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<DollarSign className="h-5 w-5" />
|
||||
Profit Analysis - {productName}
|
||||
<DialogTitle className="flex items-center gap-2 text-xl">
|
||||
<div className="p-2 rounded-lg bg-indigo-500/10 border border-indigo-500/20">
|
||||
<DollarSign className="h-5 w-5 text-indigo-400" />
|
||||
</div>
|
||||
<span>Profit Analysis: <span className="text-muted-foreground font-normal">{productName}</span></span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<motion.div
|
||||
className="space-y-6 py-4"
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{/* Summary Cards */}
|
||||
{profitData.summary.hasCostData ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="bg-emerald-500/5 border-emerald-500/20 backdrop-blur-sm hover:bg-emerald-500/10 transition-colors">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Average Profit</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-emerald-400">Average Profit</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
<div className="text-3xl font-bold text-emerald-500">
|
||||
{formatCurrency(profitData.summary.averageProfit)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Per unit sold</p>
|
||||
<p className="text-xs text-emerald-400/60 mt-1">Per unit sold</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<Card>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="bg-blue-500/5 border-blue-500/20 backdrop-blur-sm hover:bg-blue-500/10 transition-colors">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Average Profit Margin</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-blue-400">Avg. Margin</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
<div className="text-3xl font-bold text-blue-500">
|
||||
{formatPercentage(profitData.summary.averageProfitMargin)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Of selling price</p>
|
||||
<p className="text-xs text-blue-400/60 mt-1">Of selling price</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<Card>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="bg-indigo-500/5 border-indigo-500/20 backdrop-blur-sm hover:bg-indigo-500/10 transition-colors">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Average Markup</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-indigo-400">Avg. Markup</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
<div className="text-3xl font-bold text-indigo-500">
|
||||
{formatPercentage(profitData.summary.averageMarkup)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">On cost price</p>
|
||||
<p className="text-xs text-indigo-400/60 mt-1">On cost price</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="border-dashed border-2 border-muted bg-muted/20">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<Calculator className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No Cost Data Available</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Add a cost per unit to this product to see profit calculations.
|
||||
<div className="text-center py-6">
|
||||
<Calculator className="h-12 w-12 text-muted-foreground mx-auto mb-4 opacity-50" />
|
||||
<h3 className="text-lg font-medium mb-2">Missing Cost Data</h3>
|
||||
<p className="text-muted-foreground mb-4 max-w-sm mx-auto">
|
||||
Add a generic "Cost Per Unit" to this product to see detailed profit calculations.
|
||||
</p>
|
||||
<Badge variant="outline">Cost Per Unit: {formatCurrency(profitData.costPerUnit)}</Badge>
|
||||
<Badge variant="outline" className="text-sm py-1 px-3">
|
||||
Current Cost: {formatCurrency(profitData.costPerUnit)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Cost Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Cost Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="bg-white/5 border-white/10 backdrop-blur-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Cost Per Unit:</span>
|
||||
<span className="text-lg font-semibold">{formatCurrency(profitData.costPerUnit)}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-md bg-muted/50">
|
||||
<Info className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-muted-foreground">Base Cost Per Unit</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-white">{formatCurrency(profitData.costPerUnit)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Pricing Tier Analysis */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Pricing Tier Analysis</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<motion.div variants={itemVariants} className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider pl-1">Tier Breakdown</h3>
|
||||
<div className="space-y-3">
|
||||
{profitData.profitMargins
|
||||
.sort((a, b) => a.minQuantity - b.minQuantity)
|
||||
.map((tier, index) => {
|
||||
@@ -215,61 +254,83 @@ export const ProfitAnalysisModal: React.FC<ProfitAnalysisModalProps> = ({
|
||||
const totalCostForMinQty = profitData.costPerUnit * tier.minQuantity;
|
||||
|
||||
return (
|
||||
<div
|
||||
<motion.div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-4 border rounded-lg"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.2 + (index * 0.1) }}
|
||||
className="relative overflow-hidden group rounded-xl border border-white/5 bg-white/5 hover:bg-white/10 transition-all duration-300"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<ProfitIcon className={`h-5 w-5 ${getProfitColor(tier.profit)}`} />
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{tier.minQuantity}+ units @ {formatCurrency(tier.pricePerUnit)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Revenue for {tier.minQuantity} units: {formatCurrency(totalRevenueForMinQty)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Cost for {tier.minQuantity} units: {formatCurrency(totalCostForMinQty)}
|
||||
</p>
|
||||
<div className={`absolute left-0 top-0 bottom-0 w-1 ${tier.profit && tier.profit >= 0 ? 'bg-emerald-500' : 'bg-rose-500'}`} />
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between p-4 pl-6 gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="font-mono text-xs">
|
||||
{tier.minQuantity}+ UNITS
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-sm">at</span>
|
||||
<span className="font-bold text-white text-lg">{formatCurrency(tier.pricePerUnit)}</span>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-muted-foreground mt-2">
|
||||
<span>Rev: <span className="text-white">{formatCurrency(totalRevenueForMinQty)}</span></span>
|
||||
<span className="w-px h-4 bg-white/10" />
|
||||
<span>Cost: <span className="text-white">{formatCurrency(totalCostForMinQty)}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right space-y-1">
|
||||
<div className={`font-medium ${getProfitColor(totalProfitForMinQty)}`}>
|
||||
Total Profit: {formatCurrency(totalProfitForMinQty)}
|
||||
<div className="flex items-center justify-between sm:justify-end gap-6 sm:w-auto w-full pt-2 sm:pt-0 border-t sm:border-0 border-white/5">
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] uppercase text-muted-foreground font-medium mb-0.5">Margin</div>
|
||||
<div className={`font-mono font-bold ${tier.profit && tier.profit >= 50 ? 'text-emerald-400' : 'text-blue-400'}`}>
|
||||
{formatPercentage(tier.profitMargin)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Per unit: {formatCurrency(tier.profit)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Margin: {formatPercentage(tier.profitMargin)} |
|
||||
Markup: {formatPercentage(tier.markup)}
|
||||
|
||||
<div className="text-right pl-4 border-l border-white/10">
|
||||
<div className="text-[10px] uppercase text-muted-foreground font-medium mb-0.5">Net Profit</div>
|
||||
<div className={`text-xl font-bold flex items-center justify-end gap-1 ${getProfitColor(tier.profit)}`}>
|
||||
{tier.profit && tier.profit > 0 ? '+' : ''}{formatCurrency(tier.profit)}
|
||||
</div>
|
||||
<div className={`text-[10px] ${getProfitColor(totalProfitForMinQty)} opacity-80`}>
|
||||
Total: {formatCurrency(totalProfitForMinQty)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Help Text */}
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-2 text-sm">
|
||||
<h4 className="font-medium">Understanding the Metrics:</h4>
|
||||
<ul className="space-y-1 text-muted-foreground">
|
||||
<li><strong>Profit:</strong> Selling price minus cost price</li>
|
||||
<li><strong>Profit Margin:</strong> Profit as a percentage of selling price</li>
|
||||
<li><strong>Markup:</strong> Profit as a percentage of cost price</li>
|
||||
</ul>
|
||||
<motion.div variants={itemVariants}>
|
||||
<div className="bg-indigo-500/5 rounded-lg border border-indigo-500/10 p-4">
|
||||
<h4 className="flex items-center gap-2 text-sm font-medium text-indigo-300 mb-2">
|
||||
<Info className="h-4 w-4" />
|
||||
Quick Guide
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs text-muted-foreground">
|
||||
<div>
|
||||
<span className="text-indigo-200 font-semibold block mb-0.5">Profit</span>
|
||||
Selling Price - Cost Price
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div>
|
||||
<span className="text-indigo-200 font-semibold block mb-0.5">Margin</span>
|
||||
(Profit / Selling Price) × 100
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-indigo-200 font-semibold block mb-0.5">Markup</span>
|
||||
(Profit / Cost Price) × 100
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button onClick={onClose} variant="secondary" className="hover:bg-white/20">Close Analysis</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function UnifiedNotifications() {
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80">
|
||||
<DropdownMenuContent align="end" className="w-80" collisionPadding={10}>
|
||||
<div className="p-2 border-b">
|
||||
<Tabs defaultValue="all" value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
|
||||
86
components/orders/order-timeline.tsx
Normal file
86
components/orders/order-timeline.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
"use client"
|
||||
|
||||
import { CheckCircle2, Circle, Clock, Package, Truck, Flag } from "lucide-react"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
interface OrderTimelineProps {
|
||||
status: string;
|
||||
orderDate: Date | string;
|
||||
paidAt?: Date | string;
|
||||
completedAt?: Date | string;
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{ status: "unpaid", label: "Ordered", icon: Clock },
|
||||
{ status: "paid", label: "Paid", icon: CheckCircle2 },
|
||||
{ status: "acknowledged", label: "Processing", icon: Package },
|
||||
{ status: "shipped", label: "Shipped", icon: Truck },
|
||||
{ status: "completed", label: "Completed", icon: Flag },
|
||||
]
|
||||
|
||||
export default function OrderTimeline({ status, orderDate, paidAt }: OrderTimelineProps) {
|
||||
const currentStatusIndex = steps.findIndex(step =>
|
||||
step.status === status ||
|
||||
(status === "confirming" && step.status === "unpaid") ||
|
||||
(status === "acknowledged" && step.status === "paid") // Processed is after paid
|
||||
);
|
||||
|
||||
// If status is "confirming", it's basically "unpaid" for the timeline
|
||||
// If status is "acknowledged", it's "Processing"
|
||||
|
||||
const getStepStatus = (index: number) => {
|
||||
// Basic logic to determine if a step is completed, current, or pending
|
||||
let effectiveIndex = currentStatusIndex;
|
||||
if (status === "confirming") effectiveIndex = 0;
|
||||
if (status === "paid") effectiveIndex = 1;
|
||||
if (status === "acknowledged") effectiveIndex = 2;
|
||||
if (status === "shipped") effectiveIndex = 3;
|
||||
if (status === "completed") effectiveIndex = 4;
|
||||
|
||||
if (index < effectiveIndex) return "completed";
|
||||
if (index === effectiveIndex) return "current";
|
||||
return "pending";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex justify-between items-center w-full px-4 py-8">
|
||||
{/* Connector Line */}
|
||||
<div className="absolute left-10 right-10 top-1/2 h-0.5 bg-muted -translate-y-1/2 z-0">
|
||||
<motion.div
|
||||
className="h-full bg-primary"
|
||||
initial={{ width: "0%" }}
|
||||
animate={{ width: `${(Math.max(0, steps.findIndex(s => s.status === status)) / (steps.length - 1)) * 100}%` }}
|
||||
transition={{ duration: 1, ease: "easeInOut" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{steps.map((step, index) => {
|
||||
const stepStatus = getStepStatus(index);
|
||||
const Icon = step.icon;
|
||||
|
||||
return (
|
||||
<div key={step.label} className="relative flex flex-col items-center z-10">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className={`flex items-center justify-center w-10 h-10 rounded-full border-2 transition-colors duration-500 ${stepStatus === "completed"
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: stepStatus === "current"
|
||||
? "bg-background border-primary text-primary ring-4 ring-primary/10"
|
||||
: "bg-background border-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</motion.div>
|
||||
<div className="absolute top-12 whitespace-nowrap text-xs font-medium tracking-tight">
|
||||
<p className={stepStatus === "pending" ? "text-muted-foreground" : "text-foreground"}>
|
||||
{step.label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import React from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -127,9 +130,12 @@ const PageSizeSelector = ({ currentSize, onChange, options }: { currentSize: num
|
||||
|
||||
|
||||
export default function OrderTable() {
|
||||
const searchParams = useSearchParams();
|
||||
const initialStatus = searchParams?.get("status") || "all";
|
||||
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [statusFilter, setStatusFilter] = useState(initialStatus);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalOrders, setTotalOrders] = useState(0);
|
||||
@@ -155,6 +161,15 @@ export default function OrderTable() {
|
||||
}, []);
|
||||
|
||||
// Fetch orders with server-side pagination
|
||||
// State for browser detection
|
||||
// Browser detection
|
||||
const [isFirefox, setIsFirefox] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
setIsFirefox(ua.includes("firefox") && !ua.includes("chrome"));
|
||||
}, []);
|
||||
|
||||
const fetchOrders = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -238,37 +253,56 @@ export default function OrderTable() {
|
||||
return;
|
||||
}
|
||||
|
||||
const orderIdsToShip = Array.from(selectedOrders);
|
||||
|
||||
// Store previous state for rollback
|
||||
const previousOrders = [...orders];
|
||||
|
||||
// Optimistic update - immediately mark orders as shipped in UI
|
||||
setOrders(prev =>
|
||||
prev.map(order =>
|
||||
selectedOrders.has(order._id)
|
||||
? { ...order, status: "shipped" as const }
|
||||
: order
|
||||
)
|
||||
);
|
||||
setSelectedOrders(new Set());
|
||||
|
||||
// Show optimistic toast
|
||||
toast.success(`Marking ${orderIdsToShip.length} order(s) as shipped...`, { id: "shipping-optimistic" });
|
||||
|
||||
try {
|
||||
setIsShipping(true);
|
||||
const response = await clientFetch("/orders/mark-shipped", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ orderIds: Array.from(selectedOrders) })
|
||||
body: JSON.stringify({ orderIds: orderIdsToShip })
|
||||
});
|
||||
|
||||
// Only update orders that were successfully marked as shipped
|
||||
// Handle partial success/failure
|
||||
if (response.success && response.success.orders) {
|
||||
const successfulOrderIds = new Set(response.success.orders.map((o: any) => o.id));
|
||||
|
||||
setOrders(prev =>
|
||||
prev.map(order =>
|
||||
successfulOrderIds.has(order._id)
|
||||
? { ...order, status: "shipped" }
|
||||
: order
|
||||
)
|
||||
);
|
||||
|
||||
// If some orders failed, revert those specifically
|
||||
if (response.failed && response.failed.count > 0) {
|
||||
toast.warning(`${response.failed.count} orders could not be marked as shipped`);
|
||||
setOrders(prev =>
|
||||
prev.map(order => {
|
||||
if (orderIdsToShip.includes(order._id) && !successfulOrderIds.has(order._id)) {
|
||||
// Find original status from previousOrders
|
||||
const originalOrder = previousOrders.find(o => o._id === order._id);
|
||||
return originalOrder || order;
|
||||
}
|
||||
|
||||
if (response.success.count > 0) {
|
||||
toast.success(`${response.success.count} orders marked as shipped`);
|
||||
return order;
|
||||
})
|
||||
);
|
||||
toast.warning(`${response.failed.count} order(s) could not be marked as shipped`, { id: "shipping-optimistic" });
|
||||
} else if (response.success.count > 0) {
|
||||
toast.success(`${response.success.count} order(s) marked as shipped!`, { id: "shipping-optimistic" });
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedOrders(new Set());
|
||||
} catch (error) {
|
||||
toast.error("Failed to update orders");
|
||||
// Revert all changes on error
|
||||
setOrders(previousOrders);
|
||||
toast.error("Failed to update orders - changes reverted", { id: "shipping-optimistic" });
|
||||
console.error("Shipping error:", error);
|
||||
} finally {
|
||||
setIsShipping(false);
|
||||
@@ -278,39 +312,40 @@ export default function OrderTable() {
|
||||
const statusConfig: Record<OrderStatus, StatusConfig> = {
|
||||
acknowledged: {
|
||||
icon: CheckCircle2,
|
||||
color: "text-white",
|
||||
bgColor: "bg-purple-600"
|
||||
color: "text-purple-100",
|
||||
bgColor: "bg-purple-600/90 shadow-[0_0_10px_rgba(147,51,234,0.3)]"
|
||||
},
|
||||
paid: {
|
||||
icon: CheckCircle2,
|
||||
color: "text-white",
|
||||
bgColor: "bg-emerald-600"
|
||||
color: "text-emerald-100",
|
||||
bgColor: "bg-emerald-600/90 shadow-[0_0_10px_rgba(16,185,129,0.3)]",
|
||||
animate: "animate-pulse"
|
||||
},
|
||||
unpaid: {
|
||||
icon: XCircle,
|
||||
color: "text-white",
|
||||
bgColor: "bg-red-500"
|
||||
color: "text-amber-100",
|
||||
bgColor: "bg-amber-500/90"
|
||||
},
|
||||
confirming: {
|
||||
icon: Loader2,
|
||||
color: "text-white",
|
||||
bgColor: "bg-yellow-500",
|
||||
color: "text-blue-100",
|
||||
bgColor: "bg-blue-500/90",
|
||||
animate: "animate-spin"
|
||||
},
|
||||
shipped: {
|
||||
icon: Truck,
|
||||
color: "text-white",
|
||||
bgColor: "bg-blue-600"
|
||||
color: "text-indigo-100",
|
||||
bgColor: "bg-indigo-600/90 shadow-[0_0_10px_rgba(79,70,229,0.3)]"
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
color: "text-white",
|
||||
bgColor: "bg-green-600"
|
||||
color: "text-green-100",
|
||||
bgColor: "bg-green-600/90"
|
||||
},
|
||||
cancelled: {
|
||||
icon: XCircle,
|
||||
color: "text-white",
|
||||
bgColor: "bg-gray-500"
|
||||
color: "text-gray-100",
|
||||
bgColor: "bg-gray-600/90"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -391,9 +426,9 @@ export default function OrderTable() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="border border-zinc-800 rounded-md bg-black/40 overflow-hidden">
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
||||
{/* Filters header */}
|
||||
<div className="p-4 border-b border-zinc-800 bg-black/60">
|
||||
<div className="p-4 border-b border-border/50 bg-muted/30">
|
||||
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4">
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm:items-center w-full lg:w-auto">
|
||||
<StatusFilter
|
||||
@@ -413,6 +448,7 @@ export default function OrderTable() {
|
||||
disabled={exporting}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-background/50 border-border/50 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{exporting ? (
|
||||
<>
|
||||
@@ -432,8 +468,8 @@ export default function OrderTable() {
|
||||
<div className="flex items-center gap-2 self-end lg:self-auto">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button disabled={selectedOrders.size === 0 || isShipping}>
|
||||
<Truck className="mr-2 h-5 w-5" />
|
||||
<Button disabled={selectedOrders.size === 0 || isShipping} className="shadow-md">
|
||||
<Truck className="mr-2 h-4 w-4" />
|
||||
{isShipping ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
@@ -462,63 +498,76 @@ export default function OrderTable() {
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="relative">
|
||||
<CardContent className="p-0 relative min-h-[400px]">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 bg-background/50 flex items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-[2px] flex items-center justify-center z-50">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-indigo-500" />
|
||||
<span className="text-zinc-400 text-sm font-medium">Loading orders...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-[calc(100vh-300px)] overflow-auto">
|
||||
<Table className="[&_tr]:border-b [&_tr]:border-zinc-800 [&_tr:last-child]:border-b-0 [&_td]:border-r [&_td]:border-zinc-800 [&_td:last-child]:border-r-0 [&_th]:border-r [&_th]:border-zinc-800 [&_th:last-child]:border-r-0 [&_tr:hover]:bg-zinc-900/70">
|
||||
<TableHeader className="bg-black/60 sticky top-0 z-10">
|
||||
<TableRow>
|
||||
<div className="max-h-[calc(100vh-350px)] overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/30 sticky top-0 z-20">
|
||||
<TableRow className="hover:bg-transparent border-border/50">
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={selectedOrders.size === paginatedOrders.length && paginatedOrders.length > 0}
|
||||
onCheckedChange={toggleAll}
|
||||
className="border-white/20 data-[state=checked]:bg-indigo-500 data-[state=checked]:border-indigo-500"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="cursor-pointer" onClick={() => handleSort("orderId")}>
|
||||
Order ID <ArrowUpDown className="ml-2 inline h-4 w-4" />
|
||||
<TableHead className="cursor-pointer hover:text-indigo-400 transition-colors text-zinc-400" onClick={() => handleSort("orderId")}>
|
||||
Order ID <ArrowUpDown className="ml-2 inline h-3 w-3 opacity-50" />
|
||||
</TableHead>
|
||||
<TableHead className="cursor-pointer" onClick={() => handleSort("totalPrice")}>
|
||||
Total <ArrowUpDown className="ml-2 inline h-4 w-4" />
|
||||
<TableHead className="cursor-pointer hover:text-indigo-400 transition-colors text-zinc-400" onClick={() => handleSort("totalPrice")}>
|
||||
Total <ArrowUpDown className="ml-2 inline h-3 w-3 opacity-50" />
|
||||
</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Promotion</TableHead>
|
||||
<TableHead className="cursor-pointer" onClick={() => handleSort("status")}>
|
||||
Status <ArrowUpDown className="ml-2 inline h-4 w-4" />
|
||||
<TableHead className="hidden lg:table-cell text-zinc-400">Promotion</TableHead>
|
||||
<TableHead className="cursor-pointer hover:text-indigo-400 transition-colors text-zinc-400" onClick={() => handleSort("status")}>
|
||||
Status <ArrowUpDown className="ml-2 inline h-3 w-3 opacity-50" />
|
||||
</TableHead>
|
||||
<TableHead className="hidden md:table-cell cursor-pointer" onClick={() => handleSort("orderDate")}>
|
||||
Order Date <ArrowUpDown className="ml-2 inline h-4 w-4" />
|
||||
<TableHead className="hidden md:table-cell cursor-pointer hover:text-indigo-400 transition-colors text-zinc-400" onClick={() => handleSort("orderDate")}>
|
||||
Date <ArrowUpDown className="ml-2 inline h-3 w-3 opacity-50" />
|
||||
</TableHead>
|
||||
<TableHead className="hidden xl:table-cell cursor-pointer" onClick={() => handleSort("paidAt")}>
|
||||
Paid At <ArrowUpDown className="ml-2 inline h-4 w-4" />
|
||||
<TableHead className="hidden xl:table-cell cursor-pointer hover:text-indigo-400 transition-colors text-zinc-400" onClick={() => handleSort("paidAt")}>
|
||||
Paid At <ArrowUpDown className="ml-2 inline h-3 w-3 opacity-50" />
|
||||
</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Buyer</TableHead>
|
||||
<TableHead className="w-24 text-center">Actions</TableHead>
|
||||
<TableHead className="hidden lg:table-cell text-zinc-400">Buyer</TableHead>
|
||||
<TableHead className="w-24 text-center text-zinc-400">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedOrders.map((order) => {
|
||||
{isFirefox ? (
|
||||
paginatedOrders.map((order, index) => {
|
||||
const StatusIcon = statusConfig[order.status as keyof typeof statusConfig]?.icon || XCircle;
|
||||
const underpaidInfo = getUnderpaidInfo(order);
|
||||
|
||||
return (
|
||||
<TableRow key={order._id}>
|
||||
<motion.tr
|
||||
key={order._id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="group hover:bg-muted/50 border-b border-border/50 transition-colors"
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedOrders.has(order._id)}
|
||||
onCheckedChange={() => toggleSelection(order._id)}
|
||||
disabled={order.status !== "paid" && order.status !== "acknowledged"}
|
||||
className="border-white/20 data-[state=checked]:bg-indigo-500 data-[state=checked]:border-indigo-500"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>#{order.orderId}</TableCell>
|
||||
<TableCell className="font-mono text-sm font-medium text-zinc-300">#{order.orderId}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span>£{order.totalPrice.toFixed(2)}</span>
|
||||
<span className="font-medium text-zinc-200">£{order.totalPrice.toFixed(2)}</span>
|
||||
{underpaidInfo && (
|
||||
<span className="text-xs text-red-400">
|
||||
Missing: £{underpaidInfo.missingGbp.toFixed(2)} ({underpaidInfo.missing.toFixed(8)} LTC)
|
||||
<span className="text-[10px] text-red-400 flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
-£{underpaidInfo.missingGbp.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -527,69 +576,63 @@ export default function OrderTable() {
|
||||
{order.promotionCode ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<Tag className="h-3 w-3 text-green-500" />
|
||||
<span className="text-xs font-mono bg-green-100 text-green-800 px-2 py-0.5 rounded">
|
||||
<Tag className="h-3 w-3 text-emerald-400" />
|
||||
<span className="text-[10px] font-mono bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5 rounded border border-emerald-500/20">
|
||||
{order.promotionCode}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-green-600">
|
||||
<Percent className="h-3 w-3" />
|
||||
<div className="flex items-center gap-1 text-[10px] text-emerald-400/80">
|
||||
<Percent className="h-2.5 w-2.5" />
|
||||
<span>-£{(order.discountAmount || 0).toFixed(2)}</span>
|
||||
{order.subtotalBeforeDiscount && order.subtotalBeforeDiscount > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
(was £{order.subtotalBeforeDiscount.toFixed(2)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
<span className="text-xs text-zinc-600">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm ${
|
||||
statusConfig[order.status as OrderStatus]?.bgColor || "bg-gray-500"
|
||||
} ${statusConfig[order.status as OrderStatus]?.color || "text-white"}`}>
|
||||
<div className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border shadow-sm ${statusConfig[order.status as OrderStatus]?.bgColor || "bg-muted text-muted-foreground border-border"} ${statusConfig[order.status as OrderStatus]?.color || ""}`}>
|
||||
{React.createElement(statusConfig[order.status as OrderStatus]?.icon || XCircle, {
|
||||
className: `h-4 w-4 ${statusConfig[order.status as OrderStatus]?.animate || ""}`
|
||||
className: `h-3.5 w-3.5 ${statusConfig[order.status as OrderStatus]?.animate || ""}`
|
||||
})}
|
||||
{order.status.charAt(0).toUpperCase() + order.status.slice(1)}
|
||||
</div>
|
||||
{isOrderUnderpaid(order) && (
|
||||
<div className="flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-red-600 text-white">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
<div className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-red-500/10 text-red-400 border border-red-500/20 font-medium">
|
||||
{underpaidInfo?.percentage}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<TableCell className="hidden md:table-cell text-sm text-zinc-400">
|
||||
{new Date(order.orderDate).toLocaleDateString("en-GB", {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})}
|
||||
<span className="ml-1 opacity-50 text-[10px]">
|
||||
{new Date(order.orderDate).toLocaleTimeString("en-GB", { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden xl:table-cell">
|
||||
<TableCell className="hidden xl:table-cell text-sm text-zinc-400">
|
||||
{order.paidAt ? new Date(order.paidAt).toLocaleDateString("en-GB", {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
minute: '2-digit'
|
||||
}) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
{order.telegramUsername ? `@${order.telegramUsername}` : "-"}
|
||||
{order.telegramUsername ? (
|
||||
<span className="text-sm font-medium text-primary">@{order.telegramUsername}</span>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-500 italic">Guest</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-zinc-400 hover:text-white hover:bg-white/10" asChild>
|
||||
<Link href={`/dashboard/orders/${order._id}`}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
@@ -598,28 +641,151 @@ export default function OrderTable() {
|
||||
{(order.telegramBuyerId || order.telegramUsername) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-zinc-400 hover:text-indigo-400 hover:bg-indigo-500/10"
|
||||
asChild
|
||||
title={`Chat with customer${order.telegramUsername ? ` @${order.telegramUsername}` : ''}`}
|
||||
>
|
||||
<Link href={`/dashboard/chats/new?buyerId=${order.telegramBuyerId || order.telegramUsername}`}>
|
||||
<MessageCircle className="h-4 w-4 text-primary" />
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</motion.tr>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<AnimatePresence mode="popLayout">
|
||||
{paginatedOrders.map((order, index) => {
|
||||
const StatusIcon = statusConfig[order.status as keyof typeof statusConfig]?.icon || XCircle;
|
||||
const underpaidInfo = getUnderpaidInfo(order);
|
||||
|
||||
return (
|
||||
<motion.tr
|
||||
key={order._id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.03 }}
|
||||
className="group hover:bg-muted/50 border-b border-border/50 transition-colors"
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedOrders.has(order._id)}
|
||||
onCheckedChange={() => toggleSelection(order._id)}
|
||||
disabled={order.status !== "paid" && order.status !== "acknowledged"}
|
||||
className="border-white/20 data-[state=checked]:bg-indigo-500 data-[state=checked]:border-indigo-500"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm font-medium text-zinc-300">#{order.orderId}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-zinc-200">£{order.totalPrice.toFixed(2)}</span>
|
||||
{underpaidInfo && (
|
||||
<span className="text-[10px] text-red-400 flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
-£{underpaidInfo.missingGbp.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
{order.promotionCode ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<Tag className="h-3 w-3 text-emerald-400" />
|
||||
<span className="text-[10px] font-mono bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5 rounded border border-emerald-500/20">
|
||||
{order.promotionCode}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[10px] text-emerald-400/80">
|
||||
<Percent className="h-2.5 w-2.5" />
|
||||
<span>-£{(order.discountAmount || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-600">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border shadow-sm ${statusConfig[order.status as OrderStatus]?.bgColor || "bg-muted text-muted-foreground border-border"} ${statusConfig[order.status as OrderStatus]?.color || ""}`}>
|
||||
{React.createElement(statusConfig[order.status as OrderStatus]?.icon || XCircle, {
|
||||
className: `h-3.5 w-3.5 ${statusConfig[order.status as OrderStatus]?.animate || ""}`
|
||||
})}
|
||||
{order.status.charAt(0).toUpperCase() + order.status.slice(1)}
|
||||
</div>
|
||||
{isOrderUnderpaid(order) && (
|
||||
<div className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-red-500/10 text-red-400 border border-red-500/20 font-medium">
|
||||
{underpaidInfo?.percentage}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-sm text-zinc-400">
|
||||
{new Date(order.orderDate).toLocaleDateString("en-GB", {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
<span className="ml-1 opacity-50 text-[10px]">
|
||||
{new Date(order.orderDate).toLocaleTimeString("en-GB", { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden xl:table-cell text-sm text-zinc-400">
|
||||
{order.paidAt ? new Date(order.paidAt).toLocaleDateString("en-GB", {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
{order.telegramUsername ? (
|
||||
<span className="text-sm font-medium text-primary">@{order.telegramUsername}</span>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-500 italic">Guest</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-zinc-400 hover:text-white hover:bg-white/10" asChild>
|
||||
<Link href={`/dashboard/orders/${order._id}`}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{(order.telegramBuyerId || order.telegramUsername) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-zinc-400 hover:text-indigo-400 hover:bg-indigo-500/10"
|
||||
asChild
|
||||
title={`Chat with customer${order.telegramUsername ? ` @${order.telegramUsername}` : ''}`}
|
||||
>
|
||||
<Link href={`/dashboard/chats/new?buyerId=${order.telegramBuyerId || order.telegramUsername}`}>
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</motion.tr>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between px-4 py-4 border-t border-zinc-800 bg-black/40">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-between px-4 py-4 border-t border-white/5 bg-white/[0.02]">
|
||||
<div className="text-sm text-zinc-500">
|
||||
Page {currentPage} of {totalPages} ({totalOrders} total)
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -628,8 +794,9 @@ export default function OrderTable() {
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1 || loading}
|
||||
className="h-8 bg-transparent border-white/10 hover:bg-white/5 text-zinc-400 hover:text-white"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<ChevronLeft className="h-3 w-3 mr-1" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
@@ -637,14 +804,15 @@ export default function OrderTable() {
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages || loading}
|
||||
className="h-8 bg-transparent border-white/10 hover:bg-white/5 text-zinc-400 hover:text-white"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<ChevronRight className="h-3 w-3 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
Edit,
|
||||
Trash,
|
||||
@@ -14,11 +15,23 @@ import {
|
||||
AlertCircle,
|
||||
Calculator,
|
||||
Copy,
|
||||
PackageX,
|
||||
Archive
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Product } from "@/models/products";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
const getProductImageUrl = (product: Product) => {
|
||||
if (!product.image) return null;
|
||||
if (typeof product.image === 'string' && product.image.startsWith('http')) return product.image;
|
||||
// Use the API endpoint to serve the image
|
||||
return `${process.env.NEXT_PUBLIC_API_URL}/products/${product._id}/image`;
|
||||
};
|
||||
|
||||
interface ProductTableProps {
|
||||
products: Product[];
|
||||
@@ -68,35 +81,60 @@ const ProductTable = ({
|
||||
}
|
||||
};
|
||||
|
||||
const renderProductRow = (product: Product, isDisabled: boolean = false) => (
|
||||
<TableRow
|
||||
const renderProductRow = (product: Product, index: number, isDisabled: boolean = false) => (
|
||||
<motion.tr
|
||||
key={product._id}
|
||||
className={`transition-colors hover:bg-gray-50 dark:hover:bg-zinc-800/70 ${isDisabled ? "opacity-60" : ""}`}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}
|
||||
className={`group hover:bg-muted/40 border-b border-border/50 transition-colors ${isDisabled ? "opacity-60 bg-muted/20" : ""}`}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="font-medium truncate max-w-[180px]">{product.name}</div>
|
||||
<div className="hidden sm:block text-sm text-muted-foreground mt-1">
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded bg-muted/50 flex items-center justify-center text-muted-foreground overflow-hidden relative">
|
||||
{getProductImageUrl(product) ? (
|
||||
<Image
|
||||
src={getProductImageUrl(product)!}
|
||||
alt={product.name}
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-full w-full object-cover"
|
||||
unoptimized={getProductImageUrl(product)?.startsWith('data:')}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs font-bold">{product.name.charAt(0).toUpperCase()}</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="truncate max-w-[180px]">{product.name}</div>
|
||||
<div className="sm:hidden text-xs text-muted-foreground">
|
||||
{getCategoryNameById(product.category)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell text-center">
|
||||
<Badge variant="outline" className="font-normal bg-background/50">
|
||||
{getCategoryNameById(product.category)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-center">
|
||||
<TableCell className="hidden md:table-cell text-center text-muted-foreground text-sm">
|
||||
{product.unitType}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{product.stockTracking ? (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
{getStockIcon(product)}
|
||||
<span className="text-sm">
|
||||
{product.currentStock !== undefined ? product.currentStock : 0}{" "}
|
||||
{product.unitType}
|
||||
<span className={`text-sm font-medium ${product.stockStatus === 'out_of_stock' ? 'text-destructive' :
|
||||
product.stockStatus === 'low_stock' ? 'text-amber-500' : 'text-foreground'
|
||||
}`}>
|
||||
{product.currentStock !== undefined ? product.currentStock : 0}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Not Tracked
|
||||
<Badge variant="secondary" className="text-[10px] h-5 px-1.5 text-muted-foreground bg-muted/50">
|
||||
Unlimited
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
@@ -106,17 +144,19 @@ const ProductTable = ({
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleEnabled(product._id as string, checked)
|
||||
}
|
||||
className="data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right flex justify-end space-x-1">
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{onProfitAnalysis && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
onProfitAnalysis(product._id as string, product.name)
|
||||
}
|
||||
className="text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-950/20"
|
||||
className="h-8 w-8 text-emerald-500 hover:text-emerald-600 hover:bg-emerald-500/10"
|
||||
title="Profit Analysis"
|
||||
>
|
||||
<Calculator className="h-4 w-4" />
|
||||
@@ -125,9 +165,9 @@ const ProductTable = ({
|
||||
{onClone && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon"
|
||||
onClick={() => onClone(product)}
|
||||
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50 dark:hover:bg-blue-950/20"
|
||||
className="h-8 w-8 text-blue-500 hover:text-blue-600 hover:bg-blue-500/10"
|
||||
title="Clone Listing"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
@@ -135,28 +175,39 @@ const ProductTable = ({
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon"
|
||||
onClick={() => onEdit(product)}
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
title="Edit Product"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon"
|
||||
onClick={() => onDelete(product._id as string)}
|
||||
className="text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
title="Delete Product"
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</motion.tr>
|
||||
);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
// Browser detection
|
||||
const [isFirefox, setIsFirefox] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
setIsFirefox(ua.includes("firefox") && !ua.includes("chrome"));
|
||||
}, []);
|
||||
|
||||
const renderTableHeader = () => (
|
||||
<TableHeader className="bg-gray-50 dark:bg-zinc-800/50">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHeader className="bg-muted/50 sticky top-0 z-10">
|
||||
<TableRow className="hover:bg-transparent border-border/50">
|
||||
<TableHead className="w-[200px]">Product</TableHead>
|
||||
<TableHead className="hidden sm:table-cell text-center">
|
||||
Category
|
||||
@@ -166,57 +217,115 @@ const ProductTable = ({
|
||||
<TableHead className="hidden lg:table-cell text-center">
|
||||
Enabled
|
||||
</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
<TableHead className="text-right pr-6">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
{/* Enabled Products Table */}
|
||||
<div className="rounded-lg border dark:border-zinc-700 shadow-sm overflow-hidden">
|
||||
<Table className="relative">
|
||||
<Card className="border-white/10 bg-black/40 backdrop-blur-xl shadow-2xl overflow-hidden rounded-xl">
|
||||
<CardHeader className="py-4 px-6 border-b border-white/5 bg-white/[0.02]">
|
||||
<CardTitle className="text-lg font-bold flex items-center gap-3 text-white">
|
||||
<div className="p-2 rounded-lg bg-indigo-500/10 border border-indigo-500/20">
|
||||
<CheckCircle className="h-5 w-5 text-indigo-400" />
|
||||
</div>
|
||||
Active Products
|
||||
<Badge variant="secondary" className="ml-2 bg-indigo-500/10 text-indigo-300 border-indigo-500/20 hover:bg-indigo-500/20">
|
||||
{sortedEnabledProducts.length}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="max-h-[600px] overflow-auto">
|
||||
<Table>
|
||||
{renderTableHeader()}
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
Array.from({ length: 1 }).map((_, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>Loading...</TableCell>
|
||||
<TableCell>Loading...</TableCell>
|
||||
<TableCell>Loading...</TableCell>
|
||||
<TableCell>Loading...</TableCell>
|
||||
<TableCell>Loading...</TableCell>
|
||||
<TableCell>Loading...</TableCell>
|
||||
{isFirefox ? (
|
||||
loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-indigo-500 border-t-transparent" />
|
||||
<span>Loading products...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : sortedEnabledProducts.length > 0 ? (
|
||||
sortedEnabledProducts.map((product) => renderProductRow(product))
|
||||
sortedEnabledProducts.map((product, index) => renderProductRow(product, index))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-24 text-center">
|
||||
No enabled products found.
|
||||
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<PackageX className="h-8 w-8 opacity-20" />
|
||||
<p>No active products found</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
) : (
|
||||
<AnimatePresence>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-indigo-500 border-t-transparent" />
|
||||
<span>Loading products...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : sortedEnabledProducts.length > 0 ? (
|
||||
sortedEnabledProducts.map((product, index) => renderProductRow(product, index))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<PackageX className="h-8 w-8 opacity-20" />
|
||||
<p>No active products found</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Disabled Products Section */}
|
||||
{!loading && disabledProducts.length > 0 && (
|
||||
<div className="rounded-lg border dark:border-zinc-700 shadow-sm overflow-hidden bg-gray-50/30 dark:bg-zinc-900/30">
|
||||
<Table className="relative">
|
||||
<Card className="border-white/5 bg-black/20 backdrop-blur-sm shadow-none overflow-hidden opacity-80 hover:opacity-100 transition-opacity">
|
||||
<CardHeader className="py-4 px-6 border-b border-white/5 bg-white/[0.01]">
|
||||
<CardTitle className="text-lg font-medium flex items-center gap-2 text-zinc-400">
|
||||
<Archive className="h-5 w-5" />
|
||||
Archived / Disabled
|
||||
<Badge variant="outline" className="ml-2 border-white/10 text-zinc-500">
|
||||
{sortedDisabledProducts.length}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="max-h-[400px] overflow-auto">
|
||||
<Table>
|
||||
{renderTableHeader()}
|
||||
<TableBody>
|
||||
{sortedDisabledProducts.map((product) =>
|
||||
renderProductRow(product, true),
|
||||
<AnimatePresence>
|
||||
{sortedDisabledProducts.map((product, index) =>
|
||||
renderProductRow(product, index, true),
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
export default ProductTable;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
@@ -8,8 +9,9 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Edit, Trash } from "lucide-react";
|
||||
|
||||
import { Edit, Trash, Truck, PackageX } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ShippingMethod } from "@/lib/types";
|
||||
|
||||
interface ShippingTableProps {
|
||||
@@ -25,62 +27,161 @@ export const ShippingTable: React.FC<ShippingTableProps> = ({
|
||||
onEditShipping,
|
||||
onDeleteShipping,
|
||||
}) => {
|
||||
// Browser detection
|
||||
const [isFirefox, setIsFirefox] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
setIsFirefox(ua.includes("firefox") && !ua.includes("chrome"));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border dark:border-zinc-700 shadow-sm overflow-hidden">
|
||||
<Table className="relative">
|
||||
<TableHeader className="bg-gray-50 dark:bg-zinc-800/50">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[60%]">Name</TableHead>
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
||||
<CardHeader className="py-4 px-6 border-b border-border/50 bg-muted/30">
|
||||
<CardTitle className="text-lg font-medium flex items-center gap-2">
|
||||
<Truck className="h-5 w-5 text-primary" />
|
||||
Available Methods
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50 sticky top-0 z-10">
|
||||
<TableRow className="hover:bg-transparent border-border/50">
|
||||
<TableHead className="w-[60%] pl-6">Method Name</TableHead>
|
||||
<TableHead className="text-center">Price</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
<TableHead className="text-right pr-6">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
{isFirefox ? (
|
||||
loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center">
|
||||
<Skeleton className="h-4 w-[200px]" />
|
||||
<TableCell colSpan={3} className="h-24 text-center">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
Loading methods...
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : shippingMethods.length > 0 ? (
|
||||
shippingMethods.map((method) => (
|
||||
<TableRow
|
||||
shippingMethods.map((method, index) => (
|
||||
<motion.tr
|
||||
key={method._id}
|
||||
className="transition-colors hover:bg-gray-50 dark:hover:bg-zinc-800/70"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}
|
||||
className="group hover:bg-muted/40 border-b border-border/50 transition-colors"
|
||||
>
|
||||
<TableCell className="font-medium">{method.name}</TableCell>
|
||||
<TableCell className="text-center">£{method.price}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end">
|
||||
<TableCell className="font-medium pl-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Truck className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
{method.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center font-mono">£{method.price}</TableCell>
|
||||
<TableCell className="text-right pr-6">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
onClick={() => onEditShipping(method)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-red-600 hover:text-red-700 dark:text-red-400"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
onClick={() => onDeleteShipping(method._id ?? "")}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</motion.tr>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-24 text-center">
|
||||
No shipping methods found
|
||||
<TableCell colSpan={3} className="h-32 text-center text-muted-foreground">
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<PackageX className="h-8 w-8 opacity-20" />
|
||||
<p>No shipping methods found</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
) : (
|
||||
<AnimatePresence mode="popLayout">
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-24 text-center">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
Loading methods...
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : shippingMethods.length > 0 ? (
|
||||
shippingMethods.map((method, index) => (
|
||||
<motion.tr
|
||||
key={method._id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}
|
||||
className="group hover:bg-muted/40 border-b border-border/50 transition-colors"
|
||||
>
|
||||
<TableCell className="font-medium pl-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Truck className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
{method.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center font-mono">£{method.price}</TableCell>
|
||||
<TableCell className="text-right pr-6">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
onClick={() => onEditShipping(method)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
onClick={() => onDeleteShipping(method._id ?? "")}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</motion.tr>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-32 text-center text-muted-foreground">
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<PackageX className="h-8 w-8 opacity-20" />
|
||||
<p>No shipping methods found</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
138
components/ui/empty-state.tsx
Normal file
138
components/ui/empty-state.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Package,
|
||||
ShoppingBag,
|
||||
Users,
|
||||
Truck,
|
||||
MessageCircle,
|
||||
Plus,
|
||||
Share2,
|
||||
LucideIcon
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: LucideIcon
|
||||
title: string
|
||||
description: string
|
||||
actionLabel?: string
|
||||
actionHref?: string
|
||||
actionOnClick?: () => void
|
||||
secondaryActionLabel?: string
|
||||
secondaryActionHref?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* EmptyState - Reusable component for empty tables/lists
|
||||
* Shows an icon, title, description, and optional action button
|
||||
*/
|
||||
export function EmptyState({
|
||||
icon: Icon = Package,
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
actionHref,
|
||||
actionOnClick,
|
||||
secondaryActionLabel,
|
||||
secondaryActionHref,
|
||||
className = ""
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div className={`flex flex-col items-center justify-center py-12 px-4 text-center ${className}`}>
|
||||
<div className="h-16 w-16 rounded-full bg-muted/50 flex items-center justify-center mb-4">
|
||||
<Icon className="h-8 w-8 text-muted-foreground/50" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-2">{title}</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm mb-6">{description}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{actionLabel && (
|
||||
actionHref ? (
|
||||
<Button asChild>
|
||||
<Link href={actionHref}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{actionLabel}
|
||||
</Link>
|
||||
</Button>
|
||||
) : actionOnClick ? (
|
||||
<Button onClick={actionOnClick}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{actionLabel}
|
||||
</Button>
|
||||
) : null
|
||||
)}
|
||||
{secondaryActionLabel && secondaryActionHref && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={secondaryActionHref}>
|
||||
<Share2 className="h-4 w-4 mr-2" />
|
||||
{secondaryActionLabel}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Preset empty states for common scenarios
|
||||
export function OrdersEmptyState() {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={ShoppingBag}
|
||||
title="No orders yet"
|
||||
description="When customers place orders, they'll appear here. Share your store link to start selling!"
|
||||
secondaryActionLabel="Share Store"
|
||||
secondaryActionHref="/dashboard/storefront"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProductsEmptyState({ onAddProduct }: { onAddProduct?: () => void }) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title="No products yet"
|
||||
description="Add your first product to start selling. You can add products manually or import from a file."
|
||||
actionLabel="Add Product"
|
||||
actionOnClick={onAddProduct}
|
||||
actionHref={onAddProduct ? undefined : "/dashboard/products/add"}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function CustomersEmptyState() {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No customers yet"
|
||||
description="Once customers interact with your store, they'll appear here. Share your store link to attract customers!"
|
||||
secondaryActionLabel="Share Store"
|
||||
secondaryActionHref="/dashboard/storefront"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ShippingEmptyState({ onAddMethod }: { onAddMethod?: () => void }) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Truck}
|
||||
title="No shipping methods"
|
||||
description="Add shipping methods so customers know how they'll receive their orders."
|
||||
actionLabel="Add Shipping Method"
|
||||
actionOnClick={onAddMethod}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatsEmptyState() {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={MessageCircle}
|
||||
title="No conversations yet"
|
||||
description="Customer chats will appear here when they message you through Telegram."
|
||||
/>
|
||||
)
|
||||
}
|
||||
30
components/ui/motion-wrapper.tsx
Normal file
30
components/ui/motion-wrapper.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { motion, HTMLMotionProps } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
interface MotionWrapperProps extends HTMLMotionProps<"div"> {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const MotionWrapper = forwardRef<HTMLDivElement, MotionWrapperProps>(
|
||||
({ children, className, ...props }, ref) => {
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
MotionWrapper.displayName = "MotionWrapper";
|
||||
114
components/ui/relative-time.tsx
Normal file
114
components/ui/relative-time.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { format, formatDistanceToNow, isToday, isYesterday, differenceInMinutes } from "date-fns"
|
||||
|
||||
interface RelativeTimeProps {
|
||||
date: Date | string | null | undefined
|
||||
className?: string
|
||||
showTooltip?: boolean
|
||||
updateInterval?: number // ms, for auto-updating recent times
|
||||
}
|
||||
|
||||
/**
|
||||
* RelativeTime - Displays time as "2 hours ago" with full date on hover
|
||||
* Auto-updates for times less than 1 hour old
|
||||
*/
|
||||
export function RelativeTime({
|
||||
date,
|
||||
className = "",
|
||||
showTooltip = true,
|
||||
updateInterval = 60000 // Update every minute
|
||||
}: RelativeTimeProps) {
|
||||
const [, forceUpdate] = React.useReducer(x => x + 1, 0)
|
||||
|
||||
const parsedDate = React.useMemo(() => {
|
||||
if (!date) return null
|
||||
return typeof date === "string" ? new Date(date) : date
|
||||
}, [date])
|
||||
|
||||
// Auto-update for recent times
|
||||
React.useEffect(() => {
|
||||
if (!parsedDate) return
|
||||
|
||||
const minutesAgo = differenceInMinutes(new Date(), parsedDate)
|
||||
|
||||
// Only auto-update if within the last hour
|
||||
if (minutesAgo < 60) {
|
||||
const interval = setInterval(forceUpdate, updateInterval)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [parsedDate, updateInterval])
|
||||
|
||||
if (!parsedDate || isNaN(parsedDate.getTime())) {
|
||||
return <span className={className}>-</span>
|
||||
}
|
||||
|
||||
const formatRelative = (d: Date): string => {
|
||||
const now = new Date()
|
||||
const minutesAgo = differenceInMinutes(now, d)
|
||||
|
||||
// Just now (< 1 minute)
|
||||
if (minutesAgo < 1) return "Just now"
|
||||
|
||||
// Minutes ago (< 60 minutes)
|
||||
if (minutesAgo < 60) return `${minutesAgo}m ago`
|
||||
|
||||
// Hours ago (< 24 hours and today)
|
||||
if (isToday(d)) {
|
||||
const hoursAgo = Math.floor(minutesAgo / 60)
|
||||
return `${hoursAgo}h ago`
|
||||
}
|
||||
|
||||
// Yesterday
|
||||
if (isYesterday(d)) return "Yesterday"
|
||||
|
||||
// Use formatDistanceToNow for older dates
|
||||
return formatDistanceToNow(d, { addSuffix: true })
|
||||
}
|
||||
|
||||
const fullDate = format(parsedDate, "dd MMM yyyy, HH:mm")
|
||||
const relativeText = formatRelative(parsedDate)
|
||||
|
||||
if (!showTooltip) {
|
||||
return <span className={className}>{relativeText}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={`cursor-default ${className}`}>{relativeText}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
{fullDate}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to get relative time string without component
|
||||
*/
|
||||
export function getRelativeTimeString(date: Date | string | null | undefined): string {
|
||||
if (!date) return "-"
|
||||
const d = typeof date === "string" ? new Date(date) : date
|
||||
if (isNaN(d.getTime())) return "-"
|
||||
|
||||
const now = new Date()
|
||||
const minutesAgo = differenceInMinutes(now, d)
|
||||
|
||||
if (minutesAgo < 1) return "Just now"
|
||||
if (minutesAgo < 60) return `${minutesAgo}m ago`
|
||||
if (isToday(d)) return `${Math.floor(minutesAgo / 60)}h ago`
|
||||
if (isYesterday(d)) return "Yesterday"
|
||||
|
||||
return formatDistanceToNow(d, { addSuffix: true })
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Package, Clock, CheckCircle, AlertTriangle } from "lucide-react"
|
||||
|
||||
export const statsConfig = [
|
||||
{ title: "Total Orders", key: "totalOrders", icon: Package },
|
||||
{ title: "Completed Orders", key: "completedOrders", icon: CheckCircle },
|
||||
{ title: "Pending Orders", key: "ongoingOrders", icon: Clock },
|
||||
{ title: "Cancelled Orders", key: "cancelledOrders", icon: AlertTriangle },
|
||||
{ title: "Total Orders", key: "totalOrders", icon: Package, filterStatus: "all" },
|
||||
{ title: "Completed Orders", key: "completedOrders", icon: CheckCircle, filterStatus: "completed" },
|
||||
{ title: "Pending Orders", key: "ongoingOrders", icon: Clock, filterStatus: "paid" },
|
||||
{ title: "Cancelled Orders", key: "cancelledOrders", icon: AlertTriangle, filterStatus: "cancelled" },
|
||||
]
|
||||
|
||||
|
||||
165
hooks/useFilterState.ts
Normal file
165
hooks/useFilterState.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { usePathname, useSearchParams, useRouter } from "next/navigation"
|
||||
import { DateRange } from "react-day-picker"
|
||||
|
||||
interface FilterState {
|
||||
searchQuery?: string
|
||||
statusFilter?: string
|
||||
dateRange?: DateRange
|
||||
page?: number
|
||||
itemsPerPage?: number
|
||||
sortColumn?: string
|
||||
sortDirection?: "asc" | "desc"
|
||||
}
|
||||
|
||||
interface UseFilterStateOptions {
|
||||
/** Unique key for this page's filter state */
|
||||
storageKey: string
|
||||
/** Initialize from URL params on mount */
|
||||
syncWithUrl?: boolean
|
||||
/** Default values */
|
||||
defaults?: Partial<FilterState>
|
||||
}
|
||||
|
||||
/**
|
||||
* useFilterState - Persist filter state across navigation
|
||||
* Uses sessionStorage to remember filters per page
|
||||
*/
|
||||
export function useFilterState({
|
||||
storageKey,
|
||||
syncWithUrl = false,
|
||||
defaults = {}
|
||||
}: UseFilterStateOptions) {
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
|
||||
const fullKey = `filterState:${storageKey}`
|
||||
|
||||
// Initialize state from sessionStorage or URL params
|
||||
const getInitialState = (): FilterState => {
|
||||
if (typeof window === "undefined") return defaults
|
||||
|
||||
// First try URL params if syncWithUrl is enabled
|
||||
if (syncWithUrl && searchParams) {
|
||||
const urlState: FilterState = {}
|
||||
const status = searchParams.get("status")
|
||||
const search = searchParams.get("search")
|
||||
const page = searchParams.get("page")
|
||||
|
||||
if (status) urlState.statusFilter = status
|
||||
if (search) urlState.searchQuery = search
|
||||
if (page) urlState.page = parseInt(page)
|
||||
|
||||
if (Object.keys(urlState).length > 0) {
|
||||
return { ...defaults, ...urlState }
|
||||
}
|
||||
}
|
||||
|
||||
// Then try sessionStorage
|
||||
try {
|
||||
const stored = sessionStorage.getItem(fullKey)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored)
|
||||
// Restore dateRange as Date objects
|
||||
if (parsed.dateRange) {
|
||||
if (parsed.dateRange.from) parsed.dateRange.from = new Date(parsed.dateRange.from)
|
||||
if (parsed.dateRange.to) parsed.dateRange.to = new Date(parsed.dateRange.to)
|
||||
}
|
||||
return { ...defaults, ...parsed }
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to load filter state from storage:", e)
|
||||
}
|
||||
|
||||
return defaults
|
||||
}
|
||||
|
||||
const [filterState, setFilterState] = useState<FilterState>(getInitialState)
|
||||
|
||||
// Save to sessionStorage whenever state changes
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(fullKey, JSON.stringify(filterState))
|
||||
} catch (e) {
|
||||
console.warn("Failed to save filter state to storage:", e)
|
||||
}
|
||||
}, [filterState, fullKey])
|
||||
|
||||
// Update URL if syncWithUrl is enabled
|
||||
useEffect(() => {
|
||||
if (!syncWithUrl) return
|
||||
|
||||
const params = new URLSearchParams()
|
||||
if (filterState.statusFilter && filterState.statusFilter !== "all") {
|
||||
params.set("status", filterState.statusFilter)
|
||||
}
|
||||
if (filterState.searchQuery) {
|
||||
params.set("search", filterState.searchQuery)
|
||||
}
|
||||
if (filterState.page && filterState.page > 1) {
|
||||
params.set("page", filterState.page.toString())
|
||||
}
|
||||
|
||||
const queryString = params.toString()
|
||||
const newUrl = queryString ? `${pathname}?${queryString}` : pathname
|
||||
|
||||
// Only update if URL would change
|
||||
const currentQuery = searchParams?.toString() || ""
|
||||
if (queryString !== currentQuery) {
|
||||
router.replace(newUrl, { scroll: false })
|
||||
}
|
||||
}, [filterState, syncWithUrl, pathname, router, searchParams])
|
||||
|
||||
// Convenience setters
|
||||
const setSearchQuery = useCallback((query: string) => {
|
||||
setFilterState(prev => ({ ...prev, searchQuery: query, page: 1 }))
|
||||
}, [])
|
||||
|
||||
const setStatusFilter = useCallback((status: string) => {
|
||||
setFilterState(prev => ({ ...prev, statusFilter: status, page: 1 }))
|
||||
}, [])
|
||||
|
||||
const setDateRange = useCallback((range: DateRange | undefined) => {
|
||||
setFilterState(prev => ({ ...prev, dateRange: range, page: 1 }))
|
||||
}, [])
|
||||
|
||||
const setPage = useCallback((page: number) => {
|
||||
setFilterState(prev => ({ ...prev, page }))
|
||||
}, [])
|
||||
|
||||
const setItemsPerPage = useCallback((count: number) => {
|
||||
setFilterState(prev => ({ ...prev, itemsPerPage: count, page: 1 }))
|
||||
}, [])
|
||||
|
||||
const setSort = useCallback((column: string, direction: "asc" | "desc") => {
|
||||
setFilterState(prev => ({ ...prev, sortColumn: column, sortDirection: direction }))
|
||||
}, [])
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setFilterState(defaults)
|
||||
}, [defaults])
|
||||
|
||||
const hasActiveFilters = Boolean(
|
||||
filterState.searchQuery ||
|
||||
(filterState.statusFilter && filterState.statusFilter !== "all") ||
|
||||
filterState.dateRange?.from
|
||||
)
|
||||
|
||||
return {
|
||||
...filterState,
|
||||
setFilterState,
|
||||
setSearchQuery,
|
||||
setStatusFilter,
|
||||
setDateRange,
|
||||
setPage,
|
||||
setItemsPerPage,
|
||||
setSort,
|
||||
clearFilters,
|
||||
hasActiveFilters
|
||||
}
|
||||
}
|
||||
195
hooks/useWidgetLayout.ts
Normal file
195
hooks/useWidgetLayout.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
|
||||
// Per-widget settings types
|
||||
export interface RecentActivitySettings {
|
||||
itemCount: number // 5, 10, 15
|
||||
}
|
||||
|
||||
export interface TopProductsSettings {
|
||||
itemCount: number // 3, 5, 10
|
||||
showRevenue: boolean
|
||||
}
|
||||
|
||||
export interface OverviewSettings {
|
||||
showChange: boolean // Show % change from previous period
|
||||
}
|
||||
|
||||
export interface RevenueChartSettings {
|
||||
days: number // 7, 14, 30
|
||||
showComparison: boolean
|
||||
}
|
||||
|
||||
export interface LowStockSettings {
|
||||
threshold: number // Show items with stock below this
|
||||
itemCount: number
|
||||
}
|
||||
|
||||
export interface RecentCustomersSettings {
|
||||
itemCount: number
|
||||
showSpent: boolean
|
||||
}
|
||||
|
||||
export interface PendingChatsSettings {
|
||||
showPreview: boolean
|
||||
}
|
||||
|
||||
export type WidgetSettings =
|
||||
| { type: "quick-actions" }
|
||||
| { type: "overview"; settings: OverviewSettings }
|
||||
| { type: "recent-activity"; settings: RecentActivitySettings }
|
||||
| { type: "top-products"; settings: TopProductsSettings }
|
||||
| { type: "revenue-chart"; settings: RevenueChartSettings }
|
||||
| { type: "low-stock"; settings: LowStockSettings }
|
||||
| { type: "recent-customers"; settings: RecentCustomersSettings }
|
||||
| { type: "pending-chats"; settings: PendingChatsSettings }
|
||||
|
||||
export interface WidgetConfig {
|
||||
id: string
|
||||
title: string
|
||||
visible: boolean
|
||||
order: number
|
||||
colSpan: number // 1, 2, 3, 4 (full)
|
||||
settings?: Record<string, any>
|
||||
}
|
||||
|
||||
const DEFAULT_WIDGETS: WidgetConfig[] = [
|
||||
{ id: "quick-actions", title: "Quick Actions", visible: true, order: 0, colSpan: 4 },
|
||||
{ id: "overview", title: "Overview", visible: true, order: 1, colSpan: 4, settings: { showChange: false } },
|
||||
{ id: "recent-activity", title: "Recent Activity", visible: true, order: 2, colSpan: 2, settings: { itemCount: 10 } },
|
||||
{ id: "top-products", title: "Top Products", visible: true, order: 3, colSpan: 2, settings: { itemCount: 5, showRevenue: true } },
|
||||
{ id: "revenue-chart", title: "Revenue Chart", visible: false, order: 4, colSpan: 2, settings: { days: 7, showComparison: false } },
|
||||
{ id: "low-stock", title: "Low Stock Alerts", visible: false, order: 5, colSpan: 2, settings: { threshold: 5, itemCount: 5 } },
|
||||
{ id: "recent-customers", title: "Recent Customers", visible: false, order: 6, colSpan: 2, settings: { itemCount: 5, showSpent: true } },
|
||||
{ id: "pending-chats", title: "Pending Chats", visible: false, order: 7, colSpan: 2, settings: { showPreview: true } },
|
||||
]
|
||||
|
||||
const STORAGE_KEY = "dashboard-widget-layout-v3"
|
||||
|
||||
/**
|
||||
* useWidgetLayout - Persist and manage dashboard widget visibility, order, and settings
|
||||
*/
|
||||
export function useWidgetLayout() {
|
||||
const [widgets, setWidgets] = useState<WidgetConfig[]>(DEFAULT_WIDGETS)
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
|
||||
// Load from localStorage on mount
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as WidgetConfig[]
|
||||
// Merge with defaults to handle new widgets added in future
|
||||
const merged = DEFAULT_WIDGETS.map(defaultWidget => {
|
||||
const savedWidget = parsed.find(w => w.id === defaultWidget.id)
|
||||
return savedWidget
|
||||
? { ...defaultWidget, ...savedWidget, settings: { ...defaultWidget.settings, ...savedWidget.settings } }
|
||||
: defaultWidget
|
||||
})
|
||||
setWidgets(merged.sort((a, b) => a.order - b.order))
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to load widget layout:", e)
|
||||
}
|
||||
setIsLoaded(true)
|
||||
}, [])
|
||||
|
||||
// Save to localStorage whenever widgets change
|
||||
useEffect(() => {
|
||||
if (!isLoaded || typeof window === "undefined") return
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(widgets))
|
||||
} catch (e) {
|
||||
console.warn("Failed to save widget layout:", e)
|
||||
}
|
||||
}, [widgets, isLoaded])
|
||||
|
||||
const toggleWidget = useCallback((widgetId: string) => {
|
||||
setWidgets(prev =>
|
||||
prev.map(w => w.id === widgetId ? { ...w, visible: !w.visible } : w)
|
||||
)
|
||||
}, [])
|
||||
|
||||
const moveWidget = useCallback((widgetId: string, direction: "up" | "down") => {
|
||||
setWidgets(prev => {
|
||||
const index = prev.findIndex(w => w.id === widgetId)
|
||||
if (index === -1) return prev
|
||||
|
||||
const newIndex = direction === "up" ? index - 1 : index + 1
|
||||
if (newIndex < 0 || newIndex >= prev.length) return prev
|
||||
|
||||
const newWidgets = [...prev]
|
||||
const [widget] = newWidgets.splice(index, 1)
|
||||
newWidgets.splice(newIndex, 0, widget)
|
||||
|
||||
// Update order values
|
||||
return newWidgets.map((w, i) => ({ ...w, order: i }))
|
||||
})
|
||||
}, [])
|
||||
|
||||
const updateWidgetSettings = useCallback((widgetId: string, newSettings: Record<string, any>) => {
|
||||
setWidgets(prev =>
|
||||
prev.map(w => w.id === widgetId
|
||||
? { ...w, settings: { ...w.settings, ...newSettings } }
|
||||
: w
|
||||
)
|
||||
)
|
||||
}, [])
|
||||
|
||||
const updateWidgetColSpan = useCallback((widgetId: string, colSpan: number) => {
|
||||
setWidgets(prev =>
|
||||
prev.map(w => w.id === widgetId ? { ...w, colSpan } : w)
|
||||
)
|
||||
}, [])
|
||||
|
||||
const getWidgetSettings = useCallback(<T extends Record<string, any>>(widgetId: string): T | undefined => {
|
||||
return widgets.find(w => w.id === widgetId)?.settings as T | undefined
|
||||
}, [widgets])
|
||||
|
||||
const resetLayout = useCallback(() => {
|
||||
setWidgets(DEFAULT_WIDGETS)
|
||||
}, [])
|
||||
|
||||
const getVisibleWidgets = useCallback(() => {
|
||||
return widgets.filter(w => w.visible).sort((a, b) => a.order - b.order)
|
||||
}, [widgets])
|
||||
|
||||
const isWidgetVisible = useCallback((widgetId: string) => {
|
||||
return widgets.find(w => w.id === widgetId)?.visible ?? true
|
||||
}, [widgets])
|
||||
|
||||
// Reorder widgets by moving activeId to overId's position
|
||||
const reorderWidgets = useCallback((activeId: string, overId: string) => {
|
||||
setWidgets(prev => {
|
||||
const oldIndex = prev.findIndex(w => w.id === activeId)
|
||||
const newIndex = prev.findIndex(w => w.id === overId)
|
||||
|
||||
if (oldIndex === -1 || newIndex === -1) return prev
|
||||
|
||||
const newWidgets = [...prev]
|
||||
const [removed] = newWidgets.splice(oldIndex, 1)
|
||||
newWidgets.splice(newIndex, 0, removed)
|
||||
|
||||
// Update order values
|
||||
return newWidgets.map((w, i) => ({ ...w, order: i }))
|
||||
})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
widgets,
|
||||
toggleWidget,
|
||||
moveWidget,
|
||||
reorderWidgets,
|
||||
updateWidgetSettings,
|
||||
updateWidgetColSpan,
|
||||
getWidgetSettings,
|
||||
resetLayout,
|
||||
getVisibleWidgets,
|
||||
isWidgetVisible,
|
||||
isLoaded
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ export {
|
||||
getCustomerInsightsWithStore,
|
||||
getOrderAnalyticsWithStore,
|
||||
getStoreIdForUser,
|
||||
formatGBP,
|
||||
|
||||
// Types
|
||||
type AnalyticsOverview,
|
||||
|
||||
@@ -190,10 +190,12 @@ export const getCustomerInsights = async (
|
||||
storeId?: string,
|
||||
page: number = 1,
|
||||
limit: number = 10,
|
||||
sortBy: string = "spent",
|
||||
): Promise<CustomerInsights> => {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
sort: sortBy,
|
||||
});
|
||||
if (storeId) params.append("storeId", storeId);
|
||||
|
||||
@@ -272,9 +274,10 @@ export const getProductPerformanceWithStore = async (): Promise<
|
||||
export const getCustomerInsightsWithStore = async (
|
||||
page: number = 1,
|
||||
limit: number = 10,
|
||||
sortBy: string = "spent",
|
||||
): Promise<CustomerInsights> => {
|
||||
const storeId = getStoreIdForUser();
|
||||
return getCustomerInsights(storeId, page, limit);
|
||||
return getCustomerInsights(storeId, page, limit, sortBy);
|
||||
};
|
||||
|
||||
export const getOrderAnalyticsWithStore = async (
|
||||
@@ -424,6 +427,23 @@ export interface PredictionsOverview {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface BatchPredictionsResponse {
|
||||
success: boolean;
|
||||
storeId?: string;
|
||||
historicalPeriod?: number;
|
||||
horizons?: number[];
|
||||
simulationFactors?: number[];
|
||||
predictions?: {
|
||||
[horizon: string]: {
|
||||
[simulationFactor: string]: PredictionsOverview;
|
||||
};
|
||||
};
|
||||
totalEntries?: number;
|
||||
generatedAt?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
|
||||
// Prediction Service Functions
|
||||
|
||||
/**
|
||||
@@ -494,15 +514,18 @@ export const getStockPredictions = async (
|
||||
* @param daysAhead Number of days to predict ahead (default: 7)
|
||||
* @param period Historical period in days (default: 30)
|
||||
* @param storeId Optional storeId for staff users
|
||||
* @param simulation Simulation factor (e.g. 0.1 for +10%)
|
||||
*/
|
||||
export const getPredictionsOverview = async (
|
||||
daysAhead: number = 7,
|
||||
period: number = 30,
|
||||
storeId?: string,
|
||||
simulation: number = 0,
|
||||
): Promise<PredictionsOverview> => {
|
||||
const params = new URLSearchParams({
|
||||
daysAhead: daysAhead.toString(),
|
||||
period: period.toString(),
|
||||
simulation: simulation.toString(),
|
||||
});
|
||||
if (storeId) params.append("storeId", storeId);
|
||||
|
||||
@@ -538,7 +561,33 @@ export const getStockPredictionsWithStore = async (
|
||||
export const getPredictionsOverviewWithStore = async (
|
||||
daysAhead: number = 7,
|
||||
period: number = 30,
|
||||
simulation: number = 0,
|
||||
): Promise<PredictionsOverview> => {
|
||||
const storeId = getStoreIdForUser();
|
||||
return getPredictionsOverview(daysAhead, period, storeId);
|
||||
return getPredictionsOverview(daysAhead, period, storeId, simulation);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all cached predictions in one request (for client-side switching)
|
||||
* @param period Historical period in days (default: 90)
|
||||
* @param storeId Optional storeId for staff users
|
||||
*/
|
||||
export const getBatchPredictions = async (
|
||||
period: number = 90,
|
||||
storeId?: string,
|
||||
): Promise<BatchPredictionsResponse> => {
|
||||
const params = new URLSearchParams({
|
||||
period: period.toString(),
|
||||
});
|
||||
if (storeId) params.append("storeId", storeId);
|
||||
|
||||
const url = `/analytics/predictions/batch?${params.toString()}`;
|
||||
return clientFetch<BatchPredictionsResponse>(url);
|
||||
};
|
||||
|
||||
export const getBatchPredictionsWithStore = async (
|
||||
period: number = 90,
|
||||
): Promise<BatchPredictionsResponse> => {
|
||||
const storeId = getStoreIdForUser();
|
||||
return getBatchPredictions(period, storeId);
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface ProfitOverview {
|
||||
topProfitableProducts: Array<{
|
||||
productId: string;
|
||||
productName: string;
|
||||
image?: string;
|
||||
totalQuantitySold: number;
|
||||
totalRevenue: number;
|
||||
totalCost: number;
|
||||
|
||||
107
middleware.ts
107
middleware.ts
@@ -1,107 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export async function middleware(req: NextRequest) {
|
||||
const pathname = new URL(req.url).pathname;
|
||||
|
||||
// Skip auth check for password reset page
|
||||
if (pathname.startsWith('/auth/reset-password')) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Check for auth token in cookies
|
||||
const token = req.cookies.get("Authorization")?.value;
|
||||
|
||||
// Debug info about all cookies
|
||||
const allCookies = req.cookies.getAll();
|
||||
console.log("Middleware: All cookies:", allCookies.map(c => c.name).join(', '));
|
||||
|
||||
if (!token) {
|
||||
// Try to get from Authorization header as fallback
|
||||
const authHeader = req.headers.get('Authorization');
|
||||
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
console.log("Middleware: Token found in Authorization header");
|
||||
// Continue with validation using header auth
|
||||
// The authCheckUrl will handle extracting the token from header
|
||||
} else {
|
||||
console.log("Middleware: No token found in cookies or headers, redirecting to login...");
|
||||
return NextResponse.redirect(new URL("/auth/login", req.url));
|
||||
}
|
||||
} else {
|
||||
console.log("Middleware: Token found in cookies, validating...");
|
||||
}
|
||||
|
||||
try {
|
||||
// Always use localhost for internal container communication
|
||||
const authCheckUrl = "http://localhost:3000/api/auth/check";
|
||||
|
||||
console.log(`Using internal auth check URL: ${authCheckUrl}`);
|
||||
|
||||
// Clone headers to avoid modifying the original request
|
||||
const headers = new Headers(req.headers);
|
||||
|
||||
// If token is in cookie, ensure it's also in Authorization header
|
||||
if (token && !headers.has('Authorization')) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(authCheckUrl, {
|
||||
method: "GET",
|
||||
headers,
|
||||
credentials: 'include',
|
||||
signal: AbortSignal.timeout(15000), // 15 second timeout (increased for slower connections)
|
||||
});
|
||||
} catch (fetchError) {
|
||||
// Handle timeout or network errors gracefully
|
||||
console.error("Middleware: Auth check request failed:", fetchError);
|
||||
|
||||
// If it's a timeout or network error, don't redirect - let the request proceed
|
||||
// The page will handle auth errors client-side
|
||||
if (fetchError instanceof Error && fetchError.name === 'TimeoutError') {
|
||||
console.log("Middleware: Auth check timed out, allowing request to proceed");
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// For other network errors, redirect to login
|
||||
return NextResponse.redirect(new URL("/auth/login", req.url));
|
||||
}
|
||||
|
||||
console.log(`Middleware: Auth check responded with status ${res.status}`);
|
||||
|
||||
if (!res.ok) {
|
||||
console.log(`Middleware: Auth check failed with status ${res.status}, redirecting to login`);
|
||||
return NextResponse.redirect(new URL("/auth/login", req.url));
|
||||
}
|
||||
|
||||
console.log("Middleware: Auth check successful");
|
||||
|
||||
// Admin-only protection for /dashboard/admin routes
|
||||
// Clone the response before reading it to avoid consuming the body
|
||||
if (pathname.startsWith('/dashboard/admin')) {
|
||||
try {
|
||||
const clonedRes = res.clone();
|
||||
const user = await clonedRes.json();
|
||||
const username = user?.vendor?.username;
|
||||
if (username !== 'admin1') {
|
||||
console.log("Middleware: Non-admin attempted to access /dashboard/admin, redirecting");
|
||||
return NextResponse.redirect(new URL("/dashboard", req.url));
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Middleware: Failed to parse user for admin check, redirecting to login");
|
||||
return NextResponse.redirect(new URL("/auth/login", req.url));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Authentication validation failed:", error);
|
||||
return NextResponse.redirect(new URL("/auth/login", req.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/dashboard/:path*", "/auth/reset-password/:path*"],
|
||||
};
|
||||
@@ -15,4 +15,5 @@ export interface Product {
|
||||
pricePerUnit: number;
|
||||
}>;
|
||||
image?: string | File | null | undefined;
|
||||
costPerUnit?: number;
|
||||
}
|
||||
|
||||
296
package-lock.json
generated
296
package-lock.json
generated
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"name": "my-v0-project",
|
||||
"version": "2.2.0",
|
||||
"version": "2.2.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "my-v0-project",
|
||||
"version": "2.2.0",
|
||||
"version": "2.2.1",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@radix-ui/react-accordion": "^1.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||
@@ -34,6 +37,7 @@
|
||||
"@radix-ui/react-toggle": "^1.1.1",
|
||||
"@radix-ui/react-toggle-group": "^1.1.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"axios": "^1.8.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -42,12 +46,15 @@
|
||||
"date-fns": "4.1.0",
|
||||
"embla-carousel-react": "8.5.1",
|
||||
"form-data": "^4.0.2",
|
||||
"framer-motion": "^12.25.0",
|
||||
"input-otp": "1.4.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.454.0",
|
||||
"next": "^16.1.1",
|
||||
"next-themes": "latest",
|
||||
"react": "^19.0.0",
|
||||
"react-countup": "^6.5.3",
|
||||
"react-day-picker": "8.10.1",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
@@ -55,6 +62,8 @@
|
||||
"react-hook-form": "^7.54.1",
|
||||
"react-markdown": "^10.0.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-window": "^2.2.4",
|
||||
"react-window-infinite-loader": "^2.0.0",
|
||||
"recharts": "^2.15.0",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
@@ -63,7 +72,6 @@
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@distube/ytdl-core": "^4.16.12",
|
||||
"@next/bundle-analyzer": "^16.1.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/lodash": "^4.17.16",
|
||||
@@ -383,26 +391,53 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@distube/ytdl-core": {
|
||||
"version": "4.16.12",
|
||||
"resolved": "https://registry.npmjs.org/@distube/ytdl-core/-/ytdl-core-4.16.12.tgz",
|
||||
"integrity": "sha512-/NR8Jur1Q4E2oD+DJta7uwWu7SkqdEkhwERt7f4iune70zg7ZlLLTOHs1+jgg3uD2jQjpdk7RGC16FqstG4RsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"dependencies": {
|
||||
"http-cookie-agent": "^7.0.1",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"m3u8stream": "^0.8.6",
|
||||
"miniget": "^4.2.3",
|
||||
"sax": "^1.4.1",
|
||||
"tough-cookie": "^5.1.2",
|
||||
"undici": "^7.8.0"
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/distubejs/ytdl-core?sponsor"
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
@@ -2831,6 +2866,31 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.13.18",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz",
|
||||
"integrity": "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.13.18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.13.18",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz",
|
||||
"integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz",
|
||||
@@ -3473,16 +3533,6 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
@@ -4248,6 +4298,11 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/countup.js": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/countup.js/-/countup.js-2.9.0.tgz",
|
||||
"integrity": "sha512-llqrvyXztRFPp6+i8jx25phHWcVWhrHO4Nlt0uAOSKHB8778zzQswa4MU3qKBvkXfJKftRYFJuVHez67lyKdHg=="
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
|
||||
@@ -5646,6 +5701,32 @@
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.26.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.26.0.tgz",
|
||||
"integrity": "sha512-yFatQro5/mNKVqBT/IAMq9v27z4dJsjKklnsCu7mdp2mrn78UW3mkG4qfmmLxHzh6WMts1o+A4FH4Iiomt/jFQ==",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.24.11",
|
||||
"motion-utils": "^12.24.10",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -6082,45 +6163,6 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/http-cookie-agent": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/http-cookie-agent/-/http-cookie-agent-7.0.3.tgz",
|
||||
"integrity": "sha512-EeZo7CGhfqPW6R006rJa4QtZZUpBygDa2HZH3DJqsTzTjyRE6foDBVQIv/pjVsxHC8z2GIdbB1Hvn9SRorP3WQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/3846masa"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tough-cookie": "^4.0.0 || ^5.0.0 || ^6.0.0",
|
||||
"undici": "^7.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"undici": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -6985,20 +7027,6 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/m3u8stream": {
|
||||
"version": "0.8.6",
|
||||
"resolved": "https://registry.npmjs.org/m3u8stream/-/m3u8stream-0.8.6.tgz",
|
||||
"integrity": "sha512-LZj8kIVf9KCphiHmH7sbFQTVe4tOemb202fWwvJwR9W5ENW/1hxJN6ksAWGhQgSBSa3jyWhnjKU1Fw1GaOdbyA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"miniget": "^4.2.2",
|
||||
"sax": "^1.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -7648,16 +7676,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/miniget": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/miniget/-/miniget-4.2.3.tgz",
|
||||
"integrity": "sha512-SjbDPDICJ1zT+ZvQwK0hUcRY4wxlhhNpHL9nJOB2MEAXRGagTljsO8MEDzQMTFf0Q8g4QNi8P9lEm/g7e+qgzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
@@ -7691,6 +7709,19 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.24.11",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.24.11.tgz",
|
||||
"integrity": "sha512-DlWOmsXMJrV8lzZyd+LKjG2CXULUs++bkq8GZ2Sr0R0RRhs30K2wtY+LKiTjhmJU3W61HK+rB0GLz6XmPvTA1A==",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.24.10"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.24.10",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.24.10.tgz",
|
||||
"integrity": "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww=="
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||
@@ -8427,6 +8458,17 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-countup": {
|
||||
"version": "6.5.3",
|
||||
"resolved": "https://registry.npmjs.org/react-countup/-/react-countup-6.5.3.tgz",
|
||||
"integrity": "sha512-udnqVQitxC7QWADSPDOxVWULkLvKUWrDapn5i53HE4DPRVgs+Y5rr4bo25qEl8jSh+0l2cToJgGMx+clxPM3+w==",
|
||||
"dependencies": {
|
||||
"countup.js": "^2.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-day-picker": {
|
||||
"version": "8.10.1",
|
||||
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
|
||||
@@ -8651,6 +8693,24 @@
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-window": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.5.tgz",
|
||||
"integrity": "sha512-6viWvPSZvVuMIe9hrl4IIZoVfO/npiqOb03m4Z9w+VihmVzBbiudUrtUqDpsWdKvd/Ai31TCR25CBcFFAUm28w==",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-window-infinite-loader": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-window-infinite-loader/-/react-window-infinite-loader-2.0.0.tgz",
|
||||
"integrity": "sha512-dioOyvShGheEqqFHcPNKCixCOc2evwb2VEt9sitfJfTZ1hir8m6b8W0CNBvcUj+8Y8IeWu4yb88DI7k88aYTQQ==",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -8958,13 +9018,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz",
|
||||
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.25.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
|
||||
@@ -9699,26 +9752,6 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
|
||||
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^6.1.86"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
|
||||
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -9742,19 +9775,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
|
||||
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^6.1.32"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/trim-lines": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||
@@ -9975,16 +9995,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz",
|
||||
"integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.20.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
||||
|
||||
10
package.json
10
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "my-v0-project",
|
||||
"version": "2.2.0",
|
||||
"gitCommit": "2.1.0",
|
||||
"version": "2.2.1",
|
||||
"gitCommit": "2.2.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"predev": "node scripts/get-git-hash.js",
|
||||
@@ -17,6 +17,9 @@
|
||||
"analyze": "ANALYZE=true next build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@radix-ui/react-accordion": "^1.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||
@@ -52,12 +55,15 @@
|
||||
"date-fns": "4.1.0",
|
||||
"embla-carousel-react": "8.5.1",
|
||||
"form-data": "^4.0.2",
|
||||
"framer-motion": "^12.25.0",
|
||||
"input-otp": "1.4.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.454.0",
|
||||
"next": "^16.1.1",
|
||||
"next-themes": "latest",
|
||||
"react": "^19.0.0",
|
||||
"react-countup": "^6.5.3",
|
||||
"react-day-picker": "8.10.1",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
|
||||
75
pnpm-lock.yaml
generated
75
pnpm-lock.yaml
generated
@@ -113,12 +113,18 @@ importers:
|
||||
form-data:
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.5
|
||||
framer-motion:
|
||||
specifier: ^12.25.0
|
||||
version: 12.25.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
input-otp:
|
||||
specifier: 1.4.1
|
||||
version: 1.4.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
jwt-decode:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
lodash:
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.21
|
||||
lucide-react:
|
||||
specifier: ^0.454.0
|
||||
version: 0.454.0(react@19.2.3)
|
||||
@@ -131,6 +137,9 @@ importers:
|
||||
react:
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.3
|
||||
react-countup:
|
||||
specifier: ^6.5.3
|
||||
version: 6.5.3(react@19.2.3)
|
||||
react-day-picker:
|
||||
specifier: 8.10.1
|
||||
version: 8.10.1(date-fns@4.1.0)(react@19.2.3)
|
||||
@@ -1728,6 +1737,9 @@ packages:
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
countup.js@2.9.0:
|
||||
resolution: {integrity: sha512-llqrvyXztRFPp6+i8jx25phHWcVWhrHO4Nlt0uAOSKHB8778zzQswa4MU3qKBvkXfJKftRYFJuVHez67lyKdHg==}
|
||||
|
||||
cross-env@7.0.3:
|
||||
resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
|
||||
engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
|
||||
@@ -2141,6 +2153,20 @@ packages:
|
||||
fraction.js@5.3.4:
|
||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||
|
||||
framer-motion@12.25.0:
|
||||
resolution: {integrity: sha512-mlWqd0rApIjeyhTCSNCqPYsUAEhkcUukZxH3ke6KbstBRPcxhEpuIjmiUQvB+1E9xkEm5SpNHBgHCapH/QHTWg==}
|
||||
peerDependencies:
|
||||
'@emotion/is-prop-valid': '*'
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@emotion/is-prop-valid':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -2634,6 +2660,12 @@ packages:
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
motion-dom@12.24.11:
|
||||
resolution: {integrity: sha512-DlWOmsXMJrV8lzZyd+LKjG2CXULUs++bkq8GZ2Sr0R0RRhs30K2wtY+LKiTjhmJU3W61HK+rB0GLz6XmPvTA1A==}
|
||||
|
||||
motion-utils@12.24.10:
|
||||
resolution: {integrity: sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==}
|
||||
|
||||
mrmime@2.0.1:
|
||||
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2863,6 +2895,11 @@ packages:
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
react-countup@6.5.3:
|
||||
resolution: {integrity: sha512-udnqVQitxC7QWADSPDOxVWULkLvKUWrDapn5i53HE4DPRVgs+Y5rr4bo25qEl8jSh+0l2cToJgGMx+clxPM3+w==}
|
||||
peerDependencies:
|
||||
react: '>= 16.3.0'
|
||||
|
||||
react-day-picker@8.10.1:
|
||||
resolution: {integrity: sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==}
|
||||
peerDependencies:
|
||||
@@ -4967,6 +5004,8 @@ snapshots:
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
countup.js@2.9.0: {}
|
||||
|
||||
cross-env@7.0.3:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
@@ -5237,8 +5276,8 @@ snapshots:
|
||||
'@next/eslint-plugin-next': 16.1.1
|
||||
eslint: 9.39.2(jiti@1.21.7)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7))
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7))
|
||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@1.21.7))
|
||||
eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@1.21.7))
|
||||
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@1.21.7))
|
||||
@@ -5260,7 +5299,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7)):
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7)):
|
||||
dependencies:
|
||||
'@nolyfill/is-core-module': 1.0.39
|
||||
debug: 4.4.3
|
||||
@@ -5271,22 +5310,22 @@ snapshots:
|
||||
tinyglobby: 0.2.15
|
||||
unrs-resolver: 1.11.1
|
||||
optionalDependencies:
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)):
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7)):
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
|
||||
eslint: 9.39.2(jiti@1.21.7)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7))
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)):
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7)):
|
||||
dependencies:
|
||||
'@rtsao/scc': 1.1.0
|
||||
array-includes: 3.1.9
|
||||
@@ -5297,7 +5336,7 @@ snapshots:
|
||||
doctrine: 2.1.0
|
||||
eslint: 9.39.2(jiti@1.21.7)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7))
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7))
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.16.1
|
||||
is-glob: 4.0.3
|
||||
@@ -5509,6 +5548,15 @@ snapshots:
|
||||
|
||||
fraction.js@5.3.4: {}
|
||||
|
||||
framer-motion@12.25.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
motion-dom: 12.24.11
|
||||
motion-utils: 12.24.10
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
@@ -6131,6 +6179,12 @@ snapshots:
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
motion-dom@12.24.11:
|
||||
dependencies:
|
||||
motion-utils: 12.24.10
|
||||
|
||||
motion-utils@12.24.10: {}
|
||||
|
||||
mrmime@2.0.1: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
@@ -6345,6 +6399,11 @@ snapshots:
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
react-countup@6.5.3(react@19.2.3):
|
||||
dependencies:
|
||||
countup.js: 2.9.0
|
||||
react: 19.2.3
|
||||
|
||||
react-day-picker@8.10.1(date-fns@4.1.0)(react@19.2.3):
|
||||
dependencies:
|
||||
date-fns: 4.1.0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"commitHash": "8a43477",
|
||||
"buildTime": "2026-01-07T13:11:47.102Z"
|
||||
"commitHash": "a6b7286",
|
||||
"buildTime": "2026-01-12T10:20:09.966Z"
|
||||
}
|
||||
@@ -117,6 +117,7 @@ const config: Config = {
|
||||
future: {
|
||||
hoverOnlyWhenSupported: true,
|
||||
},
|
||||
// test commit final final final
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
Reference in New Issue
Block a user