diff --git a/app/Http/Controllers/PublicAccess/AmazonReferralController.php b/app/Http/Controllers/PublicAccess/AmazonReferralController.php index 1f3a09c..4716888 100644 --- a/app/Http/Controllers/PublicAccess/AmazonReferralController.php +++ b/app/Http/Controllers/PublicAccess/AmazonReferralController.php @@ -1,24 +1,134 @@ 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', '')), + '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', '')); @@ -93,4 +203,316 @@ private function normalizeAsin(string $value): ?string return preg_match('/^[A-Z0-9]{10}$/', $value) ? $value : null; } -} \ No newline at end of file + + /** @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); + } +} diff --git a/app/Services/Catalog/AmazonCreatorsCatalogSyncService.php b/app/Services/Catalog/AmazonCreatorsCatalogSyncService.php index fad39b4..bfac1bc 100644 --- a/app/Services/Catalog/AmazonCreatorsCatalogSyncService.php +++ b/app/Services/Catalog/AmazonCreatorsCatalogSyncService.php @@ -33,27 +33,39 @@ public function syncProduct(Product $product, array $options = []): array throw new RuntimeException('La risposta Amazon non contiene un ASIN utilizzabile.'); } - $title = $this->extractTitle($item) ?: (string) ($product->name ?? 'Prodotto Amazon'); - $marketplace = trim((string) ($options['marketplace'] ?? $this->apiService->getMarketplace())) ?: $this->apiService->getMarketplace(); - $associateTag = trim((string) ($options['associate_tag'] ?? $this->apiService->getAssociateTag() ?? '')) ?: null; - $detailUrl = $this->extractDetailPageUrl($item); - $referralUrl = $detailUrl !== null ? $this->appendAssociateTag($detailUrl, $associateTag) : $this->productOfferService->buildAmazonReferralUrl($product, $associateTag, $this->mapMarketplaceToLocale($marketplace)); - $price = $this->extractPriceAmount($item); - $compareAt = $this->extractCompareAtAmount($item); - $currency = $this->extractCurrency($item) ?? 'EUR'; - $availability = $this->extractAvailability($item) ?? 'catalog_synced'; - $externalId = $asin; - $ean = $this->extractEan($item); - $imageUrl = $this->extractPrimaryImageUrl($item); - $payloadSummary = [ - 'asin' => $asin, - 'title' => $title, - 'marketplace' => $marketplace, - 'detail_url' => $detailUrl, - 'image_url' => $imageUrl, - 'price_amount' => $price, - 'currency' => $currency, - ]; + $title = $this->extractTitle($item) ?: (string) ($product->name ?? 'Prodotto Amazon'); + $marketplace = trim((string) ($options['marketplace'] ?? $this->apiService->getMarketplace())) ?: $this->apiService->getMarketplace(); + $associateTag = trim((string) ($options['associate_tag'] ?? $this->apiService->getAssociateTag() ?? '')) ?: null; + $detailUrl = $this->extractDetailPageUrl($item); + $referralUrl = $detailUrl !== null ? $this->appendAssociateTag($detailUrl, $associateTag) : $this->productOfferService->buildAmazonReferralUrl($product, $associateTag, $this->mapMarketplaceToLocale($marketplace)); + $price = $this->extractPriceAmount($item); + $compareAt = $this->extractCompareAtAmount($item); + $currency = $this->extractCurrency($item) ?? 'EUR'; + $availability = $this->extractAvailability($item) ?? 'catalog_synced'; + $externalId = $asin; + $ean = $this->extractEan($item); + $upc = $this->extractUpc($item); + $manufacturerCode = $this->extractManufacturerPartNumber($item); + $imageUrls = $this->extractImageUrls($item); + $imageUrl = $imageUrls[0] ?? null; + $payloadSummary = $this->buildAmazonSnapshot($item, [ + 'asin' => $asin, + 'ean' => $ean, + 'upc' => $upc, + 'manufacturer_code' => $manufacturerCode, + 'title' => $title, + 'marketplace' => $marketplace, + 'associate_tag' => $associateTag, + 'detail_url' => $detailUrl, + 'referral_url' => $referralUrl, + 'image_url' => $imageUrl, + 'image_urls' => $imageUrls, + 'price_amount' => $price, + 'price_compare_at' => $compareAt, + 'currency' => $currency, + 'availability' => $availability, + 'search_query' => trim((string) ($options['search_query'] ?? '')), + ]); $identifiersCreated = 0; $mediaCreated = 0; @@ -64,9 +76,24 @@ public function syncProduct(Product $product, array $options = []): array if ($ean !== null) { $identifiersCreated += $this->upsertIdentifier($product, 'ean', $ean, 'amazon_creators_api', false) ? 1 : 0; } + if ($upc !== null) { + $identifiersCreated += $this->upsertIdentifier($product, 'upc', $upc, 'amazon_creators_api', false) ? 1 : 0; + } + if ($manufacturerCode !== null) { + $identifiersCreated += $this->upsertIdentifier($product, 'mpn', $manufacturerCode, 'amazon_creators_api', false) ? 1 : 0; + } - if ($imageUrl !== null) { - $mediaCreated += $this->upsertRemoteMedia($product, $imageUrl, $title) ? 1 : 0; + foreach (array_values(array_slice($imageUrls, 0, 12)) as $index => $remoteUrl) { + $mediaCreated += $this->upsertRemoteMedia( + $product, + $remoteUrl, + $title, + $index, + [ + 'provider' => 'amazon_creators_api', + 'role' => $index === 0 ? 'primary' : 'gallery', + ] + ) ? 1 : 0; } $offer = $this->productOfferService->syncOffer($product, [ @@ -97,11 +124,14 @@ public function syncProduct(Product $product, array $options = []): array 'detail_url' => $detailUrl, 'referral_url' => $referralUrl, 'image_url' => $imageUrl, + 'image_urls' => $imageUrls, 'price_amount' => $price, 'price_compare_at' => $compareAt, 'currency' => $currency, 'availability' => $availability, 'ean' => $ean, + 'upc' => $upc, + 'manufacturer_code' => $manufacturerCode, 'identifiers_created' => $identifiersCreated, 'media_created' => $mediaCreated, 'offer_id' => $offer instanceof ProductOffer ? (int) $offer->id : null, @@ -125,15 +155,19 @@ public function searchCatalog(string $query, int $limit = 8, array $options = [] return $this->extractItems($response) ->take(max(1, $limit)) ->map(function (array $item): array { - $asin = $this->extractAsin($item); - $ean = $this->extractEan($item); - $existingProduct = $this->findExistingProduct($asin, $ean); - $detailUrl = $this->extractDetailPageUrl($item); - $associateTag = $this->apiService->getAssociateTag(); + $asin = $this->extractAsin($item); + $ean = $this->extractEan($item); + $upc = $this->extractUpc($item); + $manufacturerCode = $this->extractManufacturerPartNumber($item); + $existingProduct = $this->findExistingProduct($asin, $ean, $upc, $manufacturerCode); + $detailUrl = $this->extractDetailPageUrl($item); + $associateTag = $this->apiService->getAssociateTag(); return [ 'asin' => $asin, 'ean' => $ean, + 'upc' => $upc, + 'manufacturer_code' => $manufacturerCode, 'title' => $this->extractTitle($item) ?: 'Prodotto Amazon', 'brand' => $this->extractBrand($item), 'price_amount' => $this->extractPriceAmount($item), @@ -157,16 +191,42 @@ public function searchCatalog(string $query, int $limit = 8, array $options = [] /** @return array{product: Product, sync: array, created: bool} */ public function importItemForSupplier(Fornitore $fornitore, array $item, array $options = []): array { + $item = $this->enrichItem($item, $options); + $asin = $this->extractAsin($item); if ($asin === null) { throw new RuntimeException('Il risultato Amazon selezionato non contiene un ASIN valido.'); } - $ean = $this->extractEan($item); - $title = $this->extractTitle($item) ?: 'Prodotto Amazon'; - $brand = $this->extractBrand($item); - $product = $this->findExistingProduct($asin, $ean); - $created = false; + $ean = $this->extractEan($item); + $upc = $this->extractUpc($item); + $manufacturerCode = $this->extractManufacturerPartNumber($item); + $title = $this->extractTitle($item) ?: 'Prodotto Amazon'; + $brand = $this->extractBrand($item); + $model = $this->extractModel($item); + $description = $this->buildDescriptionFromAmazon($item); + $product = $this->findExistingProduct($asin, $ean, $upc, $manufacturerCode); + $created = false; + + $marketplace = trim((string) ($options['marketplace'] ?? $this->apiService->getMarketplace())) ?: $this->apiService->getMarketplace(); + $associateTag = trim((string) ($options['associate_tag'] ?? ($this->apiService->getAssociateTag() ?? ''))) ?: null; + $detailUrl = $this->extractDetailPageUrl($item); + $referralUrl = $detailUrl !== null ? $this->appendAssociateTag($detailUrl, $associateTag) : null; + $snapshot = $this->buildAmazonSnapshot($item, [ + 'asin' => $asin, + 'ean' => $ean, + 'upc' => $upc, + 'manufacturer_code' => $manufacturerCode, + 'title' => $title, + 'brand' => $brand, + 'model' => $model, + 'marketplace' => $marketplace, + 'associate_tag' => $associateTag, + 'detail_url' => $detailUrl, + 'referral_url' => $referralUrl, + 'search_query' => trim((string) ($options['search_query'] ?? '')), + 'sync_source' => (string) ($options['source'] ?? 'amazon_catalog_search_ui'), + ]); if (! $product instanceof Product) { $product = Product::query()->create([ @@ -175,46 +235,48 @@ public function importItemForSupplier(Fornitore $fornitore, array $item, array $ 'canonical_key' => $this->buildCanonicalKey($title, $asin, $ean), 'name' => $title, 'brand' => $brand, - 'model' => null, - 'description' => null, + 'model' => $model, + 'description' => $description, 'track_serials' => false, 'is_active' => true, 'meta' => [ 'source' => 'amazon_creators_api', 'catalog' => [ 'publication_mode' => 'internal_only', + 'public_showcase' => false, ], 'verification' => [ 'status' => 'pending_review', 'channel' => 'amazon_catalog_search_ui', 'updated_at' => now()->toIso8601String(), ], - 'amazon' => [ - 'asin' => $asin, - 'ean' => $ean, - 'title' => $title, - ], + 'amazon' => $snapshot, ], ]); $created = true; } else { $meta = is_array($product->meta ?? null) ? $product->meta : []; $meta['source'] = $meta['source'] ?? 'amazon_creators_api'; + $meta['catalog'] = array_replace([ + 'publication_mode' => 'internal_only', + 'public_showcase' => false, + ], is_array($meta['catalog'] ?? null) ? $meta['catalog'] : []); $meta['verification'] = [ 'status' => 'pending_review', 'channel' => 'amazon_catalog_search_ui', 'updated_at' => now()->toIso8601String(), ]; - $meta['amazon'] = array_filter([ - 'asin' => $asin, - 'ean' => $ean, - 'title' => $title, - ], static fn(mixed $value): bool => $value !== null && $value !== ''); + $meta['amazon'] = array_filter(array_replace( + is_array($meta['amazon'] ?? null) ? $meta['amazon'] : [], + $snapshot + ), static fn(mixed $value): bool => $value !== null && $value !== ''); $product->fill([ 'default_fornitore_id' => $product->default_fornitore_id ?: (int) $fornitore->id, 'name' => trim((string) $product->name) !== '' ? $product->name : $title, 'brand' => trim((string) ($product->brand ?? '')) !== '' ? $product->brand : $brand, + 'model' => trim((string) ($product->model ?? '')) !== '' ? $product->model : $model, + 'description' => trim((string) ($product->description ?? '')) !== '' ? $product->description : $description, 'meta' => $meta, ]); $product->save(); @@ -223,8 +285,9 @@ public function importItemForSupplier(Fornitore $fornitore, array $item, array $ $sync = $this->syncProduct($product, [ 'item' => $item, 'source' => (string) ($options['source'] ?? 'amazon_catalog_search_ui'), - 'marketplace' => (string) ($options['marketplace'] ?? $this->apiService->getMarketplace()), - 'associate_tag' => (string) ($options['associate_tag'] ?? ($this->apiService->getAssociateTag() ?? '')), + 'marketplace' => $marketplace, + 'associate_tag' => (string) ($associateTag ?? ''), + 'search_query' => trim((string) ($options['search_query'] ?? '')), ]); return [ @@ -234,6 +297,50 @@ public function importItemForSupplier(Fornitore $fornitore, array $item, array $ ]; } + /** @param array> $rows + * @return array + */ + public function importSearchResultsForSupplier(Fornitore $fornitore, array $rows, array $options = []): array + { + $created = 0; + $updated = 0; + $skipped = 0; + $errors = []; + + foreach ($rows as $row) { + $item = is_array($row['item_payload'] ?? null) ? $row['item_payload'] : null; + if (! is_array($item) || $item === []) { + $skipped++; + continue; + } + + try { + $result = $this->importItemForSupplier($fornitore, $item, [ + 'source' => (string) ($options['source'] ?? 'amazon_catalog_search_bulk_ui'), + 'marketplace' => (string) ($options['marketplace'] ?? $this->apiService->getMarketplace()), + 'associate_tag' => (string) ($options['associate_tag'] ?? ($this->apiService->getAssociateTag() ?? '')), + 'search_query' => trim((string) ($options['search_query'] ?? '')), + ]); + + if (! empty($result['created'])) { + $created++; + } else { + $updated++; + } + } catch (RuntimeException $e) { + $errors[] = $e->getMessage(); + } + } + + return [ + 'created' => $created, + 'updated' => $updated, + 'skipped' => $skipped, + 'errors' => array_values(array_unique($errors)), + 'processed' => $created + $updated, + ]; + } + /** @return array */ private function resolveItemPayload(Product $product, array $options): array { @@ -273,6 +380,9 @@ private function extractItems(array $payload): Collection { foreach ([ 'items', + 'itemsResult.items', + 'searchResult.items', + 'getItemsResult.items', 'ItemsResult.Items', 'SearchResult.Items', 'GetItemsResult.Items', @@ -282,7 +392,7 @@ private function extractItems(array $payload): Collection ] as $path) { $items = data_get($payload, $path); if (is_array($items)) { - return collect($items)->filter('is_array')->values(); + return collect($items)->filter(static fn(mixed $item): bool => is_array($item))->values(); } } @@ -294,6 +404,9 @@ private function pickFirstItem(array $payload): ?array { foreach ([ 'items', + 'itemsResult.items', + 'searchResult.items', + 'getItemsResult.items', 'ItemsResult.Items', 'SearchResult.Items', 'GetItemsResult.Items', @@ -329,6 +442,7 @@ private function extractEan(array $item): ?string 'EAN', 'ExternalIds.EANs.DisplayValues.0', 'externalIds.eans.0', + 'itemInfo.externalIds.eans.displayValues.0', 'identifiers.ean', ] as $path) { $value = preg_replace('/\s+/', '', trim((string) data_get($item, $path, ''))); @@ -340,6 +454,24 @@ private function extractEan(array $item): ?string return null; } + private function extractUpc(array $item): ?string + { + foreach ([ + 'UPC', + 'upc', + 'ExternalIds.UPCs.DisplayValues.0', + 'externalIds.upcs.0', + 'itemInfo.externalIds.upcs.displayValues.0', + ] as $path) { + $value = preg_replace('/\s+/', '', trim((string) data_get($item, $path, ''))); + if (is_string($value) && $value !== '') { + return $value; + } + } + + return null; + } + private function extractTitle(array $item): ?string { foreach (['title', 'Title', 'ItemInfo.Title.DisplayValue', 'itemInfo.title.displayValue'] as $path) { @@ -364,6 +496,43 @@ private function extractBrand(array $item): ?string return null; } + private function extractModel(array $item): ?string + { + foreach ([ + 'model', + 'Model', + 'ItemInfo.ManufactureInfo.Model.DisplayValue', + 'itemInfo.manufactureInfo.model.displayValue', + 'ItemInfo.Classifications.ProductGroup.DisplayValue', + 'itemInfo.classifications.productGroup.displayValue', + ] as $path) { + $value = trim((string) data_get($item, $path, '')); + if ($value !== '') { + return $value; + } + } + + return null; + } + + private function extractManufacturerPartNumber(array $item): ?string + { + foreach ([ + 'manufacturer_code', + 'mpn', + 'MPN', + 'ItemInfo.ManufactureInfo.ItemPartNumber.DisplayValue', + 'itemInfo.manufactureInfo.itemPartNumber.displayValue', + ] as $path) { + $value = trim((string) data_get($item, $path, '')); + if ($value !== '') { + return $value; + } + } + + return null; + } + private function extractDetailPageUrl(array $item): ?string { foreach (['detailPageURL', 'DetailPageURL', 'detailPageUrl', 'url'] as $path) { @@ -378,22 +547,40 @@ private function extractDetailPageUrl(array $item): ?string private function extractPrimaryImageUrl(array $item): ?string { + return $this->extractImageUrls($item)[0] ?? null; + } + + /** @return array */ + private function extractImageUrls(array $item): array + { + $urls = []; + foreach ([ 'Images.Primary.Large.URL', 'Images.Primary.Medium.URL', 'Images.Primary.Small.URL', + 'Images.Variants.*.Large.URL', + 'Images.Variants.*.Medium.URL', + 'Images.Variants.*.Small.URL', 'images.primary.large.url', 'images.primary.medium.url', - 'images.0.url', + 'images.primary.small.url', + 'images.variants.*.large.url', + 'images.variants.*.medium.url', + 'images.variants.*.small.url', + 'images.*.url', 'image', ] as $path) { - $value = trim((string) data_get($item, $path, '')); - if ($value !== '') { - return $value; + $value = data_get($item, $path); + foreach (Arr::wrap($value) as $candidate) { + $url = trim((string) $candidate); + if ($url !== '' && str_starts_with($url, 'http')) { + $urls[] = $url; + } } } - return null; + return array_values(array_unique($urls)); } private function extractPriceAmount(array $item): ?float @@ -401,6 +588,7 @@ private function extractPriceAmount(array $item): ?float foreach ([ 'Offers.Listings.0.Price.Amount', 'Offers.Summaries.0.LowestPrice.Amount', + 'offersV2.listings.0.price.money.amount', 'offers.listings.0.price.amount', 'offers.summaries.0.lowestPrice.amount', 'price.amount', @@ -418,6 +606,7 @@ private function extractCompareAtAmount(array $item): ?float { foreach ([ 'Offers.Listings.0.SavingBasis.Amount', + 'offersV2.listings.0.price.savingBasis.money.amount', 'offers.listings.0.savingBasis.amount', 'price.compareAt.amount', ] as $path) { @@ -435,6 +624,7 @@ private function extractCurrency(array $item): ?string foreach ([ 'Offers.Listings.0.Price.Currency', 'Offers.Summaries.0.LowestPrice.Currency', + 'offersV2.listings.0.price.money.currency', 'offers.listings.0.price.currency', 'offers.summaries.0.lowestPrice.currency', 'price.currency', @@ -464,6 +654,63 @@ private function extractAvailability(array $item): ?string return null; } + /** @return array */ + private function extractFeaturePoints(array $item): array + { + $features = []; + + foreach ([ + 'ItemInfo.Features.DisplayValues', + 'itemInfo.features.displayValues', + 'features', + 'featureBullets', + ] as $path) { + $value = data_get($item, $path); + foreach (Arr::wrap($value) as $candidate) { + $text = trim((string) $candidate); + if ($text !== '') { + $features[] = $text; + } + } + } + + return array_values(array_unique($features)); + } + + private function buildDescriptionFromAmazon(array $item): ?string + { + $features = array_slice($this->extractFeaturePoints($item), 0, 8); + + return $features !== [] ? implode(PHP_EOL, $features) : null; + } + + /** @param array $overrides + * @return array + */ + private function buildAmazonSnapshot(array $item, array $overrides = []): array + { + $snapshot = array_filter([ + 'asin' => $this->extractAsin($item), + 'ean' => $this->extractEan($item), + 'upc' => $this->extractUpc($item), + 'title' => $this->extractTitle($item), + 'brand' => $this->extractBrand($item), + 'model' => $this->extractModel($item), + 'manufacturer_code' => $this->extractManufacturerPartNumber($item), + 'detail_url' => $this->extractDetailPageUrl($item), + 'image_url' => $this->extractPrimaryImageUrl($item), + 'image_urls' => $this->extractImageUrls($item), + 'price_amount' => $this->extractPriceAmount($item), + 'price_compare_at' => $this->extractCompareAtAmount($item), + 'currency' => $this->extractCurrency($item), + 'availability' => $this->extractAvailability($item), + 'feature_points' => array_slice($this->extractFeaturePoints($item), 0, 8), + 'synced_at' => now()->toIso8601String(), + ], static fn(mixed $value): bool => $value !== null && $value !== '' && $value !== []); + + return array_filter(array_replace($snapshot, $overrides), static fn(mixed $value): bool => $value !== null && $value !== '' && $value !== []); + } + private function appendAssociateTag(string $url, ?string $associateTag): string { $associateTag = $associateTag !== null ? trim($associateTag) : ''; @@ -513,11 +760,13 @@ private function buildSearchQuery(Product $product): string return Str::limit(implode(' ', $parts), 120, ''); } - private function findExistingProduct(?string $asin, ?string $ean): ?Product + private function findExistingProduct(?string $asin, ?string $ean, ?string $upc = null, ?string $manufacturerCode = null): ?Product { $codes = array_values(array_filter([ $this->normalizeCode($asin), $this->normalizeCode($ean), + $this->normalizeCode($upc), + $this->normalizeCode($manufacturerCode), ])); if ($codes === []) { @@ -532,6 +781,37 @@ private function findExistingProduct(?string $asin, ?string $ean): ?Product return $productId ? Product::query()->find((int) $productId) : null; } + /** @param array $options */ + private function enrichItem(array $item, array $options = []): array + { + $asin = $this->extractAsin($item); + if ($asin === null) { + return $item; + } + + $needsEnrichment = $this->extractManufacturerPartNumber($item) === null + || $this->extractEan($item) === null + || $this->extractPrimaryImageUrl($item) === null + || $this->extractBrand($item) === null + || $this->extractPriceAmount($item) === null; + + if (! $needsEnrichment) { + return $item; + } + + try { + $response = $this->apiService->getItems([$asin], [ + 'marketplace' => (string) ($options['marketplace'] ?? $this->apiService->getMarketplace()), + ]); + } catch (RuntimeException) { + return $item; + } + + $richItem = $this->pickFirstItem($response); + + return is_array($richItem) ? $richItem : $item; + } + private function buildCanonicalKey(string $title, ?string $asin, ?string $ean): string { $base = Str::slug(Str::limit($title, 80, '')); @@ -587,7 +867,7 @@ private function upsertIdentifier(Product $product, string $type, string $value, return $created; } - private function upsertRemoteMedia(Product $product, string $url, string $title): bool + private function upsertRemoteMedia(Product $product, string $url, string $title, int $sortOrder = 0, array $meta = []): bool { $url = trim($url); if ($url === '' || ! str_starts_with($url, 'http')) { @@ -601,12 +881,13 @@ private function upsertRemoteMedia(Product $product, string $url, string $title) 'path' => $url, ]); - $created = ! $media->exists; - $media->title = $title; - $media->meta = [ + $created = ! $media->exists; + $media->title = $title; + $media->sort_order = max(0, $sortOrder); + $media->meta = array_replace([ 'provider' => 'amazon_creators_api', 'remote' => true, - ]; + ], $meta); $media->save(); return $created; diff --git a/docs/AMAZON-CREATORS-API.md b/docs/AMAZON-CREATORS-API.md index ac6b5e9..903b9fe 100644 --- a/docs/AMAZON-CREATORS-API.md +++ b/docs/AMAZON-CREATORS-API.md @@ -7,9 +7,15 @@ ## Stato attuale nel codice - configurazione ambiente per Amazon Creators API in `config/services.php`; - servizio `AmazonCreatorsApiService` per token OAuth e chiamate configurabili; -- servizio `AmazonCreatorsCatalogSyncService` per mappare item Amazon su `ProductOffer`, `ProductIdentifier` e `ProductMedia`; +- servizio `AmazonCreatorsCatalogSyncService` per mappare item Amazon su `ProductOffer`, `ProductIdentifier`, `ProductMedia` e snapshot strutturato del prodotto; - comando artisan `catalog:sync-amazon-creators` per sincronizzare un prodotto singolo. +In piu la pagina fornitore/prodotti puo ora: + +- cercare Amazon dalla UI; +- salvare un singolo candidato Amazon nel catalogo interno; +- salvare in massa tutti i risultati della query corrente nel catalogo del fornitore. + ## Variabili ambiente da valorizzare Queste chiavi vanno messe solo in `.env` locale o staging, mai nel repository: @@ -69,7 +75,91 @@ ## Dati che il sync salva - ASIN come `ProductIdentifier` primario, se presente; - EAN come `ProductIdentifier`, se presente; - immagine Amazon come `ProductMedia` remoto; -- referral URL con `tag=netgescon-21` se il tag e configurato. +- referral URL con `tag=netgescon-21` se il tag e configurato; +- snapshot in `products.meta.amazon` con almeno ASIN, EAN, titolo, marca, modello se presente, detail URL, referral URL, prezzo, listino, valuta, disponibilita, immagini, feature points e timestamp sync; +- snapshot offerta in `product_offers.meta.payload` con i dati prezzo e contesto di sync, inclusa la query di ricerca quando l'import nasce dalla UI; +- `products.description` popolata con i feature points Amazon quando il prodotto interno e ancora vuoto; +- `products.model` valorizzato quando Amazon espone un modello o gruppo prodotto utile. + +## Workflow operativo consigliato + +1. Apri la pagina catalogo fornitore. +2. Cerca il prodotto su Amazon con una query libera o con ASIN/EAN. +3. Se il risultato e corretto puoi scegliere: + - `Salva candidato` per importare solo quella scheda; + - `Salva tutti i risultati nel catalogo` per costruire rapidamente un mini catalogo interno da una query. +4. I prezzi Amazon salvati restano disponibili nelle offerte prodotto e possono essere riaggiornati con una nuova sync quando serve. +5. Le procedure interne devono leggere prima il catalogo interno NetGescon e le relative `product_offers`, senza interrogare Amazon ogni volta. + +Questo consente di usare il catalogo Amazon come sorgente di acquisizione e aggiornamento, ma di lavorare poi sempre su dati interni gia normalizzati e riusabili. + +## Pagina pubblica di abilitazione e demo + +La pagina pubblica `public/amazon` non e piu solo un apri-link con tag affiliato. +Ora serve anche come vetrina condivisibile per mostrare: + +- offerte Amazon gia importate nel catalogo interno; +- stato delle integrazioni in corso; +- prerequisiti per collegare il canale Telegram offerte; +- limiti attuali su messaggi privati Telegram, carrello Amazon, fattura e provenienza venditore. + +Questo aiuta a rendere visibile il lavoro a chi ha contribuito all abilitazione API e a tenere allineati partner e operatori senza entrare subito nel pannello interno. + +La pagina ora puo essere usata anche come mini sito vetrina per brand selezionati: + +- filtro brand via query string, per esempio `public/amazon?brand=EPSON`; +- cards prodotto piu compatte, pensate per stare bene affiancate in griglia; +- cards prodotto con gallery immagini quando Amazon o `product_media` le rendono disponibili; +- esposizione pubblica del codice produttore, EAN o UPC al posto dell ASIN quando disponibili; +- import del codice produttore da `itemInfo.manufactureInfo.itemPartNumber` quando Amazon lo restituisce; +- pubblicazione governata dal flag interno `meta.catalog.public_showcase`, cosi il sito espone solo i prodotti scelti esplicitamente; +- esposizione del solo prezzo Amazon pubblico nella pagina pubblica, senza mostrare il prezzo interno del gestionale; +- casi guida gia riallineati con payload ricco Amazon per EPSON `B0CPPKNRLJ`, Samsung `B0B9C3ZVHR` e Samsung `B087DFLF9S`, con immagini, brand, codice produttore ed EAN quando disponibili. + +Se il payload Creators non espone ancora immagini o prezzo, la pagina continua a funzionare ma mostra il dato come da completare. Questo evita di bloccare il mini sito mentre continuiamo le prove API. + +La pagina include anche un carrello interno lato browser: raccoglie i prodotti selezionati nel mini sito e apre le relative schede referral Amazon. Non corrisponde ancora a un carrello Amazon ufficiale condiviso, ma prepara il passaggio dal sito NetGescon all acquisto finale su Amazon. + +## Telegram canale offerte + +Per pubblicare le offerte Amazon sul canale Telegram servono: + +- un bot Telegram creato con BotFather; +- il bot aggiunto come admin del canale target; +- `TELEGRAM_OFFERS_BOT_TOKEN` valorizzato; +- `TELEGRAM_OFFERS_CHAT_ID` valorizzato con id o riferimento compatibile del canale; +- `TELEGRAM_OFFERS_CHANNEL_NAME` valorizzato per mostrare il canale in UI e documentazione; +- opzionalmente `TELEGRAM_OFFERS_WEBHOOK_SECRET` se si usa anche la ricezione webhook. + +Nota importante: un bot non puo inviare messaggi privati a un semplice iscritto del canale se quell utente non ha prima aperto una chat diretta col bot o inviato almeno un comando come `/start`. + +Per il primo invio operativo c e ora anche un comando Artisan dedicato: + +```bash +php artisan offers:publish-telegram 2917 --dry-run +php artisan offers:publish-telegram B0B9C3ZVHR +php artisan offers:publish-telegram ART-003315 --chat=@nethomestorelettronica +``` + +Il comando cerca una offerta `amazon_creators_api` per ID offerta, ASIN o `internal_code` prodotto e pubblica titolo, prezzo, disponibilita e referral URL sul canale configurato. + +## Carrello Amazon, fattura e priorita venditori + +- Carrello interno NetGescon: fattibile, possiamo costruirlo noi con selezione multipla e riepilogo. +- Passaggio diretto di piu prodotti al carrello Amazon: da trattare come non garantito finche non troviamo un endpoint o un meccanismo ufficiale stabile; i link referral di prodotto restano la via affidabile. +- Fattura: va verificato se il segnale e davvero presente e affidabile nei dati Amazon disponibili. +- Priorita venditori italiani, europei, cinesi: possibile solo se otteniamo e persistiamo un dato attendibile su merchant o paese venditore. + +## KB operativo per la macchina vetrina prodotti + +Per preparare una macchina dedicata al sito vetrina prodotti Amazon servono almeno questi passaggi: + +1. configurare le variabili `AMAZON_CREATORS_*`, `AMAZON_REFERRAL_TAG` e, se serve, `TELEGRAM_OFFERS_*`; +2. importare i prodotti candidati dal catalogo fornitore interno; +3. usare `public/amazon?brand=EPSON` o altri brand come vetrina iniziale del sito; +4. marcare come pubblici solo i prodotti scelti impostando `meta.catalog.public_showcase = true`; +5. pubblicare solo prodotti e brand selezionati, lasciando l acquisto finale su Amazon tramite referral URL o carrello interno di handoff; +6. tenere l eventuale confronto economico col fornitore come informazione interna di NetGescon, senza esporlo nella pagina pubblica. ## Cosa serve per il canale Telegram offerte diff --git a/resources/views/public/amazon-referral.blade.php b/resources/views/public/amazon-referral.blade.php index 6a1b205..e3cbeef 100644 --- a/resources/views/public/amazon-referral.blade.php +++ b/resources/views/public/amazon-referral.blade.php @@ -4,6 +4,7 @@ Amazon con Tag NetGescon + @include('components.google-analytics') @@ -182,20 +552,43 @@
Tag attivo: {{ $associateTag }}

Apri Amazon con il link affiliato NetGescon

-

Questa pagina pubblica serve ad aprire Amazon con il tag corretto. Puoi cercare un prodotto, incollare un ASIN oppure ripulire un link Amazon già esistente aggiungendo automaticamente il tag.

+

Questa pagina pubblica serve sia ad aprire Amazon con il tag corretto sia a mostrare, in modo visibile e condivisibile, le prove che stiamo costruendo su catalogo interno, offerte, Telegram e prossime integrazioni.

In qualità di Affiliato Amazon io ricevo un guadagno dagli acquisti idonei.

-

Come usarla

+

Stato laboratorio

    -
  • Se conosci il nome del prodotto usa la ricerca per testo.
  • -
  • Se hai già il codice ASIN, incollalo e apri direttamente la scheda prodotto.
  • -
  • Se ti mandano un link Amazon, incollalo qui e il sistema aggiunge il tag automaticamente.
  • +
  • Qui mostriamo le prove reali che stiamo portando nel catalogo interno.
  • +
  • Le offerte Amazon importate restano riusabili nelle procedure NetGescon.
  • +
  • Telegram, carrello Amazon, fattura e provenienza venditore sono esposti con stato reale: attivo, pronto o da validare.
+
+
+
Offerte Amazon salvate
+
{{ $catalogStats['offers_count'] }}
+

Offerte Amazon attive memorizzate nel catalogo interno.

+
+
+
Prodotti collegati
+
{{ $catalogStats['products_count'] }}
+

Prodotti NetGescon che hanno gia un aggancio Amazon persistito.

+
+
+
Canale Amazon
+
{{ $associateTag }}
+

Tag affiliato applicato ai link pubblici e ai referral interni.

+
+
+
Brand {{ $selectedBrand }}
+
{{ $catalogStats['brand_products_count'] }}
+

Prodotti del brand selezionato gia disponibili nella vetrina interna.

+
+
+

Ricerca per testo

@@ -237,6 +630,390 @@ Apri B0DN6PD8QS con tag NetGescon
Link base da far girare: {{ route('public.amazon.index') }}
+ +
+

Mini sito brand selezionati

+

Questa pagina puo gia funzionare come vetrina del tuo sito: l'utente vede prima i prodotti selezionati nel catalogo NetGescon, poi decide se aprire Amazon tramite il referral.

+
+ EPSON + SAMSUNG +
+
+ + @if(count($featuredComparisons) > 0) +
+

Prodotti in evidenza

+

Qui mettiamo le schede piu' curate da usare come vetrina pubblica: immagini pulite, codice prodotto ufficiale e uscita finale verso Amazon.

+
+ @foreach($featuredComparisons as $item) +
+
+
+ @if($item['image_url'] !== '') + {{ $item['title'] }} + @else + Nessuna immagine + @endif +
+ @if(count($item['image_urls']) > 1) +
+ @foreach(array_slice($item['image_urls'], 0, 4) as $imageUrl) + miniatura {{ $item['asin'] }} + @endforeach +
+ @endif +
+
+

{{ $item['title'] }}

+
+ Brand {{ $item['brand'] }} + @if(($item['reference']['label'] ?? '') !== '' && ($item['reference']['value'] ?? '') !== '') + {{ $item['reference']['label'] }} {{ $item['reference']['value'] }} + @endif +
+
+ @if($item['amazon_public_price'] !== null) + {{ number_format((float) $item['amazon_public_price'], 2, ',', '.') }} {{ $item['currency'] }} + @else + Prezzo Amazon in aggiornamento + @endif +
+ @if($item['availability'] !== '') +

Disponibilita Amazon: {{ $item['availability'] }}

+ @endif +

{{ $item['note'] }}

+
+ @if($item['referral_url'] !== '') + + @endif + @if($item['referral_url'] !== '') + Vai ad Amazon + @elseif($item['detail_url'] !== '') + Apri scheda Amazon + @endif +
+
+
+ @endforeach +
+
+ @endif + + @if(count($brandCatalog) > 0) +
+

Catalogo {{ $selectedBrand }} sul tuo sito

+

Questa sezione e' il primo nucleo del mini sito: schede piu' compatte, leggibili e adatte a stare affiancate anche quando il catalogo cresce.

+
+ @foreach($brandCatalog as $offer) +
+
+
+ @if($offer['image_url'] !== '') + {{ $offer['title'] }} + @else + Nessuna immagine + @endif +
+ @if(count($offer['image_urls']) > 1) +
+ @foreach(array_slice($offer['image_urls'], 0, 4) as $imageUrl) + miniatura {{ $offer['asin'] }} + @endforeach +
+ @endif +
+
+

{{ $offer['title'] }}

+
+ @if($offer['internal_code'] !== '') + Catalogo {{ $offer['internal_code'] }} + @endif + Brand {{ $offer['brand'] }} + @if(($offer['reference']['label'] ?? '') !== '' && ($offer['reference']['value'] ?? '') !== '') + {{ $offer['reference']['label'] }} {{ $offer['reference']['value'] }} + @endif +
+
{{ $offer['amazon_price'] !== null ? number_format((float) $offer['amazon_price'], 2, ',', '.') . ' ' . $offer['currency'] : 'Prezzo Amazon in aggiornamento' }}
+ @if($offer['availability'] !== '') +

Disponibilita: {{ $offer['availability'] }}

+ @endif + @if(count($offer['feature_points']) > 0) +
    + @foreach(array_slice($offer['feature_points'], 0, 2) as $point) +
  • {{ $point }}
  • + @endforeach +
+ @endif +
+ @if($offer['referral_url'] !== '') + + @endif + @if($offer['referral_url'] !== '') + Vai ad Amazon + @endif +
+
+
+ @endforeach +
+
+ @endif + +
+

Esempi live dal catalogo interno

+

Qui compaiono le offerte Amazon gia importate e rese disponibili dentro NetGescon. Sono il ponte fra la ricerca API e l'utilizzo operativo interno.

+ + @if(count($exampleOffers) > 0) +
+ @foreach($exampleOffers as $offer) +
+
+
+ @if($offer['image_url'] !== '') + {{ $offer['title'] }} + @else + Nessuna immagine + @endif +
+ @if(count($offer['image_urls']) > 1) +
+ @foreach(array_slice($offer['image_urls'], 0, 4) as $imageUrl) + miniatura {{ $offer['asin'] }} + @endforeach +
+ @endif +
+
+

{{ $offer['title'] }}

+
+ @if($offer['internal_code'] !== '') + Catalogo {{ $offer['internal_code'] }} + @endif + @if($offer['brand'] !== '') + Marca {{ $offer['brand'] }} + @endif + @if(($offer['reference_label'] ?? '') !== '' && ($offer['reference_value'] ?? '') !== '') + {{ $offer['reference_label'] }} {{ $offer['reference_value'] }} + @endif + @if($offer['search_query'] !== '') + Query {{ $offer['search_query'] }} + @endif +
+ +
+ @if($offer['price_amount'] !== null) + {{ number_format((float) $offer['price_amount'], 2, ',', '.') }} {{ $offer['currency'] !== '' ? $offer['currency'] : 'EUR' }} + @else + Prezzo Amazon in aggiornamento + @endif +
+ + @if($offer['availability'] !== '') +

Disponibilita: {{ $offer['availability'] }}

+ @endif + + @if(count($offer['feature_points']) > 0) +
    + @foreach(array_slice($offer['feature_points'], 0, 2) as $point) +
  • {{ $point }}
  • + @endforeach +
+ @endif + +
+ @if($offer['referral_url'] !== '') + + @endif + @if($offer['referral_url'] !== '') + Apri referral Amazon + @endif + @if($offer['detail_url'] !== '' && $offer['detail_url'] !== $offer['referral_url']) + Apri dettaglio + @endif +
+ +

Ultimo sync: {{ $offer['synced_at'] !== '' ? $offer['synced_at'] : 'n/d' }} · Sorgente: {{ $offer['sync_source'] !== '' ? $offer['sync_source'] : 'amazon_creators_api' }}

+
+
+ @endforeach +
+ @else +
Nessuna offerta Amazon e' ancora stata importata nel catalogo interno.
+ @endif +
+ +
+

Cosa stiamo facendo adesso

+

Questi blocchi sono pensati anche per rendere partecipe chi ha contribuito all'abilitazione delle API Amazon: mostrano cosa e' gia operativo e cosa stiamo ancora validando.

+
+ @foreach($capabilityCards as $card) +
+
{{ $card['status'] }}
+

{{ $card['title'] }}

+

{{ $card['body'] }}

+
+ @endforeach +
+
+ +
+
+

Canale Telegram offerte

+

Per collegare il canale {{ '@nethomestorelettronica' }} e iniziare a pubblicare le offerte Amazon servono pochi passaggi, ma vanno fatti bene lato Telegram e lato ambiente.

+
+ @foreach($telegramSetup as $item) +
+ {{ $item['label'] }} +

{{ $item['value'] }}

+
+ @endforeach +
+
TELEGRAM_OFFERS_BOT_TOKEN= +TELEGRAM_OFFERS_CHAT_ID= +TELEGRAM_OFFERS_CHANNEL_NAME=@nethomestorelettronica +TELEGRAM_OFFERS_WEBHOOK_SECRET=
+
+ +
+

Vincoli da conoscere subito

+

Qui chiarisco le domande chiave aperte, cosi' evitiamo di progettare contro un vincolo della piattaforma.

+
+ @foreach($evaluationNotes as $note) +
+ {{ $note['title'] }} +

{{ $note['body'] }}

+
+ @endforeach +
+
+
+ +
+

KB operativo di questa prova

+

Questi sono i passaggi da riversare nel KB per replicare la stessa configurazione su una macchina dedicata al sito vetrina prodotti Amazon.

+
+ @foreach($kbChecklist as $item) +
+ {{ $item['title'] }} +

{{ $item['body'] }}

+
+ @endforeach +
+
+ +
+
+
+

Carrello interno pronto per Amazon

+

Qui raccogli i prodotti selezionati nel tuo sito. Il checkout finale apre le schede referral Amazon dei prodotti scelti, cosi' il cliente passa dal tuo sito ad Amazon solo all'ultimo step.

+
+
Nessun prodotto selezionato.
+
+
+
+
Carrello
+
0
+

Prodotti selezionati dal mini sito.

+
+ + +
+
+
+
+ \ No newline at end of file