I vendor publish, example products

This commit is contained in:
Fabian @ Blax Software 2025-12-17 13:04:58 +01:00
parent 7360391581
commit 9d07523d78
3 changed files with 526 additions and 291 deletions

View File

@ -16,9 +16,9 @@ class ShopAddExampleProducts extends Command
{ {
protected $signature = 'shop:add-example-products protected $signature = 'shop:add-example-products
{--clean : Remove existing example products first} {--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 * Available product types in the shop system
@ -52,6 +52,8 @@ class ShopAddExampleProducts extends Command
protected $faker; protected $faker;
protected $categories = []; protected $categories = [];
protected $createdProducts = [];
protected $productTypeIndex = [];
public function handle() public function handle()
{ {
@ -61,7 +63,7 @@ class ShopAddExampleProducts extends Command
$this->cleanExampleProducts(); $this->cleanExampleProducts();
} }
$this->info('Creating example products for Laravel Shop Package...'); $this->info('Creating hotel-themed example products...');
$this->newLine(); $this->newLine();
// Create categories first // Create categories first
@ -70,6 +72,11 @@ class ShopAddExampleProducts extends Command
$count = (int) $this->option('count'); $count = (int) $this->option('count');
$totalCreated = 0; $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) { foreach (self::PRODUCT_TYPES as $type => $details) {
$this->line("<fg=cyan>Creating {$count} {$details['name']}(s)...</>"); $this->line("<fg=cyan>Creating {$count} {$details['name']}(s)...</>");
@ -77,13 +84,16 @@ class ShopAddExampleProducts extends Command
$product = $this->createProduct($type, $i); $product = $this->createProduct($type, $i);
$totalCreated++; $totalCreated++;
$this->line(" <fg=green>✓</> {$product->slug}"); $this->line(" <fg=green>✓</> {$product->name}");
} }
$this->newLine(); $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(" - Products: {$totalCreated}");
$this->line(" - Categories: " . count($this->categories)); $this->line(" - Categories: " . count($this->categories));
$this->newLine(); $this->newLine();
@ -104,11 +114,12 @@ class ShopAddExampleProducts extends Command
protected function createCategories(): void protected function createCategories(): void
{ {
$categoryNames = [ $categoryNames = [
'Electronics' => 'Electronic devices and gadgets', 'Hotel Rooms' => 'Available room types and accommodations',
'Clothing' => 'Apparel and fashion items', 'Room Upgrades' => 'Premium room enhancements and services',
'Books' => 'Books and publications', 'Food & Beverage' => 'In-room dining and bar selections',
'Home & Garden' => 'Home improvement and garden supplies', 'Spa & Wellness' => 'Spa treatments and wellness packages',
'Sports' => 'Sports equipment and accessories', 'Parking & Transport' => 'Parking spaces and transportation services',
'Activities & Tours' => 'Local tours and activities',
]; ];
foreach ($categoryNames as $name => $description) { foreach ($categoryNames as $name => $description) {
@ -129,246 +140,429 @@ class ShopAddExampleProducts extends Command
protected function createProduct(string $type, int $index): Product protected function createProduct(string $type, int $index): Product
{ {
$productName = $this->generateProductName($type); $productData = $this->getProductDataByType($type, $index);
$slug = 'example-' . \Illuminate\Support\Str::slug($productName) . '-' . $this->faker->unique()->numberBetween(1000, 9999); $productName = $productData['name'];
$slug = 'example-' . \Illuminate\Support\Str::slug($productName) . '-' . uniqid();
// Determine pricing and sale window for the product (prices managed via ProductPrice) // Determine pricing
$baseUnitAmount = $this->faker->numberBetween(1000, 50000); // cents $baseUnitAmount = $productData['price'];
$onSale = $this->faker->boolean(30); // 30% chance of being on sale $onSale = $productData['on_sale'] ?? false;
$saleStart = $onSale ? now()->subDays($this->faker->numberBetween(1, 30)) : null; $saleStart = $onSale ? now()->subDays($this->faker->numberBetween(1, 30)) : null;
$saleEnd = $onSale ? now()->addDays($this->faker->numberBetween(7, 60)) : null; $saleEnd = $onSale ? now()->addDays($this->faker->numberBetween(7, 60)) : null;
$product = Product::create([ $product = Product::create([
'slug' => $slug, 'slug' => $slug,
'name' => $productName, 'name' => $productName,
'sku' => 'EX-' . strtoupper($this->faker->bothify('??-####')), 'sku' => $productData['sku'],
'type' => ProductType::from($type), 'type' => ProductType::from($type),
'status' => $this->faker->randomElement([ProductStatus::PUBLISHED, ProductStatus::PUBLISHED, ProductStatus::PUBLISHED, ProductStatus::DRAFT]), 'status' => ProductStatus::PUBLISHED,
'is_visible' => true, 'is_visible' => $productData['visible'] ?? true,
'featured' => $this->faker->boolean(20), 'featured' => $productData['featured'] ?? false,
'sale_start' => $saleStart, 'sale_start' => $saleStart,
'sale_end' => $saleEnd, 'sale_end' => $saleEnd,
'manage_stock' => $type !== ProductType::EXTERNAL->value, 'manage_stock' => $productData['manage_stock'] ?? ($type !== ProductType::EXTERNAL->value),
'low_stock_threshold' => $type !== ProductType::EXTERNAL->value ? 5 : null, 'low_stock_threshold' => $productData['low_stock_threshold'] ?? 5,
'weight' => $type === 'virtual' ? null : $this->faker->randomFloat(2, 0.1, 50), 'weight' => $productData['weight'] ?? null,
'length' => $type === 'virtual' ? null : $this->faker->randomFloat(2, 5, 100), 'length' => $productData['length'] ?? null,
'width' => $type === 'virtual' ? null : $this->faker->randomFloat(2, 5, 100), 'width' => $productData['width'] ?? null,
'height' => $type === 'virtual' ? null : $this->faker->randomFloat(2, 5, 100), 'height' => $productData['height'] ?? null,
'virtual' => $type === ProductType::VARIABLE->value ? $this->faker->boolean(20) : false, 'virtual' => $productData['virtual'] ?? false,
'downloadable' => $type === ProductType::SIMPLE->value ? $this->faker->boolean(15) : false, 'downloadable' => $productData['downloadable'] ?? false,
'published_at' => now(), 'published_at' => now(),
'sort_order' => $this->faker->numberBetween(0, 100), 'sort_order' => $this->faker->numberBetween(0, 100),
'tax_class' => $this->faker->randomElement(['standard', 'reduced', 'zero']), 'tax_class' => 'standard',
'meta' => [ 'meta' => [
'description' => $this->faker->paragraph(3), 'description' => $productData['description'],
'short_description' => $this->faker->sentence(10), 'short_description' => $productData['short_description'],
'example' => true, 'example' => true,
], ],
]); ]);
// Set localized name // Set localized name and descriptions
$product->setLocalized('name', $productName, null, true); $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 // Add to appropriate categories
$randomCategories = $this->faker->randomElements($this->categories, $this->faker->numberBetween(1, 3)); $categoryNames = $productData['categories'] ?? [];
$product->categories()->attach(collect($randomCategories)->pluck('id')); $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 // Create default price
$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)
$product->prices()->create([ $product->prices()->create([
'name' => 'Default', 'name' => 'Default',
'type' => 'one_time', 'type' => 'one_time',
'currency' => 'EUR', 'currency' => 'EUR',
'unit_amount' => $baseUnitAmount, '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, 'is_default' => true,
'active' => true, 'active' => true,
'billing_scheme' => 'per_unit', 'billing_scheme' => 'per_unit',
'meta' => ['example' => true], 'meta' => ['example' => true],
]); ]);
// Add stock if needed
if ($productData['stock'] ?? 0 > 0) {
$product->increaseStock($productData['stock']);
}
// Add attributes // Add attributes
$this->addAttributes($product, $type); if (isset($productData['attributes'])) {
$this->addAttributesToProduct($product, $productData['attributes']);
// Add additional prices (multi-currency or subscription)
if ($type === ProductType::SIMPLE->value || $type === ProductType::VARIABLE->value) {
$this->addAdditionalPrices($product, $baseUnitAmount);
} }
// Add example actions // Store for later relationship building
$this->addExampleActions($product); $this->createdProducts[$type][] = $product;
// For variable products, add variations // Handle type-specific creation
if ($type === ProductType::VARIABLE->value) { if ($type === ProductType::VARIABLE->value) {
$this->addVariations($product, $baseUnitAmount); $this->addVariationsForHotel($product, $productData, $baseUnitAmount);
} } elseif ($type === ProductType::GROUPED->value) {
$this->addGroupedProductsForHotel($product, $productData);
// For grouped products, add child products } elseif ($type === ProductType::POOL->value) {
if ($type === ProductType::GROUPED->value) { $this->addPoolItemsForHotel($product, $productData);
$this->addGroupedProducts($product); } elseif ($type === ProductType::BOOKING->value) {
} // Bookings need stock to be bookable
if ($product->stock_quantity === 0) {
// Special example: Booking product that includes a parking pool $product->increaseStock($productData['stock'] ?? 10);
// 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());
} }
} }
return $product; return $product;
} }
protected function generateProductName(string $type): string protected function getProductDataByType(string $type, int $index): array
{ {
$names = [ $data = [];
'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 = [];
switch ($type) { switch ($type) {
case 'simple': case ProductType::SIMPLE->value:
$attributes = [ $simpleProducts = [
['name' => 'Material', 'value' => $this->faker->randomElement(['Cotton', 'Polyester', 'Leather', 'Metal', 'Plastic', 'Wood'])], [
['name' => 'Brand', 'value' => $this->faker->company()], 'name' => 'Premium Wine Bottle - Château Margaux',
['name' => 'Country of Origin', 'value' => $this->faker->country()], '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; break;
case 'variable': case ProductType::VARIABLE->value:
$attributes = [ $variableProducts = [
['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' => 'In-Room Breakfast Service',
['name' => 'Material', 'value' => $this->faker->randomElement(['Cotton', 'Polyester', 'Blend'])], '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; break;
case 'grouped': case ProductType::GROUPED->value:
$attributes = [ $groupedProducts = [
['name' => 'Items Included', 'value' => $this->faker->numberBetween(3, 10) . ' pieces'], [
['name' => 'Bundle Type', 'value' => 'Curated Collection'], '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; break;
case 'external': case ProductType::EXTERNAL->value:
$attributes = [ $externalProducts = [
['name' => 'External URL', 'value' => 'https://example.com/product'], [
['name' => 'Affiliate Link', 'value' => 'Yes'], '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; break;
} }
return $data;
}
protected function addAttributesToProduct(Product $product, array $attributes): void
{
foreach ($attributes as $index => $attr) { foreach ($attributes as $index => $attr) {
ProductAttribute::create([ ProductAttribute::create([
'product_id' => $product->id, '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 (!isset($productData['variations'])) {
if ($this->faker->boolean(30)) { return;
$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],
]);
} }
// Add USD price variant $variations = $productData['variations'];
if ($this->faker->boolean(40)) { $prices = $productData['variation_prices'] ?? [];
$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'];
foreach ($variations as $index => $variation) { foreach ($variations as $index => $variation) {
$variationProduct = Product::create([ $variationProduct = Product::create([
'slug' => $product->slug . '-' . \Illuminate\Support\Str::slug($variation), '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', 'type' => 'simple',
'parent_id' => $product->id, 'parent_id' => $product->id,
'status' => 'published', 'status' => 'published',
@ -473,8 +598,7 @@ class ShopAddExampleProducts extends Command
$variationProduct->setLocalized('name', ($product->getLocalized('name') ?: 'Product') . ' - ' . $variation, null, true); $variationProduct->setLocalized('name', ($product->getLocalized('name') ?: 'Product') . ' - ' . $variation, null, true);
// Create a slightly adjusted default price for the variation $variationAmount = $prices[$index] ?? ($basePrice + ($index * 500));
$variationAmount = $baseUnitAmount + ($index * 500); // +5.00 per size
$variationProduct->prices()->create([ $variationProduct->prices()->create([
'name' => 'Default', 'name' => 'Default',
'type' => 'one_time', 'type' => 'one_time',
@ -488,7 +612,7 @@ class ShopAddExampleProducts extends Command
ProductAttribute::create([ ProductAttribute::create([
'product_id' => $variationProduct->id, 'product_id' => $variationProduct->id,
'key' => 'Size', 'key' => 'Option',
'value' => $variation, 'value' => $variation,
'sort_order' => 0, 'sort_order' => 0,
'meta' => null, '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([ $childProduct = Product::create([
'slug' => $product->slug . '-item-' . ($i + 1), 'slug' => $product->slug . '-item-' . ($i + 1),
'sku' => $product->sku . '-' . ($i + 1), 'sku' => $item['sku'],
'type' => 'simple', 'type' => 'simple',
'parent_id' => $product->id, 'parent_id' => $product->id,
'status' => 'published', 'status' => 'published',
@ -513,15 +639,13 @@ class ShopAddExampleProducts extends Command
'meta' => ['grouped_item' => true, 'example' => true], '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([ $childProduct->prices()->create([
'name' => 'Default', 'name' => 'Default',
'type' => 'one_time', 'type' => 'one_time',
'currency' => 'EUR', 'currency' => 'EUR',
'unit_amount' => $childAmount, 'unit_amount' => $item['price'],
'is_default' => true, 'is_default' => true,
'active' => true, 'active' => true,
'billing_scheme' => 'per_unit', '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(' <fg=green>✓</> Cross-sells and upsells added');
}
protected function generateProductName(string $type): string
{
// This method is deprecated - kept for compatibility
return 'Example Product';
}
} }

View File

@ -29,13 +29,20 @@ class ShopServiceProvider extends ServiceProvider
// Publish config // Publish config
$this->publishes([ $this->publishes([
__DIR__ . '/../config/shop.php' => config_path('shop.php'), __DIR__ . '/../config/shop.php' => config_path('shop.php'),
], 'shop-config'); ], ['shop-config', 'config']);
// Publish migrations // Publish migrations
$this->publishes([ $this->publishes([
__DIR__ . '/../database/migrations/create_blax_shop_tables.php.stub' => $this->getMigrationFileName('create_blax_shop_tables.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'), __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) // Load routes if enabled (API only)
if (config('shop.routes.enabled', true)) { if (config('shop.routes.enabled', true)) {

View File

@ -19,15 +19,15 @@ class CommandProductExamplesTest extends TestCase
$this->artisan(ShopAddExampleProducts::class, ['--clean' => true, '--count' => 2]) $this->artisan(ShopAddExampleProducts::class, ['--clean' => true, '--count' => 2])
->assertExitCode(0); ->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(); $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) // Total products should include variations, grouped children, and pool items
$this->assertGreaterThanOrEqual(18, Product::count(), 'Expected at least 18 total products including children'); $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) // Categories are created (6 hotel categories) and attached to parents
$this->assertGreaterThanOrEqual(5, \Blax\Shop\Models\ProductCategory::count(), 'Expected at least 5 example categories'); $this->assertGreaterThanOrEqual(6, \Blax\Shop\Models\ProductCategory::count(), 'Expected at least 6 example categories');
// Each product (including variants/children) must have a default price // Each product (including variants/children) must have a default price
/** @var Product $p */ /** @var Product $p */
@ -37,7 +37,6 @@ class CommandProductExamplesTest extends TestCase
$variation = Product::whereNotNull('parent_id')->first(); $variation = Product::whereNotNull('parent_id')->first();
$this->assertNotNull($variation, 'There should be at least one variation'); $this->assertNotNull($variation, 'There should be at least one variation');
$this->assertTrue($variation->attributes()->where('key', 'Size')->exists());
// Localization for name is populated // Localization for name is populated
$this->assertNotEmpty(Product::first()->getLocalized('name')); $this->assertNotEmpty(Product::first()->getLocalized('name'));
@ -53,9 +52,9 @@ class CommandProductExamplesTest extends TestCase
// Clean again (count=0 will create categories but no products) // Clean again (count=0 will create categories but no products)
$this->artisan(ShopAddExampleProducts::class, ['--clean' => true, '--count' => 0])->assertExitCode(0); $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(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 */ /** @test */
@ -64,15 +63,17 @@ class CommandProductExamplesTest extends TestCase
$this->artisan(ShopAddExampleProducts::class, ['--clean' => true, '--count' => 3]) $this->artisan(ShopAddExampleProducts::class, ['--clean' => true, '--count' => 3])
->assertExitCode(0); ->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(); $parents = Product::whereNull('parent_id')->get();
$this->assertCount(30, $parents); $this->assertCount(18, $parents);
$byType = $parents->groupBy('type'); $byType = $parents->groupBy('type');
$this->assertEquals(3, $byType['simple']->count()); $this->assertEquals(3, $byType['simple']->count());
$this->assertEquals(3, $byType['variable']->count()); $this->assertEquals(3, $byType['variable']->count());
$this->assertEquals(3, $byType['grouped']->count()); $this->assertEquals(3, $byType['grouped']->count());
$this->assertEquals(3, $byType['external']->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 // Sanity: external products do not manage stock
$this->assertTrue($byType['external']->every(fn($p) => $p->manage_stock === false)); $this->assertTrue($byType['external']->every(fn($p) => $p->manage_stock === false));