query('brand', 'EPSON'))); $amazonOffers = $this->resolvePublicShowcaseOffers(); $exampleOffers = $amazonOffers ->filter(fn(ProductOffer $offer): bool => $this->matchesSelectedBrand($offer, $selectedBrand)) ->take(8) ->map(function (ProductOffer $offer): array { $payload = is_array($offer->meta['payload'] ?? null) ? $offer->meta['payload'] : []; $galleryUrls = $this->resolveGalleryUrls($offer, $payload); $amazonPrice = $offer->price_amount !== null ? (float) $offer->price_amount : $this->normalizeMoney(data_get($payload, 'price_amount')); $reference = $this->buildReferenceLabel($payload); return [ 'title' => trim((string) ($offer->title ?: $offer->product?->name ?: 'Prodotto Amazon')), 'internal_code' => trim((string) ($offer->product?->internal_code ?? '')), 'brand' => trim((string) ($offer->product?->brand ?? data_get($payload, 'brand', ''))), 'asin' => trim((string) data_get($payload, 'asin', $offer->external_product_id)), 'reference_label' => $reference['label'], 'reference_value' => $reference['value'], 'price_amount' => $amazonPrice, 'price_compare_at' => $offer->price_compare_at !== null ? (float) $offer->price_compare_at : $this->normalizeMoney(data_get($payload, 'price_compare_at')), 'currency' => trim((string) ($offer->currency ?: data_get($payload, 'currency', 'EUR'))), 'availability' => trim((string) ($offer->availability ?: data_get($payload, 'availability', ''))), 'referral_url' => trim((string) ($offer->referral_url ?: data_get($payload, 'referral_url', ''))), 'detail_url' => trim((string) ($offer->external_url ?: data_get($payload, 'detail_url', ''))), 'image_url' => $galleryUrls[0] ?? '', 'image_urls' => $galleryUrls, 'search_query' => trim((string) data_get($payload, 'search_query', '')), 'sync_source' => trim((string) ($offer->meta['sync_source'] ?? 'amazon_creators_api')), 'synced_at' => trim((string) data_get($payload, 'synced_at', optional($offer->updated_at)->toIso8601String())), 'feature_points' => array_values(array_filter(array_map(static fn(mixed $value): string => trim((string) $value), (array) data_get($payload, 'feature_points', [])))), ]; })->values(); $brandCatalog = $this->buildBrandCatalog($amazonOffers, $selectedBrand); $featuredComparisons = $this->buildFeaturedComparisons($selectedBrand); $catalogStats = [ 'offers_count' => $amazonOffers->count(), 'products_count' => $amazonOffers->pluck('product_id')->filter()->unique()->count(), 'brand_products_count' => count($brandCatalog), ]; return view('public.amazon-referral', [ 'associateTag' => $this->associateTag(), 'defaultQuery' => trim((string) $request->query('q', '')), 'defaultAsin' => trim((string) $request->query('asin', '')), 'defaultUrl' => trim((string) $request->query('url', '')), 'selectedBrand' => $selectedBrand, 'exampleOffers' => $exampleOffers->all(), 'brandCatalog' => $brandCatalog, 'catalogStats' => $catalogStats, 'featuredComparisons' => $featuredComparisons, 'capabilityCards' => $this->capabilityCards(), 'telegramSetup' => $this->telegramSetup(), 'evaluationNotes' => $this->evaluationNotes(), 'kbChecklist' => $this->kbChecklist(), ]); } /** @return Collection */ private function resolvePublicShowcaseOffers(): Collection { return ProductOffer::query() ->with(['product', 'product.media']) ->where('source_type', 'amazon_creators_api') ->where('is_active', true) ->latest('last_seen_at') ->latest('id') ->limit(24) ->get() ->filter(fn(ProductOffer $offer): bool => $this->isPublicShowcaseOffer($offer)) ->unique(fn(ProductOffer $offer): string => (string) ($offer->product_id ?: $offer->external_product_id ?: $offer->id)) ->values(); } private function matchesSelectedBrand(ProductOffer $offer, string $selectedBrand): bool { $brand = trim((string) ($offer->product?->brand ?: data_get($offer->meta, 'payload.brand', ''))); return $brand !== '' && Str::contains(Str::upper($brand), $selectedBrand); } private function isPublicShowcaseOffer(ProductOffer $offer): bool { $product = $offer->product; if (! $product instanceof Product) { return false; } $catalogMeta = is_array(data_get($product->meta, 'catalog')) ? data_get($product->meta, 'catalog') : []; if (($catalogMeta['public_showcase'] ?? false) === true) { return true; } return in_array(strtoupper((string) $offer->external_product_id), $this->featuredShowcaseAsins(), true); } /** @return array */ private function featuredShowcaseAsins(): array { return [ 'B0CPPKNRLJ', 'B0B9C3ZVHR', 'B087DFLF9S', ]; } public function go(Request $request): RedirectResponse { $asin = $this->normalizeAsin((string) $request->query('asin', '')); if ($asin !== null) { return redirect()->away($this->appendAssociateTag('https://www.amazon.it/dp/' . rawurlencode($asin))); } $url = trim((string) $request->query('url', '')); if ($url !== '' && filter_var($url, FILTER_VALIDATE_URL)) { return redirect()->away($this->appendAssociateTag($this->normalizeAmazonUrl($url))); } $query = trim((string) $request->query('q', '')); if ($query !== '') { return redirect()->away($this->appendAssociateTag('https://www.amazon.it/s?k=' . rawurlencode($query))); } return redirect()->route('public.amazon.index'); } private function associateTag(): string { $tag = trim((string) config('services.amazon.referral_tag', '')); if ($tag === '') { $tag = trim((string) config('catalog.amazon.associate_tag', 'netgescon-21')); } return $tag !== '' ? $tag : 'netgescon-21'; } private function appendAssociateTag(string $url): string { $parts = parse_url($url); if (! is_array($parts)) { return $url; } $query = []; parse_str((string) ($parts['query'] ?? ''), $query); $query['tag'] = $this->associateTag(); $base = ($parts['scheme'] ?? 'https') . '://' . ($parts['host'] ?? 'www.amazon.it') . ($parts['path'] ?? ''); return $base . '?' . http_build_query($query); } private function normalizeAmazonUrl(string $url): string { $asin = $this->extractAsinFromUrl($url); if ($asin !== null) { return 'https://www.amazon.it/dp/' . rawurlencode($asin); } return $url; } private function extractAsinFromUrl(string $url): ?string { if (preg_match('~/(?:dp|gp/product)/([A-Z0-9]{10})(?:[/?]|$)~i', $url, $matches)) { return strtoupper($matches[1]); } return null; } private function normalizeAsin(string $value): ?string { $value = strtoupper(trim($value)); $value = preg_replace('/[^A-Z0-9]/', '', $value) ?? ''; return preg_match('/^[A-Z0-9]{10}$/', $value) ? $value : null; } /** @return array> */ private function capabilityCards(): array { return [ [ 'title' => 'Catalogo interno Amazon', 'status' => 'Attivo', 'tone' => 'live', 'body' => 'La ricerca Amazon e il salvataggio candidati funzionano. Ora salviamo anche snapshot strutturati di prezzo, disponibilita, immagini e metadati di sync.', ], [ 'title' => 'Bulk import risultati', 'status' => 'Attivo', 'tone' => 'live', 'body' => 'Dalla UI fornitore possiamo importare in massa i risultati della query corrente per creare un mini catalogo pronto per prove e riuso interno.', ], [ 'title' => 'Canale Telegram offerte', 'status' => 'Pronto da collegare', 'tone' => 'next', 'body' => 'Abbiamo gia la configurazione base Telegram. Serve aggiungere il bot come admin al canale e usare token e chat id del canale per pubblicare le offerte.', ], [ 'title' => 'Carrello interno -> Amazon', 'status' => 'Da validare', 'tone' => 'review', 'body' => 'Possiamo costruire un carrello interno NetGescon, ma il passaggio affidabile di piu prodotti nel carrello Amazon non e garantito dalle API attuali. La prima strada solida resta aprire link referral dei prodotti selezionati.', ], [ 'title' => 'Fattura e provenienza venditore', 'status' => 'Da testare', 'tone' => 'review', 'body' => 'Dobbiamo verificare se Amazon Creators espone davvero venditore, paese e segnali utili per fattura e garanzia. Se i dati non arrivano via API serve una validazione supplementare.', ], [ 'title' => 'Mini sito brand selezionati', 'status' => 'Attivo', 'tone' => 'live', 'body' => 'La pagina pubblica puo gia funzionare come mini sito vetrina per brand scelti, con immagini, codici prodotto ufficiali e uscita finale verso Amazon tramite referral.', ], ]; } /** @return array> */ private function telegramSetup(): array { return [ ['label' => 'Bot Telegram', 'value' => 'Crea o usa un bot con BotFather e recupera il token API.'], ['label' => 'Canale', 'value' => 'Aggiungi il bot come amministratore del canale @nethomestorelettronica con permesso di pubblicare messaggi.'], ['label' => 'Chat ID', 'value' => 'Recupera il chat_id del canale, di solito un id numerico negativo o lo username del canale se accettato dal bot.'], ['label' => 'Config', 'value' => 'Valorizza TELEGRAM_OFFERS_BOT_TOKEN, TELEGRAM_OFFERS_CHAT_ID e TELEGRAM_OFFERS_CHANNEL_NAME in ambiente.'], ['label' => 'Pubblicazione', 'value' => 'Il primo invio puo partire con il publisher offerte; esiste gia un endpoint test Telegram autenticato per invii singoli.'], ]; } /** @return array> */ private function evaluationNotes(): array { return [ [ 'title' => 'Messaggio privato a un iscritto del canale', 'body' => 'Non e possibile scrivere in privato a un iscritto solo perche segue il canale. Un bot Telegram puo scrivere a un utente solo se quell utente ha avviato il bot o ha gia aperto una chat diretta con lui.', ], [ 'title' => 'Ordinare prima venditori italiani, poi UE, poi Cina', 'body' => 'E fattibile solo se il dato del merchant o del paese arriva davvero dall API o da una fonte affidabile. Per ora non lo stiamo ancora persistendo, quindi va trattato come test aperto.', ], [ 'title' => 'Fattura e garanzia', 'body' => 'La semplice presenza del prodotto su Amazon non basta. Serve verificare se il listing espone indicatori affidabili su fattura e venditore, altrimenti il dato va raccolto con un controllo operativo separato.', ], ]; } /** @return array> */ private function buildBrandCatalog(Collection $amazonOffers, string $selectedBrand): array { return $amazonOffers ->filter(fn(ProductOffer $offer): bool => $this->matchesSelectedBrand($offer, $selectedBrand)) ->take(12) ->map(function (ProductOffer $offer): array { $payload = is_array($offer->meta['payload'] ?? null) ? $offer->meta['payload'] : []; $galleryUrls = $this->resolveGalleryUrls($offer, $payload); $amazonPrice = $offer->price_amount !== null ? (float) $offer->price_amount : $this->normalizeMoney(data_get($payload, 'price_amount')); $reference = $this->buildReferenceLabel($payload); return [ 'title' => trim((string) ($offer->title ?: $offer->product?->name ?: 'Prodotto Amazon')), 'internal_code' => trim((string) ($offer->product?->internal_code ?? '')), 'brand' => trim((string) ($offer->product?->brand ?: data_get($payload, 'brand', ''))), 'asin' => trim((string) data_get($payload, 'asin', $offer->external_product_id)), 'reference' => $reference, 'amazon_price' => $amazonPrice, 'currency' => trim((string) ($offer->currency ?: data_get($payload, 'currency', 'EUR'))), 'availability' => trim((string) ($offer->availability ?: data_get($payload, 'availability', ''))), 'referral_url' => trim((string) ($offer->referral_url ?: data_get($payload, 'referral_url', ''))), 'image_url' => $galleryUrls[0] ?? '', 'image_urls' => $galleryUrls, 'feature_points' => array_values(array_filter(array_map(static fn(mixed $value): string => trim((string) $value), (array) data_get($payload, 'feature_points', [])))), ]; }) ->values() ->all(); } /** @return array> */ private function buildFeaturedComparisons(string $selectedBrand): array { $examples = [ [ 'asin' => 'B0CPPKNRLJ', 'brand' => 'EPSON', 'note' => 'Scheda vetrina ufficiale EPSON con codice produttore, immagini e link referral verso Amazon.', ], [ 'asin' => 'B0B9C3ZVHR', 'brand' => 'SAMSUNG', 'note' => 'Prodotto Samsung da negozio riallineato con metadata Amazon completi: titolo, immagini, codice produttore ed EAN.', ], ]; return collect($examples) ->filter(static fn(array $item): bool => Str::upper((string) ($item['brand'] ?? '')) === $selectedBrand) ->map(function (array $item): array { $amazon = $this->fetchAmazonShowcaseItem((string) ($item['asin'] ?? '')); $amazonPrice = $amazon['price_amount'] ?? null; return [ 'asin' => (string) ($item['asin'] ?? ''), 'brand' => (string) ($item['brand'] ?? ''), 'title' => (string) ($amazon['title'] ?? 'Prodotto selezionato'), 'reference' => $this->buildReferenceLabel($amazon), 'detail_url' => (string) ($amazon['detail_url'] ?? ''), 'referral_url' => (string) ($amazon['referral_url'] ?? ''), 'image_url' => (string) ($amazon['image_url'] ?? ''), 'image_urls' => (array) ($amazon['image_urls'] ?? []), 'currency' => (string) ($amazon['currency'] ?? 'EUR'), 'amazon_public_price' => $amazonPrice, 'availability' => (string) ($amazon['availability'] ?? ''), 'note' => (string) ($item['note'] ?? ''), ]; }) ->values() ->all(); } /** @return array */ private function fetchAmazonShowcaseItem(string $asin): array { $asin = strtoupper(trim($asin)); if ($asin === '' || ! $this->amazonCreatorsApiService->isConfigured()) { return []; } try { $payload = $this->amazonCreatorsApiService->getItems([$asin]); } catch (\Throwable) { return []; } $item = data_get($payload, 'itemsResult.items.0') ?? data_get($payload, 'getItemsResult.items.0') ?? data_get($payload, 'ItemsResult.Items.0'); if (! is_array($item)) { return []; } $detailUrl = trim((string) data_get($item, 'detailPageURL', '')); $imageUrls = array_values(array_filter(array_map( static fn(mixed $value): string => trim((string) (is_array($value) ? data_get($value, 'large.url', data_get($value, 'url', '')) : $value)), array_merge( array_filter([(string) data_get($item, 'images.primary.large.url', '')]), (array) data_get($item, 'images.variants', []), (array) data_get($item, 'image_urls', []) ) ))); return [ 'title' => trim((string) data_get($item, 'itemInfo.title.displayValue', data_get($item, 'title', 'Prodotto Amazon'))), 'detail_url' => $detailUrl, 'referral_url' => $detailUrl !== '' ? $this->appendAssociateTag($detailUrl) : '', 'image_url' => $imageUrls[0] ?? '', 'image_urls' => $imageUrls, 'manufacturer_code' => trim((string) data_get($item, 'itemInfo.manufactureInfo.itemPartNumber.displayValue', '')), 'ean' => trim((string) data_get($item, 'itemInfo.externalIds.eans.displayValues.0', '')), 'upc' => trim((string) data_get($item, 'itemInfo.externalIds.upcs.displayValues.0', '')), 'price_amount' => $this->normalizeMoney(data_get($item, 'offersV2.listings.0.price.money.amount', data_get($item, 'offers.listings.0.price.amount', data_get($item, 'price.amount')))), 'currency' => trim((string) data_get($item, 'offersV2.listings.0.price.money.currency', data_get($item, 'offers.listings.0.price.currency', data_get($item, 'price.currency', 'EUR')))), 'availability' => trim((string) data_get($item, 'offers.listings.0.availability.message', '')), ]; } /** @param array $payload * @return array{label:string,value:string} */ private function buildReferenceLabel(array $payload): array { $manufacturerCode = trim((string) data_get($payload, 'manufacturer_code', '')); if ($manufacturerCode !== '') { return ['label' => 'Codice produttore', 'value' => $manufacturerCode]; } $ean = trim((string) data_get($payload, 'ean', '')); if ($ean !== '') { return ['label' => 'EAN', 'value' => $ean]; } $upc = trim((string) data_get($payload, 'upc', '')); if ($upc !== '') { return ['label' => 'UPC', 'value' => $upc]; } return ['label' => '', 'value' => '']; } private function resolvePreferredSupplierOffer(?Product $product): ?ProductOffer { if (! $product instanceof Product) { return null; } return ProductOffer::query() ->where('product_id', (int) $product->id) ->where('source_type', 'internal_supplier') ->whereNotNull('price_amount') ->orderByDesc('last_seen_at') ->orderByDesc('id') ->first(); } /** @param array $payload * @return array */ private function resolveGalleryUrls(ProductOffer $offer, array $payload): array { $urls = array_values(array_filter(array_map( static fn(mixed $value): string => trim((string) $value), (array) data_get($payload, 'image_urls', []) ), static fn(string $value): bool => $value !== '')); if ($urls !== []) { return array_values(array_unique($urls)); } $product = $offer->product; if (! $product instanceof Product) { return []; } $mediaUrls = $product->media ->sortBy('sort_order') ->map(function (ProductMedia $media): ?string { $sourceUrl = trim((string) data_get($media->meta, 'source_url', '')); if ($sourceUrl !== '') { return $sourceUrl; } if (($media->disk ?? '') === 'public' && trim((string) ($media->path ?? '')) !== '') { return asset('storage/' . ltrim((string) $media->path, '/')); } return null; }) ->filter(static fn(?string $value): bool => is_string($value) && $value !== '') ->values() ->all(); return array_values(array_unique($mediaUrls)); } /** @return array> */ private function kbChecklist(): array { return [ ['title' => 'Import Amazon', 'body' => 'Cercare da catalogo fornitore, salvare il singolo candidato o tutti i risultati, poi verificare che prezzo, gallery e referral siano finiti nel catalogo interno.'], ['title' => 'Scheda pubblica prodotto', 'body' => 'Esporre immagini, prezzo Amazon, codice produttore o EAN/UPC e pulsante referral senza mostrare il prezzo interno del gestionale.'], ['title' => 'Mini sito brand', 'body' => 'Usare public/amazon come vetrina per brand selezionati come EPSON, mostrando prima il catalogo interno e poi il salto finale su Amazon.'], ['title' => 'Telegram', 'body' => 'Configurare token e chat del canale, provare il dry-run e poi pubblicare una offerta reale con il comando offers:publish-telegram.'], ]; } private function normalizeMoney(mixed $value): ?float { if ($value === null || $value === '') { return null; } if (is_string($value)) { $value = str_replace('.', '', trim($value)); $value = str_replace(',', '.', $value); } return is_numeric($value) ? round((float) $value, 2) : null; } private function calculateDelta(?float $supplierPrice, ?float $amazonPrice): ?float { if ($supplierPrice === null || $amazonPrice === null) { return null; } return round($supplierPrice - $amazonPrice, 2); } private function calculateDeltaPercent(?float $supplierPrice, ?float $amazonPrice): ?float { if ($supplierPrice === null || $amazonPrice === null || $supplierPrice == 0.0) { return null; } return round((($supplierPrice - $amazonPrice) / $supplierPrice) * 100, 2); } }