feat: Stock infinity symbol, sale price display, rich text editor, inline create categories/tags
Fixed 4 major UX issues:
1. Stock Column - Show Infinity Symbol
Problem: Stock shows badge even when not managed
Solution:
- Check manage_stock flag
- If true: Show StockBadge with quantity
- If false: Show ∞ (infinity symbol) for unlimited
Result: Clear visual for unlimited stock
2. Type Column & Price Display
Problem: Type column empty, price ignores sale price
Solution:
- Type: Show badge with product.type (simple, variable, etc.)
- Price: Respect sale price hierarchy:
1. price_html (WooCommerce formatted)
2. sale_price (show strikethrough regular + green sale)
3. regular_price (normal display)
4. — (dash for no price)
Result:
- Type visible with badge styling
- Sale prices show with strikethrough
- Clear visual hierarchy
3. Rich Text Editor for Description
Problem: Description shows raw HTML in textarea
Solution:
- Created RichTextEditor component with Tiptap
- Toolbar: Bold, Italic, H2, Lists, Quote, Undo/Redo
- Integrated into GeneralTab
Features:
- WYSIWYG editing
- Keyboard shortcuts
- Clean toolbar UI
- Saves as HTML
Result: Professional rich text editing experience
4. Inline Create Categories & Tags
Problem: Cannot create new categories/tags in product form
Solution:
- Added input + "Add" button above each list
- Press Enter or click Add to create
- Auto-selects newly created item
- Shows loading state
- Toast notifications
Result:
- No need to leave product form
- Seamless workflow
- Better UX
Files Changed:
- index.tsx: Stock ∞, sale price display, type badge
- GeneralTab.tsx: RichTextEditor integration
- OrganizationTab.tsx: Inline create UI
- RichTextEditor.tsx: New reusable component
Note: Variation attribute value issue (screenshot 1) needs API data format investigation
This commit is contained in:
118
admin-spa/src/components/RichTextEditor.tsx
Normal file
118
admin-spa/src/components/RichTextEditor.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useEditor, EditorContent } from '@tiptap/react';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import { Bold, Italic, List, ListOrdered, Heading2, Quote, Undo, Redo } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type RichTextEditorProps = {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RichTextEditor({ value, onChange, placeholder, className }: RichTextEditorProps) {
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [StarterKit],
|
||||||
|
content: value,
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
onChange(editor.getHTML());
|
||||||
|
},
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: 'prose prose-sm max-w-none focus:outline-none min-h-[150px] px-3 py-2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!editor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('border rounded-md', className)}>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="border-b bg-muted/30 p-2 flex flex-wrap gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
|
className={cn('h-8 w-8 p-0', editor.isActive('bold') && 'bg-muted')}
|
||||||
|
>
|
||||||
|
<Bold className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
|
className={cn('h-8 w-8 p-0', editor.isActive('italic') && 'bg-muted')}
|
||||||
|
>
|
||||||
|
<Italic className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||||
|
className={cn('h-8 w-8 p-0', editor.isActive('heading', { level: 2 }) && 'bg-muted')}
|
||||||
|
>
|
||||||
|
<Heading2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
|
className={cn('h-8 w-8 p-0', editor.isActive('bulletList') && 'bg-muted')}
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
|
className={cn('h-8 w-8 p-0', editor.isActive('orderedList') && 'bg-muted')}
|
||||||
|
>
|
||||||
|
<ListOrdered className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||||
|
className={cn('h-8 w-8 p-0', editor.isActive('blockquote') && 'bg-muted')}
|
||||||
|
>
|
||||||
|
<Quote className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="w-px h-8 bg-border mx-1" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().undo().run()}
|
||||||
|
disabled={!editor.can().undo()}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<Undo className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().redo().run()}
|
||||||
|
disabled={!editor.can().redo()}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<Redo className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor */}
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -381,18 +381,31 @@ export default function Products() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="p-3 font-mono text-sm">{product.sku || '—'}</td>
|
<td className="p-3 font-mono text-sm">{product.sku || '—'}</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<StockBadge value={product.stock_status} quantity={product.manage_stock ? product.stock_quantity : undefined} />
|
{product.manage_stock ? (
|
||||||
|
<StockBadge value={product.stock_status} quantity={product.stock_quantity} />
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">∞</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
{product.price_html ? (
|
{product.price_html ? (
|
||||||
<span dangerouslySetInnerHTML={{ __html: product.price_html }} />
|
<span dangerouslySetInnerHTML={{ __html: product.price_html }} />
|
||||||
|
) : product.sale_price ? (
|
||||||
|
<span className="space-x-1">
|
||||||
|
<span className="line-through text-muted-foreground text-sm">{formatMoney(product.regular_price)}</span>
|
||||||
|
<span className="text-emerald-600 font-medium">{formatMoney(product.sale_price)}</span>
|
||||||
|
</span>
|
||||||
) : product.regular_price ? (
|
) : product.regular_price ? (
|
||||||
<span>{formatMoney(product.regular_price)}</span>
|
<span>{formatMoney(product.regular_price)}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">—</span>
|
<span className="text-muted-foreground">—</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-sm capitalize">{product.type || '—'}</td>
|
<td className="p-3 text-sm">
|
||||||
|
<span className="capitalize px-2 py-1 rounded-md bg-muted text-muted-foreground text-xs">
|
||||||
|
{product.type || 'simple'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td className="p-3 text-right">
|
<td className="p-3 text-right">
|
||||||
<Link to={`/products/${product.id}/edit`} className="text-sm text-primary hover:underline">
|
<Link to={`/products/${product.id}/edit`} className="text-sm text-primary hover:underline">
|
||||||
{__('Edit')}
|
{__('Edit')}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectVa
|
|||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { DollarSign } from 'lucide-react';
|
import { DollarSign } from 'lucide-react';
|
||||||
import { getStoreCurrency } from '@/lib/currency';
|
import { getStoreCurrency } from '@/lib/currency';
|
||||||
|
import { RichTextEditor } from '@/components/RichTextEditor';
|
||||||
|
|
||||||
type GeneralTabProps = {
|
type GeneralTabProps = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -138,14 +139,13 @@ export function GeneralTab({
|
|||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="description">{__('Description')}</Label>
|
<Label htmlFor="description">{__('Description')}</Label>
|
||||||
<Textarea
|
<div className="mt-1.5">
|
||||||
id="description"
|
<RichTextEditor
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={setDescription}
|
||||||
placeholder={__('Describe your product in detail...')}
|
placeholder={__('Describe your product in detail...')}
|
||||||
rows={6}
|
|
||||||
className="mt-1.5"
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
{__('Full product description for the product page')}
|
{__('Full product description for the product page')}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
type OrganizationTabProps = {
|
type OrganizationTabProps = {
|
||||||
categories: any[];
|
categories: any[];
|
||||||
@@ -21,6 +26,57 @@ export function OrganizationTab({
|
|||||||
selectedTags,
|
selectedTags,
|
||||||
setSelectedTags,
|
setSelectedTags,
|
||||||
}: OrganizationTabProps) {
|
}: OrganizationTabProps) {
|
||||||
|
const [newCategoryName, setNewCategoryName] = useState('');
|
||||||
|
const [newTagName, setNewTagName] = useState('');
|
||||||
|
const [creatingCategory, setCreatingCategory] = useState(false);
|
||||||
|
const [creatingTag, setCreatingTag] = useState(false);
|
||||||
|
|
||||||
|
const handleCreateCategory = async () => {
|
||||||
|
if (!newCategoryName.trim()) {
|
||||||
|
toast.error(__('Please enter a category name'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreatingCategory(true);
|
||||||
|
try {
|
||||||
|
const response = await api.post('/products/categories', { name: newCategoryName.trim() });
|
||||||
|
toast.success(__('Category created successfully'));
|
||||||
|
setNewCategoryName('');
|
||||||
|
// Auto-select the new category
|
||||||
|
if (response.id) {
|
||||||
|
setSelectedCategories([...selectedCategories, response.id]);
|
||||||
|
}
|
||||||
|
// Note: Parent component should refetch categories
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || __('Failed to create category'));
|
||||||
|
} finally {
|
||||||
|
setCreatingCategory(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateTag = async () => {
|
||||||
|
if (!newTagName.trim()) {
|
||||||
|
toast.error(__('Please enter a tag name'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreatingTag(true);
|
||||||
|
try {
|
||||||
|
const response = await api.post('/products/tags', { name: newTagName.trim() });
|
||||||
|
toast.success(__('Tag created successfully'));
|
||||||
|
setNewTagName('');
|
||||||
|
// Auto-select the new tag
|
||||||
|
if (response.id) {
|
||||||
|
setSelectedTags([...selectedTags, response.id]);
|
||||||
|
}
|
||||||
|
// Note: Parent component should refetch tags
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || __('Failed to create tag'));
|
||||||
|
} finally {
|
||||||
|
setCreatingTag(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -28,9 +84,34 @@ export function OrganizationTab({
|
|||||||
<CardTitle>{__('Categories')}</CardTitle>
|
<CardTitle>{__('Categories')}</CardTitle>
|
||||||
<CardDescription>{__('Organize your product into categories')}</CardDescription>
|
<CardDescription>{__('Organize your product into categories')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-4">
|
||||||
|
{/* Create New Category */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder={__('New category name...')}
|
||||||
|
value={newCategoryName}
|
||||||
|
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCreateCategory();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCreateCategory}
|
||||||
|
disabled={creatingCategory || !newCategoryName.trim()}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
{__('Add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Existing Categories */}
|
||||||
{categories.length === 0 ? (
|
{categories.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">{__('No categories available')}</p>
|
<p className="text-sm text-muted-foreground">{__('No categories yet. Create one above!')}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{categories.map((cat: any) => (
|
{categories.map((cat: any) => (
|
||||||
@@ -61,9 +142,34 @@ export function OrganizationTab({
|
|||||||
<CardTitle>{__('Tags')}</CardTitle>
|
<CardTitle>{__('Tags')}</CardTitle>
|
||||||
<CardDescription>{__('Add tags to help customers find your product')}</CardDescription>
|
<CardDescription>{__('Add tags to help customers find your product')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-4">
|
||||||
|
{/* Create New Tag */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder={__('New tag name...')}
|
||||||
|
value={newTagName}
|
||||||
|
onChange={(e) => setNewTagName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCreateTag();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCreateTag}
|
||||||
|
disabled={creatingTag || !newTagName.trim()}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
{__('Add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Existing Tags */}
|
||||||
{tags.length === 0 ? (
|
{tags.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">{__('No tags available')}</p>
|
<p className="text-sm text-muted-foreground">{__('No tags yet. Create one above!')}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{tags.map((tag: any) => (
|
{tags.map((tag: any) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user