@extends('layouts.app')
@section('title', ($import->isImproveExisting() ? 'Improve' : 'Import').' #'.$import->id.' – Product AI')
@section('content')
@php
$isImprove = $import->isImproveExisting();
$seoLocked = $import->isSeoLocked();
$improveMeta = $import->improveMeta();
$media = $import->media;
$canEditMedia = $import->canAddMoreMedia();
$form = is_array($import->form_data) ? $import->form_data : [];
$needs = is_array($form['needs_manual_confirmation'] ?? null)
? $form['needs_manual_confirmation']
: (is_array($import->ai_analysis['needs_manual_confirmation'] ?? null) ? $import->ai_analysis['needs_manual_confirmation'] : ['sku', 'price', 'inventory', 'weight']);
$fv = function (string $key, $fallback = '') use ($form, $import) {
if (in_array($key, ['title', 'description', 'sku', 'product_details'], true)) {
return old($key, $import->{$key} ?? $fallback);
}
$stored = $form[$key] ?? $fallback;
return old($key, $stored);
};
$lifestyleRefs = $import->lifestyleSourceImages();
$hasLifestyleSource = $lifestyleRefs->isNotEmpty();
$lifestylePromptValue = old('lifestyle_prompt', $lifestylePrompt ?? '');
$enhancePromptValue = old('enhance_prompt', $enhancePrompt ?? '');
$needClass = function (string $key) use ($needs, $fv) {
$empty = trim((string) $fv($key, '')) === '';
return ($empty && (in_array($key, $needs, true) || in_array($key, ['sku', 'price', 'inventory', 'weight', 'clarity', 'diamond_color', 'carat_weight'], true)))
? 'pai-field--needs'
: '';
};
$stepKeys = array_keys($wizardSteps);
$stepIndex = array_search($wizardStep, $stepKeys, true);
if ($stepIndex === false) {
$stepIndex = 0;
$wizardStep = 'media';
}
$prevStep = $stepIndex > 0 ? $stepKeys[$stepIndex - 1] : null;
$nextStep = $stepIndex < count($stepKeys) - 1 ? $stepKeys[$stepIndex + 1] : null;
$typeOptions = config('product_ai_import.product_type_options', []);
$categoryOptions = config('product_ai_import.product_category_options', []);
$typeToCategory = config('product_ai_import.product_type_to_category', []);
$themeTemplateOptions = config('product_ai_import.theme_template_options', []);
$typeToThemeTemplate = config('product_ai_import.product_type_to_theme_template', []);
$defaultVendor = config('product_ai_import.default_vendor', 'AVI THE JEWELER');
$shopifyDone = (bool) $import->shopify_product_gid;
$adminUrl = $form['shopify_admin_url'] ?? null;
$selectedType = old('product_type', $form['product_type'] ?? '');
$selectedCategoryId = old('category_id', $form['category_id'] ?? ($typeToCategory[$selectedType] ?? ''));
$selectedTemplateSuffix = old('template_suffix', $form['template_suffix'] ?? ($typeToThemeTemplate[$selectedType] ?? ''));
$selectedCollectionId = old('collection_id', $form['collection_id'] ?? '');
$selectedCollectionTitle = old('collection_title', $form['collection_title'] ?? '');
$selectedTags = old('tags', $form['tags'] ?? '');
$selectedCollectionIds = old('collection_ids', $form['collection_ids'] ?? []);
if (! is_array($selectedCollectionIds)) {
$selectedCollectionIds = [];
}
$guideProductTitle = $form['guide_product_title'] ?? null;
$guideProductId = $form['guide_product_id'] ?? null;
$guideMetafields = is_array($form['guide_metafields'] ?? null) ? $form['guide_metafields'] : [];
$guideColorValues = is_array($form['guide_color_values'] ?? null) ? $form['guide_color_values'] : [];
$guideColorOption = $form['guide_color_option'] ?? 'Color';
$guideSnapshot = is_array($form['guide_snapshot'] ?? null) ? $form['guide_snapshot'] : null;
$guideStructureMetafields = is_array($guideSnapshot['metafields'] ?? null)
? $guideSnapshot['metafields']
: (is_array($form['guide_metafields'] ?? null) ? $form['guide_metafields'] : []);
$metafieldPlan = app(\App\Services\ProductAiGuideMetafieldService::class)
->planForImport($import, $guideStructureMetafields);
$processedCount = $media->filter(fn ($m) => filled($m->processed_path))->count();
$stepUrl = fn (string $s) => route('product-ai-import.show', ['productAiImport' => $import, 'step' => $s]);
@endphp
← All imports
AVI THE JEWELER · Product AI{{ $isImprove ? ' · Improve' : '' }}
{{ $isImprove ? 'Improve' : 'Import' }} #{{ $import->id }}
@if(trim((string) $import->title) !== '')
{{ $import->title }}
@else
Guided listing studio
@endif
{{ $import->statusLabel() }}
@if($isImprove)
Media only
@endif
@if ($import->error_message && ! $import->is_auto_mode)
{{ $import->error_message }}
@endif
@include('product_ai_import.partials.auto_sync_gate')
@if ($errors->any())
@foreach ($errors->all() as $e)
{{ $e }}
@endforeach
@endif
{{-- Stays visible on every step while scrolling long content --}}
@include('product_ai_import.partials.bg_work_banner', ['import' => $import, 'wizardStep' => $wizardStep])
@include('product_ai_import.partials.pipeline_status', ['import' => $import, 'wizardStep' => $wizardStep])
@foreach($wizardSteps as $key => $meta)
@php
$i = $meta['n'];
$done = $i < ($wizardSteps[$wizardStep]['n'] ?? 1);
$active = $key === $wizardStep;
@endphp
{{ $done ? '✓' : $i }}
{{ $meta['label'] }}
{{ $meta['desc'] }}
@if(!$loop->last)
@endif
@endforeach
{{-- ═══════════════ STEP 1: MEDIA ═══════════════ --}}
@if($wizardStep === 'media')
@php
// Same source of truth as Prepare / Sync — never a stale $media filter that shows Originals.
$mediaStepItems = $import->shopifyMediaItems();
@endphp
@if($mediaStepItems->isEmpty())
No media yet. Add images or a video below.
@else
@php
$galleryItems = $mediaStepItems->values();
@endphp
Media gallery
As it will appear on Shopify. Drag any tile (or use ← →) — #1 is featured. Saves automatically.
{{-- CSRF helpers for tile action buttons that post outside the reorder field set --}}
@if($canEditMedia)
@foreach($galleryItems as $item)
@if($item->isImage() && ! $item->is_ai_generated && ! $item->is_primary)
@endif
@if($item->isImage() && ! $item->is_ai_generated && ($settings->lifestyle_enabled ?? true))
@endif
@endforeach
@endif
@endif
@if($canEditMedia)
@php
$uploadedImgCount = $import->uploadedProductImagesCount();
$uploadedVidCount = $import->uploadedVideosCount();
$remainImg = max(0, (int) $settings->max_images_per_import - $uploadedImgCount);
$remainVid = max(0, (int) $settings->max_videos_per_import - $uploadedVidCount);
$canUploadMore = $remainImg > 0 || $remainVid > 0;
@endphp
@endif
@endif
{{-- ═══════════════ STEP 2: DETAILS ═══════════════ --}}
@if($wizardStep === 'details')
@php
$copyAutoMessage = $copyAutoMessage ?? null;
$titleSuggestions = is_array($form['title_suggestions'] ?? null) ? $form['title_suggestions'] : [];
$titleSuggestions = array_values(array_filter($titleSuggestions, static function ($row) {
return is_array($row) && trim((string) ($row['title'] ?? '')) !== '';
}));
$pickedTitleId = (string) ($form['title_suggestion_id'] ?? '');
if ($pickedTitleId === '' && $titleSuggestions !== []) {
$last = $titleSuggestions[array_key_last($titleSuggestions)] ?? null;
$pickedTitleId = (string) ($last['id'] ?? '');
foreach ($titleSuggestions as $opt) {
if (($opt['id'] ?? '') === 'detailed') {
$pickedTitleId = 'detailed';
break;
}
}
}
$hasDetailsNotes = trim((string) $fv('product_details')) !== '';
$canAutoCopy = $import->images->isNotEmpty() && $hasDetailsNotes;
@endphp
@if($isImprove)
Improve existing never changes title, description, SKU, pricing, metafields, collections, or status on Shopify.
Focus on Enhance → Lifestyle → Prepare → Sync media.
@if(! empty($improveMeta['product_status']))
Live status:
{{ $improveMeta['product_status'] }}
@if(! empty($improveMeta['admin_url']))
·
Open in Shopify
@endif
@endif
@endif
@if(! $isImprove && $copyAutoMessage)
{{ $copyAutoMessage }}
@endif
@if(! $isImprove)
Product notes
GemLightbox-style notes (WEIGHT, CARATS, MATERIAL, DIAMOND, PRICE…).
Saves as you type — copy builds itself when notes are ready.
@if($titleSuggestions !== [])
Refresh copy
@endif
@if($import->images->isEmpty())
Add photos in Media first — titles use the images for the design name.
@elseif(! $hasDetailsNotes)
Paste your product notes above. When they look complete, 3 titles + description appear here automatically.
@endif
@if($titleSuggestions !== [])
Choose a title
Three styles for the same piece — click one to apply. SEO updates with your choice.
{{ count($titleSuggestions) }} options
@foreach($titleSuggestions as $i => $opt)
@php
$optId = (string) ($opt['id'] ?? ('opt_'.$i));
$optLabel = (string) ($opt['label'] ?? ('Option '.($i + 1)));
$optTitle = (string) ($opt['title'] ?? '');
$isPicked = $pickedTitleId !== '' && $pickedTitleId === $optId;
$letters = ['A', 'B', 'C'];
$letter = $letters[$i] ?? (string) ($i + 1);
@endphp
{{ $letter }}
{{ $optLabel }}
✓
{{ $optTitle }}
{{ mb_strlen($optTitle) }} characters
@endforeach
@elseif($canAutoCopy)
Building your listing…
Writing 3 title options, description, and SEO from your notes and photos.
@endif
@endif
{{ $isImprove ? 'Live listing (view only)' : 'Listing fields' }}
@if(! $isImprove || ! $seoLocked)
{{ $isImprove ? 'SEO saves automatically' : 'Saves automatically' }}
@endif
@endif
{{-- ═══════════════ STEP 3: PROCESS ═══════════════ --}}
{{-- ═══════════════ STEP 5: PREPARE (auto crop/compress/SEO) ═══════════════ --}}
@if($wizardStep === 'prepare')
@php
$themeRatio = (string) ($settings->aspect_ratio ?? '1.25:1');
$outFormat = strtoupper((string) ($settings->image_output_format ?? 'webp'));
$prepareItems = $import->shopifyMediaItems();
$readyCount = $prepareItems->filter(fn ($m) => $m->isShopifyPrepared())->count();
$missingAlt = $prepareItems->filter(fn ($m) => $m->isImage() && trim((string) $m->alt_text) === '')->count();
$prepareNote = $prepareAutoMessage ?? null;
$prepareStaleLocal = $import->isPrepareStale();
@endphp
5 · Prepare for Shopify
Only the gallery that uploads to Shopify is prepared here (selected enhances, lifestyle, originals, video).
Cropped to {{ $themeRatio }} , compressed to
{{ $outFormat }} , and named for SEO. History versions stay unprocessed.
{{ $readyCount }}
/ {{ $prepareItems->count() }} for Shopify
@if($prepareNote)
{{ $prepareNote }}
@endif
@if(filled($import->shopify_product_gid) && ($prepareStaleLocal || $import->hasPendingShopifySync()))
Gallery changed. Use Update Shopify in the blue bar above — no need to re-prepare here first.
Or continue to the Shopify step with the button below.
@elseif($prepareStaleLocal)
Preview of the Shopify set. Continue the wizard when ready — prepare runs automatically on create/update.
@endif
{{ $readyCount }} ready
{{ $prepareItems->count() }} for Shopify
{{ $outFormat }}
{{ $themeRatio }}
@if($missingAlt > 0)
{{ $missingAlt }} need alt
@else
SEO alts set
@endif
@php
$preparePendingCount = $prepareItems->filter(fn ($m) => ! $m->isShopifyPrepared())->count();
@endphp
@if($preparePendingCount > 0)
@csrf
isAutoRunning())
title="Crop/compress only Pending files. Ready files are skipped. No Enhance / Lifestyle AI.">
{{ $import->isBackgroundBusy() ? 'Queue prepare pending' : 'Prepare '.$preparePendingCount.' pending' }}
@endif
@csrf
isEmpty() || $import->isAutoRunning())
title="Re-run crop/compress on every Shopify file (including video). Slow — only if you need a full refresh.">
{{ $import->isBackgroundBusy() ? 'Queue re-prepare all' : 'Re-prepare all' }}
@php
$prepareFailItems = $prepareItems->filter(fn ($m) => filled($m->processing_error) || $m->processing_status === \App\Models\ProductAiImportMedia::PROCESSING_FAILED);
$preparePendingVideos = $prepareItems->filter(fn ($m) => $m->isVideo() && ! $m->isShopifyPrepared());
$preparePendingPhotos = $prepareItems->filter(fn ($m) => $m->isImage() && ! $m->isShopifyPrepared());
@endphp
@if($prepareFailItems->isNotEmpty() || $preparePendingVideos->isNotEmpty() || $preparePendingPhotos->isNotEmpty())
@if($prepareFailItems->isNotEmpty())
Prepare failed for:
@foreach($prepareFailItems as $failItem)
#{{ $failItem->id }} {{ $failItem->isVideo() ? 'video' : 'image' }}:
{{ $failItem->processing_error ?: 'unknown error' }}
@endforeach
@endif
@if($preparePendingPhotos->isNotEmpty())
{{ $preparePendingPhotos->count() }} photo(s) still Pending —
click Prepare {{ $preparePendingPhotos->count() }} pending .
Crop/compress only — no Enhance or Lifestyle AI.
@endif
@if($preparePendingVideos->isNotEmpty())
Video still Pending — prepare letterboxes the selected clip to {{ $themeRatio }} . It does not re-run Omni.
@endif
@endif
@if($prepareItems->isEmpty())
Nothing selected for Shopify yet. Choose enhances / lifestyle in earlier steps, or upload media.
@else
@foreach($prepareItems as $item)
@php
$viewUrl = $item->displayUrl();
$isReady = $item->isShopifyPrepared();
$fileExt = pathinfo((string) ($item->processed_path ?: $item->filename), PATHINFO_EXTENSION);
if ($fileExt === '') {
$fileExt = $item->isVideo() ? 'mp4' : strtolower($outFormat);
}
$ext = strtoupper($fileExt);
$sizeKb = null;
if ($item->processed_size) {
$sizeKb = (int) round($item->processed_size / 1024);
} elseif ($item->original_size) {
$sizeKb = (int) round($item->original_size / 1024);
}
$sizeLabel = $sizeKb !== null ? ($ext.' · '.$sizeKb.' KB') : $ext;
$altLabel = $item->alt_text
? \Illuminate\Support\Str::limit((string) $item->alt_text, 72)
: '—';
@endphp
@if($item->isVideo())
Video
@endif
@if($isReady)
Ready
@else
Pending
@endif
{{ $sizeLabel }}
{{ $themeRatio }}
@if($item->isVideo() && $isReady && $item->width && $item->height)
{{ (int) $item->width }}×{{ (int) $item->height }}
@endif
@if($item->isVideo() && $item->duration)
{{ number_format((float) $item->duration, 1) }}s
@endif
{{ $item->filename }}
@if(filled($item->processing_error))
{{ \Illuminate\Support\Str::limit($item->processing_error, 160) }}
@endif
Alt
{{ $altLabel }}
@endforeach
@endif
@endif
{{-- ═══════════════ STEP 3: ENHANCE ═══════════════ --}}
@if($wizardStep === 'enhance')
@php
// Match Media / Shopify gallery order. Do NOT sort originals by raw `position` alone:
// after Enhance+reorder, studio originals are pushed to the tail while Worn (still in
// the gallery as Original) keeps a low slot → Enhance showed "cómo luce" first.
$galleryOrder = [];
foreach ($import->shopifyMediaItems() as $gIdx => $gItem) {
/** @var \App\Models\ProductAiImportMedia $gItem */
if ($gItem->isEnhance() && (int) ($gItem->source_media_id ?? 0) > 0) {
$src = (int) $gItem->source_media_id;
if (! isset($galleryOrder[$src])) {
$galleryOrder[$src] = (int) $gIdx;
}
continue;
}
if ($gItem->isImage() && ! $gItem->is_ai_generated) {
$id = (int) $gItem->id;
if (! isset($galleryOrder[$id])) {
$galleryOrder[$id] = (int) $gIdx;
}
}
}
$productPhotos = $import->images()
->where('media_type', \App\Models\ProductAiImportMedia::TYPE_IMAGE)
->where('is_ai_generated', false)
->reorder()
->orderBy('position')
->orderBy('id')
->get()
->sortBy(static function ($m) use ($galleryOrder) {
$id = (int) $m->id;
$slot = $galleryOrder[$id] ?? (5000 + (int) $m->position);
return sprintf('%010d-%010d', $slot, $id);
})
->values();
$enhances = $import->images()
->where('ai_provider', \App\Models\ProductAiImportMedia::PROVIDER_ENHANCE)
->where('media_type', \App\Models\ProductAiImportMedia::TYPE_IMAGE)
// images() already orders position,id ASC — clear that or "Latest" becomes the oldest.
->reorder()
->orderByDesc('id')
->get();
$enhancesBySource = $enhances->groupBy(fn ($m) => (int) $m->source_media_id)
->map(fn ($group) => $group->sortByDesc('id')->values());
$selectedEnhanceCount = $enhances->where('is_shopify_selected', true)->count();
$enhanceScaleService = app(\App\Services\ProductAiEnhanceService::class);
$enhanceScaleMax = $enhanceScaleService->subjectScaleMaxPercent($import);
$enhanceSubjectScalePct = (int) old(
'enhance_subject_scale_pct',
$enhanceScaleService->subjectScalePercent($import)
);
$enhanceSubjectScalePct = max(40, min($enhanceScaleMax, $enhanceSubjectScalePct > 0 ? $enhanceSubjectScalePct : 55));
// Studio AI Apply count = checked photos above that have an enhance pick (not all history).
$enhanceScaleEligibleIds = array_fill_keys(
$enhanceScaleService->eligibleSubjectScaleSourceIds($import),
true
);
$enhanceScaleCheckedIds = $enhanceScaleService->checkedEnhanceRunSourceIds($import);
$enhanceScaleTargetCount = 0;
foreach ($enhanceScaleCheckedIds as $srcId) {
if (! isset($enhanceScaleEligibleIds[$srcId])) {
continue;
}
$versions = $enhancesBySource[(int) $srcId] ?? collect();
if ($versions->isEmpty()) {
continue;
}
$enhanceScaleTargetCount++;
}
$wornScaleFlip = array_fill_keys($import->wornSkipEnhanceIds(), true);
$formRunIds = is_array($form['enhance_run_ids'] ?? null)
? array_values(array_map('intval', $form['enhance_run_ids']))
: [];
foreach ($formRunIds as $checkedId) {
$checkedId = (int) $checkedId;
if ($checkedId < 1 || isset($wornScaleFlip[$checkedId])) {
continue;
}
$origVersions = $enhancesBySource[$checkedId] ?? collect();
if ($origVersions->isEmpty()) {
$enhanceScaleTargetCount++;
}
}
$productVideos = $media->where('media_type', 'video')->where('is_ai_generated', false)->values();
$videoEnhances = $media->where('ai_provider', \App\Models\ProductAiImportMedia::PROVIDER_ENHANCE)
->where('media_type', 'video')
->sortByDesc('id')
->values();
$videoEnhancesBySource = $videoEnhances->groupBy(fn ($m) => (int) $m->source_media_id)
->map(fn ($group) => $group->sortByDesc('id')->values());
$videoEnhanceEnabled = $settings->videoEnhanceEnabled();
$videoEnhancePromptValue = old('video_enhance_prompt', $settings->resolvedVideoEnhancePrompt());
$videoTrimSeconds = $settings->videoEnhanceMaxSeconds();
$themeW = (int) ($settings->image_target_width ?? 2000);
$themeH = (int) ($settings->image_target_height ?? 1600);
$themeRatio = (string) ($settings->aspect_ratio ?? '1.25:1');
$themeCropMode = in_array((string) ($settings->image_crop_mode ?? 'contain'), ['contain', 'cover'], true)
? (string) $settings->image_crop_mode
: 'contain';
// CSS aspect-ratio from "1.25:1" or "5:4"
$enhFrameCss = '1.25 / 1';
if (preg_match('/^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$/', $themeRatio, $rm)) {
$enhFrameCss = $rm[1].' / '.$rm[2];
} elseif ($themeW > 0 && $themeH > 0) {
$enhFrameCss = $themeW.' / '.$themeH;
}
$enhScaleFamily = app(\App\Services\ProductAiEnhanceService::class)->subjectScaleCategoryMeta($import)['category'] ?? 'other';
$enhIsPendants = $enhScaleFamily === 'pendants';
@endphp
3 · Enhance
Optional polish on original photos and video: same piece — better lighting/sharpness only.
Photos: OpenAI / Google. Video: Google Omni (trim ~{{ number_format($videoTrimSeconds, 1) }}s, no compress until Prepare).
{{ $selectedEnhanceCount }}
selected for Shopify
@if(!($settings->enhance_enabled ?? true) && ! $videoEnhanceEnabled)
Enhance is disabled in Settings. Skip to Lifestyle or re-enable it.
@elseif($productPhotos->isEmpty() && $productVideos->isEmpty())
@else
@if(($settings->enhance_enabled ?? true) && $productPhotos->isNotEmpty())
@php
// Saved enhance-run pick (null = first visit → all selected). Survives refresh.
$enhanceRunIdsRaw = $form['enhance_run_ids'] ?? null;
$enhanceRunIdsSaved = is_array($enhanceRunIdsRaw);
$enhanceRunIds = $enhanceRunIdsSaved
? array_values(array_map('intval', $enhanceRunIdsRaw))
: [];
$wornSkipEnhanceIds = $import->aiWornTipIds();
$measurementSkipEnhanceIds = $import->measurementSkipEnhanceIds();
$wornAiTipIds = $wornSkipEnhanceIds;
$measurementAiTipIds = $measurementSkipEnhanceIds;
$forcedEnhanceIds = $import->merchantForcedEnhanceIds();
$allPhotoIds = $productPhotos->pluck('id')->map(fn ($id) => (int) $id)->all();
if (! $enhanceRunIdsSaved) {
$skipFlip = array_fill_keys(array_merge($wornSkipEnhanceIds, $measurementSkipEnhanceIds), true);
$enhanceRunIds = array_values(array_filter(
$allPhotoIds,
static fn (int $id) => ! isset($skipFlip[$id])
));
}
$merchantPicksForPick = $import->merchantEnhancePicksMap();
// Pick grid: Shopify-selected enhances first, thumb = enhanced version when chosen for sync.
$productPhotosForPick = $productPhotos->values()->sortBy(static function ($m) use ($enhancesBySource, $merchantPicksForPick, $galleryOrder) {
$id = (int) $m->id;
$versions = $enhancesBySource[$id] ?? collect();
$pick = array_key_exists($id, $merchantPicksForPick) ? (int) $merchantPicksForPick[$id] : null;
$hasShopifyEnhance = false;
if ($pick !== null) {
$hasShopifyEnhance = $pick > 0 && (bool) $versions->firstWhere('id', $pick);
} else {
$hasShopifyEnhance = (bool) $versions->firstWhere('is_shopify_selected', true);
}
$slot = $galleryOrder[$id] ?? (5000 + (int) $m->position);
// 0 = already chosen enhance for Shopify (top); 1 = rest
return sprintf('%d-%010d-%010d', $hasShopifyEnhance ? 0 : 1, $slot, $id);
})->values();
@endphp
@csrf
Photos · Select to enhance
Unchecked photos are skipped — even with OpenAI/Google batch. Selection is saved if you refresh.
Photos already chosen for Shopify show their Enhanced preview first (top).
Or enhance one photo at a time below.
AI may tag Worn / Measures as a hint (auto-unchecked). If it is wrong, check the photo — Enhance will run on it.
@if(! $import->isImproveExisting())
New Import has no “how it looks” photos yet — Worn tags are cleared automatically.
@endif
Select all
Select none
2 · Enhance prompt
Locked: same design + same background (white stays white). Only polish sparkle/sharpness. Theme crop is in Prepare, not here.
{{ $enhancePromptValue }}
Extra notes for this run (optional)
Spanish or English OK. Blank = default Settings prompt. Filled = Settings prompt + your notes (OpenAI and Google).
{{-- Always-enabled: never rely on submit-button name/value (disabled submitters are omitted from POST). --}}
Enhance selected with OpenAI
Enhance selected with Google
Runs only the checked photos above with that AI. Unchecked stay untouched.
Use Only this / each card’s Re-enhance for one at a time.
{{-- One-photo enhance forms (outside batch form; prompt + provider + notes copied on submit) --}}
@foreach($productPhotos as $item)
@csrf
@endforeach
Product scale
{{ $enhanceSubjectScalePct }}%
@csrf
Apply scale to AI photos{{ $enhanceScaleTargetCount > 0 ? ' ('.$enhanceScaleTargetCount.')' : '' }}
Primary gets the % · other checked photos match that size · measures get the same scale (original, no AI) · skips worn
3 · Compare & choose for Shopify
Click Original or an AI version — the page reloads and must show
Shopify → Original (or Enhanced #…). Only then open Prepare:
that photo must show an Original badge, not Enhanced.
@foreach($productPhotos as $source)
@php
$versions = ($enhancesBySource[(int) $source->id] ?? collect())->values();
$latestEnhanceId = (int) ($versions->first()->id ?? 0);
$isWornTip = in_array((int) $source->id, $wornAiTipIds, true) && $versions->isEmpty();
$isMeasTip = in_array((int) $source->id, $measurementAiTipIds, true) && $versions->isEmpty();
$isWornSource = $isWornTip;
$isMeasSource = $isMeasTip;
$isSpecialSource = $isWornSource || $isMeasSource;
$merchantPicks = $import->merchantEnhancePicksMap();
$sourcePick = array_key_exists((int) $source->id, $merchantPicks)
? (int) $merchantPicks[(int) $source->id]
: null;
if ($sourcePick !== null) {
$selectedVer = $sourcePick > 0
? $versions->firstWhere('id', $sourcePick)
: null;
$usingEnhanced = $sourcePick > 0 && $selectedVer;
} else {
$selectedVer = $versions->firstWhere('is_shopify_selected', true);
$usingEnhanced = (bool) $selectedVer;
}
$preview = $selectedVer ?: $versions->first();
$versionTotal = $versions->count();
$sourceCompareUrl = $source->originalUrl() ?: $source->displayUrl();
$previewCompareUrl = $preview
? ($preview->originalUrl() ?: $preview->displayUrl())
: null;
@endphp
{{ \Illuminate\Support\Str::limit($source->filename ?: ('Photo #'.$source->id), 42) }}
@if($source->is_primary)
Primary @endif
@if($isWornTip)
Worn · AI tip @endif
@if($isMeasTip)
Measures · AI tip @endif
@if($usingEnhanced)
Shopify → Enhanced #{{ $selectedVer->id }}@if($selectedVer->engineLabel()) · {{ $selectedVer->engineLabel() }}@endif
@elseif($isSpecialSource && ! $preview)
AI tip: keep Original — or Enhance below if wrong
@elseif($preview)
Shopify → Original
@else
Not enhanced yet
@endif
AI for this photo
imageAiProvider() ?: 'openai') === 'openai')>OpenAI
imageAiProvider() === 'google')>Google
{{ $preview ? 'Re-enhance this photo' : 'Enhance this photo' }}
Extra notes for this photo (optional)
Shopify frame {{ $themeRatio }} · contain + white pad{{ ! empty($enhIsPendants) ? ' (pendants: top-aligned, chain at top)' : '' }} — Prepare keeps Enhance framing
Original
{{ $themeRatio }}
View
Enhanced preview
@if($preview)
@if($preview->id === $versions->first()->id) Latest @else #{{ $preview->id }} @endif
@if($preview->engineLabel()) · {{ $preview->engineLabel() }}@endif
@endif
{{ $themeRatio }}
@if($preview)
@php
$editTarget = $selectedVer ?: $preview;
$enhEditProvider = $editTarget->engineKey() ?: ($settings->imageAiProvider() ?: 'openai');
@endphp
View
Edit with AI
Edits the Shopify-selected version (or preview). New version is added — Settings prompt unchanged.
@else
@if($isSpecialSource)
{{ $isMeasSource ? 'AI tip: measurement — Original by default.' : 'AI tip: worn / how it looks — Original by default.' }}
Wrong? Enhance anyway:
@else
No AI version yet.
@endif
Enhance this photo
@endif
@if($preview && ! $isSpecialSource)
Choose for Shopify
{{ $versionTotal }} version{{ $versionTotal === 1 ? '' : 's' }} · click to save · Edit with AI on any version
@csrf
Original
No AI
@foreach($versions as $idx => $ver)
@php
$isLatest = $latestEnhanceId > 0 && (int) $ver->id === $latestEnhanceId;
$verLabel = $isLatest ? 'Latest' : 'v'.($versionTotal - $idx);
$when = $ver->created_at
? $ver->created_at->timezone(config('app.deals_display_timezone', 'America/New_York'))->format('M j, g:i A')
: '';
$verEngine = $ver->engineLabel();
$verCompareUrl = $ver->originalUrl() ?: $ver->displayUrl();
$verIsSelected = $selectedVer && (int) $selectedVer->id === (int) $ver->id;
$verEditProvider = $ver->engineKey() ?: ($settings->imageAiProvider() ?: 'openai');
@endphp
@csrf
@if($isLatest)Latest @endif
@if($verEngine !== '')
{{ $verEngine }}
@endif
{{ $verLabel }}
{{ $verEngine !== '' ? $verEngine.' · ' : '' }}{{ $when !== '' ? $when : '#'.$ver->id }}
✎
×
@endforeach
@endif
@endforeach
@endif {{-- photos enhance block --}}
@if($videoEnhanceEnabled && $productVideos->isNotEmpty())
@php
$vehForm = is_array($import->form_data) ? $import->form_data : [];
$vehLast = is_array($vehForm['video_enhance_last'] ?? null) ? $vehForm['video_enhance_last'] : null;
$omniQuotaService = app(\App\Services\ProductAiOmniQuotaService::class);
// Prefer controller snapshot, but always re-derive from DB failure so UI locks even if cache is empty.
$omniQuota = $omniQuotaService->snapshotForImport($vehLast);
$omniBlocked = $omniQuotaService->isHardBlocked();
$vehBg = $import->backgroundJob();
$vehBgKind = is_array($vehBg) ? (string) ($vehBg['kind'] ?? '') : '';
$vehBgStatus = is_array($vehBg) ? (string) ($vehBg['status'] ?? '') : '';
$vehBgMsg = is_array($vehBg) ? (string) ($vehBg['message'] ?? '') : '';
$vehRunning = $import->isBackgroundBusy() && $vehBgKind === 'video_enhance';
$vehFlash = session('error') ?: session('warning') ?: session('success');
$vehFlashType = session('error') ? 'error' : (session('warning') ? 'warning' : (session('success') ? 'success' : ''));
// Prefer live/failed job message, then last Omni result, then session flash.
if ($vehRunning) {
$vehFlash = $vehBgMsg !== '' ? $vehBgMsg : 'Enhancing video with Google Omni — you can scroll or open other steps.';
$vehFlashType = 'progress';
} elseif ($vehBgKind === 'video_enhance' && $vehBgStatus === 'failed' && $vehBgMsg !== '') {
$vehFlash = $vehBgMsg;
$vehFlashType = 'error';
} elseif (is_array($vehLast) && trim((string) ($vehLast['message'] ?? '')) !== '' && $vehFlashType !== 'success') {
if (! session('success') && ! session('warning')) {
$vehFlash = (string) $vehLast['message'];
$vehFlashType = ! empty($vehLast['ok']) ? 'success' : 'error';
}
} elseif (! $vehFlash && filled($import->error_message) && str_contains(strtolower((string) $import->error_message), 'video')) {
$vehFlash = (string) $import->error_message;
$vehFlashType = 'error';
}
$vehAutoIssues = is_array($vehForm['auto_issues'] ?? null) ? $vehForm['auto_issues'] : [];
if ((! $vehFlash || $vehFlashType === 'success')
&& ! $import->hasAiEnhancedVideo()
&& ! empty($vehAutoIssues['video_enhance_error'])
&& (int) ($vehAutoIssues['video_enhance_ok'] ?? 0) < 1) {
$vehFlash = (string) $vehAutoIssues['video_enhance_error'];
$vehFlashType = 'warning';
}
$vehRetryHint = '';
if (is_array($vehLast) && trim((string) ($vehLast['retry_hint'] ?? '')) !== '') {
$vehRetryHint = (string) $vehLast['retry_hint'];
} elseif (! empty($vehAutoIssues['video_enhance_retry_hint'])) {
$vehRetryHint = (string) $vehAutoIssues['video_enhance_retry_hint'];
}
$vehCanRetry = is_array($vehLast)
? (array_key_exists('can_retry', $vehLast) ? (bool) $vehLast['can_retry'] : null)
: (isset($vehAutoIssues['video_enhance_can_retry']) ? (bool) $vehAutoIssues['video_enhance_can_retry'] : null);
$vehRetryWait = is_array($vehLast)
? (bool) ($vehLast['wait'] ?? false)
: (bool) ($vehAutoIssues['video_enhance_retry_wait'] ?? false);
// Never show a stale Omni pause NOTICE. Live meter/banner owns that story.
// Old Auto runs left “quota lock until 7:59 PM” in form_data even after the lock expired.
$vehFlashIsQuota = is_string($vehFlash) && $omniQuotaService->isQuotaFailureMessage($vehFlash);
$vehLastIsQuota = is_array($vehLast)
&& (
($vehLast['retry_code'] ?? '') === 'quota'
|| $vehRetryWait
|| $omniQuotaService->isQuotaFailureMessage((string) ($vehLast['message'] ?? ''))
);
$vehAutoIsQuota = is_string($vehFlash)
&& $omniQuotaService->isQuotaFailureMessage($vehFlash);
if ($vehFlashIsQuota || $vehLastIsQuota || $vehAutoIsQuota) {
$vehFlash = null;
$vehFlashType = '';
}
$omniBlocked = $omniQuotaService->isHardBlocked();
$omniCanClear = ! empty($omniQuota['can_clear_lock']);
$omniPauseTitle = ($omniQuota['pause_reason'] ?? '') === 'daily_limit'
? 'Daily Omni limit reached'
: 'Omni paused — wait until the time below';
$omniSpacingHint = (($omniQuota['pause_reason'] ?? '') === 'rpm_spacing')
? (string) ($omniQuota['hint'] ?? '')
: '';
@endphp
Video · Compare & choose for Shopify
Same idea as photos: Google Omni polishes the full original
(trim ~{{ number_format($videoTrimSeconds, 1) }}s, no compress ).
Prepare later compresses the chosen version for Shopify / mobile.
Long legacy videos: trim first in Prepare .
Each Enhance video click = 1 Omni attempt (no 35s wait, no auto-retry).
Batch Auto may retry up to 3 times. Times below are Miami time .
{{ $omniQuota['label'] ?? 'Omni today' }}
@if($omniBlocked)
Paused until {{ $omniQuota['blocked_until_human'] ?? 'reset' }} (Miami)
@elseif($omniSpacingHint !== '')
{{ $omniSpacingHint }}
@elseif(! empty($omniQuota['hint']))
{{ $omniQuota['hint'] }}
@endif
{{ $omniPauseTitle }}
{{ $omniQuota['hint'] ?: 'Omni is paused. Enhance video will not run so you do not burn attempts.' }}
@csrf
Clear Google wait lock
Only if AI Studio already shows free Omni quota. Does not add more daily attempts.
@if($vehFlashType === 'progress') In progress
@elseif($vehFlashType === 'error') Error
@elseif($vehFlashType === 'warning') Notice
@else Result
@endif
{{ $vehFlash }}
@if($vehFlashType === 'error' || $vehFlashType === 'warning')
@if($vehRetryWait || $vehCanRetry === false)
Do not retry yet.
@elseif($vehCanRetry === true)
OK to retry (1 click = 1 Omni attempt).
@endif
@if($vehRetryHint !== '' && ! str_contains((string) $vehFlash, $vehRetryHint))
{{ $vehRetryHint }}
@endif
@if(! empty($vehAutoIssues['video_enhance_auto_continues']) || ! empty($vehLast['auto_continues']))
Auto continues with the original video and still syncs to Shopify when the rest is ready.
@endif
@endif
Video enhance prompt (Settings default — Omni only)
{{ $videoEnhancePromptValue }}
Same style as Google AI Studio: keep background, boost diamond sparkle & metal reflections. Prepare later pads the Shopify pick to 1.25:1.
@foreach($productVideos as $vSource)
@php
$vVersions = ($videoEnhancesBySource[(int) $vSource->id] ?? collect())->values();
$vLatestEnhanceId = (int) ($vVersions->first()->id ?? 0);
$vMerchantPicks = $import->merchantEnhancePicksMap();
$vSourcePick = array_key_exists((int) $vSource->id, $vMerchantPicks)
? (int) $vMerchantPicks[(int) $vSource->id]
: null;
if ($vSourcePick !== null) {
$vSelected = $vSourcePick > 0 ? $vVersions->firstWhere('id', $vSourcePick) : null;
$usingVeh = $vSourcePick > 0 && $vSelected;
} else {
$vSelected = $vVersions->firstWhere('is_shopify_selected', true);
$usingVeh = (bool) $vSelected;
}
$vPreview = $vSelected ?: $vVersions->first();
$vVersionTotal = $vVersions->count();
$vOrigUrl = $vSource->originalUrl() ?: $vSource->displayUrl();
$vOrigThumb = $vOrigUrl ? ($vOrigUrl.(str_contains($vOrigUrl, '#') ? '' : '#t=0.1')) : null;
$vPrevUrl = $vPreview ? ($vPreview->originalUrl() ?: $vPreview->displayUrl()) : null;
$vDur = $vSource->duration ? number_format((float) $vSource->duration, 1).'s' : null;
$vSizeMb = $vSource->original_size ? round($vSource->original_size / 1048576, 1).' MB' : null;
@endphp
{{ \Illuminate\Support\Str::limit($vSource->filename ?: ('Video #'.$vSource->id), 42) }}
Google Omni
@if($vDur)
{{ $vDur }}@if($vSource->duration && (float) $vSource->duration <= 10.2) · ready for Omni @endif
@endif
@if($vSizeMb)
{{ $vSizeMb }} @endif
@if($usingVeh)
Shopify → Enhanced #{{ $vSelected->id }} · Google
@elseif($vPreview)
Shopify → Original
@else
Not enhanced yet
@endif
isAutoRunning() || $omniBlocked)
title="{{ $omniBlocked ? ('Paused until '.($omniQuota['blocked_until_human'] ?? 'reset').' (Miami) — no Omni attempt so you do not burn quota') : ('Auto-trims ~'.number_format($videoTrimSeconds, 1).'s then Google Omni. Queues 1-by-1 if another job is running.') }}">
{{ $omniBlocked ? 'Paused — no Omni today' : ($vPreview ? 'Re-enhance this video' : 'Enhance this video') }}
Extra notes for this video (optional)
@if($vSource->duration && (float) $vSource->duration > 10.2)
Where to start the ~{{ number_format($videoTrimSeconds, 1) }}s clip (sec)
Enhance auto-cuts ~{{ number_format($videoTrimSeconds, 1) }}s — no separate trim step.
@else
@endif
Original vs Omni — Enhance already clips ~{{ number_format($videoTrimSeconds, 1) }}s; Prepare compresses the Shopify pick later
Original
source
@if($vOrigUrl)
@endif
Enhanced preview
@if($vPreview)
@if($vPreview->id === $vVersions->first()->id) Latest @else #{{ $vPreview->id }} @endif
· Google
@endif
@if($vPrevUrl)
@else
No Omni version yet.
isAutoRunning() || $omniBlocked)>
{{ $omniBlocked ? 'Paused — no Omni today' : 'Enhance this video' }}
@endif
@if($vPreview)
Choose for Shopify
{{ $vVersionTotal }} version{{ $vVersionTotal === 1 ? '' : 's' }} · click to save which one Sync uses
@csrf
@if($vOrigThumb)
▶
@endif
Original
No AI
@foreach($vVersions as $idx => $ver)
@php
$isLatest = $vLatestEnhanceId > 0 && (int) $ver->id === $vLatestEnhanceId;
$verLabel = $isLatest ? 'Latest' : 'v'.($vVersionTotal - $idx);
$when = $ver->created_at
? $ver->created_at->timezone(config('app.deals_display_timezone', 'America/New_York'))->format('M j, g:i A')
: '';
$verUrl = $ver->originalUrl() ?: $ver->displayUrl();
$verThumb = $verUrl ? ($verUrl.(str_contains($verUrl, '#') ? '' : '#t=0.1')) : null;
$verIsSelected = $usingVeh && $vSelected && (int) $vSelected->id === (int) $ver->id;
@endphp
@csrf
@if($isLatest)Latest @endif
Google
@if($verThumb)
▶
@endif
{{ $verLabel }}
Google · {{ $when !== '' ? $when : '#'.$ver->id }}
×
@endforeach
@endif
@csrf
@endforeach
@elseif(! $videoEnhanceEnabled && $productVideos->isNotEmpty())
Video enhance is disabled in Settings. Video still goes through Prepare compress for Shopify.
@endif
@endif
@endif
{{-- ═══════════════ STEP 4: LIFESTYLE ═══════════════ --}}
@if($wizardStep === 'lifestyle')
@php
$lifestyleCandidates = $media->where('media_type', 'image')->where('is_ai_generated', false)->values();
$maxLifeRefs = max(1, (int) config('product_ai_import.lifestyle_max_reference_images', 6));
$lifestyleRefIds = $import->lifestyleRefIds();
$lifestyleRefFlip = array_fill_keys($lifestyleRefIds, true);
$aiMedia = $media->where('is_ai_generated', true)->filter(fn ($m) => ! $m->isEnhance())->sortByDesc('id')->values();
@endphp
@if(!$settings->lifestyle_enabled)
Lifestyle generation is disabled in Settings.
@else
@csrf
Select reference photos
Thumbnails show the Enhanced pick from the Enhance step when available.
Click to select / deselect (up to {{ $maxLifeRefs }}). Selection saves automatically.
@if($lifestyleCandidates->isEmpty())
@else
@endif
Lifestyle prompt
Locked rule: same design, color, and shape as your selected references — luxury lighting only.
{{ $lifestylePromptValue }}
Extra notes for this run (optional)
Spanish or English OK. Blank = default Settings. Filled = Settings + your notes (OpenAI and Google). Uses the selected reference photos.
Generate with
imageAiProvider() ?: 'openai') === 'openai')>OpenAI
imageAiProvider() === 'google')>Google Gemini
isEmpty())>
Generate Lifestyle Image
Save selection only
Pick OpenAI or Google for this generation. The new image is selected for Shopify (older ones stay in history). Google uses an extra fidelity wrapper from Product AI Import settings (OpenAI prompt unchanged).
Or upload your own lifestyle image
If you already have a “how it looks” photo (or prefer one you made elsewhere), upload it here.
Click Upload lifestyle image to add it. It appears as Your upload (not AI)
and is marked for Shopify — you can switch anytime with Use for Shopify .
@csrf
Upload lifestyle image
@if($aiMedia->isNotEmpty())
Lifestyle history
AI generations and your uploads are kept. Mark Use for Shopify on the one you want;
delete any you do not need. Edit with AI revises that image only (new history card — generate prompt stays untouched).
@foreach($aiMedia as $item)
@php
$lifeEditProvider = $item->engineKey() ?: ($settings->imageAiProvider() ?: 'openai');
@endphp
{{ $item->providerLabel() }}
View full size
is_shopify_selected) hidden @endif>
Selected for Shopify
#{{ $item->id }}
@if($item->processed_size)
· {{ number_format($item->processed_size / 1024, 0) }} KB
@endif
· {{ $item->created_at?->timezone(config('app.deals_display_timezone', 'America/New_York'))->format('M j, g:i A') }}
Edit with AI
is_shopify_selected)>
{{ $item->is_shopify_selected ? 'Selected' : 'Use for Shopify' }}
Delete
@endforeach
@endif
@endif
@endif
{{-- ═══════════════ STEP 6: SHOPIFY ═══════════════ --}}
@if($wizardStep === 'shopify')
6 · {{ $isImprove ? 'Sync media to Shopify' : 'Shopify Draft' }}
@if($isImprove)
Media-only sync: upload replacements, remap variant images, restore gallery order, then remove old media.
Never changes Active/Draft, collections, metafields, title, body, or pricing.
@elseif($shopifyDone)
Product linked. When something changes, use the blue Update Shopify bar at the top.
@else
Create a single-variant DRAFT (not Active). Review pricing and inventory first.
@endif
@if($isImprove)
@php
$lastImproveSync = ! empty($form['last_improve_sync_at'])
? \Illuminate\Support\Carbon::parse($form['last_improve_sync_at'])
->timezone(config('app.deals_display_timezone', 'America/New_York'))
->format('M j, Y g:i A')
: null;
$adminImproveUrl = $adminUrl ?: ($improveMeta['admin_url'] ?? null);
@endphp
Linked
{{ $import->shopify_product_gid }}
@if($lastImproveSync)
Last media sync {{ $lastImproveSync }}
@endif
@if($adminImproveUrl)
Open in Shopify
@endif
{{ $import->shopifyMediaItems()->count() }} file(s) selected for Shopify.
Sync prepares pending files first, then replaces media while keeping variant ↔ image links.
@csrf
isEmpty() || $import->isAutoRunning())
title="{{ $import->isBackgroundBusy() ? 'Queues behind the current job (1-by-1).' : 'Upload → remap variants → reorder → delete old media' }}">
{{ $import->isBackgroundBusy() ? 'Queue Sync media' : 'Sync media to Shopify' }}
@if($adminImproveUrl)
Open in Shopify
@endif
Prefer the blue Sync media bar at the top when you see “pending”.
Title, pricing, metafields, collections, and status are never touched.
@if($import->wasRevertedToOriginals())
Already reverted to originals. Tag
{{ \App\Models\ProductAiImport::SHOPIFY_IMPROVED_TAG }}
is kept so Improve will not run again.
@else
@csrf
isAutoRunning() || $media->isEmpty())
title="Restore original media on Shopify and keep the Improve skip tag"
>
{{ $import->isBackgroundBusy() ? 'Queue Revert to originals' : 'Revert to originals' }}
Puts Shopify back on the downloaded originals (no AI, no crop). Keeps
{{ \App\Models\ProductAiImport::SHOPIFY_IMPROVED_TAG }}
so Improve Existing will skip this product.
@endif
@elseif($shopifyDone)
@php
$lastSynced = ! empty($form['shopify_last_synced_at'])
? \Illuminate\Support\Carbon::parse($form['shopify_last_synced_at'])
->timezone(config('app.deals_display_timezone', 'America/New_York'))
->format('M j, Y g:i A')
: null;
@endphp
{{-- Quiet link row only — pending Update lives in the top banner (one place). --}}
Linked
{{ $import->shopify_product_gid }}
@if($lastSynced)
Last synced {{ $lastSynced }}
@endif
@if($adminUrl)
Open in Shopify
@endif
@include('product_ai_import.partials.guide_from_product')
@if(count($guideColorValues))
Color variants from guide
{{ $guideColorOption }}: {{ implode(', ', $guideColorValues) }}
@endif
@csrf
@foreach($selectedCollectionIds as $cid)
@endforeach
@include('product_ai_import.partials.guide_metafields_editor')
title)==='' || $media->isEmpty() || $import->isAutoRunning())
@if($import->isAutoRunning()) aria-disabled="true" @endif
title="{{ $import->isAutoRunning() ? 'Wait until Auto finishes, then sync.' : ($import->isBackgroundBusy() ? 'Queues behind the current job (1-by-1).' : '') }}">
{{ $import->isAutoRunning() ? 'Update Shopify (Auto running)' : ($import->isBackgroundBusy() ? 'Queue Update Shopify' : 'Update Shopify') }}
@if($adminUrl)
Open in Shopify
@endif
Prefer the blue Update Shopify bar at the top when you see “pending” — it always syncs saved data.
After you confirm, watch the dark Working banner; when it finishes the pending bar should disappear and “Last synced” updates.
@else
@include('product_ai_import.partials.guide_from_product')
@if(count($guideColorValues))
Color variants from guide
{{ $guideColorOption }}: {{ implode(', ', $guideColorValues) }}
@endif
@csrf
@foreach($selectedCollectionIds as $cid)
@endforeach
@include('product_ai_import.partials.guide_metafields_editor')
title)==='' || $media->isEmpty() || $import->isAutoRunning())
@if($import->isAutoRunning()) aria-disabled="true" @endif
title="{{ $import->isAutoRunning() ? 'Wait until Auto finishes, then create the draft.' : ($import->isBackgroundBusy() ? 'Queues behind the current job (1-by-1).' : '') }}">
{{ $import->isAutoRunning() ? 'Create Shopify Draft (Auto running)' : ($import->isBackgroundBusy() ? 'Queue Create Shopify Draft' : 'Create Shopify Draft') }}
@endif
@endif
@include('product_ai_import.partials.luxury-loader')
{{-- Lightbox --}}