385 lines
15 KiB
TypeScript
385 lines
15 KiB
TypeScript
'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="text-sm">
|
|
<label className="font-medium text-muted-foreground">Created</label>
|
|
<p>{formatDate(promotion.createdAt)}</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</ScrollArea>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
} |