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:
391
components/dashboard/promotions/PromotionDetailsModal.tsx
Normal file
391
components/dashboard/promotions/PromotionDetailsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -34,6 +34,7 @@ import { Promotion } from '@/lib/types/promotion';
|
||||
import { fetchClient } from '@/lib/api';
|
||||
import NewPromotionForm from './NewPromotionForm';
|
||||
import EditPromotionForm from './EditPromotionForm';
|
||||
import PromotionDetailsModal from './PromotionDetailsModal';
|
||||
|
||||
export default function PromotionsList() {
|
||||
const router = useRouter();
|
||||
@@ -43,6 +44,7 @@ export default function PromotionsList() {
|
||||
const [editingPromotion, setEditingPromotion] = useState<Promotion | null>(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [promotionToDelete, setPromotionToDelete] = useState<string | null>(null);
|
||||
const [viewingPromotion, setViewingPromotion] = useState<Promotion | null>(null);
|
||||
|
||||
// Load promotions on mount
|
||||
useEffect(() => {
|
||||
@@ -194,10 +196,19 @@ export default function PromotionsList() {
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<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
|
||||
onClick={() => handleOpenEditDialog(promotion)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Edit Promotion"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -209,6 +220,7 @@ export default function PromotionsList() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-red-500 hover:text-red-600"
|
||||
title="Delete Promotion"
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -285,6 +297,12 @@ export default function PromotionsList() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Promotion Details Modal */}
|
||||
<PromotionDetailsModal
|
||||
promotion={viewingPromotion}
|
||||
onClose={() => setViewingPromotion(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user