Compare commits

...

64 Commits

Author SHA1 Message Date
g
9acd18955e Add scroll area to widget settings modal
Some checks failed
Build Frontend / build (push) Failing after 6s
Wrapped the widget settings modal content in a ScrollArea to improve usability when there are many settings, preventing overflow and keeping the modal compact.
2026-01-12 10:41:52 +00:00
g
318927cd0c Add modular dashboard widgets and layout editor
Some checks failed
Build Frontend / build (push) Failing after 7s
Introduces a modular dashboard system with draggable, configurable widgets including revenue, low stock, recent customers, and pending chats. Adds a dashboard editor for layout customization, widget visibility, and settings. Refactors dashboard content to use the new widget system and improves UI consistency and interactivity.
2026-01-12 10:39:50 +00:00
g
a6b7286b45 Refactor customer dialog styles for consistency
Updated the customer profile dialog to use more consistent and theme-based styling classes, replacing hardcoded colors and gradients with utility classes. Improved layout and text handling for better responsiveness and readability, and simplified button styles for maintainability.
2026-01-12 09:40:08 +00:00
g
d78e6c0725 Update order-table.tsx
All checks were successful
Build Frontend / build (push) Successful in 1m9s
2026-01-12 09:02:51 +00:00
g
3f9d28bf1b Improve browser detection and table UX for Firefox
All checks were successful
Build Frontend / build (push) Successful in 1m10s
Standardizes browser detection logic across admin and storefront pages to more accurately identify Firefox. Updates table rendering logic to provide better compatibility and fallback for Firefox, including conditional use of AnimatePresence and improved loading/empty states. Refines table UI styles for consistency and accessibility.
2026-01-12 08:59:04 +00:00
g
064cd7a486 Update page.tsx
All checks were successful
Build Frontend / build (push) Successful in 1m8s
2026-01-12 08:33:54 +00:00
g
6cd658c4cb Revamp OrderTable UI and add Firefox animation fallback
All checks were successful
Build Frontend / build (push) Successful in 1m8s
Updated the OrderTable component with a new dark-themed UI, improved color schemes, and enhanced button and table styles. Added browser detection to provide a simplified animation experience for Firefox users, ensuring compatibility and smoother rendering. Improved loading state visuals and refined table header and cell styling for better readability.
2026-01-12 08:28:36 +00:00
g
6997838bf7 Revamp dashboard UI with improved dark theme styles
All checks were successful
Build Frontend / build (push) Successful in 1m11s
Updated category, quick actions, and product table components to use enhanced dark theme styling, including new background colors, borders, gradients, and shadow effects. Improved visual hierarchy, contrast, and hover states for better user experience and consistency across dashboard elements.
2026-01-12 08:19:59 +00:00
g
e369741b2d Enhance customer and profit analysis dialogs UI/UX
All checks were successful
Build Frontend / build (push) Successful in 1m14s
Revamps the customer details dialog with improved layout, animations, and clearer stats breakdown. Upgrades the profit analysis modal with animated cards, clearer tier breakdown, and improved cost/margin/profit explanations. Also increases recent activity fetch limit, fixes quote hydration in dashboard content, and minor animation tweak in order table.
2026-01-12 08:12:36 +00:00
g
7ddcd7afb6 Update UI styles and dashboard product display
All checks were successful
Build Frontend / build (push) Successful in 1m10s
Refined color scheme in AnimatedStatsSection to use indigo instead of pink, and improved gradient backgrounds. In dashboard/content.tsx, updated TopProduct price to support arrays and display revenue per product. UnifiedNotifications received minor style and layout adjustments for better consistency and usability.
2026-01-12 08:03:19 +00:00
g
3ffb64cf9a admin
All checks were successful
Build Frontend / build (push) Successful in 1m6s
2026-01-12 07:52:36 +00:00
g
e9737c8b24 Refactor UI to remove Christmas theme and improve actions
All checks were successful
Build Frontend / build (push) Successful in 1m11s
Removed all Christmas-specific theming and logic from the home page and navbar, standardizing colors to indigo. Updated QuickActions to open a modal for adding products instead of navigating to a new page, including logic for product creation and category fetching. Simplified ChatTable row animations and fixed minor layout issues. Updated button styles and mobile menu links for consistency.
2026-01-12 07:43:33 +00:00
g
244014f33a Improve admin UI and vendor invite experience
All checks were successful
Build Frontend / build (push) Successful in 1m7s
Enhanced the admin dashboard tab styling for better clarity. Refactored InviteVendorCard with improved UI, feedback, and clipboard copy functionality. Fixed vendor store ID update to send raw object instead of JSON string. Ensured product price formatting is robust against non-numeric values.
2026-01-12 07:33:16 +00:00
g
1186952ed8 Update page.tsx
All checks were successful
Build Frontend / build (push) Successful in 1m10s
2026-01-12 07:25:05 +00:00
g
0bb1497db6 Update page.tsx
Some checks failed
Build Frontend / build (push) Has been cancelled
2026-01-12 07:24:49 +00:00
g
688f519fd6 Update AdminAnalytics.tsx 2026-01-12 07:23:45 +00:00
g
73adbe5d07 Enhance admin dashboard UI and tables with new styles
All checks were successful
Build Frontend / build (push) Successful in 1m4s
Refactors admin dashboard, users, vendors, shipping, and stock pages to improve UI consistency and visual clarity. Adds new icons, animated transitions, and card styles for stats and tables. Updates table row rendering with framer-motion for smooth animations, improves badge and button styling, and enhances search/filter inputs. Refines loading skeletons and overall layout for a more modern, accessible admin experience.
2026-01-12 07:16:33 +00:00
g
63c833b510 Update page.tsx
All checks were successful
Build Frontend / build (push) Successful in 1m11s
2026-01-12 07:03:19 +00:00
g
bfd31f9d35 Update product-modal.tsx
Some checks failed
Build Frontend / build (push) Has been cancelled
2026-01-12 07:02:08 +00:00
g
f7e768f6d6 Improve product image handling and add costPerUnit
All checks were successful
Build Frontend / build (push) Successful in 1m10s
Added a utility to generate product image URLs, ensuring images are displayed correctly in the product table. Updated the Product model to include an optional costPerUnit field. Minor UI and code formatting improvements were made for consistency.
2026-01-12 06:59:21 +00:00
g
7c7db0fc09 Update product-table.tsx 2026-01-12 06:54:28 +00:00
g
211cdc71f9 Enhance dashboard UI and add order timeline
All checks were successful
Build Frontend / build (push) Successful in 1m12s
Refactored dashboard pages for improved layout and visual consistency using Card components, motion animations, and updated color schemes. Added an OrderTimeline component to the order details page to visualize order lifecycle. Improved customer management page with better sorting, searching, and a detailed customer dialog. Updated storefront settings page with a modernized layout and clearer sectioning.
2026-01-12 06:53:28 +00:00
g
7b95589867 Update PredictionsChart.tsx
All checks were successful
Build Frontend / build (push) Successful in 1m5s
2026-01-12 05:53:16 +00:00
g
c209dd60fc Add 180-day analytics time range options
All checks were successful
Build Frontend / build (push) Successful in 1m6s
Extended analytics dashboards and charts to support a 180-day time range selection. Also updated tooltip position in PredictionsChart for improved UI consistency.
2026-01-12 05:51:29 +00:00
g
a05787a091 Revamp analytics dashboard UI and charts
All checks were successful
Build Frontend / build (push) Successful in 1m11s
Enhanced the AnalyticsDashboard layout with a premium glassmorphism UI, improved toolbar, and reorganized tabs for better clarity. MetricsCard now features dynamic color coding and trend badges. PredictionsChart received scenario simulation UI upgrades, disabled future ranges based on available history, and improved chart tooltips and visuals. ProfitAnalyticsChart added error handling for product images and minor UI refinements. Updated globals.css with new premium utility classes and improved dark mode color variables.
2026-01-12 05:44:54 +00:00
g
a0605e47de Improve chart visuals and add null safety in analytics
All checks were successful
Build Frontend / build (push) Successful in 1m19s
Refactored GrowthAnalyticsChart to use Area for 'orders' with gradient fill and improved dot handling. Enhanced PredictionsChart with consistent null checks for predictions data, improved tooltip rendering, and adjusted chart margins and axis styles. Updated RevenueChart to add activeDot styling for better interactivity.
2026-01-12 04:52:40 +00:00
g
1933ef4007 Update package.json 2026-01-12 04:39:24 +00:00
g
f7af5b933d Optimize PredictionsChart with batch data loading
All checks were successful
Build Frontend / build (push) Successful in 1m5s
Refactors PredictionsChart to fetch all prediction data in a single batch for instant client-side switching between horizons and simulation factors. Updates state management and effects to utilize the pre-cached batch data, reducing API calls and improving responsiveness. Minor UI text update to remove TensorFlow.js mention.
2026-01-12 04:33:31 +00:00
g
198b9a82ff Update analytics-service.ts 2026-01-12 04:29:47 +00:00
g
6eeed4267a fix chart issues
All checks were successful
Build Frontend / build (push) Successful in 1m10s
2026-01-12 04:16:04 +00:00
g
5d9f8fa07b Redesign auth pages and enhance analytics UI with motion
All checks were successful
Build Frontend / build (push) Successful in 1m17s
Refactored login and registration pages for a modern, consistent look with animated backgrounds and improved form feedback. Enhanced analytics dashboard and metrics cards with framer-motion animations and visual polish. Updated MotionWrapper for flexible motion props and improved transitions. Minor UI/UX improvements and code cleanup throughout auth and analytics components.
2026-01-12 04:06:36 +00:00
g
02ba4b0e66 :D 2026-01-12 02:42:44 +00:00
g
624bfa5485 balls :D
All checks were successful
Build Frontend / build (push) Successful in 1m6s
2026-01-12 02:32:23 +00:00
g
b10e8f8939 Update PredictionsChart.tsx
All checks were successful
Build Frontend / build (push) Successful in 1m5s
2026-01-12 01:56:14 +00:00
g
4f55629f4b Update PredictionsChart.tsx
All checks were successful
Build Frontend / build (push) Successful in 1m4s
2026-01-12 01:47:12 +00:00
g
bd4683d91e Update PredictionsChart.tsx
All checks were successful
Build Frontend / build (push) Successful in 1m3s
2026-01-12 01:41:17 +00:00
g
c0f4b05ef4 Update PredictionsChart.tsx
All checks were successful
Build Frontend / build (push) Successful in 1m5s
2026-01-12 00:59:40 +00:00
g
a8fdac532c Update PredictionsChart.tsx
All checks were successful
Build Frontend / build (push) Successful in 1m3s
2026-01-11 16:07:44 +00:00
g
506b4b2f04 Optimize Dockerfile for smaller image size using Next.js standalone
All checks were successful
Build Frontend / build (push) Successful in 1m4s
2026-01-11 12:46:39 +00:00
g
397177fe36 Fix tag and push commands
All checks were successful
Build Frontend / build (push) Successful in 1m59s
2026-01-11 12:25:11 +00:00
g
660547e901 Update workflow to HTTPS, test commit
Some checks failed
Build Frontend / build (push) Failing after 2m1s
2026-01-11 12:22:11 +00:00
g
5ceadb7cbe Test commit final final
Some checks failed
Build Frontend / build (push) Failing after 1m53s
2026-01-11 10:39:49 +00:00
g
a88c1422e8 Change registry to HTTP on runner.thaiboydigit.al
Some checks failed
Build Frontend / build (push) Failing after 2m4s
2026-01-11 10:33:02 +00:00
g
9fc3f12a44 Change registry URL to runner.thaiboydigit.al to avoid Cloudflare limit
Some checks failed
Build Frontend / build (push) Failing after 1m48s
2026-01-11 10:30:38 +00:00
g
9bf2e22fe1 Test commit final
Some checks failed
Build Frontend / build (push) Failing after 1m47s
2026-01-11 10:27:33 +00:00
g
120d09f63c Change secret name to REGISTRY_TOKEN
Some checks failed
Build Frontend / build (push) Has been cancelled
2026-01-11 10:27:01 +00:00
g
8211ed4d8e Add registry push to workflow
Some checks failed
Build Frontend / build (push) Failing after 1m56s
2026-01-11 10:24:16 +00:00
g
dec1358b49 Invalidate Docker cache and fix git_commit_sha
All checks were successful
Build Frontend / build (push) Successful in 1m50s
2026-01-11 10:15:44 +00:00
g
31158c50d3 Fix git_commit_sha creation in Dockerfile
Some checks failed
Build Frontend / build (push) Failing after 1m2s
2026-01-11 10:13:57 +00:00
g
4fff74a351 Another test commit
Some checks failed
Build Frontend / build (push) Failing after 1m50s
2026-01-11 10:08:28 +00:00
g
bbd8334dbd Test commit
Some checks failed
Build Frontend / build (push) Failing after 11s
2026-01-11 10:01:53 +00:00
g
66489f8e77 Test build comment
Some checks failed
Build Frontend / build (push) Failing after 48s
2026-01-11 09:55:00 +00:00
g
653083a618 Test commits
Some checks failed
Build Frontend / build (push) Failing after 38s
2026-01-11 09:52:06 +00:00
g
078391334a Update workflow to build Docker image
Some checks failed
Build Frontend / build (push) Failing after 46s
2026-01-11 09:43:27 +00:00
g
a05fbfb85b Initial commit
Some checks failed
Build Frontend / build (push) Failing after 1m41s
2026-01-11 09:37:20 +00:00
g
c62ad7feac Update Dockerfile 2026-01-11 08:02:34 +00:00
g
be31df404e argh 2026-01-11 08:01:02 +00:00
g
862f4ac874 Update Dockerfile 2026-01-11 07:58:06 +00:00
g
acc8c2aa09 asdasd 2026-01-11 07:57:02 +00:00
g
2477e9bb0f fix docker? 2026-01-11 07:48:01 +00:00
g
db757d0107 Update OrdersTable.tsx 2026-01-11 07:41:13 +00:00
g
6f62414888 Update Dockerfile 2026-01-11 07:41:04 +00:00
g
4d9f205277 fix 2026-01-11 07:39:20 +00:00
g
86b812f42b ok 2026-01-11 07:38:00 +00:00
71 changed files with 8902 additions and 4306 deletions

View 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
View File

@@ -46,3 +46,8 @@ public/git-info.json
public/git-info.json public/git-info.json
public/git-info.json public/git-info.json
/.next - Copy /.next - Copy
# Docker Gitea
docker-compose.gitea.yml
runner/
nginx/

View File

@@ -1,3 +1,5 @@
# syntax=docker/dockerfile:1
# Use official Node.js image as base # Use official Node.js image as base
# Next.js 16 requires Node 18.17+ or Node 20+ # Next.js 16 requires Node 18.17+ or Node 20+
# Using node:20-alpine for better compatibility with Next.js 16 and Turbopack # 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 # Install git early for commit hash
RUN apk add --no-cache git RUN apk add --no-cache git
# Install pnpm # Install pnpm properly for Docker
RUN npm install -g pnpm RUN corepack enable && corepack prepare pnpm@latest --activate
# Copy package files for dependency installation
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
# Install dependencies with pnpm # Install dependencies with pnpm
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
# Copy source code after dependencies are installed (for better caching)
COPY . . COPY . .
# Get commit hash (fallback to "unknown" if not in git repo) # 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 NEXT_PUBLIC_API_URL=/api
ENV SERVER_API_URL=https://internal-api.inboxi.ng/api ENV SERVER_API_URL=https://internal-api.inboxi.ng/api
ENV API_BASE_URL=https://internal-api.inboxi.ng ENV API_BASE_URL=https://internal-api.inboxi.ng
ENV API_HOSTNAME=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 # Next.js 16 uses Turbopack by default which may need more memory
RUN echo "Building with GIT_COMMIT_SHA=$(cat /app/git_commit_sha)" && \ 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=4096' NEXT_TELEMETRY_DISABLED=1 pnpm run build NODE_OPTIONS='--max_old_space_size=2048' NEXT_TELEMETRY_DISABLED=1 pnpm run build
# ---- Production Stage ---- # ---- 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 FROM node:20-alpine AS runner
# Set working directory inside the container # Set working directory inside the container
WORKDIR /app 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 static files
COPY --from=builder /app/package.json /app/pnpm-lock.yaml ./ COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules # Copy public files
COPY --from=builder /app/public ./public 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 COPY --from=builder /app/git_commit_sha /app/git_commit_sha
EXPOSE 3000 EXPOSE 3000
@@ -60,4 +69,4 @@ ENV API_HOSTNAME=internal-api.inboxi.ng
# The file is available at /app/git_commit_sha if needed # The file is available at /app/git_commit_sha if needed
# Start Next.js server # Start Next.js server
CMD ["pnpm", "run", "start"] CMD ["node", "server.js"]

View File

@@ -1,3 +1,4 @@
"use client" "use client"
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
@@ -7,7 +8,8 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { toast } from "sonner"; import { toast } from "sonner";
import { Loader2 } from "lucide-react"; import { Loader2, ArrowRight } from "lucide-react";
import { motion } from "framer-motion";
export default function LoginForm() { export default function LoginForm() {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
@@ -25,7 +27,7 @@ export default function LoginForm() {
.split("; ") .split("; ")
.find((row) => row.startsWith("Authorization=")) .find((row) => row.startsWith("Authorization="))
?.split("=")[1]; ?.split("=")[1];
if (authToken) { if (authToken) {
router.push("/dashboard"); router.push("/dashboard");
} }
@@ -45,13 +47,12 @@ export default function LoginForm() {
setIsLoading(true); setIsLoading(true);
try { try {
// Using fetch directly with the proxy path
const response = await fetch("/api/auth/login", { const response = await fetch("/api/auth/login", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
}); });
let data; let data;
try { try {
data = await response.json(); data = await response.json();
@@ -63,95 +64,106 @@ export default function LoginForm() {
setIsLoading(false); setIsLoading(false);
return; return;
} }
if (response.ok && data.token) { 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`; document.cookie = `Authorization=${data.token}; path=/; Secure; SameSite=Strict; max-age=10800`;
localStorage.setItem("Authorization", data.token); localStorage.setItem("Authorization", data.token);
// Show success notification toast.success("Welcome back!", { duration: 2000 });
toast.success("Login successful");
// Set success state for animation
setLoginSuccess(true); setLoginSuccess(true);
// Try Next.js router navigation
router.push(redirectUrl); router.push(redirectUrl);
// Set up a fallback manual redirect if Next.js navigation doesn't work
redirectTimeoutRef.current = setTimeout(() => { redirectTimeoutRef.current = setTimeout(() => {
window.location.href = redirectUrl; window.location.href = redirectUrl;
}, 1500); // Wait 1.5 seconds before trying manual redirect }, 1500);
} else { } else {
// Handle HTTP error responses
const errorMessage = data.error || data.message || data.details || "Invalid credentials"; const errorMessage = data.error || data.message || data.details || "Invalid credentials";
toast.error("Login Failed", { toast.error("Access Denied", {
description: errorMessage, description: errorMessage,
}); });
console.error("Login error response:", { status: response.status, data });
setIsLoading(false); setIsLoading(false);
} }
} catch (error) { } catch (error) {
toast.error("Connection 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); setIsLoading(false);
} }
} }
return ( 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'}`}> <motion.div
<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' : ''}`}> initial={{ opacity: 0, y: 20 }}
<div className="text-center"> animate={{ opacity: 1, y: 0 }}
<h2 className="mt-6 text-3xl font-bold text-gray-900 dark:text-white">Welcome back</h2> transition={{ duration: 0.5 }}
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">Please sign in to your account</p> 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> </div>
<form className="mt-8 space-y-6" onSubmit={handleLogin}> <form className="space-y-6" onSubmit={handleLogin}>
<div className="space-y-4"> <div className="space-y-4">
<div className="animate-in fade-in duration-500"> <div className="space-y-2">
<Label htmlFor="username">Username</Label> <Label htmlFor="username" className="text-zinc-300">Username</Label>
<Input <Input
id="username" id="username"
name="username"
type="text" type="text"
autoComplete="username" autoComplete="username"
required required
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} 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} disabled={isLoading || loginSuccess}
/> />
</div> </div>
<div className="animate-in fade-in duration-500 delay-150"> <div className="space-y-2">
<Label htmlFor="password">Password</Label> <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 <Input
id="password" id="password"
name="password"
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
required required
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} 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} disabled={isLoading || loginSuccess}
/> />
</div> </div>
</div> </div>
<Button <Button
type="submit" 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} disabled={isLoading || loginSuccess}
> >
{isLoading ? ( {isLoading ? (
<span className="flex items-center justify-center"> <span className="flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
Signing in... Signing in...
</span> </span>
) : loginSuccess ? ( ) : loginSuccess ? (
<span className="flex items-center justify-center"> <span className="flex items-center justify-center gap-2">
<ArrowRight className="h-4 w-4" />
Redirecting... Redirecting...
</span> </span>
) : ( ) : (
@@ -160,13 +172,18 @@ export default function LoginForm() {
</Button> </Button>
</form> </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">
Don't have an account?{" "} <p className="text-sm text-zinc-400">
<Link href="/auth/register" className="text-blue-600 hover:underline dark:text-blue-400"> Don't have an account?{" "}
Sign up <Link
</Link> href="/auth/register"
</p> 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> </div>
</div> </motion.div>
); );
} }

View File

@@ -2,28 +2,42 @@
import React, { Suspense, lazy } from "react"; import React, { Suspense, lazy } from "react";
// Use lazy loading for the form component // Use lazy loading for the form component
const LoginForm = lazy(() => import('./components/LoginForm')); 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() { function LoginLoading() {
return ( 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-black/40 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl text-center relative z-10">
<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="mt-6 flex flex-col items-center justify-center">
<div className="mt-6 flex flex-col items-center justify-center"> <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>
<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-zinc-400">Loading secure login...</p>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading login form...</p>
</div>
</div> </div>
</div> </div>
); );
} }
// Main page component that uses Suspense // Main page component
export default function LoginPage() { export default function LoginPage() {
return ( return (
<Suspense fallback={<LoginLoading />}> <div className="relative flex items-center justify-center min-h-screen overflow-hidden">
<LoginForm /> <AuthBackground />
</Suspense> <div className="flex flex-col items-center w-full px-4">
<Suspense fallback={<LoginLoading />}>
<LoginForm />
</Suspense>
</div>
</div>
); );
} }

View File

@@ -1,5 +1,5 @@
"use client"; "use client";
import { fetchData } from "@/lib/api";
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Image from "next/image"; import Image from "next/image";
@@ -7,111 +7,159 @@ import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; 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() { export default function RegisterPage() {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [invitationCode, setInvitationCode] = useState(""); const [invitationCode, setInvitationCode] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const router = useRouter(); const router = useRouter();
async function handleRegister(e: React.FormEvent) { async function handleRegister(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setError("");
setLoading(true); setLoading(true);
const res = await fetchData( try {
`/api/auth/register`, const res = await fetch(`/api/auth/register`, {
{
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password, invitationCode }), body: JSON.stringify({ username, password, invitationCode }),
});
const data = await res.json();
if (res.ok) {
toast({
title: "Account Created! 🎉",
description: "Welcome to Ember Market. Redirecting to login...",
variant: "default",
});
setTimeout(() => router.push("/auth/login"), 1500);
} else {
toast({
title: "Registration Failed",
description: data.error || "Please check your details.",
variant: "destructive",
});
setLoading(false);
} }
); } catch (error) {
toast({
const data = await res; title: "Error",
description: "Something went wrong. Please try again.",
if (res) { variant: "destructive",
console.log("Registered successfully:", data); });
router.push("/auth/login"); setLoading(false);
} else {
setError(data.error || "Registration failed");
} }
setLoading(false);
} }
return ( return (
<div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-[#0F0F12]"> <div className="relative flex items-center justify-center min-h-screen overflow-hidden">
<div className="w-full max-w-md p-8 space-y-8 bg-white dark:bg-[#1F1F23] rounded-xl shadow-lg"> <AuthBackground />
<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>
{error && <p className="text-red-500 text-sm text-center">{error}</p>} <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>
<form className="mt-8 space-y-6" onSubmit={handleRegister}> <form className="space-y-5" onSubmit={handleRegister}>
<div className="space-y-4"> <div className="space-y-4 text-left">
<div> <div className="space-y-2">
<Label htmlFor="username">Username</Label> <Label htmlFor="username" className="text-zinc-300">Username</Label>
<Input <Input
id="username" id="username"
name="username" name="username"
type="text" type="text"
autoComplete="username" autoComplete="username"
required required
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} 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"
</div> disabled={loading}
<div> />
<Label htmlFor="password">Password</Label> </div>
<Input <div className="space-y-2">
id="password" <Label htmlFor="password" className="text-zinc-300">Password</Label>
name="password" <Input
type="password" id="password"
autoComplete="new-password" name="password"
required type="password"
value={password} autoComplete="new-password"
onChange={(e) => setPassword(e.target.value)} required
className="mt-1" value={password}
/> onChange={(e) => setPassword(e.target.value)}
</div> 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"
<div> placeholder="Create a strong password"
<Label htmlFor="invitationCode">Invitation Code</Label> disabled={loading}
<Input />
id="invitationCode" </div>
name="invitationCode" <div className="space-y-2">
type="text" <Label htmlFor="invitationCode" className="text-zinc-300">Invitation Code</Label>
required <Input
value={invitationCode} id="invitationCode"
onChange={(e) => setInvitationCode(e.target.value)} name="invitationCode"
className="mt-1" type="text"
/> required
value={invitationCode}
onChange={(e) => setInvitationCode(e.target.value)}
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 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>
<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-indigo-400 hover:text-indigo-300 font-medium transition-colors inline-flex items-center gap-1"
>
Sign in <ArrowRight className="w-3 h-3" />
</Link>
</p>
</div> </div>
</div> </div>
</motion.div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Registering..." : "Sign Up"}
</Button>
</form>
<p className="mt-6 text-sm text-center text-gray-600 dark:text-gray-400">
Already have an account?{" "}
<Link
href="/auth/login"
className="text-blue-600 hover:underline dark:text-blue-400"
>
Sign in
</Link>
</p>
</div> </div>
</div> </div>
); );

View File

@@ -1,8 +1,9 @@
import React from "react"; import React from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; 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 { fetchServer } from "@/lib/api";
import OrdersTable from "@/components/admin/OrdersTable"; import OrdersTable from "@/components/admin/OrdersTable";
import { MotionWrapper } from "@/components/ui/motion-wrapper";
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -26,7 +27,6 @@ interface SystemStats {
chats: number; chats: number;
} }
export default async function AdminOrdersPage() { export default async function AdminOrdersPage() {
let orders: Order[] = []; let orders: Order[] = [];
let systemStats: SystemStats | null = null; let systemStats: SystemStats | null = null;
@@ -46,14 +46,14 @@ export default async function AdminOrdersPage() {
if (error) { if (error) {
return ( return (
<div className="space-y-6"> <div className="space-y-6 p-1">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">Recent Orders</h1> <h1 className="text-3xl font-bold tracking-tight">Recent Orders</h1>
<p className="text-sm text-muted-foreground mt-1">Monitor and manage platform orders</p> <p className="text-muted-foreground mt-2">Monitor and manage platform orders</p>
</div> </div>
<Card> <Card className="border-destructive/50 bg-destructive/10">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="text-center text-red-500"> <div className="text-center text-destructive">
<p>{error}</p> <p>{error}</p>
</div> </div>
</CardContent> </CardContent>
@@ -62,146 +62,182 @@ export default async function AdminOrdersPage() {
); );
} }
const acknowledgedOrders = orders.filter(o => o.status === 'acknowledged'); const acknowledgedOrders = orders.filter(o => o.status === 'acknowledged');
const paidOrders = orders.filter(o => o.status === 'paid'); const paidOrders = orders.filter(o => o.status === 'paid');
const completedOrders = orders.filter(o => o.status === 'completed'); const completedOrders = orders.filter(o => o.status === 'completed');
const cancelledOrders = orders.filter(o => o.status === 'cancelled'); const cancelledOrders = orders.filter(o => o.status === 'cancelled');
return ( return (
<div className="space-y-6"> <div className="space-y-8 p-1">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">Recent Orders</h1> <h1 className="text-3xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">
<p className="text-sm text-muted-foreground mt-1">Monitor and manage platform orders</p> Recent Orders
</h1>
<p className="text-muted-foreground mt-2 text-lg">
Monitor and manage platform transaction activity
</p>
</div> </div>
{/* Stats Cards */} <MotionWrapper>
<div className="grid gap-4 md:grid-cols-4"> <div className="space-y-8">
<Card> {/* Stats Cards */}
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <div className="grid gap-4 md:grid-cols-4">
<CardTitle className="text-sm font-medium">Total Orders</CardTitle> <Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm hover:shadow-md transition-all duration-300">
<Package className="h-4 w-4 text-muted-foreground" /> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
</CardHeader> <CardTitle className="text-sm font-medium text-muted-foreground">Total Orders</CardTitle>
<CardContent> <div className="p-2 rounded-lg bg-primary/10">
<div className="text-2xl font-bold">{systemStats?.orders || 0}</div> <Package className="h-4 w-4 text-primary" />
<p className="text-xs text-muted-foreground">All platform orders</p> </div>
</CardContent> </CardHeader>
</Card> <CardContent>
<Card> <div className="text-2xl font-bold">{systemStats?.orders || 0}</div>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <div className="flex items-center mt-1">
<CardTitle className="text-sm font-medium">Acknowledged</CardTitle> <div className="h-1.5 w-1.5 rounded-full bg-primary mr-2" />
<AlertTriangle className="h-4 w-4 text-muted-foreground" /> <p className="text-xs text-muted-foreground">Lifetime volume</p>
</CardHeader> </div>
<CardContent> </CardContent>
<div className="text-2xl font-bold">{acknowledgedOrders.length}</div> </Card>
<p className="text-xs text-muted-foreground">Vendor accepted</p>
</CardContent>
</Card>
<Card>
<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" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{paidOrders.length}</div>
<p className="text-xs text-muted-foreground">Payment received</p>
</CardContent>
</Card>
<Card>
<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" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{completedOrders.length}</div>
<p className="text-xs text-muted-foreground">Successfully delivered</p>
</CardContent>
</Card>
</div>
{/* Orders Table with Pagination */} <Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm hover:shadow-md transition-all duration-300">
<OrdersTable orders={orders} /> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<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>
<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>
{/* Order Analytics */} <Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm hover:shadow-md transition-all duration-300">
<div className="grid gap-6 md:grid-cols-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<Card> <CardTitle className="text-sm font-medium text-muted-foreground">Paid</CardTitle>
<CardHeader> <div className="p-2 rounded-lg bg-emerald-500/10">
<CardTitle>Order Status Distribution</CardTitle> <DollarSign className="h-4 w-4 text-emerald-500" />
<CardDescription>Breakdown of recent orders by status</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> </div>
<span className="text-sm font-medium"> </CardHeader>
{orders.length > 0 ? Math.round((acknowledgedOrders.length / orders.length) * 100) : 0}% <CardContent>
</span> <div className="text-2xl font-bold">{paidOrders.length}</div>
</div> <div className="flex items-center mt-1">
<div className="flex items-center justify-between"> <div className="h-1.5 w-1.5 rounded-full bg-emerald-500 mr-2" />
<div className="flex items-center space-x-2"> <p className="text-xs text-muted-foreground">Processing</p>
<div className="w-3 h-3 bg-emerald-500 rounded-full"></div>
<span className="text-sm">Paid</span>
</div> </div>
<span className="text-sm font-medium"> </CardContent>
{orders.length > 0 ? Math.round((paidOrders.length / orders.length) * 100) : 0}% </Card>
</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>
<span className="text-sm font-medium">
{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 hover:shadow-md transition-all duration-300">
<CardHeader> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle>Order Summary</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">Completed</CardTitle>
<CardDescription>Recent order activity breakdown</CardDescription> <div className="p-2 rounded-lg bg-blue-500/10">
</CardHeader> <CheckCircle2 className="h-4 w-4 text-blue-500" />
<CardContent> </div>
<div className="space-y-4"> </CardHeader>
<div className="flex items-center justify-between"> <CardContent>
<span className="text-sm">Total Recent Orders</span> <div className="text-2xl font-bold">{completedOrders.length}</div>
<span className="text-sm font-medium">{orders.length}</span> <div className="flex items-center mt-1">
</div> <div className="h-1.5 w-1.5 rounded-full bg-blue-500 mr-2" />
<div className="flex items-center justify-between"> <p className="text-xs text-muted-foreground">Delivered</p>
<span className="text-sm">Acknowledged</span> </div>
<span className="text-sm font-medium">{acknowledgedOrders.length}</span> </CardContent>
</div> </Card>
<div className="flex items-center justify-between"> </div>
<span className="text-sm">Paid</span>
<span className="text-sm font-medium">{paidOrders.length}</span> {/* Orders Table with Pagination */}
</div> <div className="bg-background/50 backdrop-blur-sm rounded-xl border border-border/40 overflow-hidden shadow-sm">
<div className="flex items-center justify-between"> <OrdersTable orders={orders} />
<span className="text-sm">Completed</span> </div>
<span className="text-sm font-medium">{completedOrders.length}</span>
</div> {/* Order Analytics */}
<div className="flex items-center justify-between"> <div className="grid gap-6 md:grid-cols-2">
<span className="text-sm">Cancelled</span> <Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm">
<span className="text-sm font-medium">{cancelledOrders.length}</span> <CardHeader>
</div> <CardTitle>Status Distribution</CardTitle>
</div> <CardDescription>Breakdown of active orders</CardDescription>
</CardContent> </CardHeader>
</Card> <CardContent>
</div> <div className="space-y-4">
<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>
</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 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>
</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 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>
</div>
<span className="font-bold">
{orders.length > 0 ? Math.round((completedOrders.length / orders.length) * 100) : 0}%
</span>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm">
<CardHeader>
<CardTitle>Activity Summary</CardTitle>
<CardDescription>Recent volume breakdown</CardDescription>
</CardHeader>
<CardContent>
<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="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="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="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>
</div>
</CardContent>
</Card>
</div>
</div>
</MotionWrapper>
</div> </div>
); );
} }

View File

@@ -37,7 +37,7 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error(`Error loading ${this.props.componentName || 'component'}:`, error, errorInfo); console.error(`Error loading ${this.props.componentName || 'component'}:`, error, errorInfo);
// Log to error tracking service if available // Log to error tracking service if available
if (typeof window !== 'undefined' && (window as any).gtag) { if (typeof window !== 'undefined' && (window as any).gtag) {
(window as any).gtag('event', 'exception', { (window as any).gtag('event', 'exception', {
@@ -105,31 +105,31 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
} }
// Lazy load admin components with error handling // Lazy load admin components with error handling
const AdminAnalytics = lazy(() => const AdminAnalytics = lazy(() =>
import("@/components/admin/AdminAnalytics").catch((err) => { import("@/components/admin/AdminAnalytics").catch((err) => {
console.error("Failed to load AdminAnalytics:", err); console.error("Failed to load AdminAnalytics:", err);
throw err; throw err;
}) })
); );
const InviteVendorCard = lazy(() => const InviteVendorCard = lazy(() =>
import("@/components/admin/InviteVendorCard").catch((err) => { import("@/components/admin/InviteVendorCard").catch((err) => {
console.error("Failed to load InviteVendorCard:", err); console.error("Failed to load InviteVendorCard:", err);
throw err; throw err;
}) })
); );
const BanUserCard = lazy(() => const BanUserCard = lazy(() =>
import("@/components/admin/BanUserCard").catch((err) => { import("@/components/admin/BanUserCard").catch((err) => {
console.error("Failed to load BanUserCard:", err); console.error("Failed to load BanUserCard:", err);
throw err; throw err;
}) })
); );
const InvitationsListCard = lazy(() => const InvitationsListCard = lazy(() =>
import("@/components/admin/InvitationsListCard").catch((err) => { import("@/components/admin/InvitationsListCard").catch((err) => {
console.error("Failed to load InvitationsListCard:", err); console.error("Failed to load InvitationsListCard:", err);
throw err; throw err;
}) })
); );
const VendorsCard = lazy(() => const VendorsCard = lazy(() =>
import("@/components/admin/VendorsCard").catch((err) => { import("@/components/admin/VendorsCard").catch((err) => {
console.error("Failed to load VendorsCard:", err); console.error("Failed to load VendorsCard:", err);
throw err; throw err;
@@ -139,7 +139,7 @@ const VendorsCard = lazy(() =>
// Loading skeleton with timeout warning // Loading skeleton with timeout warning
function AdminComponentSkeleton({ showSlowWarning = false }: { showSlowWarning?: boolean }) { function AdminComponentSkeleton({ showSlowWarning = false }: { showSlowWarning?: boolean }) {
return ( return (
<div <div
className="space-y-6 animate-in fade-in duration-500 relative" className="space-y-6 animate-in fade-in duration-500 relative"
role="status" role="status"
aria-label="Loading analytics dashboard" aria-label="Loading analytics dashboard"
@@ -156,7 +156,7 @@ function AdminComponentSkeleton({ showSlowWarning = false }: { showSlowWarning?:
)} )}
{/* Subtle loading indicator */} {/* Subtle loading indicator */}
<div className="absolute top-0 left-0 right-0 h-1 bg-muted overflow-hidden rounded-full"> <div className="absolute top-0 left-0 right-0 h-1 bg-muted overflow-hidden rounded-full">
<div className="h-full bg-primary w-1/3 animate-[shimmer_2s_ease-in-out_infinite]" <div className="h-full bg-primary w-1/3 animate-[shimmer_2s_ease-in-out_infinite]"
style={{ style={{
background: 'linear-gradient(90deg, transparent, hsl(var(--primary)), transparent)', background: 'linear-gradient(90deg, transparent, hsl(var(--primary)), transparent)',
backgroundSize: '200% 100%', backgroundSize: '200% 100%',
@@ -164,7 +164,7 @@ function AdminComponentSkeleton({ showSlowWarning = false }: { showSlowWarning?:
}} }}
/> />
</div> </div>
{/* Header skeleton */} {/* Header skeleton */}
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="space-y-2"> <div className="space-y-2">
@@ -181,9 +181,9 @@ function AdminComponentSkeleton({ showSlowWarning = false }: { showSlowWarning?:
{/* Metric cards grid skeleton */} {/* Metric cards grid skeleton */}
<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-3 xl:grid-cols-4 gap-4">
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => ( {[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
<Card <Card
key={i} 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={{ style={{
animationDelay: `${i * 50}ms`, animationDelay: `${i * 50}ms`,
animationDuration: '400ms', animationDuration: '400ms',
@@ -227,14 +227,14 @@ function AdminComponentSkeleton({ showSlowWarning = false }: { showSlowWarning?:
} }
// Suspense wrapper with timeout // Suspense wrapper with timeout
function SuspenseWithTimeout({ function SuspenseWithTimeout({
children, children,
fallback, fallback,
timeout = 5000, timeout = 5000,
timeoutFallback timeoutFallback
}: { }: {
children: ReactNode; children: ReactNode;
fallback: ReactNode; fallback: ReactNode;
timeout?: number; timeout?: number;
timeoutFallback?: ReactNode; timeoutFallback?: ReactNode;
}) { }) {
@@ -258,7 +258,7 @@ function SuspenseWithTimeout({
// Loading skeleton for management cards // Loading skeleton for management cards
function ManagementCardsSkeleton({ showSlowWarning = false }: { showSlowWarning?: boolean }) { function ManagementCardsSkeleton({ showSlowWarning = false }: { showSlowWarning?: boolean }) {
return ( return (
<div <div
className="grid gap-4 lg:gap-6 sm:grid-cols-2 lg:grid-cols-3 items-stretch relative" className="grid gap-4 lg:gap-6 sm:grid-cols-2 lg:grid-cols-3 items-stretch relative"
role="status" role="status"
aria-label="Loading management tools" aria-label="Loading management tools"
@@ -277,7 +277,7 @@ function ManagementCardsSkeleton({ showSlowWarning = false }: { showSlowWarning?
)} )}
{/* Subtle loading indicator */} {/* Subtle loading indicator */}
<div className="absolute -top-6 left-0 right-0 h-1 bg-muted overflow-hidden rounded-full"> <div className="absolute -top-6 left-0 right-0 h-1 bg-muted overflow-hidden rounded-full">
<div className="h-full bg-primary w-1/3" <div className="h-full bg-primary w-1/3"
style={{ style={{
background: 'linear-gradient(90deg, transparent, hsl(var(--primary)), transparent)', background: 'linear-gradient(90deg, transparent, hsl(var(--primary)), transparent)',
backgroundSize: '200% 100%', backgroundSize: '200% 100%',
@@ -286,9 +286,9 @@ function ManagementCardsSkeleton({ showSlowWarning = false }: { showSlowWarning?
/> />
</div> </div>
{[1, 2, 3, 4].map((i) => ( {[1, 2, 3, 4].map((i) => (
<Card <Card
key={i} 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={{ style={{
animationDelay: `${i * 75}ms`, animationDelay: `${i * 75}ms`,
animationDuration: '400ms', animationDuration: '400ms',
@@ -311,8 +311,8 @@ function ManagementCardsSkeleton({ showSlowWarning = false }: { showSlowWarning?
/* List items skeleton for list cards */ /* List items skeleton for list cards */
<> <>
{[1, 2, 3].map((j) => ( {[1, 2, 3].map((j) => (
<div <div
key={j} key={j}
className="space-y-2 p-3 rounded border border-border/50 animate-in fade-in" className="space-y-2 p-3 rounded border border-border/50 animate-in fade-in"
style={{ style={{
animationDelay: `${(i - 2) * 75 + j * 50}ms`, animationDelay: `${(i - 2) * 75 + j * 50}ms`,
@@ -345,9 +345,9 @@ export default function AdminPage() {
const prefetchTabComponents = (tab: string) => { const prefetchTabComponents = (tab: string) => {
// Avoid prefetching if already done // Avoid prefetching if already done
if (prefetchedTabs.has(tab)) return; if (prefetchedTabs.has(tab)) return;
const startTime = performance.now(); const startTime = performance.now();
if (tab === "analytics") { if (tab === "analytics") {
// Prefetch analytics component // Prefetch analytics component
import("@/components/admin/AdminAnalytics") import("@/components/admin/AdminAnalytics")
@@ -355,7 +355,7 @@ export default function AdminPage() {
const loadTime = performance.now() - startTime; const loadTime = performance.now() - startTime;
console.log(`[Performance] AdminAnalytics prefetched in ${loadTime.toFixed(2)}ms`); console.log(`[Performance] AdminAnalytics prefetched in ${loadTime.toFixed(2)}ms`);
}) })
.catch(() => {}); .catch(() => { });
} else if (tab === "management") { } else if (tab === "management") {
// Prefetch management components // Prefetch management components
Promise.all([ Promise.all([
@@ -368,9 +368,9 @@ export default function AdminPage() {
const loadTime = performance.now() - startTime; const loadTime = performance.now() - startTime;
console.log(`[Performance] Management components prefetched in ${loadTime.toFixed(2)}ms`); console.log(`[Performance] Management components prefetched in ${loadTime.toFixed(2)}ms`);
}) })
.catch(() => {}); .catch(() => { });
} }
setPrefetchedTabs(prev => new Set(prev).add(tab)); setPrefetchedTabs(prev => new Set(prev).add(tab));
}; };
@@ -392,29 +392,31 @@ export default function AdminPage() {
return ( return (
<div className="space-y-6"> <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> <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> <p className="text-sm text-muted-foreground mt-1">Platform analytics and vendor management</p>
</div> </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> <Link href="/dashboard">Back to Dashboard</Link>
</Button> </Button>
</div> </div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6"> <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 <TabsTrigger
value="analytics" value="analytics"
onMouseEnter={() => handleTabHover("analytics")} onMouseEnter={() => handleTabHover("analytics")}
onFocus={() => handleTabFocus("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 Analytics
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="management" value="management"
onMouseEnter={() => handleTabHover("management")} onMouseEnter={() => handleTabHover("management")}
onFocus={() => handleTabFocus("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 Management
</TabsTrigger> </TabsTrigger>
@@ -422,7 +424,7 @@ export default function AdminPage() {
<TabsContent value="analytics" className="space-y-6 relative"> <TabsContent value="analytics" className="space-y-6 relative">
<ErrorBoundary componentName="Analytics Dashboard"> <ErrorBoundary componentName="Analytics Dashboard">
<SuspenseWithTimeout <SuspenseWithTimeout
fallback={<AdminComponentSkeleton />} fallback={<AdminComponentSkeleton />}
timeout={5000} timeout={5000}
timeoutFallback={<AdminComponentSkeleton showSlowWarning={true} />} timeoutFallback={<AdminComponentSkeleton showSlowWarning={true} />}
@@ -436,7 +438,7 @@ export default function AdminPage() {
<TabsContent value="management" className="space-y-6 relative"> <TabsContent value="management" className="space-y-6 relative">
<ErrorBoundary componentName="Management Tools"> <ErrorBoundary componentName="Management Tools">
<SuspenseWithTimeout <SuspenseWithTimeout
fallback={<ManagementCardsSkeleton />} fallback={<ManagementCardsSkeleton />}
timeout={5000} timeout={5000}
timeoutFallback={<ManagementCardsSkeleton showSlowWarning={true} />} timeoutFallback={<ManagementCardsSkeleton showSlowWarning={true} />}

View File

@@ -1,9 +1,10 @@
import React from "react"; 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 { 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 { fetchServer } from "@/lib/api";
import SystemStatusCard from "@/components/admin/SystemStatusCard"; import SystemStatusCard from "@/components/admin/SystemStatusCard";
import { MotionWrapper } from "@/components/ui/motion-wrapper";
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -35,17 +36,19 @@ export default async function AdminStatusPage() {
console.error("Failed to fetch system status:", err); console.error("Failed to fetch system status:", err);
error = "Failed to load system status"; error = "Failed to load system status";
} }
if (error) { if (error) {
return ( return (
<div className="space-y-6"> <div className="space-y-8 p-1">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">System Status</h1> <h1 className="text-3xl font-bold tracking-tight">System Status</h1>
<p className="text-sm text-muted-foreground mt-1">Monitor system health and performance metrics</p> <p className="text-muted-foreground mt-2">Monitor system health and real-time performance metrics</p>
</div> </div>
<Card> <Card className="border-destructive/50 bg-destructive/10">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="text-center text-red-500"> <div className="flex items-center gap-3 text-destructive">
<p>{error}</p> <Activity className="h-5 w-5" />
<p className="font-medium">{error}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -67,116 +70,176 @@ export default async function AdminStatusPage() {
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
}; };
const memoryUsagePercent = systemStatus ? const memoryUsagePercent = systemStatus ?
Math.round((systemStatus.memory.heapUsed / systemStatus.memory.heapTotal) * 100) : 0; Math.round((systemStatus.memory.heapUsed / systemStatus.memory.heapTotal) * 100) : 0;
return ( return (
<div className="space-y-6"> <div className="space-y-8 p-1">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">System Status</h1> <h1 className="text-3xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">
<p className="text-sm text-muted-foreground mt-1">Monitor system health and performance metrics</p> System Status
</h1>
<p className="text-muted-foreground mt-2 text-lg">
Monitor system health and real-time performance metrics
</p>
</div> </div>
<SystemStatusCard /> <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 */} {/* 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"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Server Status</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">Server Uptime</CardTitle>
<Server className="h-4 w-4 text-muted-foreground" /> <div className="p-2 rounded-lg bg-green-500/10">
</CardHeader> <Server className="h-4 w-4 text-green-500" />
<CardContent> </div>
<div className="flex items-center space-x-2"> </CardHeader>
<Badge variant="default" className="bg-green-500">Online</Badge> <CardContent>
<span className="text-sm text-muted-foreground"> <div className="flex items-center space-x-2">
{systemStatus ? formatUptime(systemStatus.uptimeSeconds) : 'N/A'} <span className="text-2xl font-bold">
</span> {systemStatus ? formatUptime(systemStatus.uptimeSeconds) : 'N/A'}
</div> </span>
<p className="text-xs text-muted-foreground mt-2"> </div>
Last checked: {new Date().toLocaleTimeString()} <div className="flex items-center mt-2 space-x-2">
</p> <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
</CardContent> Online
</Card> </Badge>
<span className="text-xs text-muted-foreground">
Checked: {new Date().toLocaleTimeString()}
</span>
</div>
</CardContent>
</Card>
{/* Database Status */} {/* 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"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Database</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">Database Health</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" /> <div className="p-2 rounded-lg bg-blue-500/10">
</CardHeader> <Database className="h-4 w-4 text-blue-500" />
<CardContent> </div>
<div className="flex items-center space-x-2"> </CardHeader>
<Badge variant="default" className="bg-green-500">Connected</Badge> <CardContent>
<span className="text-sm text-muted-foreground"> <div className="flex items-center space-x-2">
{systemStatus ? `${systemStatus.counts.vendors + systemStatus.counts.orders + systemStatus.counts.products} records` : 'N/A'} <span className="text-2xl font-bold">
</span> {systemStatus ? `${(systemStatus.counts.vendors + systemStatus.counts.orders + systemStatus.counts.products).toLocaleString()}` : '0'}
</div> </span>
<p className="text-xs text-muted-foreground mt-2"> <span className="text-sm text-muted-foreground self-end mb-1">records</span>
Total collections: 4 </div>
</p> <div className="flex items-center mt-2 space-x-2">
</CardContent> <Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">
</Card> Connected
</Badge>
<span className="text-xs text-muted-foreground">
4 active collections
</span>
</div>
</CardContent>
</Card>
{/* Memory Usage */} {/* 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"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Memory</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">Memory Usage</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" /> <div className="p-2 rounded-lg bg-purple-500/10">
</CardHeader> <HardDrive className="h-4 w-4 text-purple-500" />
<CardContent> </div>
<div className="flex items-center space-x-2"> </CardHeader>
<Badge variant={memoryUsagePercent > 80 ? "destructive" : memoryUsagePercent > 60 ? "secondary" : "outline"}> <CardContent>
{memoryUsagePercent}% <div className="flex items-center space-x-2">
</Badge> <span className="text-2xl font-bold">
<span className="text-sm text-muted-foreground"> {systemStatus ? formatBytes(systemStatus.memory.heapUsed) : 'N/A'}
{systemStatus ? formatBytes(systemStatus.memory.heapUsed) : 'N/A'} </span>
</span> <span className="text-sm text-muted-foreground self-end mb-1">used</span>
</div> </div>
<p className="text-xs text-muted-foreground mt-2"> <div className="flex items-center mt-2 space-x-2">
Total: {systemStatus ? formatBytes(systemStatus.memory.heapTotal) : 'N/A'} <Badge variant="outline" className={`
</p> ${memoryUsagePercent > 80 ? 'bg-red-500/10 text-red-500 border-red-500/20' :
</CardContent> memoryUsagePercent > 60 ? 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20' :
</Card> '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'}
</span>
</div>
</CardContent>
</Card>
{/* Platform Stats */} {/* 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"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Platform Stats</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">Platform Activity</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" /> <div className="p-2 rounded-lg bg-orange-500/10">
</CardHeader> <Activity className="h-4 w-4 text-orange-500" />
<CardContent> </div>
<div className="flex items-center space-x-2"> </CardHeader>
<Badge variant="default" className="bg-green-500">Active</Badge> <CardContent>
<span className="text-sm text-muted-foreground"> <div className="flex items-center space-x-2">
{systemStatus ? `${systemStatus.counts.vendors} vendors` : 'N/A'} <span className="text-2xl font-bold">
</span> {systemStatus ? systemStatus.counts.vendors : '0'}
</div> </span>
<p className="text-xs text-muted-foreground mt-2"> <span className="text-sm text-muted-foreground self-end mb-1">Active Vendors</span>
{systemStatus ? `${systemStatus.counts.orders} orders, ${systemStatus.counts.products} products` : 'N/A'} </div>
</p> <div className="flex items-center mt-2 space-x-2">
</CardContent> <Badge variant="outline" className="bg-orange-500/10 text-orange-500 border-orange-500/20">
</Card> Live
</Badge>
<span className="text-xs text-muted-foreground">
{systemStatus ? `${systemStatus.counts.orders} orders` : '0 orders'}
</span>
</div>
</CardContent>
</Card>
{/* Node.js Version */} {/* Runtime Info */}
<Card> <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"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Runtime</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">Runtime Environment</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" /> <div className="p-2 rounded-lg bg-zinc-500/10">
</CardHeader> <Cpu className="h-4 w-4 text-zinc-500" />
<CardContent> </div>
<div className="flex items-center space-x-2"> </CardHeader>
<Badge variant="outline"> <CardContent>
{systemStatus ? `Node ${systemStatus.versions.node}` : 'N/A'} <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
</Badge> <div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">Runtime</span> <div>
</div> <p className="text-2xl font-bold">Node.js</p>
<p className="text-xs text-muted-foreground mt-2"> <p className="text-xs text-muted-foreground mt-1">Runtime</p>
{systemStatus ? `V8: ${systemStatus.versions.v8}` : 'N/A'} </div>
</p> <Badge variant="secondary" className="text-sm h-7">
</CardContent> {systemStatus ? `v${systemStatus.versions.node}` : 'N/A'}
</Card> </Badge>
</div> </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>
</CardContent>
</Card>
</div>
</div>
</MotionWrapper>
</div> </div>
); );
} }

View File

@@ -7,9 +7,10 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; 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 { fetchClient } from "@/lib/api-client";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { motion, AnimatePresence } from "framer-motion";
interface TelegramUser { interface TelegramUser {
telegramUserId: string; telegramUserId: string;
@@ -49,6 +50,14 @@ export default function AdminUsersPage() {
const { toast } = useToast(); const { toast } = useToast();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [users, setUsers] = useState<TelegramUser[]>([]); 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 [searchQuery, setSearchQuery] = useState("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pagination, setPagination] = useState<PaginationResponse['pagination'] | null>(null); 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 totalSpent = users.reduce((sum, u) => sum + u.totalSpent, 0);
const totalOrders = users.reduce((sum, u) => sum + u.totalOrders, 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 ( return (
<div className="space-y-6"> <div className="space-y-6 animate-in fade-in duration-500">
<div> <div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold tracking-tight">Telegram Users</h1> <div>
<p className="text-sm text-muted-foreground mt-1">Manage Telegram user accounts and view statistics</p> <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> </div>
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-5"> <div className="grid gap-4 md:grid-cols-5">
<Card> {stats.map((stat, i) => (
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <Card key={i} className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
<CardTitle className="text-sm font-medium">Total Users</CardTitle> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
</CardHeader> <CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
<CardContent> <stat.icon className="h-4 w-4 text-muted-foreground" />
{loading ? ( </CardHeader>
<div className="flex items-center justify-center py-4"> <CardContent>
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> {loading ? (
</div> <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> <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>
</CardContent> </div>
</Card> )}
<Card> </CardContent>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> </Card>
<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>
</>
)}
</CardContent>
</Card>
</div> </div>
{/* Search and Filters */} {/* Search and Filters */}
<Card> <Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
<CardHeader> <CardHeader className="pb-4">
<div className="flex items-center justify-between"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div> <div>
<CardTitle>User Management</CardTitle> <CardTitle className="text-lg font-medium">User Management</CardTitle>
<CardDescription>View and manage all Telegram user accounts</CardDescription> <CardDescription>View and manage all Telegram user accounts</CardDescription>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="relative"> <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 <Input
placeholder="Search users..." 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} value={searchQuery}
onChange={(e) => { onChange={(e) => {
setSearchQuery(e.target.value); setSearchQuery(e.target.value);
@@ -216,19 +188,11 @@ export default function AdminUsersPage() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{loading ? ( <div className="rounded-md border border-border/50 overflow-hidden">
<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>
) : (
<Table> <Table>
<TableHeader> <TableHeader className="bg-muted/30">
<TableRow> <TableRow className="border-border/50 hover:bg-transparent">
<TableHead>User ID</TableHead> <TableHead className="w-[100px]">User ID</TableHead>
<TableHead>Username</TableHead> <TableHead>Username</TableHead>
<TableHead>Orders</TableHead> <TableHead>Orders</TableHead>
<TableHead>Total Spent</TableHead> <TableHead>Total Spent</TableHead>
@@ -239,88 +203,103 @@ export default function AdminUsersPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{users.map((user) => ( <AnimatePresence mode="popLayout">
<TableRow key={user.telegramUserId}> {loading ? (
<TableCell> <TableRow>
<div className="font-mono text-sm">{user.telegramUserId}</div> <TableCell colSpan={8} className="h-24 text-center">
</TableCell> <div className="flex items-center justify-center gap-2 text-muted-foreground">
<TableCell> <Loader2 className="h-4 w-4 animate-spin" />
<div className="font-medium"> Loading users...
{user.telegramUsername !== "Unknown" ? `@${user.telegramUsername}` : "Unknown"} </div>
</div> </TableCell>
</TableCell> </TableRow>
<TableCell> ) : users.length > 0 ? (
<div className="flex items-center space-x-2"> users.map((user, index) => (
<Package className="h-4 w-4 text-muted-foreground" /> <motion.tr
<span>{user.totalOrders}</span> key={user.telegramUserId}
{user.completedOrders > 0 && ( initial={{ opacity: 0, y: 10 }}
<Badge variant="secondary" className="text-xs"> animate={{ opacity: 1, y: 0 }}
{user.completedOrders} completed exit={{ opacity: 0 }}
</Badge> 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"}`}
</div> >
</TableCell> <TableCell className="font-mono text-xs">{user.telegramUserId}</TableCell>
<TableCell> <TableCell>
<div className="flex items-center space-x-1"> <div className="flex items-center gap-2">
<DollarSign className="h-4 w-4 text-muted-foreground" /> <span className="font-medium">@{user.telegramUsername || "Unknown"}</span>
<span className="font-medium">{formatCurrency(user.totalSpent)}</span> {user.isBlocked && (
</div> <Badge variant="destructive" className="h-5 px-1.5 text-[10px]">Blocked</Badge>
</TableCell>
<TableCell>
{user.isBlocked ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="destructive">
<Ban className="h-3 w-3 mr-1" />
Blocked
</Badge>
</TooltipTrigger>
{user.blockedReason && (
<TooltipContent>
<p className="max-w-xs">{user.blockedReason}</p>
</TooltipContent>
)} )}
</Tooltip> </div>
</TooltipProvider> </TableCell>
) : user.totalOrders > 0 ? ( <TableCell>{user.totalOrders}</TableCell>
<Badge variant="default">Active</Badge> <TableCell>{formatCurrency(user.totalSpent)}</TableCell>
) : ( <TableCell>
<Badge variant="secondary">No Orders</Badge> <div className="flex flex-col gap-1 text-xs">
)} <span className="text-emerald-500">{user.completedOrders} Completed</span>
</TableCell> <span className="text-muted-foreground">{user.paidOrders - user.completedOrders} Pending</span>
<TableCell> </div>
{user.firstOrderDate </TableCell>
? new Date(user.firstOrderDate).toLocaleDateString() <TableCell className="text-xs text-muted-foreground">
: 'N/A'} {user.firstOrderDate ? new Date(user.firstOrderDate).toLocaleDateString() : "-"}
</TableCell> </TableCell>
<TableCell> <TableCell className="text-xs text-muted-foreground">
{user.lastOrderDate {user.lastOrderDate ? new Date(user.lastOrderDate).toLocaleDateString() : "-"}
? new Date(user.lastOrderDate).toLocaleDateString() </TableCell>
: 'N/A'} <TableCell className="text-right">
</TableCell> <div className="flex justify-end gap-1">
<TableCell className="text-right"> {user.isBlocked ? (
<div className="flex items-center justify-end space-x-2"> <TooltipProvider>
{!user.isBlocked ? ( <Tooltip>
<Button variant="outline" size="sm"> <TooltipTrigger asChild>
<Ban className="h-4 w-4" /> <Button size="sm" variant="outline" className="h-8 border-emerald-500/20 text-emerald-500 hover:bg-emerald-500/10 hover:text-emerald-400">
</Button> <UserCheck className="h-4 w-4 mr-1" />
) : ( Unblock
<Button variant="outline" size="sm"> </Button>
<UserCheck className="h-4 w-4" /> </TooltipTrigger>
</Button> <TooltipContent>
)} <p>Unblock this user</p>
</div> </TooltipContent>
</TableCell> </Tooltip>
</TableRow> </TooltipProvider>
))} ) : (
<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> </TableBody>
</Table> </Table>
)} </div>
{pagination && pagination.totalPages > 1 && ( {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"> <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>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
@@ -328,6 +307,7 @@ export default function AdminUsersPage() {
size="sm" size="sm"
onClick={() => setPage(p => Math.max(1, p - 1))} onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={!pagination.hasPrevPage} disabled={!pagination.hasPrevPage}
className="h-8"
> >
Previous Previous
</Button> </Button>
@@ -336,6 +316,7 @@ export default function AdminUsersPage() {
size="sm" size="sm"
onClick={() => setPage(p => p + 1)} onClick={() => setPage(p => p + 1)}
disabled={!pagination.hasNextPage} disabled={!pagination.hasNextPage}
className="h-8"
> >
Next Next
</Button> </Button>

View File

@@ -6,9 +6,20 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; 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 { fetchClient } from "@/lib/api-client";
import { useToast } from "@/hooks/use-toast"; 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 { interface Vendor {
_id: string; _id: string;
@@ -37,9 +48,73 @@ export default function AdminVendorsPage() {
const { toast } = useToast(); const { toast } = useToast();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [vendors, setVendors] = useState<Vendor[]>([]); 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 [page, setPage] = useState(1);
const [pagination, setPagination] = useState<PaginationResponse['pagination'] | null>(null); const [pagination, setPagination] = useState<PaginationResponse['pagination'] | null>(null);
const [searchQuery, setSearchQuery] = useState(""); 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 () => { const fetchVendors = useCallback(async () => {
try { try {
@@ -68,10 +143,10 @@ export default function AdminVendorsPage() {
}, [fetchVendors]); }, [fetchVendors]);
const filteredVendors = searchQuery.trim() const filteredVendors = searchQuery.trim()
? vendors.filter(v => ? vendors.filter(v =>
v.username.toLowerCase().includes(searchQuery.toLowerCase()) || v.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
(v.storeId && v.storeId.toString().toLowerCase().includes(searchQuery.toLowerCase())) (v.storeId && v.storeId.toString().toLowerCase().includes(searchQuery.toLowerCase()))
) )
: vendors; : vendors;
const activeVendors = vendors.filter(v => v.isActive); const activeVendors = vendors.filter(v => v.isActive);
@@ -79,177 +154,420 @@ export default function AdminVendorsPage() {
const adminVendors = vendors.filter(v => v.isAdmin); const adminVendors = vendors.filter(v => v.isAdmin);
const totalVendors = pagination?.total || vendors.length; 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 ( return (
<div className="space-y-6"> <div className="space-y-6 animate-in fade-in duration-500">
<div> <div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold tracking-tight">All Vendors</h1> <div>
<p className="text-sm text-muted-foreground mt-1">Manage vendor accounts and permissions</p> <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> </div>
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-4"> <div className="grid gap-4 md:grid-cols-4">
<Card> {stats.map((stat, i) => (
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <Card key={i} className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
<CardTitle className="text-sm font-medium">Total Vendors</CardTitle> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
</CardHeader> <CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
<CardContent> <stat.icon className="h-4 w-4 text-muted-foreground" />
<div className="text-2xl font-bold">{totalVendors}</div> </CardHeader>
<p className="text-xs text-muted-foreground">Registered vendors</p> <CardContent>
</CardContent> <div className="animate-in fade-in slide-in-from-bottom-2 duration-500" style={{ animationDelay: `${i * 100}ms` }}>
</Card> <div className="text-2xl font-bold">{stat.value}</div>
<Card> <p className="text-xs text-muted-foreground">{stat.description}</p>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> </div>
<CardTitle className="text-sm font-medium">Active Vendors</CardTitle> </CardContent>
</CardHeader> </Card>
<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>
</CardContent>
</Card>
</div> </div>
{/* Search and Filters */} {/* Search and Filters */}
<Card> <Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
<CardHeader> <CardHeader className="pb-4">
<div className="flex items-center justify-between"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div> <div>
<CardTitle>Vendor Management</CardTitle> <CardTitle className="text-lg font-medium">Vendor Management</CardTitle>
<CardDescription>View and manage all vendor accounts</CardDescription> <CardDescription>View and manage all vendor accounts</CardDescription>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="relative"> <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 <Input
placeholder="Search vendors..." 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} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
</div> </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" /> <Mail className="h-4 w-4 mr-2" />
Send Message Message
</Button> </Button>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{loading ? ( <div className="rounded-md border border-border/50 overflow-hidden">
<div className="flex items-center justify-center py-8"> <Table>
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> <TableHeader className="bg-muted/30">
</div> <TableRow className="border-border/50 hover:bg-transparent">
) : filteredVendors.length === 0 ? ( <TableHead>Vendor</TableHead>
<div className="text-center py-8 text-muted-foreground"> <TableHead>Store</TableHead>
{searchQuery.trim() ? "No vendors found matching your search" : "No vendors found"} <TableHead>Status</TableHead>
</div> <TableHead>Join Date</TableHead>
) : ( <TableHead>Last Login</TableHead>
<> <TableHead className="text-right">Actions</TableHead>
<Table> </TableRow>
<TableHeader> </TableHeader>
<TableRow> <TableBody>
<TableHead>Vendor</TableHead> {isFirefox ? (
<TableHead>Store</TableHead> loading ? (
<TableHead>Status</TableHead> <TableRow>
<TableHead>Join Date</TableHead> <TableCell colSpan={6} className="h-32 text-center">
<TableHead>Last Login</TableHead> <div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
<TableHead className="text-right">Actions</TableHead> <Loader2 className="h-8 w-8 animate-spin opacity-25" />
</TableRow> <p>Loading vendors...</p>
</TableHeader>
<TableBody>
{filteredVendors.map((vendor) => (
<TableRow key={vendor._id}>
<TableCell>
<div className="font-medium">{vendor.username}</div>
</TableCell>
<TableCell>{vendor.storeId || 'No store'}</TableCell>
<TableCell>
<div className="flex flex-col space-y-1">
<Badge
variant={vendor.isActive ? "default" : "destructive"}
>
{vendor.isActive ? "active" : "suspended"}
</Badge>
{vendor.isAdmin && (
<Badge variant="secondary" className="text-xs">
Admin
</Badge>
)}
</div>
</TableCell>
<TableCell>
{vendor.createdAt ? new Date(vendor.createdAt).toLocaleDateString() : 'N/A'}
</TableCell>
<TableCell>
{vendor.lastLogin ? new Date(vendor.lastLogin).toLocaleDateString() : 'Never'}
</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">
<MoreHorizontal className="h-4 w-4" />
</Button>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ) : filteredVendors.length === 0 ? (
</TableBody> <TableRow>
</Table> <TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
{pagination && pagination.totalPages > 1 && ( {searchQuery.trim() ? "No vendors found matching your search" : "No vendors found"}
<div className="mt-4 flex items-center justify-between text-sm text-muted-foreground"> </TableCell>
<span> </TableRow>
Page {pagination.page} of {pagination.totalPages} ({pagination.total} total) ) : (
</span> filteredVendors.map((vendor, index) => (
<div className="flex gap-2"> <motion.tr
<Button key={vendor._id}
variant="outline" initial={{ opacity: 0, y: 10 }}
size="sm" animate={{ opacity: 1, y: 0 }}
onClick={() => setPage(p => Math.max(1, p - 1))} transition={{ duration: 0.2, delay: index * 0.03 }}
disabled={!pagination.hasPrevPage || loading} className="group hover:bg-muted/40 border-b border-border/50 transition-colors"
> >
Previous <TableCell>
</Button> <div className="font-medium flex items-center gap-2">
<Button <div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
variant="outline" {vendor.username.substring(0, 2).toUpperCase()}
size="sm" </div>
onClick={() => setPage(p => p + 1)} {vendor.username}
disabled={!pagination.hasNextPage || loading} </div>
> </TableCell>
Next <TableCell>
</Button> {vendor.storeId ? (
</div> <div className="flex items-center gap-2 group/store">
</div> <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 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 border-t border-border/50 pt-4 text-sm text-muted-foreground">
<span>
Page <span className="font-medium text-foreground">{pagination.page}</span> of {pagination.totalPages}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={!pagination.hasPrevPage || loading}
className="h-8"
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => p + 1)}
disabled={!pagination.hasNextPage || loading}
className="h-8"
>
Next
</Button>
</div>
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div>
<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 >
); );
} }

View File

@@ -25,7 +25,8 @@ import {
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { apiRequest } from "@/lib/api"; import { apiRequest } from "@/lib/api";
import type { Category } from "@/models/categories"; 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 // Drag and Drop imports
import { DndProvider, useDrag, useDrop, DropTargetMonitor } from 'react-dnd'; import { DndProvider, useDrag, useDrop, DropTargetMonitor } from 'react-dnd';
@@ -49,14 +50,15 @@ export default function CategoriesPage() {
const [editingCategory, setEditingCategory] = useState<Category | null>(null); const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [categoryToDelete, setCategoryToDelete] = useState<Category | null>(null); const [categoryToDelete, setCategoryToDelete] = useState<Category | null>(null);
const [expanded, setExpanded] = useState<Record<string, boolean>>({}); const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState(true);
// Get root categories sorted by order // Get root categories sorted by order
const rootCategories = categories const rootCategories = categories
.filter(cat => !cat.parentId) .filter(cat => !cat.parentId)
.sort((a, b) => (a.order || 0) - (b.order || 0)); .sort((a, b) => (a.order || 0) - (b.order || 0));
// Get subcategories sorted by order // Get subcategories sorted by order
const getSubcategories = (parentId: string) => const getSubcategories = (parentId: string) =>
categories categories
.filter(cat => cat.parentId === parentId) .filter(cat => cat.parentId === parentId)
.sort((a, b) => (a.order || 0) - (b.order || 0)); .sort((a, b) => (a.order || 0) - (b.order || 0));
@@ -67,10 +69,13 @@ export default function CategoriesPage() {
const fetchCategories = async () => { const fetchCategories = async () => {
try { try {
setLoading(true);
const fetchedCategories = await apiRequest("/categories", "GET"); const fetchedCategories = await apiRequest("/categories", "GET");
setCategories(fetchedCategories); setCategories(fetchedCategories);
} catch (error) { } catch (error) {
toast.error("Failed to fetch categories"); toast.error("Failed to fetch categories");
} finally {
setLoading(false);
} }
}; };
@@ -82,13 +87,13 @@ export default function CategoriesPage() {
try { try {
// Find the highest order in the selected parent group // Find the highest order in the selected parent group
const siblings = selectedParentId const siblings = selectedParentId
? categories.filter(cat => cat.parentId === selectedParentId) ? categories.filter(cat => cat.parentId === selectedParentId)
: categories.filter(cat => !cat.parentId); : categories.filter(cat => !cat.parentId);
const maxOrder = siblings.reduce((max, cat) => const maxOrder = siblings.reduce((max, cat) =>
Math.max(max, cat.order || 0), 0); Math.max(max, cat.order || 0), 0);
const response = await apiRequest("/categories", "POST", { const response = await apiRequest("/categories", "POST", {
name: newCategoryName, name: newCategoryName,
parentId: selectedParentId || undefined, parentId: selectedParentId || undefined,
@@ -110,7 +115,7 @@ export default function CategoriesPage() {
name: newName, name: newName,
}); });
setCategories(categories.map(cat => setCategories(categories.map(cat =>
cat._id === categoryId ? { ...cat, name: newName } : cat cat._id === categoryId ? { ...cat, name: newName } : cat
)); ));
setEditingCategory(null); setEditingCategory(null);
@@ -138,55 +143,55 @@ export default function CategoriesPage() {
// Create new array with updated orders // Create new array with updated orders
const dragIndex = categories.findIndex(cat => cat._id === dragId); const dragIndex = categories.findIndex(cat => cat._id === dragId);
const hoverIndex = categories.findIndex(cat => cat._id === hoverId); const hoverIndex = categories.findIndex(cat => cat._id === hoverId);
if (dragIndex === -1 || hoverIndex === -1) return; if (dragIndex === -1 || hoverIndex === -1) return;
const draggedCategory = categories[dragIndex]; const draggedCategory = categories[dragIndex];
// Make sure we're only reordering within the same parent group // Make sure we're only reordering within the same parent group
if (draggedCategory.parentId !== parentId) return; if (draggedCategory.parentId !== parentId) return;
// Create a copy for reordering // Create a copy for reordering
const updatedCategories = [...categories]; const updatedCategories = [...categories];
// Get only categories with the same parent for reordering // Get only categories with the same parent for reordering
const siblingCategories = updatedCategories const siblingCategories = updatedCategories
.filter(cat => cat.parentId === parentId) .filter(cat => cat.parentId === parentId)
.sort((a, b) => (a.order || 0) - (b.order || 0)); .sort((a, b) => (a.order || 0) - (b.order || 0));
// Remove the dragged category from its position // Remove the dragged category from its position
const draggedItem = siblingCategories.find(cat => cat._id === dragId); const draggedItem = siblingCategories.find(cat => cat._id === dragId);
if (!draggedItem) return; if (!draggedItem) return;
const filteredSiblings = siblingCategories.filter(cat => cat._id !== dragId); const filteredSiblings = siblingCategories.filter(cat => cat._id !== dragId);
// Find where to insert the dragged item // Find where to insert the dragged item
const hoverItem = siblingCategories.find(cat => cat._id === hoverId); const hoverItem = siblingCategories.find(cat => cat._id === hoverId);
if (!hoverItem) return; if (!hoverItem) return;
const hoverPos = filteredSiblings.findIndex(cat => cat._id === hoverId); const hoverPos = filteredSiblings.findIndex(cat => cat._id === hoverId);
// Insert the dragged item at the new position // Insert the dragged item at the new position
filteredSiblings.splice(hoverPos, 0, draggedItem); filteredSiblings.splice(hoverPos, 0, draggedItem);
// Update the order for all siblings // Update the order for all siblings
filteredSiblings.forEach((cat, index) => { filteredSiblings.forEach((cat, index) => {
cat.order = index; cat.order = index;
}); });
// Update the categories state // Update the categories state
setCategories(updatedCategories.map(cat => { setCategories(updatedCategories.map(cat => {
const updatedCat = filteredSiblings.find(c => c._id === cat._id); const updatedCat = filteredSiblings.find(c => c._id === cat._id);
return updatedCat || cat; return updatedCat || cat;
})); }));
// Save the new order to the server using bulk update // Save the new order to the server using bulk update
try { try {
const categoriesToUpdate = filteredSiblings.map(cat => ({ const categoriesToUpdate = filteredSiblings.map(cat => ({
_id: cat._id, _id: cat._id,
order: cat.order order: cat.order
})); }));
await apiRequest("/categories/bulk-order", "PUT", { await apiRequest("/categories/bulk-order", "PUT", {
categories: categoriesToUpdate categories: categoriesToUpdate
}); });
@@ -210,9 +215,9 @@ export default function CategoriesPage() {
const hasSubcategories = subcategories.length > 0; const hasSubcategories = subcategories.length > 0;
const isEditing = editingCategory?._id === category._id; const isEditing = editingCategory?._id === category._id;
const isExpanded = expanded[category._id]; const isExpanded = expanded[category._id];
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
// Set up drag // Set up drag
const [{ isDragging }, drag] = useDrag({ const [{ isDragging }, drag] = useDrag({
type: ItemTypes.CATEGORY, type: ItemTypes.CATEGORY,
@@ -221,7 +226,7 @@ export default function CategoriesPage() {
isDragging: monitor.isDragging(), isDragging: monitor.isDragging(),
}), }),
}); });
// Set up drop // Set up drop
const [{ handlerId, isOver }, drop] = useDrop<DragItem, void, { handlerId: string | symbol | null; isOver: boolean }>({ const [{ handlerId, isOver }, drop] = useDrop<DragItem, void, { handlerId: string | symbol | null; isOver: boolean }>({
accept: ItemTypes.CATEGORY, accept: ItemTypes.CATEGORY,
@@ -231,56 +236,64 @@ export default function CategoriesPage() {
}), }),
hover(item: DragItem, monitor) { hover(item: DragItem, monitor) {
if (!ref.current) return; if (!ref.current) return;
const dragId = item.id; const dragId = item.id;
const hoverId = category._id; const hoverId = category._id;
// Don't replace items with themselves // Don't replace items with themselves
if (dragId === hoverId) return; if (dragId === hoverId) return;
// Only allow reordering within the same parent // Only allow reordering within the same parent
if (item.parentId !== category.parentId) return; if (item.parentId !== category.parentId) return;
moveCategory(dragId, hoverId, category.parentId); moveCategory(dragId, hoverId, category.parentId);
}, },
}); });
// Connect the drag and drop refs // Connect the drag and drop refs
drag(drop(ref)); drag(drop(ref));
return ( return (
<div key={category._id} className="space-y-1"> <motion.div
<div key={category._id}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="space-y-1"
>
<div
ref={ref} ref={ref}
className={`group flex items-center p-2 rounded-md transition-colors className={`group flex items-center p-3 rounded-xl transition-all duration-200 border mb-2
${isEditing ? 'bg-gray-100 dark:bg-gray-800' : ''} ${isEditing ? 'bg-indigo-500/10 border-indigo-500/30' : ''}
${isOver ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-100 dark:hover:bg-gray-800'} ${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-50' : 'opacity-100'}`} ${isDragging ? 'opacity-30' : 'opacity-100'} backdrop-blur-sm`}
style={{ marginLeft: `${level * 24}px` }} style={{ marginLeft: `${level * 24}px` }}
data-handler-id={handlerId} 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" /> <MoveVertical className="h-4 w-4" />
</div> </div>
{hasSubcategories && ( {hasSubcategories ? (
<button <button
onClick={() => toggleExpand(category._id)} 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 ? {isExpanded ?
<ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4" /> :
<ChevronRight className="h-4 w-4 text-muted-foreground" /> <ChevronRight className="h-4 w-4" />
} }
</button> </button>
) : (
<div className="w-6 h-5" /> // Spacer
)} )}
<div className="flex-1 flex items-center space-x-2"> <div className="flex-1 flex items-center space-x-2">
{isEditing ? ( {isEditing ? (
<Input <Input
value={editingCategory?.name || ""} value={editingCategory?.name || ""}
onChange={(e) => setEditingCategory(prev => prev ? { ...prev, name: e.target.value } : prev)} 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 autoFocus
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && editingCategory) { if (e.key === 'Enter' && editingCategory) {
@@ -300,7 +313,7 @@ export default function CategoriesPage() {
<Button <Button
size="sm" size="sm"
variant="ghost" 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)} onClick={() => editingCategory && handleUpdateCategory(category._id, editingCategory.name)}
> >
Save Save
@@ -317,83 +330,107 @@ export default function CategoriesPage() {
) : ( ) : (
<> <>
<Button <Button
size="sm" size="icon"
variant="ghost" 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)} onClick={() => setEditingCategory(category)}
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
<Button <Button
size="sm" size="icon"
variant="ghost" 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)} onClick={() => setCategoryToDelete(category)}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</> </>
)} )}
</div> </div>
</div> </div>
{isExpanded && subcategories.map(subcat => <AnimatePresence>
<CategoryItem key={subcat._id} category={subcat} level={level + 1} /> {isExpanded && hasSubcategories && (
)} <motion.div
</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} />
)}
</motion.div>
)}
</AnimatePresence>
</motion.div>
); );
}; };
return ( return (
<Layout> <Layout>
<div className="space-y-6"> <div className="space-y-8 animate-in fade-in duration-500">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white flex items-center"> <div>
<FolderTree className="mr-2 h-6 w-6" /> <h1 className="text-2xl font-semibold text-foreground flex items-center">
Categories <FolderTree className="mr-3 h-6 w-6 text-primary" />
</h1> Categories
</h1>
<p className="text-muted-foreground text-sm mt-1">
Manage your product categories and hierarchy
</p>
</div>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 xl:grid-cols-5 gap-4 lg:gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 xl:grid-cols-5 gap-6 lg:gap-8">
{/* Add Category Card - Takes up 2 columns */} {/* Add Category Card */}
<Card className="lg:col-span-2"> <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> <CardHeader className="bg-white/[0.02] border-b border-white/5 pb-4">
<CardTitle className="text-lg font-medium">Add New Category</CardTitle> <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> </CardHeader>
<CardContent> <CardContent className="pt-6">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <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 Category Name
</label> </label>
<Input <Input
value={newCategoryName} value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)} onChange={(e) => setNewCategoryName(e.target.value)}
placeholder="Enter category name" placeholder="e.g. Electronics, Clothing..."
className="h-9" className="h-10 border-white/10 bg-black/20 focus:bg-black/40 transition-colors text-white placeholder:text-zinc-600"
/> />
</div> </div>
<div className="space-y-2"> <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 Parent Category
</label> </label>
<Select <Select
value={selectedParentId || "none"} value={selectedParentId || "none"}
onValueChange={setSelectedParentId} 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" /> <SelectValue placeholder="Select parent category" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="bg-zinc-900 border-white/10 text-white">
<SelectItem value="none">No parent (root category)</SelectItem> <SelectItem value="none" className="focus:bg-zinc-800">No parent (root category)</SelectItem>
{categories.map((cat) => ( {categories.map((cat) => (
<SelectItem key={cat._id} value={cat._id}> <SelectItem key={cat._id} value={cat._id} className="focus:bg-zinc-800">
{cat.name} {cat.name}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </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" /> <Plus className="h-4 w-4 mr-2" />
Add Category Add Category
</Button> </Button>
@@ -401,47 +438,59 @@ export default function CategoriesPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Category List Card - Takes up 3 columns */} {/* Category List Card */}
<Card className="lg:col-span-3"> <Card className="lg:col-span-3 border-none bg-transparent shadow-none">
<CardHeader> <CardHeader className="pl-0 pt-0 pb-4">
<CardTitle className="text-lg font-medium">Category List</CardTitle> <CardTitle className="text-lg font-bold text-white">Structure</CardTitle>
<CardDescription className="text-zinc-400">
Drag and drop to reorder categories
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-0">
<DndProvider backend={HTML5Backend}> <DndProvider backend={HTML5Backend}>
<div className="space-y-1"> <div className="space-y-2 min-h-[300px]">
{rootCategories.length === 0 ? ( {loading ? (
<p className="text-sm text-muted-foreground text-center py-4"> <div className="flex flex-col items-center justify-center py-12 text-muted-foreground animate-pulse">
No categories yet. Add your first category above. <FolderTree className="h-10 w-10 mb-3 opacity-20" />
</p> <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">
<CategoryItem key={category._id} category={category} /> {rootCategories.map(category => (
)) <CategoryItem key={category._id} category={category} />
))}
</div>
)} )}
</div> </div>
</DndProvider> </DndProvider>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Delete Confirmation Dialog */} {/* Delete Confirmation Dialog */}
{categoryToDelete && ( <AlertDialog open={!!categoryToDelete} onOpenChange={() => setCategoryToDelete(null)}>
<AlertDialog open={!!categoryToDelete} onOpenChange={() => setCategoryToDelete(null)}> <AlertDialogContent>
<AlertDialogContent> <AlertDialogHeader>
<AlertDialogHeader> <AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogTitle>Are you sure?</AlertDialogTitle> <AlertDialogDescription>
<AlertDialogDescription> This will permanently delete the category <span className="font-medium text-foreground">"{categoryToDelete?.name}"</span>.
This will permanently delete the category "{categoryToDelete.name}". This action cannot be undone.
This action cannot be undone. </AlertDialogDescription>
</AlertDialogDescription> </AlertDialogHeader>
</AlertDialogHeader> <AlertDialogFooter>
<AlertDialogFooter> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogAction onClick={handleDeleteConfirm} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
<AlertDialogAction onClick={handleDeleteConfirm}>Delete</AlertDialogAction> Delete Category
</AlertDialogFooter> </AlertDialogAction>
</AlertDialogContent> </AlertDialogFooter>
</AlertDialog> </AlertDialogContent>
)} </AlertDialog>
</div> </div>
</Layout> </Layout>
); );

View File

@@ -39,6 +39,8 @@ import {
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import Layout from "@/components/layout/layout"; import Layout from "@/components/layout/layout";
import { cacheUtils } from '@/lib/api-client'; import { cacheUtils } from '@/lib/api-client';
import OrderTimeline from "@/components/orders/order-timeline";
import { motion, AnimatePresence } from "framer-motion";
interface Order { interface Order {
orderId: string; orderId: string;
@@ -170,7 +172,7 @@ export default function OrderDetailsPage() {
authToken: string authToken: string
): Promise<Record<string, string>> => { ): Promise<Record<string, string>> => {
const productNamesMap: Record<string, string> = {}; const productNamesMap: Record<string, string> = {};
// Process each product ID independently // Process each product ID independently
const fetchPromises = productIds.map(async (id) => { const fetchPromises = productIds.map(async (id) => {
try { try {
@@ -184,10 +186,10 @@ export default function OrderDetailsPage() {
productNamesMap[id] = "Unknown Product (Deleted)"; productNamesMap[id] = "Unknown Product (Deleted)";
} }
}); });
// Wait for all fetch operations to complete (successful or failed) // Wait for all fetch operations to complete (successful or failed)
await Promise.all(fetchPromises); await Promise.all(fetchPromises);
return productNamesMap; return productNamesMap;
}; };
@@ -195,38 +197,38 @@ export default function OrderDetailsPage() {
try { try {
// Add a loading state to give feedback // Add a loading state to give feedback
const loadingToast = toast.loading("Marking order as paid..."); const loadingToast = toast.loading("Marking order as paid...");
// Log the request for debugging // Log the request for debugging
console.log(`Sending request to /orders/${orderId}/status with clientFetch`); console.log(`Sending request to /orders/${orderId}/status with clientFetch`);
console.log("Request payload:", { status: "paid" }); console.log("Request payload:", { status: "paid" });
// Use clientFetch which handles API URL and auth token automatically // Use clientFetch which handles API URL and auth token automatically
const response = await clientFetch(`/orders/${orderId}/status`, { const response = await clientFetch(`/orders/${orderId}/status`, {
method: "PUT", method: "PUT",
body: JSON.stringify({ status: "paid" }), body: JSON.stringify({ status: "paid" }),
}); });
// Log the response // Log the response
console.log("API response:", response); console.log("API response:", response);
toast.dismiss(loadingToast); toast.dismiss(loadingToast);
if (response && response.message === "Order status updated successfully") { if (response && response.message === "Order status updated successfully") {
// Update both states // Update both states
setIsPaid(true); setIsPaid(true);
setOrder((prevOrder) => (prevOrder ? { setOrder((prevOrder) => (prevOrder ? {
...prevOrder, ...prevOrder,
status: "paid", status: "paid",
// Clear underpayment flags when marking as paid // Clear underpayment flags when marking as paid
underpaid: false, underpaid: false,
underpaymentAmount: 0 underpaymentAmount: 0
} : null)); } : null));
// Invalidate order cache to ensure fresh data everywhere // Invalidate order cache to ensure fresh data everywhere
cacheUtils.invalidateOrderData(orderId as string); cacheUtils.invalidateOrderData(orderId as string);
toast.success("Order marked as paid successfully"); toast.success("Order marked as paid successfully");
// Refresh order data to get latest status // Refresh order data to get latest status
setTimeout(() => { setTimeout(() => {
setRefreshTrigger(prev => prev + 1); setRefreshTrigger(prev => prev + 1);
@@ -240,14 +242,14 @@ export default function OrderDetailsPage() {
} }
} catch (error: any) { } catch (error: any) {
console.error("Failed to mark order as paid:", error); console.error("Failed to mark order as paid:", error);
// More detailed error handling // More detailed error handling
let errorMessage = "Failed to mark order as paid"; let errorMessage = "Failed to mark order as paid";
if (error.message) { if (error.message) {
errorMessage += `: ${error.message}`; errorMessage += `: ${error.message}`;
} }
if (error.response) { if (error.response) {
try { try {
const errorData = await error.response.json(); const errorData = await error.response.json();
@@ -259,7 +261,7 @@ export default function OrderDetailsPage() {
console.error("Could not parse error response:", e); console.error("Could not parse error response:", e);
} }
} }
toast.error(errorMessage); toast.error(errorMessage);
} }
}; };
@@ -317,7 +319,7 @@ export default function OrderDetailsPage() {
...prevOrder, ...prevOrder,
trackingNumber: trackingNumber trackingNumber: trackingNumber
} : null); } : null);
toast.success("Tracking number added successfully!"); toast.success("Tracking number added successfully!");
} catch (err: any) { } catch (err: any) {
console.error("Failed to add tracking number:", err); console.error("Failed to add tracking number:", err);
@@ -330,7 +332,7 @@ export default function OrderDetailsPage() {
const handleMarkAsAcknowledged = async () => { const handleMarkAsAcknowledged = async () => {
try { try {
setIsAcknowledging(true); setIsAcknowledging(true);
// Use clientFetch which handles API URL and auth token automatically // Use clientFetch which handles API URL and auth token automatically
const response = await clientFetch(`/orders/${orderId}/status`, { const response = await clientFetch(`/orders/${orderId}/status`, {
method: "PUT", method: "PUT",
@@ -382,7 +384,7 @@ export default function OrderDetailsPage() {
const handleCancelOrder = async () => { const handleCancelOrder = async () => {
try { try {
setIsCancelling(true); setIsCancelling(true);
// Use clientFetch which handles API URL and auth token automatically // Use clientFetch which handles API URL and auth token automatically
const response = await clientFetch(`/orders/${orderId}/status`, { const response = await clientFetch(`/orders/${orderId}/status`, {
method: "PUT", method: "PUT",
@@ -429,10 +431,10 @@ export default function OrderDetailsPage() {
const productIds = data.order.products.map((product) => product.productId); const productIds = data.order.products.map((product) => product.productId);
const productNamesMap = await fetchProductNames(productIds, authToken); const productNamesMap = await fetchProductNames(productIds, authToken);
setProductNames(productNamesMap); setProductNames(productNamesMap);
setTimeout(() => { setTimeout(() => {
setProductNames(prev => { setProductNames(prev => {
const newMap = {...prev}; const newMap = { ...prev };
productIds.forEach(id => { productIds.forEach(id => {
if (!newMap[id] || newMap[id] === "Loading...") { if (!newMap[id] || newMap[id] === "Loading...") {
newMap[id] = "Unknown Product (Deleted)"; newMap[id] = "Unknown Product (Deleted)";
@@ -440,7 +442,7 @@ export default function OrderDetailsPage() {
}); });
return newMap; return newMap;
}); });
}, 3000); }, 3000);
if (data.order.status === "paid") { if (data.order.status === "paid") {
setIsPaid(true); setIsPaid(true);
@@ -460,7 +462,7 @@ export default function OrderDetailsPage() {
const fetchAdjacentOrders = async () => { const fetchAdjacentOrders = async () => {
try { try {
const authToken = document.cookie.split("Authorization=")[1]; const authToken = document.cookie.split("Authorization=")[1];
if (!order?.orderId) return; if (!order?.orderId) return;
// Get the current numerical orderId // Get the current numerical orderId
@@ -470,7 +472,7 @@ export default function OrderDetailsPage() {
// Use the new optimized backend endpoint to get adjacent orders // Use the new optimized backend endpoint to get adjacent orders
const adjacentOrdersUrl = `${process.env.NEXT_PUBLIC_API_URL}/orders/adjacent/${currentOrderId}`; const adjacentOrdersUrl = `${process.env.NEXT_PUBLIC_API_URL}/orders/adjacent/${currentOrderId}`;
console.log('Fetching adjacent orders:', adjacentOrdersUrl); console.log('Fetching adjacent orders:', adjacentOrdersUrl);
const adjacentOrdersRes = await fetchData( const adjacentOrdersRes = await fetchData(
adjacentOrdersUrl, adjacentOrdersUrl,
{ {
@@ -488,17 +490,17 @@ export default function OrderDetailsPage() {
// Set the next and previous order IDs // Set the next and previous order IDs
const { newer, older } = adjacentOrdersRes; const { newer, older } = adjacentOrdersRes;
// Set IDs for navigation // Set IDs for navigation
setPrevOrderId(newer?._id || null); setPrevOrderId(newer?._id || null);
setNextOrderId(older?._id || null); setNextOrderId(older?._id || null);
if (newer) { if (newer) {
console.log(`Newer order: ${newer.orderId} (ID: ${newer._id})`); console.log(`Newer order: ${newer.orderId} (ID: ${newer._id})`);
} else { } else {
console.log('No newer order found'); console.log('No newer order found');
} }
if (older) { if (older) {
console.log(`Older order: ${older.orderId} (ID: ${older._id})`); console.log(`Older order: ${older.orderId} (ID: ${older._id})`);
} else { } else {
@@ -544,7 +546,7 @@ export default function OrderDetailsPage() {
...prevOrder, ...prevOrder,
trackingNumber: trackingNumber trackingNumber: trackingNumber
} : null); } : null);
toast.success("Tracking number updated successfully!"); toast.success("Tracking number updated successfully!");
setTrackingNumber(""); // Clear the input setTrackingNumber(""); // Clear the input
} catch (err: any) { } catch (err: any) {
@@ -569,11 +571,11 @@ export default function OrderDetailsPage() {
try { try {
const lines = []; const lines = [];
// Order number // Order number
lines.push(`Order Number: ${order.orderId}`); lines.push(`Order Number: ${order.orderId}`);
lines.push(''); lines.push('');
// Order details // Order details
lines.push('Order Details:'); lines.push('Order Details:');
if (order.products && order.products.length > 0) { if (order.products && order.products.length > 0) {
@@ -582,30 +584,30 @@ export default function OrderDetailsPage() {
lines.push(` - ${productName} (Qty: ${product.quantity} @ £${product.pricePerUnit.toFixed(2)} = £${product.totalItemPrice.toFixed(2)})`); lines.push(` - ${productName} (Qty: ${product.quantity} @ £${product.pricePerUnit.toFixed(2)} = £${product.totalItemPrice.toFixed(2)})`);
}); });
} }
// Shipping // Shipping
if (order.shippingMethod) { if (order.shippingMethod) {
lines.push(` - Shipping: ${order.shippingMethod.type}${order.shippingMethod.price.toFixed(2)})`); lines.push(` - Shipping: ${order.shippingMethod.type}${order.shippingMethod.price.toFixed(2)})`);
} }
// Discount // Discount
if (order.discountAmount && order.discountAmount > 0) { if (order.discountAmount && order.discountAmount > 0) {
lines.push(` - Discount: -£${order.discountAmount.toFixed(2)}${order.promotionCode ? ` (Promo: ${order.promotionCode})` : ''}`); lines.push(` - Discount: -£${order.discountAmount.toFixed(2)}${order.promotionCode ? ` (Promo: ${order.promotionCode})` : ''}`);
} }
// Subtotal if different from total // Subtotal if different from total
if (order.subtotalBeforeDiscount && order.subtotalBeforeDiscount !== order.totalPrice) { if (order.subtotalBeforeDiscount && order.subtotalBeforeDiscount !== order.totalPrice) {
lines.push(` - Subtotal: £${order.subtotalBeforeDiscount.toFixed(2)}`); lines.push(` - Subtotal: £${order.subtotalBeforeDiscount.toFixed(2)}`);
} }
// Total // Total
lines.push(` - Total: £${order.totalPrice.toFixed(2)}`); lines.push(` - Total: £${order.totalPrice.toFixed(2)}`);
lines.push(''); lines.push('');
// Address // Address
lines.push('Address:'); lines.push('Address:');
lines.push(order.pgpAddress || 'N/A'); lines.push(order.pgpAddress || 'N/A');
const textToCopy = lines.join('\n'); const textToCopy = lines.join('\n');
await navigator.clipboard.writeText(textToCopy); await navigator.clipboard.writeText(textToCopy);
toast.success("Order data copied to clipboard!"); toast.success("Order data copied to clipboard!");
@@ -618,28 +620,28 @@ export default function OrderDetailsPage() {
// Helper function to check if order is underpaid // Helper function to check if order is underpaid
const isOrderUnderpaid = (order: Order | null) => { const isOrderUnderpaid = (order: Order | null) => {
// More robust check - only show underpaid if status is NOT paid and underpayment exists // More robust check - only show underpaid if status is NOT paid and underpayment exists
return order?.underpaid === true && return order?.underpaid === true &&
order?.underpaymentAmount && order?.underpaymentAmount &&
order.underpaymentAmount > 0 && order.underpaymentAmount > 0 &&
order.status !== "paid" && order.status !== "paid" &&
order.status !== "completed" && order.status !== "completed" &&
order.status !== "shipped"; order.status !== "shipped";
}; };
// Helper function to get underpaid information // Helper function to get underpaid information
const getUnderpaidInfo = (order: Order | null) => { const getUnderpaidInfo = (order: Order | null) => {
if (!isOrderUnderpaid(order)) return null; if (!isOrderUnderpaid(order)) return null;
const received = order?.lastBalanceReceived || 0; const received = order?.lastBalanceReceived || 0;
const required = order?.cryptoTotal || 0; const required = order?.cryptoTotal || 0;
const missing = order?.underpaymentAmount || 0; const missing = order?.underpaymentAmount || 0;
// Calculate LTC to GBP exchange rate from order data // Calculate LTC to GBP exchange rate from order data
const ltcToGbpRate = required > 0 ? (order?.totalPrice || 0) / required : 0; const ltcToGbpRate = required > 0 ? (order?.totalPrice || 0) / required : 0;
const receivedGbp = received * ltcToGbpRate; const receivedGbp = received * ltcToGbpRate;
const requiredGbp = order?.totalPrice || 0; const requiredGbp = order?.totalPrice || 0;
const missingGbp = missing * ltcToGbpRate; const missingGbp = missing * ltcToGbpRate;
return { return {
received, received,
required, required,
@@ -772,7 +774,7 @@ export default function OrderDetailsPage() {
<p className="font-semibold">{underpaidInfo.percentage}% paid</p> <p className="font-semibold">{underpaidInfo.percentage}% paid</p>
</div> </div>
</div> </div>
{order?.paymentAddress && ( {order?.paymentAddress && (
<div className="pt-3 border-t border-red-200 dark:border-red-800"> <div className="pt-3 border-t border-red-200 dark:border-red-800">
<p className="text-sm text-muted-foreground mb-2">Payment Address:</p> <p className="text-sm text-muted-foreground mb-2">Payment Address:</p>
@@ -791,10 +793,26 @@ export default function OrderDetailsPage() {
</CardContent> </CardContent>
</Card> </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>
<motion.div
initial={{ opacity: 0, y: 20 }}
<div className="grid grid-cols-3 gap-6"> animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="grid grid-cols-3 gap-6"
>
{/* Left Column - Order Details */} {/* Left Column - Order Details */}
<div className="col-span-2 space-y-6"> <div className="col-span-2 space-y-6">
{/* Products Card */} {/* Products Card */}
@@ -929,7 +947,7 @@ export default function OrderDetailsPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-zinc-400">Customer Since</span> <span className="text-zinc-400">Customer Since</span>
<span className="font-medium"> <span className="font-medium">
{customerInsights.firstOrder ? {customerInsights.firstOrder ?
new Date(customerInsights.firstOrder).toLocaleDateString('en-GB', { new Date(customerInsights.firstOrder).toLocaleDateString('en-GB', {
day: '2-digit', day: '2-digit',
month: 'short', month: 'short',
@@ -1092,34 +1110,34 @@ export default function OrderDetailsPage() {
)} )}
{/* Cancel Order Button */} {/* Cancel Order Button */}
{order?.status !== "cancelled" && {order?.status !== "cancelled" &&
order?.status !== "completed" && order?.status !== "completed" &&
order?.status !== "shipped" && ( order?.status !== "shipped" && (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="destructive" className="w-full"> <Button variant="destructive" className="w-full">
Cancel Order Cancel Order
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Cancel Order</AlertDialogTitle> <AlertDialogTitle>Cancel Order</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to cancel this order? This action cannot be undone. Are you sure you want to cancel this order? This action cannot be undone.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={handleCancelOrder} onClick={handleCancelOrder}
className="bg-red-500 hover:bg-red-600" className="bg-red-500 hover:bg-red-600"
> >
Confirm Cancel Confirm Cancel
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
)} )}
{/* No Actions Available Message */} {/* No Actions Available Message */}
{(order?.status === "completed" || order?.status === "cancelled") && ( {(order?.status === "completed" || order?.status === "cancelled") && (
@@ -1168,11 +1186,10 @@ export default function OrderDetailsPage() {
{[...Array(5)].map((_, i) => ( {[...Array(5)].map((_, i) => (
<svg <svg
key={i} key={i}
className={`w-4 h-4 ${ className={`w-4 h-4 ${i < (order?.review?.stars || 0)
i < (order?.review?.stars || 0)
? "text-yellow-400" ? "text-yellow-400"
: "text-zinc-600" : "text-zinc-600"
}`} }`}
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
> >
@@ -1198,10 +1215,10 @@ export default function OrderDetailsPage() {
</Card> </Card>
)} )}
</div> </div>
</div> </motion.div>
{/* Shipping Dialog removed; use inline tracking input above */}
</div> </div>
{/* Shipping Dialog removed; use inline tracking input above */}
</Layout> </Layout>
); );
} }

View File

@@ -45,7 +45,7 @@ const ProfitAnalysisModal = dynamic(() => import("@/components/modals/profit-ana
function ProductTableSkeleton() { function ProductTableSkeleton() {
return ( 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> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Skeleton className="h-6 w-32" /> <Skeleton className="h-6 w-32" />
@@ -60,8 +60,8 @@ function ProductTableSkeleton() {
<div className="border-b p-4"> <div className="border-b p-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{['Product', 'Category', 'Price', 'Stock', 'Status', 'Actions'].map((header, i) => ( {['Product', 'Category', 'Price', 'Stock', 'Status', 'Actions'].map((header, i) => (
<Skeleton <Skeleton
key={i} key={i}
className="h-4 w-20 flex-1 animate-in fade-in" className="h-4 w-20 flex-1 animate-in fade-in"
style={{ style={{
animationDelay: `${i * 50}ms`, animationDelay: `${i * 50}ms`,
@@ -72,10 +72,10 @@ function ProductTableSkeleton() {
))} ))}
</div> </div>
</div> </div>
{[...Array(8)].map((_, i) => ( {[...Array(8)].map((_, i) => (
<div <div
key={i} key={i}
className="border-b last:border-b-0 p-4 animate-in fade-in" className="border-b last:border-b-0 p-4 animate-in fade-in"
style={{ style={{
animationDelay: `${300 + i * 50}ms`, animationDelay: `${300 + i * 50}ms`,
@@ -152,7 +152,7 @@ export default function ProductsPage() {
const [importModalOpen, setImportModalOpen] = useState(false); const [importModalOpen, setImportModalOpen] = useState(false);
const [addProductOpen, setAddProductOpen] = useState(false); const [addProductOpen, setAddProductOpen] = useState(false);
const [profitAnalysisOpen, setProfitAnalysisOpen] = 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 // Fetch products and categories
useEffect(() => { useEffect(() => {
@@ -169,7 +169,7 @@ export default function ProductsPage() {
const fetchDataAsync = async () => { const fetchDataAsync = async () => {
try { try {
setLoading(true); setLoading(true);
const [fetchedProducts, fetchedCategories] = await Promise.all([ const [fetchedProducts, fetchedCategories] = await Promise.all([
clientFetch('/products'), clientFetch('/products'),
clientFetch('/categories'), clientFetch('/categories'),
@@ -210,7 +210,7 @@ export default function ProductsPage() {
})); }));
}; };
// Handle input changes // Handle input changes
const handleChange = ( const handleChange = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement> e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => setProductData({ ...productData, [e.target.name]: e.target.value }); ) => setProductData({ ...productData, [e.target.name]: e.target.value });
@@ -226,7 +226,7 @@ export default function ProductsPage() {
setProductData({ ...productData, pricing: updatedPricing }); setProductData({ ...productData, pricing: updatedPricing });
}; };
const handleSaveProduct = async (data: Product, file?: File | null) => { const handleSaveProduct = async (data: Product, file?: File | null) => {
try { try {
setLoading(true); setLoading(true);
@@ -247,7 +247,7 @@ export default function ProductsPage() {
// Save the product data // Save the product data
const endpoint = editing ? `/products/${data._id}` : "/products"; const endpoint = editing ? `/products/${data._id}` : "/products";
const method = editing ? "PUT" : "POST"; const method = editing ? "PUT" : "POST";
const productResponse = await clientFetch(endpoint, { const productResponse = await clientFetch(endpoint, {
method, method,
headers: { headers: {
@@ -259,10 +259,10 @@ export default function ProductsPage() {
// If there's a new image to upload // If there's a new image to upload
if (file) { if (file) {
const imageEndpoint = `/products/${productResponse._id || data._id}/image`; const imageEndpoint = `/products/${productResponse._id || data._id}/image`;
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
await fetch(`${process.env.NEXT_PUBLIC_API_URL}${imageEndpoint}`, { await fetch(`${process.env.NEXT_PUBLIC_API_URL}${imageEndpoint}`, {
method: "PUT", method: "PUT",
headers: { headers: {
@@ -279,10 +279,10 @@ export default function ProductsPage() {
// Refresh products list // Refresh products list
const fetchedProducts = await clientFetch('/products'); const fetchedProducts = await clientFetch('/products');
setProducts(fetchedProducts); setProducts(fetchedProducts);
setModalOpen(false); setModalOpen(false);
setLoading(false); setLoading(false);
toast.success( toast.success(
editing ? "Product updated successfully" : "Product added successfully" editing ? "Product updated successfully" : "Product added successfully"
); );
@@ -296,18 +296,18 @@ export default function ProductsPage() {
// Handle delete product // Handle delete product
const handleDeleteProduct = async (productId: string) => { const handleDeleteProduct = async (productId: string) => {
if (!confirm("Are you sure you want to delete this product?")) return; if (!confirm("Are you sure you want to delete this product?")) return;
try { try {
setLoading(true); setLoading(true);
await clientFetch(`/products/${productId}`, { await clientFetch(`/products/${productId}`, {
method: "DELETE", method: "DELETE",
}); });
// Refresh products list // Refresh products list
const fetchedProducts = await clientFetch('/products'); const fetchedProducts = await clientFetch('/products');
setProducts(fetchedProducts); setProducts(fetchedProducts);
toast.success("Product deleted successfully"); toast.success("Product deleted successfully");
setLoading(false); setLoading(false);
} catch (error) { } catch (error) {
@@ -323,9 +323,9 @@ export default function ProductsPage() {
...product, ...product,
pricing: product.pricing pricing: product.pricing
? product.pricing.map((tier) => ({ ? product.pricing.map((tier) => ({
minQuantity: tier.minQuantity, minQuantity: tier.minQuantity,
pricePerUnit: tier.pricePerUnit, pricePerUnit: tier.pricePerUnit,
})) }))
: [{ minQuantity: 1, pricePerUnit: 0 }], : [{ minQuantity: 1, pricePerUnit: 0 }],
costPerUnit: product.costPerUnit || 0, costPerUnit: product.costPerUnit || 0,
}); });
@@ -343,16 +343,16 @@ export default function ProductsPage() {
image: null, // Clear image so user can upload a new one image: null, // Clear image so user can upload a new one
pricing: product.pricing pricing: product.pricing
? product.pricing.map((tier) => ({ ? product.pricing.map((tier) => ({
minQuantity: tier.minQuantity, minQuantity: tier.minQuantity,
pricePerUnit: tier.pricePerUnit, pricePerUnit: tier.pricePerUnit,
})) }))
: [{ minQuantity: 1, pricePerUnit: 0 }], : [{ minQuantity: 1, pricePerUnit: 0 }],
costPerUnit: product.costPerUnit || 0, costPerUnit: product.costPerUnit || 0,
// Reset stock to defaults for cloned product // Reset stock to defaults for cloned product
currentStock: 0, currentStock: 0,
stockStatus: 'out_of_stock' as const, stockStatus: 'out_of_stock' as const,
}; };
setProductData(clonedProduct); setProductData(clonedProduct);
setEditing(false); // Set to false so it creates a new product setEditing(false); // Set to false so it creates a new product
setAddProductOpen(true); setAddProductOpen(true);
@@ -390,19 +390,19 @@ export default function ProductsPage() {
// Filter products based on search term // Filter products based on search term
const filteredProducts = products.filter(product => { const filteredProducts = products.filter(product => {
if (!searchTerm) return true; if (!searchTerm) return true;
const searchLower = searchTerm.toLowerCase(); const searchLower = searchTerm.toLowerCase();
// Search in product name // Search in product name
if (product.name.toLowerCase().includes(searchLower)) return true; if (product.name.toLowerCase().includes(searchLower)) return true;
// Search in product description if it exists // Search in product description if it exists
if (product.description && product.description.toLowerCase().includes(searchLower)) return true; if (product.description && product.description.toLowerCase().includes(searchLower)) return true;
// Search in category name // Search in category name
const categoryName = getCategoryNameById(product.category).toLowerCase(); const categoryName = getCategoryNameById(product.category).toLowerCase();
if (categoryName.includes(searchLower)) return true; if (categoryName.includes(searchLower)) return true;
return false; return false;
}); });
@@ -437,19 +437,19 @@ export default function ProductsPage() {
const handleToggleEnabled = async (productId: string, enabled: boolean) => { const handleToggleEnabled = async (productId: string, enabled: boolean) => {
try { try {
setLoading(true); setLoading(true);
await clientFetch(`/products/${productId}`, { await clientFetch(`/products/${productId}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ enabled }), body: JSON.stringify({ enabled }),
}); });
// Update the local state // Update the local state
setProducts(products.map(product => setProducts(products.map(product =>
product._id === productId product._id === productId
? { ...product, enabled } ? { ...product, enabled }
: product : product
)); ));
toast.success(`Product ${enabled ? 'enabled' : 'disabled'} successfully`); toast.success(`Product ${enabled ? 'enabled' : 'disabled'} successfully`);
setLoading(false); setLoading(false);
} catch (error) { } catch (error) {
@@ -489,9 +489,9 @@ export default function ProductsPage() {
)} )}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
onClick={() => setImportModalOpen(true)} onClick={() => setImportModalOpen(true)}
variant="outline" variant="outline"
className="gap-2 flex-1 sm:flex-none" className="gap-2 flex-1 sm:flex-none"
> >
<Upload className="h-4 w-4" /> <Upload className="h-4 w-4" />

View File

@@ -54,10 +54,10 @@ const ShippingTable = dynamic(() => import("@/components/tables/shipping-table")
// Loading skeleton for shipping table // Loading skeleton for shipping table
function ShippingTableSkeleton() { function ShippingTableSkeleton() {
return ( 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 */} {/* Subtle loading indicator */}
<div className="absolute top-0 left-0 right-0 h-1 bg-muted overflow-hidden rounded-t-lg"> <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" <div className="h-full bg-primary w-1/3"
style={{ style={{
background: 'linear-gradient(90deg, transparent, hsl(var(--primary)), transparent)', background: 'linear-gradient(90deg, transparent, hsl(var(--primary)), transparent)',
backgroundSize: '200% 100%', backgroundSize: '200% 100%',
@@ -65,7 +65,7 @@ function ShippingTableSkeleton() {
}} }}
/> />
</div> </div>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Skeleton className="h-6 w-32" /> <Skeleton className="h-6 w-32" />
@@ -77,8 +77,8 @@ function ShippingTableSkeleton() {
<div className="border-b p-4"> <div className="border-b p-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{['Method Name', 'Price', 'Actions'].map((header, i) => ( {['Method Name', 'Price', 'Actions'].map((header, i) => (
<Skeleton <Skeleton
key={i} key={i}
className="h-4 w-20 flex-1 animate-in fade-in" className="h-4 w-20 flex-1 animate-in fade-in"
style={{ style={{
animationDelay: `${i * 50}ms`, animationDelay: `${i * 50}ms`,
@@ -89,10 +89,10 @@ function ShippingTableSkeleton() {
))} ))}
</div> </div>
</div> </div>
{[...Array(4)].map((_, i) => ( {[...Array(4)].map((_, i) => (
<div <div
key={i} key={i}
className="border-b last:border-b-0 p-4 animate-in fade-in" className="border-b last:border-b-0 p-4 animate-in fade-in"
style={{ style={{
animationDelay: `${200 + i * 50}ms`, animationDelay: `${200 + i * 50}ms`,
@@ -180,12 +180,12 @@ export default function ShippingPage() {
.split("; ") .split("; ")
.find((row) => row.startsWith("Authorization=")) .find((row) => row.startsWith("Authorization="))
?.split("=")[1]; ?.split("=")[1];
if (!authToken) { if (!authToken) {
console.error("No auth token found"); console.error("No auth token found");
return; return;
} }
console.log("Sending request to add shipping method:", newShipping); console.log("Sending request to add shipping method:", newShipping);
const response = await addShippingMethod( const response = await addShippingMethod(
newShipping, newShipping,
@@ -196,10 +196,10 @@ export default function ShippingPage() {
// Close modal and reset form before refreshing to avoid UI delays // Close modal and reset form before refreshing to avoid UI delays
setModalOpen(false); setModalOpen(false);
setNewShipping({ name: "", price: 0 }); setNewShipping({ name: "", price: 0 });
// Refresh the list after adding // Refresh the list after adding
refreshShippingMethods(); refreshShippingMethods();
console.log("Shipping method added successfully"); console.log("Shipping method added successfully");
} catch (error) { } catch (error) {
console.error("Error adding shipping method:", error); console.error("Error adding shipping method:", error);
@@ -218,12 +218,12 @@ export default function ShippingPage() {
.split("; ") .split("; ")
.find((row) => row.startsWith("Authorization=")) .find((row) => row.startsWith("Authorization="))
?.split("=")[1]; ?.split("=")[1];
if (!authToken) { if (!authToken) {
console.error("No auth token found"); console.error("No auth token found");
return; return;
} }
await updateShippingMethod( await updateShippingMethod(
newShipping._id, newShipping._id,
newShipping, newShipping,
@@ -234,10 +234,10 @@ export default function ShippingPage() {
setModalOpen(false); setModalOpen(false);
setNewShipping({ name: "", price: 0 }); setNewShipping({ name: "", price: 0 });
setEditing(false); setEditing(false);
// Refresh the list after updating // Refresh the list after updating
refreshShippingMethods(); refreshShippingMethods();
console.log("Shipping method updated successfully"); console.log("Shipping method updated successfully");
} catch (error) { } catch (error) {
console.error("Error updating shipping method:", error); console.error("Error updating shipping method:", error);

View File

@@ -7,18 +7,22 @@ import { Button } from "@/components/ui/button";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
DropdownMenu, import { Badge } from "@/components/ui/badge";
DropdownMenuContent, import {
DropdownMenuItem, DropdownMenu,
DropdownMenuTrigger DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuLabel,
DropdownMenuSeparator
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
@@ -30,12 +34,13 @@ import {
AlertDialogTrigger, AlertDialogTrigger,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Product } from "@/models/products"; 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 { clientFetch } from "@/lib/api";
import { toast } from "sonner"; import { toast } from "sonner";
import { DatePicker, DateRangePicker, DateRangeDisplay, MonthPicker } from "@/components/ui/date-picker"; import { DatePicker, DateRangePicker, DateRangeDisplay, MonthPicker } from "@/components/ui/date-picker";
import { DateRange } from "react-day-picker"; import { DateRange } from "react-day-picker";
import { addDays, startOfDay, endOfDay, format, isSameDay } from "date-fns"; import { addDays, startOfDay, endOfDay, format, isSameDay } from "date-fns";
import { motion, AnimatePresence } from "framer-motion";
interface StockData { interface StockData {
currentStock: number; currentStock: number;
@@ -55,7 +60,7 @@ export default function StockManagementPage() {
const [selectedProducts, setSelectedProducts] = useState<string[]>([]); const [selectedProducts, setSelectedProducts] = useState<string[]>([]);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
const [bulkAction, setBulkAction] = useState<'enable' | 'disable' | null>(null); const [bulkAction, setBulkAction] = useState<'enable' | 'disable' | null>(null);
// Export state // Export state
const [exportDate, setExportDate] = useState<string>(new Date().toISOString().split('T')[0]); const [exportDate, setExportDate] = useState<string>(new Date().toISOString().split('T')[0]);
const [exportDateRange, setExportDateRange] = useState<DateRange | undefined>({ const [exportDateRange, setExportDateRange] = useState<DateRange | undefined>({
@@ -82,7 +87,7 @@ export default function StockManagementPage() {
const response = await clientFetch<Product[]>('api/products'); const response = await clientFetch<Product[]>('api/products');
const fetchedProducts = response || []; const fetchedProducts = response || [];
setProducts(fetchedProducts); setProducts(fetchedProducts);
// Initialize stock values // Initialize stock values
const initialStockValues: Record<string, number> = {}; const initialStockValues: Record<string, number> = {};
fetchedProducts.forEach((product: Product) => { fetchedProducts.forEach((product: Product) => {
@@ -91,7 +96,7 @@ export default function StockManagementPage() {
} }
}); });
setStockValues(initialStockValues); setStockValues(initialStockValues);
setLoading(false); setLoading(false);
} catch (error) { } catch (error) {
console.error("Error fetching products:", error); console.error("Error fetching products:", error);
@@ -114,7 +119,7 @@ export default function StockManagementPage() {
try { try {
const newStockValue = stockValues[product._id] || 0; const newStockValue = stockValues[product._id] || 0;
const stockData: StockData = { const stockData: StockData = {
currentStock: newStockValue, currentStock: newStockValue,
stockTracking: product.stockTracking || false, stockTracking: product.stockTracking || false,
@@ -165,14 +170,14 @@ export default function StockManagementPage() {
try { try {
// Toggle the stock tracking status // Toggle the stock tracking status
const newTrackingStatus = !product.stockTracking; const newTrackingStatus = !product.stockTracking;
// For enabling tracking, we need to ensure there's a stock value // For enabling tracking, we need to ensure there's a stock value
const stockData: StockData = { const stockData: StockData = {
stockTracking: newTrackingStatus, stockTracking: newTrackingStatus,
currentStock: product.currentStock || 0, currentStock: product.currentStock || 0,
lowStockThreshold: product.lowStockThreshold || 10, lowStockThreshold: product.lowStockThreshold || 10,
}; };
// Update stock tracking status // Update stock tracking status
await clientFetch(`api/stock/${product._id}`, { await clientFetch(`api/stock/${product._id}`, {
method: 'PUT', method: 'PUT',
@@ -212,7 +217,7 @@ export default function StockManagementPage() {
try { try {
const productsToUpdate = products.filter(p => selectedProducts.includes(p._id || '')); const productsToUpdate = products.filter(p => selectedProducts.includes(p._id || ''));
await Promise.all(productsToUpdate.map(async (product) => { await Promise.all(productsToUpdate.map(async (product) => {
if (!product._id) return; if (!product._id) return;
@@ -254,7 +259,7 @@ export default function StockManagementPage() {
}; };
const toggleSelectProduct = (productId: string) => { const toggleSelectProduct = (productId: string) => {
setSelectedProducts(prev => setSelectedProducts(prev =>
prev.includes(productId) prev.includes(productId)
? prev.filter(id => id !== productId) ? prev.filter(id => id !== productId)
: [...prev, productId] : [...prev, productId]
@@ -262,7 +267,7 @@ export default function StockManagementPage() {
}; };
const toggleSelectAll = () => { const toggleSelectAll = () => {
setSelectedProducts(prev => setSelectedProducts(prev =>
prev.length === products.length prev.length === products.length
? [] ? []
: products.map(p => p._id || '') : products.map(p => p._id || '')
@@ -280,7 +285,7 @@ export default function StockManagementPage() {
response = await clientFetch(`/api/analytics/daily-stock-report?date=${exportDate}`); response = await clientFetch(`/api/analytics/daily-stock-report?date=${exportDate}`);
filename = `daily-stock-report-${exportDate}.csv`; filename = `daily-stock-report-${exportDate}.csv`;
break; break;
case 'weekly': case 'weekly':
if (!exportDateRange?.from) { if (!exportDateRange?.from) {
toast.error('Please select a date range for weekly report'); toast.error('Please select a date range for weekly report');
@@ -290,14 +295,14 @@ export default function StockManagementPage() {
response = await clientFetch(`/api/analytics/weekly-stock-report?weekStart=${weekStart}`); response = await clientFetch(`/api/analytics/weekly-stock-report?weekStart=${weekStart}`);
filename = `weekly-stock-report-${weekStart}.csv`; filename = `weekly-stock-report-${weekStart}.csv`;
break; break;
case 'monthly': case 'monthly':
const year = selectedMonth.getFullYear(); const year = selectedMonth.getFullYear();
const month = selectedMonth.getMonth() + 1; const month = selectedMonth.getMonth() + 1;
response = await clientFetch(`/api/analytics/monthly-stock-report?year=${year}&month=${month}`); response = await clientFetch(`/api/analytics/monthly-stock-report?year=${year}&month=${month}`);
filename = `monthly-stock-report-${year}-${month.toString().padStart(2, '0')}.csv`; filename = `monthly-stock-report-${year}-${month.toString().padStart(2, '0')}.csv`;
break; break;
case 'custom': case 'custom':
if (!exportDateRange?.from || !exportDateRange?.to) { if (!exportDateRange?.from || !exportDateRange?.to) {
toast.error('Please select a date range for custom report'); toast.error('Please select a date range for custom report');
@@ -308,12 +313,12 @@ export default function StockManagementPage() {
response = await clientFetch(`/api/analytics/daily-stock-report?startDate=${startDate}&endDate=${endDate}`); response = await clientFetch(`/api/analytics/daily-stock-report?startDate=${startDate}&endDate=${endDate}`);
filename = `custom-stock-report-${startDate}-to-${endDate}.csv`; filename = `custom-stock-report-${startDate}-to-${endDate}.csv`;
break; break;
default: default:
toast.error('Invalid report type'); toast.error('Invalid report type');
return; return;
} }
if (!response || !response.products) { if (!response || !response.products) {
throw new Error('No data received from server'); throw new Error('No data received from server');
} }
@@ -348,19 +353,19 @@ export default function StockManagementPage() {
const blob = new Blob([csvData], { type: 'text/csv;charset=utf-8;' }); const blob = new Blob([csvData], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a'); const link = document.createElement('a');
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
link.setAttribute('href', url); link.setAttribute('href', url);
link.setAttribute('download', filename); link.setAttribute('download', filename);
link.style.visibility = 'hidden'; link.style.visibility = 'hidden';
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
const periodText = reportType === 'daily' ? exportDate : const periodText = reportType === 'daily' ? exportDate :
reportType === 'weekly' ? `week starting ${format(exportDateRange?.from || new Date(), 'MMM dd')}` : reportType === 'weekly' ? `week starting ${format(exportDateRange?.from || new Date(), 'MMM dd')}` :
reportType === 'monthly' ? `${response.monthName || 'current month'}` : reportType === 'monthly' ? `${response.monthName || 'current month'}` :
`${format(exportDateRange?.from || new Date(), 'MMM dd')} to ${format(exportDateRange?.to || new Date(), 'MMM dd')}`; `${format(exportDateRange?.from || new Date(), 'MMM dd')} to ${format(exportDateRange?.to || new Date(), 'MMM dd')}`;
toast.success(`${reportType.charAt(0).toUpperCase() + reportType.slice(1)} stock report for ${periodText} exported successfully`); toast.success(`${reportType.charAt(0).toUpperCase() + reportType.slice(1)} stock report for ${periodText} exported successfully`);
} catch (error) { } catch (error) {
@@ -379,9 +384,29 @@ export default function StockManagementPage() {
return 'In stock'; 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 => { const filteredProducts = products.filter(product => {
if (!searchTerm) return true; if (!searchTerm) return true;
const searchLower = searchTerm.toLowerCase(); const searchLower = searchTerm.toLowerCase();
return ( return (
product.name.toLowerCase().includes(searchLower) || product.name.toLowerCase().includes(searchLower) ||
@@ -392,31 +417,39 @@ export default function StockManagementPage() {
return ( return (
<Layout> <Layout>
<div className="space-y-6"> <div className="space-y-6 animate-in fade-in duration-500">
<div className="flex items-center justify-between"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white flex items-center"> <div>
<Boxes className="mr-2 h-6 w-6" /> <h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
Stock Management <Boxes className="h-6 w-6 text-primary" />
</h1> Stock Management
<div className="flex items-center gap-3"> </h1>
<Input <p className="text-muted-foreground text-sm mt-1">
type="search" Track inventory levels and manage stock status
placeholder="Search products..." </p>
className="w-64" </div>
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} <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" />
{/* Report Type Selector */} <Input
type="search"
placeholder="Search products..."
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>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2"> <Button variant="outline" size="icon" className="h-10 w-10 border-border/50 bg-background/50">
<Calendar className="h-4 w-4" /> <Filter className="h-4 w-4 text-muted-foreground" />
{reportType.charAt(0).toUpperCase() + reportType.slice(1)} Report
<ChevronDown className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent align="end">
<DropdownMenuLabel>Filter Reports</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setReportType('daily')}> <DropdownMenuItem onClick={() => setReportType('daily')}>
Daily Report Daily Report
</DropdownMenuItem> </DropdownMenuItem>
@@ -433,51 +466,53 @@ export default function StockManagementPage() {
</DropdownMenu> </DropdownMenu>
{/* Date Selection based on report type */} {/* Date Selection based on report type */}
{reportType === 'daily' && ( <div className="hidden sm:block">
<DatePicker {reportType === 'daily' && (
date={exportDate ? new Date(exportDate) : undefined} <DatePicker
onDateChange={(date) => setExportDate(date ? date.toISOString().split('T')[0] : '')} date={exportDate ? new Date(exportDate) : undefined}
placeholder="Select export date" onDateChange={(date) => setExportDate(date ? date.toISOString().split('T')[0] : '')}
className="w-auto" placeholder="Select export date"
/> className="w-auto border-border/50 bg-background/50"
)} />
)}
{(reportType === 'weekly' || reportType === 'custom') && ( {(reportType === 'weekly' || reportType === 'custom') && (
<DateRangePicker <DateRangePicker
dateRange={exportDateRange} dateRange={exportDateRange}
onDateRangeChange={setExportDateRange} onDateRangeChange={setExportDateRange}
placeholder="Select date range" placeholder="Select date range"
className="w-auto" className="w-auto border-border/50 bg-background/50"
/> />
)} )}
{reportType === 'monthly' && ( {reportType === 'monthly' && (
<MonthPicker <MonthPicker
selectedMonth={selectedMonth} selectedMonth={selectedMonth}
onMonthChange={(date) => setSelectedMonth(date || new Date())} onMonthChange={(date) => setSelectedMonth(date || new Date())}
placeholder="Select month" placeholder="Select month"
className="w-auto" className="w-auto border-border/50 bg-background/50"
/> />
)} )}
</div>
<Button <Button
variant="outline" variant="outline"
onClick={handleExportStock} onClick={handleExportStock}
disabled={isExporting} disabled={isExporting}
className="gap-2" className="gap-2 border-border/50 bg-background/50 hover:bg-background transition-colors"
> >
{isExporting ? ( {isExporting ? (
<RefreshCw className="h-4 w-4 animate-spin" /> <RefreshCw className="h-4 w-4 animate-spin" />
) : ( ) : (
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
)} )}
{isExporting ? 'Exporting...' : 'Export CSV'} Export
</Button> </Button>
{selectedProducts.length > 0 && ( {selectedProducts.length > 0 && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2"> <Button variant="default" className="gap-2">
<Package className="h-4 w-4" /> <Package className="h-4 w-4" />
Bulk Actions Bulk Actions
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
@@ -486,11 +521,11 @@ export default function StockManagementPage() {
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleBulkAction('enable')}> <DropdownMenuItem onClick={() => handleBulkAction('enable')}>
<CheckSquare className="h-4 w-4 mr-2" /> <CheckSquare className="h-4 w-4 mr-2" />
Enable Stock Tracking Enable Tracking
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => handleBulkAction('disable')}> <DropdownMenuItem onClick={() => handleBulkAction('disable')}>
<XSquare className="h-4 w-4 mr-2" /> <XSquare className="h-4 w-4 mr-2" />
Disable Stock Tracking Disable Tracking
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -498,90 +533,140 @@ export default function StockManagementPage() {
</div> </div>
</div> </div>
<div className="rounded-md border"> <Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
<Table> <CardHeader className="py-4 px-6 border-b border-border/50 bg-muted/30 flex flex-row items-center justify-between">
<TableHeader> <div>
<TableRow> <CardTitle className="text-lg font-medium">Inventory Data</CardTitle>
<TableHead className="w-12"> <CardDescription>Manage stock levels and tracking for {products.length} products</CardDescription>
<input </div>
type="checkbox" <div className="text-xs text-muted-foreground bg-background/50 px-3 py-1 rounded-full border border-border/50">
checked={selectedProducts.length === products.length} {filteredProducts.length} items
onChange={toggleSelectAll} </div>
className="rounded border-gray-300" </CardHeader>
/> <CardContent className="p-0">
</TableHead> <div className="overflow-x-auto">
<TableHead>Product</TableHead> <Table>
<TableHead>Stock Status</TableHead> <TableHeader className="bg-muted/50">
<TableHead>Current Stock</TableHead> <TableRow className="border-border/50 hover:bg-transparent">
<TableHead>Track Stock</TableHead> <TableHead className="w-12 pl-6">
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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>
</TableRow>
) : filteredProducts.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8">
No products found
</TableCell>
</TableRow>
) : (
filteredProducts.map((product) => (
<TableRow key={product._id}>
<TableCell>
<input <input
type="checkbox" type="checkbox"
checked={selectedProducts.includes(product._id || '')} checked={selectedProducts.length === products.length && products.length > 0}
onChange={() => toggleSelectProduct(product._id || '')} onChange={toggleSelectAll}
className="rounded border-gray-300" className="rounded border-gray-300 dark:border-zinc-700 bg-background focus:ring-primary/20"
/> />
</TableCell> </TableHead>
<TableCell>{product.name}</TableCell> <TableHead>Product</TableHead>
<TableCell>{getStockStatus(product)}</TableCell> <TableHead>Status</TableHead>
<TableCell> <TableHead>Current Stock</TableHead>
{editingStock[product._id || ''] ? ( <TableHead>Tracking</TableHead>
<div className="flex items-center gap-2"> <TableHead className="text-right pr-6">Actions</TableHead>
<Input
type="number"
value={stockValues[product._id || ''] || 0}
onChange={(e) => handleStockChange(product._id || '', parseInt(e.target.value) || 0)}
className="w-24"
/>
<Button size="sm" onClick={() => handleSaveStock(product)}>Save</Button>
</div>
) : (
<span>{product.currentStock || 0}</span>
)}
</TableCell>
<TableCell>
<Switch
checked={product.stockTracking || false}
onCheckedChange={() => handleToggleStockTracking(product)}
/>
</TableCell>
<TableCell className="text-right">
{!editingStock[product._id || ''] && (
<Button
variant="outline"
size="sm"
onClick={() => handleEditStock(product._id || '')}
>
Edit Stock
</Button>
)}
</TableCell>
</TableRow> </TableRow>
)) </TableHeader>
)} <TableBody>
</TableBody> <AnimatePresence mode="popLayout">
</Table> {loading ? (
</div> <TableRow>
<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-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, 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 dark:border-zinc-700 bg-background focus:ring-primary/20"
/>
</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">
<Input
type="number"
value={stockValues[product._id || ''] || 0}
onChange={(e) => handleStockChange(product._id || '', parseInt(e.target.value) || 0)}
className="w-20 h-8 font-mono bg-background"
/>
</div>
) : (
<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 pr-6">
<div className="flex justify-end gap-1">
{editingStock[product._id || ''] ? (
<>
<Button
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 || '')}
>
<Edit2 className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</motion.tr>
))
)}
</AnimatePresence>
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div> </div>
<AlertDialog open={isConfirmDialogOpen} onOpenChange={setIsConfirmDialogOpen}> <AlertDialog open={isConfirmDialogOpen} onOpenChange={setIsConfirmDialogOpen}>
@@ -589,12 +674,14 @@ export default function StockManagementPage() {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Confirm Bulk Action</AlertDialogTitle> <AlertDialogTitle>Confirm Bulk Action</AlertDialogTitle>
<AlertDialogDescription> <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> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel onClick={() => setBulkAction(null)}>Cancel</AlertDialogCancel> <AlertDialogCancel onClick={() => setBulkAction(null)}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={executeBulkAction}>Continue</AlertDialogAction> <AlertDialogAction onClick={executeBulkAction} className="bg-primary text-primary-foreground">
Continue
</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>

File diff suppressed because it is too large Load Diff

View File

@@ -6,11 +6,15 @@ import Layout from "@/components/layout/layout";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; 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 { apiRequest } from "@/lib/api";
import { toast } from "sonner"; import { toast } from "sonner";
import BroadcastDialog from "@/components/modals/broadcast-dialog"; import BroadcastDialog from "@/components/modals/broadcast-dialog";
import Dashboard from "@/components/dashboard/dashboard"; 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 { import {
Select, Select,
SelectContent, SelectContent,
@@ -166,251 +170,298 @@ export default function StorefrontPage() {
return ( return (
<Dashboard> <Dashboard>
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-6"> <div className="flex items-center gap-4">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white flex items-center"> <div className="p-3 rounded-xl bg-primary/10 text-primary">
<Globe className="mr-2 h-6 w-6" /> <Globe className="h-8 w-8" />
Storefront Settings </div>
</h1> <div>
<div className="flex items-center gap-2"> <h1 className="text-3xl font-bold tracking-tight text-foreground">
<TooltipProvider> Storefront Settings
<Tooltip> </h1>
<TooltipTrigger asChild> <p className="text-muted-foreground">
<div className="flex items-center gap-2"> Manage your shop's appearance, policies, and configuration
<Switch </p>
checked={storefront.isEnabled}
onCheckedChange={(checked) =>
setStorefront((prev) => ({
...prev,
isEnabled: checked,
}))
}
/>
<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> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-3">
<Button <Button
variant="outline" variant="outline"
onClick={() => setBroadcastOpen(true)} onClick={() => setBroadcastOpen(true)}
className="gap-2" className="gap-2 h-10"
size="sm"
> >
<Send className="h-4 w-4" /> <Send className="h-4 w-4" />
Broadcast Broadcast
</Button> </Button>
<Button <Button
onClick={saveStorefront} onClick={saveStorefront}
disabled={saving} disabled={saving}
className="gap-2" className="gap-2 h-10 min-w-[120px]"
size="sm"
> >
<Save className="h-4 w-4" /> {saving ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{saving ? "Saving..." : "Save Changes"} {saving ? "Saving..." : "Save Changes"}
</Button> </Button>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 lg:gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Security Settings */} {/* Main Column */}
<div className="space-y-3"> <div className="lg:col-span-2 space-y-6">
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-3"> {/* Store Status Card */}
<Shield className="h-4 w-4 text-purple-400" /> <motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}>
<h2 className="text-base font-medium text-zinc-100"> <Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden relative">
Security <div className={`absolute top-0 left-0 w-1 h-full ${storefront.isEnabled ? 'bg-emerald-500' : 'bg-destructive'}`} />
</h2> <CardHeader className="pb-3">
</div> <div className="flex items-center justify-between">
<div className="space-y-3"> <div className="space-y-1">
<div> <CardTitle>Store Status</CardTitle>
<label className="text-xs font-medium mb-1 block text-zinc-400">PGP Public Key</label> <CardDescription>Control your store's visibility to customers</CardDescription>
<Textarea </div>
value={storefront.pgpKey} <Badge variant={storefront.isEnabled ? "default" : "destructive"} className="h-6">
onChange={(e) => setStorefront(prev => ({ ...prev, pgpKey: e.target.value }))} {storefront.isEnabled ? "Open for Business" : "Store Closed"}
placeholder="Enter your PGP public key" </Badge>
className="font-mono text-sm h-24 bg-[#1C1C1C] border-zinc-800 resize-none" </div>
/> </CardHeader>
</div> <CardContent className="pb-6">
<div> <div className="flex items-center gap-4 p-4 rounded-lg bg-card border border-border/50">
<label className="text-xs font-medium mb-1 block text-zinc-400">Telegram Bot Token</label> <Switch
<Input checked={storefront.isEnabled}
type="password" onCheckedChange={(checked) =>
value={storefront.telegramToken} setStorefront((prev) => ({
onChange={(e) => setStorefront(prev => ({ ...prev, telegramToken: e.target.value }))} ...prev,
placeholder="Enter your Telegram bot token" isEnabled: checked,
className="bg-[#1C1C1C] border-zinc-800 h-8 text-sm" }))
/> }
</div> className="data-[state=checked]:bg-emerald-500"
</div> />
<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>
</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>
{/* Shipping Settings */} {/* Security Settings */}
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800"> <motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }}>
<div className="flex items-center gap-2 mb-3"> <Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
<Globe className="h-4 w-4 text-blue-400" /> <CardHeader>
<h2 className="text-base font-medium text-zinc-100"> <CardTitle className="flex items-center gap-2">
Shipping <Key className="h-5 w-5 text-purple-400" />
</h2> Security Configuration
</div> </CardTitle>
<div className="grid grid-cols-2 gap-3"> <CardDescription>Manage keys and access tokens for your store security</CardDescription>
<div> </CardHeader>
<label className="text-xs font-medium mb-1 block text-zinc-400">Ships From</label> <CardContent className="space-y-6">
<Select <div className="grid grid-cols-1 gap-4">
value={storefront.shipsFrom} <div className="space-y-2">
onValueChange={(value) => <Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">PGP Public Key</Label>
setStorefront((prev) => ({ ...prev, shipsFrom: value as any })) <Textarea
} value={storefront.pgpKey}
> onChange={(e) => setStorefront(prev => ({ ...prev, pgpKey: e.target.value }))}
<SelectTrigger className="bg-[#1C1C1C] border-zinc-800 h-8 text-sm"> placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----..."
<SelectValue /> className="font-mono text-xs h-32 bg-zinc-950/50 border-zinc-800/50 resize-none"
</SelectTrigger> />
<SelectContent> </div>
{SHIPPING_REGIONS.map((region) => ( <div className="space-y-2">
<SelectItem key={region.value} value={region.value}> <Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Telegram Bot Token</Label>
{region.emoji} {region.label} <div className="relative">
</SelectItem> <Input
))} type="password"
</SelectContent> value={storefront.telegramToken}
</Select> onChange={(e) => setStorefront(prev => ({ ...prev, telegramToken: e.target.value }))}
</div> placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
<div> className="bg-background/50 border-border/50 font-mono text-sm pl-10"
<label className="text-xs font-medium mb-1 block text-zinc-400">Ships To</label> />
<Select <div className="absolute left-3 top-2.5 text-muted-foreground">
value={storefront.shipsTo} <Shield className="h-4 w-4" />
onValueChange={(value) => </div>
setStorefront((prev) => ({ ...prev, shipsTo: value as any })) </div>
} <p className="text-[10px] text-muted-foreground">Used for notifications and bot integration.</p>
> </div>
<SelectTrigger className="bg-[#1C1C1C] border-zinc-800 h-8 text-sm"> </div>
<SelectValue /> </CardContent>
</SelectTrigger> </Card>
<SelectContent> </motion.div>
{SHIPPING_REGIONS.map((region) => (
<SelectItem key={region.value} value={region.value}>
{region.emoji} {region.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</div> </div>
{/* Messaging and Payments */} {/* Sidebar Column */}
<div className="space-y-3"> <div className="space-y-6">
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800"> {/* Shipping Settings */}
<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 }}>
<MessageSquare className="h-4 w-4 text-emerald-400" /> <Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
<h2 className="text-base font-medium text-zinc-100"> <CardHeader>
Welcome Message <CardTitle className="flex items-center gap-2 text-lg">
</h2> <Globe className="h-4 w-4 text-blue-400" />
</div> Shipping & Logistics
<Textarea </CardTitle>
value={storefront.welcomeMessage} </CardHeader>
onChange={(e) => setStorefront(prev => ({ ...prev, welcomeMessage: e.target.value }))} <CardContent className="space-y-4">
placeholder="Enter the welcome message for new customers" <div className="space-y-2">
className="h-36 bg-[#1C1C1C] border-zinc-800 text-sm resize-none" <Label>Ships From</Label>
/> <Select
</div> value={storefront.shipsFrom}
onValueChange={(value) =>
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800"> setStorefront((prev) => ({ ...prev, shipsFrom: value as any }))
<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"> <SelectTrigger className="bg-background/50 border-border/50">
Store Policy <SelectValue />
</h2> </SelectTrigger>
</div> <SelectContent>
<Textarea {SHIPPING_REGIONS.map((region) => (
value={storefront.storePolicy} <SelectItem key={region.value} value={region.value}>
onChange={(e) => setStorefront(prev => ({ ...prev, storePolicy: e.target.value }))} <span className="flex items-center gap-2">
placeholder="Enter your store's policies, terms, and conditions" <span className="text-lg">{region.emoji}</span>
className="h-48 bg-[#1C1C1C] border-zinc-800 text-sm resize-none" {region.label}
/> </span>
</div> </SelectItem>
))}
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800"> </SelectContent>
<div className="flex items-center gap-2 mb-3"> </Select>
<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">
{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>
{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>
<Switch
checked={storefront.enabledWallets[wallet.id]}
onCheckedChange={(checked) =>
setStorefront((prev) => ({
...prev,
enabledWallets: {
...prev.enabledWallets,
[wallet.id]: checked,
},
}))
}
disabled={wallet.disabled}
/>
</div>
</TooltipTrigger>
{wallet.disabled && (
<TooltipContent>
<p>Coming soon</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</div>
{storefront.enabledWallets[wallet.id] && !wallet.disabled && (
<Input
value={storefront.wallets[wallet.id]}
onChange={(e) =>
setStorefront((prev) => ({
...prev,
wallets: {
...prev.wallets,
[wallet.id]: e.target.value,
},
}))
}
placeholder={wallet.placeholder}
className="font-mono text-sm h-8 bg-[#1C1C1C] border-zinc-800"
/>
)}
</div> </div>
))} <div className="space-y-2">
</div> <Label>Ships To</Label>
</div> <Select
value={storefront.shipsTo}
onValueChange={(value) =>
setStorefront((prev) => ({ ...prev, shipsTo: value as any }))
}
>
<SelectTrigger className="bg-background/50 border-border/50">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SHIPPING_REGIONS.map((region) => (
<SelectItem key={region.value} value={region.value}>
<span className="flex items-center gap-2">
<span className="text-lg">{region.emoji}</span>
{region.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
</motion.div>
{/* 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="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}
</label>
<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) =>
setStorefront((prev) => ({
...prev,
enabledWallets: {
...prev.enabledWallets,
[wallet.id]: checked,
},
}))
}
disabled={wallet.disabled}
className="scale-90"
/>
</div>
</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) =>
setStorefront((prev) => ({
...prev,
wallets: {
...prev.wallets,
[wallet.id]: e.target.value,
},
}))
}
placeholder={wallet.placeholder}
className="font-mono text-xs h-9 bg-background/50"
/>
</motion.div>
)}
</div>
))}
</CardContent>
</Card>
</motion.div>
</div> </div>
</div> </div>
</div> </div>
<BroadcastDialog open={broadcastOpen} setOpen={setBroadcastOpen} /> <BroadcastDialog open={broadcastOpen} setOpen={setBroadcastOpen} />
</Dashboard> </Dashboard >
); );
} }

View File

@@ -10,17 +10,18 @@ body {
.text-balance { .text-balance {
text-wrap: balance; text-wrap: balance;
} }
/* Shimmer animation for loading indicators */ /* Shimmer animation for loading indicators */
@keyframes shimmer { @keyframes shimmer {
0% { 0% {
background-position: -200% 0; background-position: -200% 0;
} }
100% { 100% {
background-position: 200% 0; background-position: 200% 0;
} }
} }
/* Accessibility improvements */ /* Accessibility improvements */
.sr-only { .sr-only {
position: absolute; position: absolute;
@@ -33,164 +34,182 @@ body {
white-space: nowrap; white-space: nowrap;
border: 0; border: 0;
} }
/* Better focus states for keyboard navigation */ /* Better focus states for keyboard navigation */
.focus-visible:focus-visible { .focus-visible:focus-visible {
outline: 2px solid hsl(var(--ring)); outline: 2px solid hsl(var(--ring));
outline-offset: 2px; outline-offset: 2px;
} }
/* Improved touch targets for mobile/Chromebook */ /* Improved touch targets for mobile/Chromebook */
.touch-target { .touch-target {
min-height: 44px; min-height: 44px;
min-width: 44px; min-width: 44px;
} }
/* Better contrast for Chromebook displays */ /* Better contrast for Chromebook displays */
@media (prefers-contrast: high) { @media (prefers-contrast: high) {
.border-input { .border-input {
border-color: hsl(var(--foreground)); border-color: hsl(var(--foreground));
} }
} }
/* Chromebook and touch device optimizations */ /* Chromebook and touch device optimizations */
@media (pointer: coarse) { @media (pointer: coarse) {
.touch-target { .touch-target {
min-height: 48px; min-height: 48px;
min-width: 48px; min-width: 48px;
} }
/* Larger touch targets for interactive elements */ /* Larger touch targets for interactive elements */
button, input, textarea, [role="button"] { button,
input,
textarea,
[role="button"] {
min-height: 44px; min-height: 44px;
} }
} }
/* Better focus indicators for keyboard navigation */ /* Better focus indicators for keyboard navigation */
.focus-visible:focus-visible { .focus-visible:focus-visible {
outline: 2px solid hsl(var(--ring)); outline: 2px solid hsl(var(--ring));
outline-offset: 2px; outline-offset: 2px;
box-shadow: 0 0 0 2px hsl(var(--ring) / 0.2); box-shadow: 0 0 0 2px hsl(var(--ring) / 0.2);
} }
/* Improved scrolling for touch devices */ /* Improved scrolling for touch devices */
.overflow-y-auto { .overflow-y-auto {
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
scroll-behavior: smooth; scroll-behavior: smooth;
} }
/* Enhanced contrast for better visibility */ /* Enhanced contrast for better visibility */
.text-muted-foreground { .text-muted-foreground {
color: hsl(var(--muted-foreground) / 0.8); color: hsl(var(--muted-foreground) / 0.8);
} }
/* Better button contrast */ /* Better button contrast */
button:not(:disabled):hover { button:not(:disabled):hover {
filter: brightness(1.05); filter: brightness(1.05);
} }
/* Improved focus visibility */ /* Improved focus visibility */
input:focus, textarea:focus, button:focus { input:focus,
textarea:focus,
button:focus {
outline: 2px solid hsl(var(--ring)); outline: 2px solid hsl(var(--ring));
outline-offset: 2px; outline-offset: 2px;
} }
/* Better message bubble contrast */ /* Better message bubble contrast */
.bg-primary { .bg-primary {
background-color: hsl(var(--primary) / 0.9); background-color: hsl(var(--primary) / 0.9);
} }
/* Chromebook-specific optimizations */ /* Chromebook-specific optimizations */
@media screen and (max-width: 1366px) and (min-resolution: 1.5dppx) { @media screen and (max-width: 1366px) and (min-resolution: 1.5dppx) {
/* Chromebook display optimizations */ /* Chromebook display optimizations */
.text-sm { .text-sm {
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.25rem; line-height: 1.25rem;
} }
.text-base { .text-base {
font-size: 1rem; font-size: 1rem;
line-height: 1.5rem; line-height: 1.5rem;
} }
/* Better touch targets for Chromebooks */ /* Better touch targets for Chromebooks */
button, input, textarea, [role="button"], [role="tab"] { button,
input,
textarea,
[role="button"],
[role="tab"] {
min-height: 48px; min-height: 48px;
min-width: 48px; min-width: 48px;
} }
/* Improved spacing for Chromebook screens */ /* Improved spacing for Chromebook screens */
.space-y-2 > * + * { .space-y-2>*+* {
margin-top: 0.75rem; margin-top: 0.75rem;
} }
.space-y-4 > * + * { .space-y-4>*+* {
margin-top: 1.25rem; margin-top: 1.25rem;
} }
} }
/* Chromebook touch screen optimizations */ /* Chromebook touch screen optimizations */
@media (pointer: coarse) and (hover: none) { @media (pointer: coarse) and (hover: none) {
/* Larger touch targets */ /* Larger touch targets */
.touch-target { .touch-target {
min-height: 52px; min-height: 52px;
min-width: 52px; min-width: 52px;
} }
/* Better spacing for touch interactions */ /* Better spacing for touch interactions */
.space-y-2 > * + * { .space-y-2>*+* {
margin-top: 1rem; margin-top: 1rem;
} }
/* Improved button padding */ /* Improved button padding */
button { button {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
} }
/* Better input field sizing */ /* Better input field sizing */
input, textarea { input,
textarea {
padding: 0.875rem; padding: 0.875rem;
font-size: 1rem; font-size: 1rem;
} }
/* Enhanced focus states for touch */ /* 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: 3px solid hsl(var(--ring));
outline-offset: 2px; outline-offset: 2px;
} }
} }
/* Chromebook keyboard navigation improvements */ /* Chromebook keyboard navigation improvements */
@media (hover: hover) and (pointer: fine) { @media (hover: hover) and (pointer: fine) {
/* Better hover states for mouse/trackpad */ /* Better hover states for mouse/trackpad */
button:hover:not(:disabled) { button:hover:not(:disabled) {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
} }
/* Improved focus indicators */ /* 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: 2px solid hsl(var(--ring));
outline-offset: 2px; outline-offset: 2px;
box-shadow: 0 0 0 4px hsl(var(--ring) / 0.2); box-shadow: 0 0 0 4px hsl(var(--ring) / 0.2);
} }
} }
/* Chromebook display scaling fixes */ /* Chromebook display scaling fixes */
@media screen and (min-resolution: 1.5dppx) { @media screen and (min-resolution: 1.5dppx) {
/* Prevent text from being too small on high-DPI displays */ /* Prevent text from being too small on high-DPI displays */
html { html {
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
text-size-adjust: 100%; text-size-adjust: 100%;
} }
/* Better font rendering */ /* Better font rendering */
body { body {
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
} }
/* Chromebook scrolling improvements */ /* Chromebook scrolling improvements */
.overflow-y-auto { .overflow-y-auto {
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
@@ -198,14 +217,14 @@ body {
/* Better momentum scrolling for Chromebooks */ /* Better momentum scrolling for Chromebooks */
overscroll-behavior: contain; overscroll-behavior: contain;
} }
/* Chromebook chat interface optimizations */ /* Chromebook chat interface optimizations */
.chat-message { .chat-message {
/* Better message bubble sizing for touch */ /* Better message bubble sizing for touch */
min-height: 44px; min-height: 44px;
padding: 0.75rem; padding: 0.75rem;
} }
/* Chromebook form optimizations */ /* Chromebook form optimizations */
.form-input { .form-input {
/* Better input field sizing for Chromebooks */ /* Better input field sizing for Chromebooks */
@@ -213,7 +232,7 @@ body {
font-size: 1rem; font-size: 1rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
} }
/* Chromebook button optimizations */ /* Chromebook button optimizations */
.btn-chromebook { .btn-chromebook {
min-height: 48px; min-height: 48px;
@@ -222,17 +241,17 @@ body {
font-size: 1rem; font-size: 1rem;
border-radius: 0.5rem; border-radius: 0.5rem;
} }
/* Enhanced keyboard focus indicators for Chromebooks */ /* Enhanced keyboard focus indicators for Chromebooks */
.keyboard-focus { .keyboard-focus {
outline: 3px solid hsl(var(--ring)); outline: 3px solid hsl(var(--ring));
outline-offset: 2px; outline-offset: 2px;
box-shadow: 0 0 0 4px hsl(var(--ring) / 0.3); box-shadow: 0 0 0 4px hsl(var(--ring) / 0.3);
} }
/* Better focus management for Chromebook keyboard navigation */ /* Better focus management for Chromebook keyboard navigation */
button:focus-visible, button:focus-visible,
input:focus-visible, input:focus-visible,
textarea:focus-visible, textarea:focus-visible,
[role="button"]:focus-visible, [role="button"]:focus-visible,
[role="tab"]:focus-visible { [role="tab"]:focus-visible {
@@ -240,22 +259,31 @@ body {
outline-offset: 2px; outline-offset: 2px;
box-shadow: 0 0 0 4px hsl(var(--ring) / 0.3); box-shadow: 0 0 0 4px hsl(var(--ring) / 0.3);
} }
/* Chromebook-specific focus ring */ /* Chromebook-specific focus ring */
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
.keyboard-focus { .keyboard-focus {
transition: outline 0.2s ease, box-shadow 0.2s ease; transition: outline 0.2s ease, box-shadow 0.2s ease;
} }
} }
.bg-muted { .bg-muted {
background-color: hsl(var(--muted) / 0.8); background-color: hsl(var(--muted) / 0.8);
} }
/* Christmas-themed animations */ /* Christmas-themed animations */
@keyframes twinkle { @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 { @keyframes snowflake {
@@ -263,6 +291,7 @@ body {
transform: translateY(-100vh) rotate(0deg); transform: translateY(-100vh) rotate(0deg);
opacity: 1; opacity: 1;
} }
100% { 100% {
transform: translateY(100vh) rotate(360deg); transform: translateY(100vh) rotate(360deg);
opacity: 0; opacity: 0;
@@ -270,20 +299,26 @@ body {
} }
@keyframes sparkle { @keyframes sparkle {
0%, 100% {
0%,
100% {
opacity: 0; opacity: 0;
transform: scale(0) rotate(0deg); transform: scale(0) rotate(0deg);
} }
50% {
50% {
opacity: 1; opacity: 1;
transform: scale(1) rotate(180deg); transform: scale(1) rotate(180deg);
} }
} }
@keyframes glow { @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)); box-shadow: 0 0 5px hsl(var(--christmas-red)), 0 0 10px hsl(var(--christmas-red)), 0 0 15px hsl(var(--christmas-red));
} }
50% { 50% {
box-shadow: 0 0 10px hsl(var(--christmas-green)), 0 0 20px hsl(var(--christmas-green)), 0 0 30px hsl(var(--christmas-green)); box-shadow: 0 0 10px hsl(var(--christmas-green)), 0 0 20px hsl(var(--christmas-green)), 0 0 30px hsl(var(--christmas-green));
} }
@@ -312,19 +347,19 @@ body {
/* Subtle Christmas gradient backgrounds */ /* Subtle Christmas gradient backgrounds */
.christmas-gradient { .christmas-gradient {
background: linear-gradient(135deg, background: linear-gradient(135deg,
hsl(var(--christmas-red) / 0.1) 0%, hsl(var(--christmas-red) / 0.1) 0%,
hsl(var(--christmas-green) / 0.1) 50%, hsl(var(--christmas-green) / 0.1) 50%,
hsl(var(--christmas-gold) / 0.1) 100%); hsl(var(--christmas-gold) / 0.1) 100%);
} }
/* Christmas-themed borders */ /* Christmas-themed borders */
.christmas-border { .christmas-border {
border: 2px solid; border: 2px solid;
border-image: linear-gradient(45deg, border-image: linear-gradient(45deg,
hsl(var(--christmas-red)), hsl(var(--christmas-red)),
hsl(var(--christmas-green)), hsl(var(--christmas-green)),
hsl(var(--christmas-gold))) 1; hsl(var(--christmas-gold))) 1;
} }
/* Christmas-themed styles - only active in December */ /* Christmas-themed styles - only active in December */
@@ -342,7 +377,7 @@ body {
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
background-image: background-image:
radial-gradient(circle at 20% 50%, hsl(var(--christmas-red) / 0.03) 0%, transparent 50%), radial-gradient(circle at 20% 50%, hsl(var(--christmas-red) / 0.03) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, hsl(var(--christmas-green) / 0.03) 0%, transparent 50%), radial-gradient(circle at 80% 80%, hsl(var(--christmas-green) / 0.03) 0%, transparent 50%),
radial-gradient(circle at 40% 20%, hsl(var(--christmas-gold) / 0.03) 0%, transparent 50%); radial-gradient(circle at 40% 20%, hsl(var(--christmas-gold) / 0.03) 0%, transparent 50%);
@@ -360,9 +395,9 @@ body {
/* Using more specific selector to avoid Turbopack CSS parsing issues */ /* Using more specific selector to avoid Turbopack CSS parsing issues */
.christmas-theme button[class*="bg-primary"]:hover, .christmas-theme button[class*="bg-primary"]:hover,
.christmas-theme [class*="bg-primary"]:hover { .christmas-theme [class*="bg-primary"]:hover {
background: linear-gradient(135deg, background: linear-gradient(135deg,
hsl(var(--christmas-red)), hsl(var(--christmas-red)),
hsl(var(--christmas-green))); hsl(var(--christmas-green)));
transition: background 0.3s ease; transition: background 0.3s ease;
} }
@@ -376,6 +411,42 @@ body {
.christmas-theme *:focus-visible { .christmas-theme *:focus-visible {
outline-color: hsl(var(--christmas-red)); 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 { @layer base {
@@ -410,26 +481,27 @@ body {
--christmas-green: 142 76% 36%; --christmas-green: 142 76% 36%;
--christmas-gold: 43 96% 56%; --christmas-gold: 43 96% 56%;
} }
.dark { .dark {
--background: 0 0% 3.9%; --background: 240 10% 2%;
--foreground: 0 0% 98%; --foreground: 0 0% 98%;
--card: 0 0% 3.9%; --card: 240 10% 3%;
--card-foreground: 0 0% 98%; --card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%; --popover: 240 10% 2%;
--popover-foreground: 0 0% 98%; --popover-foreground: 0 0% 98%;
--primary: 0 0% 98%; --primary: 0 0% 98%;
--primary-foreground: 0 0% 9%; --primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%; --secondary: 240 4% 10%;
--secondary-foreground: 0 0% 98%; --secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%; --muted: 240 4% 10%;
--muted-foreground: 0 0% 63.9%; --muted-foreground: 240 5% 64.9%;
--accent: 0 0% 14.9%; --accent: 240 4% 10%;
--accent-foreground: 0 0% 98%; --accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%; --destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%; --destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%; --border: 240 4% 12%;
--input: 0 0% 14.9%; --input: 240 4% 12%;
--ring: 0 0% 83.1%; --ring: 240 5% 83.1%;
--chart-1: 220 70% 50%; --chart-1: 220 70% 50%;
--chart-2: 160 60% 45%; --chart-2: 160 60% 45%;
--chart-3: 30 80% 55%; --chart-3: 30 80% 55%;
@@ -464,7 +536,8 @@ body {
* { * {
@apply border-border; @apply border-border;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }

View File

@@ -1,79 +1,43 @@
import { getPlatformStatsServer } from "@/lib/server-api"; import { getPlatformStatsServer } from "@/lib/server-api";
import { HomeNavbar } from "@/components/home-navbar"; import { HomeNavbar } from "@/components/home-navbar";
import { Suspense } from "react"; import { Shield, LineChart, Zap, ArrowRight, Sparkles } from "lucide-react";
import { Shield, LineChart, Zap, ArrowRight, CheckCircle2, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import Link from "next/link"; import Link from "next/link";
import { AnimatedStatsSection } from "@/components/animated-stats-section"; 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'; 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() { export default async function Home() {
try { try {
const stats = await getPlatformStatsServer(); const stats = await getPlatformStatsServer();
const isDec = isDecember();
return ( return (
<div className={`flex flex-col min-h-screen bg-black text-white ${isDec ? 'christmas-theme' : ''}`}> <div className="relative flex flex-col min-h-screen bg-black text-white">
<div className={`absolute inset-0 bg-gradient-to-br pointer-events-none scale-100 ${ <div className="absolute inset-0 bg-gradient-to-br from-indigo-500/10 via-purple-500/5 to-transparent pointer-events-none scale-100" />
isDec
? 'from-red-500/10 via-green-500/5 to-transparent'
: 'from-[#D53F8C]/10 via-[#D53F8C]/3 to-transparent'
}`} />
<HomeNavbar /> <HomeNavbar />
{/* Hero Section */} {/* Hero Section */}
<section className="relative overflow-hidden"> <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="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="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 ${ <div className="inline-flex items-center px-4 py-2 rounded-full border border-indigo-500/20 bg-indigo-500/10 mb-4">
isDec <Sparkles className="h-4 w-4 mr-2 text-indigo-400" />
? 'bg-red-500/10 border-red-500/20' <span className="text-sm font-medium text-indigo-400">
: '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]'}`}>
Secure Crypto Payments Secure Crypto Payments
</span> </span>
</div> </div>
<h1 className="text-4xl md:text-6xl font-bold tracking-tight"> <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> </h1>
<p className="text-lg md:text-xl text-zinc-400 max-w-2xl"> <p className="text-lg md:text-xl text-zinc-400 max-w-2xl">
{isDec Streamline your online business with our all-in-one platform. Secure payments, order tracking, and analytics in one place.
? '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.'
}
</p> </p>
<div className="flex flex-col sm:flex-row gap-4 mt-4"> <div className="flex flex-col sm:flex-row gap-4 mt-4">
<Link href="/dashboard"> <Link href="/dashboard">
<Button <Button
size="lg" size="lg"
className={`gap-2 text-white border-0 h-12 px-8 ${ className="gap-2 text-white border-0 h-12 px-8 bg-indigo-600 hover:bg-indigo-700"
isDec
? 'bg-gradient-to-r from-red-500 to-green-500 hover:from-red-600 hover:to-green-600'
: 'bg-[#D53F8C] hover:bg-[#B83280]'
}`}
> >
Get Started Get Started
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
@@ -87,56 +51,41 @@ export default async function Home() {
{/* Features Grid */} {/* Features Grid */}
<section className="relative py-20 px-4"> <section className="relative py-20 px-4">
<div className="max-w-7xl mx-auto space-y-20"> <div className="max-w-7xl mx-auto space-y-20">
<div className="grid md:grid-cols-3 gap-6"> <MotionWrapper>
{[ <div className="grid md:grid-cols-3 gap-6">
{ {[
icon: Shield, {
title: "Secure Payments", icon: Shield,
description: "Built-in cryptocurrency support with maximum privacy and security for your transactions." title: "Secure Payments",
}, description: "Built-in cryptocurrency support with maximum privacy and security for your transactions."
{ },
icon: LineChart, {
title: "Real-time Analytics", icon: LineChart,
description: "Track your business performance with detailed insights and reporting tools." title: "Real-time Analytics",
}, description: "Track your business performance with detailed insights and reporting tools."
{ },
icon: Zap, {
title: "Lightning Fast", icon: Zap,
description: "Optimized for speed with real-time updates and instant notifications." 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']; ].map((feature, i) => (
const christmasBorders = ['border-red-500/30', 'border-green-500/30', 'border-yellow-500/30']; <div
const christmasIcons = ['text-red-400', 'text-green-400', 'text-yellow-400']; key={i}
const christmasBgs = ['bg-red-500/10', 'bg-green-500/10', 'bg-yellow-500/10']; 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"
return (
<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'
}`}
> >
<div <div className="absolute inset-0 bg-gradient-to-b from-indigo-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
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="relative"> <div className="relative">
<div className={`h-12 w-12 flex items-center justify-center rounded-lg mb-4 ${ <div className="h-12 w-12 flex items-center justify-center rounded-lg mb-4 bg-indigo-500/10">
isDec ? christmasBgs[i % 3] : 'bg-[#D53F8C]/10' <feature.icon className="h-6 w-6 text-indigo-500" />
}`}>
<feature.icon className={`h-6 w-6 ${isDec ? christmasIcons[i % 3] : 'text-[#D53F8C]'}`} />
</div> </div>
<h3 className="text-lg font-semibold text-white mb-2">{feature.title}</h3> <h3 className="text-lg font-semibold text-white mb-2">{feature.title}</h3>
<p className="text-sm text-zinc-400">{feature.description}</p> <p className="text-sm text-zinc-400">{feature.description}</p>
</div> </div>
</div> </div>
); ))}
})} </div>
</div> </MotionWrapper>
{/* Stats Section */} {/* Stats Section */}
<div className="relative"> <div className="relative">
@@ -148,13 +97,6 @@ export default async function Home() {
{/* Footer */} {/* Footer */}
<footer className="relative py-12 px-4 mt-auto"> <footer className="relative py-12 px-4 mt-auto">
<div className="max-w-7xl mx-auto flex flex-col items-center"> <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"> <div className="text-sm text-zinc-500">
© {new Date().getFullYear()} Ember. All rights reserved. © {new Date().getFullYear()} Ember. All rights reserved.
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,47 +1,104 @@
"use client";
import { useState } from "react"; import { useState } from "react";
import { fetchClient } from "@/lib/api-client"; 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() { export default function InviteVendorCard() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [code, setCode] = useState<string | null>(null); const [code, setCode] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const { toast } = useToast();
async function handleInvite() { async function handleInvite() {
setLoading(true); setLoading(true);
setMessage(null);
setCode(null); setCode(null);
setCopied(false);
try { try {
const res = await fetchClient<{ code: string }>("/admin/invitations", { method: "POST" }); const res = await fetchClient<{ code: string }>("/admin/invitations", { method: "POST" });
setMessage("Invitation created");
setCode(res.code); setCode(res.code);
toast({
title: "Invitation Created",
description: "New vendor invitation code generated successfully.",
});
} catch (e: any) { } catch (e: any) {
setMessage(e?.message || "Failed to send invitation"); toast({
title: "Error",
description: e?.message || "Failed to generate invitation",
variant: "destructive",
});
} finally { } finally {
setLoading(false); 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 ( return (
<div className="rounded-lg border border-border/60 bg-background p-4 h-full min-h-[200px]"> <Card className="h-full border-border/40 bg-background/50 backdrop-blur-sm shadow-sm flex flex-col">
<h2 className="font-medium">Invite Vendor</h2> <CardHeader className="pb-3">
<p className="text-sm text-muted-foreground mt-1">Generate a new invitation code</p> <div className="flex items-center justify-between">
<div className="mt-4 space-y-3"> <CardTitle className="text-base font-medium flex items-center gap-2">
<button <Ticket className="h-4 w-4 text-primary" />
onClick={handleInvite} Invite Vendor
className="inline-flex items-center rounded-md bg-primary px-3 py-2 text-sm text-primary-foreground disabled:opacity-60" </CardTitle>
disabled={loading} </div>
> <CardDescription>Generate a one-time invitation code.</CardDescription>
{loading ? "Generating..." : "Generate Invite Code"} </CardHeader>
</button> <CardContent className="flex-1 flex flex-col justify-center gap-4">
{message && <p className="text-xs text-muted-foreground">{message}</p>} {code ? (
{code && ( <div className="space-y-3 animate-in fade-in zoom-in-95 duration-300">
<div className="text-sm"> <div className="p-3 rounded-md bg-muted/50 border border-border/50 text-center relative group">
Code: <span className="font-mono px-1.5 py-0.5 rounded bg-muted">{code}</span> <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}
>
{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>
</div> <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>
); );
} }

View File

@@ -7,7 +7,7 @@ import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Search, Filter, Eye, ChevronLeft, ChevronRight } from "lucide-react"; 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"; import OrderDetailsModal from "./OrderDetailsModal";
interface Order { interface Order {
@@ -215,7 +215,52 @@ export default function OrdersTable({ orders, enableModal = true }: OrdersTableP
itemSize={60} itemSize={60}
className="border" 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> </List>
) : ( ) : (
<TableBody> <TableBody>

View File

@@ -46,8 +46,8 @@ import { DateRangePicker } from "@/components/ui/date-picker";
import { DateRange } from "react-day-picker"; import { DateRange } from "react-day-picker";
import { addDays, startOfDay, endOfDay } from "date-fns"; import { addDays, startOfDay, endOfDay } from "date-fns";
import type { DateRange as ProfitDateRange } from "@/lib/services/profit-analytics-service"; import type { DateRange as ProfitDateRange } from "@/lib/services/profit-analytics-service";
import { MotionWrapper } from "@/components/ui/motion-wrapper";
// Lazy load chart components - already handled individually below import { motion } from "framer-motion";
const RevenueChart = dynamic(() => import("./RevenueChart"), { const RevenueChart = dynamic(() => import("./RevenueChart"), {
loading: () => <ChartSkeleton />, loading: () => <ChartSkeleton />,
@@ -195,243 +195,259 @@ export default function AnalyticsDashboard({
]; ];
return ( return (
<div className="space-y-6"> <div className="space-y-10 pb-20">
{/* Header with Privacy Toggle */} {/* Header with Integrated Toolbar */}
<div className="flex items-center justify-between"> <div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div> <div>
<h2 className="text-3xl font-bold tracking-tight"> <h2 className="text-3xl font-bold tracking-tight">
Analytics Dashboard Analytics Dashboard
</h2> </h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground mt-1">
Overview of your store's performance and metrics. Real-time performance metrics and AI-driven insights.
</p> </p>
</div> </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 <Button
variant="outline" variant="ghost"
size="sm" size="sm"
onClick={() => setHideNumbers(!hideNumbers)} 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 ? ( {hideNumbers ? (
<> <>
<EyeOff className="h-4 w-4" /> <EyeOff className="h-4 w-4" />
Show Numbers <span className="hidden sm:inline">Numbers Hidden</span>
</> </>
) : ( ) : (
<> <>
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4 text-primary/70" />
Hide Numbers <span className="hidden sm:inline">Hide Numbers</span>
</> </>
)} )}
</Button> </Button>
<div className="w-px h-5 bg-white/10 mx-1" />
<Button <Button
variant="outline" variant="ghost"
size="sm" size="sm"
onClick={refreshData} onClick={refreshData}
disabled={isLoading} 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 <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> </Button>
</div> </div>
</div> </div>
{/* Key Metrics Cards */} <MotionWrapper className="space-y-12">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 lg:gap-6"> {/* Analytics Tabs Setup */}
{isLoading <Tabs defaultValue="overview" className="space-y-10">
? [...Array(4)].map((_, i) => <MetricsCardSkeleton key={i} />) <div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-6 pb-2">
: metrics.map((metric) => ( <TabsList className="bg-transparent h-auto p-0 flex flex-wrap gap-2 lg:gap-4">
<MetricsCard key={metric.title} {...metric} /> <TabsTrigger
))} value="overview"
</div> 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>
{/* Completion Rate Card */} {/* Contextual Time Range Selector */}
<Card> <div className="flex items-center gap-3 bg-muted/30 p-1 rounded-xl border border-border/20">
<CardHeader> <span className="text-xs font-semibold text-muted-foreground px-2">Range</span>
<CardTitle className="flex items-center gap-2"> <Select value={timeRange} onValueChange={setTimeRange}>
<Activity className="h-5 w-5" /> <SelectTrigger className="w-[130px] h-8 border-none bg-transparent shadow-none focus:ring-0">
Order Completion Rate <SelectValue />
</CardTitle> </SelectTrigger>
<CardDescription> <SelectContent className="rounded-xl border-border/40">
Percentage of orders that have been successfully completed <SelectItem value="7">Last 7 days</SelectItem>
</CardDescription> <SelectItem value="30">Last 30 days</SelectItem>
</CardHeader> <SelectItem value="90">Last 90 days</SelectItem>
<CardContent> <SelectItem value="180">Last 180 days</SelectItem>
{isLoading ? ( </SelectContent>
<div className="flex items-center gap-4"> </Select>
<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> </div>
) : ( </div>
<div className="flex items-center gap-4">
<div className="text-3xl font-bold"> <TabsContent value="overview" className="space-y-10 outline-none">
{hideNumbers ? "**%" : `${data.orders.completionRate}%`} <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-4 gap-6">
{isLoading
? [...Array(4)].map((_, i) => <MetricsCardSkeleton key={i} />)
: metrics.map((metric) => (
<MetricsCard key={metric.title} {...metric} />
))}
</div> </div>
<div className="flex-1">
<div className="w-full bg-secondary rounded-full h-2"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div {/* Completion Rate Card */}
className="bg-primary h-2 rounded-full transition-all duration-300" <Card className="lg:col-span-1 glass-morphism premium-card">
style={{ <CardHeader>
width: hideNumbers <CardTitle className="flex items-center gap-2 text-lg">
? "0%" <Activity className="h-5 w-5 text-emerald-500" />
: `${data.orders.completionRate}%`, Order Completion
}} </CardTitle>
/> <CardDescription>
Successfully processed orders
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-24 w-full rounded-2xl" />
) : (
<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>
<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>
{/* Growth Chart Snippet (Simplified) */}
<div className="lg:col-span-2 min-w-0">
<Suspense fallback={<ChartSkeleton />}>
<GrowthAnalyticsChart hideNumbers={hideNumbers} />
</Suspense>
</div> </div>
</div> </div>
<Badge variant="secondary"> </motion.div>
{hideNumbers
? "** / **"
: `${data.orders.completed} / ${data.orders.total}`}
</Badge>
</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">
<Suspense fallback={<ChartSkeleton />}>
<GrowthAnalyticsChart hideNumbers={hideNumbers} />
</Suspense>
</TabsContent> </TabsContent>
<TabsContent value="revenue" className="space-y-6"> <TabsContent value="financials" className="space-y-8 outline-none">
<Suspense fallback={<ChartSkeleton />}> <motion.div
<RevenueChart timeRange={timeRange} hideNumbers={hideNumbers} /> initial={{ opacity: 0, y: 20 }}
</Suspense> animate={{ opacity: 1, y: 0 }}
</TabsContent> transition={{ duration: 0.4 }}
className="grid grid-cols-1 xl:grid-cols-2 gap-8"
<TabsContent value="profit" className="space-y-6"> >
{/* Date Range Selector for Profit Calculator */} <Suspense fallback={<ChartSkeleton />}>
<Card> <div className="min-w-0">
<CardHeader> <RevenueChart timeRange={timeRange} hideNumbers={hideNumbers} />
<CardTitle className="text-lg">Date Range</CardTitle>
<CardDescription>
Select a custom date range for profit calculations
</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"
/>
{profitDateRange?.from && profitDateRange?.to && (
<div className="text-sm text-muted-foreground flex items-center">
<span>
{profitDateRange.from.toLocaleDateString()} -{" "}
{profitDateRange.to.toLocaleDateString()}
</span>
</div>
)}
</div> </div>
</CardContent> </Suspense>
</Card>
<Suspense fallback={<ChartSkeleton />}> <div className="space-y-8">
<ProfitAnalyticsChart <Card className="glass-morphism">
dateRange={ <CardHeader>
profitDateRange?.from && profitDateRange?.to <CardTitle className="text-lg">Profit Range</CardTitle>
? { <CardDescription>
from: profitDateRange.from, Custom date selection for analysis
to: profitDateRange.to, </CardDescription>
} </CardHeader>
: undefined <CardContent>
} <DateRangePicker
hideNumbers={hideNumbers} dateRange={profitDateRange}
/> onDateRangeChange={setProfitDateRange}
</Suspense> placeholder="Select date range"
showPresets={true}
className="w-full"
/>
</CardContent>
</Card>
<Suspense fallback={<ChartSkeleton />}>
<ProfitAnalyticsChart
dateRange={
profitDateRange?.from && profitDateRange?.to
? {
from: profitDateRange.from,
to: profitDateRange.to,
}
: undefined
}
hideNumbers={hideNumbers}
/>
</Suspense>
</div>
</motion.div>
</TabsContent> </TabsContent>
<TabsContent value="products" className="space-y-6"> <TabsContent value="performance" className="space-y-8 outline-none">
<Suspense fallback={<ChartSkeleton />}> <motion.div
<ProductPerformanceChart /> initial={{ opacity: 0, y: 20 }}
</Suspense> 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>
<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>
<TabsContent value="customers" className="space-y-6"> <TabsContent value="ai" className="space-y-8 outline-none">
<Suspense fallback={<ChartSkeleton />}> <motion.div
<CustomerInsightsChart /> initial={{ opacity: 0, y: 20 }}
</Suspense> animate={{ opacity: 1, y: 0 }}
</TabsContent> transition={{ duration: 0.4 }}
className="min-w-0"
<TabsContent value="orders" className="space-y-6"> >
<Suspense fallback={<ChartSkeleton />}> <Suspense fallback={<ChartSkeleton />}>
<OrderAnalyticsChart timeRange={timeRange} /> <PredictionsChart timeRange={parseInt(timeRange)} />
</Suspense> </Suspense>
</TabsContent> </motion.div>
<TabsContent value="predictions" className="space-y-6">
<Suspense fallback={<ChartSkeleton />}>
<PredictionsChart timeRange={parseInt(timeRange)} />
</Suspense>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </MotionWrapper>
</div> </div>
); );
} }

View File

@@ -24,6 +24,7 @@ import {
CartesianGrid, CartesianGrid,
Tooltip, Tooltip,
ResponsiveContainer, ResponsiveContainer,
Area,
} from "recharts"; } from "recharts";
interface GrowthAnalyticsChartProps { interface GrowthAnalyticsChartProps {
@@ -182,7 +183,7 @@ export default function GrowthAnalyticsChart({
</div> </div>
) : growthData?.monthly && growthData.monthly.length > 0 ? ( ) : growthData?.monthly && growthData.monthly.length > 0 ? (
<div className="h-80"> <div className="h-80">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer key={growthData?.monthly?.length || 0} width="100%" height="100%">
<ComposedChart <ComposedChart
data={growthData.monthly.map((m) => ({ data={growthData.monthly.map((m) => ({
...m, ...m,
@@ -195,18 +196,36 @@ export default function GrowthAnalyticsChart({
}))} }))}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
> >
<CartesianGrid strokeDasharray="3 3" /> <defs>
<XAxis dataKey="formattedMonth" tick={{ fontSize: 12 }} /> <linearGradient id="colorRevenueGrowth" x1="0" y1="0" x2="0" y2="1">
<YAxis yAxisId="left" tick={{ fontSize: 12 }} /> <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 <YAxis
yAxisId="right" yAxisId="right"
orientation="right" orientation="right"
tick={{ fontSize: 12 }} tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
axisLine={false}
tickLine={false}
tickFormatter={(value) => tickFormatter={(value) =>
hideNumbers ? "***" : `£${(value / 1000).toFixed(0)}k` hideNumbers ? "***" : `£${(value / 1000).toFixed(0)}k`
} }
/> />
<Tooltip <Tooltip
cursor={{ fill: "transparent", stroke: "hsl(var(--muted-foreground))", strokeDasharray: "3 3" }}
content={({ active, payload }) => { content={({ active, payload }) => {
if (active && payload?.length) { if (active && payload?.length) {
const data = payload[0].payload; const data = payload[0].payload;
@@ -240,21 +259,27 @@ export default function GrowthAnalyticsChart({
return null; return null;
}} }}
/> />
<Bar <Area
yAxisId="left" yAxisId="left"
type="monotone"
dataKey="orders" dataKey="orders"
fill="#3b82f6" stroke="#3b82f6"
radius={[4, 4, 0, 0]} strokeWidth={2}
dot={false}
activeDot={{ r: 4, strokeWidth: 0 }}
name="Orders" name="Orders"
fill="url(#colorOrdersGrowth)"
/> />
<Line <Area
yAxisId="right" yAxisId="right"
type="monotone" type="monotone"
dataKey="revenue" dataKey="revenue"
stroke="#10b981" stroke="#10b981"
strokeWidth={3} strokeWidth={3}
dot={{ fill: "#10b981", r: 4 }} dot={false}
activeDot={{ r: 5, strokeWidth: 0 }}
name="Revenue" name="Revenue"
fill="url(#colorRevenueGrowth)"
/> />
</ComposedChart> </ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>

View File

@@ -3,6 +3,7 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { TrendingUp, TrendingDown, Minus } from "lucide-react"; import { TrendingUp, TrendingDown, Minus } from "lucide-react";
import { LucideIcon } from "lucide-react"; import { LucideIcon } from "lucide-react";
import { motion } from "framer-motion";
interface MetricsCardProps { interface MetricsCardProps {
title: string; title: string;
@@ -13,54 +14,102 @@ interface MetricsCardProps {
trendValue: string; trendValue: string;
} }
export default function MetricsCard({ export default function MetricsCard({
title, title,
value, value,
description, description,
icon: Icon, icon: Icon,
trend, trend,
trendValue trendValue
}: MetricsCardProps) { }: MetricsCardProps) {
const getTrendIcon = () => { const getTrendIcon = () => {
switch (trend) { switch (trend) {
case "up": case "up":
return <TrendingUp className="h-4 w-4 text-green-500" />; return <TrendingUp className="h-4 w-4 text-emerald-500" />;
case "down": case "down":
return <TrendingDown className="h-4 w-4 text-red-500" />; return <TrendingDown className="h-4 w-4 text-rose-500" />;
default: default:
return <Minus className="h-4 w-4 text-gray-500" />; return <Minus className="h-4 w-4 text-muted-foreground" />;
} }
}; };
const getTrendColor = () => { const getTrendColor = () => {
switch (trend) { switch (trend) {
case "up": case "up":
return "text-green-600"; return "text-emerald-400 bg-emerald-500/10 border-emerald-500/10";
case "down": case "down":
return "text-red-600"; return "text-rose-400 bg-rose-500/10 border-rose-500/10";
default: 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 ( return (
<Card> <motion.div
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> whileHover={{ y: -4 }}
<CardTitle className="text-sm font-medium text-muted-foreground"> transition={{ type: "spring", stiffness: 300, damping: 20 }}
{title} >
</CardTitle> <Card className="glass-morphism premium-card relative overflow-hidden group border-white/5">
<Icon className="h-4 w-4 text-muted-foreground" /> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4 relative z-10">
</CardHeader> <CardTitle className="text-[10px] font-bold tracking-wider text-white/40 uppercase">
<CardContent> {title}
<div className="text-2xl font-bold">{value}</div> </CardTitle>
<p className="text-xs text-muted-foreground mt-1">{description}</p> <div className={`p-2.5 rounded-2xl border ${getIconContainerColor()} transition-all duration-300 group-hover:scale-105`}>
<div className="flex items-center gap-1 mt-2"> <Icon className="h-4 w-4" />
{getTrendIcon()} </div>
<span className={`text-xs ${getTrendColor()}`}> </CardHeader>
{trendValue}
</span> <CardContent className="relative z-10">
</div> <div className="space-y-1.5">
</CardContent> <div className="text-3xl font-bold text-white">
</Card> {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-[10px] font-bold uppercase tracking-wide">
{trend === "up" ? "+" : ""}{trendValue}
</span>
</div>
</div>
</CardContent>
</Card>
</motion.div>
); );
} }

View File

@@ -26,10 +26,15 @@ import {
RefreshCw, RefreshCw,
Calendar, Calendar,
BarChart3, BarChart3,
Sparkles, Brain,
Layers,
Zap,
Info,
Download,
} from "lucide-react"; } from "lucide-react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import CountUp from "react-countup";
import { import {
getPredictionsOverviewWithStore, getPredictionsOverviewWithStore,
getStockPredictionsWithStore, getStockPredictionsWithStore,
@@ -46,6 +51,23 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { format } from "date-fns"; 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 { interface PredictionsChartProps {
timeRange?: number; timeRange?: number;
@@ -57,21 +79,52 @@ export default function PredictionsChart({
const [predictions, setPredictions] = useState<PredictionsOverview | null>( const [predictions, setPredictions] = useState<PredictionsOverview | null>(
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] = const [stockPredictions, setStockPredictions] =
useState<StockPredictionsResponse | null>(null); useState<StockPredictionsResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isSimulating, setIsSimulating] = useState(false);
const [daysAhead, setDaysAhead] = useState(7); const [daysAhead, setDaysAhead] = useState(7);
const [activeTab, setActiveTab] = useState<"overview" | "stock">("overview"); const [activeTab, setActiveTab] = useState<"overview" | "stock">("overview");
const [simulationFactor, setSimulationFactor] = useState(0);
const [committedSimulationFactor, setCommittedSimulationFactor] = useState(0);
const { toast } = useToast(); const { toast } = useToast();
const fetchPredictions = async () => { // Fetch all predictions in batch (for instant client-side switching)
const fetchBatchData = async () => {
try { try {
setLoading(true); setLoading(true);
const [overview, stock] = await Promise.all([ const { getBatchPredictionsWithStore } = await import("@/lib/services/analytics-service");
getPredictionsOverviewWithStore(daysAhead, timeRange), const [batchResponse, stock] = await Promise.all([
getBatchPredictionsWithStore(timeRange),
getStockPredictionsWithStore(timeRange), getStockPredictionsWithStore(timeRange),
]); ]);
setPredictions(overview);
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); setStockPredictions(stock);
} catch (error) { } catch (error) {
console.error("Error fetching predictions:", 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(() => { useEffect(() => {
fetchPredictions(); fetchBatchData();
}, [daysAhead, timeRange]); 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) => { const getConfidenceColor = (confidence: string) => {
switch (confidence) { 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) { if (loading) {
return ( return (
<Card> <Card>
@@ -163,12 +312,9 @@ export default function PredictionsChart({
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5" /> <BarChart3 className="h-5 w-5" />
Predictions & Forecasting Predictions & Forecasting
{predictions.sales.aiModel?.used && (
<Sparkles className="h-4 w-4 text-purple-500" />
)}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{predictions.sales.aiModel?.used {predictions?.sales?.aiModel?.used
? "AI neural network + statistical models for sales, demand, and inventory" ? "AI neural network + statistical models for sales, demand, and inventory"
: "AI-powered predictions for sales, demand, and inventory"} : "AI-powered predictions for sales, demand, and inventory"}
</CardDescription> </CardDescription>
@@ -183,18 +329,31 @@ export default function PredictionsChart({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="7">7 days</SelectItem> <SelectItem value="7">7 days</SelectItem>
<SelectItem value="14">14 days</SelectItem> <SelectItem value="14" disabled={timeRange < 14}>
<SelectItem value="30">30 days</SelectItem> 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> </SelectContent>
</Select> </Select>
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
onClick={fetchPredictions} onClick={fetchBatchData}
disabled={loading} disabled={loading || isSimulating}
> >
<RefreshCw <RefreshCw
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} className={`h-4 w-4 ${loading || isSimulating ? "animate-spin" : ""}`}
/> />
</Button> </Button>
</div> </div>
@@ -222,205 +381,417 @@ export default function PredictionsChart({
<div className="space-y-6"> <div className="space-y-6">
{/* Sales Predictions */} {/* Sales Predictions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card> <TooltipProvider delayDuration={0}>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<DollarSign className="h-4 w-4" />
Revenue Prediction
</CardTitle>
</CardHeader>
<CardContent>
{predictions.sales.predicted !== null ? (
<div className="space-y-2">
<div className="text-2xl font-bold">
{formatGBP(predictions.sales.predicted)}
</div>
<div className="flex items-center gap-2 flex-wrap">
<Badge
className={getConfidenceColor(
predictions.sales.confidence,
)}
>
{getConfidenceLabel(predictions.sales.confidence)} Confidence
{predictions.sales.confidenceScore !== undefined && (
<span className="ml-1 opacity-75">
({Math.round(predictions.sales.confidenceScore * 100)}%)
</span>
)}
</Badge>
{predictions.sales.aiModel?.used && (
<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 && (
<span className="ml-1 opacity-75">
({Math.round(predictions.sales.aiModel.modelAccuracy * 100)}%)
</span>
)}
</Badge>
)}
{predictions.sales.trend && (
<Badge
variant="outline"
className={
predictions.sales.trend.direction === "up"
? "text-green-600 border-green-600"
: predictions.sales.trend.direction === "down"
? "text-red-600 border-red-600"
: ""
}
>
{predictions.sales.trend.direction === "up" && (
<TrendingUp className="h-3 w-3 mr-1" />
)}
{predictions.sales.trend.direction === "down" && (
<TrendingDown className="h-3 w-3 mr-1" />
)}
{predictions.sales.trend.direction === "up"
? "Trending Up"
: predictions.sales.trend.direction === "down"
? "Trending Down"
: "Stable"}
</Badge>
)}
<span className="text-xs text-muted-foreground">
Next {daysAhead} days
</span>
</div>
{predictions.sales.predictedOrders && (
<div className="text-sm text-muted-foreground">
~{Math.round(predictions.sales.predictedOrders)}{" "}
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 && (
<div className="text-xs text-muted-foreground">
Range: {formatGBP(predictions.sales.minPrediction)} -{" "}
{formatGBP(predictions.sales.maxPrediction)}
</div>
)}
</div>
) : (
<div className="text-sm text-muted-foreground">
{predictions.sales.message ||
"Insufficient data for prediction"}
</div>
)}
</CardContent>
</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
</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>
<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
</div>
)}
{predictions.demand.predictedMonthly && (
<div className="text-sm text-muted-foreground">
~{predictions.demand.predictedMonthly.toFixed(0)} units/month
</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>
)}
</div>
) : (
<div className="text-sm text-muted-foreground">
{predictions.demand.message ||
"Insufficient data for prediction"}
</div>
)}
</CardContent>
</Card>
</div>
{/* Daily Predictions Chart */}
{predictions.sales.dailyPredictions &&
predictions.sales.dailyPredictions.length > 0 && (
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-sm font-medium"> <CardTitle className="text-sm font-medium flex items-center gap-2">
Daily Revenue Forecast <DollarSign className="h-4 w-4" />
Revenue Prediction
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-2"> {predictions?.sales?.predicted !== null && predictions?.sales?.predicted !== undefined ? (
{predictions.sales.dailyPredictions.map((day) => ( <div className="space-y-2">
<div <Tooltip>
key={day.day} <TooltipTrigger asChild>
className="flex items-center justify-between p-2 rounded-lg border" <div className="text-2xl font-bold w-fit cursor-help">
> <CountUp
<div className="flex items-center gap-3"> end={predictions?.sales?.predicted || 0}
<Calendar className="h-4 w-4 text-muted-foreground" /> duration={1.5}
<div> separator=","
<div className="text-sm font-medium"> decimals={2}
Day {day.day} prefix="£"
</div> />
<div className="text-xs text-muted-foreground">
{format(new Date(day.date), "MMM d, yyyy")}
</div>
</div> </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 || "low",
)}
>
{getConfidenceLabel(predictions?.sales?.confidence || "low")} Confidence
{predictions?.sales?.confidenceScore !== undefined && (
<span className="ml-1 opacity-75">
({Math.round((predictions?.sales?.confidenceScore || 0) * 100)}%)
</span>
)}
</Badge>
</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 && (
<span className="ml-1 opacity-75">
({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 optimal100% is avoided to prevent "memorizing" the past and ensure the model remains flexible for future shifts.
</p>
</div>
</TooltipContent>
</Tooltip>
)}
{predictions?.sales?.trend && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-help">
<Badge
variant="outline"
className={
predictions?.sales?.trend?.direction === "up"
? "text-green-600 border-green-600"
: predictions?.sales?.trend?.direction === "down"
? "text-red-600 border-red-600"
: ""
}
>
{predictions?.sales?.trend?.direction === "up" && (
<TrendingUp className="h-3 w-3 mr-1" />
)}
{predictions?.sales?.trend?.direction === "down" && (
<TrendingDown className="h-3 w-3 mr-1" />
)}
{predictions?.sales?.trend?.direction === "up"
? "Trending Up"
: 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 && (
<div className="text-sm text-muted-foreground">
~{Math.round(predictions?.sales?.predictedOrders || 0)}{" "}
orders
</div> </div>
<div className="text-sm font-semibold"> )}
{formatGBP(day.predicted)} {!predictions?.sales?.confidenceIntervals &&
predictions?.sales?.minPrediction &&
predictions?.sales?.maxPrediction && (
<div className="text-xs text-muted-foreground">
Range: {formatGBP(predictions?.sales?.minPrediction || 0)} -{" "}
{formatGBP(predictions?.sales?.maxPrediction || 0)}
</div>
)}
</div>
) : (
<div className="text-sm text-muted-foreground">
{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">
<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>
<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>
{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>
)}
<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>
<div className="pt-2 border-t text-xs text-muted-foreground">
Model automatically retrains with new sales data.
</div>
</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 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>
<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="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>
</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>
);
}
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> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -559,6 +930,6 @@ export default function PredictionsChart({
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card >
); );
} }

View File

@@ -4,14 +4,15 @@ import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { import {
TrendingUp, TrendingUp,
TrendingDown, TrendingDown,
DollarSign, DollarSign,
PieChart, PieChart,
Calculator, Calculator,
Info, Info,
AlertTriangle AlertTriangle,
Package
} from "lucide-react"; } from "lucide-react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { formatGBP } from "@/utils/format"; import { formatGBP } from "@/utils/format";
@@ -28,6 +29,7 @@ export default function ProfitAnalyticsChart({ timeRange, dateRange, hideNumbers
const [data, setData] = useState<ProfitOverview | null>(null); const [data, setData] = useState<ProfitOverview | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [imageErrors, setImageErrors] = useState<Record<string, boolean>>({});
const { toast } = useToast(); const { toast } = useToast();
const maskValue = (value: string): string => { const maskValue = (value: string): string => {
@@ -93,7 +95,7 @@ export default function ProfitAnalyticsChart({ timeRange, dateRange, hideNumbers
</Card> </Card>
))} ))}
</div> </div>
{/* Coverage Card Skeleton */} {/* Coverage Card Skeleton */}
<Card> <Card>
<CardHeader> <CardHeader>
@@ -110,7 +112,7 @@ export default function ProfitAnalyticsChart({ timeRange, dateRange, hideNumbers
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Products List Skeleton */} {/* Products List Skeleton */}
<Card> <Card>
<CardHeader> <CardHeader>
@@ -188,7 +190,7 @@ export default function ProfitAnalyticsChart({ timeRange, dateRange, hideNumbers
} }
const profitDirection = data.summary.totalProfit >= 0; const profitDirection = data.summary.totalProfit >= 0;
// Fallback for backwards compatibility // Fallback for backwards compatibility
const revenueFromTracked = data.summary.revenueFromTrackedProducts || data.summary.totalRevenue || 0; const revenueFromTracked = data.summary.revenueFromTrackedProducts || data.summary.totalRevenue || 0;
const totalRevenue = data.summary.totalRevenue || 0; const totalRevenue = data.summary.totalRevenue || 0;
@@ -237,9 +239,8 @@ export default function ProfitAnalyticsChart({ timeRange, dateRange, hideNumbers
<CardTitle className="text-sm font-medium">Total Profit</CardTitle> <CardTitle className="text-sm font-medium">Total Profit</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className={`text-2xl font-bold flex items-center gap-2 ${ <div className={`text-2xl font-bold flex items-center gap-2 ${profitDirection ? 'text-green-600' : 'text-red-600'
profitDirection ? 'text-green-600' : 'text-red-600' }`}>
}`}>
{profitDirection ? ( {profitDirection ? (
<TrendingUp className="h-5 w-5" /> <TrendingUp className="h-5 w-5" />
) : ( ) : (
@@ -286,7 +287,7 @@ export default function ProfitAnalyticsChart({ timeRange, dateRange, hideNumbers
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="w-full bg-secondary rounded-full h-2"> <div className="w-full bg-secondary rounded-full h-2">
<div <div
className="bg-primary h-2 rounded-full transition-all duration-300" className="bg-primary h-2 rounded-full transition-all duration-300"
style={{ width: hideNumbers ? "0%" : `${data.summary.costDataCoverage || 0}%` }} style={{ width: hideNumbers ? "0%" : `${data.summary.costDataCoverage || 0}%` }}
/> />
@@ -307,7 +308,7 @@ export default function ProfitAnalyticsChart({ timeRange, dateRange, hideNumbers
Most Profitable Products Most Profitable Products
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{dateRange {dateRange
? `Products generating the highest total profit (${new Date(dateRange.from).toLocaleDateString()} - ${new Date(dateRange.to).toLocaleDateString()})` ? `Products generating the highest total profit (${new Date(dateRange.from).toLocaleDateString()} - ${new Date(dateRange.to).toLocaleDateString()})`
: `Products generating the highest total profit (last ${timeRange || '30'} days)` : `Products generating the highest total profit (last ${timeRange || '30'} days)`
} }
@@ -323,24 +324,43 @@ export default function ProfitAnalyticsChart({ timeRange, dateRange, hideNumbers
<div className="space-y-4"> <div className="space-y-4">
{data.topProfitableProducts.map((product, index) => { {data.topProfitableProducts.map((product, index) => {
const profitPositive = product.totalProfit >= 0; const profitPositive = product.totalProfit >= 0;
return ( return (
<div <div
key={product.productId} 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 gap-4">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary font-semibold text-sm"> <div className="relative flex-shrink-0">
{index + 1} <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>
<div> <div>
<p className="font-medium">{product.productName}</p> <p className="font-semibold">{product.productName}</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground flex items-center gap-1">
<Package className="h-3 w-3" />
{product.totalQuantitySold} units sold {product.totalQuantitySold} units sold
</p> </p>
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className={`font-medium ${profitPositive ? 'text-green-600' : 'text-red-600'}`}> <div className={`font-medium ${profitPositive ? 'text-green-600' : 'text-red-600'}`}>
{maskValue(formatGBP(product.totalProfit))} {maskValue(formatGBP(product.totalProfit))}

View File

@@ -7,7 +7,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { TrendingUp, DollarSign } from "lucide-react"; import { TrendingUp, DollarSign } from "lucide-react";
import { getRevenueTrendsWithStore, type RevenueData } from "@/lib/services/analytics-service"; import { getRevenueTrendsWithStore, type RevenueData } from "@/lib/services/analytics-service";
import { formatGBP } from "@/utils/format"; 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'; import { ChartSkeleton } from './SkeletonLoaders';
interface RevenueChartProps { interface RevenueChartProps {
@@ -61,9 +61,9 @@ export default function RevenueChart({ timeRange, hideNumbers = false }: Revenue
date: date.toISOString().split('T')[0], // YYYY-MM-DD format date: date.toISOString().split('T')[0], // YYYY-MM-DD format
revenue: item.revenue || 0, revenue: item.revenue || 0,
orders: item.orders || 0, orders: item.orders || 0,
formattedDate: date.toLocaleDateString('en-GB', { formattedDate: date.toLocaleDateString('en-GB', {
weekday: 'short', weekday: 'short',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
timeZone: 'UTC' timeZone: 'UTC'
}) })
@@ -79,12 +79,12 @@ export default function RevenueChart({ timeRange, hideNumbers = false }: Revenue
// Function to mask sensitive numbers // Function to mask sensitive numbers
const maskValue = (value: string): string => { const maskValue = (value: string): string => {
if (!hideNumbers) return value; if (!hideNumbers) return value;
// For currency values (£X.XX), show £*** // For currency values (£X.XX), show £***
if (value.includes('£')) { if (value.includes('£')) {
return '£***'; return '£***';
} }
// For regular numbers, replace with asterisks // For regular numbers, replace with asterisks
return '***'; return '***';
}; };
@@ -110,7 +110,7 @@ export default function RevenueChart({ timeRange, hideNumbers = false }: Revenue
if (isLoading) { if (isLoading) {
return ( return (
<ChartSkeleton <ChartSkeleton
title="Revenue Trends" title="Revenue Trends"
description="Revenue performance over the selected time period" description="Revenue performance over the selected time period"
icon={TrendingUp} icon={TrendingUp}
@@ -175,32 +175,45 @@ export default function RevenueChart({ timeRange, hideNumbers = false }: Revenue
<div className="space-y-6"> <div className="space-y-6">
{/* Chart */} {/* Chart */}
<div className="h-64"> <div className="h-64">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer key={timeRange} width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}> <AreaChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" /> <defs>
<XAxis <linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1">
dataKey="formattedDate" <stop offset="5%" stopColor="#2563eb" stopOpacity={0.8} />
tick={{ fontSize: 12 }} <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, fill: "hsl(var(--muted-foreground))" }}
axisLine={false}
tickLine={false}
angle={-45} angle={-45}
textAnchor="end" textAnchor="end"
height={60} height={60}
minTickGap={30}
/> />
<YAxis <YAxis
tick={{ fontSize: 12 }} tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
axisLine={false}
tickLine={false}
tickFormatter={(value) => hideNumbers ? '***' : `£${(value / 1000).toFixed(0)}k`} tickFormatter={(value) => hideNumbers ? '***' : `£${(value / 1000).toFixed(0)}k`}
/> />
<Tooltip content={<CustomTooltip />} /> <Tooltip content={<CustomTooltip />} cursor={{ fill: "transparent", stroke: "hsl(var(--muted-foreground))", strokeDasharray: "3 3" }} />
<Bar <Area
dataKey="revenue" type="monotone"
fill="#2563eb" dataKey="revenue"
stroke="#1d4ed8" stroke="#2563eb"
strokeWidth={1} fillOpacity={1}
radius={[2, 2, 0, 0]} fill="url(#colorRevenue)"
strokeWidth={2}
activeDot={{ r: 4, strokeWidth: 0 }}
/> />
</BarChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
{/* Summary stats */} {/* Summary stats */}
<div className="grid grid-cols-3 gap-4 pt-4 border-t"> <div className="grid grid-cols-3 gap-4 pt-4 border-t">
<div className="text-center"> <div className="text-center">

View File

@@ -20,12 +20,12 @@ export function AnimatedStatsSection({ stats }: AnimatedStatsProps) {
return ( return (
<div className="mx-auto grid max-w-5xl grid-cols-1 gap-6 md:grid-cols-3"> <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="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"> <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"> <div className="mt-4 text-3xl font-bold text-white">
<AnimatedCounter <AnimatedCounter
value={stats.orders.completed} value={stats.orders.completed}
duration={2000} duration={2000}
suffix="+" suffix="+"
/> />
@@ -38,14 +38,14 @@ export function AnimatedStatsSection({ stats }: AnimatedStatsProps) {
</p> </p>
</div> </div>
</div> </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="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"> <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"> <div className="mt-4 text-3xl font-bold text-white">
<AnimatedCounter <AnimatedCounter
value={stats.vendors.total} value={stats.vendors.total}
duration={2000} duration={2000}
suffix="+" suffix="+"
/> />
@@ -58,14 +58,14 @@ export function AnimatedStatsSection({ stats }: AnimatedStatsProps) {
</p> </p>
</div> </div>
</div> </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="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"> <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"> <div className="mt-4 text-3xl font-bold text-white">
<AnimatedCounter <AnimatedCounter
value={stats.transactions.volume} value={stats.transactions.volume}
duration={2500} duration={2500}
formatter={formatCurrency} formatter={formatCurrency}
/> />

View File

@@ -13,6 +13,7 @@ import { toast } from "sonner";
import { ArrowLeft, Send, RefreshCw, File, FileText, Image as ImageIcon, Download } from "lucide-react"; import { ArrowLeft, Send, RefreshCw, File, FileText, Image as ImageIcon, Download } from "lucide-react";
import { getCookie, clientFetch } from "@/lib/api"; import { getCookie, clientFetch } from "@/lib/api";
import { ImageViewerModal } from "@/components/modals/image-viewer-modal"; import { ImageViewerModal } from "@/components/modals/image-viewer-modal";
import Image from "next/image";
import BuyerOrderInfo from "./BuyerOrderInfo"; import BuyerOrderInfo from "./BuyerOrderInfo";
import { useIsTouchDevice } from "@/hooks/use-mobile"; import { useIsTouchDevice } from "@/hooks/use-mobile";
import { useChromebookScroll, useSmoothScrollToBottom } from "@/hooks/use-chromebook-scroll"; import { useChromebookScroll, useSmoothScrollToBottom } from "@/hooks/use-chromebook-scroll";
@@ -45,41 +46,40 @@ const getFileNameFromUrl = (url: string): string => {
// Try to extract filename from the URL path // Try to extract filename from the URL path
const pathParts = url.split('/'); const pathParts = url.split('/');
const lastPart = pathParts[pathParts.length - 1]; const lastPart = pathParts[pathParts.length - 1];
// Remove query parameters if any // Remove query parameters if any
const fileNameParts = lastPart.split('?'); const fileNameParts = lastPart.split('?');
let fileName = fileNameParts[0]; let fileName = fileNameParts[0];
// If filename is too long or not found, create a generic name // If filename is too long or not found, create a generic name
if (!fileName || fileName.length > 30) { if (!fileName || fileName.length > 30) {
return 'attachment'; return 'attachment';
} }
// URL decode the filename (handle spaces and special characters)
try { try {
fileName = decodeURIComponent(fileName); fileName = decodeURIComponent(fileName);
} catch (e) { } catch (e) {
// If decoding fails, use the original // If decoding fails, use the original
} }
return fileName; return fileName;
}; };
// Helper function to get file icon based on extension or URL pattern // Helper function to get file icon based on extension or URL pattern
const getFileIcon = (url: string): React.ReactNode => { const getFileIcon = (url: string): React.ReactNode => {
const fileName = url.toLowerCase(); const fileName = url.toLowerCase();
// Image files // Image files
if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff)($|\?)/i.test(fileName) || if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff)($|\?)/i.test(fileName) ||
url.includes('/photos/') || url.includes('/photo/')) { url.includes('/photos/') || url.includes('/photo/')) {
return <ImageIcon className="h-5 w-5" />; return <ImageIcon className="h-5 w-5" />;
} }
// Document files // Document files
if (/\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|rtf|csv)($|\?)/i.test(fileName)) { if (/\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|rtf|csv)($|\?)/i.test(fileName)) {
return <FileText className="h-5 w-5" />; return <FileText className="h-5 w-5" />;
} }
// Default file icon // Default file icon
return <File className="h-5 w-5" />; return <File className="h-5 w-5" />;
}; };
@@ -106,7 +106,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
const { scrollToBottom, scrollToBottomInstant } = useSmoothScrollToBottom(); const { scrollToBottom, scrollToBottomInstant } = useSmoothScrollToBottom();
useChromebookKeyboard(); useChromebookKeyboard();
const { focusMessageInput, focusNextMessage, focusPreviousMessage } = useChatFocus(); const { focusMessageInput, focusNextMessage, focusPreviousMessage } = useChatFocus();
// Scroll to bottom utility functions // Scroll to bottom utility functions
const scrollToBottomHandler = () => { const scrollToBottomHandler = () => {
if (scrollContainerRef.current) { if (scrollContainerRef.current) {
@@ -115,40 +115,40 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
} }
}; };
const isNearBottom = () => { const isNearBottom = () => {
if (!messagesEndRef.current) return true; if (!messagesEndRef.current) return true;
const container = messagesEndRef.current.parentElement; const container = messagesEndRef.current.parentElement;
if (!container) return true; if (!container) return true;
const { scrollTop, scrollHeight, clientHeight } = container; const { scrollTop, scrollHeight, clientHeight } = container;
// Consider "near bottom" if within 100px of the bottom // Consider "near bottom" if within 100px of the bottom
return scrollHeight - (scrollTop + clientHeight) < 100; return scrollHeight - (scrollTop + clientHeight) < 100;
}; };
// Initialize audio element // Initialize audio element
useEffect(() => { useEffect(() => {
// Create audio element for notification sound // Create audio element for notification sound
audioRef.current = new Audio('/notification.mp3'); audioRef.current = new Audio('/notification.mp3');
// Fallback if notification.mp3 doesn't exist - use browser API for a simple beep // Fallback if notification.mp3 doesn't exist - use browser API for a simple beep
audioRef.current.addEventListener('error', () => { audioRef.current.addEventListener('error', () => {
audioRef.current = null; audioRef.current = null;
}); });
return () => { return () => {
if (audioRef.current) { if (audioRef.current) {
audioRef.current = null; audioRef.current = null;
} }
// Clear any pending timeouts when component unmounts // Clear any pending timeouts when component unmounts
if (markReadTimeoutRef.current) { if (markReadTimeoutRef.current) {
clearTimeout(markReadTimeoutRef.current); clearTimeout(markReadTimeoutRef.current);
} }
}; };
}, []); }, []);
// Function to play notification sound // Function to play notification sound
const playNotificationSound = () => { const playNotificationSound = () => {
if (audioRef.current) { if (audioRef.current) {
@@ -186,7 +186,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
} }
} }
}; };
// Function to mark messages as read // Function to mark messages as read
const markMessagesAsRead = async () => { const markMessagesAsRead = async () => {
try { try {
@@ -199,7 +199,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
console.error("Error marking messages as read:", error); console.error("Error marking messages as read:", error);
} }
}; };
// Loading effect // Loading effect
useEffect(() => { useEffect(() => {
if (chatId) { if (chatId) {
@@ -211,7 +211,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
const getAuthAxios = () => { const getAuthAxios = () => {
const authToken = getCookie("Authorization"); const authToken = getCookie("Authorization");
if (!authToken) return null; if (!authToken) return null;
return axios.create({ return axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL, baseURL: process.env.NEXT_PUBLIC_API_URL,
headers: { headers: {
@@ -220,22 +220,22 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
} }
}); });
}; };
// Fetch chat information and messages // Fetch chat information and messages
const fetchChatData = async () => { const fetchChatData = async () => {
if (!chatId) return; if (!chatId) return;
try { try {
setLoading(true); setLoading(true);
// Use clientFetch to load chat data // Use clientFetch to load chat data
// For now, we're only loading chat data, but this could be extended // For now, we're only loading chat data, but this could be extended
// to load additional data in parallel (user profiles, order details, etc.) // to load additional data in parallel (user profiles, order details, etc.)
const response = await clientFetch(`/chats/${chatId}`); const response = await clientFetch(`/chats/${chatId}`);
setChatData(response); setChatData(response);
setChat(response); // Set chat data to maintain compatibility setChat(response); // Set chat data to maintain compatibility
// Set messages with a transition effect // Set messages with a transition effect
// If we already have messages, append new ones to avoid jumpiness // If we already have messages, append new ones to avoid jumpiness
if (messages.length > 0) { if (messages.length > 0) {
@@ -243,10 +243,10 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
const newMessages = response.messages.filter( const newMessages = response.messages.filter(
(msg: Message) => !existingMessageIds.has(msg._id) (msg: Message) => !existingMessageIds.has(msg._id)
); );
if (newMessages.length > 0) { if (newMessages.length > 0) {
setMessages(prev => [...prev, ...newMessages]); setMessages(prev => [...prev, ...newMessages]);
// Mark all these messages as seen to avoid notification sounds // Mark all these messages as seen to avoid notification sounds
newMessages.forEach((msg: Message) => { newMessages.forEach((msg: Message) => {
seenMessageIdsRef.current.add(msg._id); seenMessageIdsRef.current.add(msg._id);
@@ -254,7 +254,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
} else { } else {
// If we need to replace all messages (e.g., first load or refresh) // If we need to replace all messages (e.g., first load or refresh)
setMessages(Array.isArray(response.messages) ? response.messages : []); setMessages(Array.isArray(response.messages) ? response.messages : []);
// Mark all messages as seen // Mark all messages as seen
if (Array.isArray(response.messages)) { if (Array.isArray(response.messages)) {
response.messages.forEach((msg: Message) => { response.messages.forEach((msg: Message) => {
@@ -266,20 +266,20 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
// Initial load // Initial load
const initialMessages = Array.isArray(response.messages) ? response.messages : []; const initialMessages = Array.isArray(response.messages) ? response.messages : [];
setMessages(initialMessages); setMessages(initialMessages);
// Mark all initial messages as seen // Mark all initial messages as seen
initialMessages.forEach((msg: Message) => { initialMessages.forEach((msg: Message) => {
seenMessageIdsRef.current.add(msg._id); seenMessageIdsRef.current.add(msg._id);
}); });
} }
// Scroll to bottom on initial load // Scroll to bottom on initial load
setTimeout(() => { setTimeout(() => {
scrollToBottomHandler(); scrollToBottomHandler();
}, 100); }, 100);
} catch (error: any) { } catch (error: any) {
console.error("Error fetching chat data:", error); console.error("Error fetching chat data:", error);
// Don't redirect on auth errors - let the middleware handle it // Don't redirect on auth errors - let the middleware handle it
// Only show error toast for non-auth errors // Only show error toast for non-auth errors
if (error?.message?.includes('401') || error?.message?.includes('403')) { if (error?.message?.includes('401') || error?.message?.includes('403')) {
@@ -292,7 +292,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
setLoading(false); setLoading(false);
} }
}; };
// Setup polling for new messages // Setup polling for new messages
useEffect(() => { useEffect(() => {
// Set up a polling interval to check for new messages // Set up a polling interval to check for new messages
@@ -301,60 +301,60 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
pollNewMessages(); pollNewMessages();
} }
}, 3000); // Poll every 3 seconds }, 3000); // Poll every 3 seconds
return () => { return () => {
clearInterval(pollInterval); clearInterval(pollInterval);
}; };
}, [chatId]); }, [chatId]);
// Poll for new messages without replacing existing ones // Poll for new messages without replacing existing ones
const pollNewMessages = async () => { const pollNewMessages = async () => {
if (!chatId || isPollingRef.current) return; if (!chatId || isPollingRef.current) return;
isPollingRef.current = true; isPollingRef.current = true;
try { try {
const response = await clientFetch(`/chats/${chatId}`); const response = await clientFetch(`/chats/${chatId}`);
// Update chat metadata // Update chat metadata
setChatData(response); setChatData(response);
setChat(response); setChat(response);
// Check if there are new messages // Check if there are new messages
if (Array.isArray(response.messages) && response.messages.length > 0) { if (Array.isArray(response.messages) && response.messages.length > 0) {
// Get existing message IDs to avoid duplicates // Get existing message IDs to avoid duplicates
const existingIds = new Set(messages.map(m => m._id)); const existingIds = new Set(messages.map(m => m._id));
const newMessages = response.messages.filter((msg: Message) => !existingIds.has(msg._id)); const newMessages = response.messages.filter((msg: Message) => !existingIds.has(msg._id));
if (newMessages.length > 0) { if (newMessages.length > 0) {
// Add only new messages to avoid re-rendering all messages // Add only new messages to avoid re-rendering all messages
setMessages(prev => [...prev, ...newMessages]); setMessages(prev => [...prev, ...newMessages]);
// Play notification sound only for new buyer messages we haven't seen before // Play notification sound only for new buyer messages we haven't seen before
const unseenBuyerMessages = newMessages.filter((msg: Message) => const unseenBuyerMessages = newMessages.filter((msg: Message) =>
msg.sender === 'buyer' && !seenMessageIdsRef.current.has(msg._id) msg.sender === 'buyer' && !seenMessageIdsRef.current.has(msg._id)
); );
// If we have unseen buyer messages, play sound and mark them as seen // If we have unseen buyer messages, play sound and mark them as seen
if (unseenBuyerMessages.length > 0) { if (unseenBuyerMessages.length > 0) {
playNotificationSound(); playNotificationSound();
// Add these messages to our seen set // Add these messages to our seen set
unseenBuyerMessages.forEach((msg: Message) => { unseenBuyerMessages.forEach((msg: Message) => {
seenMessageIdsRef.current.add(msg._id); seenMessageIdsRef.current.add(msg._id);
}); });
} }
// If near bottom, scroll to new messages // If near bottom, scroll to new messages
if (isNearBottom()) { if (isNearBottom()) {
setTimeout(scrollToBottom, 50); setTimeout(scrollToBottom, 50);
} }
// Set timeout to mark new messages as read // Set timeout to mark new messages as read
if (markReadTimeoutRef.current) { if (markReadTimeoutRef.current) {
clearTimeout(markReadTimeoutRef.current); clearTimeout(markReadTimeoutRef.current);
} }
markReadTimeoutRef.current = setTimeout(() => { markReadTimeoutRef.current = setTimeout(() => {
markMessagesAsRead(); markMessagesAsRead();
}, 1000); }, 1000);
@@ -362,7 +362,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
} }
} catch (error: any) { } catch (error: any) {
console.error("Error polling new messages:", error); console.error("Error polling new messages:", error);
// Silently fail on auth errors during polling - don't disrupt the user // Silently fail on auth errors during polling - don't disrupt the user
if (error?.message?.includes('401') || error?.message?.includes('403')) { if (error?.message?.includes('401') || error?.message?.includes('403')) {
console.log("Auth error during polling, stopping poll"); console.log("Auth error during polling, stopping poll");
@@ -410,12 +410,12 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
const focusableElements = document.querySelectorAll( const focusableElements = document.querySelectorAll(
'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' 'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
) as NodeListOf<HTMLElement>; ) as NodeListOf<HTMLElement>;
const currentIndex = Array.from(focusableElements).indexOf(document.activeElement as HTMLElement); const currentIndex = Array.from(focusableElements).indexOf(document.activeElement as HTMLElement);
const nextIndex = e.shiftKey const nextIndex = e.shiftKey
? (currentIndex > 0 ? currentIndex - 1 : focusableElements.length - 1) ? (currentIndex > 0 ? currentIndex - 1 : focusableElements.length - 1)
: (currentIndex < focusableElements.length - 1 ? currentIndex + 1 : 0); : (currentIndex < focusableElements.length - 1 ? currentIndex + 1 : 0);
focusableElements[nextIndex]?.focus(); focusableElements[nextIndex]?.focus();
} }
}; };
@@ -424,9 +424,9 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
const sendMessage = async (newMessage: string, file?: File | null) => { const sendMessage = async (newMessage: string, file?: File | null) => {
// Don't send empty messages // Don't send empty messages
if (!newMessage.trim() && !file) return; if (!newMessage.trim() && !file) return;
if (!chatId || !chatData) return; if (!chatId || !chatData) return;
// Create a temporary message with a unique temporary ID // Create a temporary message with a unique temporary ID
const tempId = `temp-${Date.now()}`; const tempId = `temp-${Date.now()}`;
const tempMessage: Message = { const tempMessage: Message = {
@@ -439,27 +439,27 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
buyerId: chatData.buyerId || '', buyerId: chatData.buyerId || '',
vendorId: chatData.vendorId || '' vendorId: chatData.vendorId || ''
}; };
// Add the temp message ID to seen messages // Add the temp message ID to seen messages
seenMessageIdsRef.current.add(tempId); seenMessageIdsRef.current.add(tempId);
// Optimistically add the temp message to the UI // Optimistically add the temp message to the UI
setMessages(prev => [...prev, tempMessage]); setMessages(prev => [...prev, tempMessage]);
// Scroll to bottom to show the new message // Scroll to bottom to show the new message
setTimeout(scrollToBottom, 50); setTimeout(scrollToBottom, 50);
try { try {
setSending(true); setSending(true);
let response; let response;
if (file) { if (file) {
// Use FormData for file uploads // Use FormData for file uploads
const formData = new FormData(); const formData = new FormData();
formData.append('content', newMessage); formData.append('content', newMessage);
formData.append('attachment', file); formData.append('attachment', file);
response = await clientFetch(`/chats/${chatId}/message`, { response = await clientFetch(`/chats/${chatId}/message`, {
method: 'POST', method: 'POST',
body: formData, body: formData,
@@ -469,35 +469,35 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
// Use JSON for text-only messages // Use JSON for text-only messages
response = await clientFetch(`/chats/${chatId}/message`, { response = await clientFetch(`/chats/${chatId}/message`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
content: newMessage content: newMessage
}), }),
headers: { headers: {
'Content-Type': 'application/json; charset=utf-8' 'Content-Type': 'application/json; charset=utf-8'
} }
}); });
} }
// Replace the temporary message with the real one from the server // Replace the temporary message with the real one from the server
setMessages(prev => prev.map(msg => setMessages(prev => prev.map(msg =>
msg._id === tempId ? response : msg msg._id === tempId ? response : msg
)); ));
// Add the real message ID to seen messages // Add the real message ID to seen messages
if (response && response._id) { if (response && response._id) {
seenMessageIdsRef.current.add(response._id); seenMessageIdsRef.current.add(response._id);
} }
// Update the textarea value to empty // Update the textarea value to empty
setMessage(''); setMessage('');
// Clear the file if there was one // Clear the file if there was one
if (file) { if (file) {
setSelectedImage(null); setSelectedImage(null);
setSelectedMessageIndex(null); setSelectedMessageIndex(null);
setSelectedAttachmentIndex(null); setSelectedAttachmentIndex(null);
} }
// Update the chat's last message // Update the chat's last message
if (chatData) { if (chatData) {
setChatData({ setChatData({
@@ -505,34 +505,34 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
lastUpdated: new Date().toISOString() lastUpdated: new Date().toISOString()
}); });
} }
} catch (error) { } catch (error) {
console.error('Error sending message:', error); console.error('Error sending message:', error);
toast.error('Failed to send message'); toast.error('Failed to send message');
// Remove the temporary message if sending failed // Remove the temporary message if sending failed
setMessages(prev => prev.filter(msg => msg._id !== tempId)); setMessages(prev => prev.filter(msg => msg._id !== tempId));
} finally { } finally {
setSending(false); setSending(false);
} }
}; };
const handleBackClick = () => { const handleBackClick = () => {
router.push("/dashboard/chats"); router.push("/dashboard/chats");
}; };
// Add function to handle image navigation // Add function to handle image navigation
const handleImageNavigation = (direction: 'prev' | 'next') => { const handleImageNavigation = (direction: 'prev' | 'next') => {
if (!chat || selectedMessageIndex === null || selectedAttachmentIndex === null) return; if (!chat || selectedMessageIndex === null || selectedAttachmentIndex === null) return;
// Get all images from all messages // Get all images from all messages
const allImages: { messageIndex: number; attachmentIndex: number; url: string }[] = []; const allImages: { messageIndex: number; attachmentIndex: number; url: string }[] = [];
chat.messages.forEach((msg, msgIndex) => { chat.messages.forEach((msg, msgIndex) => {
msg.attachments.forEach((att, attIndex) => { msg.attachments.forEach((att, attIndex) => {
if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff)($|\?)/i.test(att) || if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff)($|\?)/i.test(att) ||
att.includes('/photos/') || att.includes('/photos/') ||
att.includes('/photo/')) { att.includes('/photo/')) {
allImages.push({ messageIndex: msgIndex, attachmentIndex: attIndex, url: att }); allImages.push({ messageIndex: msgIndex, attachmentIndex: attIndex, url: att });
} }
}); });
@@ -541,8 +541,8 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
if (allImages.length === 0) return; if (allImages.length === 0) return;
// Find current image index // Find current image index
const currentIndex = allImages.findIndex(img => const currentIndex = allImages.findIndex(img =>
img.messageIndex === selectedMessageIndex && img.messageIndex === selectedMessageIndex &&
img.attachmentIndex === selectedAttachmentIndex img.attachmentIndex === selectedAttachmentIndex
); );
@@ -569,7 +569,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
setSelectedMessageIndex(messageIndex); setSelectedMessageIndex(messageIndex);
setSelectedAttachmentIndex(attachmentIndex); setSelectedAttachmentIndex(attachmentIndex);
}; };
if (loading) { if (loading) {
return ( return (
<div className="flex flex-col h-screen w-full relative"> <div className="flex flex-col h-screen w-full relative">
@@ -585,7 +585,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
</div> </div>
); );
} }
if (!chat) { if (!chat) {
return ( return (
<div className="flex flex-col h-screen w-full relative"> <div className="flex flex-col h-screen w-full relative">
@@ -604,17 +604,17 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
</div> </div>
); );
} }
return ( return (
<div className="flex flex-col h-screen w-full relative"> <div className="flex flex-col h-screen w-full relative">
<div className={cn( <div className={cn(
"border-b bg-card z-10 flex items-center justify-between", "border-b bg-background/80 backdrop-blur-md z-10 flex items-center justify-between sticky top-0",
isTouchDevice ? "h-20 px-3" : "h-16 px-4" isTouchDevice ? "h-16 px-4" : "h-16 px-6"
)}> )}>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={handleBackClick} onClick={handleBackClick}
className={cn( className={cn(
"transition-all duration-200", "transition-all duration-200",
@@ -643,15 +643,15 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
)} )}
</div> </div>
</div> </div>
<BuyerOrderInfo buyerId={chat.buyerId} chatId={chatId} /> <BuyerOrderInfo buyerId={chat.buyerId} chatId={chatId} />
</div> </div>
<div <div
ref={scrollContainerRef} ref={scrollContainerRef}
className={cn( className={cn(
isTouchDevice isTouchDevice
? "flex-1 overflow-y-auto space-y-2 p-3 pb-[calc(112px+env(safe-area-inset-bottom))]" ? "flex-1 overflow-y-auto space-y-2 p-3 pb-[calc(112px+env(safe-area-inset-bottom))]"
: "flex-1 overflow-y-auto space-y-2 p-2 pb-[calc(88px+env(safe-area-inset-bottom))]" : "flex-1 overflow-y-auto space-y-2 p-2 pb-[calc(88px+env(safe-area-inset-bottom))]"
)} )}
role="log" role="log"
@@ -680,11 +680,10 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
> >
<div <div
className={cn( className={cn(
"max-w-[90%] rounded-lg chat-message", "max-w-[85%] rounded-2xl p-4 shadow-sm",
isTouchDevice ? "p-4" : "p-3",
msg.sender === "vendor" msg.sender === "vendor"
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground rounded-tr-none"
: "bg-muted" : "bg-muted text-muted-foreground rounded-tl-none border border-border/50"
)} )}
> >
<div className="flex items-center space-x-2 mb-1"> <div className="flex items-center space-x-2 mb-1">
@@ -704,15 +703,15 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
{msg.attachments && msg.attachments.length > 0 && ( {msg.attachments && msg.attachments.length > 0 && (
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
{msg.attachments.map((attachment, attachmentIndex) => { {msg.attachments.map((attachment, attachmentIndex) => {
const isImage = /\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff)($|\?)/i.test(attachment) || const isImage = /\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff)($|\?)/i.test(attachment) ||
attachment.includes('/photos/') || attachment.includes('/photos/') ||
attachment.includes('/photo/'); attachment.includes('/photo/');
const fileName = getFileNameFromUrl(attachment); const fileName = getFileNameFromUrl(attachment);
return isImage ? ( return isImage ? (
<div <div
key={`attachment-${attachmentIndex}`} key={`attachment-${attachmentIndex}`}
className="rounded-md overflow-hidden bg-background/20 p-1" className="rounded-md overflow-hidden bg-background/20 p-1"
onClick={() => handleImageClick(attachment, messageIndex, attachmentIndex)} onClick={() => handleImageClick(attachment, messageIndex, attachmentIndex)}
role="button" role="button"
@@ -730,10 +729,10 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
<ImageIcon className="h-3 w-3 mr-1" aria-hidden="true" /> <ImageIcon className="h-3 w-3 mr-1" aria-hidden="true" />
{fileName} {fileName}
</span> </span>
<a <a
href={attachment} href={attachment}
download={fileName} download={fileName}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-xs opacity-70 hover:opacity-100" className="text-xs opacity-70 hover:opacity-100"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@@ -742,13 +741,23 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
<Download className="h-3 w-3" aria-hidden="true" /> <Download className="h-3 w-3" aria-hidden="true" />
</a> </a>
</div> </div>
<img <Image
src={attachment} src={attachment}
alt={fileName} 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" className="max-w-full max-h-60 object-contain cursor-pointer hover:opacity-90 transition-opacity rounded"
onError={(e) => { 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> </div>
) : ( ) : (
@@ -762,8 +771,8 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
{fileName} {fileName}
</div> </div>
</div> </div>
<a <a
href={attachment} href={attachment}
download={fileName} download={fileName}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@@ -783,49 +792,38 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
)} )}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
<div className={cn( <div className={cn(
"absolute bottom-0 left-0 right-0 border-t border-border bg-background", "absolute bottom-0 left-0 right-0 px-4 pt-10 bg-gradient-to-t from-background via-background/95 to-transparent",
isTouchDevice ? "p-3" : "p-4", "pb-[calc(1.5rem+env(safe-area-inset-bottom))]"
"pb-[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">
<Input <div className="relative flex-1">
value={message} <Input
onChange={(e) => setMessage(e.target.value)} value={message}
placeholder="Type your message..." onChange={(e) => setMessage(e.target.value)}
disabled={sending} placeholder="Type your message..."
className={cn( disabled={sending}
"flex-1 text-base transition-all duration-200 form-input", className={cn(
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 onKeyDown={handleKeyDown}
aria-label="Message input" autoFocus
aria-describedby="message-help" aria-label="Message input"
role="textbox" role="textbox"
autoComplete="off" autoComplete="off"
spellCheck="true" />
maxLength={2000} </div>
style={{ <Button
WebkitAppearance: 'none', type="submit"
borderRadius: '0.5rem'
}}
/>
<Button
type="submit"
disabled={sending || !message.trim()} disabled={sending || !message.trim()}
aria-label={sending ? "Sending message" : "Send message"}
className={cn( className={cn(
"transition-all duration-200 btn-chromebook", "rounded-full shadow-md transition-all duration-200 bg-primary hover:bg-primary/90 text-primary-foreground",
isTouchDevice ? "min-h-[52px] min-w-[52px]" : "min-h-[48px] min-w-[48px]" 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> </Button>
</form> </form>
<div id="message-help" className="sr-only"> <div id="message-help" className="sr-only">

View File

@@ -2,6 +2,7 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { motion, AnimatePresence } from "framer-motion";
import { import {
Table, Table,
TableBody, TableBody,
@@ -10,14 +11,15 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { import {
Plus, Plus,
MessageCircle, MessageCircle,
Loader2, Loader2,
RefreshCw, RefreshCw,
Eye, Eye,
User, User,
@@ -30,7 +32,8 @@ import {
CheckCheck, CheckCheck,
Search, Search,
Volume2, Volume2,
VolumeX VolumeX,
MoreHorizontal
} from "lucide-react"; } from "lucide-react";
import { import {
Select, Select,
@@ -78,7 +81,7 @@ export default function ChatTable() {
const [totalChats, setTotalChats] = useState(0); const [totalChats, setTotalChats] = useState(0);
const [itemsPerPage, setItemsPerPage] = useState<number>(10); const [itemsPerPage, setItemsPerPage] = useState<number>(10);
const isManualRefresh = useRef(false); const isManualRefresh = useRef(false);
// Initialize audio element for notifications // Initialize audio element for notifications
useEffect(() => { useEffect(() => {
audioRef.current = new Audio('/notification.mp3'); audioRef.current = new Audio('/notification.mp3');
@@ -88,7 +91,7 @@ export default function ChatTable() {
} }
}; };
}, []); }, []);
// Play notification sound // Play notification sound
const playNotificationSound = () => { const playNotificationSound = () => {
if (audioRef.current) { if (audioRef.current) {
@@ -100,30 +103,30 @@ export default function ChatTable() {
} }
} }
}; };
// Get vendor ID from JWT token // Get vendor ID from JWT token
const getVendorIdFromToken = () => { const getVendorIdFromToken = () => {
const authToken = getCookie("Authorization") || ""; const authToken = getCookie("Authorization") || "";
if (!authToken) { if (!authToken) {
throw new Error("No auth token found"); throw new Error("No auth token found");
} }
const tokenParts = authToken.split("."); const tokenParts = authToken.split(".");
if (tokenParts.length !== 3) { if (tokenParts.length !== 3) {
throw new Error("Invalid token format"); throw new Error("Invalid token format");
} }
const payload = JSON.parse(atob(tokenParts[1])); const payload = JSON.parse(atob(tokenParts[1]));
const vendorId = payload.id; const vendorId = payload.id;
if (!vendorId) { if (!vendorId) {
throw new Error("Vendor ID not found in token"); throw new Error("Vendor ID not found in token");
} }
return { vendorId, authToken }; return { vendorId, authToken };
}; };
// Fetch chats when component mounts or page/limit changes // Fetch chats when component mounts or page/limit changes
useEffect(() => { useEffect(() => {
// Skip fetch if this effect was triggered by a manual refresh // Skip fetch if this effect was triggered by a manual refresh
@@ -131,57 +134,57 @@ export default function ChatTable() {
if (!isManualRefresh.current) { if (!isManualRefresh.current) {
fetchChats(); fetchChats();
} }
isManualRefresh.current = false; isManualRefresh.current = false;
// Set up polling for unread messages // Set up polling for unread messages
const interval = setInterval(() => { const interval = setInterval(() => {
fetchUnreadCounts(); fetchUnreadCounts();
}, 30000); // Check every 30 seconds }, 30000); // Check every 30 seconds
return () => clearInterval(interval); return () => clearInterval(interval);
}, [currentPage, itemsPerPage]); }, [currentPage, itemsPerPage]);
// Handle refresh button click // Handle refresh button click
const handleRefresh = () => { const handleRefresh = () => {
isManualRefresh.current = true; isManualRefresh.current = true;
setCurrentPage(1); setCurrentPage(1);
fetchChats(); fetchChats();
}; };
// Fetch unread counts // Fetch unread counts
const fetchUnreadCounts = async () => { const fetchUnreadCounts = async () => {
try { try {
// Get the vendor ID from the auth token // Get the vendor ID from the auth token
const { vendorId } = getVendorIdFromToken(); const { vendorId } = getVendorIdFromToken();
// Fetch unread counts for this vendor using clientFetch // Fetch unread counts for this vendor using clientFetch
const response = await clientFetch(`/chats/vendor/${vendorId}/unread`); const response = await clientFetch(`/chats/vendor/${vendorId}/unread`);
const newUnreadCounts = response; const newUnreadCounts = response;
// Play sound if there are new messages // Play sound if there are new messages
if (newUnreadCounts.totalUnread > unreadCounts.totalUnread) { if (newUnreadCounts.totalUnread > unreadCounts.totalUnread) {
//playNotificationSound(); //playNotificationSound();
} }
setUnreadCounts(newUnreadCounts); setUnreadCounts(newUnreadCounts);
} catch (error) { } catch (error) {
console.error("Failed to fetch unread counts:", error); console.error("Failed to fetch unread counts:", error);
} }
}; };
// Fetch chats with pagination // Fetch chats with pagination
const fetchChats = async (page = currentPage, limit = itemsPerPage) => { const fetchChats = async (page = currentPage, limit = itemsPerPage) => {
setLoading(true); setLoading(true);
try { try {
// Get the vendor ID from the auth token // Get the vendor ID from the auth token
const { vendorId } = getVendorIdFromToken(); const { vendorId } = getVendorIdFromToken();
// Use the optimized batch endpoint that fetches chats and unread counts together // Use the optimized batch endpoint that fetches chats and unread counts together
const batchResponse = await clientFetch(`/chats/vendor/${vendorId}/batch?page=${page}&limit=${limit}`); const batchResponse = await clientFetch(`/chats/vendor/${vendorId}/batch?page=${page}&limit=${limit}`);
// Handle batch response (contains both chats and unread counts) // Handle batch response (contains both chats and unread counts)
if (Array.isArray(batchResponse)) { if (Array.isArray(batchResponse)) {
// Fallback to old API response format (backward compatibility) // Fallback to old API response format (backward compatibility)
@@ -201,15 +204,15 @@ export default function ChatTable() {
setTotalPages(batchResponse.totalPages || 1); setTotalPages(batchResponse.totalPages || 1);
setCurrentPage(batchResponse.page || 1); setCurrentPage(batchResponse.page || 1);
setTotalChats(batchResponse.totalChats || 0); setTotalChats(batchResponse.totalChats || 0);
// Handle unread counts from batch response // Handle unread counts from batch response
const newUnreadCounts = batchResponse.unreadCounts || { totalUnread: 0, chatCounts: {} }; const newUnreadCounts = batchResponse.unreadCounts || { totalUnread: 0, chatCounts: {} };
// Play sound if there are new messages // Play sound if there are new messages
if (newUnreadCounts.totalUnread > unreadCounts.totalUnread) { if (newUnreadCounts.totalUnread > unreadCounts.totalUnread) {
//playNotificationSound(); //playNotificationSound();
} }
setUnreadCounts(newUnreadCounts); setUnreadCounts(newUnreadCounts);
} }
} catch (error) { } catch (error) {
@@ -220,12 +223,12 @@ export default function ChatTable() {
setLoading(false); setLoading(false);
} }
}; };
// Navigate to chat detail page // Navigate to chat detail page
const handleChatClick = (chatId: string) => { const handleChatClick = (chatId: string) => {
router.push(`/dashboard/chats/${chatId}`); router.push(`/dashboard/chats/${chatId}`);
}; };
// Create new chat // Create new chat
const handleCreateChat = () => { const handleCreateChat = () => {
router.push("/dashboard/chats/new"); router.push("/dashboard/chats/new");
@@ -261,163 +264,213 @@ export default function ChatTable() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-between items-center"> <div className="flex justify-between items-end">
<Button <div>
variant="outline" <h2 className="text-2xl font-bold tracking-tight">Messages</h2>
size="sm" <p className="text-muted-foreground">Manage your customer conversations</p>
onClick={handleRefresh} </div>
disabled={loading} <div className="flex gap-2">
> <Button
{loading ? ( variant="outline"
<Loader2 className="h-4 w-4 animate-spin" /> size="sm"
) : ( onClick={handleRefresh}
<RefreshCw className="h-4 w-4" /> disabled={loading}
)} className="h-9"
<span className="ml-2">Refresh</span> >
</Button> {loading ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
<Button onClick={handleCreateChat} size="sm"> ) : (
<Plus className="h-4 w-4 mr-2" /> <RefreshCw className="h-4 w-4 mr-2" />
New Chat )}
</Button> Refresh
</Button>
<Button onClick={handleCreateChat} size="sm" className="h-9">
<Plus className="h-4 w-4 mr-2" />
New Chat
</Button>
</div>
</div> </div>
<div className="rounded-md border"> <Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
<Table> <CardContent className="p-0">
<TableHeader> <Table>
<TableRow> <TableHeader className="bg-muted/50">
<TableHead className="w-[200px]">Customer</TableHead> <TableRow className="hover:bg-transparent">
<TableHead>Last Activity</TableHead> <TableHead className="w-[300px] pl-6">Customer</TableHead>
<TableHead>Status</TableHead> <TableHead>Last Activity</TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead>Status</TableHead>
</TableRow> <TableHead className="text-right pr-6">Actions</TableHead>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
</TableCell>
</TableRow> </TableRow>
) : chats.length === 0 ? ( </TableHeader>
<TableRow> <TableBody>
<TableCell colSpan={4} className="h-24 text-center"> <AnimatePresence>
<div className="flex flex-col items-center justify-center"> {loading ? (
<MessageCircle className="h-8 w-8 text-muted-foreground mb-2" /> <TableRow>
<p className="text-muted-foreground">No chats found</p> <TableCell colSpan={4} className="h-32 text-center">
</div> <div className="flex flex-col items-center justify-center gap-2">
</TableCell> <Loader2 className="h-8 w-8 animate-spin text-primary" />
</TableRow> <span className="text-sm text-muted-foreground">Loading conversations...</span>
) : ( </div>
chats.map((chat) => ( </TableCell>
<TableRow </TableRow>
key={chat._id} ) : chats.length === 0 ? (
className="cursor-pointer hover:bg-muted/50" <TableRow>
onClick={() => handleChatClick(chat._id)} <TableCell colSpan={4} className="h-32 text-center">
> <div className="flex flex-col items-center justify-center">
<TableCell> <MessageCircle className="h-10 w-10 text-muted-foreground/50 mb-3" />
<div className="flex items-center space-x-3"> <p className="text-muted-foreground font-medium">No chats found</p>
<Avatar> <p className="text-xs text-muted-foreground mt-1">Start a new conversation to communicate with customers</p>
<AvatarFallback> </div>
<User className="h-4t w-4" /> </TableCell>
</AvatarFallback> </TableRow>
</Avatar> ) : (
<div> chats.map((chat, index) => (
<div className="font-medium"> <motion.tr
{chat.telegramUsername ? `@${chat.telegramUsername}` : 'Customer'} key={chat._id}
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 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>
{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>
<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-[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> </div>
<div className="text-xs text-muted-foreground"> </TableCell>
ID: {chat.buyerId} <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> </div>
{chat.orderId && ( </TableCell>
<div className="text-xs text-muted-foreground"> <TableCell className="py-4">
Order #{chat.orderId} {unreadCounts.chatCounts[chat._id] > 0 ? (
<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>
) : (
<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> </div>
)} )}
</div> </TableCell>
</div> <TableCell className="text-right pr-6 py-4">
</TableCell> <div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<TableCell> <Button
{formatDistanceToNow(new Date(chat.lastUpdated), { addSuffix: true })} variant="secondary"
</TableCell> size="sm"
<TableCell> className="h-8 w-8 p-0 rounded-full"
{unreadCounts.chatCounts[chat._id] > 0 ? ( onClick={(e) => {
<Badge variant="destructive" className="ml-1"> e.stopPropagation();
{unreadCounts.chatCounts[chat._id]} new handleChatClick(chat._id);
</Badge> }}
) : ( >
<Badge variant="outline">Read</Badge> <ArrowRightCircle className="h-4 w-4" />
)} <span className="sr-only">View</span>
</TableCell> </Button>
<TableCell className="text-right"> </div>
<div className="flex justify-end space-x-2"> </TableCell>
<Button </motion.tr>
variant="ghost" ))
size="icon" )}
onClick={(e) => { </AnimatePresence>
e.stopPropagation(); </TableBody>
handleChatClick(chat._id); </Table>
}} </CardContent>
> </Card>
<Eye className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination controls */} {/* Pagination controls */}
{!loading && chats.length > 0 && ( {
<div className="flex items-center justify-between"> !loading && chats.length > 0 && (
<div className="text-sm text-muted-foreground"> <div className="flex items-center justify-between">
Showing {chats.length} of {totalChats} chats <div className="text-sm text-muted-foreground">
</div> Showing {chats.length} of {totalChats} chats
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-muted-foreground">Rows per page:</span>
<Select
value={itemsPerPage.toString()}
onValueChange={handleItemsPerPageChange}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={itemsPerPage.toString()} />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
</SelectContent>
</Select>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-4">
<Button <div className="flex items-center space-x-2">
variant="outline" <span className="text-sm text-muted-foreground">Rows per page:</span>
size="sm" <Select
onClick={goToPrevPage} value={itemsPerPage.toString()}
disabled={currentPage <= 1 || loading} onValueChange={handleItemsPerPageChange}
> >
<ChevronLeft className="h-4 w-4" /> <SelectTrigger className="h-8 w-[70px]">
</Button> <SelectValue placeholder={itemsPerPage.toString()} />
<div className="text-sm"> </SelectTrigger>
Page {currentPage} of {totalPages} <SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={goToPrevPage}
disabled={currentPage <= 1 || loading}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="text-sm">
Page {currentPage} of {totalPages}
</div>
<Button
variant="outline"
size="sm"
onClick={goToNextPage}
disabled={currentPage >= totalPages || loading}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div> </div>
<Button
variant="outline"
size="sm"
onClick={goToNextPage}
disabled={currentPage >= totalPages || loading}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div> </div>
</div> </div>
</div> )}
)}
</div> </div>
); );
} }

View File

@@ -2,16 +2,29 @@
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import OrderStats from "./order-stats" 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 { getGreeting } from "@/lib/utils/general"
import { statsConfig } from "@/config/dashboard" import { statsConfig } from "@/config/dashboard"
import { getRandomQuote } from "@/config/quotes" import { getRandomQuote } from "@/config/quotes"
import type { OrderStatsData } from "@/lib/types" import type { OrderStatsData } from "@/lib/types"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" 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 { Button } from "@/components/ui/button"
import { useToast } from "@/components/ui/use-toast" import { useToast } from "@/components/ui/use-toast"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { clientFetch } from "@/lib/api" import { clientFetch } from "@/lib/api"
import { motion } from "framer-motion"
import Link from "next/link"
import { useWidgetLayout, WidgetConfig } from "@/hooks/useWidgetLayout"
interface ContentProps { interface ContentProps {
username: string username: string
@@ -21,7 +34,7 @@ interface ContentProps {
interface TopProduct { interface TopProduct {
id: string; id: string;
name: string; name: string;
price: number; price: number | number[];
image: string; image: string;
count: number; count: number;
revenue: number; revenue: number;
@@ -33,147 +46,248 @@ export default function Content({ username, orderStats }: ContentProps) {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { toast } = useToast(); const { toast } = useToast();
const { widgets, toggleWidget, moveWidget, reorderWidgets, resetLayout, isWidgetVisible, updateWidgetSettings, updateWidgetColSpan } = useWidgetLayout();
// Initialize with a random quote from the quotes config const [configuredWidget, setConfiguredWidget] = useState<WidgetConfig | null>(null);
const [randomQuote, setRandomQuote] = useState(getRandomQuote()); const [isEditMode, setIsEditMode] = useState(false);
// Fetch top-selling products data // 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());
}, []);
const fetchTopProducts = async () => { const fetchTopProducts = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const data = await clientFetch('/orders/top-products'); const data = await clientFetch('/orders/top-products');
setTopProducts(data); setTopProducts(data);
} catch (err) { } catch (err) {
console.error("Error fetching top products:", err); console.error("Error fetching top products:", err);
setError(err instanceof Error ? err.message : "Failed to fetch top products"); 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 { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
// Initialize greeting and fetch data on component mount const handleRetry = () => {
fetchTopProducts();
};
const renderWidget = (widget: WidgetConfig) => {
switch (widget.id) {
case "quick-actions":
return (
<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() || "0"}
icon={stat.icon}
index={index}
filterStatus={stat.filterStatus}
/>
))}
</div>
</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>Top Performing Listings</CardTitle>
<CardDescription>Your products with the highest sales volume</CardDescription>
</div>
{error && (
<Button
variant="outline"
size="sm"
onClick={handleRetry}
className="flex items-center gap-1"
>
<RefreshCcw className="h-3 w-3" />
<span>Retry</span>
</Button>
)}
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center gap-4">
<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 ? (
<div className="py-12 text-center">
<div className="text-muted-foreground mb-4">Failed to load product insights</div>
</div>
) : topProducts.length === 0 ? (
<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>
) : (
<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-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)`
: 'none'
}}
>
{!product.image && (
<ShoppingCart className="h-6 w-6 text-muted-foreground/50" />
)}
</div>
<div className="flex-grow min-w-0">
<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="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(() => { useEffect(() => {
setGreeting(getGreeting()); setGreeting(getGreeting());
fetchTopProducts(); fetchTopProducts();
}, []); }, []);
// Retry fetching top products data
const handleRetry = () => {
fetchTopProducts();
};
return ( return (
<div className="space-y-6"> <div className="space-y-10 pb-10">
<div> <motion.div
<h1 className="text-2xl font-semibold text-foreground"> initial={{ opacity: 0, y: -20 }}
{greeting}, {username}! animate={{ opacity: 1, y: 0 }}
</h1> className="flex flex-col md:flex-row md:items-end md:justify-between gap-4"
<p className="text-muted-foreground mt-1 italic text-sm"> >
"{randomQuote.text}" <span className="font-medium">{randomQuote.author}</span> <div>
</p> <h1 className="text-4xl font-bold tracking-tight text-foreground">
</div> {greeting}, <span className="text-primary">{username}</span>!
</h1>
{/* Order Statistics */} <p className="text-muted-foreground mt-2 text-lg">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 lg:gap-6"> "{randomQuote.text}" <span className="font-medium">{randomQuote.author}</span>
{statsConfig.map((stat) => ( </p>
<OrderStats </div>
key={stat.title} <div className="flex items-center gap-2">
title={stat.title} <EditDashboardButton
value={orderStats[stat.key as keyof OrderStatsData].toLocaleString()} isEditMode={isEditMode}
icon={stat.icon} onToggle={() => setIsEditMode(!isEditMode)}
/> />
))} <WidgetSettings
</div> widgets={widgets}
onToggle={toggleWidget}
onMove={moveWidget}
onReset={resetLayout}
onConfigure={(widget) => setConfiguredWidget(widget)}
/>
</div>
</motion.div>
{/* Best Selling Products Section */} <DashboardEditor
<div className="mt-8"> widgets={widgets}
<Card> isEditMode={isEditMode}
<CardHeader className="flex flex-row items-center justify-between pb-2"> onToggleEditMode={() => setIsEditMode(false)}
<div> onReorder={reorderWidgets}
<CardTitle>Your Best Selling Products</CardTitle> onReset={resetLayout}
<CardDescription>Products with the highest sales from your store</CardDescription> >
</div> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 auto-rows-min">
{error && ( {widgets.map((widget) => {
<Button if (!widget.visible && !isEditMode) return null;
variant="outline"
size="sm" return (
onClick={handleRetry} <DraggableWidget
className="flex items-center gap-1" key={widget.id}
widget={widget}
isEditMode={isEditMode}
onConfigure={() => setConfiguredWidget(widget)}
onToggleVisibility={() => toggleWidget(widget.id)}
> >
<RefreshCcw className="h-3 w-3" /> {!widget.visible && isEditMode ? (
<span>Retry</span> <div className="opacity-40 grayscale pointer-events-none h-full">
</Button> {renderWidget(widget)}
)}
</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" />
</div>
</div> </div>
))} ) : (
</div> renderWidget(widget)
) : error ? ( )}
// Error state </DraggableWidget>
<div className="py-8 text-center"> );
<div className="text-muted-foreground mb-4">Failed to load products</div> })}
</div> </div>
) : topProducts.length === 0 ? ( </DashboardEditor>
// Empty state
<div className="py-8 text-center"> {/* Widget Settings Modal */}
<ShoppingCart className="h-12 w-12 mx-auto text-muted-foreground mb-4" /> <WidgetSettingsModal
<h3 className="text-lg font-medium mb-2">No products sold yet</h3> widget={configuredWidget}
<p className="text-muted-foreground"> open={!!configuredWidget}
Your best-selling products will appear here after you make some sales. onOpenChange={(open) => !open && setConfiguredWidget(null)}
</p> onSave={(widgetId, settings, colSpan) => {
</div> updateWidgetSettings(widgetId, settings);
) : ( if (colSpan !== undefined) updateWidgetColSpan(widgetId, colSpan);
// 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="h-12 w-12 bg-cover bg-center rounded-md border flex-shrink-0 flex items-center justify-center overflow-hidden"
style={{
backgroundImage: product.image
? `url(/api/products/${product.id}/image)`
: 'none'
}}
>
{!product.image && (
<ShoppingCart className="h-6 w-6 text-muted-foreground" />
)}
</div>
<div className="flex-grow min-w-0">
<h4 className="font-medium truncate">{product.name}</h4>
</div>
<div className="text-right">
<div className="font-medium">{product.count} sold</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div> </div>
); );
} }

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -1,23 +1,61 @@
import type { LucideIcon } from "lucide-react" import type { LucideIcon } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { motion } from "framer-motion"
import Link from "next/link"
interface OrderStatsProps { interface OrderStatsProps {
title: string title: string
value: string value: string
icon: LucideIcon 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 ( return (
<Card> <motion.div
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> initial={{ opacity: 0, scale: 0.95 }}
<CardTitle className="text-sm font-medium">{title}</CardTitle> animate={{ opacity: 1, scale: 1 }}
<Icon className="h-4 w-4 text-muted-foreground" /> transition={{ delay: index * 0.05 }}
</CardHeader> >
<CardContent> <CardWrapper {...wrapperProps as any}>
<div className="text-2xl font-bold">{value}</div> <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" : ""}`}>
</CardContent> <div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
</Card> <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 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>
) )
} }

View 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>
)
}

View 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}
/>
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -2,6 +2,7 @@
import { ChangeEvent } from "react"; import { ChangeEvent } from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import Image from "next/image";
interface ImageUploadProps { interface ImageUploadProps {
imagePreview: string | null; imagePreview: string | null;
@@ -21,9 +22,15 @@ export const ImageUpload = ({
style={{ width: imageDimensions.width, height: imageDimensions.height }} style={{ width: imageDimensions.width, height: imageDimensions.height }}
> >
{imagePreview ? ( {imagePreview ? (
<img <Image
src={imagePreview} src={imagePreview}
alt="Preview" 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" className="object-contain w-full h-full rounded-md"
/> />
) : ( ) : (

View File

@@ -3,7 +3,6 @@
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { LogIn } from "lucide-react"; import { LogIn } from "lucide-react";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { useState } from "react"; import { useState } from "react";
export function HomeNavbar() { export function HomeNavbar() {
@@ -27,16 +26,16 @@ export function HomeNavbar() {
Log In Log In
</Button> </Button>
</Link> </Link>
<Link href="/auth/login"> <Link href="/dashboard">
<Button className="bg-[#D53F8C] hover:bg-[#B83280] text-white border-0">Get Started</Button> <Button className="bg-indigo-600 hover:bg-indigo-700 text-white border-0">Get Started</Button>
</Link> </Link>
</nav> </nav>
<div className="md:hidden"> <div className="md:hidden">
<Button variant="ghost" size="icon" onClick={() => setMenuOpen(!menuOpen)} className="text-white hover:bg-gray-900"> <Button variant="ghost" size="icon" onClick={() => setMenuOpen(!menuOpen)} className="text-white hover:bg-gray-900">
<span className="sr-only">Toggle menu</span> <span className="sr-only">Toggle menu</span>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="24" width="24"
height="24" height="24"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
@@ -51,42 +50,42 @@ export function HomeNavbar() {
</svg> </svg>
</Button> </Button>
</div> </div>
{/* Mobile menu */} {/* Mobile menu */}
{menuOpen && ( {menuOpen && (
<div className="md:hidden absolute top-16 left-0 right-0 bg-[#1C1C1C] border-b border-gray-800 p-4 z-50"> <div className="md:hidden absolute top-16 left-0 right-0 bg-[#1C1C1C] border-b border-gray-800 p-4 z-50">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Link <Link
href="#features" href="#features"
className="text-sm p-2 hover:bg-gray-900 rounded-md text-gray-300" className="text-sm p-2 hover:bg-gray-900 rounded-md text-gray-300"
onClick={() => setMenuOpen(false)} onClick={() => setMenuOpen(false)}
> >
Features Features
</Link> </Link>
<Link <Link
href="#benefits" href="#benefits"
className="text-sm p-2 hover:bg-gray-900 rounded-md text-gray-300" className="text-sm p-2 hover:bg-gray-900 rounded-md text-gray-300"
onClick={() => setMenuOpen(false)} onClick={() => setMenuOpen(false)}
> >
Benefits Benefits
</Link> </Link>
<Link <Link
href="/auth/login" href="/auth/login"
className="text-sm p-2 hover:bg-gray-900 rounded-md text-gray-300" className="text-sm p-2 hover:bg-gray-900 rounded-md text-gray-300"
onClick={() => setMenuOpen(false)} onClick={() => setMenuOpen(false)}
> >
Log In Log In
</Link> </Link>
<Link <Link
href="/auth/register" href="/dashboard"
className="text-sm p-2 hover:bg-gray-900 rounded-md text-gray-300" className="text-sm p-2 hover:bg-gray-900 rounded-md text-gray-300"
onClick={() => setMenuOpen(false)} onClick={() => setMenuOpen(false)}
> >
Create Account Get Started
</Link> </Link>
</div> </div>
</div> </div>
)} )}
</header> </header>
); );
} }

View File

@@ -7,6 +7,7 @@ import { Send, Bold, Italic, Code, Link as LinkIcon, Image as ImageIcon, X, Eye,
import { toast } from "sonner"; import { toast } from "sonner";
import { clientFetch } from "@/lib/api"; import { clientFetch } from "@/lib/api";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import Image from "next/image";
import ProductSelector from "./product-selector"; import ProductSelector from "./product-selector";
const ReactMarkdown = lazy(() => import('react-markdown')); const ReactMarkdown = lazy(() => import('react-markdown'));
@@ -275,9 +276,15 @@ __italic text__
{imagePreview && ( {imagePreview && (
<div className="relative"> <div className="relative">
<img <Image
src={imagePreview} 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" className="max-h-[200px] rounded-md object-contain"
/> />
<Button <Button

View File

@@ -22,6 +22,7 @@ import type React from "react";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { apiRequest } from "@/lib/api"; import { apiRequest } from "@/lib/api";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
type CategorySelectProps = { type CategorySelectProps = {
categories: { _id: string; name: string; parentId?: string }[]; categories: { _id: string; name: string; parentId?: string }[];
@@ -213,141 +214,40 @@ const ProductBasicInfo: React.FC<{
setProductData: React.Dispatch<React.SetStateAction<ProductData>>; setProductData: React.Dispatch<React.SetStateAction<ProductData>>;
onAddCategory: (newCategory: { _id: string; name: string; parentId?: string }) => void; onAddCategory: (newCategory: { _id: string; name: string; parentId?: string }) => void;
}> = ({ productData, handleChange, categories, setProductData, onAddCategory }) => ( }> = ({ productData, handleChange, categories, setProductData, onAddCategory }) => (
<div className="space-y-6"> <div className="space-y-8">
<div> <div className="space-y-4">
<label htmlFor="name" className="text-sm font-medium"> <div className="grid gap-2">
Product Name <label htmlFor="name" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
</label> Product Name
<Input
id="name"
name="name"
value={productData.name}
onChange={handleChange}
placeholder="Enter product name"
/>
</div>
<div>
<label htmlFor="description" className="text-sm font-medium">
Description
</label>
<textarea
id="description"
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"
/>
</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="flex items-center space-x-2">
<Switch
id="enabled"
checked={productData.enabled !== false}
onCheckedChange={(checked) => {
setProductData({
...productData,
enabled: checked
});
}}
/>
<label htmlFor="enabled" className="text-sm">
Enable Product
</label> </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
</label>
<Input
id="currentStock"
name="currentStock"
type="number"
min="0"
step={productData.unitType === 'gr' || productData.unitType === 'ml' ? '0.1' : '1'}
value={productData.currentStock || 0}
onChange={handleChange}
placeholder="0"
/>
</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>
<label htmlFor="costPerUnit" className="text-sm font-medium">
Cost Per Unit (Optional)
</label>
<p className="text-xs text-muted-foreground mb-2">
How much you paid for each unit of this product
</p>
<Input <Input
id="costPerUnit" id="name"
name="costPerUnit" name="name"
type="number" value={productData.name}
min="0"
step="0.01"
value={productData.costPerUnit || ''}
onChange={handleChange} onChange={handleChange}
placeholder="0.00" placeholder="e.g. Premium Wireless Headphones"
className="border-border/50 bg-background/50 focus:bg-background transition-colors"
/>
</div>
<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
id="description"
name="description"
value={productData.description}
onChange={handleChange}
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> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div className="space-y-2">
<label className="text-sm font-medium">Category</label> <label className="text-sm font-medium leading-none">Category</label>
<CategorySelect <CategorySelect
categories={categories} categories={categories}
value={productData.category} value={productData.category}
@@ -355,8 +255,8 @@ const ProductBasicInfo: React.FC<{
onAddCategory={onAddCategory} onAddCategory={onAddCategory}
/> />
</div> </div>
<div> <div className="space-y-2">
<label className="text-sm font-medium">Unit Type</label> <label className="text-sm font-medium leading-none">Unit Type</label>
<UnitTypeSelect <UnitTypeSelect
value={productData.unitType} value={productData.unitType}
setProductData={setProductData} setProductData={setProductData}
@@ -364,6 +264,117 @@ const ProductBasicInfo: React.FC<{
/> />
</div> </div>
</div> </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">
<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="stockTracking"
checked={productData.stockTracking !== false}
onCheckedChange={(checked) => {
setProductData({
...productData,
stockTracking: checked
});
}}
/>
</div>
{productData.stockTracking !== false && (
<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"
name="currentStock"
type="number"
min="0"
step={productData.unitType === 'gr' || productData.unitType === 'ml' ? '0.1' : '1'}
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-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
</label>
<div className="relative">
<span className="absolute left-3 top-2.5 text-muted-foreground text-sm">$</span>
<Input
id="costPerUnit"
name="costPerUnit"
type="number"
min="0"
step="0.01"
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="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>
<Switch
id="enabled"
checked={productData.enabled !== false}
onCheckedChange={(checked) => {
setProductData({
...productData,
enabled: checked
});
}}
/>
</div>
</div> </div>
); );

View File

@@ -5,9 +5,10 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; 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 { toast } from "sonner";
import { apiRequest } from "@/lib/api"; import { apiRequest } from "@/lib/api";
import { motion, AnimatePresence } from "framer-motion";
interface ProfitAnalysisModalProps { interface ProfitAnalysisModalProps {
open: boolean; open: boolean;
@@ -69,7 +70,11 @@ export const ProfitAnalysisModal: React.FC<ProfitAnalysisModalProps> = ({
const formatCurrency = (amount: number | null) => { const formatCurrency = (amount: number | null) => {
if (amount === null) return "N/A"; 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) => { const formatPercentage = (percentage: number | null) => {
@@ -79,7 +84,7 @@ export const ProfitAnalysisModal: React.FC<ProfitAnalysisModalProps> = ({
const getProfitColor = (profit: number | null) => { const getProfitColor = (profit: number | null) => {
if (profit === null) return "text-muted-foreground"; 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) => { const getProfitIcon = (profit: number | null) => {
@@ -87,17 +92,33 @@ export const ProfitAnalysisModal: React.FC<ProfitAnalysisModalProps> = ({
return profit >= 0 ? TrendingUp : TrendingDown; 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) { if (loading) {
return ( return (
<Dialog open={open} onOpenChange={onClose}> <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> <DialogHeader>
<DialogTitle>Profit Analysis - {productName}</DialogTitle> <DialogTitle>Profit Analysis - {productName}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="text-center"> <div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div> <Loader2 className="h-8 w-8 animate-spin text-primary mx-auto mb-2" />
<p className="text-muted-foreground">Loading profit analysis...</p> <p className="text-muted-foreground">Calculating metrics...</p>
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
@@ -108,7 +129,7 @@ export const ProfitAnalysisModal: React.FC<ProfitAnalysisModalProps> = ({
if (!profitData) { if (!profitData) {
return ( return (
<Dialog open={open} onOpenChange={onClose}> <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> <DialogHeader>
<DialogTitle>Profit Analysis - {productName}</DialogTitle> <DialogTitle>Profit Analysis - {productName}</DialogTitle>
</DialogHeader> </DialogHeader>
@@ -122,154 +143,194 @@ export const ProfitAnalysisModal: React.FC<ProfitAnalysisModalProps> = ({
return ( return (
<Dialog open={open} onOpenChange={onClose}> <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> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2 text-xl">
<DollarSign className="h-5 w-5" /> <div className="p-2 rounded-lg bg-indigo-500/10 border border-indigo-500/20">
Profit Analysis - {productName} <DollarSign className="h-5 w-5 text-indigo-400" />
</div>
<span>Profit Analysis: <span className="text-muted-foreground font-normal">{productName}</span></span>
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-6"> <motion.div
className="space-y-6 py-4"
variants={containerVariants}
initial="hidden"
animate="visible"
>
{/* Summary Cards */} {/* Summary Cards */}
{profitData.summary.hasCostData ? ( {profitData.summary.hasCostData ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card> <motion.div variants={itemVariants}>
<CardHeader className="pb-2"> <Card className="bg-emerald-500/5 border-emerald-500/20 backdrop-blur-sm hover:bg-emerald-500/10 transition-colors">
<CardTitle className="text-sm font-medium">Average Profit</CardTitle> <CardHeader className="pb-2">
</CardHeader> <CardTitle className="text-sm font-medium text-emerald-400">Average Profit</CardTitle>
<CardContent> </CardHeader>
<div className="text-2xl font-bold text-green-600"> <CardContent>
{formatCurrency(profitData.summary.averageProfit)} <div className="text-3xl font-bold text-emerald-500">
</div> {formatCurrency(profitData.summary.averageProfit)}
<p className="text-xs text-muted-foreground">Per unit sold</p> </div>
</CardContent> <p className="text-xs text-emerald-400/60 mt-1">Per unit sold</p>
</Card> </CardContent>
</Card>
</motion.div>
<Card> <motion.div variants={itemVariants}>
<CardHeader className="pb-2"> <Card className="bg-blue-500/5 border-blue-500/20 backdrop-blur-sm hover:bg-blue-500/10 transition-colors">
<CardTitle className="text-sm font-medium">Average Profit Margin</CardTitle> <CardHeader className="pb-2">
</CardHeader> <CardTitle className="text-sm font-medium text-blue-400">Avg. Margin</CardTitle>
<CardContent> </CardHeader>
<div className="text-2xl font-bold text-blue-600"> <CardContent>
{formatPercentage(profitData.summary.averageProfitMargin)} <div className="text-3xl font-bold text-blue-500">
</div> {formatPercentage(profitData.summary.averageProfitMargin)}
<p className="text-xs text-muted-foreground">Of selling price</p> </div>
</CardContent> <p className="text-xs text-blue-400/60 mt-1">Of selling price</p>
</Card> </CardContent>
</Card>
</motion.div>
<Card> <motion.div variants={itemVariants}>
<CardHeader className="pb-2"> <Card className="bg-indigo-500/5 border-indigo-500/20 backdrop-blur-sm hover:bg-indigo-500/10 transition-colors">
<CardTitle className="text-sm font-medium">Average Markup</CardTitle> <CardHeader className="pb-2">
</CardHeader> <CardTitle className="text-sm font-medium text-indigo-400">Avg. Markup</CardTitle>
<CardContent> </CardHeader>
<div className="text-2xl font-bold text-purple-600"> <CardContent>
{formatPercentage(profitData.summary.averageMarkup)} <div className="text-3xl font-bold text-indigo-500">
</div> {formatPercentage(profitData.summary.averageMarkup)}
<p className="text-xs text-muted-foreground">On cost price</p> </div>
</CardContent> <p className="text-xs text-indigo-400/60 mt-1">On cost price</p>
</Card> </CardContent>
</Card>
</motion.div>
</div> </div>
) : ( ) : (
<Card> <motion.div variants={itemVariants}>
<CardContent className="pt-6"> <Card className="border-dashed border-2 border-muted bg-muted/20">
<div className="text-center"> <CardContent className="pt-6">
<Calculator className="h-12 w-12 text-muted-foreground mx-auto mb-4" /> <div className="text-center py-6">
<h3 className="text-lg font-medium mb-2">No Cost Data Available</h3> <Calculator className="h-12 w-12 text-muted-foreground mx-auto mb-4 opacity-50" />
<p className="text-muted-foreground mb-4"> <h3 className="text-lg font-medium mb-2">Missing Cost Data</h3>
Add a cost per unit to this product to see profit calculations. <p className="text-muted-foreground mb-4 max-w-sm mx-auto">
</p> Add a generic "Cost Per Unit" to this product to see detailed profit calculations.
<Badge variant="outline">Cost Per Unit: {formatCurrency(profitData.costPerUnit)}</Badge> </p>
</div> <Badge variant="outline" className="text-sm py-1 px-3">
</CardContent> Current Cost: {formatCurrency(profitData.costPerUnit)}
</Card> </Badge>
</div>
</CardContent>
</Card>
</motion.div>
)} )}
{/* Cost Information */} {/* Cost Information */}
<Card> <motion.div variants={itemVariants}>
<CardHeader> <Card className="bg-white/5 border-white/10 backdrop-blur-sm">
<CardTitle className="text-base">Cost Information</CardTitle> <CardContent className="p-4">
</CardHeader> <div className="flex items-center justify-between">
<CardContent> <div className="flex items-center gap-3">
<div className="flex items-center justify-between"> <div className="p-2 rounded-md bg-muted/50">
<span className="text-sm font-medium">Cost Per Unit:</span> <Info className="h-4 w-4 text-muted-foreground" />
<span className="text-lg font-semibold">{formatCurrency(profitData.costPerUnit)}</span> </div>
</div> <span className="text-sm font-medium text-muted-foreground">Base Cost Per Unit</span>
</CardContent> </div>
</Card> <span className="text-xl font-bold text-white">{formatCurrency(profitData.costPerUnit)}</span>
</div>
</CardContent>
</Card>
</motion.div>
{/* Pricing Tier Analysis */} {/* Pricing Tier Analysis */}
<Card> <motion.div variants={itemVariants} className="space-y-3">
<CardHeader> <h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider pl-1">Tier Breakdown</h3>
<CardTitle className="text-base">Pricing Tier Analysis</CardTitle> <div className="space-y-3">
</CardHeader> {profitData.profitMargins
<CardContent> .sort((a, b) => a.minQuantity - b.minQuantity)
<div className="space-y-4"> .map((tier, index) => {
{profitData.profitMargins
.sort((a, b) => a.minQuantity - b.minQuantity)
.map((tier, index) => {
const ProfitIcon = getProfitIcon(tier.profit); const ProfitIcon = getProfitIcon(tier.profit);
const totalProfitForMinQty = tier.profit !== null ? tier.profit * tier.minQuantity : null; const totalProfitForMinQty = tier.profit !== null ? tier.profit * tier.minQuantity : null;
const totalRevenueForMinQty = tier.pricePerUnit * tier.minQuantity; const totalRevenueForMinQty = tier.pricePerUnit * tier.minQuantity;
const totalCostForMinQty = profitData.costPerUnit * tier.minQuantity; const totalCostForMinQty = profitData.costPerUnit * tier.minQuantity;
return ( return (
<div <motion.div
key={index} 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"> <div className={`absolute left-0 top-0 bottom-0 w-1 ${tier.profit && tier.profit >= 0 ? 'bg-emerald-500' : 'bg-rose-500'}`} />
<ProfitIcon className={`h-5 w-5 ${getProfitColor(tier.profit)}`} />
<div> <div className="flex flex-col sm:flex-row sm:items-center justify-between p-4 pl-6 gap-4">
<p className="font-medium"> <div className="space-y-1">
{tier.minQuantity}+ units @ {formatCurrency(tier.pricePerUnit)} <div className="flex items-center gap-2">
</p> <Badge variant="secondary" className="font-mono text-xs">
<p className="text-sm text-muted-foreground"> {tier.minQuantity}+ UNITS
Revenue for {tier.minQuantity} units: {formatCurrency(totalRevenueForMinQty)} </Badge>
</p> <span className="text-muted-foreground text-sm">at</span>
<p className="text-sm text-muted-foreground"> <span className="font-bold text-white text-lg">{formatCurrency(tier.pricePerUnit)}</span>
Cost for {tier.minQuantity} units: {formatCurrency(totalCostForMinQty)} </div>
</p> <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="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>
<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>
</div> </div>
</motion.div>
<div className="text-right space-y-1">
<div className={`font-medium ${getProfitColor(totalProfitForMinQty)}`}>
Total Profit: {formatCurrency(totalProfitForMinQty)}
</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>
</div>
</div>
); );
})} })}
</div> </div>
</CardContent> </motion.div>
</Card>
{/* Help Text */} {/* Help Text */}
<Card className="bg-muted/50"> <motion.div variants={itemVariants}>
<CardContent className="pt-6"> <div className="bg-indigo-500/5 rounded-lg border border-indigo-500/10 p-4">
<div className="space-y-2 text-sm"> <h4 className="flex items-center gap-2 text-sm font-medium text-indigo-300 mb-2">
<h4 className="font-medium">Understanding the Metrics:</h4> <Info className="h-4 w-4" />
<ul className="space-y-1 text-muted-foreground"> Quick Guide
<li><strong>Profit:</strong> Selling price minus cost price</li> </h4>
<li><strong>Profit Margin:</strong> Profit as a percentage of selling price</li> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs text-muted-foreground">
<li><strong>Markup:</strong> Profit as a percentage of cost price</li> <div>
</ul> <span className="text-indigo-200 font-semibold block mb-0.5">Profit</span>
Selling Price - Cost Price
</div>
<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>
</CardContent> </div>
</Card> </motion.div>
</div> </motion.div>
<div className="flex justify-end pt-4"> <div className="flex justify-end pt-2">
<Button onClick={onClose}>Close</Button> <Button onClick={onClose} variant="secondary" className="hover:bg-white/20">Close Analysis</Button>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -18,7 +18,7 @@ import { useNotifications } from "@/lib/notification-context";
export default function UnifiedNotifications() { export default function UnifiedNotifications() {
const router = useRouter(); const router = useRouter();
const [activeTab, setActiveTab] = useState<string>("all"); const [activeTab, setActiveTab] = useState<string>("all");
// Get notification state from context // Get notification state from context
const { const {
unreadCounts, unreadCounts,
@@ -33,11 +33,11 @@ export default function UnifiedNotifications() {
const handleChatClick = (chatId: string) => { const handleChatClick = (chatId: string) => {
router.push(`/dashboard/chats/${chatId}`); router.push(`/dashboard/chats/${chatId}`);
}; };
const handleOrderClick = (orderId: string) => { const handleOrderClick = (orderId: string) => {
router.push(`/dashboard/orders/${orderId}`); router.push(`/dashboard/orders/${orderId}`);
}; };
// Format the price as currency // Format the price as currency
const formatPrice = (price: number) => { const formatPrice = (price: number) => {
return `£${price.toFixed(2)}`; return `£${price.toFixed(2)}`;
@@ -49,8 +49,8 @@ export default function UnifiedNotifications() {
<Button variant="ghost" size="icon" className="relative" disabled={loading}> <Button variant="ghost" size="icon" className="relative" disabled={loading}>
<BellRing className="h-5 w-5" /> <BellRing className="h-5 w-5" />
{totalNotifications > 0 && ( {totalNotifications > 0 && (
<Badge <Badge
variant="destructive" variant="destructive"
className="absolute -top-1 -right-1 px-1.5 py-0.5 text-xs" className="absolute -top-1 -right-1 px-1.5 py-0.5 text-xs"
> >
{totalNotifications} {totalNotifications}
@@ -58,12 +58,12 @@ export default function UnifiedNotifications() {
)} )}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80"> <DropdownMenuContent align="end" className="w-80" collisionPadding={10}>
<div className="p-2 border-b"> <div className="p-2 border-b">
<Tabs defaultValue="all" value={activeTab} onValueChange={setActiveTab}> <Tabs defaultValue="all" value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3"> <TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="all" className="text-xs"> <TabsTrigger value="all" className="text-xs">
All All
{totalNotifications > 0 && ( {totalNotifications > 0 && (
<Badge variant="secondary" className="ml-1"> <Badge variant="secondary" className="ml-1">
{totalNotifications} {totalNotifications}
@@ -87,7 +87,7 @@ export default function UnifiedNotifications() {
)} )}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="all" className="m-0"> <TabsContent value="all" className="m-0">
{totalNotifications === 0 ? ( {totalNotifications === 0 ? (
<div className="p-4 flex items-center justify-center"> <div className="p-4 flex items-center justify-center">
@@ -102,7 +102,7 @@ export default function UnifiedNotifications() {
Unread Messages Unread Messages
</div> </div>
{Object.entries(unreadCounts.chatCounts).slice(0, 3).map(([chatId, count]) => ( {Object.entries(unreadCounts.chatCounts).slice(0, 3).map(([chatId, count]) => (
<DropdownMenuItem <DropdownMenuItem
key={`chat-${chatId}`} key={`chat-${chatId}`}
className="p-3 cursor-pointer" className="p-3 cursor-pointer"
onClick={() => handleChatClick(chatId)} onClick={() => handleChatClick(chatId)}
@@ -130,15 +130,15 @@ export default function UnifiedNotifications() {
)} )}
</> </>
)} )}
{/* Orders Section */} {/* Orders Section */}
{newOrders.length > 0 && ( {newOrders.length > 0 && (
<> <>
<div className="px-3 py-2 text-xs font-medium bg-muted/50 flex justify-between items-center"> <div className="px-3 py-2 text-xs font-medium bg-muted/50 flex justify-between items-center">
<span>New Paid Orders</span> <span>New Paid Orders</span>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
clearOrderNotifications(); clearOrderNotifications();
@@ -149,7 +149,7 @@ export default function UnifiedNotifications() {
</Button> </Button>
</div> </div>
{newOrders.slice(0, 3).map((order) => ( {newOrders.slice(0, 3).map((order) => (
<DropdownMenuItem <DropdownMenuItem
key={`order-${order._id}`} key={`order-${order._id}`}
className="p-3 cursor-pointer" className="p-3 cursor-pointer"
onClick={() => handleOrderClick(order._id)} onClick={() => handleOrderClick(order._id)}
@@ -178,7 +178,7 @@ export default function UnifiedNotifications() {
</div> </div>
)} )}
</TabsContent> </TabsContent>
<TabsContent value="messages" className="m-0"> <TabsContent value="messages" className="m-0">
{unreadCounts.totalUnread === 0 ? ( {unreadCounts.totalUnread === 0 ? (
<div className="p-4 flex items-center justify-center"> <div className="p-4 flex items-center justify-center">
@@ -188,7 +188,7 @@ export default function UnifiedNotifications() {
<> <>
<div className="max-h-96 overflow-y-auto"> <div className="max-h-96 overflow-y-auto">
{Object.entries(unreadCounts.chatCounts).map(([chatId, count]) => ( {Object.entries(unreadCounts.chatCounts).map(([chatId, count]) => (
<DropdownMenuItem <DropdownMenuItem
key={`chat-tab-${chatId}`} key={`chat-tab-${chatId}`}
className="p-3 cursor-pointer" className="p-3 cursor-pointer"
onClick={() => handleChatClick(chatId)} onClick={() => handleChatClick(chatId)}
@@ -211,8 +211,8 @@ export default function UnifiedNotifications() {
))} ))}
</div> </div>
<div className="p-2 border-t"> <div className="p-2 border-t">
<Button <Button
variant="outline" variant="outline"
className="w-full" className="w-full"
onClick={() => router.push('/dashboard/chats')} onClick={() => router.push('/dashboard/chats')}
> >
@@ -222,7 +222,7 @@ export default function UnifiedNotifications() {
</> </>
)} )}
</TabsContent> </TabsContent>
<TabsContent value="orders" className="m-0"> <TabsContent value="orders" className="m-0">
{newOrders.length === 0 ? ( {newOrders.length === 0 ? (
<div className="p-4 flex items-center justify-center"> <div className="p-4 flex items-center justify-center">
@@ -232,9 +232,9 @@ export default function UnifiedNotifications() {
<> <>
<div className="px-3 py-2 text-xs font-medium bg-muted/50 flex justify-between items-center"> <div className="px-3 py-2 text-xs font-medium bg-muted/50 flex justify-between items-center">
<span>New Paid Orders</span> <span>New Paid Orders</span>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
clearOrderNotifications(); clearOrderNotifications();
@@ -246,7 +246,7 @@ export default function UnifiedNotifications() {
</div> </div>
<div className="max-h-96 overflow-y-auto"> <div className="max-h-96 overflow-y-auto">
{newOrders.map((order) => ( {newOrders.map((order) => (
<DropdownMenuItem <DropdownMenuItem
key={`order-tab-${order._id}`} key={`order-tab-${order._id}`}
className="p-3 cursor-pointer" className="p-3 cursor-pointer"
onClick={() => handleOrderClick(order._id)} onClick={() => handleOrderClick(order._id)}
@@ -267,8 +267,8 @@ export default function UnifiedNotifications() {
))} ))}
</div> </div>
<div className="p-2 border-t"> <div className="p-2 border-t">
<Button <Button
variant="outline" variant="outline"
className="w-full" className="w-full"
onClick={() => router.push('/dashboard/orders')} onClick={() => router.push('/dashboard/orders')}
> >

View 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>
)
}

View File

@@ -2,6 +2,8 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import React from "react"; import React from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useSearchParams } from "next/navigation";
import { import {
Table, Table,
TableBody, TableBody,
@@ -11,6 +13,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -127,9 +130,12 @@ const PageSizeSelector = ({ currentSize, onChange, options }: { currentSize: num
export default function OrderTable() { export default function OrderTable() {
const searchParams = useSearchParams();
const initialStatus = searchParams?.get("status") || "all";
const [orders, setOrders] = useState<Order[]>([]); const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState(initialStatus);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [totalOrders, setTotalOrders] = useState(0); const [totalOrders, setTotalOrders] = useState(0);
@@ -155,6 +161,15 @@ export default function OrderTable() {
}, []); }, []);
// Fetch orders with server-side pagination // 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 () => { const fetchOrders = useCallback(async () => {
try { try {
setLoading(true); setLoading(true);
@@ -167,7 +182,7 @@ export default function OrderTable() {
}); });
const data = await clientFetch(`/orders?${queryParams}`); const data = await clientFetch(`/orders?${queryParams}`);
console.log("Fetched orders with fresh data:", data.orders?.length || 0); console.log("Fetched orders with fresh data:", data.orders?.length || 0);
setOrders(data.orders || []); setOrders(data.orders || []);
setTotalPages(data.totalPages || 1); setTotalPages(data.totalPages || 1);
@@ -238,37 +253,56 @@ export default function OrderTable() {
return; 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 { try {
setIsShipping(true); setIsShipping(true);
const response = await clientFetch("/orders/mark-shipped", { const response = await clientFetch("/orders/mark-shipped", {
method: "POST", 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) { if (response.success && response.success.orders) {
const successfulOrderIds = new Set(response.success.orders.map((o: any) => o.id)); const successfulOrderIds = new Set(response.success.orders.map((o: any) => o.id));
setOrders(prev => // If some orders failed, revert those specifically
prev.map(order =>
successfulOrderIds.has(order._id)
? { ...order, status: "shipped" }
: order
)
);
if (response.failed && response.failed.count > 0) { 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)) {
if (response.success.count > 0) { // Find original status from previousOrders
toast.success(`${response.success.count} orders marked as shipped`); const originalOrder = previousOrders.find(o => o._id === order._id);
return originalOrder || order;
}
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) { } 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); console.error("Shipping error:", error);
} finally { } finally {
setIsShipping(false); setIsShipping(false);
@@ -276,68 +310,69 @@ export default function OrderTable() {
}; };
const statusConfig: Record<OrderStatus, StatusConfig> = { const statusConfig: Record<OrderStatus, StatusConfig> = {
acknowledged: { acknowledged: {
icon: CheckCircle2, icon: CheckCircle2,
color: "text-white", color: "text-purple-100",
bgColor: "bg-purple-600" bgColor: "bg-purple-600/90 shadow-[0_0_10px_rgba(147,51,234,0.3)]"
}, },
paid: { paid: {
icon: CheckCircle2, icon: CheckCircle2,
color: "text-white", color: "text-emerald-100",
bgColor: "bg-emerald-600" bgColor: "bg-emerald-600/90 shadow-[0_0_10px_rgba(16,185,129,0.3)]",
animate: "animate-pulse"
}, },
unpaid: { unpaid: {
icon: XCircle, icon: XCircle,
color: "text-white", color: "text-amber-100",
bgColor: "bg-red-500" bgColor: "bg-amber-500/90"
}, },
confirming: { confirming: {
icon: Loader2, icon: Loader2,
color: "text-white", color: "text-blue-100",
bgColor: "bg-yellow-500", bgColor: "bg-blue-500/90",
animate: "animate-spin" animate: "animate-spin"
}, },
shipped: { shipped: {
icon: Truck, icon: Truck,
color: "text-white", color: "text-indigo-100",
bgColor: "bg-blue-600" bgColor: "bg-indigo-600/90 shadow-[0_0_10px_rgba(79,70,229,0.3)]"
}, },
completed: { completed: {
icon: CheckCircle2, icon: CheckCircle2,
color: "text-white", color: "text-green-100",
bgColor: "bg-green-600" bgColor: "bg-green-600/90"
}, },
cancelled: { cancelled: {
icon: XCircle, icon: XCircle,
color: "text-white", color: "text-gray-100",
bgColor: "bg-gray-500" bgColor: "bg-gray-600/90"
} }
}; };
// Helper function to determine if order is underpaid // Helper function to determine if order is underpaid
const isOrderUnderpaid = (order: Order) => { const isOrderUnderpaid = (order: Order) => {
// More robust check - only show underpaid if status allows it and underpayment exists // More robust check - only show underpaid if status allows it and underpayment exists
return order.underpaid === true && return order.underpaid === true &&
order.underpaymentAmount && order.underpaymentAmount &&
order.underpaymentAmount > 0 && order.underpaymentAmount > 0 &&
order.status !== "paid" && order.status !== "paid" &&
order.status !== "completed" && order.status !== "completed" &&
order.status !== "shipped" && order.status !== "shipped" &&
order.status !== "cancelled"; order.status !== "cancelled";
}; };
// Helper function to get underpaid display info // Helper function to get underpaid display info
const getUnderpaidInfo = (order: Order) => { const getUnderpaidInfo = (order: Order) => {
if (!isOrderUnderpaid(order)) return null; if (!isOrderUnderpaid(order)) return null;
const received = order.lastBalanceReceived || 0; const received = order.lastBalanceReceived || 0;
const required = order.cryptoTotal || 0; const required = order.cryptoTotal || 0;
const missing = order.underpaymentAmount || 0; const missing = order.underpaymentAmount || 0;
// Calculate LTC to GBP exchange rate from order data // Calculate LTC to GBP exchange rate from order data
const ltcToGbpRate = required > 0 ? order.totalPrice / required : 0; const ltcToGbpRate = required > 0 ? order.totalPrice / required : 0;
const missingGbp = missing * ltcToGbpRate; const missingGbp = missing * ltcToGbpRate;
return { return {
received, received,
required, required,
@@ -377,7 +412,7 @@ export default function OrderTable() {
useEffect(() => { useEffect(() => {
// Check if we have any underpaid orders // Check if we have any underpaid orders
const hasUnderpaidOrders = orders.some(order => isOrderUnderpaid(order)); const hasUnderpaidOrders = orders.some(order => isOrderUnderpaid(order));
if (hasUnderpaidOrders) { if (hasUnderpaidOrders) {
console.log("Found underpaid orders, setting up refresh interval"); console.log("Found underpaid orders, setting up refresh interval");
const interval = setInterval(() => { const interval = setInterval(() => {
@@ -391,16 +426,16 @@ export default function OrderTable() {
return ( return (
<div className="space-y-4"> <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 */} {/* 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 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"> <div className="flex flex-col sm:flex-row gap-2 sm:items-center w-full lg:w-auto">
<StatusFilter <StatusFilter
currentStatus={statusFilter} currentStatus={statusFilter}
onChange={setStatusFilter} onChange={setStatusFilter}
/> />
<PageSizeSelector <PageSizeSelector
currentSize={itemsPerPage} currentSize={itemsPerPage}
onChange={(value) => handleItemsPerPageChange({ target: { value } } as React.ChangeEvent<HTMLSelectElement>)} onChange={(value) => handleItemsPerPageChange({ target: { value } } as React.ChangeEvent<HTMLSelectElement>)}
@@ -413,6 +448,7 @@ export default function OrderTable() {
disabled={exporting} disabled={exporting}
variant="outline" variant="outline"
size="sm" size="sm"
className="bg-background/50 border-border/50 hover:bg-muted/50 transition-colors"
> >
{exporting ? ( {exporting ? (
<> <>
@@ -428,12 +464,12 @@ export default function OrderTable() {
</Button> </Button>
)} )}
</div> </div>
<div className="flex items-center gap-2 self-end lg:self-auto"> <div className="flex items-center gap-2 self-end lg:self-auto">
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button disabled={selectedOrders.size === 0 || isShipping}> <Button disabled={selectedOrders.size === 0 || isShipping} className="shadow-md">
<Truck className="mr-2 h-5 w-5" /> <Truck className="mr-2 h-4 w-4" />
{isShipping ? ( {isShipping ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
@@ -445,7 +481,7 @@ export default function OrderTable() {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Mark Orders as Shipped</AlertDialogTitle> <AlertDialogTitle>Mark Orders as Shipped</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to mark {selectedOrders.size} order{selectedOrders.size !== 1 ? 's' : ''} as shipped? Are you sure you want to mark {selectedOrders.size} order{selectedOrders.size !== 1 ? 's' : ''} as shipped?
This action cannot be undone. This action cannot be undone.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
@@ -462,164 +498,294 @@ export default function OrderTable() {
</div> </div>
{/* Table */} {/* Table */}
<div className="relative"> <CardContent className="p-0 relative min-h-[400px]">
{loading && ( {loading && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center"> <div className="absolute inset-0 bg-black/60 backdrop-blur-[2px] flex items-center justify-center z-50">
<Loader2 className="h-6 w-6 animate-spin" /> <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>
)} )}
<div className="max-h-[calc(100vh-300px)] overflow-auto"> <div className="max-h-[calc(100vh-350px)] 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"> <Table>
<TableHeader className="bg-black/60 sticky top-0 z-10"> <TableHeader className="bg-muted/30 sticky top-0 z-20">
<TableRow> <TableRow className="hover:bg-transparent border-border/50">
<TableHead className="w-12"> <TableHead className="w-12">
<Checkbox <Checkbox
checked={selectedOrders.size === paginatedOrders.length && paginatedOrders.length > 0} checked={selectedOrders.size === paginatedOrders.length && paginatedOrders.length > 0}
onCheckedChange={toggleAll} onCheckedChange={toggleAll}
className="border-white/20 data-[state=checked]:bg-indigo-500 data-[state=checked]:border-indigo-500"
/> />
</TableHead> </TableHead>
<TableHead className="cursor-pointer" onClick={() => handleSort("orderId")}> <TableHead className="cursor-pointer hover:text-indigo-400 transition-colors text-zinc-400" onClick={() => handleSort("orderId")}>
Order ID <ArrowUpDown className="ml-2 inline h-4 w-4" /> Order ID <ArrowUpDown className="ml-2 inline h-3 w-3 opacity-50" />
</TableHead> </TableHead>
<TableHead className="cursor-pointer" onClick={() => handleSort("totalPrice")}> <TableHead className="cursor-pointer hover:text-indigo-400 transition-colors text-zinc-400" onClick={() => handleSort("totalPrice")}>
Total <ArrowUpDown className="ml-2 inline h-4 w-4" /> Total <ArrowUpDown className="ml-2 inline h-3 w-3 opacity-50" />
</TableHead> </TableHead>
<TableHead className="hidden lg:table-cell">Promotion</TableHead> <TableHead className="hidden lg:table-cell text-zinc-400">Promotion</TableHead>
<TableHead className="cursor-pointer" onClick={() => handleSort("status")}> <TableHead className="cursor-pointer hover:text-indigo-400 transition-colors text-zinc-400" onClick={() => handleSort("status")}>
Status <ArrowUpDown className="ml-2 inline h-4 w-4" /> Status <ArrowUpDown className="ml-2 inline h-3 w-3 opacity-50" />
</TableHead> </TableHead>
<TableHead className="hidden md:table-cell cursor-pointer" onClick={() => handleSort("orderDate")}> <TableHead className="hidden md:table-cell cursor-pointer hover:text-indigo-400 transition-colors text-zinc-400" onClick={() => handleSort("orderDate")}>
Order Date <ArrowUpDown className="ml-2 inline h-4 w-4" /> Date <ArrowUpDown className="ml-2 inline h-3 w-3 opacity-50" />
</TableHead> </TableHead>
<TableHead className="hidden xl:table-cell cursor-pointer" onClick={() => handleSort("paidAt")}> <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-4 w-4" /> Paid At <ArrowUpDown className="ml-2 inline h-3 w-3 opacity-50" />
</TableHead> </TableHead>
<TableHead className="hidden lg:table-cell">Buyer</TableHead> <TableHead className="hidden lg:table-cell text-zinc-400">Buyer</TableHead>
<TableHead className="w-24 text-center">Actions</TableHead> <TableHead className="w-24 text-center text-zinc-400">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{paginatedOrders.map((order) => { {isFirefox ? (
const StatusIcon = statusConfig[order.status as keyof typeof statusConfig]?.icon || XCircle; paginatedOrders.map((order, index) => {
const underpaidInfo = getUnderpaidInfo(order); const StatusIcon = statusConfig[order.status as keyof typeof statusConfig]?.icon || XCircle;
const underpaidInfo = getUnderpaidInfo(order);
return ( return (
<TableRow key={order._id}> <motion.tr
<TableCell> key={order._id}
<Checkbox initial={{ opacity: 0 }}
checked={selectedOrders.has(order._id)} animate={{ opacity: 1 }}
onCheckedChange={() => toggleSelection(order._id)} transition={{ duration: 0.2 }}
disabled={order.status !== "paid" && order.status !== "acknowledged"} className="group hover:bg-muted/50 border-b border-border/50 transition-colors"
/> >
</TableCell> <TableCell>
<TableCell>#{order.orderId}</TableCell> <Checkbox
<TableCell> checked={selectedOrders.has(order._id)}
<div className="flex flex-col"> onCheckedChange={() => toggleSelection(order._id)}
<span>£{order.totalPrice.toFixed(2)}</span> disabled={order.status !== "paid" && order.status !== "acknowledged"}
{underpaidInfo && ( className="border-white/20 data-[state=checked]:bg-indigo-500 data-[state=checked]:border-indigo-500"
<span className="text-xs text-red-400"> />
Missing: £{underpaidInfo.missingGbp.toFixed(2)} ({underpaidInfo.missing.toFixed(8)} LTC) </TableCell>
</span> <TableCell className="font-mono text-sm font-medium text-zinc-300">#{order.orderId}</TableCell>
)} <TableCell>
</div> <div className="flex flex-col">
</TableCell> <span className="font-medium text-zinc-200">£{order.totalPrice.toFixed(2)}</span>
<TableCell className="hidden lg:table-cell"> {underpaidInfo && (
{order.promotionCode ? ( <span className="text-[10px] text-red-400 flex items-center gap-1">
<div className="flex flex-col gap-1"> <AlertTriangle className="h-3 w-3" />
<div className="flex items-center gap-1"> -£{underpaidInfo.missingGbp.toFixed(2)}
<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">
{order.promotionCode}
</span> </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> </div>
<div className="flex items-center gap-1 text-xs text-green-600"> ) : (
<Percent className="h-3 w-3" /> <span className="text-xs text-zinc-600">-</span>
<span>-£{(order.discountAmount || 0).toFixed(2)}</span> )}
{order.subtotalBeforeDiscount && order.subtotalBeforeDiscount > 0 && ( </TableCell>
<span className="text-muted-foreground"> <TableCell>
(was £{order.subtotalBeforeDiscount.toFixed(2)}) <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 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> </span>
)} )}
</div> </div>
</div> </TableCell>
) : ( <TableCell className="hidden lg:table-cell">
<span className="text-xs text-muted-foreground">-</span> {order.promotionCode ? (
)} <div className="flex flex-col gap-1">
</TableCell> <div className="flex items-center gap-1">
<TableCell> <Tag className="h-3 w-3 text-emerald-400" />
<div className="flex items-center gap-2"> <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">
<div className={`inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm ${ {order.promotionCode}
statusConfig[order.status as OrderStatus]?.bgColor || "bg-gray-500" </span>
} ${statusConfig[order.status as OrderStatus]?.color || "text-white"}`}> </div>
{React.createElement(statusConfig[order.status as OrderStatus]?.icon || XCircle, { <div className="flex items-center gap-1 text-[10px] text-emerald-400/80">
className: `h-4 w-4 ${statusConfig[order.status as OrderStatus]?.animate || ""}` <Percent className="h-2.5 w-2.5" />
})} <span>-£{(order.discountAmount || 0).toFixed(2)}</span>
{order.status.charAt(0).toUpperCase() + order.status.slice(1)} </div>
</div> </div>
{isOrderUnderpaid(order) && ( ) : (
<div className="flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-red-600 text-white"> <span className="text-xs text-zinc-600">-</span>
<AlertTriangle className="h-3 w-3" /> )}
{underpaidInfo?.percentage}% </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> </div>
)} </TableCell>
</div> <TableCell className="hidden md:table-cell text-sm text-zinc-400">
</TableCell> {new Date(order.orderDate).toLocaleDateString("en-GB", {
<TableCell className="hidden md:table-cell"> day: '2-digit',
{new Date(order.orderDate).toLocaleDateString("en-GB", { month: 'short',
day: '2-digit', year: 'numeric',
month: 'short', })}
year: 'numeric', <span className="ml-1 opacity-50 text-[10px]">
hour: '2-digit', {new Date(order.orderDate).toLocaleTimeString("en-GB", { hour: '2-digit', minute: '2-digit' })}
minute: '2-digit', </span>
hour12: false </TableCell>
})} <TableCell className="hidden xl:table-cell text-sm text-zinc-400">
</TableCell> {order.paidAt ? new Date(order.paidAt).toLocaleDateString("en-GB", {
<TableCell className="hidden xl:table-cell"> day: '2-digit',
{order.paidAt ? new Date(order.paidAt).toLocaleDateString("en-GB", { month: 'short',
day: '2-digit', hour: '2-digit',
month: 'short', minute: '2-digit'
year: 'numeric', }) : "-"}
hour: '2-digit', </TableCell>
minute: '2-digit', <TableCell className="hidden lg:table-cell">
hour12: false {order.telegramUsername ? (
}) : "-"} <span className="text-sm font-medium text-primary">@{order.telegramUsername}</span>
</TableCell> ) : (
<TableCell className="hidden lg:table-cell"> <span className="text-xs text-zinc-500 italic">Guest</span>
{order.telegramUsername ? `@${order.telegramUsername}` : "-"} )}
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<div className="flex items-center justify-center gap-1"> <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}`}> <Link href={`/dashboard/orders/${order._id}`}>
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
</Link> </Link>
</Button> </Button>
{(order.telegramBuyerId || order.telegramUsername) && ( {(order.telegramBuyerId || order.telegramUsername) && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="icon"
asChild className="h-8 w-8 text-zinc-400 hover:text-indigo-400 hover:bg-indigo-500/10"
title={`Chat with customer${order.telegramUsername ? ` @${order.telegramUsername}` : ''}`} 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" /> <Link href={`/dashboard/chats/new?buyerId=${order.telegramBuyerId || order.telegramUsername}`}>
</Link> <MessageCircle className="h-4 w-4" />
</Button> </Link>
)} </Button>
</div> )}
</TableCell> </div>
</TableRow> </TableCell>
); </motion.tr>
})} );
})}
</AnimatePresence>
)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</div> </CardContent>
{/* Pagination */} {/* Pagination */}
<div className="flex items-center justify-between px-4 py-4 border-t border-zinc-800 bg-black/40"> <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-muted-foreground"> <div className="text-sm text-zinc-500">
Page {currentPage} of {totalPages} ({totalOrders} total) Page {currentPage} of {totalPages} ({totalOrders} total)
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -628,8 +794,9 @@ export default function OrderTable() {
size="sm" size="sm"
onClick={() => handlePageChange(currentPage - 1)} onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || loading} 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 Previous
</Button> </Button>
<Button <Button
@@ -637,14 +804,15 @@ export default function OrderTable() {
size="sm" size="sm"
onClick={() => handlePageChange(currentPage + 1)} onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages || loading} disabled={currentPage >= totalPages || loading}
className="h-8 bg-transparent border-white/10 hover:bg-white/5 text-zinc-400 hover:text-white"
> >
Next Next
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-3 w-3 ml-1" />
</Button> </Button>
</div> </div>
</div> </div>
</div> </Card>
</div> </div >
); );
} }

View File

@@ -6,6 +6,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import Image from "next/image";
import { import {
Edit, Edit,
Trash, Trash,
@@ -14,11 +15,23 @@ import {
AlertCircle, AlertCircle,
Calculator, Calculator,
Copy, Copy,
PackageX,
Archive
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import React, { useState, useEffect } from "react";
import { Product } from "@/models/products"; import { Product } from "@/models/products";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch"; 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 { interface ProductTableProps {
products: Product[]; products: Product[];
@@ -68,35 +81,60 @@ const ProductTable = ({
} }
}; };
const renderProductRow = (product: Product, isDisabled: boolean = false) => ( const renderProductRow = (product: Product, index: number, isDisabled: boolean = false) => (
<TableRow <motion.tr
key={product._id} 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> <TableCell className="font-medium">
<div className="font-medium truncate max-w-[180px]">{product.name}</div> <div className="flex items-center gap-2">
<div className="hidden sm:block text-sm text-muted-foreground mt-1"> <div className="h-8 w-8 rounded bg-muted/50 flex items-center justify-center text-muted-foreground overflow-hidden relative">
{getCategoryNameById(product.category)} {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> </div>
</TableCell> </TableCell>
<TableCell className="hidden sm:table-cell text-center"> <TableCell className="hidden sm:table-cell text-center">
{getCategoryNameById(product.category)} <Badge variant="outline" className="font-normal bg-background/50">
{getCategoryNameById(product.category)}
</Badge>
</TableCell> </TableCell>
<TableCell className="hidden md:table-cell text-center"> <TableCell className="hidden md:table-cell text-center text-muted-foreground text-sm">
{product.unitType} {product.unitType}
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
{product.stockTracking ? ( {product.stockTracking ? (
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1.5">
{getStockIcon(product)} {getStockIcon(product)}
<span className="text-sm"> <span className={`text-sm font-medium ${product.stockStatus === 'out_of_stock' ? 'text-destructive' :
{product.currentStock !== undefined ? product.currentStock : 0}{" "} product.stockStatus === 'low_stock' ? 'text-amber-500' : 'text-foreground'
{product.unitType} }`}>
{product.currentStock !== undefined ? product.currentStock : 0}
</span> </span>
</div> </div>
) : ( ) : (
<Badge variant="outline" className="text-xs"> <Badge variant="secondary" className="text-[10px] h-5 px-1.5 text-muted-foreground bg-muted/50">
Not Tracked Unlimited
</Badge> </Badge>
)} )}
</TableCell> </TableCell>
@@ -106,57 +144,70 @@ const ProductTable = ({
onCheckedChange={(checked) => onCheckedChange={(checked) =>
onToggleEnabled(product._id as string, checked) onToggleEnabled(product._id as string, checked)
} }
className="data-[state=checked]:bg-primary"
/> />
</TableCell> </TableCell>
<TableCell className="text-right flex justify-end space-x-1"> <TableCell className="text-right">
{onProfitAnalysis && ( <div className="flex items-center justify-end gap-1">
{onProfitAnalysis && (
<Button
variant="ghost"
size="icon"
onClick={() =>
onProfitAnalysis(product._id as string, product.name)
}
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" />
</Button>
)}
{onClone && (
<Button
variant="ghost"
size="icon"
onClick={() => onClone(product)}
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" />
</Button>
)}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="icon"
onClick={() => onClick={() => onEdit(product)}
onProfitAnalysis(product._id as string, product.name) className="h-8 w-8 text-muted-foreground hover:text-foreground"
} title="Edit Product"
className="text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-950/20"
title="Profit Analysis"
> >
<Calculator className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
)}
{onClone && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="icon"
onClick={() => onClone(product)} onClick={() => onDelete(product._id as string)}
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50 dark:hover:bg-blue-950/20" className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
title="Clone Listing" title="Delete Product"
> >
<Copy className="h-4 w-4" /> <Trash className="h-4 w-4" />
</Button> </Button>
)} </div>
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(product)}
title="Edit Product"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(product._id as string)}
className="text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
title="Delete Product"
>
<Trash className="h-4 w-4" />
</Button>
</TableCell> </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 = () => ( const renderTableHeader = () => (
<TableHeader className="bg-gray-50 dark:bg-zinc-800/50"> <TableHeader className="bg-muted/50 sticky top-0 z-10">
<TableRow className="hover:bg-transparent"> <TableRow className="hover:bg-transparent border-border/50">
<TableHead className="w-[200px]">Product</TableHead> <TableHead className="w-[200px]">Product</TableHead>
<TableHead className="hidden sm:table-cell text-center"> <TableHead className="hidden sm:table-cell text-center">
Category Category
@@ -166,57 +217,115 @@ const ProductTable = ({
<TableHead className="hidden lg:table-cell text-center"> <TableHead className="hidden lg:table-cell text-center">
Enabled Enabled
</TableHead> </TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right pr-6">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
); );
return ( return (
<div className="space-y-6"> <div className="space-y-8">
{/* Enabled Products Table */} {/* Enabled Products Table */}
<div className="rounded-lg border dark:border-zinc-700 shadow-sm overflow-hidden"> <Card className="border-white/10 bg-black/40 backdrop-blur-xl shadow-2xl overflow-hidden rounded-xl">
<Table className="relative"> <CardHeader className="py-4 px-6 border-b border-white/5 bg-white/[0.02]">
{renderTableHeader()} <CardTitle className="text-lg font-bold flex items-center gap-3 text-white">
<TableBody> <div className="p-2 rounded-lg bg-indigo-500/10 border border-indigo-500/20">
{loading ? ( <CheckCircle className="h-5 w-5 text-indigo-400" />
Array.from({ length: 1 }).map((_, index) => ( </div>
<TableRow key={index}> Active Products
<TableCell>Loading...</TableCell> <Badge variant="secondary" className="ml-2 bg-indigo-500/10 text-indigo-300 border-indigo-500/20 hover:bg-indigo-500/20">
<TableCell>Loading...</TableCell> {sortedEnabledProducts.length}
<TableCell>Loading...</TableCell> </Badge>
<TableCell>Loading...</TableCell> </CardTitle>
<TableCell>Loading...</TableCell> </CardHeader>
<TableCell>Loading...</TableCell> <CardContent className="p-0">
</TableRow> <div className="max-h-[600px] overflow-auto">
)) <Table>
) : sortedEnabledProducts.length > 0 ? ( {renderTableHeader()}
sortedEnabledProducts.map((product) => renderProductRow(product)) <TableBody>
) : ( {isFirefox ? (
<TableRow> loading ? (
<TableCell colSpan={6} className="h-24 text-center"> <TableRow>
No enabled products found. <TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
</TableCell> <div className="flex flex-col items-center gap-2">
</TableRow> <div className="h-6 w-6 animate-spin rounded-full border-2 border-indigo-500 border-t-transparent" />
)} <span>Loading products...</span>
</TableBody> </div>
</Table> </TableCell>
</div> </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>
{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 */} {/* Disabled Products Section */}
{!loading && disabledProducts.length > 0 && ( {!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"> <Card className="border-white/5 bg-black/20 backdrop-blur-sm shadow-none overflow-hidden opacity-80 hover:opacity-100 transition-opacity">
<Table className="relative"> <CardHeader className="py-4 px-6 border-b border-white/5 bg-white/[0.01]">
{renderTableHeader()} <CardTitle className="text-lg font-medium flex items-center gap-2 text-zinc-400">
<TableBody> <Archive className="h-5 w-5" />
{sortedDisabledProducts.map((product) => Archived / Disabled
renderProductRow(product, true), <Badge variant="outline" className="ml-2 border-white/10 text-zinc-500">
)} {sortedDisabledProducts.length}
</TableBody> </Badge>
</Table> </CardTitle>
</div> </CardHeader>
<CardContent className="p-0">
<div className="max-h-[400px] overflow-auto">
<Table>
{renderTableHeader()}
<TableBody>
<AnimatePresence>
{sortedDisabledProducts.map((product, index) =>
renderProductRow(product, index, true),
)}
</AnimatePresence>
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)} )}
</div> </div>
); );
}; };
export default ProductTable; export default ProductTable;

View File

@@ -1,3 +1,4 @@
import React, { useState, useEffect } from "react";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { import {
Table, Table,
@@ -8,8 +9,9 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Button } from "@/components/ui/button"; 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"; import { ShippingMethod } from "@/lib/types";
interface ShippingTableProps { interface ShippingTableProps {
@@ -25,62 +27,161 @@ export const ShippingTable: React.FC<ShippingTableProps> = ({
onEditShipping, onEditShipping,
onDeleteShipping, onDeleteShipping,
}) => { }) => {
// Browser detection
const [isFirefox, setIsFirefox] = useState(false);
useEffect(() => {
const ua = navigator.userAgent.toLowerCase();
setIsFirefox(ua.includes("firefox") && !ua.includes("chrome"));
}, []);
return ( return (
<div className="rounded-lg border dark:border-zinc-700 shadow-sm overflow-hidden"> <Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
<Table className="relative"> <CardHeader className="py-4 px-6 border-b border-border/50 bg-muted/30">
<TableHeader className="bg-gray-50 dark:bg-zinc-800/50"> <CardTitle className="text-lg font-medium flex items-center gap-2">
<TableRow className="hover:bg-transparent"> <Truck className="h-5 w-5 text-primary" />
<TableHead className="w-[60%]">Name</TableHead> Available Methods
<TableHead className="text-center">Price</TableHead> </CardTitle>
<TableHead className="text-right">Actions</TableHead> </CardHeader>
</TableRow> <CardContent className="p-0">
</TableHeader> <div className="overflow-auto">
<TableBody> <Table>
{loading ? ( <TableHeader className="bg-muted/50 sticky top-0 z-10">
<TableRow> <TableRow className="hover:bg-transparent border-border/50">
<TableCell colSpan={3} className="text-center"> <TableHead className="w-[60%] pl-6">Method Name</TableHead>
<Skeleton className="h-4 w-[200px]" /> <TableHead className="text-center">Price</TableHead>
</TableCell> <TableHead className="text-right pr-6">Actions</TableHead>
</TableRow>
) : shippingMethods.length > 0 ? (
shippingMethods.map((method) => (
<TableRow
key={method._id}
className="transition-colors hover:bg-gray-50 dark:hover:bg-zinc-800/70"
>
<TableCell className="font-medium">{method.name}</TableCell>
<TableCell className="text-center">£{method.price}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end">
<Button
size="sm"
variant="ghost"
className="text-blue-600 hover:text-blue-700 dark:text-blue-400"
onClick={() => onEditShipping(method)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="text-red-600 hover:text-red-700 dark:text-red-400"
onClick={() => onDeleteShipping(method._id ?? "")}
>
<Trash className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow> </TableRow>
)) </TableHeader>
) : ( <TableBody>
<TableRow> {isFirefox ? (
<TableCell colSpan={3} className="h-24 text-center"> loading ? (
No shipping methods found <TableRow>
</TableCell> <TableCell colSpan={3} className="h-24 text-center">
</TableRow> <div className="flex items-center justify-center gap-2 text-muted-foreground">
)} <Skeleton className="h-4 w-4 rounded-full" />
</TableBody> Loading methods...
</Table> </div>
</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 }}
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 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>
); );
}; };

View 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."
/>
)
}

View 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";

View 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 })
}

View File

@@ -1,9 +1,9 @@
import { Package, Clock, CheckCircle, AlertTriangle } from "lucide-react" import { Package, Clock, CheckCircle, AlertTriangle } from "lucide-react"
export const statsConfig = [ export const statsConfig = [
{ title: "Total Orders", key: "totalOrders", icon: Package }, { title: "Total Orders", key: "totalOrders", icon: Package, filterStatus: "all" },
{ title: "Completed Orders", key: "completedOrders", icon: CheckCircle }, { title: "Completed Orders", key: "completedOrders", icon: CheckCircle, filterStatus: "completed" },
{ title: "Pending Orders", key: "ongoingOrders", icon: Clock }, { title: "Pending Orders", key: "ongoingOrders", icon: Clock, filterStatus: "paid" },
{ title: "Cancelled Orders", key: "cancelledOrders", icon: AlertTriangle }, { title: "Cancelled Orders", key: "cancelledOrders", icon: AlertTriangle, filterStatus: "cancelled" },
] ]

165
hooks/useFilterState.ts Normal file
View 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
View 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
}
}

View File

@@ -5,14 +5,14 @@ export {
fetchClient, fetchClient,
getAuthToken, getAuthToken,
getCookie, getCookie,
// Customer API // Customer API
getCustomers, getCustomers,
getCustomerDetails, getCustomerDetails,
// Orders API // Orders API
exportOrdersToCSV, exportOrdersToCSV,
// Types // Types
type CustomerStats, type CustomerStats,
type CustomerResponse, type CustomerResponse,
@@ -28,7 +28,7 @@ export {
uploadProductImage, uploadProductImage,
getProductStock, getProductStock,
updateProductStock, updateProductStock,
// Types // Types
type Product, type Product,
type ProductsResponse, type ProductsResponse,
@@ -42,7 +42,7 @@ export {
createShippingOption, createShippingOption,
updateShippingOption, updateShippingOption,
deleteShippingOption, deleteShippingOption,
// Types // Types
type ShippingOption, type ShippingOption,
type ShippingOptionsResponse, type ShippingOptionsResponse,
@@ -61,7 +61,8 @@ export {
getCustomerInsightsWithStore, getCustomerInsightsWithStore,
getOrderAnalyticsWithStore, getOrderAnalyticsWithStore,
getStoreIdForUser, getStoreIdForUser,
formatGBP,
// Types // Types
type AnalyticsOverview, type AnalyticsOverview,
type RevenueData, type RevenueData,
@@ -91,9 +92,9 @@ export {
} from './services/stats-service'; } from './services/stats-service';
// Re-export server API functions // Re-export server API functions
export { export {
fetchServer, fetchServer,
getCustomersServer, getCustomersServer,
getCustomerDetailsServer, getCustomerDetailsServer,
getPlatformStatsServer, getPlatformStatsServer,
getAnalyticsOverviewServer, getAnalyticsOverviewServer,
@@ -127,11 +128,11 @@ export const apiRequest = async (endpoint: string, method = 'GET', data: any = n
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: data ? JSON.stringify(data) : undefined, body: data ? JSON.stringify(data) : undefined,
}; };
if (token) { if (token) {
options.headers.Authorization = `Bearer ${token}`; options.headers.Authorization = `Bearer ${token}`;
} }
return clientFetch(endpoint, options); return clientFetch(endpoint, options);
}; };

View File

@@ -190,10 +190,12 @@ export const getCustomerInsights = async (
storeId?: string, storeId?: string,
page: number = 1, page: number = 1,
limit: number = 10, limit: number = 10,
sortBy: string = "spent",
): Promise<CustomerInsights> => { ): Promise<CustomerInsights> => {
const params = new URLSearchParams({ const params = new URLSearchParams({
page: page.toString(), page: page.toString(),
limit: limit.toString(), limit: limit.toString(),
sort: sortBy,
}); });
if (storeId) params.append("storeId", storeId); if (storeId) params.append("storeId", storeId);
@@ -272,9 +274,10 @@ export const getProductPerformanceWithStore = async (): Promise<
export const getCustomerInsightsWithStore = async ( export const getCustomerInsightsWithStore = async (
page: number = 1, page: number = 1,
limit: number = 10, limit: number = 10,
sortBy: string = "spent",
): Promise<CustomerInsights> => { ): Promise<CustomerInsights> => {
const storeId = getStoreIdForUser(); const storeId = getStoreIdForUser();
return getCustomerInsights(storeId, page, limit); return getCustomerInsights(storeId, page, limit, sortBy);
}; };
export const getOrderAnalyticsWithStore = async ( export const getOrderAnalyticsWithStore = async (
@@ -424,6 +427,23 @@ export interface PredictionsOverview {
message?: string; 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 // Prediction Service Functions
/** /**
@@ -494,15 +514,18 @@ export const getStockPredictions = async (
* @param daysAhead Number of days to predict ahead (default: 7) * @param daysAhead Number of days to predict ahead (default: 7)
* @param period Historical period in days (default: 30) * @param period Historical period in days (default: 30)
* @param storeId Optional storeId for staff users * @param storeId Optional storeId for staff users
* @param simulation Simulation factor (e.g. 0.1 for +10%)
*/ */
export const getPredictionsOverview = async ( export const getPredictionsOverview = async (
daysAhead: number = 7, daysAhead: number = 7,
period: number = 30, period: number = 30,
storeId?: string, storeId?: string,
simulation: number = 0,
): Promise<PredictionsOverview> => { ): Promise<PredictionsOverview> => {
const params = new URLSearchParams({ const params = new URLSearchParams({
daysAhead: daysAhead.toString(), daysAhead: daysAhead.toString(),
period: period.toString(), period: period.toString(),
simulation: simulation.toString(),
}); });
if (storeId) params.append("storeId", storeId); if (storeId) params.append("storeId", storeId);
@@ -538,7 +561,33 @@ export const getStockPredictionsWithStore = async (
export const getPredictionsOverviewWithStore = async ( export const getPredictionsOverviewWithStore = async (
daysAhead: number = 7, daysAhead: number = 7,
period: number = 30, period: number = 30,
simulation: number = 0,
): Promise<PredictionsOverview> => { ): Promise<PredictionsOverview> => {
const storeId = getStoreIdForUser(); 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);
}; };

View File

@@ -18,6 +18,7 @@ export interface ProfitOverview {
topProfitableProducts: Array<{ topProfitableProducts: Array<{
productId: string; productId: string;
productName: string; productName: string;
image?: string;
totalQuantitySold: number; totalQuantitySold: number;
totalRevenue: number; totalRevenue: number;
totalCost: number; totalCost: number;
@@ -51,7 +52,7 @@ export const getProfitOverview = async (
periodOrRange?: string | DateRange periodOrRange?: string | DateRange
): Promise<ProfitOverview> => { ): Promise<ProfitOverview> => {
let url = '/analytics/profit-overview'; let url = '/analytics/profit-overview';
if (periodOrRange && typeof periodOrRange !== 'string') { if (periodOrRange && typeof periodOrRange !== 'string') {
// Date range provided // Date range provided
const startDate = periodOrRange.from.toISOString().split('T')[0]; const startDate = periodOrRange.from.toISOString().split('T')[0];
@@ -62,7 +63,7 @@ export const getProfitOverview = async (
const period = periodOrRange || '30'; const period = periodOrRange || '30';
url += `?period=${period}`; url += `?period=${period}`;
} }
return apiRequest(url); return apiRequest(url);
}; };
@@ -70,7 +71,7 @@ export const getProfitTrends = async (
periodOrRange?: string | DateRange periodOrRange?: string | DateRange
): Promise<ProfitTrend[]> => { ): Promise<ProfitTrend[]> => {
let url = '/analytics/profit-trends'; let url = '/analytics/profit-trends';
if (periodOrRange && typeof periodOrRange !== 'string') { if (periodOrRange && typeof periodOrRange !== 'string') {
// Date range provided // Date range provided
const startDate = periodOrRange.from.toISOString().split('T')[0]; const startDate = periodOrRange.from.toISOString().split('T')[0];
@@ -81,6 +82,6 @@ export const getProfitTrends = async (
const period = periodOrRange || '30'; const period = periodOrRange || '30';
url += `?period=${period}`; url += `?period=${period}`;
} }
return apiRequest(url); return apiRequest(url);
}; };

View File

@@ -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*"],
};

View File

@@ -15,4 +15,5 @@ export interface Product {
pricePerUnit: number; pricePerUnit: number;
}>; }>;
image?: string | File | null | undefined; image?: string | File | null | undefined;
costPerUnit?: number;
} }

View File

@@ -25,7 +25,7 @@ const baseConfig = {
}, },
async rewrites() { async rewrites() {
const apiBaseUrl = process.env.API_BASE_URL; const apiBaseUrl = process.env.API_BASE_URL;
if (!apiBaseUrl || apiBaseUrl === 'undefined') { if (!apiBaseUrl || apiBaseUrl === 'undefined') {
console.warn('⚠️ API_BASE_URL not set! Set it to your backend domain'); console.warn('⚠️ API_BASE_URL not set! Set it to your backend domain');
console.warn('⚠️ Using localhost fallback - this will fail in production!'); console.warn('⚠️ Using localhost fallback - this will fail in production!');
@@ -36,7 +36,7 @@ const baseConfig = {
}, },
]; ];
} }
console.log(`🔗 API rewrites pointing to: ${apiBaseUrl}`); console.log(`🔗 API rewrites pointing to: ${apiBaseUrl}`);
return [ return [
{ {

296
package-lock.json generated
View File

@@ -1,13 +1,16 @@
{ {
"name": "my-v0-project", "name": "my-v0-project",
"version": "2.2.0", "version": "2.2.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "my-v0-project", "name": "my-v0-project",
"version": "2.2.0", "version": "2.2.1",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-alert-dialog": "^1.1.4",
@@ -34,6 +37,7 @@
"@radix-ui/react-toggle": "^1.1.1", "@radix-ui/react-toggle": "^1.1.1",
"@radix-ui/react-toggle-group": "^1.1.1", "@radix-ui/react-toggle-group": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6", "@radix-ui/react-tooltip": "^1.1.6",
"@tanstack/react-virtual": "^3.13.18",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"axios": "^1.8.1", "axios": "^1.8.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -42,12 +46,15 @@
"date-fns": "4.1.0", "date-fns": "4.1.0",
"embla-carousel-react": "8.5.1", "embla-carousel-react": "8.5.1",
"form-data": "^4.0.2", "form-data": "^4.0.2",
"framer-motion": "^12.25.0",
"input-otp": "1.4.1", "input-otp": "1.4.1",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lodash": "^4.17.21",
"lucide-react": "^0.454.0", "lucide-react": "^0.454.0",
"next": "^16.1.1", "next": "^16.1.1",
"next-themes": "latest", "next-themes": "latest",
"react": "^19.0.0", "react": "^19.0.0",
"react-countup": "^6.5.3",
"react-day-picker": "8.10.1", "react-day-picker": "8.10.1",
"react-dnd": "^16.0.1", "react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1", "react-dnd-html5-backend": "^16.0.1",
@@ -55,6 +62,8 @@
"react-hook-form": "^7.54.1", "react-hook-form": "^7.54.1",
"react-markdown": "^10.0.0", "react-markdown": "^10.0.0",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"react-window": "^2.2.4",
"react-window-infinite-loader": "^2.0.0",
"recharts": "^2.15.0", "recharts": "^2.15.0",
"sonner": "^1.7.4", "sonner": "^1.7.4",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
@@ -63,7 +72,6 @@
"zod": "^3.25.0" "zod": "^3.25.0"
}, },
"devDependencies": { "devDependencies": {
"@distube/ytdl-core": "^4.16.12",
"@next/bundle-analyzer": "^16.1.1", "@next/bundle-analyzer": "^16.1.1",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@types/lodash": "^4.17.16", "@types/lodash": "^4.17.16",
@@ -383,26 +391,53 @@
"node": ">=10.0.0" "node": ">=10.0.0"
} }
}, },
"node_modules/@distube/ytdl-core": { "node_modules/@dnd-kit/accessibility": {
"version": "4.16.12", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/@distube/ytdl-core/-/ytdl-core-4.16.12.tgz", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-/NR8Jur1Q4E2oD+DJta7uwWu7SkqdEkhwERt7f4iune70zg7ZlLLTOHs1+jgg3uD2jQjpdk7RGC16FqstG4RsA==", "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"http-cookie-agent": "^7.0.1", "tslib": "^2.0.0"
"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"
}, },
"engines": { "peerDependencies": {
"node": ">=20.18.1" "react": ">=16.8.0"
}
},
"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"
}, },
"funding": { "peerDependencies": {
"url": "https://github.com/distubejs/ytdl-core?sponsor" "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": { "node_modules/@emnapi/core": {
@@ -2831,6 +2866,31 @@
"node": ">=4" "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": { "node_modules/@tybys/wasm-util": {
"version": "0.9.0", "version": "0.9.0",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz",
@@ -3473,16 +3533,6 @@
"node": ">=0.4.0" "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": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -4248,6 +4298,11 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/cross-env": {
"version": "7.0.3", "version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
@@ -5646,6 +5701,32 @@
"url": "https://github.com/sponsors/rawify" "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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -6082,45 +6163,6 @@
"url": "https://opencollective.com/unified" "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": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "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" "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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -7648,16 +7676,6 @@
"node": ">= 0.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": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -7691,6 +7709,19 @@
"node": ">=16 || 14 >=14.17" "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": { "node_modules/mrmime": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@@ -8427,6 +8458,17 @@
"node": ">=0.10.0" "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": { "node_modules/react-day-picker": {
"version": "8.10.1", "version": "8.10.1",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz", "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
@@ -8651,6 +8693,24 @@
"react-dom": ">=16.6.0" "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": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -8958,13 +9018,6 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/scheduler": {
"version": "0.25.0", "version": "0.25.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
@@ -9699,26 +9752,6 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -9742,19 +9775,6 @@
"node": ">=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": { "node_modules/trim-lines": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -9975,16 +9995,6 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/undici-types": {
"version": "6.20.0", "version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",

View File

@@ -1,7 +1,7 @@
{ {
"name": "my-v0-project", "name": "my-v0-project",
"version": "2.2.0", "version": "2.2.1",
"gitCommit": "2.1.0", "gitCommit": "2.2.1",
"private": true, "private": true,
"scripts": { "scripts": {
"predev": "node scripts/get-git-hash.js", "predev": "node scripts/get-git-hash.js",
@@ -17,6 +17,9 @@
"analyze": "ANALYZE=true next build" "analyze": "ANALYZE=true next build"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-alert-dialog": "^1.1.4",
@@ -52,12 +55,15 @@
"date-fns": "4.1.0", "date-fns": "4.1.0",
"embla-carousel-react": "8.5.1", "embla-carousel-react": "8.5.1",
"form-data": "^4.0.2", "form-data": "^4.0.2",
"framer-motion": "^12.25.0",
"input-otp": "1.4.1", "input-otp": "1.4.1",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lodash": "^4.17.21",
"lucide-react": "^0.454.0", "lucide-react": "^0.454.0",
"next": "^16.1.1", "next": "^16.1.1",
"next-themes": "latest", "next-themes": "latest",
"react": "^19.0.0", "react": "^19.0.0",
"react-countup": "^6.5.3",
"react-day-picker": "8.10.1", "react-day-picker": "8.10.1",
"react-dnd": "^16.0.1", "react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1", "react-dnd-html5-backend": "^16.0.1",

75
pnpm-lock.yaml generated
View File

@@ -113,12 +113,18 @@ importers:
form-data: form-data:
specifier: ^4.0.2 specifier: ^4.0.2
version: 4.0.5 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: input-otp:
specifier: 1.4.1 specifier: 1.4.1
version: 1.4.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 1.4.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
jwt-decode: jwt-decode:
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.0.0 version: 4.0.0
lodash:
specifier: ^4.17.21
version: 4.17.21
lucide-react: lucide-react:
specifier: ^0.454.0 specifier: ^0.454.0
version: 0.454.0(react@19.2.3) version: 0.454.0(react@19.2.3)
@@ -131,6 +137,9 @@ importers:
react: react:
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.2.3 version: 19.2.3
react-countup:
specifier: ^6.5.3
version: 6.5.3(react@19.2.3)
react-day-picker: react-day-picker:
specifier: 8.10.1 specifier: 8.10.1
version: 8.10.1(date-fns@4.1.0)(react@19.2.3) version: 8.10.1(date-fns@4.1.0)(react@19.2.3)
@@ -1728,6 +1737,9 @@ packages:
convert-source-map@2.0.0: convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
countup.js@2.9.0:
resolution: {integrity: sha512-llqrvyXztRFPp6+i8jx25phHWcVWhrHO4Nlt0uAOSKHB8778zzQswa4MU3qKBvkXfJKftRYFJuVHez67lyKdHg==}
cross-env@7.0.3: cross-env@7.0.3:
resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
@@ -2141,6 +2153,20 @@ packages:
fraction.js@5.3.4: fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} 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: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -2634,6 +2660,12 @@ packages:
minimist@1.2.8: minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} 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: mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -2863,6 +2895,11 @@ packages:
queue-microtask@1.2.3: queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} 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: react-day-picker@8.10.1:
resolution: {integrity: sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==} resolution: {integrity: sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==}
peerDependencies: peerDependencies:
@@ -4967,6 +5004,8 @@ snapshots:
convert-source-map@2.0.0: {} convert-source-map@2.0.0: {}
countup.js@2.9.0: {}
cross-env@7.0.3: cross-env@7.0.3:
dependencies: dependencies:
cross-spawn: 7.0.6 cross-spawn: 7.0.6
@@ -5237,8 +5276,8 @@ snapshots:
'@next/eslint-plugin-next': 16.1.1 '@next/eslint-plugin-next': 16.1.1
eslint: 9.39.2(jiti@1.21.7) eslint: 9.39.2(jiti@1.21.7)
eslint-import-resolver-node: 0.3.9 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))
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))
eslint-plugin-jsx-a11y: 6.10.2(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: 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)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@1.21.7))
@@ -5260,7 +5299,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: dependencies:
'@nolyfill/is-core-module': 1.0.39 '@nolyfill/is-core-module': 1.0.39
debug: 4.4.3 debug: 4.4.3
@@ -5271,22 +5310,22 @@ snapshots:
tinyglobby: 0.2.15 tinyglobby: 0.2.15
unrs-resolver: 1.11.1 unrs-resolver: 1.11.1
optionalDependencies: 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: transitivePeerDependencies:
- supports-color - 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: dependencies:
debug: 3.2.7 debug: 3.2.7
optionalDependencies: optionalDependencies:
'@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@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-import-resolver-node: 0.3.9 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: transitivePeerDependencies:
- supports-color - 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: dependencies:
'@rtsao/scc': 1.1.0 '@rtsao/scc': 1.1.0
array-includes: 3.1.9 array-includes: 3.1.9
@@ -5297,7 +5336,7 @@ snapshots:
doctrine: 2.1.0 doctrine: 2.1.0
eslint: 9.39.2(jiti@1.21.7) eslint: 9.39.2(jiti@1.21.7)
eslint-import-resolver-node: 0.3.9 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 hasown: 2.0.2
is-core-module: 2.16.1 is-core-module: 2.16.1
is-glob: 4.0.3 is-glob: 4.0.3
@@ -5509,6 +5548,15 @@ snapshots:
fraction.js@5.3.4: {} 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: fsevents@2.3.3:
optional: true optional: true
@@ -6131,6 +6179,12 @@ snapshots:
minimist@1.2.8: {} minimist@1.2.8: {}
motion-dom@12.24.11:
dependencies:
motion-utils: 12.24.10
motion-utils@12.24.10: {}
mrmime@2.0.1: {} mrmime@2.0.1: {}
ms@2.1.3: {} ms@2.1.3: {}
@@ -6345,6 +6399,11 @@ snapshots:
queue-microtask@1.2.3: {} 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): react-day-picker@8.10.1(date-fns@4.1.0)(react@19.2.3):
dependencies: dependencies:
date-fns: 4.1.0 date-fns: 4.1.0

View File

@@ -1,4 +1,4 @@
{ {
"commitHash": "8a43477", "commitHash": "a6b7286",
"buildTime": "2026-01-07T13:11:47.102Z" "buildTime": "2026-01-12T10:20:09.966Z"
} }

View File

@@ -117,6 +117,7 @@ const config: Config = {
future: { future: {
hoverOnlyWhenSupported: true, hoverOnlyWhenSupported: true,
}, },
// test commit final final final
}; };
export default config; export default config;