Skip to content

Commit 6b7c6eb

Browse files
committed
feat: implement cart UI and add to cart functionality
- Add cart page with product list, quantity controls, and order summary - Implement add to cart on homepage and product detail pages - Rename Cart relationship from item() to product() for customer-facing consistency - Add CartItem type definition - Update AppHeader to show cart (placeholder)
1 parent 7c379a0 commit 6b7c6eb

8 files changed

Lines changed: 307 additions & 13 deletions

File tree

app/Models/Cart.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@ public function user(): BelongsTo
2525
}
2626

2727
/**
28-
* Get the item that owns the Cart
28+
* Get the product (item) for this cart entry
2929
*
3030
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
3131
*/
32-
public function item(): BelongsTo
32+
public function product(): BelongsTo
3333
{
3434
return $this->belongsTo(Item::class, 'item_id');
3535
}

app/Repositories/CartRepository.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public function __construct(Cart $cart)
2727
public function getUserCart(int $userId): Collection
2828
{
2929
return $this->query()
30-
->with('item')
30+
->with('product')
3131
->where('user_id', $userId)
3232
->get();
3333
}

app/Services/Customer/CartService.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ public function updateQuantity(int $cartId, int $quantity): bool
9191
return false;
9292
}
9393

94-
if ($cart->item->quantity < $quantity) {
94+
if ($cart->product->quantity < $quantity) {
9595
throw new \Exception('Insufficient stock');
9696
}
9797

resources/js/components/AppHeader.vue

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,11 @@ import {
3131
import UserMenuContent from '@/components/UserMenuContent.vue';
3232
import { getInitials } from '@/composables/useInitials';
3333
import { toUrl, urlIsActive } from '@/lib/utils';
34+
import cartsRoutes from '@/routes/customer/carts';
3435
import homepageRoutes from '@/routes/customer/homepage';
3536
import type { BreadcrumbItem, NavItem } from '@/types';
3637
import { InertiaLinkProps, Link, usePage } from '@inertiajs/vue3';
37-
import { LayoutGrid, Menu, Search } from 'lucide-vue-next';
38+
import { LayoutGrid, Menu, Search, ShoppingBag } from 'lucide-vue-next';
3839
import { computed } from 'vue';
3940
4041
interface Props {
@@ -66,6 +67,11 @@ const mainNavItems: NavItem[] = [
6667
href: homepageRoutes.index(),
6768
icon: LayoutGrid,
6869
},
70+
{
71+
title: 'My Cart',
72+
href: cartsRoutes.index(),
73+
icon: ShoppingBag
74+
}
6975
];
7076
7177
const rightNavItems: NavItem[] = [
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
<script setup lang="ts">
2+
import AppLayout from '@/layouts/AppLayout.vue';
3+
import { type BreadcrumbItem } from '@/types';
4+
import { Head, router } from '@inertiajs/vue3';
5+
import {
6+
Trash2,
7+
Plus,
8+
Minus,
9+
Package,
10+
ArrowRight,
11+
ShoppingBag,
12+
} from 'lucide-vue-next';
13+
import { Button } from '@/components/ui/button';
14+
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
15+
import { Input } from '@/components/ui/input';
16+
import LinkButton from '@/components/LinkButton.vue';
17+
import { useFormatters } from '@/composables/useFormatters';
18+
import homepageRoutes from '@/routes/customer/homepage';
19+
import cartsRoutes from '@/routes/customer/carts';
20+
import { ref } from 'vue';
21+
import { CartItem } from '@/types/customer';
22+
23+
interface Props {
24+
cartItems: CartItem[];
25+
cartTotal: number;
26+
cartCount: number;
27+
}
28+
29+
defineProps<Props>();
30+
const { formatCurrency } = useFormatters();
31+
32+
const breadcrumbs: BreadcrumbItem[] = [
33+
{
34+
title: 'Homepage',
35+
href: homepageRoutes.index().url,
36+
},
37+
{
38+
title: 'Shopping Cart',
39+
href: '#',
40+
},
41+
];
42+
43+
const updatingItems = ref<Set<number>>(new Set());
44+
45+
const updateQuantity = (cartId: number, newQuantity: number) => {
46+
if (newQuantity < 1) return;
47+
48+
updatingItems.value.add(cartId);
49+
50+
router.put(cartsRoutes.update(cartId).url, {
51+
quantity: newQuantity,
52+
}, {
53+
preserveScroll: true,
54+
onFinish: () => {
55+
updatingItems.value.delete(cartId);
56+
},
57+
});
58+
};
59+
60+
const removeItem = (cartId: number) => {
61+
if (confirm('Are you sure you want to remove this item from your cart?')) {
62+
router.delete(cartsRoutes.destroy(cartId).url, {
63+
preserveScroll: true,
64+
});
65+
}
66+
};
67+
68+
const clearCart = () => {
69+
if (confirm('Are you sure you want to clear your entire cart?')) {
70+
router.delete(cartsRoutes.clear().url);
71+
}
72+
};
73+
</script>
74+
75+
<template>
76+
77+
<Head title="Shopping Cart" />
78+
79+
<AppLayout :breadcrumbs="breadcrumbs">
80+
<div class="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
81+
<!-- Header -->
82+
<div class="flex justify-between items-start">
83+
<div>
84+
<h1 class="text-3xl font-bold flex items-center gap-3">
85+
Shopping Cart
86+
</h1>
87+
<p class="text-muted-foreground mt-1">
88+
{{ cartCount }} {{ cartCount === 1 ? 'item' : 'items' }} in your cart
89+
</p>
90+
</div>
91+
<LinkButton :href="homepageRoutes.index().url" mode="back" label="Continue Shopping" />
92+
</div>
93+
94+
<!-- Empty Cart State -->
95+
<div v-if="cartItems.length === 0" class="flex flex-col items-center justify-center py-16 px-4">
96+
<div class="rounded-full bg-muted p-8 mb-6">
97+
<ShoppingBag class="h-16 w-16 text-muted-foreground" />
98+
</div>
99+
<h3 class="text-2xl font-semibold mb-2">Your cart is empty</h3>
100+
<p class="text-muted-foreground text-center mb-6 max-w-md">
101+
Looks like you haven't added any items to your cart yet. Start shopping to find great products!
102+
</p>
103+
<LinkButton :href="homepageRoutes.index().url" label="Start Shopping" size="lg" />
104+
</div>
105+
106+
<!-- Cart Content -->
107+
<div v-else class="grid grid-cols-1 lg:grid-cols-3 gap-6">
108+
<!-- Cart Items -->
109+
<div class="lg:col-span-2 space-y-4">
110+
<Card v-for="cartItem in cartItems" :key="cartItem.id"
111+
class="overflow-hidden border-2 hover:border-primary/50 transition-all duration-200">
112+
<CardContent class="p-4">
113+
<div class="flex gap-4">
114+
<!-- Product Image -->
115+
<div class="relative w-24 h-24 flex-shrink-0 rounded-lg overflow-hidden border">
116+
<img v-if="cartItem.product.item_image_1"
117+
:src="`/storage/${cartItem.product.item_image_1}`"
118+
:alt="cartItem.product.item_name" class="w-full h-full object-contain p-2" />
119+
<div v-else class="w-full h-full flex items-center justify-center bg-muted">
120+
<Package class="h-8 w-8 text-muted-foreground" />
121+
</div>
122+
</div>
123+
124+
<!-- Product Info -->
125+
<div class="flex-1 min-w-0">
126+
<div class="flex justify-between items-start gap-2 mb-2">
127+
<div class="flex-1 min-w-0">
128+
<p
129+
class="text-xs text-muted-foreground font-medium uppercase tracking-wider">
130+
{{ cartItem.product.brand_name }}
131+
</p>
132+
<h3 class="font-bold text-lg line-clamp-1"
133+
:title="cartItem.product.item_name">
134+
{{ cartItem.product.item_name }}
135+
</h3>
136+
<p class="text-sm text-muted-foreground">
137+
Code: {{ cartItem.product.item_code }}
138+
</p>
139+
</div>
140+
<Button @click="removeItem(cartItem.id)" variant="ghost" size="icon"
141+
class="text-destructive hover:text-destructive hover:bg-destructive/10">
142+
<Trash2 class="h-4 w-4" />
143+
</Button>
144+
</div>
145+
146+
<div class="flex items-center justify-between gap-4 mt-4">
147+
<!-- Quantity Controls -->
148+
<div class="flex items-center gap-2">
149+
<Button @click="updateQuantity(cartItem.id, cartItem.quantity - 1)"
150+
:disabled="cartItem.quantity <= 1 || updatingItems.has(cartItem.id)"
151+
variant="outline" size="icon" class="h-8 w-8">
152+
<Minus class="h-3 w-3" />
153+
</Button>
154+
<Input v-model.number="cartItem.quantity"
155+
type="number"
156+
:min="1"
157+
:max="cartItem.product.quantity"
158+
@change="updateQuantity(cartItem.id, cartItem.quantity)"
159+
:disabled="updatingItems.has(cartItem.id)"
160+
class="text-center font-semibold w-16 h-8 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
161+
/>
162+
<Button @click="updateQuantity(cartItem.id, cartItem.quantity + 1)"
163+
:disabled="cartItem.quantity >= cartItem.product.quantity || updatingItems.has(cartItem.id)"
164+
variant="outline" size="icon" class="h-8 w-8">
165+
<Plus class="h-3 w-3" />
166+
</Button>
167+
</div>
168+
169+
<!-- Price -->
170+
<div class="text-right">
171+
<p class="text-xs text-muted-foreground">
172+
{{ formatCurrency(cartItem.price) }} × {{ cartItem.quantity }}
173+
</p>
174+
<p class="text-xl font-bold text-primary">
175+
{{ formatCurrency(cartItem.price * cartItem.quantity) }}
176+
</p>
177+
</div>
178+
</div>
179+
180+
<!-- Stock Warning -->
181+
<div v-if="cartItem.quantity > cartItem.product.quantity"
182+
class="mt-3 p-2 rounded-lg bg-destructive/10 border border-destructive/20">
183+
<p class="text-xs text-destructive font-medium">
184+
Only {{ cartItem.product.quantity }} units available
185+
</p>
186+
</div>
187+
</div>
188+
</div>
189+
</CardContent>
190+
</Card>
191+
192+
<!-- Clear Cart Button -->
193+
<Button @click="clearCart" variant="outline"
194+
class="w-full text-destructive hover:bg-destructive/10">
195+
<Trash2 class="h-4 w-4 mr-2" />
196+
Clear Cart
197+
</Button>
198+
</div>
199+
200+
<!-- Order Summary -->
201+
<div class="lg:col-span-1">
202+
<Card class="sticky top-6 border-2 border-primary/20">
203+
<CardHeader>
204+
<CardTitle>Order Summary</CardTitle>
205+
</CardHeader>
206+
<CardContent class="space-y-4">
207+
<div class="space-y-2">
208+
<div class="flex justify-between text-sm">
209+
<span class="text-muted-foreground">Subtotal</span>
210+
<span class="font-medium">{{ formatCurrency(cartTotal) }}</span>
211+
</div>
212+
<div class="flex justify-between text-sm">
213+
<span class="text-muted-foreground">Items</span>
214+
<span class="font-medium">{{ cartCount }}</span>
215+
</div>
216+
</div>
217+
218+
<div class="border-t pt-4">
219+
<div class="flex justify-between items-baseline">
220+
<span class="text-lg font-semibold">Total</span>
221+
<span class="text-2xl font-bold text-primary">{{ formatCurrency(cartTotal) }}</span>
222+
</div>
223+
</div>
224+
</CardContent>
225+
<CardFooter class="flex-col gap-3">
226+
<Button size="lg" class="w-full">
227+
Proceed to Checkout
228+
<ArrowRight class="h-5 w-5 ml-2" />
229+
</Button>
230+
<Button :href="homepageRoutes.index().url" as="a" variant="outline" size="lg"
231+
class="w-full">
232+
Continue Shopping
233+
</Button>
234+
</CardFooter>
235+
</Card>
236+
</div>
237+
</div>
238+
</div>
239+
</AppLayout>
240+
</template>

resources/js/pages/customer/Homepage/Index.vue

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script setup lang="ts">
22
import AppLayout from '@/layouts/AppLayout.vue';
33
import { type BreadcrumbItem, type PaginationData } from '@/types';
4-
import { Head, Link } from '@inertiajs/vue3';
4+
import { Head, Link, router } from '@inertiajs/vue3';
55
import { Product } from '@/types/customer';
66
import { Search, ShoppingCart, Package, TrendingUp, Sparkles } from 'lucide-vue-next';
77
import { Input } from '@/components/ui/input';
@@ -13,6 +13,7 @@ import { useFormatters } from '@/composables/useFormatters';
1313
import { useFilters } from '@/composables/useFilters';
1414
import homepageRoutes from '@/routes/customer/homepage';
1515
import productsRoutes from '@/routes/customer/homepage/products';
16+
import cartsRoutes from '@/routes/customer/carts';
1617
1718
interface Props {
1819
products: PaginationData & {
@@ -60,6 +61,17 @@ const getStockStatus = (item: Product) => {
6061
return { label: 'In Stock', class: 'bg-green-500/10 text-green-600 border-green-500/20' };
6162
};
6263
64+
const addToCart = (productId: number, event: Event) => {
65+
event.preventDefault();
66+
event.stopPropagation();
67+
68+
router.post(cartsRoutes.store().url, {
69+
item_id: productId,
70+
quantity: 1,
71+
}, {
72+
preserveScroll: true
73+
});
74+
};
6375
</script>
6476

6577
<template>
@@ -199,6 +211,7 @@ const getStockStatus = (item: Product) => {
199211

200212
<CardFooter class="p-4 pt-0">
201213
<Button :disabled="product.quantity <= 0"
214+
@click="(e) => addToCart(product.id, e)"
202215
class="w-full group-hover:bg-primary group-hover:text-primary-foreground transition-all duration-200"
203216
size="lg">
204217
<ShoppingCart class="h-4 w-4 mr-2" />

0 commit comments

Comments
 (0)