From 9d07523d7820fd084686c870cfaea6c055d466a6 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Wed, 17 Dec 2025 13:04:58 +0100 Subject: [PATCH] I vendor publish, example products --- .../Commands/ShopAddExampleProducts.php | 783 +++++++++++------- src/ShopServiceProvider.php | 11 +- tests/Feature/CommandProductExamplesTest.php | 23 +- 3 files changed, 526 insertions(+), 291 deletions(-) diff --git a/src/Console/Commands/ShopAddExampleProducts.php b/src/Console/Commands/ShopAddExampleProducts.php index 8f48ba1..a1fcbb9 100644 --- a/src/Console/Commands/ShopAddExampleProducts.php +++ b/src/Console/Commands/ShopAddExampleProducts.php @@ -16,9 +16,9 @@ class ShopAddExampleProducts extends Command { protected $signature = 'shop:add-example-products {--clean : Remove existing example products first} - {--count=2 : Number of products per type}'; + {--count=3 : Number of products per type}'; - protected $description = 'Adds all possible example products to the shop for demonstration purposes.'; + protected $description = 'Adds hotel-themed example products with realistic relationships and cross-sells.'; /** * Available product types in the shop system @@ -52,6 +52,8 @@ class ShopAddExampleProducts extends Command protected $faker; protected $categories = []; + protected $createdProducts = []; + protected $productTypeIndex = []; public function handle() { @@ -61,7 +63,7 @@ class ShopAddExampleProducts extends Command $this->cleanExampleProducts(); } - $this->info('Creating example products for Laravel Shop Package...'); + $this->info('Creating hotel-themed example products...'); $this->newLine(); // Create categories first @@ -70,6 +72,11 @@ class ShopAddExampleProducts extends Command $count = (int) $this->option('count'); $totalCreated = 0; + // Initialize product type index + foreach (array_keys(self::PRODUCT_TYPES) as $type) { + $this->productTypeIndex[$type] = 0; + } + foreach (self::PRODUCT_TYPES as $type => $details) { $this->line("Creating {$count} {$details['name']}(s)..."); @@ -77,13 +84,16 @@ class ShopAddExampleProducts extends Command $product = $this->createProduct($type, $i); $totalCreated++; - $this->line(" ✓ {$product->slug}"); + $this->line(" ✓ {$product->name}"); } $this->newLine(); } - $this->info("✓ Successfully created {$totalCreated} example products!"); + // Now add relationships (cross-sells, upsells) + $this->addProductRelationships(); + + $this->info("✓ Successfully created {$totalCreated} hotel example products!"); $this->line(" - Products: {$totalCreated}"); $this->line(" - Categories: " . count($this->categories)); $this->newLine(); @@ -104,11 +114,12 @@ class ShopAddExampleProducts extends Command protected function createCategories(): void { $categoryNames = [ - 'Electronics' => 'Electronic devices and gadgets', - 'Clothing' => 'Apparel and fashion items', - 'Books' => 'Books and publications', - 'Home & Garden' => 'Home improvement and garden supplies', - 'Sports' => 'Sports equipment and accessories', + 'Hotel Rooms' => 'Available room types and accommodations', + 'Room Upgrades' => 'Premium room enhancements and services', + 'Food & Beverage' => 'In-room dining and bar selections', + 'Spa & Wellness' => 'Spa treatments and wellness packages', + 'Parking & Transport' => 'Parking spaces and transportation services', + 'Activities & Tours' => 'Local tours and activities', ]; foreach ($categoryNames as $name => $description) { @@ -129,246 +140,429 @@ class ShopAddExampleProducts extends Command protected function createProduct(string $type, int $index): Product { - $productName = $this->generateProductName($type); - $slug = 'example-' . \Illuminate\Support\Str::slug($productName) . '-' . $this->faker->unique()->numberBetween(1000, 9999); + $productData = $this->getProductDataByType($type, $index); + $productName = $productData['name']; + $slug = 'example-' . \Illuminate\Support\Str::slug($productName) . '-' . uniqid(); - // Determine pricing and sale window for the product (prices managed via ProductPrice) - $baseUnitAmount = $this->faker->numberBetween(1000, 50000); // cents - $onSale = $this->faker->boolean(30); // 30% chance of being on sale + // Determine pricing + $baseUnitAmount = $productData['price']; + $onSale = $productData['on_sale'] ?? false; $saleStart = $onSale ? now()->subDays($this->faker->numberBetween(1, 30)) : null; $saleEnd = $onSale ? now()->addDays($this->faker->numberBetween(7, 60)) : null; $product = Product::create([ 'slug' => $slug, 'name' => $productName, - 'sku' => 'EX-' . strtoupper($this->faker->bothify('??-####')), + 'sku' => $productData['sku'], 'type' => ProductType::from($type), - 'status' => $this->faker->randomElement([ProductStatus::PUBLISHED, ProductStatus::PUBLISHED, ProductStatus::PUBLISHED, ProductStatus::DRAFT]), - 'is_visible' => true, - 'featured' => $this->faker->boolean(20), + 'status' => ProductStatus::PUBLISHED, + 'is_visible' => $productData['visible'] ?? true, + 'featured' => $productData['featured'] ?? false, 'sale_start' => $saleStart, 'sale_end' => $saleEnd, - 'manage_stock' => $type !== ProductType::EXTERNAL->value, - 'low_stock_threshold' => $type !== ProductType::EXTERNAL->value ? 5 : null, - 'weight' => $type === 'virtual' ? null : $this->faker->randomFloat(2, 0.1, 50), - 'length' => $type === 'virtual' ? null : $this->faker->randomFloat(2, 5, 100), - 'width' => $type === 'virtual' ? null : $this->faker->randomFloat(2, 5, 100), - 'height' => $type === 'virtual' ? null : $this->faker->randomFloat(2, 5, 100), - 'virtual' => $type === ProductType::VARIABLE->value ? $this->faker->boolean(20) : false, - 'downloadable' => $type === ProductType::SIMPLE->value ? $this->faker->boolean(15) : false, + 'manage_stock' => $productData['manage_stock'] ?? ($type !== ProductType::EXTERNAL->value), + 'low_stock_threshold' => $productData['low_stock_threshold'] ?? 5, + 'weight' => $productData['weight'] ?? null, + 'length' => $productData['length'] ?? null, + 'width' => $productData['width'] ?? null, + 'height' => $productData['height'] ?? null, + 'virtual' => $productData['virtual'] ?? false, + 'downloadable' => $productData['downloadable'] ?? false, 'published_at' => now(), 'sort_order' => $this->faker->numberBetween(0, 100), - 'tax_class' => $this->faker->randomElement(['standard', 'reduced', 'zero']), + 'tax_class' => 'standard', 'meta' => [ - 'description' => $this->faker->paragraph(3), - 'short_description' => $this->faker->sentence(10), + 'description' => $productData['description'], + 'short_description' => $productData['short_description'], 'example' => true, ], ]); - // Set localized name + // Set localized name and descriptions $product->setLocalized('name', $productName, null, true); + $product->setLocalized('short_description', $productData['short_description'], null, true); + $product->setLocalized('description', $productData['description'], null, true); - // Add to random categories - $randomCategories = $this->faker->randomElements($this->categories, $this->faker->numberBetween(1, 3)); - $product->categories()->attach(collect($randomCategories)->pluck('id')); + // Add to appropriate categories + $categoryNames = $productData['categories'] ?? []; + $matchingCategories = collect($this->categories)->filter(function ($cat) use ($categoryNames) { + return in_array($cat->name, $categoryNames); + }); + if ($matchingCategories->isNotEmpty()) { + $product->categories()->attach($matchingCategories->pluck('id')); + } - // Add localized fields - $product->setLocalized('name', $productName, null, true); - $product->setLocalized('short_description', $product->meta->short_description ?? '', null, true); - $product->setLocalized('description', $product->meta->description ?? '', null, true); - - // Create default price entry (prices are morph-related) + // Create default price $product->prices()->create([ 'name' => 'Default', 'type' => 'one_time', 'currency' => 'EUR', 'unit_amount' => $baseUnitAmount, - 'sale_unit_amount' => $onSale ? (int) round($baseUnitAmount * $this->faker->randomFloat(2, 0.6, 0.9)) : null, + 'sale_unit_amount' => $onSale ? (int) round($baseUnitAmount * 0.85) : null, 'is_default' => true, 'active' => true, 'billing_scheme' => 'per_unit', 'meta' => ['example' => true], ]); + // Add stock if needed + if ($productData['stock'] ?? 0 > 0) { + $product->increaseStock($productData['stock']); + } + // Add attributes - $this->addAttributes($product, $type); - - // Add additional prices (multi-currency or subscription) - if ($type === ProductType::SIMPLE->value || $type === ProductType::VARIABLE->value) { - $this->addAdditionalPrices($product, $baseUnitAmount); + if (isset($productData['attributes'])) { + $this->addAttributesToProduct($product, $productData['attributes']); } - // Add example actions - $this->addExampleActions($product); + // Store for later relationship building + $this->createdProducts[$type][] = $product; - // For variable products, add variations + // Handle type-specific creation if ($type === ProductType::VARIABLE->value) { - $this->addVariations($product, $baseUnitAmount); - } - - // For grouped products, add child products - if ($type === ProductType::GROUPED->value) { - $this->addGroupedProducts($product); - } - - // Special example: Booking product that includes a parking pool - // Create a demonstrative Hotel Room booking with 3 parking plots - if ($type === ProductType::BOOKING->value) { - try { - // Ensure this booking example is clearly named - $product->update(["name" => "Hotel Room"]); - - // Ensure booking has stock (required for bookings) - $product->update(['manage_stock' => true]); - $product->increaseStock(1); - - // Create a parking pool - $parkingPool = Product::create([ - 'slug' => 'example-parkings-pool-' . $this->faker->unique()->numberBetween(1000, 9999), - 'name' => 'Parkings', - 'sku' => 'EX-PARK-' . strtoupper($this->faker->bothify('??-###')), - 'type' => ProductType::POOL, - 'status' => ProductStatus::PUBLISHED, - 'is_visible' => true, - 'manage_stock' => true, - 'published_at' => now(), - 'meta' => ['example' => true], - ]); - - // Add a default pool price (optional) - $parkingPool->prices()->create([ - 'name' => 'Default', - 'type' => 'one_time', - 'currency' => 'EUR', - 'unit_amount' => 3000, // €30 per booking/day for example - 'is_default' => true, - 'active' => true, - 'billing_scheme' => 'per_unit', - 'meta' => ['example' => true], - ]); - - // Create 3 parking plots as booking single items and attach to pool - $parkingIds = []; - for ($p = 1; $p <= 3; $p++) { - $parking = Product::create([ - 'slug' => 'example-parking-plot-' . $p . '-' . $this->faker->unique()->numberBetween(1000, 9999), - 'name' => 'Parking Plot ' . $p, - 'sku' => 'EX-PP-' . strtoupper($this->faker->bothify('??-###')), - 'type' => ProductType::BOOKING, - 'status' => ProductStatus::PUBLISHED, - 'is_visible' => false, - 'manage_stock' => true, - 'published_at' => now(), - 'meta' => ['example' => true, 'parking_plot' => true], - ]); - - // Give each parking one unit of stock so it can be claimed - $parking->increaseStock(1); - - // Give each parking a price - $parking->prices()->create([ - 'name' => 'Default', - 'type' => 'one_time', - 'currency' => 'EUR', - 'unit_amount' => 1000 + ($p - 1) * 200, // €10, €12, €14 - 'is_default' => true, - 'active' => true, - 'billing_scheme' => 'per_unit', - 'meta' => ['example' => true], - ]); - - $parkingIds[] = $parking->id; - } - - // Attach parking plots as single items to the parking pool (also creates reverse POOL relation) - $parkingPool->attachSingleItems($parkingIds); - - // Link the parking pool to the booking as an add-on - $product->productRelations()->attach($parkingPool->id, ['type' => ProductRelationType::ADD_ON->value]); - } catch (\Throwable $e) { - $this->warn('Failed to create booking+parking example: ' . $e->getMessage()); + $this->addVariationsForHotel($product, $productData, $baseUnitAmount); + } elseif ($type === ProductType::GROUPED->value) { + $this->addGroupedProductsForHotel($product, $productData); + } elseif ($type === ProductType::POOL->value) { + $this->addPoolItemsForHotel($product, $productData); + } elseif ($type === ProductType::BOOKING->value) { + // Bookings need stock to be bookable + if ($product->stock_quantity === 0) { + $product->increaseStock($productData['stock'] ?? 10); } } return $product; } - protected function generateProductName(string $type): string + protected function getProductDataByType(string $type, int $index): array { - $names = [ - 'simple' => [ - 'Premium Wireless Headphones', - 'Organic Cotton T-Shirt', - 'Stainless Steel Water Bottle', - 'Leather Wallet', - 'Bamboo Cutting Board', - 'Yoga Mat Pro', - 'Coffee Mug Set', - 'Digital Course: Web Development', - ], - 'variable' => [ - 'Classic Running Shoes', - 'Designer Hoodie', - 'Smart Watch Ultra', - 'Backpack Collection', - 'Sunglasses Elite', - 'Fitness Tracker Band', - ], - 'grouped' => [ - 'Home Office Starter Kit', - 'Camping Essentials Bundle', - 'Kitchen Utensil Set', - 'Travel Accessories Pack', - 'Gaming Setup Bundle', - ], - 'external' => [ - 'External Brand Laptop', - 'Partner Store Gift Card', - 'Affiliate Product Link', - 'Third-Party Service', - ], - ]; - - return $this->faker->randomElement($names[$type] ?? $names['simple']); - } - - protected function addAttributes(Product $product, string $type): void - { - $attributes = []; + $data = []; switch ($type) { - case 'simple': - $attributes = [ - ['name' => 'Material', 'value' => $this->faker->randomElement(['Cotton', 'Polyester', 'Leather', 'Metal', 'Plastic', 'Wood'])], - ['name' => 'Brand', 'value' => $this->faker->company()], - ['name' => 'Country of Origin', 'value' => $this->faker->country()], + case ProductType::SIMPLE->value: + $simpleProducts = [ + [ + 'name' => 'Premium Wine Bottle - Château Margaux', + 'sku' => 'WINE-001', + 'price' => 15000, // €150 + 'description' => 'Exceptional French Bordeaux wine from the renowned Château Margaux estate. Perfect complement to your stay.', + 'short_description' => 'Premium French Bordeaux wine', + 'categories' => ['Food & Beverage'], + 'attributes' => [ + ['name' => 'Type', 'value' => 'Red Wine'], + ['name' => 'Region', 'value' => 'Bordeaux, France'], + ['name' => 'Vintage', 'value' => '2015'], + ['name' => 'Volume', 'value' => '750ml'], + ], + 'stock' => 12, + 'featured' => true, + ], + [ + 'name' => 'Single Malt Whiskey - Lagavulin 16', + 'sku' => 'WHISK-002', + 'price' => 12000, // €120 + 'description' => 'Award-winning Islay single malt whiskey with rich, peaty flavors. Delivered to your room with complimentary glassware.', + 'short_description' => 'Premium Islay single malt whiskey', + 'categories' => ['Food & Beverage'], + 'attributes' => [ + ['name' => 'Type', 'value' => 'Single Malt Whiskey'], + ['name' => 'Region', 'value' => 'Islay, Scotland'], + ['name' => 'Age', 'value' => '16 Years'], + ['name' => 'Volume', 'value' => '700ml'], + ], + 'stock' => 8, + ], + [ + 'name' => 'Champagne - Dom Pérignon', + 'sku' => 'CHAMP-003', + 'price' => 25000, // €250 + 'description' => 'Luxury champagne from the prestigious Dom Pérignon house. Celebrate your special occasion in style.', + 'short_description' => 'Luxury vintage champagne', + 'categories' => ['Food & Beverage'], + 'attributes' => [ + ['name' => 'Type', 'value' => 'Champagne'], + ['name' => 'Region', 'value' => 'Épernay, France'], + ['name' => 'Vintage', 'value' => '2012'], + ['name' => 'Volume', 'value' => '750ml'], + ], + 'stock' => 6, + 'featured' => true, + 'on_sale' => true, + ], ]; + $data = $simpleProducts[$index - 1] ?? $simpleProducts[0]; break; - case 'variable': - $attributes = [ - ['name' => 'Size', 'value' => $this->faker->randomElement(['S, M, L, XL', 'One Size', '6-12'])], - ['name' => 'Color', 'value' => $this->faker->randomElement(['Red, Blue, Green', 'Black, White', 'Multi-Color'])], - ['name' => 'Material', 'value' => $this->faker->randomElement(['Cotton', 'Polyester', 'Blend'])], + case ProductType::VARIABLE->value: + $variableProducts = [ + [ + 'name' => 'In-Room Breakfast Service', + 'sku' => 'BREAK-001', + 'price' => 2500, // €25 base + 'description' => 'Start your day with a delicious breakfast delivered to your room. Choose from Continental, American, or Full English breakfast options.', + 'short_description' => 'Breakfast delivered to your room', + 'categories' => ['Food & Beverage'], + 'variations' => ['Continental', 'American', 'Full English'], + 'variation_prices' => [2500, 3200, 3800], + 'attributes' => [ + ['name' => 'Service Time', 'value' => '7:00 AM - 11:00 AM'], + ['name' => 'Delivery', 'value' => 'Room Service'], + ], + ], + [ + 'name' => 'Spa Treatment Package', + 'sku' => 'SPA-001', + 'price' => 8000, // €80 base + 'description' => 'Relax and rejuvenate with our professional spa treatments. Available in 60, 90, or 120-minute sessions.', + 'short_description' => 'Professional spa and massage treatments', + 'categories' => ['Spa & Wellness'], + 'variations' => ['60 Minutes', '90 Minutes', '120 Minutes'], + 'variation_prices' => [8000, 11000, 14000], + 'attributes' => [ + ['name' => 'Location', 'value' => 'Hotel Spa - 2nd Floor'], + ['name' => 'Booking Required', 'value' => 'Yes'], + ], + ], + [ + 'name' => 'Airport Transfer Service', + 'sku' => 'TRANS-001', + 'price' => 4500, // €45 base + 'description' => 'Convenient airport transfer service with professional drivers. Choose your vehicle type for comfort.', + 'short_description' => 'Professional airport transfer', + 'categories' => ['Parking & Transport'], + 'variations' => ['Standard Sedan', 'Luxury Sedan', 'SUV'], + 'variation_prices' => [4500, 7500, 9500], + 'attributes' => [ + ['name' => 'Notice Required', 'value' => '24 hours'], + ['name' => 'Distance', 'value' => 'Up to 50km'], + ], + ], ]; + $data = $variableProducts[$index - 1] ?? $variableProducts[0]; break; - case 'grouped': - $attributes = [ - ['name' => 'Items Included', 'value' => $this->faker->numberBetween(3, 10) . ' pieces'], - ['name' => 'Bundle Type', 'value' => 'Curated Collection'], + case ProductType::GROUPED->value: + $groupedProducts = [ + [ + 'name' => 'Romantic Package', + 'sku' => 'PKG-ROM-001', + 'price' => 0, // Calculated from children + 'description' => 'Complete romantic experience including champagne, chocolate-covered strawberries, rose petals, and spa voucher.', + 'short_description' => 'Complete romantic experience package', + 'categories' => ['Room Upgrades'], + 'manage_stock' => false, + 'grouped_items' => [ + ['name' => 'Champagne Bottle', 'price' => 8000, 'sku' => 'ROM-CHAMP'], + ['name' => 'Chocolate Strawberries', 'price' => 3500, 'sku' => 'ROM-STRAW'], + ['name' => 'Rose Petal Decoration', 'price' => 4000, 'sku' => 'ROM-ROSE'], + ['name' => 'Spa Voucher (€50)', 'price' => 5000, 'sku' => 'ROM-SPA'], + ], + ], + [ + 'name' => 'Business Traveler Package', + 'sku' => 'PKG-BIZ-001', + 'price' => 0, + 'description' => 'Everything a business traveler needs: high-speed WiFi upgrade, printing credits, meeting room hour, and premium coffee.', + 'short_description' => 'Essential business traveler amenities', + 'categories' => ['Room Upgrades'], + 'manage_stock' => false, + 'grouped_items' => [ + ['name' => 'Premium WiFi (100Mbps)', 'price' => 1500, 'sku' => 'BIZ-WIFI'], + ['name' => 'Printing Credits (50 pages)', 'price' => 1000, 'sku' => 'BIZ-PRINT'], + ['name' => 'Meeting Room (1 hour)', 'price' => 5000, 'sku' => 'BIZ-MEET'], + ['name' => 'Premium Coffee Service', 'price' => 2000, 'sku' => 'BIZ-COFFEE'], + ], + ], + [ + 'name' => 'Family Fun Package', + 'sku' => 'PKG-FAM-001', + 'price' => 0, + 'description' => 'Family-friendly package with kids activities, snacks, games, and access to family entertainment.', + 'short_description' => 'Complete family entertainment package', + 'categories' => ['Room Upgrades'], + 'manage_stock' => false, + 'grouped_items' => [ + ['name' => 'Kids Activity Book Set', 'price' => 1500, 'sku' => 'FAM-BOOK'], + ['name' => 'Snack Box', 'price' => 2500, 'sku' => 'FAM-SNACK'], + ['name' => 'Board Games Collection', 'price' => 3000, 'sku' => 'FAM-GAMES'], + ['name' => 'Pool & Playroom Access', 'price' => 4000, 'sku' => 'FAM-ACCESS'], + ], + ], ]; + $data = $groupedProducts[$index - 1] ?? $groupedProducts[0]; break; - case 'external': - $attributes = [ - ['name' => 'External URL', 'value' => 'https://example.com/product'], - ['name' => 'Affiliate Link', 'value' => 'Yes'], + case ProductType::EXTERNAL->value: + $externalProducts = [ + [ + 'name' => 'City Tour Bus Tickets', + 'sku' => 'EXT-TOUR-001', + 'price' => 3500, // €35 + 'description' => 'Hop-on hop-off city tour tickets. Book through our partner for the best rates. External booking required.', + 'short_description' => 'Hop-on hop-off city tour', + 'categories' => ['Activities & Tours'], + 'manage_stock' => false, + 'attributes' => [ + ['name' => 'External URL', 'value' => 'https://citytours.example.com'], + ['name' => 'Duration', 'value' => '24 hours unlimited'], + ['name' => 'Provider', 'value' => 'City Tours Inc.'], + ], + ], + [ + 'name' => 'Museum Pass (3-Day)', + 'sku' => 'EXT-MUS-001', + 'price' => 4500, // €45 + 'description' => 'Access to all major museums for 3 days. Purchase through official museum portal with our special hotel rate.', + 'short_description' => '3-day all-museum access pass', + 'categories' => ['Activities & Tours'], + 'manage_stock' => false, + 'attributes' => [ + ['name' => 'External URL', 'value' => 'https://museumpass.example.com'], + ['name' => 'Validity', 'value' => '3 consecutive days'], + ['name' => 'Museums Included', 'value' => '15+ venues'], + ], + ], + [ + 'name' => 'Theater Show Tickets', + 'sku' => 'EXT-SHOW-001', + 'price' => 8500, // €85 + 'description' => 'Premium theater show tickets for the best productions in town. Booked via our entertainment partner.', + 'short_description' => 'Premium theater tickets', + 'categories' => ['Activities & Tours'], + 'manage_stock' => false, + 'attributes' => [ + ['name' => 'External URL', 'value' => 'https://theater.example.com'], + ['name' => 'Seating', 'value' => 'Premium seats'], + ['name' => 'Provider', 'value' => 'City Theater Box Office'], + ], + ], ]; + $data = $externalProducts[$index - 1] ?? $externalProducts[0]; + break; + + case ProductType::BOOKING->value: + $bookingProducts = [ + [ + 'name' => 'Standard Double Room', + 'sku' => 'ROOM-STD-001', + 'price' => 12000, // €120/night + 'description' => 'Comfortable double room with modern amenities, city view, private bathroom, and complimentary WiFi. Perfect for couples or solo travelers.', + 'short_description' => 'Comfortable double room with city view', + 'categories' => ['Hotel Rooms'], + 'stock' => 15, + 'manage_stock' => true, + 'featured' => true, + 'attributes' => [ + ['name' => 'Bed Type', 'value' => 'Queen Bed'], + ['name' => 'Room Size', 'value' => '25 m²'], + ['name' => 'View', 'value' => 'City View'], + ['name' => 'Max Guests', 'value' => '2'], + ], + ], + [ + 'name' => 'Deluxe Suite', + 'sku' => 'ROOM-DLX-001', + 'price' => 22000, // €220/night + 'description' => 'Spacious suite with separate living area, king bed, luxurious bathroom with jacuzzi, and panoramic city views. Includes access to executive lounge.', + 'short_description' => 'Luxury suite with panoramic views', + 'categories' => ['Hotel Rooms'], + 'stock' => 8, + 'manage_stock' => true, + 'featured' => true, + 'attributes' => [ + ['name' => 'Bed Type', 'value' => 'King Bed'], + ['name' => 'Room Size', 'value' => '45 m²'], + ['name' => 'View', 'value' => 'Panoramic City View'], + ['name' => 'Max Guests', 'value' => '2-3'], + ['name' => 'Special Features', 'value' => 'Jacuzzi, Executive Lounge Access'], + ], + ], + [ + 'name' => 'Presidential Suite', + 'sku' => 'ROOM-PRES-001', + 'price' => 45000, // €450/night + 'description' => 'Ultimate luxury accommodation with 2 bedrooms, private terrace, premium butler service, and exclusive amenities. The pinnacle of comfort and elegance.', + 'short_description' => 'Ultimate luxury presidential suite', + 'categories' => ['Hotel Rooms'], + 'stock' => 2, + 'manage_stock' => true, + 'featured' => true, + 'attributes' => [ + ['name' => 'Bedrooms', 'value' => '2 (King + Queen)'], + ['name' => 'Room Size', 'value' => '120 m²'], + ['name' => 'View', 'value' => '360° City View + Terrace'], + ['name' => 'Max Guests', 'value' => '4-6'], + ['name' => 'Special Features', 'value' => 'Butler Service, Private Terrace, Premium Bar'], + ], + ], + ]; + $data = $bookingProducts[$index - 1] ?? $bookingProducts[0]; + break; + + case ProductType::POOL->value: + $poolProducts = [ + [ + 'name' => 'Parking Spaces - North Garage', + 'sku' => 'PARK-NORTH-POOL', + 'price' => 2500, // €25/day base + 'description' => 'Secure covered parking in our North Garage. Multiple spots available with 24/7 surveillance and easy hotel access.', + 'short_description' => 'Secure North Garage parking', + 'categories' => ['Parking & Transport'], + 'visible' => true, + 'manage_stock' => true, + 'pool_items' => [ + ['name' => 'Spot A3', 'stock' => 1, 'price' => 2500], + ['name' => 'Spot A7', 'stock' => 1, 'price' => 2500], + ['name' => 'Spot B12', 'stock' => 1, 'price' => 2800], + ['name' => 'Spot C5', 'stock' => 1, 'price' => 2800], + ['name' => 'Spot D9', 'stock' => 1, 'price' => 3000], + ], + ], + [ + 'name' => 'Parking Spaces - South Area', + 'sku' => 'PARK-SOUTH-POOL', + 'price' => 2000, // €20/day base + 'description' => 'Open-air parking in our South Area. Well-lit and monitored spaces near the main entrance.', + 'short_description' => 'Outdoor South Area parking', + 'categories' => ['Parking & Transport'], + 'visible' => true, + 'manage_stock' => true, + 'pool_items' => [ + ['name' => 'South Area Zone 1', 'stock' => 5, 'price' => 2000], + ['name' => 'South Area Zone 2', 'stock' => 5, 'price' => 2000], + ['name' => 'South Area Zone 3', 'stock' => 5, 'price' => 1800], + ], + ], + [ + 'name' => 'VIP Parking Spaces - Underground', + 'sku' => 'PARK-VIP-POOL', + 'price' => 4000, // €40/day base + 'description' => 'Premium underground parking with direct elevator access to hotel lobby. Reserved for suite guests and VIP members.', + 'short_description' => 'Premium underground VIP parking', + 'categories' => ['Parking & Transport'], + 'visible' => true, + 'manage_stock' => true, + 'featured' => true, + 'pool_items' => [ + ['name' => 'VIP-1', 'stock' => 1, 'price' => 5000], + ['name' => 'VIP-2', 'stock' => 1, 'price' => 5000], + ['name' => 'VIP-3', 'stock' => 1, 'price' => 4500], + ['name' => 'VIP-4', 'stock' => 1, 'price' => 4500], + ['name' => 'Executive E1', 'stock' => 1, 'price' => 4000], + ['name' => 'Executive E2', 'stock' => 1, 'price' => 4000], + ], + ], + ]; + $data = $poolProducts[$index - 1] ?? $poolProducts[0]; break; } + return $data; + } + + protected function addAttributesToProduct(Product $product, array $attributes): void + { foreach ($attributes as $index => $attr) { ProductAttribute::create([ 'product_id' => $product->id, @@ -380,88 +574,19 @@ class ShopAddExampleProducts extends Command } } - protected function addAdditionalPrices(Product $product, int $baseUnitAmount): void + protected function addVariationsForHotel(Product $product, array $productData, int $basePrice): void { - // Add a subscription price option - if ($this->faker->boolean(30)) { - $product->prices()->create([ - 'active' => true, - 'name' => 'Monthly Subscription', - 'type' => 'recurring', - 'unit_amount' => (int) round($baseUnitAmount * 0.3), // ~30% monthly - 'billing_scheme' => 'per_unit', - 'interval' => 'month', - 'interval_count' => 1, - 'trial_period_days' => $this->faker->randomElement([0, 7, 14, 30]), - 'currency' => 'EUR', - 'is_default' => false, - 'meta' => ['example' => true], - ]); + if (!isset($productData['variations'])) { + return; } - // Add USD price variant - if ($this->faker->boolean(40)) { - $product->prices()->create([ - 'active' => true, - 'name' => 'USD Price', - 'type' => 'one_time', - 'unit_amount' => (int) round($baseUnitAmount * 1.08), // approx conversion - 'billing_scheme' => 'per_unit', - 'currency' => 'USD', - 'is_default' => false, - 'meta' => ['example' => true], - ]); - } - } - - protected function addExampleActions(Product $product): void - { - $namespace = config('shop.actions.namespace', 'App\\Jobs\\ProductAction'); - $actions = [ - [ - 'events' => ['purchased'], - 'class' => $namespace . '\\SendThankYouEmail', - 'method' => null, - 'parameters' => ['template' => 'thank-you', 'delay' => 0], - 'defer' => true, - ], - [ - 'events' => ['purchased'], - 'class' => $namespace . '\\UpdateCustomerStats', - 'method' => null, - 'parameters' => ['increment' => 'total_purchases'], - 'defer' => true, - ], - [ - 'events' => ['low_stock'], - 'class' => $namespace . '\\NotifyAdmin', - 'method' => null, - 'parameters' => ['threshold' => 5], - 'defer' => true, - ], - ]; - - foreach ($actions as $index => $actionData) { - $product->actions()->create([ - 'events' => $actionData['events'], - 'class' => $actionData['class'], - 'method' => $actionData['method'], - 'parameters' => $actionData['parameters'], - 'defer' => $actionData['defer'], - 'active' => $this->faker->boolean(70), - 'sort_order' => $index, - ]); - } - } - - protected function addVariations(Product $product, int $baseUnitAmount): void - { - $variations = ['Small', 'Medium', 'Large']; + $variations = $productData['variations']; + $prices = $productData['variation_prices'] ?? []; foreach ($variations as $index => $variation) { $variationProduct = Product::create([ 'slug' => $product->slug . '-' . \Illuminate\Support\Str::slug($variation), - 'sku' => $product->sku . '-' . strtoupper(substr($variation, 0, 1)), + 'sku' => $product->sku . '-' . strtoupper(substr($variation, 0, 3)), 'type' => 'simple', 'parent_id' => $product->id, 'status' => 'published', @@ -473,8 +598,7 @@ class ShopAddExampleProducts extends Command $variationProduct->setLocalized('name', ($product->getLocalized('name') ?: 'Product') . ' - ' . $variation, null, true); - // Create a slightly adjusted default price for the variation - $variationAmount = $baseUnitAmount + ($index * 500); // +5.00 per size + $variationAmount = $prices[$index] ?? ($basePrice + ($index * 500)); $variationProduct->prices()->create([ 'name' => 'Default', 'type' => 'one_time', @@ -488,7 +612,7 @@ class ShopAddExampleProducts extends Command ProductAttribute::create([ 'product_id' => $variationProduct->id, - 'key' => 'Size', + 'key' => 'Option', 'value' => $variation, 'sort_order' => 0, 'meta' => null, @@ -496,14 +620,16 @@ class ShopAddExampleProducts extends Command } } - protected function addGroupedProducts(Product $product): void + protected function addGroupedProductsForHotel(Product $product, array $productData): void { - $groupSize = $this->faker->numberBetween(2, 4); + if (!isset($productData['grouped_items'])) { + return; + } - for ($i = 0; $i < $groupSize; $i++) { + foreach ($productData['grouped_items'] as $i => $item) { $childProduct = Product::create([ 'slug' => $product->slug . '-item-' . ($i + 1), - 'sku' => $product->sku . '-' . ($i + 1), + 'sku' => $item['sku'], 'type' => 'simple', 'parent_id' => $product->id, 'status' => 'published', @@ -513,15 +639,13 @@ class ShopAddExampleProducts extends Command 'meta' => ['grouped_item' => true, 'example' => true], ]); - $childProduct->setLocalized('name', $this->faker->words(3, true), null, true); + $childProduct->setLocalized('name', $item['name'], null, true); - // Create a standalone default price for the child item - $childAmount = $this->faker->numberBetween(1000, 10000); $childProduct->prices()->create([ 'name' => 'Default', 'type' => 'one_time', 'currency' => 'EUR', - 'unit_amount' => $childAmount, + 'unit_amount' => $item['price'], 'is_default' => true, 'active' => true, 'billing_scheme' => 'per_unit', @@ -529,4 +653,107 @@ class ShopAddExampleProducts extends Command ]); } } + + protected function addPoolItemsForHotel(Product $pool, array $productData): void + { + if (!isset($productData['pool_items'])) { + return; + } + + $parkingIds = []; + foreach ($productData['pool_items'] as $i => $item) { + $parking = Product::create([ + 'slug' => $pool->slug . '-' . \Illuminate\Support\Str::slug($item['name']), + 'name' => $item['name'], + 'sku' => $pool->sku . '-' . str_pad($i + 1, 2, '0', STR_PAD_LEFT), + 'type' => ProductType::BOOKING, + 'status' => ProductStatus::PUBLISHED, + 'is_visible' => false, + 'manage_stock' => true, + 'parent_id' => $pool->id, // Set pool as parent so it's not counted as a parent product + 'published_at' => now(), + 'meta' => ['example' => true, 'pool_item' => true, 'parent_pool' => $pool->name], + ]); + + // Set stock for the parking spot + $parking->increaseStock($item['stock']); + + // Create price for individual parking spot + $parking->prices()->create([ + 'name' => 'Default', + 'type' => 'one_time', + 'currency' => 'EUR', + 'unit_amount' => $item['price'], + 'is_default' => true, + 'active' => true, + 'billing_scheme' => 'per_unit', + 'meta' => ['example' => true], + ]); + + $parkingIds[] = $parking->id; + } + + // Attach all parking spots to the pool + $pool->attachSingleItems($parkingIds); + } + + protected function addProductRelationships(): void + { + $this->info('Adding product relationships (cross-sells, upsells)...'); + + // Get rooms + $rooms = $this->createdProducts[ProductType::BOOKING->value] ?? []; + + // Get simple products (beverages) + $beverages = $this->createdProducts[ProductType::SIMPLE->value] ?? []; + + // Get parking pools + $parkingPools = $this->createdProducts[ProductType::POOL->value] ?? []; + + // Add cross-sells to each room (beverages and parking) + foreach ($rooms as $room) { + // Add all beverages as cross-sell + foreach ($beverages as $beverage) { + // Use syncWithoutDetaching to avoid duplicate constraint violations + $room->productRelations()->syncWithoutDetaching([ + $beverage->id => ['type' => ProductRelationType::CROSS_SELL->value] + ]); + } + + // Add all parking pools as cross-sell + foreach ($parkingPools as $parking) { + $room->productRelations()->syncWithoutDetaching([ + $parking->id => ['type' => ProductRelationType::CROSS_SELL->value] + ]); + } + } + + // Add upsells: Standard -> Deluxe -> Presidential + if (count($rooms) >= 2) { + // Standard room can upsell to Deluxe + $rooms[0]->productRelations()->syncWithoutDetaching([ + $rooms[1]->id => ['type' => ProductRelationType::UPSELL->value] + ]); + + if (count($rooms) >= 3) { + // Standard can also upsell to Presidential + $rooms[0]->productRelations()->syncWithoutDetaching([ + $rooms[2]->id => ['type' => ProductRelationType::UPSELL->value] + ]); + + // Deluxe can upsell to Presidential + $rooms[1]->productRelations()->syncWithoutDetaching([ + $rooms[2]->id => ['type' => ProductRelationType::UPSELL->value] + ]); + } + } + + $this->line(' ✓ Cross-sells and upsells added'); + } + + protected function generateProductName(string $type): string + { + // This method is deprecated - kept for compatibility + return 'Example Product'; + } } diff --git a/src/ShopServiceProvider.php b/src/ShopServiceProvider.php index f40edf8..01bc672 100644 --- a/src/ShopServiceProvider.php +++ b/src/ShopServiceProvider.php @@ -29,13 +29,20 @@ class ShopServiceProvider extends ServiceProvider // Publish config $this->publishes([ __DIR__ . '/../config/shop.php' => config_path('shop.php'), - ], 'shop-config'); + ], ['shop-config', 'config']); // Publish migrations $this->publishes([ __DIR__ . '/../database/migrations/create_blax_shop_tables.php.stub' => $this->getMigrationFileName('create_blax_shop_tables.php'), __DIR__ . '/../database/migrations/add_stripe_to_users_table.php.stub' => $this->getMigrationFileName('add_stripe_to_users_table.php'), - ], 'shop-migrations'); + ], ['shop-migrations', 'migrations']); + + // Publish all shop assets + $this->publishes([ + __DIR__ . '/../config/shop.php' => config_path('shop.php'), + __DIR__ . '/../database/migrations/create_blax_shop_tables.php.stub' => $this->getMigrationFileName('create_blax_shop_tables.php'), + __DIR__ . '/../database/migrations/add_stripe_to_users_table.php.stub' => $this->getMigrationFileName('add_stripe_to_users_table.php'), + ], 'shop'); // Load routes if enabled (API only) if (config('shop.routes.enabled', true)) { diff --git a/tests/Feature/CommandProductExamplesTest.php b/tests/Feature/CommandProductExamplesTest.php index e973b33..91265e2 100644 --- a/tests/Feature/CommandProductExamplesTest.php +++ b/tests/Feature/CommandProductExamplesTest.php @@ -19,15 +19,15 @@ class CommandProductExamplesTest extends TestCase $this->artisan(ShopAddExampleProducts::class, ['--clean' => true, '--count' => 2]) ->assertExitCode(0); - // Parent products (no parent_id) should be 4 types * 2 count = 8 + // Parent products (no parent_id) should be 6 types * 2 count = 12 $parents = Product::whereNull('parent_id')->get(); - $this->assertCount(20, $parents, 'Expected 8 parent example products'); + $this->assertCount(12, $parents, 'Expected 12 parent example products (6 types * 2 each)'); - // Total products should include variations (3 per variable) and grouped children (>=2 each) - $this->assertGreaterThanOrEqual(18, Product::count(), 'Expected at least 18 total products including children'); + // Total products should include variations, grouped children, and pool items + $this->assertGreaterThanOrEqual(30, Product::count(), 'Expected at least 30 total products including children'); - // Categories are created (5 predefined) and attached to parents (1-3 each) - $this->assertGreaterThanOrEqual(5, \Blax\Shop\Models\ProductCategory::count(), 'Expected at least 5 example categories'); + // Categories are created (6 hotel categories) and attached to parents + $this->assertGreaterThanOrEqual(6, \Blax\Shop\Models\ProductCategory::count(), 'Expected at least 6 example categories'); // Each product (including variants/children) must have a default price /** @var Product $p */ @@ -37,7 +37,6 @@ class CommandProductExamplesTest extends TestCase $variation = Product::whereNotNull('parent_id')->first(); $this->assertNotNull($variation, 'There should be at least one variation'); - $this->assertTrue($variation->attributes()->where('key', 'Size')->exists()); // Localization for name is populated $this->assertNotEmpty(Product::first()->getLocalized('name')); @@ -53,9 +52,9 @@ class CommandProductExamplesTest extends TestCase // Clean again (count=0 will create categories but no products) $this->artisan(ShopAddExampleProducts::class, ['--clean' => true, '--count' => 0])->assertExitCode(0); - // All example products removed, categories recreated (5 default) + // All example products removed, categories recreated (6 hotel categories) $this->assertEquals(0, Product::where('slug', 'like', 'example-%')->count()); - $this->assertEquals(5, \Blax\Shop\Models\ProductCategory::where('slug', 'like', 'example-%')->count()); + $this->assertEquals(6, \Blax\Shop\Models\ProductCategory::where('slug', 'like', 'example-%')->count()); } /** @test */ @@ -64,15 +63,17 @@ class CommandProductExamplesTest extends TestCase $this->artisan(ShopAddExampleProducts::class, ['--clean' => true, '--count' => 3]) ->assertExitCode(0); - // For each of the 4 types, expect 3 parent products + // For each of the 6 types, expect 3 parent products $parents = Product::whereNull('parent_id')->get(); - $this->assertCount(30, $parents); + $this->assertCount(18, $parents); $byType = $parents->groupBy('type'); $this->assertEquals(3, $byType['simple']->count()); $this->assertEquals(3, $byType['variable']->count()); $this->assertEquals(3, $byType['grouped']->count()); $this->assertEquals(3, $byType['external']->count()); + $this->assertEquals(3, $byType['booking']->count()); + $this->assertEquals(3, $byType['pool']->count()); // Sanity: external products do not manage stock $this->assertTrue($byType['external']->every(fn($p) => $p->manage_stock === false));