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:
@@ -2,10 +2,7 @@
|
|||||||
|
|
||||||
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 { Label } from "@/components/ui/label";
|
import { Trash, PlusCircle } from "lucide-react";
|
||||||
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[];
|
||||||
@@ -23,175 +20,112 @@ export const PricingTiers = ({
|
|||||||
handleRemoveTier,
|
handleRemoveTier,
|
||||||
handleAddTier,
|
handleAddTier,
|
||||||
}: PricingTiersProps) => {
|
}: PricingTiersProps) => {
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
|
||||||
|
|
||||||
const formatNumber = (num: number) => {
|
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);
|
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 (quantity * pricePerUnit).toFixed(2);
|
return formatTotal(quantity * pricePerUnit);
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateTier = (tier: any, index: number) => {
|
const handleTotalChange = (
|
||||||
const errors = [];
|
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
|
const syntheticEvent = {
|
||||||
if (tier.minQuantity > 0 && tier.pricePerUnit > 0) {
|
target: {
|
||||||
// Check for duplicate quantities only if both fields are complete
|
name: 'pricePerUnit',
|
||||||
const duplicateIndex = pricing.findIndex((p, i) =>
|
value: formatNumber(pricePerUnit)
|
||||||
i !== index && p.minQuantity === tier.minQuantity && p.minQuantity > 0
|
|
||||||
);
|
|
||||||
|
|
||||||
if (duplicateIndex !== -1) {
|
|
||||||
errors.push("Duplicate quantity found");
|
|
||||||
}
|
}
|
||||||
}
|
} as React.ChangeEvent<HTMLInputElement>;
|
||||||
|
|
||||||
// Only show validation errors for completed fields
|
handleTierChange(syntheticEvent, index);
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortedPricing = [...pricing].sort((a, b) => a.minQuantity - b.minQuantity);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div>
|
||||||
<div className="flex items-center justify-between">
|
<h3 className="text-sm font-medium">Tiered Pricing</h3>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{pricing?.length > 0 ? (
|
{pricing?.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<>
|
||||||
{sortedPricing.map((tier, sortedIndex) => {
|
<div className="grid grid-cols-[1fr_1fr_1fr_auto] gap-2 mt-2 mb-1">
|
||||||
const originalIndex = pricing.findIndex(p => p === tier);
|
<div className="text-xs text-muted-foreground">Quantity</div>
|
||||||
const errors = validateTier(tier, originalIndex);
|
<div className="text-xs text-muted-foreground">Price Per Unit</div>
|
||||||
const total = tier.minQuantity && tier.pricePerUnit
|
<div className="text-xs text-muted-foreground">Total Price</div>
|
||||||
? calculateTotal(tier.minQuantity, tier.pricePerUnit)
|
<div className="w-8" />
|
||||||
: "0.00";
|
</div>
|
||||||
|
|
||||||
return (
|
{[...pricing]
|
||||||
<Card key={tier._id || originalIndex} className={`p-4 ${errors.length > 0 ? 'border-red-200 bg-red-50' : 'border-gray-200'}`}>
|
.sort((a, b) => a.minQuantity - b.minQuantity)
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
.map((tier, sortedIndex) => {
|
||||||
<div className="space-y-2">
|
// Find the original index for proper event handling
|
||||||
<Label htmlFor={`quantity-${originalIndex}`} className="text-xs font-medium">
|
const originalIndex = pricing.findIndex(p =>
|
||||||
Minimum Quantity *
|
p === tier || (p.minQuantity === tier.minQuantity && p.pricePerUnit === tier.pricePerUnit)
|
||||||
</Label>
|
);
|
||||||
<Input
|
return (
|
||||||
id={`quantity-${originalIndex}`}
|
<div
|
||||||
name="minQuantity"
|
key={tier._id || originalIndex}
|
||||||
type="number"
|
className="grid grid-cols-[1fr_1fr_1fr_auto] gap-2 mt-2"
|
||||||
min="1"
|
>
|
||||||
step="1"
|
<Input
|
||||||
placeholder="e.g. 29"
|
name="minQuantity"
|
||||||
value={tier.minQuantity === 0 ? "" : tier.minQuantity}
|
type="number"
|
||||||
onChange={(e) => handleTierChange(e, originalIndex)}
|
placeholder="Min Quantity"
|
||||||
className={`h-10 ${errors.some(e => e.includes('Quantity') || e.includes('Duplicate')) ? 'border-red-500' : ''}`}
|
value={tier.minQuantity === 0 ? "" : tier.minQuantity}
|
||||||
/>
|
onChange={(e) => handleTierChange(e, originalIndex)}
|
||||||
{errors.some(e => e.includes('Quantity')) && (
|
className="h-8 text-sm px-2"
|
||||||
<p className="text-xs text-red-600">{errors.find(e => e.includes('Quantity'))}</p>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<Input
|
||||||
<Label htmlFor={`price-${originalIndex}`} className="text-xs font-medium">
|
name="pricePerUnit"
|
||||||
Price Per Unit (£) *
|
type="number"
|
||||||
</Label>
|
placeholder="Price per unit"
|
||||||
<Input
|
value={tier.pricePerUnit === 0 ? "" : formatNumber(tier.pricePerUnit)}
|
||||||
id={`price-${originalIndex}`}
|
onChange={(e) => handleTierChange(e, originalIndex)}
|
||||||
name="pricePerUnit"
|
className="h-8 text-sm px-2"
|
||||||
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">
|
<Input
|
||||||
<Label className="text-xs font-medium text-muted-foreground">
|
type="number"
|
||||||
Total Price
|
placeholder="Total price"
|
||||||
</Label>
|
value={
|
||||||
<div className="h-10 px-3 py-2 bg-gray-50 border border-gray-200 rounded-md flex items-center">
|
tier.minQuantity && tier.pricePerUnit
|
||||||
<span className="text-sm font-medium">£{total}</span>
|
? calculateTotal(tier.minQuantity, tier.pricePerUnit)
|
||||||
</div>
|
: ""
|
||||||
<p className="text-xs text-muted-foreground">
|
}
|
||||||
{tier.minQuantity} × £{formatNumber(tier.pricePerUnit)}
|
onChange={(e) => handleTotalChange(e, originalIndex, tier.minQuantity)}
|
||||||
</p>
|
className="h-8 text-sm px-2"
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{errors.some(e => e.includes('Duplicate')) && tier.minQuantity > 0 && tier.pricePerUnit > 0 && (
|
<Button
|
||||||
<p className="text-xs text-red-600 mt-2">
|
variant="ghost"
|
||||||
⚠️ This quantity is already used in another tier
|
size="icon"
|
||||||
</p>
|
className="text-red-500 hover:bg-red-100"
|
||||||
)}
|
onClick={() => handleRemoveTier(originalIndex)}
|
||||||
|
>
|
||||||
<div className="flex justify-end mt-3">
|
<Trash className="h-5 w-5" />
|
||||||
<Button
|
</Button>
|
||||||
variant="ghost"
|
</div>
|
||||||
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">
|
<p className="text-sm text-gray-500 mt-2">No pricing tiers added.</p>
|
||||||
<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
|
<Button variant="outline" size="sm" className="mt-2" onClick={handleAddTier}>
|
||||||
variant="outline"
|
<PlusCircle className="w-4 h-4 mr-1" />
|
||||||
onClick={handleAddTier}
|
Add Tier
|
||||||
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