Add promotion details and modal to orders and promotions

Introduces promotion-related fields to the Order interface and displays promotion discounts in both the order details and order table. Adds a PromotionDetailsModal component for viewing detailed promotion analytics and eligibility, and integrates it into the PromotionsList with a new 'view details' action.
This commit is contained in:
NotII
2025-08-07 17:16:49 +01:00
parent 2c48ecd2b4
commit 3cef1076d0
4 changed files with 458 additions and 2 deletions

View File

@@ -69,6 +69,11 @@ interface Order {
lastBalanceReceived?: number; lastBalanceReceived?: number;
cryptoTotal?: number; cryptoTotal?: number;
paymentAddress?: string; paymentAddress?: string;
// Promotion fields
promotion?: string;
promotionCode?: string;
discountAmount?: number;
subtotalBeforeDiscount?: number;
} }
interface OrderInList extends Order { interface OrderInList extends Order {
@@ -697,6 +702,17 @@ export default function OrderDetailsPage() {
<TableCell className="text-right font-medium">Shipping ({order?.shippingMethod.type})</TableCell> <TableCell className="text-right font-medium">Shipping ({order?.shippingMethod.type})</TableCell>
<TableCell className="text-right">£{order?.shippingMethod.price.toFixed(2)}</TableCell> <TableCell className="text-right">£{order?.shippingMethod.price.toFixed(2)}</TableCell>
</TableRow> </TableRow>
{order?.promotionCode && order?.discountAmount && order?.discountAmount > 0 && (
<TableRow>
<TableCell colSpan={2} />
<TableCell className="text-right font-medium text-green-600">
Promotion ({order.promotionCode})
</TableCell>
<TableCell className="text-right text-green-600">
-£{order.discountAmount.toFixed(2)}
</TableCell>
</TableRow>
)}
<TableRow> <TableRow>
<TableCell colSpan={2} /> <TableCell colSpan={2} />
<TableCell className="text-right font-bold">Total</TableCell> <TableCell className="text-right font-bold">Total</TableCell>

View File

@@ -0,0 +1,391 @@
'use client';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Calendar,
Target,
TrendingUp,
Package,
ShoppingCart,
Percent,
DollarSign,
Users,
Clock,
CheckCircle,
XCircle,
Info
} from 'lucide-react';
import { Promotion, Product } from '@/lib/types/promotion';
import { fetchClient } from '@/lib/api';
import { toast } from '@/components/ui/use-toast';
interface PromotionDetailsModalProps {
promotion: Promotion | null;
onClose: () => void;
}
export default function PromotionDetailsModal({
promotion,
onClose
}: PromotionDetailsModalProps) {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (promotion && (promotion.blacklistedProducts.length > 0 || promotion.specificProducts.length > 0)) {
loadProducts();
}
}, [promotion]);
const loadProducts = async () => {
setLoading(true);
try {
const response = await fetchClient<Product[]>('/promotions/products/all');
setProducts(response || []);
} catch (error) {
console.error('Failed to fetch products:', error);
toast({
title: 'Warning',
description: 'Could not load product details',
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
const getProductDetails = (productIds: string[]): Product[] => {
return products.filter(product => productIds.includes(product._id));
};
const formatDate = (dateString: string | null) => {
if (!dateString) return 'No end date';
return new Date(dateString).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const calculateUsagePercentage = () => {
if (!promotion?.maxUsage) return 0;
return Math.round((promotion.usageCount / promotion.maxUsage) * 100);
};
const getDiscountDisplay = () => {
if (!promotion) return '';
return promotion.discountType === 'percentage'
? `${promotion.discountValue}%`
: `£${promotion.discountValue.toFixed(2)}`;
};
const getApplicabilityText = () => {
if (!promotion) return '';
switch (promotion.applicableProducts) {
case 'all':
return 'All products (except blacklisted)';
case 'specific':
return 'Only specific products';
case 'exclude_specific':
return 'All products except specific ones';
default:
return 'All products';
}
};
const isActive = () => {
if (!promotion) return false;
const now = new Date();
const startDate = new Date(promotion.startDate);
const endDate = promotion.endDate ? new Date(promotion.endDate) : null;
return promotion.isActive &&
now >= startDate &&
(!endDate || now <= endDate) &&
(!promotion.maxUsage || promotion.usageCount < promotion.maxUsage);
};
if (!promotion) return null;
return (
<Dialog open={!!promotion} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[90vh]">
<DialogHeader>
<div className="flex items-center justify-between">
<div>
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
<Target className="h-6 w-6" />
{promotion.code}
</DialogTitle>
<DialogDescription className="mt-1">
Comprehensive promotion details and analytics
</DialogDescription>
</div>
<Badge
variant={isActive() ? 'default' : 'secondary'}
className={`text-sm px-3 py-1 ${isActive() ? 'bg-green-500' : 'bg-gray-500'}`}
>
{isActive() ? 'Active' : 'Inactive'}
</Badge>
</div>
</DialogHeader>
<ScrollArea className="max-h-[70vh] pr-4">
<div className="space-y-6">
{/* Basic Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Info className="h-5 w-5" />
Basic Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Promotion Code</label>
<p className="text-lg font-mono font-bold">{promotion.code}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Description</label>
<p className="text-sm">{promotion.description || 'No description provided'}</p>
</div>
</div>
<Separator />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="flex items-center gap-3">
{promotion.discountType === 'percentage' ? (
<Percent className="h-8 w-8 text-blue-500" />
) : (
<DollarSign className="h-8 w-8 text-green-500" />
)}
<div>
<label className="text-sm font-medium text-muted-foreground">Discount</label>
<p className="text-xl font-bold">{getDiscountDisplay()}</p>
<p className="text-xs text-muted-foreground">
{promotion.discountType === 'percentage' ? 'Percentage' : 'Fixed Amount'}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<ShoppingCart className="h-8 w-8 text-orange-500" />
<div>
<label className="text-sm font-medium text-muted-foreground">Min. Order</label>
<p className="text-xl font-bold">
{promotion.minOrderAmount > 0 ? `£${promotion.minOrderAmount.toFixed(2)}` : 'None'}
</p>
<p className="text-xs text-muted-foreground">Required minimum</p>
</div>
</div>
<div className="flex items-center gap-3">
<Users className="h-8 w-8 text-purple-500" />
<div>
<label className="text-sm font-medium text-muted-foreground">Usage Limit</label>
<p className="text-xl font-bold">
{promotion.maxUsage ? promotion.maxUsage.toLocaleString() : 'Unlimited'}
</p>
<p className="text-xs text-muted-foreground">Maximum uses</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Usage Analytics */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Usage Analytics
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Current Usage</span>
<span className="text-sm text-muted-foreground">
{promotion.usageCount} / {promotion.maxUsage || '∞'}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{
width: promotion.maxUsage
? `${Math.min(calculateUsagePercentage(), 100)}%`
: '0%'
}}
></div>
</div>
{promotion.maxUsage && (
<p className="text-xs text-muted-foreground mt-1">
{calculateUsagePercentage()}% of limit used
</p>
)}
</div>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm font-medium">Total Uses</span>
<span className="text-sm font-bold">{promotion.usageCount.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-sm font-medium">Remaining Uses</span>
<span className="text-sm font-bold">
{promotion.maxUsage
? (promotion.maxUsage - promotion.usageCount).toLocaleString()
: 'Unlimited'
}
</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Validity Period */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Validity Period
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-center gap-3">
<Clock className="h-8 w-8 text-green-500" />
<div>
<label className="text-sm font-medium text-muted-foreground">Start Date</label>
<p className="font-medium">{formatDate(promotion.startDate)}</p>
</div>
</div>
<div className="flex items-center gap-3">
{promotion.endDate ? (
<Clock className="h-8 w-8 text-red-500" />
) : (
<CheckCircle className="h-8 w-8 text-blue-500" />
)}
<div>
<label className="text-sm font-medium text-muted-foreground">End Date</label>
<p className="font-medium">
{promotion.endDate ? formatDate(promotion.endDate) : 'No expiry date'}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Product Eligibility */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
Product Eligibility
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Applies To</label>
<p className="font-medium">{getApplicabilityText()}</p>
</div>
{promotion.applicableProducts === 'specific' && promotion.specificProducts.length > 0 && (
<div>
<label className="text-sm font-medium text-muted-foreground">
Specific Products ({promotion.specificProducts.length})
</label>
<div className="mt-2 space-y-1">
{loading ? (
<p className="text-sm text-muted-foreground">Loading products...</p>
) : (
getProductDetails(promotion.specificProducts).map((product) => (
<div key={product._id} className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-500" />
<span className="text-sm">{product.name}</span>
{!product.enabled && (
<Badge variant="secondary" className="text-xs">Disabled</Badge>
)}
</div>
))
)}
</div>
</div>
)}
{promotion.blacklistedProducts.length > 0 && (
<div>
<label className="text-sm font-medium text-muted-foreground">
Excluded Products ({promotion.blacklistedProducts.length})
</label>
<div className="mt-2 space-y-1">
{loading ? (
<p className="text-sm text-muted-foreground">Loading products...</p>
) : (
getProductDetails(promotion.blacklistedProducts).map((product) => (
<div key={product._id} className="flex items-center gap-2">
<XCircle className="h-4 w-4 text-red-500" />
<span className="text-sm">{product.name}</span>
{!product.enabled && (
<Badge variant="secondary" className="text-xs">Disabled</Badge>
)}
</div>
))
)}
</div>
</div>
)}
{promotion.applicableProducts === 'all' && promotion.blacklistedProducts.length === 0 && (
<div className="flex items-center gap-2 text-green-600">
<CheckCircle className="h-4 w-4" />
<span className="text-sm">This promotion applies to all products in your store</span>
</div>
)}
</CardContent>
</Card>
{/* Metadata */}
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">Additional Information</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<label className="font-medium text-muted-foreground">Created</label>
<p>{formatDate(promotion.createdAt)}</p>
</div>
<div>
<label className="font-medium text-muted-foreground">Last Updated</label>
<p>{formatDate(promotion.updatedAt)}</p>
</div>
</div>
</CardContent>
</Card>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Plus, Tag, RefreshCw, Trash, Edit, Check, X } from 'lucide-react'; import { Plus, Tag, RefreshCw, Trash, Edit, Check, X, Eye } from 'lucide-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -34,6 +34,7 @@ import { Promotion } from '@/lib/types/promotion';
import { fetchClient } from '@/lib/api'; import { fetchClient } from '@/lib/api';
import NewPromotionForm from './NewPromotionForm'; import NewPromotionForm from './NewPromotionForm';
import EditPromotionForm from './EditPromotionForm'; import EditPromotionForm from './EditPromotionForm';
import PromotionDetailsModal from './PromotionDetailsModal';
export default function PromotionsList() { export default function PromotionsList() {
const router = useRouter(); const router = useRouter();
@@ -43,6 +44,7 @@ export default function PromotionsList() {
const [editingPromotion, setEditingPromotion] = useState<Promotion | null>(null); const [editingPromotion, setEditingPromotion] = useState<Promotion | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [promotionToDelete, setPromotionToDelete] = useState<string | null>(null); const [promotionToDelete, setPromotionToDelete] = useState<string | null>(null);
const [viewingPromotion, setViewingPromotion] = useState<Promotion | null>(null);
// Load promotions on mount // Load promotions on mount
useEffect(() => { useEffect(() => {
@@ -194,10 +196,19 @@ export default function PromotionsList() {
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button
onClick={() => setViewingPromotion(promotion)}
variant="ghost"
size="icon"
title="View Details"
>
<Eye className="h-4 w-4" />
</Button>
<Button <Button
onClick={() => handleOpenEditDialog(promotion)} onClick={() => handleOpenEditDialog(promotion)}
variant="ghost" variant="ghost"
size="icon" size="icon"
title="Edit Promotion"
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
@@ -209,6 +220,7 @@ export default function PromotionsList() {
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-red-500 hover:text-red-600" className="text-red-500 hover:text-red-600"
title="Delete Promotion"
> >
<Trash className="h-4 w-4" /> <Trash className="h-4 w-4" />
</Button> </Button>
@@ -285,6 +297,12 @@ export default function PromotionsList() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Promotion Details Modal */}
<PromotionDetailsModal
promotion={viewingPromotion}
onClose={() => setViewingPromotion(null)}
/>
</> </>
); );
} }

View File

@@ -28,7 +28,9 @@ import {
ArrowUpDown, ArrowUpDown,
Truck, Truck,
MessageCircle, MessageCircle,
AlertTriangle AlertTriangle,
Tag,
Percent
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { clientFetch } from '@/lib/api'; import { clientFetch } from '@/lib/api';
@@ -60,6 +62,11 @@ interface Order {
underpaymentAmount?: number; underpaymentAmount?: number;
lastBalanceReceived?: number; lastBalanceReceived?: number;
cryptoTotal?: number; cryptoTotal?: number;
// Promotion fields
promotion?: string;
promotionCode?: string;
discountAmount?: number;
subtotalBeforeDiscount?: number;
} }
type SortableColumns = "orderId" | "totalPrice" | "status" | "orderDate" | "paidAt"; type SortableColumns = "orderId" | "totalPrice" | "status" | "orderDate" | "paidAt";
@@ -430,6 +437,7 @@ export default function OrderTable() {
<TableHead className="cursor-pointer" onClick={() => handleSort("totalPrice")}> <TableHead className="cursor-pointer" onClick={() => handleSort("totalPrice")}>
Total <ArrowUpDown className="ml-2 inline h-4 w-4" /> Total <ArrowUpDown className="ml-2 inline h-4 w-4" />
</TableHead> </TableHead>
<TableHead>Promotion</TableHead>
<TableHead className="cursor-pointer" onClick={() => handleSort("status")}> <TableHead className="cursor-pointer" onClick={() => handleSort("status")}>
Status <ArrowUpDown className="ml-2 inline h-4 w-4" /> Status <ArrowUpDown className="ml-2 inline h-4 w-4" />
</TableHead> </TableHead>
@@ -468,6 +476,29 @@ export default function OrderTable() {
)} )}
</div> </div>
</TableCell> </TableCell>
<TableCell>
{order.promotionCode ? (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<Tag className="h-3 w-3 text-green-500" />
<span className="text-xs font-mono bg-green-100 text-green-800 px-2 py-0.5 rounded">
{order.promotionCode}
</span>
</div>
<div className="flex items-center gap-1 text-xs text-green-600">
<Percent className="h-3 w-3" />
<span>-£{(order.discountAmount || 0).toFixed(2)}</span>
{order.subtotalBeforeDiscount && order.subtotalBeforeDiscount > 0 && (
<span className="text-muted-foreground">
(was £{order.subtotalBeforeDiscount.toFixed(2)})
</span>
)}
</div>
</div>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className={`inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm ${ <div className={`inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm ${