Refine Amazon public storefront showcase

This commit is contained in:
michele 2026-05-24 09:52:35 +00:00
parent 1c6611addb
commit c4f8c827b4
4 changed files with 1643 additions and 73 deletions

View File

@ -1,24 +1,134 @@
<?php <?php
namespace App\Http\Controllers\PublicAccess; namespace App\Http\Controllers\PublicAccess;
use App\Models\Product;
use App\Models\ProductMedia;
use App\Models\ProductOffer;
use App\Services\Catalog\AmazonCreatorsApiService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\View\View; use Illuminate\View\View;
class AmazonReferralController class AmazonReferralController
{ {
public function __construct(
private readonly AmazonCreatorsApiService $amazonCreatorsApiService,
) {
}
public function index(Request $request): View public function index(Request $request): View
{ {
$selectedBrand = Str::upper(trim((string) $request->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', [ return view('public.amazon-referral', [
'associateTag' => $this->associateTag(), 'associateTag' => $this->associateTag(),
'defaultQuery' => trim((string) $request->query('q', '')), 'defaultQuery' => trim((string) $request->query('q', '')),
'defaultAsin' => trim((string) $request->query('asin', '')), 'defaultAsin' => trim((string) $request->query('asin', '')),
'defaultUrl' => trim((string) $request->query('url', '')), '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<int, ProductOffer> */
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<int, string> */
private function featuredShowcaseAsins(): array
{
return [
'B0CPPKNRLJ',
'B0B9C3ZVHR',
'B087DFLF9S',
];
}
public function go(Request $request): RedirectResponse public function go(Request $request): RedirectResponse
{ {
$asin = $this->normalizeAsin((string) $request->query('asin', '')); $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; return preg_match('/^[A-Z0-9]{10}$/', $value) ? $value : null;
} }
}
/** @return array<int, array<string, string>> */
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<int, array<string, string>> */
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<int, array<string, string>> */
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<int, array<string, mixed>> */
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<int, array<string, mixed>> */
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<string, mixed> */
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<string, mixed> $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<string, mixed> $payload
* @return array<int, string>
*/
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<int, array<string, string>> */
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);
}
}

View File

@ -33,27 +33,39 @@ public function syncProduct(Product $product, array $options = []): array
throw new RuntimeException('La risposta Amazon non contiene un ASIN utilizzabile.'); throw new RuntimeException('La risposta Amazon non contiene un ASIN utilizzabile.');
} }
$title = $this->extractTitle($item) ?: (string) ($product->name ?? 'Prodotto Amazon'); $title = $this->extractTitle($item) ?: (string) ($product->name ?? 'Prodotto Amazon');
$marketplace = trim((string) ($options['marketplace'] ?? $this->apiService->getMarketplace())) ?: $this->apiService->getMarketplace(); $marketplace = trim((string) ($options['marketplace'] ?? $this->apiService->getMarketplace())) ?: $this->apiService->getMarketplace();
$associateTag = trim((string) ($options['associate_tag'] ?? $this->apiService->getAssociateTag() ?? '')) ?: null; $associateTag = trim((string) ($options['associate_tag'] ?? $this->apiService->getAssociateTag() ?? '')) ?: null;
$detailUrl = $this->extractDetailPageUrl($item); $detailUrl = $this->extractDetailPageUrl($item);
$referralUrl = $detailUrl !== null ? $this->appendAssociateTag($detailUrl, $associateTag) : $this->productOfferService->buildAmazonReferralUrl($product, $associateTag, $this->mapMarketplaceToLocale($marketplace)); $referralUrl = $detailUrl !== null ? $this->appendAssociateTag($detailUrl, $associateTag) : $this->productOfferService->buildAmazonReferralUrl($product, $associateTag, $this->mapMarketplaceToLocale($marketplace));
$price = $this->extractPriceAmount($item); $price = $this->extractPriceAmount($item);
$compareAt = $this->extractCompareAtAmount($item); $compareAt = $this->extractCompareAtAmount($item);
$currency = $this->extractCurrency($item) ?? 'EUR'; $currency = $this->extractCurrency($item) ?? 'EUR';
$availability = $this->extractAvailability($item) ?? 'catalog_synced'; $availability = $this->extractAvailability($item) ?? 'catalog_synced';
$externalId = $asin; $externalId = $asin;
$ean = $this->extractEan($item); $ean = $this->extractEan($item);
$imageUrl = $this->extractPrimaryImageUrl($item); $upc = $this->extractUpc($item);
$payloadSummary = [ $manufacturerCode = $this->extractManufacturerPartNumber($item);
'asin' => $asin, $imageUrls = $this->extractImageUrls($item);
'title' => $title, $imageUrl = $imageUrls[0] ?? null;
'marketplace' => $marketplace, $payloadSummary = $this->buildAmazonSnapshot($item, [
'detail_url' => $detailUrl, 'asin' => $asin,
'image_url' => $imageUrl, 'ean' => $ean,
'price_amount' => $price, 'upc' => $upc,
'currency' => $currency, '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; $identifiersCreated = 0;
$mediaCreated = 0; $mediaCreated = 0;
@ -64,9 +76,24 @@ public function syncProduct(Product $product, array $options = []): array
if ($ean !== null) { if ($ean !== null) {
$identifiersCreated += $this->upsertIdentifier($product, 'ean', $ean, 'amazon_creators_api', false) ? 1 : 0; $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) { foreach (array_values(array_slice($imageUrls, 0, 12)) as $index => $remoteUrl) {
$mediaCreated += $this->upsertRemoteMedia($product, $imageUrl, $title) ? 1 : 0; $mediaCreated += $this->upsertRemoteMedia(
$product,
$remoteUrl,
$title,
$index,
[
'provider' => 'amazon_creators_api',
'role' => $index === 0 ? 'primary' : 'gallery',
]
) ? 1 : 0;
} }
$offer = $this->productOfferService->syncOffer($product, [ $offer = $this->productOfferService->syncOffer($product, [
@ -97,11 +124,14 @@ public function syncProduct(Product $product, array $options = []): array
'detail_url' => $detailUrl, 'detail_url' => $detailUrl,
'referral_url' => $referralUrl, 'referral_url' => $referralUrl,
'image_url' => $imageUrl, 'image_url' => $imageUrl,
'image_urls' => $imageUrls,
'price_amount' => $price, 'price_amount' => $price,
'price_compare_at' => $compareAt, 'price_compare_at' => $compareAt,
'currency' => $currency, 'currency' => $currency,
'availability' => $availability, 'availability' => $availability,
'ean' => $ean, 'ean' => $ean,
'upc' => $upc,
'manufacturer_code' => $manufacturerCode,
'identifiers_created' => $identifiersCreated, 'identifiers_created' => $identifiersCreated,
'media_created' => $mediaCreated, 'media_created' => $mediaCreated,
'offer_id' => $offer instanceof ProductOffer ? (int) $offer->id : null, '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) return $this->extractItems($response)
->take(max(1, $limit)) ->take(max(1, $limit))
->map(function (array $item): array { ->map(function (array $item): array {
$asin = $this->extractAsin($item); $asin = $this->extractAsin($item);
$ean = $this->extractEan($item); $ean = $this->extractEan($item);
$existingProduct = $this->findExistingProduct($asin, $ean); $upc = $this->extractUpc($item);
$detailUrl = $this->extractDetailPageUrl($item); $manufacturerCode = $this->extractManufacturerPartNumber($item);
$associateTag = $this->apiService->getAssociateTag(); $existingProduct = $this->findExistingProduct($asin, $ean, $upc, $manufacturerCode);
$detailUrl = $this->extractDetailPageUrl($item);
$associateTag = $this->apiService->getAssociateTag();
return [ return [
'asin' => $asin, 'asin' => $asin,
'ean' => $ean, 'ean' => $ean,
'upc' => $upc,
'manufacturer_code' => $manufacturerCode,
'title' => $this->extractTitle($item) ?: 'Prodotto Amazon', 'title' => $this->extractTitle($item) ?: 'Prodotto Amazon',
'brand' => $this->extractBrand($item), 'brand' => $this->extractBrand($item),
'price_amount' => $this->extractPriceAmount($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<string,mixed>, created: bool} */ /** @return array{product: Product, sync: array<string,mixed>, created: bool} */
public function importItemForSupplier(Fornitore $fornitore, array $item, array $options = []): array public function importItemForSupplier(Fornitore $fornitore, array $item, array $options = []): array
{ {
$item = $this->enrichItem($item, $options);
$asin = $this->extractAsin($item); $asin = $this->extractAsin($item);
if ($asin === null) { if ($asin === null) {
throw new RuntimeException('Il risultato Amazon selezionato non contiene un ASIN valido.'); throw new RuntimeException('Il risultato Amazon selezionato non contiene un ASIN valido.');
} }
$ean = $this->extractEan($item); $ean = $this->extractEan($item);
$title = $this->extractTitle($item) ?: 'Prodotto Amazon'; $upc = $this->extractUpc($item);
$brand = $this->extractBrand($item); $manufacturerCode = $this->extractManufacturerPartNumber($item);
$product = $this->findExistingProduct($asin, $ean); $title = $this->extractTitle($item) ?: 'Prodotto Amazon';
$created = false; $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) { if (! $product instanceof Product) {
$product = Product::query()->create([ $product = Product::query()->create([
@ -175,46 +235,48 @@ public function importItemForSupplier(Fornitore $fornitore, array $item, array $
'canonical_key' => $this->buildCanonicalKey($title, $asin, $ean), 'canonical_key' => $this->buildCanonicalKey($title, $asin, $ean),
'name' => $title, 'name' => $title,
'brand' => $brand, 'brand' => $brand,
'model' => null, 'model' => $model,
'description' => null, 'description' => $description,
'track_serials' => false, 'track_serials' => false,
'is_active' => true, 'is_active' => true,
'meta' => [ 'meta' => [
'source' => 'amazon_creators_api', 'source' => 'amazon_creators_api',
'catalog' => [ 'catalog' => [
'publication_mode' => 'internal_only', 'publication_mode' => 'internal_only',
'public_showcase' => false,
], ],
'verification' => [ 'verification' => [
'status' => 'pending_review', 'status' => 'pending_review',
'channel' => 'amazon_catalog_search_ui', 'channel' => 'amazon_catalog_search_ui',
'updated_at' => now()->toIso8601String(), 'updated_at' => now()->toIso8601String(),
], ],
'amazon' => [ 'amazon' => $snapshot,
'asin' => $asin,
'ean' => $ean,
'title' => $title,
],
], ],
]); ]);
$created = true; $created = true;
} else { } else {
$meta = is_array($product->meta ?? null) ? $product->meta : []; $meta = is_array($product->meta ?? null) ? $product->meta : [];
$meta['source'] = $meta['source'] ?? 'amazon_creators_api'; $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'] = [ $meta['verification'] = [
'status' => 'pending_review', 'status' => 'pending_review',
'channel' => 'amazon_catalog_search_ui', 'channel' => 'amazon_catalog_search_ui',
'updated_at' => now()->toIso8601String(), 'updated_at' => now()->toIso8601String(),
]; ];
$meta['amazon'] = array_filter([ $meta['amazon'] = array_filter(array_replace(
'asin' => $asin, is_array($meta['amazon'] ?? null) ? $meta['amazon'] : [],
'ean' => $ean, $snapshot
'title' => $title, ), static fn(mixed $value): bool => $value !== null && $value !== '');
], static fn(mixed $value): bool => $value !== null && $value !== '');
$product->fill([ $product->fill([
'default_fornitore_id' => $product->default_fornitore_id ?: (int) $fornitore->id, 'default_fornitore_id' => $product->default_fornitore_id ?: (int) $fornitore->id,
'name' => trim((string) $product->name) !== '' ? $product->name : $title, 'name' => trim((string) $product->name) !== '' ? $product->name : $title,
'brand' => trim((string) ($product->brand ?? '')) !== '' ? $product->brand : $brand, '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, 'meta' => $meta,
]); ]);
$product->save(); $product->save();
@ -223,8 +285,9 @@ public function importItemForSupplier(Fornitore $fornitore, array $item, array $
$sync = $this->syncProduct($product, [ $sync = $this->syncProduct($product, [
'item' => $item, 'item' => $item,
'source' => (string) ($options['source'] ?? 'amazon_catalog_search_ui'), 'source' => (string) ($options['source'] ?? 'amazon_catalog_search_ui'),
'marketplace' => (string) ($options['marketplace'] ?? $this->apiService->getMarketplace()), 'marketplace' => $marketplace,
'associate_tag' => (string) ($options['associate_tag'] ?? ($this->apiService->getAssociateTag() ?? '')), 'associate_tag' => (string) ($associateTag ?? ''),
'search_query' => trim((string) ($options['search_query'] ?? '')),
]); ]);
return [ return [
@ -234,6 +297,50 @@ public function importItemForSupplier(Fornitore $fornitore, array $item, array $
]; ];
} }
/** @param array<int, array<string, mixed>> $rows
* @return array<string, mixed>
*/
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<string, mixed> */ /** @return array<string, mixed> */
private function resolveItemPayload(Product $product, array $options): array private function resolveItemPayload(Product $product, array $options): array
{ {
@ -273,6 +380,9 @@ private function extractItems(array $payload): Collection
{ {
foreach ([ foreach ([
'items', 'items',
'itemsResult.items',
'searchResult.items',
'getItemsResult.items',
'ItemsResult.Items', 'ItemsResult.Items',
'SearchResult.Items', 'SearchResult.Items',
'GetItemsResult.Items', 'GetItemsResult.Items',
@ -282,7 +392,7 @@ private function extractItems(array $payload): Collection
] as $path) { ] as $path) {
$items = data_get($payload, $path); $items = data_get($payload, $path);
if (is_array($items)) { 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 ([ foreach ([
'items', 'items',
'itemsResult.items',
'searchResult.items',
'getItemsResult.items',
'ItemsResult.Items', 'ItemsResult.Items',
'SearchResult.Items', 'SearchResult.Items',
'GetItemsResult.Items', 'GetItemsResult.Items',
@ -329,6 +442,7 @@ private function extractEan(array $item): ?string
'EAN', 'EAN',
'ExternalIds.EANs.DisplayValues.0', 'ExternalIds.EANs.DisplayValues.0',
'externalIds.eans.0', 'externalIds.eans.0',
'itemInfo.externalIds.eans.displayValues.0',
'identifiers.ean', 'identifiers.ean',
] as $path) { ] as $path) {
$value = preg_replace('/\s+/', '', trim((string) data_get($item, $path, ''))); $value = preg_replace('/\s+/', '', trim((string) data_get($item, $path, '')));
@ -340,6 +454,24 @@ private function extractEan(array $item): ?string
return null; 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 private function extractTitle(array $item): ?string
{ {
foreach (['title', 'Title', 'ItemInfo.Title.DisplayValue', 'itemInfo.title.displayValue'] as $path) { foreach (['title', 'Title', 'ItemInfo.Title.DisplayValue', 'itemInfo.title.displayValue'] as $path) {
@ -364,6 +496,43 @@ private function extractBrand(array $item): ?string
return null; 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 private function extractDetailPageUrl(array $item): ?string
{ {
foreach (['detailPageURL', 'DetailPageURL', 'detailPageUrl', 'url'] as $path) { foreach (['detailPageURL', 'DetailPageURL', 'detailPageUrl', 'url'] as $path) {
@ -378,22 +547,40 @@ private function extractDetailPageUrl(array $item): ?string
private function extractPrimaryImageUrl(array $item): ?string private function extractPrimaryImageUrl(array $item): ?string
{ {
return $this->extractImageUrls($item)[0] ?? null;
}
/** @return array<int, string> */
private function extractImageUrls(array $item): array
{
$urls = [];
foreach ([ foreach ([
'Images.Primary.Large.URL', 'Images.Primary.Large.URL',
'Images.Primary.Medium.URL', 'Images.Primary.Medium.URL',
'Images.Primary.Small.URL', 'Images.Primary.Small.URL',
'Images.Variants.*.Large.URL',
'Images.Variants.*.Medium.URL',
'Images.Variants.*.Small.URL',
'images.primary.large.url', 'images.primary.large.url',
'images.primary.medium.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', 'image',
] as $path) { ] as $path) {
$value = trim((string) data_get($item, $path, '')); $value = data_get($item, $path);
if ($value !== '') { foreach (Arr::wrap($value) as $candidate) {
return $value; $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 private function extractPriceAmount(array $item): ?float
@ -401,6 +588,7 @@ private function extractPriceAmount(array $item): ?float
foreach ([ foreach ([
'Offers.Listings.0.Price.Amount', 'Offers.Listings.0.Price.Amount',
'Offers.Summaries.0.LowestPrice.Amount', 'Offers.Summaries.0.LowestPrice.Amount',
'offersV2.listings.0.price.money.amount',
'offers.listings.0.price.amount', 'offers.listings.0.price.amount',
'offers.summaries.0.lowestPrice.amount', 'offers.summaries.0.lowestPrice.amount',
'price.amount', 'price.amount',
@ -418,6 +606,7 @@ private function extractCompareAtAmount(array $item): ?float
{ {
foreach ([ foreach ([
'Offers.Listings.0.SavingBasis.Amount', 'Offers.Listings.0.SavingBasis.Amount',
'offersV2.listings.0.price.savingBasis.money.amount',
'offers.listings.0.savingBasis.amount', 'offers.listings.0.savingBasis.amount',
'price.compareAt.amount', 'price.compareAt.amount',
] as $path) { ] as $path) {
@ -435,6 +624,7 @@ private function extractCurrency(array $item): ?string
foreach ([ foreach ([
'Offers.Listings.0.Price.Currency', 'Offers.Listings.0.Price.Currency',
'Offers.Summaries.0.LowestPrice.Currency', 'Offers.Summaries.0.LowestPrice.Currency',
'offersV2.listings.0.price.money.currency',
'offers.listings.0.price.currency', 'offers.listings.0.price.currency',
'offers.summaries.0.lowestPrice.currency', 'offers.summaries.0.lowestPrice.currency',
'price.currency', 'price.currency',
@ -464,6 +654,63 @@ private function extractAvailability(array $item): ?string
return null; return null;
} }
/** @return array<int, string> */
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<string, mixed> $overrides
* @return array<string, mixed>
*/
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 private function appendAssociateTag(string $url, ?string $associateTag): string
{ {
$associateTag = $associateTag !== null ? trim($associateTag) : ''; $associateTag = $associateTag !== null ? trim($associateTag) : '';
@ -513,11 +760,13 @@ private function buildSearchQuery(Product $product): string
return Str::limit(implode(' ', $parts), 120, ''); 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([ $codes = array_values(array_filter([
$this->normalizeCode($asin), $this->normalizeCode($asin),
$this->normalizeCode($ean), $this->normalizeCode($ean),
$this->normalizeCode($upc),
$this->normalizeCode($manufacturerCode),
])); ]));
if ($codes === []) { if ($codes === []) {
@ -532,6 +781,37 @@ private function findExistingProduct(?string $asin, ?string $ean): ?Product
return $productId ? Product::query()->find((int) $productId) : null; return $productId ? Product::query()->find((int) $productId) : null;
} }
/** @param array<string, mixed> $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 private function buildCanonicalKey(string $title, ?string $asin, ?string $ean): string
{ {
$base = Str::slug(Str::limit($title, 80, '')); $base = Str::slug(Str::limit($title, 80, ''));
@ -587,7 +867,7 @@ private function upsertIdentifier(Product $product, string $type, string $value,
return $created; 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); $url = trim($url);
if ($url === '' || ! str_starts_with($url, 'http')) { if ($url === '' || ! str_starts_with($url, 'http')) {
@ -601,12 +881,13 @@ private function upsertRemoteMedia(Product $product, string $url, string $title)
'path' => $url, 'path' => $url,
]); ]);
$created = ! $media->exists; $created = ! $media->exists;
$media->title = $title; $media->title = $title;
$media->meta = [ $media->sort_order = max(0, $sortOrder);
$media->meta = array_replace([
'provider' => 'amazon_creators_api', 'provider' => 'amazon_creators_api',
'remote' => true, 'remote' => true,
]; ], $meta);
$media->save(); $media->save();
return $created; return $created;

View File

@ -7,9 +7,15 @@ ## Stato attuale nel codice
- configurazione ambiente per Amazon Creators API in `config/services.php`; - configurazione ambiente per Amazon Creators API in `config/services.php`;
- servizio `AmazonCreatorsApiService` per token OAuth e chiamate configurabili; - 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. - 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 ## Variabili ambiente da valorizzare
Queste chiavi vanno messe solo in `.env` locale o staging, mai nel repository: 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; - ASIN come `ProductIdentifier` primario, se presente;
- EAN come `ProductIdentifier`, se presente; - EAN come `ProductIdentifier`, se presente;
- immagine Amazon come `ProductMedia` remoto; - 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 ## Cosa serve per il canale Telegram offerte

View File

@ -4,6 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Amazon con Tag NetGescon</title> <title>Amazon con Tag NetGescon</title>
@include('components.google-analytics')
<style> <style>
:root { :root {
--bg: #f3efe7; --bg: #f3efe7;
@ -13,6 +14,10 @@
--line: rgba(31, 41, 55, 0.12); --line: rgba(31, 41, 55, 0.12);
--accent: #c66a1f; --accent: #c66a1f;
--accent-dark: #8a4313; --accent-dark: #8a4313;
--olive: #276357;
--olive-soft: rgba(39, 99, 87, 0.12);
--amber-soft: rgba(198, 106, 31, 0.12);
--rose-soft: rgba(163, 76, 76, 0.12);
--shadow: 0 20px 50px rgba(82, 52, 24, 0.14); --shadow: 0 20px 50px rgba(82, 52, 24, 0.14);
} }
@ -82,6 +87,31 @@
margin-top: 24px; margin-top: 24px;
} }
.stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
margin-top: 20px;
}
.stat-card {
padding: 20px 22px;
}
.eyebrow {
margin: 0 0 6px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.78rem;
}
.stat-value {
font-size: clamp(1.6rem, 2.6vw, 2.4rem);
font-weight: 700;
line-height: 1;
}
.form-card h2, .form-card h2,
.tips h2 { .tips h2 {
margin: 0 0 14px; margin: 0 0 14px;
@ -168,11 +198,351 @@
font-weight: 700; font-weight: 700;
} }
.section-title {
margin: 0 0 10px;
font-size: 1.5rem;
}
.section-copy {
margin-bottom: 18px;
}
.showcase-grid,
.capability-grid,
.notes-grid,
.setup-grid,
.comparison-grid,
.kb-grid {
display: grid;
gap: 18px;
}
.showcase-grid,
.notes-grid,
.setup-grid,
.comparison-grid,
.kb-grid {
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
}
.capability-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.offer-card {
display: grid;
grid-template-columns: 108px minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.offer-thumb {
width: 108px;
height: 108px;
border-radius: 18px;
border: 1px solid var(--line);
background: white;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
color: var(--muted);
font-size: 0.8rem;
}
.offer-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumb-strip {
display: flex;
gap: 8px;
margin-top: 10px;
flex-wrap: wrap;
}
.thumb-strip img,
.thumb-strip span {
width: 44px;
height: 44px;
border-radius: 10px;
border: 1px solid var(--line);
object-fit: cover;
background: white;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.72rem;
color: var(--muted);
}
.offer-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 10px 0 0;
}
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
font-size: 0.78rem;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.88);
color: var(--ink);
}
.price {
margin-top: 10px;
font-size: 1.2rem;
font-weight: 700;
}
.price small {
font-size: 0.9rem;
color: var(--muted);
font-weight: 500;
}
.comparison-table {
display: grid;
gap: 10px;
margin-top: 14px;
}
.comparison-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(31, 41, 55, 0.04);
}
.comparison-row strong {
white-space: nowrap;
}
.delta-positive {
color: #8d4040;
font-weight: 700;
}
.delta-negative {
color: var(--olive);
font-weight: 700;
}
.brand-nav {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 14px;
}
.brand-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 40px;
padding: 0 14px;
border-radius: 999px;
border: 1px solid var(--line);
background: white;
color: var(--ink);
text-decoration: none;
font-weight: 700;
}
.brand-link.active {
background: linear-gradient(135deg, var(--accent), #df8d2b);
color: white;
border-color: transparent;
}
.cart-shell {
position: sticky;
bottom: 18px;
z-index: 10;
margin-top: 22px;
}
.cart-panel {
display: grid;
gap: 14px;
align-items: start;
grid-template-columns: 1.3fr 0.7fr;
}
.cart-list {
display: grid;
gap: 10px;
}
.cart-item {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-radius: 12px;
background: rgba(31, 41, 55, 0.04);
}
.cart-item strong,
.cart-item span {
display: block;
}
.cart-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.features {
margin: 12px 0 0;
padding-left: 18px;
color: var(--muted);
line-height: 1.55;
}
.status-card {
padding: 20px;
}
.status-label {
display: inline-flex;
padding: 6px 10px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
margin-bottom: 12px;
}
.tone-live .status-label {
background: var(--olive-soft);
color: var(--olive);
}
.tone-next .status-label {
background: var(--amber-soft);
color: var(--accent-dark);
}
.tone-review .status-label {
background: var(--rose-soft);
color: #8d4040;
}
.setup-list,
.notes-list {
display: grid;
gap: 14px;
}
.setup-item,
.note-item {
padding: 18px 20px;
}
.code-block {
margin-top: 10px;
padding: 12px 14px;
border-radius: 16px;
background: rgba(31, 41, 55, 0.05);
border: 1px solid rgba(31, 41, 55, 0.08);
font-family: "Fira Code", "JetBrains Mono", monospace;
font-size: 0.85rem;
white-space: pre-wrap;
word-break: break-word;
color: #243041;
}
.comparison-card {
padding: 22px;
}
.compact-card {
display: grid;
gap: 14px;
padding: 20px;
}
.compact-media {
display: grid;
gap: 10px;
}
.compact-thumb {
width: 100%;
height: 220px;
border-radius: 18px;
border: 1px solid var(--line);
background: white;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
color: var(--muted);
font-size: 0.8rem;
}
.compact-thumb img {
width: 100%;
height: 100%;
object-fit: contain;
background: white;
}
.compact-content {
display: grid;
gap: 10px;
align-content: start;
}
.compact-content h3 {
margin: 0;
font-size: 1rem;
line-height: 1.35;
}
.compact-price {
font-size: 1.1rem;
font-weight: 700;
}
.compact-note {
font-size: 0.95rem;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.hero, .hero,
.grid { .grid,
.stats,
.showcase-grid,
.capability-grid,
.notes-grid,
.setup-grid,
.comparison-grid,
.kb-grid,
.cart-panel {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.offer-card {
grid-template-columns: 1fr;
}
.offer-thumb {
width: 100%;
max-width: 180px;
height: 180px;
}
} }
</style> </style>
</head> </head>
@ -182,20 +552,43 @@
<div class="panel"> <div class="panel">
<div class="tag">Tag attivo: {{ $associateTag }}</div> <div class="tag">Tag attivo: {{ $associateTag }}</div>
<h1>Apri Amazon con il link affiliato NetGescon</h1> <h1>Apri Amazon con il link affiliato NetGescon</h1>
<p>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.</p> <p>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.</p>
<p style="margin-top:12px;font-size:0.95rem;color:#8a4313;"><strong>In qualità di Affiliato Amazon io ricevo un guadagno dagli acquisti idonei.</strong></p> <p style="margin-top:12px;font-size:0.95rem;color:#8a4313;"><strong>In qualità di Affiliato Amazon io ricevo un guadagno dagli acquisti idonei.</strong></p>
</div> </div>
<div class="panel tips"> <div class="panel tips">
<h2>Come usarla</h2> <h2>Stato laboratorio</h2>
<ul> <ul>
<li>Se conosci il nome del prodotto usa la ricerca per testo.</li> <li>Qui mostriamo le prove reali che stiamo portando nel catalogo interno.</li>
<li>Se hai già il codice ASIN, incollalo e apri direttamente la scheda prodotto.</li> <li>Le offerte Amazon importate restano riusabili nelle procedure NetGescon.</li>
<li>Se ti mandano un link Amazon, incollalo qui e il sistema aggiunge il tag automaticamente.</li> <li>Telegram, carrello Amazon, fattura e provenienza venditore sono esposti con stato reale: attivo, pronto o da validare.</li>
</ul> </ul>
</div> </div>
</section> </section>
<section class="stats">
<div class="panel stat-card">
<div class="eyebrow">Offerte Amazon salvate</div>
<div class="stat-value">{{ $catalogStats['offers_count'] }}</div>
<p style="margin-top:8px;">Offerte Amazon attive memorizzate nel catalogo interno.</p>
</div>
<div class="panel stat-card">
<div class="eyebrow">Prodotti collegati</div>
<div class="stat-value">{{ $catalogStats['products_count'] }}</div>
<p style="margin-top:8px;">Prodotti NetGescon che hanno gia un aggancio Amazon persistito.</p>
</div>
<div class="panel stat-card">
<div class="eyebrow">Canale Amazon</div>
<div class="stat-value">{{ $associateTag }}</div>
<p style="margin-top:8px;">Tag affiliato applicato ai link pubblici e ai referral interni.</p>
</div>
<div class="panel stat-card">
<div class="eyebrow">Brand {{ $selectedBrand }}</div>
<div class="stat-value">{{ $catalogStats['brand_products_count'] }}</div>
<p style="margin-top:8px;">Prodotti del brand selezionato gia disponibili nella vetrina interna.</p>
</div>
</section>
<section class="grid"> <section class="grid">
<form class="panel form-card" method="GET" action="{{ route('public.amazon.go') }}"> <form class="panel form-card" method="GET" action="{{ route('public.amazon.go') }}">
<h2>Ricerca per testo</h2> <h2>Ricerca per testo</h2>
@ -237,6 +630,390 @@
<a class="mini-link" href="{{ route('public.amazon.go', ['asin' => 'B0DN6PD8QS']) }}">Apri B0DN6PD8QS con tag NetGescon</a> <a class="mini-link" href="{{ route('public.amazon.go', ['asin' => 'B0DN6PD8QS']) }}">Apri B0DN6PD8QS con tag NetGescon</a>
<div style="margin-top:14px;font-size:0.9rem;color:#6b7280;">Link base da far girare: {{ route('public.amazon.index') }}</div> <div style="margin-top:14px;font-size:0.9rem;color:#6b7280;">Link base da far girare: {{ route('public.amazon.index') }}</div>
</section> </section>
<section class="panel" style="margin-top: 20px;">
<h2 class="section-title">Mini sito brand selezionati</h2>
<p class="section-copy">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.</p>
<div class="brand-nav">
<a class="brand-link {{ $selectedBrand === 'EPSON' ? 'active' : '' }}" href="{{ route('public.amazon.index', ['brand' => 'EPSON']) }}">EPSON</a>
<a class="brand-link {{ $selectedBrand === 'SAMSUNG' ? 'active' : '' }}" href="{{ route('public.amazon.index', ['brand' => 'SAMSUNG']) }}">SAMSUNG</a>
</div>
</section>
@if(count($featuredComparisons) > 0)
<section class="panel" style="margin-top: 20px;">
<h2 class="section-title">Prodotti in evidenza</h2>
<p class="section-copy">Qui mettiamo le schede piu' curate da usare come vetrina pubblica: immagini pulite, codice prodotto ufficiale e uscita finale verso Amazon.</p>
<div class="comparison-grid">
@foreach($featuredComparisons as $item)
<article class="panel compact-card comparison-card">
<div class="compact-media">
<div class="compact-thumb">
@if($item['image_url'] !== '')
<img src="{{ $item['image_url'] }}" alt="{{ $item['title'] }}" />
@else
Nessuna immagine
@endif
</div>
@if(count($item['image_urls']) > 1)
<div class="thumb-strip">
@foreach(array_slice($item['image_urls'], 0, 4) as $imageUrl)
<img src="{{ $imageUrl }}" alt="miniatura {{ $item['asin'] }}" />
@endforeach
</div>
@endif
</div>
<div class="compact-content">
<h3>{{ $item['title'] }}</h3>
<div class="offer-meta">
<span class="pill">Brand {{ $item['brand'] }}</span>
@if(($item['reference']['label'] ?? '') !== '' && ($item['reference']['value'] ?? '') !== '')
<span class="pill">{{ $item['reference']['label'] }} {{ $item['reference']['value'] }}</span>
@endif
</div>
<div class="compact-price">
@if($item['amazon_public_price'] !== null)
{{ number_format((float) $item['amazon_public_price'], 2, ',', '.') }} {{ $item['currency'] }}
@else
Prezzo Amazon in aggiornamento
@endif
</div>
@if($item['availability'] !== '')
<p>Disponibilita Amazon: {{ $item['availability'] }}</p>
@endif
<p class="compact-note">{{ $item['note'] }}</p>
<div class="actions" style="margin-top:4px;">
@if($item['referral_url'] !== '')
<button class="button-secondary cart-add-button" type="button" data-cart-title="{{ $item['title'] }}" data-cart-ref="{{ ($item['reference']['label'] ?? '') !== '' ? ($item['reference']['label'] . ': ' . $item['reference']['value']) : '' }}" data-cart-brand="{{ $item['brand'] }}" data-cart-price="{{ $item['amazon_public_price'] !== null ? number_format((float) $item['amazon_public_price'], 2, '.', '') : '' }}" data-cart-currency="{{ $item['currency'] }}" data-cart-url="{{ $item['referral_url'] }}">Aggiungi al carrello</button>
@endif
@if($item['referral_url'] !== '')
<a class="button" href="{{ $item['referral_url'] }}" target="_blank">Vai ad Amazon</a>
@elseif($item['detail_url'] !== '')
<a class="button" href="{{ $item['detail_url'] }}" target="_blank">Apri scheda Amazon</a>
@endif
</div>
</div>
</article>
@endforeach
</div>
</section>
@endif
@if(count($brandCatalog) > 0)
<section class="panel" style="margin-top: 20px;">
<h2 class="section-title">Catalogo {{ $selectedBrand }} sul tuo sito</h2>
<p class="section-copy">Questa sezione e' il primo nucleo del mini sito: schede piu' compatte, leggibili e adatte a stare affiancate anche quando il catalogo cresce.</p>
<div class="showcase-grid">
@foreach($brandCatalog as $offer)
<article class="panel compact-card">
<div class="compact-media">
<div class="compact-thumb">
@if($offer['image_url'] !== '')
<img src="{{ $offer['image_url'] }}" alt="{{ $offer['title'] }}" />
@else
Nessuna immagine
@endif
</div>
@if(count($offer['image_urls']) > 1)
<div class="thumb-strip">
@foreach(array_slice($offer['image_urls'], 0, 4) as $imageUrl)
<img src="{{ $imageUrl }}" alt="miniatura {{ $offer['asin'] }}" />
@endforeach
</div>
@endif
</div>
<div class="compact-content">
<h3>{{ $offer['title'] }}</h3>
<div class="offer-meta">
@if($offer['internal_code'] !== '')
<span class="pill">Catalogo {{ $offer['internal_code'] }}</span>
@endif
<span class="pill">Brand {{ $offer['brand'] }}</span>
@if(($offer['reference']['label'] ?? '') !== '' && ($offer['reference']['value'] ?? '') !== '')
<span class="pill">{{ $offer['reference']['label'] }} {{ $offer['reference']['value'] }}</span>
@endif
</div>
<div class="compact-price">{{ $offer['amazon_price'] !== null ? number_format((float) $offer['amazon_price'], 2, ',', '.') . ' ' . $offer['currency'] : 'Prezzo Amazon in aggiornamento' }}</div>
@if($offer['availability'] !== '')
<p>Disponibilita: {{ $offer['availability'] }}</p>
@endif
@if(count($offer['feature_points']) > 0)
<ul class="features">
@foreach(array_slice($offer['feature_points'], 0, 2) as $point)
<li>{{ $point }}</li>
@endforeach
</ul>
@endif
<div class="actions" style="margin-top:4px;">
@if($offer['referral_url'] !== '')
<button class="button-secondary cart-add-button" type="button" data-cart-title="{{ $offer['title'] }}" data-cart-ref="{{ ($offer['reference']['label'] ?? '') !== '' ? ($offer['reference']['label'] . ': ' . $offer['reference']['value']) : '' }}" data-cart-brand="{{ $offer['brand'] }}" data-cart-price="{{ $offer['amazon_price'] !== null ? number_format((float) $offer['amazon_price'], 2, '.', '') : '' }}" data-cart-currency="{{ $offer['currency'] }}" data-cart-url="{{ $offer['referral_url'] }}">Aggiungi al carrello</button>
@endif
@if($offer['referral_url'] !== '')
<a class="button" href="{{ $offer['referral_url'] }}" target="_blank">Vai ad Amazon</a>
@endif
</div>
</div>
</article>
@endforeach
</div>
</section>
@endif
<section class="panel" style="margin-top: 20px;">
<h2 class="section-title">Esempi live dal catalogo interno</h2>
<p class="section-copy">Qui compaiono le offerte Amazon gia importate e rese disponibili dentro NetGescon. Sono il ponte fra la ricerca API e l'utilizzo operativo interno.</p>
@if(count($exampleOffers) > 0)
<div class="showcase-grid">
@foreach($exampleOffers as $offer)
<article class="panel compact-card">
<div class="compact-media">
<div class="compact-thumb">
@if($offer['image_url'] !== '')
<img src="{{ $offer['image_url'] }}" alt="{{ $offer['title'] }}" />
@else
Nessuna immagine
@endif
</div>
@if(count($offer['image_urls']) > 1)
<div class="thumb-strip">
@foreach(array_slice($offer['image_urls'], 0, 4) as $imageUrl)
<img src="{{ $imageUrl }}" alt="miniatura {{ $offer['asin'] }}" />
@endforeach
</div>
@endif
</div>
<div class="compact-content">
<h3>{{ $offer['title'] }}</h3>
<div class="offer-meta">
@if($offer['internal_code'] !== '')
<span class="pill">Catalogo {{ $offer['internal_code'] }}</span>
@endif
@if($offer['brand'] !== '')
<span class="pill">Marca {{ $offer['brand'] }}</span>
@endif
@if(($offer['reference_label'] ?? '') !== '' && ($offer['reference_value'] ?? '') !== '')
<span class="pill">{{ $offer['reference_label'] }} {{ $offer['reference_value'] }}</span>
@endif
@if($offer['search_query'] !== '')
<span class="pill">Query {{ $offer['search_query'] }}</span>
@endif
</div>
<div class="compact-price">
@if($offer['price_amount'] !== null)
{{ number_format((float) $offer['price_amount'], 2, ',', '.') }} {{ $offer['currency'] !== '' ? $offer['currency'] : 'EUR' }}
@else
Prezzo Amazon in aggiornamento
@endif
</div>
@if($offer['availability'] !== '')
<p>Disponibilita: {{ $offer['availability'] }}</p>
@endif
@if(count($offer['feature_points']) > 0)
<ul class="features">
@foreach(array_slice($offer['feature_points'], 0, 2) as $point)
<li>{{ $point }}</li>
@endforeach
</ul>
@endif
<div class="actions" style="margin-top:4px;">
@if($offer['referral_url'] !== '')
<button class="button-secondary cart-add-button" type="button" data-cart-title="{{ $offer['title'] }}" data-cart-ref="{{ ($offer['reference_label'] ?? '') !== '' ? ($offer['reference_label'] . ': ' . $offer['reference_value']) : '' }}" data-cart-brand="{{ $offer['brand'] }}" data-cart-price="{{ $offer['price_amount'] !== null ? number_format((float) $offer['price_amount'], 2, '.', '') : '' }}" data-cart-currency="{{ $offer['currency'] !== '' ? $offer['currency'] : 'EUR' }}" data-cart-url="{{ $offer['referral_url'] }}">Aggiungi al carrello</button>
@endif
@if($offer['referral_url'] !== '')
<a class="button" href="{{ $offer['referral_url'] }}" target="_blank">Apri referral Amazon</a>
@endif
@if($offer['detail_url'] !== '' && $offer['detail_url'] !== $offer['referral_url'])
<a class="button-secondary" href="{{ $offer['detail_url'] }}" target="_blank">Apri dettaglio</a>
@endif
</div>
<p style="margin-top:10px;font-size:0.88rem;">Ultimo sync: {{ $offer['synced_at'] !== '' ? $offer['synced_at'] : 'n/d' }} · Sorgente: {{ $offer['sync_source'] !== '' ? $offer['sync_source'] : 'amazon_creators_api' }}</p>
</div>
</article>
@endforeach
</div>
@else
<div class="panel" style="padding:20px;">Nessuna offerta Amazon e' ancora stata importata nel catalogo interno.</div>
@endif
</section>
<section class="panel" style="margin-top: 20px;">
<h2 class="section-title">Cosa stiamo facendo adesso</h2>
<p class="section-copy">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.</p>
<div class="capability-grid">
@foreach($capabilityCards as $card)
<article class="panel status-card tone-{{ $card['tone'] }}">
<div class="status-label">{{ $card['status'] }}</div>
<h3 style="margin:0 0 8px;font-size:1.02rem;">{{ $card['title'] }}</h3>
<p>{{ $card['body'] }}</p>
</article>
@endforeach
</div>
</section>
<section class="setup-grid" style="margin-top: 20px;">
<div class="panel">
<h2 class="section-title">Canale Telegram offerte</h2>
<p class="section-copy">Per collegare il canale {{ '@nethomestorelettronica' }} e iniziare a pubblicare le offerte Amazon servono pochi passaggi, ma vanno fatti bene lato Telegram e lato ambiente.</p>
<div class="setup-list">
@foreach($telegramSetup as $item)
<div class="panel setup-item">
<strong>{{ $item['label'] }}</strong>
<p style="margin-top:6px;">{{ $item['value'] }}</p>
</div>
@endforeach
</div>
<div class="code-block">TELEGRAM_OFFERS_BOT_TOKEN=
TELEGRAM_OFFERS_CHAT_ID=
TELEGRAM_OFFERS_CHANNEL_NAME=@nethomestorelettronica
TELEGRAM_OFFERS_WEBHOOK_SECRET=</div>
</div>
<div class="panel">
<h2 class="section-title">Vincoli da conoscere subito</h2>
<p class="section-copy">Qui chiarisco le domande chiave aperte, cosi' evitiamo di progettare contro un vincolo della piattaforma.</p>
<div class="notes-list">
@foreach($evaluationNotes as $note)
<div class="panel note-item">
<strong>{{ $note['title'] }}</strong>
<p style="margin-top:6px;">{{ $note['body'] }}</p>
</div>
@endforeach
</div>
</div>
</section>
<section class="panel" style="margin-top: 20px;">
<h2 class="section-title">KB operativo di questa prova</h2>
<p class="section-copy">Questi sono i passaggi da riversare nel KB per replicare la stessa configurazione su una macchina dedicata al sito vetrina prodotti Amazon.</p>
<div class="kb-grid">
@foreach($kbChecklist as $item)
<div class="panel note-item">
<strong>{{ $item['title'] }}</strong>
<p style="margin-top:6px;">{{ $item['body'] }}</p>
</div>
@endforeach
</div>
</section>
<section class="panel cart-shell">
<div class="cart-panel">
<div>
<h2 class="section-title" style="margin-bottom:8px;">Carrello interno pronto per Amazon</h2>
<p class="section-copy" style="margin-bottom:12px;">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.</p>
<div class="cart-list" id="amazon-cart-list">
<div class="panel note-item">Nessun prodotto selezionato.</div>
</div>
</div>
<div class="panel">
<div class="eyebrow">Carrello</div>
<div class="stat-value" id="amazon-cart-count">0</div>
<p style="margin-top:8px;">Prodotti selezionati dal mini sito.</p>
<div class="cart-actions" style="margin-top:14px;">
<button class="button" type="button" id="amazon-cart-open">Apri selezione su Amazon</button>
<button class="button-secondary" type="button" id="amazon-cart-clear">Svuota</button>
</div>
</div>
</div>
</section>
</div> </div>
<script>
(() => {
const storageKey = 'netgescon-amazon-cart';
const list = document.getElementById('amazon-cart-list');
const count = document.getElementById('amazon-cart-count');
const openButton = document.getElementById('amazon-cart-open');
const clearButton = document.getElementById('amazon-cart-clear');
const addButtons = Array.from(document.querySelectorAll('.cart-add-button'));
const loadItems = () => {
try {
const parsed = JSON.parse(window.localStorage.getItem(storageKey) || '[]');
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
return [];
}
};
const saveItems = (items) => {
window.localStorage.setItem(storageKey, JSON.stringify(items));
};
const render = () => {
const items = loadItems();
count.textContent = String(items.length);
if (items.length === 0) {
list.innerHTML = '<div class="panel note-item">Nessun prodotto selezionato.</div>';
return;
}
list.innerHTML = items.map((item, index) => `
<div class="cart-item">
<div>
<strong>${item.title}</strong>
<span>${item.brand || ''}</span>
<span>${item.reference || ''}</span>
<span>${item.price || ''}</span>
</div>
<div>
<button class="button-secondary cart-remove-button" type="button" data-index="${index}">Rimuovi</button>
</div>
</div>
`).join('');
Array.from(document.querySelectorAll('.cart-remove-button')).forEach((button) => {
button.addEventListener('click', () => {
const items = loadItems();
items.splice(Number(button.dataset.index || -1), 1);
saveItems(items);
render();
});
});
};
addButtons.forEach((button) => {
button.addEventListener('click', () => {
const items = loadItems();
const url = button.dataset.cartUrl || '';
if (url === '') {
return;
}
if (!items.some((item) => item.url === url)) {
items.push({
title: button.dataset.cartTitle || 'Prodotto Amazon',
reference: button.dataset.cartRef || '',
brand: button.dataset.cartBrand || '',
price: button.dataset.cartPrice ? `${Number(button.dataset.cartPrice).toFixed(2).replace('.', ',')} ${button.dataset.cartCurrency || 'EUR'}` : '',
url,
});
saveItems(items);
render();
}
});
});
openButton?.addEventListener('click', () => {
loadItems().forEach((item) => {
if (item.url) {
window.open(item.url, '_blank', 'noopener');
}
});
});
clearButton?.addEventListener('click', () => {
saveItems([]);
render();
});
render();
})();
</script>
</body> </body>
</html> </html>