Skip to content

Commit 7d7e0f6

Browse files
Add inventory & stock UI
Implements Shopify-like stock management: - Added stock status badge and back-in-stock form components - Integrated stock status into product detail page - Expanded ProductDialog to support inventory fields (track_inventory, stock_quantity, low_stock_threshold, allow_backorder) - DB migrations to add inventory columns and back_in_stock_requests table with basic RLS policies - Frontend wiring for stock handling and back-in-stock notifications X-Lovable-Edit-ID: edt-3bd6acf5-1aea-404f-9beb-5a38bed74656 Co-authored-by: magnusfroste <38864257+magnusfroste@users.noreply.github.com>
2 parents 4047ea1 + fbd4df5 commit 7d7e0f6

File tree

6 files changed

+372
-57
lines changed

6 files changed

+372
-57
lines changed

src/components/admin/ProductDialog.tsx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
1010
import { Input } from '@/components/ui/input';
1111
import { Textarea } from '@/components/ui/textarea';
1212
import { Label } from '@/components/ui/label';
13+
import { Switch } from '@/components/ui/switch';
1314
import {
1415
Select,
1516
SelectContent,
@@ -32,6 +33,10 @@ interface FormData {
3233
price: string;
3334
currency: string;
3435
image_url: string;
36+
track_inventory: boolean;
37+
stock_quantity: string;
38+
low_stock_threshold: string;
39+
allow_backorder: boolean;
3540
}
3641

3742
export function ProductDialog({ open, onOpenChange, product }: ProductDialogProps) {
@@ -46,10 +51,15 @@ export function ProductDialog({ open, onOpenChange, product }: ProductDialogProp
4651
price: '',
4752
currency: 'USD',
4853
image_url: '',
54+
track_inventory: false,
55+
stock_quantity: '',
56+
low_stock_threshold: '5',
57+
allow_backorder: false,
4958
},
5059
});
5160

5261
const productType = watch('type');
62+
const trackInventory = watch('track_inventory');
5363

5464
useEffect(() => {
5565
if (product) {
@@ -60,6 +70,10 @@ export function ProductDialog({ open, onOpenChange, product }: ProductDialogProp
6070
price: (product.price_cents / 100).toString(),
6171
currency: product.currency,
6272
image_url: product.image_url || '',
73+
track_inventory: product.track_inventory,
74+
stock_quantity: product.stock_quantity?.toString() ?? '',
75+
low_stock_threshold: product.low_stock_threshold.toString(),
76+
allow_backorder: product.allow_backorder,
6377
});
6478
} else {
6579
reset({
@@ -69,6 +83,10 @@ export function ProductDialog({ open, onOpenChange, product }: ProductDialogProp
6983
price: '',
7084
currency: 'USD',
7185
image_url: '',
86+
track_inventory: false,
87+
stock_quantity: '',
88+
low_stock_threshold: '5',
89+
allow_backorder: false,
7290
});
7391
}
7492
}, [product, reset]);
@@ -84,6 +102,10 @@ export function ProductDialog({ open, onOpenChange, product }: ProductDialogProp
84102
sort_order: 0,
85103
image_url: data.image_url?.trim() || null,
86104
stripe_price_id: product?.stripe_price_id ?? null,
105+
track_inventory: data.track_inventory,
106+
stock_quantity: data.track_inventory ? (data.stock_quantity ? parseInt(data.stock_quantity) : 0) : null,
107+
low_stock_threshold: parseInt(data.low_stock_threshold) || 5,
108+
allow_backorder: data.allow_backorder,
87109
};
88110

89111
if (product) {
@@ -195,6 +217,55 @@ export function ProductDialog({ open, onOpenChange, product }: ProductDialogProp
195217
</div>
196218
</div>
197219

220+
{/* Inventory */}
221+
<div className="space-y-3 rounded-lg border p-4">
222+
<div className="flex items-center justify-between">
223+
<div>
224+
<Label className="text-sm font-medium">Track inventory</Label>
225+
<p className="text-xs text-muted-foreground">Enable stock management for this product</p>
226+
</div>
227+
<Switch
228+
checked={trackInventory}
229+
onCheckedChange={(v) => setValue('track_inventory', v)}
230+
/>
231+
</div>
232+
233+
{trackInventory && (
234+
<div className="grid grid-cols-2 gap-4 pt-2">
235+
<div className="space-y-2">
236+
<Label htmlFor="stock_quantity">Stock quantity</Label>
237+
<Input
238+
id="stock_quantity"
239+
type="number"
240+
min="0"
241+
{...register('stock_quantity')}
242+
placeholder="0"
243+
/>
244+
</div>
245+
<div className="space-y-2">
246+
<Label htmlFor="low_stock_threshold">Low stock threshold</Label>
247+
<Input
248+
id="low_stock_threshold"
249+
type="number"
250+
min="0"
251+
{...register('low_stock_threshold')}
252+
placeholder="5"
253+
/>
254+
</div>
255+
<div className="col-span-2 flex items-center justify-between">
256+
<div>
257+
<Label className="text-sm">Allow backorders</Label>
258+
<p className="text-xs text-muted-foreground">Continue selling when out of stock</p>
259+
</div>
260+
<Switch
261+
checked={watch('allow_backorder')}
262+
onCheckedChange={(v) => setValue('allow_backorder', v)}
263+
/>
264+
</div>
265+
</div>
266+
)}
267+
</div>
268+
198269
<div className="flex justify-end gap-2 pt-4">
199270
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
200271
Cancel
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { useState } from 'react';
2+
import { Badge } from '@/components/ui/badge';
3+
import { Button } from '@/components/ui/button';
4+
import { Input } from '@/components/ui/input';
5+
import { Bell, CheckCircle2, AlertTriangle, XCircle } from 'lucide-react';
6+
import { supabase } from '@/integrations/supabase/client';
7+
import { toast } from 'sonner';
8+
import { cn } from '@/lib/utils';
9+
import type { Product, StockStatus as StockStatusType } from '@/hooks/useProducts';
10+
import { getStockStatus } from '@/hooks/useProducts';
11+
12+
interface StockStatusBadgeProps {
13+
product: Product;
14+
className?: string;
15+
}
16+
17+
export function StockStatusBadge({ product, className }: StockStatusBadgeProps) {
18+
const status = getStockStatus(product);
19+
20+
if (status === 'untracked') return null;
21+
22+
const config: Record<StockStatusType, { label: string; icon: React.ReactNode; variant: string }> = {
23+
in_stock: {
24+
label: 'In stock',
25+
icon: <CheckCircle2 className="h-3.5 w-3.5" />,
26+
variant: 'text-emerald-600 bg-emerald-50 dark:text-emerald-400 dark:bg-emerald-950/50',
27+
},
28+
low_stock: {
29+
label: product.stock_quantity !== null ? `Only ${product.stock_quantity} left` : 'Low stock',
30+
icon: <AlertTriangle className="h-3.5 w-3.5" />,
31+
variant: 'text-amber-600 bg-amber-50 dark:text-amber-400 dark:bg-amber-950/50',
32+
},
33+
out_of_stock: {
34+
label: product.allow_backorder ? 'Pre-order' : 'Out of stock',
35+
icon: <XCircle className="h-3.5 w-3.5" />,
36+
variant: product.allow_backorder
37+
? 'text-blue-600 bg-blue-50 dark:text-blue-400 dark:bg-blue-950/50'
38+
: 'text-destructive bg-destructive/10',
39+
},
40+
untracked: { label: '', icon: null, variant: '' },
41+
};
42+
43+
const { label, icon, variant } = config[status];
44+
45+
return (
46+
<Badge
47+
variant="outline"
48+
className={cn(
49+
'rounded-full gap-1.5 border-transparent font-medium text-xs px-3 py-1',
50+
variant,
51+
className
52+
)}
53+
>
54+
{icon}
55+
{label}
56+
</Badge>
57+
);
58+
}
59+
60+
interface BackInStockFormProps {
61+
productId: string;
62+
productName: string;
63+
className?: string;
64+
}
65+
66+
export function BackInStockForm({ productId, productName, className }: BackInStockFormProps) {
67+
const [email, setEmail] = useState('');
68+
const [submitted, setSubmitted] = useState(false);
69+
const [loading, setLoading] = useState(false);
70+
71+
const handleSubmit = async (e: React.FormEvent) => {
72+
e.preventDefault();
73+
if (!email) return;
74+
75+
setLoading(true);
76+
const { error } = await supabase
77+
.from('back_in_stock_requests' as any)
78+
.upsert(
79+
{ product_id: productId, email } as any,
80+
{ onConflict: 'product_id,email' }
81+
);
82+
83+
setLoading(false);
84+
85+
if (error) {
86+
toast.error('Could not save your request. Please try again.');
87+
return;
88+
}
89+
90+
setSubmitted(true);
91+
toast.success('We\'ll notify you when it\'s back!');
92+
};
93+
94+
if (submitted) {
95+
return (
96+
<div className={cn('flex items-center gap-2 text-sm text-muted-foreground', className)}>
97+
<Bell className="h-4 w-4 text-primary" />
98+
<span>We'll email you when <strong>{productName}</strong> is back in stock.</span>
99+
</div>
100+
);
101+
}
102+
103+
return (
104+
<form onSubmit={handleSubmit} className={cn('space-y-3', className)}>
105+
<p className="text-sm text-muted-foreground flex items-center gap-2">
106+
<Bell className="h-4 w-4" />
107+
Get notified when this product is back in stock
108+
</p>
109+
<div className="flex gap-2">
110+
<Input
111+
type="email"
112+
placeholder="your@email.com"
113+
value={email}
114+
onChange={(e) => setEmail(e.target.value)}
115+
required
116+
className="h-10"
117+
/>
118+
<Button type="submit" size="sm" className="h-10 px-4 shrink-0" disabled={loading}>
119+
{loading ? 'Saving...' : 'Notify me'}
120+
</Button>
121+
</div>
122+
</form>
123+
);
124+
}

src/hooks/useProducts.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,31 @@ export interface Product {
1616
sort_order: number;
1717
image_url: string | null;
1818
stripe_price_id: string | null;
19+
category_id: string | null;
20+
stock_quantity: number | null;
21+
track_inventory: boolean;
22+
low_stock_threshold: number;
23+
allow_backorder: boolean;
1924
created_at: string;
2025
updated_at: string;
2126
}
2227

28+
export type StockStatus = 'in_stock' | 'low_stock' | 'out_of_stock' | 'untracked';
29+
30+
export function getStockStatus(product: Product): StockStatus {
31+
if (!product.track_inventory) return 'untracked';
32+
if (product.stock_quantity === null || product.stock_quantity === undefined) return 'untracked';
33+
if (product.stock_quantity <= 0) return 'out_of_stock';
34+
if (product.stock_quantity <= product.low_stock_threshold) return 'low_stock';
35+
return 'in_stock';
36+
}
37+
38+
export function isProductPurchasable(product: Product): boolean {
39+
const status = getStockStatus(product);
40+
if (status === 'out_of_stock') return product.allow_backorder;
41+
return true;
42+
}
43+
2344
export function useProducts(options?: { activeOnly?: boolean }) {
2445
return useQuery({
2546
queryKey: ['products', options],
@@ -64,7 +85,7 @@ export function useCreateProduct() {
6485
const queryClient = useQueryClient();
6586

6687
return useMutation({
67-
mutationFn: async (product: Omit<Product, 'id' | 'created_at' | 'updated_at'>) => {
88+
mutationFn: async (product: Omit<Product, 'id' | 'created_at' | 'updated_at' | 'category_id' | 'stock_quantity' | 'track_inventory' | 'low_stock_threshold' | 'allow_backorder'> & Partial<Pick<Product, 'category_id' | 'stock_quantity' | 'track_inventory' | 'low_stock_threshold' | 'allow_backorder'>>) => {
6889
const { data, error } = await supabase
6990
.from('products')
7091
.insert(product)

src/integrations/supabase/types.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,38 @@ export type Database = {
482482
}
483483
Relationships: []
484484
}
485+
back_in_stock_requests: {
486+
Row: {
487+
created_at: string
488+
email: string
489+
id: string
490+
notified_at: string | null
491+
product_id: string
492+
}
493+
Insert: {
494+
created_at?: string
495+
email: string
496+
id?: string
497+
notified_at?: string | null
498+
product_id: string
499+
}
500+
Update: {
501+
created_at?: string
502+
email?: string
503+
id?: string
504+
notified_at?: string | null
505+
product_id?: string
506+
}
507+
Relationships: [
508+
{
509+
foreignKeyName: "back_in_stock_requests_product_id_fkey"
510+
columns: ["product_id"]
511+
isOneToOne: false
512+
referencedRelation: "products"
513+
referencedColumns: ["id"]
514+
},
515+
]
516+
}
485517
blog_categories: {
486518
Row: {
487519
created_at: string
@@ -2235,47 +2267,59 @@ export type Database = {
22352267
}
22362268
products: {
22372269
Row: {
2270+
allow_backorder: boolean
22382271
category_id: string | null
22392272
created_at: string
22402273
currency: string
22412274
description: string | null
22422275
id: string
22432276
image_url: string | null
22442277
is_active: boolean
2278+
low_stock_threshold: number
22452279
name: string
22462280
price_cents: number
22472281
sort_order: number | null
2282+
stock_quantity: number | null
22482283
stripe_price_id: string | null
2284+
track_inventory: boolean
22492285
type: Database["public"]["Enums"]["product_type"]
22502286
updated_at: string
22512287
}
22522288
Insert: {
2289+
allow_backorder?: boolean
22532290
category_id?: string | null
22542291
created_at?: string
22552292
currency?: string
22562293
description?: string | null
22572294
id?: string
22582295
image_url?: string | null
22592296
is_active?: boolean
2297+
low_stock_threshold?: number
22602298
name: string
22612299
price_cents?: number
22622300
sort_order?: number | null
2301+
stock_quantity?: number | null
22632302
stripe_price_id?: string | null
2303+
track_inventory?: boolean
22642304
type?: Database["public"]["Enums"]["product_type"]
22652305
updated_at?: string
22662306
}
22672307
Update: {
2308+
allow_backorder?: boolean
22682309
category_id?: string | null
22692310
created_at?: string
22702311
currency?: string
22712312
description?: string | null
22722313
id?: string
22732314
image_url?: string | null
22742315
is_active?: boolean
2316+
low_stock_threshold?: number
22752317
name?: string
22762318
price_cents?: number
22772319
sort_order?: number | null
2320+
stock_quantity?: number | null
22782321
stripe_price_id?: string | null
2322+
track_inventory?: boolean
22792323
type?: Database["public"]["Enums"]["product_type"]
22802324
updated_at?: string
22812325
}

0 commit comments

Comments
 (0)