Refactor pricing tiers form UI and logic

Simplifies the pricing tiers form by removing help text, card layout, and validation logic. Switches to a compact grid-based UI, adds editable total price field, and streamlines event handling for tier changes. Improves user experience and code maintainability.
This commit is contained in:
NotII
2025-10-09 20:54:33 +01:00
parent 051d33df33
commit 32bf9d790f

View File

@@ -2,10 +2,7 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { Trash, PlusCircle, Info } from "lucide-react";
import { useState } from "react";
import { Trash, PlusCircle } from "lucide-react";
interface PricingTiersProps {
pricing: any[];
@@ -23,175 +20,112 @@ export const PricingTiers = ({
handleRemoveTier,
handleAddTier,
}: PricingTiersProps) => {
const [showHelp, setShowHelp] = useState(false);
const formatNumber = (num: number) => {
if (num === 0) return "";
// Only format to 2 decimal places if the number has decimal places
// This prevents cursor jumping when user types whole numbers
return num % 1 === 0 ? num.toString() : num.toFixed(2);
};
const formatTotal = (num: number) => {
return num.toFixed(2);
};
const calculateTotal = (quantity: number, pricePerUnit: number) => {
return (quantity * pricePerUnit).toFixed(2);
return formatTotal(quantity * pricePerUnit);
};
const validateTier = (tier: any, index: number) => {
const errors = [];
const handleTotalChange = (
e: React.ChangeEvent<HTMLInputElement>,
index: number,
minQuantity: number
) => {
const totalPrice = Number(e.target.value);
const pricePerUnit = minQuantity > 0 ? totalPrice / minQuantity : 0;
// Only validate if both fields have values
if (tier.minQuantity > 0 && tier.pricePerUnit > 0) {
// Check for duplicate quantities only if both fields are complete
const duplicateIndex = pricing.findIndex((p, i) =>
i !== index && p.minQuantity === tier.minQuantity && p.minQuantity > 0
);
if (duplicateIndex !== -1) {
errors.push("Duplicate quantity found");
const syntheticEvent = {
target: {
name: 'pricePerUnit',
value: formatNumber(pricePerUnit)
}
}
} as React.ChangeEvent<HTMLInputElement>;
// Only show validation errors for completed fields
if (tier.minQuantity !== 0 && tier.minQuantity <= 0) {
errors.push("Quantity must be greater than 0");
}
if (tier.pricePerUnit !== 0 && tier.pricePerUnit <= 0) {
errors.push("Price must be greater than 0");
}
return errors;
handleTierChange(syntheticEvent, index);
};
const sortedPricing = [...pricing].sort((a, b) => a.minQuantity - b.minQuantity);
return (
<div className="space-y-4">
<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>
{showHelp && (
<Card className="bg-blue-50 border-blue-200">
<CardContent className="p-3">
<p className="text-xs text-blue-800">
<strong>How it works:</strong> Set different prices based on quantity.
For example: 1-10 units at £5 each, 11-50 units at £4 each, 51+ units at £3 each.
Quantities should be in ascending order.
</p>
</CardContent>
</Card>
)}
<div>
<h3 className="text-sm font-medium">Tiered Pricing</h3>
{pricing?.length > 0 ? (
<div className="space-y-3">
{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";
<>
<div className="grid grid-cols-[1fr_1fr_1fr_auto] gap-2 mt-2 mb-1">
<div className="text-xs text-muted-foreground">Quantity</div>
<div className="text-xs text-muted-foreground">Price Per Unit</div>
<div className="text-xs text-muted-foreground">Total Price</div>
<div className="w-8" />
</div>
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. 29"
value={tier.minQuantity === 0 ? "" : tier.minQuantity}
onChange={(e) => handleTierChange(e, originalIndex)}
className={`h-10 ${errors.some(e => e.includes('Quantity') || e.includes('Duplicate')) ? 'border-red-500' : ''}`}
/>
{errors.some(e => e.includes('Quantity')) && (
<p className="text-xs text-red-600">{errors.find(e => e.includes('Quantity'))}</p>
)}
</div>
{[...pricing]
.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"
/>
<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>
<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"
/>
<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>
<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"
/>
{errors.some(e => e.includes('Duplicate')) && tier.minQuantity > 0 && tier.pricePerUnit > 0 && (
<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>
<Button
variant="ghost"
size="icon"
className="text-red-500 hover:bg-red-100"
onClick={() => handleRemoveTier(originalIndex)}
>
<Trash className="h-5 w-5" />
</Button>
</div>
);
})}
</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>
<p className="text-sm text-gray-500 mt-2">No pricing tiers added.</p>
)}
<Button
variant="outline"
onClick={handleAddTier}
className="w-full"
>
<PlusCircle className="w-4 h-4 mr-2" />
Add Pricing Tier
<Button variant="outline" size="sm" className="mt-2" onClick={handleAddTier}>
<PlusCircle className="w-4 h-4 mr-1" />
Add Tier
</Button>
</div>
);