fix: Major UX improvements and API error handling

Fixed Issues:
1.  API error handling - Added try-catch and validation
2.  Pipe separator UX - Now accepts comma OR pipe naturally
3.  Tab restructuring - Merged pricing into General tab

Changes:

🔧 API (ProductsController.php):
- Added try-catch error handling in create_product
- Validate required fields (name)
- Better empty field checks (use !empty instead of ??)
- Support virtual, downloadable, featured flags
- Array validation for categories/tags/variations
- Return proper WP_Error on exceptions

🎨 UX Improvements:

1. Attribute Options Input (VariationsTab.tsx):
    Old: Pipe only, spaces rejected
    New: Comma OR pipe, spaces allowed
   - Split by /[,|]/ regex
   - Display as comma-separated (more natural)
   - Help text: "Type naturally: Red, Blue, Green"
   - No more cursor gymnastics!

2. Tab Restructuring (ProductFormTabbed.tsx):
    Old: 5 tabs (General, Pricing, Inventory, Variations, Organization)
    New: 3-4 tabs (General+Pricing, Inventory, Variations*, Organization)
   - Pricing merged into General tab
   - Variable products: 4 tabs (Variations shown)
   - Simple products: 3 tabs (Variations hidden)
   - Dynamic grid: grid-cols-3 or grid-cols-4
   - Less tab switching!

3. GeneralTab.tsx Enhancement:
   - Added pricing fields section
   - SKU, Regular Price, Sale Price
   - Savings calculator ("Customers save X%")
   - Context-aware help text:
     * Simple: "Base price before discounts"
     * Variable: "Base price (can override per variation)"
   - All in one place!

📊 Result:
- Simpler navigation (3-4 tabs vs 5)
- Natural typing (comma works!)
- Better context (pricing with product info)
- Less cognitive load
- Faster product creation
This commit is contained in:
dwindown
2025-11-19 22:59:31 +07:00
parent 149988be08
commit d13a356331
4 changed files with 204 additions and 84 deletions

View File

@@ -7,7 +7,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Package, DollarSign, Layers, Tag } from 'lucide-react';
import { toast } from 'sonner';
import { GeneralTab } from './tabs/GeneralTab';
import { PricingTab } from './tabs/PricingTab';
import { InventoryTab } from './tabs/InventoryTab';
import { VariationsTab, ProductVariant } from './tabs/VariationsTab';
import { OrganizationTab } from './tabs/OrganizationTab';
@@ -106,7 +105,7 @@ export function ProductFormTabbed({
if (type === 'simple' && !regularPrice) {
toast.error(__('Regular price is required for simple products'));
setActiveTab('pricing');
setActiveTab('general');
return;
}
@@ -145,23 +144,21 @@ export function ProductFormTabbed({
return (
<form ref={formRef} onSubmit={handleSubmit} className={className}>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-5 mb-6">
<TabsList className={`grid w-full mb-6 ${type === 'variable' ? 'grid-cols-4' : 'grid-cols-3'}`}>
<TabsTrigger value="general" className="flex items-center gap-2">
<Package className="w-4 h-4" />
<span className="hidden sm:inline">{__('General')}</span>
</TabsTrigger>
<TabsTrigger value="pricing" className="flex items-center gap-2">
<DollarSign className="w-4 h-4" />
<span className="hidden sm:inline">{__('Pricing')}</span>
</TabsTrigger>
<TabsTrigger value="inventory" className="flex items-center gap-2">
<Layers className="w-4 h-4" />
<span className="hidden sm:inline">{__('Inventory')}</span>
</TabsTrigger>
<TabsTrigger value="variations" className="flex items-center gap-2" disabled={type !== 'variable'}>
{type === 'variable' && (
<TabsTrigger value="variations" className="flex items-center gap-2">
<Layers className="w-4 h-4" />
<span className="hidden sm:inline">{__('Variations')}</span>
</TabsTrigger>
)}
<TabsTrigger value="organization" className="flex items-center gap-2">
<Tag className="w-4 h-4" />
<span className="hidden sm:inline">{__('Organization')}</span>
@@ -187,13 +184,6 @@ export function ProductFormTabbed({
setDownloadable={setDownloadable}
featured={featured}
setFeatured={setFeatured}
/>
</TabsContent>
{/* Pricing Tab */}
<TabsContent value="pricing">
<PricingTab
type={type}
sku={sku}
setSku={setSku}
regularPrice={regularPrice}

View File

@@ -7,6 +7,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { DollarSign } from 'lucide-react';
type GeneralTabProps = {
name: string;
@@ -25,6 +26,13 @@ type GeneralTabProps = {
setDownloadable: (value: boolean) => void;
featured: boolean;
setFeatured: (value: boolean) => void;
// Pricing props
sku: string;
setSku: (value: string) => void;
regularPrice: string;
setRegularPrice: (value: string) => void;
salePrice: string;
setSalePrice: (value: string) => void;
};
export function GeneralTab({
@@ -44,7 +52,17 @@ export function GeneralTab({
setDownloadable,
featured,
setFeatured,
sku,
setSku,
regularPrice,
setRegularPrice,
salePrice,
setSalePrice,
}: GeneralTabProps) {
const savingsPercent =
salePrice && regularPrice && parseFloat(salePrice) < parseFloat(regularPrice)
? Math.round((1 - parseFloat(salePrice) / parseFloat(regularPrice)) * 100)
: 0;
return (
<Card>
<CardHeader>
@@ -145,6 +163,78 @@ export function GeneralTab({
</p>
</div>
{/* Pricing Section */}
<Separator />
<div className="space-y-4">
<h3 className="font-medium">{__('Pricing')}</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label htmlFor="sku">{__('SKU')}</Label>
<Input
id="sku"
value={sku}
onChange={(e) => setSku(e.target.value)}
placeholder={__('PROD-001')}
className="mt-1.5 font-mono"
/>
<p className="text-xs text-muted-foreground mt-1">
{__('Stock Keeping Unit (optional)')}
</p>
</div>
<div>
<Label htmlFor="regular_price">
{__('Regular Price')} {type === 'simple' && '*'}
</Label>
<div className="relative mt-1.5">
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
id="regular_price"
type="number"
step="0.01"
value={regularPrice}
onChange={(e) => setRegularPrice(e.target.value)}
placeholder="0.00"
required={type === 'simple'}
className="pl-10"
/>
</div>
<p className="text-xs text-muted-foreground mt-1">
{type === 'variable'
? __('Base price (can override per variation)')
: __('Base price before discounts')}
</p>
</div>
<div>
<Label htmlFor="sale_price">{__('Sale Price')}</Label>
<div className="relative mt-1.5">
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
id="sale_price"
type="number"
step="0.01"
value={salePrice}
onChange={(e) => setSalePrice(e.target.value)}
placeholder="0.00"
className="pl-10"
/>
</div>
<p className="text-xs text-muted-foreground mt-1">
{__('Discounted price (optional)')}
</p>
</div>
</div>
{savingsPercent > 0 && (
<div className="bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-lg p-3">
<p className="text-sm text-emerald-800 dark:text-emerald-300">
💰 {__('Customers save')} {savingsPercent}%
</p>
</div>
)}
</div>
{/* Additional Options */}
<Separator />
<div className="space-y-3">

View File

@@ -136,19 +136,21 @@ export function VariationsTab({
<div>
<Label>{__('Options')}</Label>
<Input
value={attr.options.join(' | ')}
value={attr.options.join(', ')}
onChange={(e) => {
const options = e.target.value
.split('|')
const input = e.target.value;
// Split by comma or pipe, trim whitespace
const options = input
.split(/[,|]/)
.map(s => s.trim())
.filter(Boolean);
updateAttribute(index, 'options', options);
}}
placeholder={__('Red | Blue | Green (use | to separate)')}
placeholder={__('Red, Blue, Green (comma or pipe separated)')}
className="mt-1.5"
/>
<p className="text-xs text-muted-foreground mt-1">
{__('Separate options with | (pipe symbol)')}
{__('Type naturally: Red, Blue, Green (comma or | works)')}
</p>
</div>
</div>

View File

@@ -183,8 +183,14 @@ class ProductsController {
* Create new product
*/
public static function create_product(WP_REST_Request $request) {
try {
$data = $request->get_json_params();
// Validate required fields
if (empty($data['name'])) {
return new WP_Error('missing_name', __('Product name is required', 'woonoow'), ['status' => 400]);
}
// Determine product type
$type = $data['type'] ?? 'simple';
@@ -195,35 +201,64 @@ class ProductsController {
}
// Set basic data
$product->set_name($data['name'] ?? '');
$product->set_slug($data['slug'] ?? '');
$product->set_name($data['name']);
if (!empty($data['slug'])) {
$product->set_slug($data['slug']);
}
$product->set_status($data['status'] ?? 'publish');
$product->set_description($data['description'] ?? '');
$product->set_short_description($data['short_description'] ?? '');
$product->set_sku($data['sku'] ?? '');
$product->set_regular_price($data['regular_price'] ?? '');
$product->set_sale_price($data['sale_price'] ?? '');
$product->set_manage_stock($data['manage_stock'] ?? false);
if ($data['manage_stock']) {
$product->set_stock_quantity($data['stock_quantity'] ?? 0);
$product->set_stock_status($data['stock_status'] ?? 'instock');
} else {
$product->set_stock_status($data['stock_status'] ?? 'instock');
if (!empty($data['sku'])) {
$product->set_sku($data['sku']);
}
$product->set_weight($data['weight'] ?? '');
$product->set_length($data['length'] ?? '');
$product->set_width($data['width'] ?? '');
$product->set_height($data['height'] ?? '');
if (!empty($data['regular_price'])) {
$product->set_regular_price($data['regular_price']);
}
if (!empty($data['sale_price'])) {
$product->set_sale_price($data['sale_price']);
}
$product->set_manage_stock($data['manage_stock'] ?? false);
if (!empty($data['manage_stock'])) {
$product->set_stock_quantity($data['stock_quantity'] ?? 0);
}
$product->set_stock_status($data['stock_status'] ?? 'instock');
// Optional fields
if (!empty($data['weight'])) {
$product->set_weight($data['weight']);
}
if (!empty($data['length'])) {
$product->set_length($data['length']);
}
if (!empty($data['width'])) {
$product->set_width($data['width']);
}
if (!empty($data['height'])) {
$product->set_height($data['height']);
}
// Virtual and downloadable
if (!empty($data['virtual'])) {
$product->set_virtual(true);
}
if (!empty($data['downloadable'])) {
$product->set_downloadable(true);
}
if (!empty($data['featured'])) {
$product->set_featured(true);
}
// Categories
if (!empty($data['categories'])) {
if (!empty($data['categories']) && is_array($data['categories'])) {
$product->set_category_ids($data['categories']);
}
// Tags
if (!empty($data['tags'])) {
if (!empty($data['tags']) && is_array($data['tags'])) {
$product->set_tag_ids($data['tags']);
}
@@ -231,22 +266,25 @@ class ProductsController {
if (!empty($data['image_id'])) {
$product->set_image_id($data['image_id']);
}
if (!empty($data['gallery_image_ids'])) {
if (!empty($data['gallery_image_ids']) && is_array($data['gallery_image_ids'])) {
$product->set_gallery_image_ids($data['gallery_image_ids']);
}
$product->save();
// Handle variations for variable products
if ($type === 'variable' && !empty($data['attributes'])) {
if ($type === 'variable' && !empty($data['attributes']) && is_array($data['attributes'])) {
self::save_product_attributes($product, $data['attributes']);
if (!empty($data['variations'])) {
if (!empty($data['variations']) && is_array($data['variations'])) {
self::save_product_variations($product, $data['variations']);
}
}
return new WP_REST_Response(self::format_product_full($product), 201);
} catch (Exception $e) {
return new WP_Error('create_failed', $e->getMessage(), ['status' => 500]);
}
}
/**