Revamp pricing tiers form UI and validation
Improves the pricing tiers form with enhanced UI using Card and Label components, adds help text, and introduces validation for quantity, price, and duplicate tiers. The form now displays errors inline and sorts tiers by quantity for better usability.
This commit is contained in:
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
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 { Trash, PlusCircle } from "lucide-react";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Trash, PlusCircle, Info } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
interface PricingTiersProps {
|
interface PricingTiersProps {
|
||||||
pricing: any[];
|
pricing: any[];
|
||||||
@@ -20,112 +23,171 @@ export const PricingTiers = ({
|
|||||||
handleRemoveTier,
|
handleRemoveTier,
|
||||||
handleAddTier,
|
handleAddTier,
|
||||||
}: PricingTiersProps) => {
|
}: PricingTiersProps) => {
|
||||||
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
|
|
||||||
const formatNumber = (num: number) => {
|
const formatNumber = (num: number) => {
|
||||||
// Only format to 2 decimal places if the number has decimal places
|
if (num === 0) return "";
|
||||||
// This prevents cursor jumping when user types whole numbers
|
|
||||||
return num % 1 === 0 ? num.toString() : num.toFixed(2);
|
return num % 1 === 0 ? num.toString() : num.toFixed(2);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTotal = (num: number) => {
|
|
||||||
return num.toFixed(2);
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateTotal = (quantity: number, pricePerUnit: number) => {
|
const calculateTotal = (quantity: number, pricePerUnit: number) => {
|
||||||
return formatTotal(quantity * pricePerUnit);
|
return (quantity * pricePerUnit).toFixed(2);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTotalChange = (
|
const validateTier = (tier: any, index: number) => {
|
||||||
e: React.ChangeEvent<HTMLInputElement>,
|
const errors = [];
|
||||||
index: number,
|
|
||||||
minQuantity: number
|
|
||||||
) => {
|
|
||||||
const totalPrice = Number(e.target.value);
|
|
||||||
const pricePerUnit = minQuantity > 0 ? totalPrice / minQuantity : 0;
|
|
||||||
|
|
||||||
const syntheticEvent = {
|
if (tier.minQuantity <= 0) {
|
||||||
target: {
|
errors.push("Quantity must be greater than 0");
|
||||||
name: 'pricePerUnit',
|
}
|
||||||
value: formatNumber(pricePerUnit)
|
|
||||||
}
|
|
||||||
} as React.ChangeEvent<HTMLInputElement>;
|
|
||||||
|
|
||||||
handleTierChange(syntheticEvent, index);
|
if (tier.pricePerUnit <= 0) {
|
||||||
|
errors.push("Price must be greater than 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate quantities
|
||||||
|
const duplicateIndex = pricing.findIndex((p, i) =>
|
||||||
|
i !== index && p.minQuantity === tier.minQuantity
|
||||||
|
);
|
||||||
|
|
||||||
|
if (duplicateIndex !== -1) {
|
||||||
|
errors.push("Duplicate quantity found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sortedPricing = [...pricing].sort((a, b) => a.minQuantity - b.minQuantity);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-medium">Tiered Pricing</h3>
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-medium">Pricing Tiers</h3>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowHelp(!showHelp)}
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Info className="w-3 h-3 mr-1" />
|
||||||
|
{showHelp ? "Hide" : "Help"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{pricing?.length > 0 ? (
|
{showHelp && (
|
||||||
<>
|
<Card className="bg-blue-50 border-blue-200">
|
||||||
<div className="grid grid-cols-[1fr_1fr_1fr_auto] gap-2 mt-2 mb-1">
|
<CardContent className="p-3">
|
||||||
<div className="text-xs text-muted-foreground">Quantity</div>
|
<p className="text-xs text-blue-800">
|
||||||
<div className="text-xs text-muted-foreground">Price Per Unit</div>
|
<strong>How it works:</strong> Set different prices based on quantity.
|
||||||
<div className="text-xs text-muted-foreground">Total Price</div>
|
For example: 1-10 units at £5 each, 11-50 units at £4 each, 51+ units at £3 each.
|
||||||
<div className="w-8" />
|
Quantities should be in ascending order.
|
||||||
</div>
|
</p>
|
||||||
|
</CardContent>
|
||||||
{[...pricing]
|
</Card>
|
||||||
.sort((a, b) => a.minQuantity - b.minQuantity)
|
|
||||||
.map((tier, sortedIndex) => {
|
|
||||||
// Find the original index for proper event handling
|
|
||||||
const originalIndex = pricing.findIndex(p =>
|
|
||||||
p === tier || (p.minQuantity === tier.minQuantity && p.pricePerUnit === tier.pricePerUnit)
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={tier._id || originalIndex}
|
|
||||||
className="grid grid-cols-[1fr_1fr_1fr_auto] gap-2 mt-2"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
name="minQuantity"
|
|
||||||
type="number"
|
|
||||||
placeholder="Min Quantity"
|
|
||||||
value={tier.minQuantity === 0 ? "" : tier.minQuantity}
|
|
||||||
onChange={(e) => handleTierChange(e, originalIndex)}
|
|
||||||
className="h-8 text-sm px-2"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
name="pricePerUnit"
|
|
||||||
type="number"
|
|
||||||
placeholder="Price per unit"
|
|
||||||
value={tier.pricePerUnit === 0 ? "" : formatNumber(tier.pricePerUnit)}
|
|
||||||
onChange={(e) => handleTierChange(e, originalIndex)}
|
|
||||||
className="h-8 text-sm px-2"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder="Total price"
|
|
||||||
value={
|
|
||||||
tier.minQuantity && tier.pricePerUnit
|
|
||||||
? calculateTotal(tier.minQuantity, tier.pricePerUnit)
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
onChange={(e) => handleTotalChange(e, originalIndex, tier.minQuantity)}
|
|
||||||
className="h-8 text-sm px-2"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="text-red-500 hover:bg-red-100"
|
|
||||||
onClick={() => handleRemoveTier(originalIndex)}
|
|
||||||
>
|
|
||||||
<Trash className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-gray-500 mt-2">No pricing tiers added.</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button variant="outline" size="sm" className="mt-2" onClick={handleAddTier}>
|
{pricing?.length > 0 ? (
|
||||||
<PlusCircle className="w-4 h-4 mr-1" />
|
<div className="space-y-3">
|
||||||
Add Tier
|
{sortedPricing.map((tier, sortedIndex) => {
|
||||||
|
const originalIndex = pricing.findIndex(p => p === tier);
|
||||||
|
const errors = validateTier(tier, originalIndex);
|
||||||
|
const total = tier.minQuantity && tier.pricePerUnit
|
||||||
|
? calculateTotal(tier.minQuantity, tier.pricePerUnit)
|
||||||
|
: "0.00";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={tier._id || originalIndex} className={`p-4 ${errors.length > 0 ? 'border-red-200 bg-red-50' : 'border-gray-200'}`}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`quantity-${originalIndex}`} className="text-xs font-medium">
|
||||||
|
Minimum Quantity *
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`quantity-${originalIndex}`}
|
||||||
|
name="minQuantity"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
placeholder="e.g. 10"
|
||||||
|
value={tier.minQuantity === 0 ? "" : tier.minQuantity}
|
||||||
|
onChange={(e) => handleTierChange(e, originalIndex)}
|
||||||
|
className={`h-10 ${errors.some(e => e.includes('Quantity')) ? 'border-red-500' : ''}`}
|
||||||
|
/>
|
||||||
|
{errors.some(e => e.includes('Quantity')) && (
|
||||||
|
<p className="text-xs text-red-600">{errors.find(e => e.includes('Quantity'))}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`price-${originalIndex}`} className="text-xs font-medium">
|
||||||
|
Price Per Unit (£) *
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`price-${originalIndex}`}
|
||||||
|
name="pricePerUnit"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="e.g. 5.00"
|
||||||
|
value={tier.pricePerUnit === 0 ? "" : formatNumber(tier.pricePerUnit)}
|
||||||
|
onChange={(e) => handleTierChange(e, originalIndex)}
|
||||||
|
className={`h-10 ${errors.some(e => e.includes('Price')) ? 'border-red-500' : ''}`}
|
||||||
|
/>
|
||||||
|
{errors.some(e => e.includes('Price')) && (
|
||||||
|
<p className="text-xs text-red-600">{errors.find(e => e.includes('Price'))}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium text-muted-foreground">
|
||||||
|
Total Price
|
||||||
|
</Label>
|
||||||
|
<div className="h-10 px-3 py-2 bg-gray-50 border border-gray-200 rounded-md flex items-center">
|
||||||
|
<span className="text-sm font-medium">£{total}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{tier.minQuantity} × £{formatNumber(tier.pricePerUnit)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errors.some(e => e.includes('Duplicate')) && (
|
||||||
|
<p className="text-xs text-red-600 mt-2">
|
||||||
|
⚠️ This quantity is already used in another tier
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRemoveTier(originalIndex)}
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-100"
|
||||||
|
>
|
||||||
|
<Trash className="w-4 h-4 mr-1" />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className="border-dashed border-2 border-gray-300">
|
||||||
|
<CardContent className="p-6 text-center">
|
||||||
|
<p className="text-sm text-gray-500 mb-2">No pricing tiers added yet</p>
|
||||||
|
<p className="text-xs text-gray-400">Add your first tier to get started</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleAddTier}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<PlusCircle className="w-4 h-4 mr-2" />
|
||||||
|
Add Pricing Tier
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user