## Major Features Added - Sale pricing support throughout checkout flow - Stock notification system for out-of-stock products - Backorder request system with job queue processing - Inventory management dashboard and tracking - Product observer for automatic inventory alerts ## Sale Pricing Fixes - Fixed checkout to respect sale_price when available - Updated cart calculations to use sale pricing - Added sale badges and strikethrough pricing to: - Product preview pages - Cart display - Checkout summary - Order details (buyer/seller) - Invoice displays ## UI/UX Improvements - Moved backorder quantity controls below button (better vertical flow) - Added case quantity display with unit counts - Fixed invoice total calculation (was showing subtotal as total) - Added payment terms surcharge visibility in invoice breakdown ## Database Changes - Created stock_notifications table for back-in-stock alerts - Created backorders table for customer backorder requests - Enhanced inventory_items and inventory_movements tables - Added missing marketplace fields to products and brands ## Bug Fixes - Fixed unit_price calculation to respect sale_price - Fixed invoice JavaScript calculateTotal() to include surcharges - Fixed CartController subtotal to use sale pricing - Removed inline styles, migrated to Tailwind/DaisyUI classes ## Architecture Improvements - Added BackorderService and StockNotificationService - Created ProcessBackorderRequest job for async processing - Implemented ProductObserver for inventory management - Enhanced OrderModificationService 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
290 lines
11 KiB
PHP
290 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\Brand;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Intervention\Image\Drivers\Gd\Driver;
|
|
use Intervention\Image\ImageManager;
|
|
|
|
class ImportBrandFromMySQL extends Command
|
|
{
|
|
protected $signature = 'brand:import-from-mysql {remoteName? : Remote brand name} {localName? : Local brand name (if different)}';
|
|
|
|
protected $description = 'Import brand data and images from remote MySQL database';
|
|
|
|
public function handle()
|
|
{
|
|
$remoteBrandName = $this->argument('remoteName') ?? 'Canna';
|
|
$localBrandName = $this->argument('localName') ?? $remoteBrandName;
|
|
|
|
$this->info('Connecting to remote MySQL database...');
|
|
|
|
try {
|
|
// Connect to remote MySQL with latin1 charset (Windows-1252)
|
|
$pdo = new \PDO(
|
|
'mysql:host=sql1.creationshop.net;dbname=hub_cannabrands;charset=latin1',
|
|
'claude',
|
|
'claude'
|
|
);
|
|
$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
|
|
|
|
$this->info('Connected successfully!');
|
|
|
|
// Fetch brand data from MySQL
|
|
$stmt = $pdo->prepare('
|
|
SELECT brand_id, name, tagline, short_desc, `desc`, url,
|
|
image, banner, address, unit_number, city, state, zip, phone,
|
|
public, fb, insta, twitter, youtube
|
|
FROM brands
|
|
WHERE name = :name
|
|
');
|
|
$stmt->execute(['name' => $remoteBrandName]);
|
|
$remoteBrand = $stmt->fetch(\PDO::FETCH_ASSOC);
|
|
|
|
if (! $remoteBrand) {
|
|
$this->error("Brand '{$remoteBrandName}' not found in remote database");
|
|
|
|
return 1;
|
|
}
|
|
|
|
$this->info("Found remote brand: {$remoteBrand['name']}");
|
|
|
|
// Find local brand by name
|
|
$localBrand = Brand::where('name', $localBrandName)->first();
|
|
|
|
if (! $localBrand) {
|
|
$this->error("Brand '{$localBrandName}' not found in local database");
|
|
$this->info('Available brands: '.Brand::pluck('name')->implode(', '));
|
|
|
|
return 1;
|
|
}
|
|
|
|
$this->info("Found local brand: {$localBrand->name} (ID: {$localBrand->id})");
|
|
|
|
// Create brands directory if it doesn't exist
|
|
if (! Storage::disk('public')->exists('brands')) {
|
|
Storage::disk('public')->makeDirectory('brands');
|
|
$this->info('Created brands directory');
|
|
}
|
|
|
|
// Initialize Intervention Image
|
|
$manager = new ImageManager(new Driver);
|
|
|
|
// Process logo image with thumbnails (save as PNG for transparency support)
|
|
if ($remoteBrand['image']) {
|
|
$logoPath = "brands/{$localBrand->slug}-logo.png";
|
|
|
|
// Read and process the original image
|
|
$originalImage = $manager->read($remoteBrand['image']);
|
|
|
|
// Try to remove white background by making white pixels transparent
|
|
// Sample corners to detect if background is white
|
|
$width = $originalImage->width();
|
|
$height = $originalImage->height();
|
|
|
|
// Use GD to manipulate pixels
|
|
$gdImage = imagecreatefromstring($remoteBrand['image']);
|
|
if ($gdImage !== false) {
|
|
// Enable alpha blending
|
|
imagealphablending($gdImage, false);
|
|
imagesavealpha($gdImage, true);
|
|
|
|
// Make white and near-white pixels transparent
|
|
for ($x = 0; $x < imagesx($gdImage); $x++) {
|
|
for ($y = 0; $y < imagesy($gdImage); $y++) {
|
|
$rgb = imagecolorat($gdImage, $x, $y);
|
|
$colors = imagecolorsforindex($gdImage, $rgb);
|
|
|
|
// If pixel is white or very close to white (RGB > 245)
|
|
if ($colors['red'] > 245 && $colors['green'] > 245 && $colors['blue'] > 245) {
|
|
$transparent = imagecolorallocatealpha($gdImage, 255, 255, 255, 127);
|
|
imagesetpixel($gdImage, $x, $y, $transparent);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save as PNG
|
|
ob_start();
|
|
imagepng($gdImage);
|
|
$processedData = ob_get_clean();
|
|
imagedestroy($gdImage);
|
|
|
|
Storage::disk('public')->put($logoPath, $processedData);
|
|
$originalImage = $manager->read($processedData);
|
|
} else {
|
|
// Fallback: save original as PNG
|
|
Storage::disk('public')->put($logoPath, $originalImage->toPng());
|
|
}
|
|
|
|
// Generate thumbnails optimized for retina displays (PNG for transparency)
|
|
// Thumbnail (160x160) for list views (2x retina at 80px)
|
|
$thumbRetina = clone $originalImage;
|
|
$thumbRetina->scale(width: 160);
|
|
Storage::disk('public')->put("brands/{$localBrand->slug}-logo-thumb.png", $thumbRetina->toPng());
|
|
|
|
// Medium (600x600) for product cards (2x retina at 300px)
|
|
$mediumRetina = clone $originalImage;
|
|
$mediumRetina->scale(width: 600);
|
|
Storage::disk('public')->put("brands/{$localBrand->slug}-logo-medium.png", $mediumRetina->toPng());
|
|
|
|
// Large (1600x1600) for detail views
|
|
$largeRetina = clone $originalImage;
|
|
$largeRetina->scale(width: 1600);
|
|
Storage::disk('public')->put("brands/{$localBrand->slug}-logo-large.png", $largeRetina->toPng());
|
|
|
|
$localBrand->logo_path = $logoPath;
|
|
$this->info("✓ Saved logo + thumbnails: {$logoPath} (".strlen($remoteBrand['image']).' bytes)');
|
|
}
|
|
|
|
// Process banner image with thumbnails
|
|
if ($remoteBrand['banner']) {
|
|
$bannerPath = "brands/{$localBrand->slug}-banner.jpg";
|
|
|
|
// Save original
|
|
Storage::disk('public')->put($bannerPath, $remoteBrand['banner']);
|
|
|
|
// Generate banner thumbnails if banner is large enough
|
|
if (strlen($remoteBrand['banner']) > 1000) {
|
|
$image = $manager->read($remoteBrand['banner']);
|
|
|
|
// Medium banner (1344px wide) for retina displays at 672px
|
|
$mediumBanner = clone $image;
|
|
$mediumBanner->scale(width: 1344);
|
|
Storage::disk('public')->put("brands/{$localBrand->slug}-banner-medium.jpg", $mediumBanner->toJpeg(quality: 92));
|
|
|
|
// Large banner (2560px wide) for full-width hero sections
|
|
$largeBanner = clone $image;
|
|
$largeBanner->scale(width: 2560);
|
|
Storage::disk('public')->put("brands/{$localBrand->slug}-banner-large.jpg", $largeBanner->toJpeg(quality: 92));
|
|
}
|
|
|
|
$localBrand->banner_path = $bannerPath;
|
|
$this->info("✓ Saved banner + thumbnails: {$bannerPath} (".strlen($remoteBrand['banner']).' bytes)');
|
|
}
|
|
|
|
// Helper function to sanitize text (convert Windows-1252 to UTF-8)
|
|
$sanitize = function ($text) {
|
|
if (! $text) {
|
|
return $text;
|
|
}
|
|
|
|
// First, convert from Windows-1252/ISO-8859-1 to UTF-8
|
|
$text = mb_convert_encoding($text, 'UTF-8', 'Windows-1252');
|
|
|
|
// Replace common Windows-1252 special characters with standard equivalents
|
|
$replacements = [
|
|
"\xE2\x80\x98" => "'", // Left single quote
|
|
"\xE2\x80\x99" => "'", // Right single quote (apostrophe)
|
|
"\xE2\x80\x9C" => '"', // Left double quote
|
|
"\xE2\x80\x9D" => '"', // Right double quote
|
|
"\xE2\x80\x93" => '-', // En dash
|
|
"\xE2\x80\x94" => '-', // Em dash
|
|
"\xE2\x80\x26" => '...', // Ellipsis
|
|
];
|
|
|
|
$text = str_replace(array_keys($replacements), array_values($replacements), $text);
|
|
|
|
return trim($text);
|
|
};
|
|
|
|
// Update other brand fields
|
|
$updates = [];
|
|
|
|
if ($remoteBrand['tagline']) {
|
|
$localBrand->tagline = $sanitize($remoteBrand['tagline']);
|
|
$updates[] = 'tagline';
|
|
}
|
|
|
|
if ($remoteBrand['short_desc']) {
|
|
$localBrand->description = $sanitize($remoteBrand['short_desc']);
|
|
$updates[] = 'description';
|
|
}
|
|
|
|
if ($remoteBrand['desc']) {
|
|
$localBrand->long_description = $sanitize($remoteBrand['desc']);
|
|
$updates[] = 'long_description';
|
|
}
|
|
|
|
if ($remoteBrand['url']) {
|
|
$localBrand->website_url = $remoteBrand['url'];
|
|
$updates[] = 'website_url';
|
|
}
|
|
|
|
// Address fields
|
|
if ($remoteBrand['address']) {
|
|
$localBrand->address = $remoteBrand['address'];
|
|
$updates[] = 'address';
|
|
}
|
|
|
|
if ($remoteBrand['unit_number']) {
|
|
$localBrand->unit_number = $remoteBrand['unit_number'];
|
|
$updates[] = 'unit_number';
|
|
}
|
|
|
|
if ($remoteBrand['city']) {
|
|
$localBrand->city = $remoteBrand['city'];
|
|
$updates[] = 'city';
|
|
}
|
|
|
|
if ($remoteBrand['state']) {
|
|
$localBrand->state = $remoteBrand['state'];
|
|
$updates[] = 'state';
|
|
}
|
|
|
|
if ($remoteBrand['zip']) {
|
|
$localBrand->zip_code = $remoteBrand['zip'];
|
|
$updates[] = 'zip_code';
|
|
}
|
|
|
|
if ($remoteBrand['phone']) {
|
|
$localBrand->phone = $remoteBrand['phone'];
|
|
$updates[] = 'phone';
|
|
}
|
|
|
|
// Social media
|
|
if ($remoteBrand['fb']) {
|
|
$localBrand->facebook_url = 'https://facebook.com/'.$remoteBrand['fb'];
|
|
$updates[] = 'facebook_url';
|
|
}
|
|
|
|
if ($remoteBrand['insta']) {
|
|
$localBrand->instagram_handle = $remoteBrand['insta'];
|
|
$updates[] = 'instagram_handle';
|
|
}
|
|
|
|
if ($remoteBrand['twitter']) {
|
|
$localBrand->twitter_handle = $remoteBrand['twitter'];
|
|
$updates[] = 'twitter_handle';
|
|
}
|
|
|
|
if ($remoteBrand['youtube']) {
|
|
$localBrand->youtube_url = $remoteBrand['youtube'];
|
|
$updates[] = 'youtube_url';
|
|
}
|
|
|
|
// Visibility
|
|
$localBrand->is_public = (bool) $remoteBrand['public'];
|
|
$updates[] = 'is_public';
|
|
|
|
// Save the brand
|
|
$localBrand->save();
|
|
|
|
$this->info("\n✓ Successfully imported brand data!");
|
|
$this->info('Updated fields: '.implode(', ', $updates));
|
|
|
|
$this->newLine();
|
|
$this->info('View the brand at:');
|
|
$this->line("http://localhost/s/cannabrands/brands/{$localBrand->hashid}/edit");
|
|
|
|
} catch (\Exception $e) {
|
|
$this->error('Error: '.$e->getMessage());
|
|
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
}
|