From d9cb44e93c827006e52a50b326af7cdeffc62472 Mon Sep 17 00:00:00 2001 From: Brian Rogers Date: Fri, 10 Apr 2026 08:54:20 -0600 Subject: [PATCH] adding a cart --- app/Http/Controllers/CartController.php | 78 +++++++++ app/Http/Requests/StoreCartRequest.php | 24 +++ app/Http/Requests/UpdateCartRequest.php | 24 +++ app/Models/Cart.php | 27 ++++ app/Models/User.php | 6 + bootstrap/app.php | 5 + database/factories/CartFactory.php | 24 +++ .../2026_04_10_135926_create_carts_table.php | 29 ++++ ...04_10_140658_create_cart_product_table.php | 29 ++++ ...2833_add_unique_user_id_to_carts_table.php | 28 ++++ ...8_make_user_id_nullable_on_carts_table.php | 30 ++++ resources/js/pages/Home.vue | 151 ++++++++++++++---- routes/api.php | 10 +- routes/web.php | 18 ++- .../Http/Controllers/CartControllerTest.php | 77 +++++++++ tests/Feature/Models/CartTest.php | 38 +++++ 16 files changed, 559 insertions(+), 39 deletions(-) create mode 100644 app/Http/Controllers/CartController.php create mode 100644 app/Http/Requests/StoreCartRequest.php create mode 100644 app/Http/Requests/UpdateCartRequest.php create mode 100644 app/Models/Cart.php create mode 100644 database/factories/CartFactory.php create mode 100644 database/migrations/2026_04_10_135926_create_carts_table.php create mode 100644 database/migrations/2026_04_10_140658_create_cart_product_table.php create mode 100644 database/migrations/2026_04_10_142833_add_unique_user_id_to_carts_table.php create mode 100644 database/migrations/2026_04_10_144128_make_user_id_nullable_on_carts_table.php create mode 100644 tests/Feature/Http/Controllers/CartControllerTest.php create mode 100644 tests/Feature/Models/CartTest.php diff --git a/app/Http/Controllers/CartController.php b/app/Http/Controllers/CartController.php new file mode 100644 index 0000000..9744cce --- /dev/null +++ b/app/Http/Controllers/CartController.php @@ -0,0 +1,78 @@ +resolveCart(); + + $existing = $cart->products()->wherePivot('product_id', $request->product_id)->first(); + + if ($existing) { + $cart->products()->updateExistingPivot($request->product_id, [ + 'quantity' => $existing->pivot->quantity + 1, + ]); + } else { + $cart->products()->attach($request->product_id, ['quantity' => 1]); + } + + return response()->json($this->cartItems($cart)); + } + + public function update(UpdateCartRequest $request, Product $product): JsonResponse + { + $cart = $this->resolveCart(); + + $cart->products()->updateExistingPivot($product->id, [ + 'quantity' => $request->quantity, + ]); + + return response()->json($this->cartItems($cart)); + } + + public function destroy(Request $request, Product $product): JsonResponse + { + $cart = $this->resolveCart(); + + $cart->products()->detach($product->id); + + return response()->json($this->cartItems($cart)); + } + + private function resolveCart(): Cart + { + $cartId = session('cart_id'); + + if ($cartId) { + $cart = Cart::find($cartId); + if ($cart) { + return $cart; + } + } + + $cart = Cart::create(['user_id' => request()->user()?->id]); + session(['cart_id' => $cart->id]); + + return $cart; + } + + /** + * @return array + */ + private function cartItems(Cart $cart): array + { + return $cart->products()->get()->map(fn ($p) => [ + 'product_id' => $p->id, + 'quantity' => $p->pivot->quantity, + ])->all(); + } +} diff --git a/app/Http/Requests/StoreCartRequest.php b/app/Http/Requests/StoreCartRequest.php new file mode 100644 index 0000000..9a35f34 --- /dev/null +++ b/app/Http/Requests/StoreCartRequest.php @@ -0,0 +1,24 @@ +|string> + */ + public function rules(): array + { + return [ + 'product_id' => ['required', 'integer', 'exists:products,id'], + ]; + } +} diff --git a/app/Http/Requests/UpdateCartRequest.php b/app/Http/Requests/UpdateCartRequest.php new file mode 100644 index 0000000..6e0ed5b --- /dev/null +++ b/app/Http/Requests/UpdateCartRequest.php @@ -0,0 +1,24 @@ +|string> + */ + public function rules(): array + { + return [ + 'quantity' => ['required', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Models/Cart.php b/app/Models/Cart.php new file mode 100644 index 0000000..171a002 --- /dev/null +++ b/app/Models/Cart.php @@ -0,0 +1,27 @@ + */ + use HasFactory; + + protected $fillable = ['user_id']; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function products(): BelongsToMany + { + return $this->belongsToMany(Product::class)->withPivot('quantity'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index f40a71d..804e01c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,6 +7,7 @@ use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Fortify\TwoFactorAuthenticatable; @@ -18,6 +19,11 @@ class User extends Authenticatable /** @use HasFactory */ use HasFactory, Notifiable, TwoFactorAuthenticatable; + public function cart(): HasOne + { + return $this->hasOne(Cart::class); + } + /** * Get the attributes that should be cast. * diff --git a/bootstrap/app.php b/bootstrap/app.php index 34a6bb8..4905b14 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -6,6 +6,7 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets; +use Illuminate\Session\Middleware\StartSession; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( @@ -17,6 +18,10 @@ return Application::configure(basePath: dirname(__DIR__)) ->withMiddleware(function (Middleware $middleware): void { $middleware->encryptCookies(except: ['appearance', 'sidebar_state']); + $middleware->api(append: [ + StartSession::class, + ]); + $middleware->web(append: [ HandleAppearance::class, HandleInertiaRequests::class, diff --git a/database/factories/CartFactory.php b/database/factories/CartFactory.php new file mode 100644 index 0000000..1c9579b --- /dev/null +++ b/database/factories/CartFactory.php @@ -0,0 +1,24 @@ + + */ +class CartFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => \App\Models\User::factory(), + ]; + } +} diff --git a/database/migrations/2026_04_10_135926_create_carts_table.php b/database/migrations/2026_04_10_135926_create_carts_table.php new file mode 100644 index 0000000..0df4b94 --- /dev/null +++ b/database/migrations/2026_04_10_135926_create_carts_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('user_id')->constrained(); + $table->timestamp('stale_date')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('carts'); + } +}; diff --git a/database/migrations/2026_04_10_140658_create_cart_product_table.php b/database/migrations/2026_04_10_140658_create_cart_product_table.php new file mode 100644 index 0000000..50f1f93 --- /dev/null +++ b/database/migrations/2026_04_10_140658_create_cart_product_table.php @@ -0,0 +1,29 @@ +foreignId('cart_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('quantity')->default(1); + $table->primary(['cart_id', 'product_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cart_product'); + } +}; diff --git a/database/migrations/2026_04_10_142833_add_unique_user_id_to_carts_table.php b/database/migrations/2026_04_10_142833_add_unique_user_id_to_carts_table.php new file mode 100644 index 0000000..dd0235d --- /dev/null +++ b/database/migrations/2026_04_10_142833_add_unique_user_id_to_carts_table.php @@ -0,0 +1,28 @@ +unique('user_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('carts', function (Blueprint $table) { + $table->dropUnique(['user_id']); + }); + } +}; diff --git a/database/migrations/2026_04_10_144128_make_user_id_nullable_on_carts_table.php b/database/migrations/2026_04_10_144128_make_user_id_nullable_on_carts_table.php new file mode 100644 index 0000000..310454a --- /dev/null +++ b/database/migrations/2026_04_10_144128_make_user_id_nullable_on_carts_table.php @@ -0,0 +1,30 @@ +dropUnique(['user_id']); + $table->foreignId('user_id')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('carts', function (Blueprint $table) { + $table->foreignId('user_id')->nullable(false)->change(); + $table->unique('user_id'); + }); + } +}; diff --git a/resources/js/pages/Home.vue b/resources/js/pages/Home.vue index 481fd01..1fb9ab7 100644 --- a/resources/js/pages/Home.vue +++ b/resources/js/pages/Home.vue @@ -1,5 +1,8 @@ @@ -23,39 +70,79 @@ function formatPrice(cents: number): string { -
-
-

Packages

-
-
-

No packages available.

-
-
- -
- No image -
-
-

{{ product.name }}

-

{{ product.description }}

-
-

{{ formatPrice(product.price_cents) }}

- +
+
+
+

Packages

+
+
+

No packages available.

+
+
+ +
+ No image +
+
+

{{ product.name }}

+

{{ product.description }}

+
+

{{ formatPrice(product.price_cents) }}

+ +
+
+
+ +
+
diff --git a/routes/api.php b/routes/api.php index ca0bca0..252122f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,9 +1,11 @@ user()->cart(); +//middleware('auth:sanctum')-> +Route::group([], function () { + Route::post('/cart', [CartController::class, 'store'])->name('cart.store'); + Route::patch('/cart/{product}', [CartController::class, 'update'])->name('cart.update'); + Route::delete('/cart/{product}', [CartController::class, 'destroy'])->name('cart.destroy'); }); -//})->middleware('auth:sanctum'); diff --git a/routes/web.php b/routes/web.php index 088b46c..f894735 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ Features::enabled(Features::registration()), ])->name('home');*/ -Route::get('/', fn () => inertia('Home', [ - 'products' => Product::all(), -]))->name('home'); +Route::get('/', function () { + $cartId = session('cart_id'); + $cart = $cartId ? Cart::with('products')->find($cartId) : null; + + return inertia('Home', [ + 'products' => Product::all(), + 'cart' => $cart + ? $cart->products->map(fn ($p) => [ + 'product_id' => $p->id, + 'quantity' => $p->pivot->quantity, + ]) + : [], + ]); +})->name('home'); Route::middleware(['auth', 'verified'])->group(function () { Route::inertia('dashboard', 'Dashboard')->name('dashboard'); diff --git a/tests/Feature/Http/Controllers/CartControllerTest.php b/tests/Feature/Http/Controllers/CartControllerTest.php new file mode 100644 index 0000000..0fc2231 --- /dev/null +++ b/tests/Feature/Http/Controllers/CartControllerTest.php @@ -0,0 +1,77 @@ +create(); + + $this->postJson('/api/cart', ['product_id' => $product->id]) + ->assertSuccessful() + ->assertJsonFragment(['product_id' => $product->id, 'quantity' => 1]); +}); + +test('adding a product creates a cart and returns cart items', function () { + $product = Product::factory()->create(); + + $response = $this->postJson('/api/cart', ['product_id' => $product->id]); + + $response->assertSuccessful() + ->assertJsonFragment(['product_id' => $product->id, 'quantity' => 1]); + + expect(Cart::count())->toBe(1); +}); + +test('adding the same product increments quantity', function () { + $product = Product::factory()->create(); + + $this->postJson('/api/cart', ['product_id' => $product->id]); + $response = $this->postJson('/api/cart', ['product_id' => $product->id]); + + $response->assertSuccessful() + ->assertJsonFragment(['product_id' => $product->id, 'quantity' => 2]); +}); + +test('adding a non-existent product fails validation', function () { + $this->postJson('/api/cart', ['product_id' => 9999]) + ->assertUnprocessable(); +}); + +test('updating a cart item changes its quantity', function () { + $product = Product::factory()->create(); + $cart = Cart::factory()->create(['user_id' => null]); + $cart->products()->attach($product->id, ['quantity' => 1]); + + $response = $this->withSession(['cart_id' => $cart->id]) + ->patchJson("/api/cart/{$product->id}", ['quantity' => 5]); + + $response->assertSuccessful() + ->assertJsonFragment(['product_id' => $product->id, 'quantity' => 5]); +}); + +test('removing a product detaches it from the cart', function () { + $product = Product::factory()->create(); + $cart = Cart::factory()->create(['user_id' => null]); + $cart->products()->attach($product->id, ['quantity' => 2]); + + $response = $this->withSession(['cart_id' => $cart->id]) + ->deleteJson("/api/cart/{$product->id}"); + + $response->assertSuccessful() + ->assertJsonMissing(['product_id' => $product->id]); + + expect($cart->fresh()->products)->toHaveCount(0); +}); + +test('a cart can optionally belong to a user', function () { + $user = User::factory()->create(); + $cartWithUser = Cart::factory()->create(['user_id' => $user->id]); + $cartGuest = Cart::factory()->create(['user_id' => null]); + + expect($cartWithUser->user)->toBeInstanceOf(User::class); + expect($cartGuest->user)->toBeNull(); +}); diff --git a/tests/Feature/Models/CartTest.php b/tests/Feature/Models/CartTest.php new file mode 100644 index 0000000..404fbea --- /dev/null +++ b/tests/Feature/Models/CartTest.php @@ -0,0 +1,38 @@ +create(); + $cart = Cart::factory()->create(['user_id' => $user->id]); + + expect($cart->user->id)->toBe($user->id); +}); + +test('a cart can exist without a user', function () { + $cart = Cart::factory()->create(['user_id' => null]); + + expect($cart->user)->toBeNull(); +}); + +test('a user has one cart', function () { + $user = User::factory()->create(); + Cart::factory()->create(['user_id' => $user->id]); + + expect($user->cart)->toBeInstanceOf(Cart::class); +}); + +test('a cart can have products with quantities', function () { + $cart = Cart::factory()->create(['user_id' => null]); + $product = Product::factory()->create(); + + $cart->products()->attach($product->id, ['quantity' => 3]); + + expect($cart->products)->toHaveCount(1); + expect($cart->products->first()->pivot->quantity)->toBe(3); +});