@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())
@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])
{{-- ═══════════════ 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

1 · Media

Gallery preview of what uploads to Shopify. If you chose an enhance, the original stays hidden here (still in Enhance history). Change picks in Enhance / Lifestyle anytime.

{{ $mediaStepItems->count() }} for Shopify
@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
{{-- 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)
@csrf
@endif @if($item->isImage() && ! $item->is_ai_generated && ($settings->lifestyle_enabled ?? true))
@csrf
@endif
@csrf @method('DELETE')
@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

Add more media

Drop or pick files — they upload automatically. Extra photos after Shopify sync are fine; then use Update Shopify.

Photos {{ $uploadedImgCount }}/{{ $settings->max_images_per_import }} Video {{ $uploadedVidCount }}/{{ $settings->max_videos_per_import }}
@if($canUploadMore)
@csrf
Drop photos or video here or click to browse · JPG, PNG, WEBP, MP4, MOV {{ $remainImg }} photo slot{{ $remainImg === 1 ? '' : 's' }} · {{ $remainVid }} video slot{{ $remainVid === 1 ? '' : 's' }} left
@else

Upload limit reached. Remove a product photo or video to free a slot.

@endif
@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

2 · Details & {{ $isImprove ? 'SEO' : 'copy' }}

@if($isImprove) Listing fields are read-only from Shopify. SEO is @if($seoLocked) locked (already filled on Shopify — never overwritten). @else editable because it was empty on pull — filled values sync with media. @endif @else Paste your specs — titles, description, and SEO generate automatically from notes + photos. Pick one of the 3 titles below. Yellow fields still need your confirmation. @endif

@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 !== []) @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 @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
@csrf @method('PUT')

Product & pricing

{{ $isImprove ? 'Pulled from Shopify — not pushed back on sync.' : 'Retail title and commercial fields for Shopify. You can still edit the title manually after picking.' }}

Description

{{ $isImprove ? 'From Shopify — not overwritten on sync.' : 'Shown as customers will read it on the store (not raw HTML).' }}

@if($isImprove)
{!! $fv('description') !!}
@else
{!! $fv('description') !!}
Bold key specs as needed. HTML is saved for Shopify automatically. @endif
@if(! $isImprove)
@endif
@if(! $isImprove)

Specifications

Facts from notes — used for metafields and SEO.

@endif

Search engine listing

@if($isImprove && $seoLocked) Locked — SEO was already set on Shopify and will not be changed. @elseif($isImprove) Was empty on Shopify — edit here; sync pushes SEO with media. @else Google-style page title and meta description (character limits below). @endif

0/70 · search result title
0/155 · Google meta (~160 max)
@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
@endif
@csrf
@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->is_primary) Primary @endif @if($item->is_ai_generated) {{ $item->isEnhance() ? 'Enhanced' : 'Lifestyle' }} @elseif($import->isWornOriginalMedia($item)) How it looks @elseif($import->isMeasurementOriginalMedia($item)) Measures @else Original @endif @if($item->isVideo()) Video @else @endif
@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())
No product photos or video yet. Upload in Media first.
@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

@foreach($productPhotosForPick as $item) @php $itemEnhanceVersions = ($enhancesBySource[(int) $item->id] ?? collect()); $pickId = array_key_exists((int) $item->id, $merchantPicksForPick) ? (int) $merchantPicksForPick[(int) $item->id] : null; $shopifyEnhance = null; if ($pickId !== null) { $shopifyEnhance = $pickId > 0 ? $itemEnhanceVersions->firstWhere('id', $pickId) : null; } else { $shopifyEnhance = $itemEnhanceVersions->firstWhere('is_shopify_selected', true); } $pickThumbUrl = $shopifyEnhance ? ($shopifyEnhance->originalUrl() ?: $shopifyEnhance->displayUrl()) : ($item->originalUrl() ?: $item->displayUrl()); $pickThumbTitle = $shopifyEnhance ? ('Enhanced #'.$shopifyEnhance->id.($shopifyEnhance->engineLabel() ? ' · '.$shopifyEnhance->engineLabel() : '')) : ($item->filename ?: 'Photo'); $isWornSkip = in_array((int) $item->id, $wornSkipEnhanceIds, true); $isMeasSkip = in_array((int) $item->id, $measurementSkipEnhanceIds, true); $isLifeRefSkip = (bool) $item->is_lifestyle_source && ! $isWornSkip && ! in_array((int) $item->id, $forcedEnhanceIds, true); $isWornTip = $isWornSkip && $itemEnhanceVersions->isEmpty(); $isMeasTip = $isMeasSkip && $itemEnhanceVersions->isEmpty(); $isSpecialSkip = $isWornSkip || $isMeasSkip || $isLifeRefSkip; $forcedEnhance = in_array((int) $item->id, $forcedEnhanceIds, true); // Special skips start unchecked unless merchant forced them, or they are in saved run pick. $runChecked = $forcedEnhance || (! $isSpecialSkip && in_array((int) $item->id, $enhanceRunIds, true)) || ($isSpecialSkip && $enhanceRunIdsSaved && in_array((int) $item->id, $enhanceRunIds, true)); @endphp
@endforeach

2 · Enhance prompt

Locked: same design + same background (white stays white). Only polish sparkle/sharpness. Theme crop is in Prepare, not here.

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). --}}

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

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

Shopify frame {{ $themeRatio }} · contain + white pad{{ ! empty($enhIsPendants) ? ' (pendants: top-aligned, chain at top)' : '' }} — Prepare keeps Enhance framing

Original {{ $themeRatio }}
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
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
@endif
@if($preview && ! $isSpecialSource)
Choose for Shopify {{ $versionTotal }} version{{ $versionTotal === 1 ? '' : 's' }} · click to save · Edit with AI on any version
@csrf
@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
@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 Only if AI Studio already shows free Omni quota. Does not add more daily attempts.

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
@if($vSource->duration && (float) $vSource->duration > 10.2)
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.

@endif
@if($vPreview)
Choose for Shopify {{ $vVersionTotal }} version{{ $vVersionTotal === 1 ? '' : 's' }} · click to save which one Sync uses
@csrf
@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
@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

4 · Lifestyle

Select reference photos — Lifestyle AI uses the Enhanced version selected in Improve (falls back to original only if that photo has no Enhance / Original is selected). Image AI: {{ $settings->imageAiProviderLabel() }} · change in Product AI Settings

{{ $lifestyleRefs->count() }} / {{ $maxLifeRefs }} selected
@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())
No product photos yet. Upload images in Media first.
@else
@foreach($lifestyleCandidates as $item) @php $checked = isset($lifestyleRefFlip[(int) $item->id]); $lifeVisual = $import->preferredLifestyleReference($item); $lifeVisualUrl = $lifeVisual->originalUrl() ?: $lifeVisual->displayUrl(); $lifeIsEnhanced = $lifeVisual->isEnhance(); @endphp
@endforeach
@endif

Locked rule: same design, color, and shape as your selected references — luxury lighting only.

Spanish or English OK. Blank = default Settings. Filled = Settings + your notes (OpenAI and Google). Uses the selected reference photos.

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
Drop or choose lifestyle image JPG, PNG, or WEBP — will be cropped to theme ratio like the others
@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() }}
#{{ $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') }}
@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
@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

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')
Default 10. Same stock is applied to every color/metal variant on sync.
Shopify Standard Category (admin Category column).
Online Store template (bracelets, rings, pendants…).
Optional. Leave empty to skip. Guided collections are also added on sync.
@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')
Default 10. Same stock is applied to every color/metal variant on sync.
Shopify Standard Category (admin Category column).
Online Store template (bracelets, rings, pendants…).
Optional. Leave empty to skip. Guided collections are also added on create.
@endif
@endif
@include('product_ai_import.partials.luxury-loader') {{-- Lightbox --}} {{-- Edit with AI (Lifestyle history + Enhance versions) — generate prompts stay untouched --}} @push('styles') @include('product_ai_import.partials.styles') @endpush @push('scripts') @include('product_ai_import.partials.upload-preview-script') @endpush @endsection