Refine Amazon public storefront showcase
This commit is contained in:
parent
1c6611addb
commit
c4f8c827b4
|
|
@ -1,24 +1,134 @@
|
|||
<?php
|
||||
|
||||
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\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AmazonReferralController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AmazonCreatorsApiService $amazonCreatorsApiService,
|
||||
) {
|
||||
}
|
||||
|
||||
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', [
|
||||
'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<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
|
||||
{
|
||||
$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 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string,mixed>, 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<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> */
|
||||
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<int, string> */
|
||||
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<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
|
||||
{
|
||||
$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<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
|
||||
{
|
||||
$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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Amazon con Tag NetGescon</title>
|
||||
@include('components.google-analytics')
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f3efe7;
|
||||
|
|
@ -13,6 +14,10 @@
|
|||
--line: rgba(31, 41, 55, 0.12);
|
||||
--accent: #c66a1f;
|
||||
--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);
|
||||
}
|
||||
|
||||
|
|
@ -82,6 +87,31 @@
|
|||
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,
|
||||
.tips h2 {
|
||||
margin: 0 0 14px;
|
||||
|
|
@ -168,11 +198,351 @@
|
|||
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) {
|
||||
.hero,
|
||||
.grid {
|
||||
.grid,
|
||||
.stats,
|
||||
.showcase-grid,
|
||||
.capability-grid,
|
||||
.notes-grid,
|
||||
.setup-grid,
|
||||
.comparison-grid,
|
||||
.kb-grid,
|
||||
.cart-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.offer-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.offer-thumb {
|
||||
width: 100%;
|
||||
max-width: 180px;
|
||||
height: 180px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
|
@ -182,20 +552,43 @@
|
|||
<div class="panel">
|
||||
<div class="tag">Tag attivo: {{ $associateTag }}</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="panel tips">
|
||||
<h2>Come usarla</h2>
|
||||
<h2>Stato laboratorio</h2>
|
||||
<ul>
|
||||
<li>Se conosci il nome del prodotto usa la ricerca per testo.</li>
|
||||
<li>Se hai già il codice ASIN, incollalo e apri direttamente la scheda prodotto.</li>
|
||||
<li>Se ti mandano un link Amazon, incollalo qui e il sistema aggiunge il tag automaticamente.</li>
|
||||
<li>Qui mostriamo le prove reali che stiamo portando nel catalogo interno.</li>
|
||||
<li>Le offerte Amazon importate restano riusabili nelle procedure NetGescon.</li>
|
||||
<li>Telegram, carrello Amazon, fattura e provenienza venditore sono esposti con stato reale: attivo, pronto o da validare.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</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">
|
||||
<form class="panel form-card" method="GET" action="{{ route('public.amazon.go') }}">
|
||||
<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>
|
||||
<div style="margin-top:14px;font-size:0.9rem;color:#6b7280;">Link base da far girare: {{ route('public.amazon.index') }}</div>
|
||||
</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>
|
||||
<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>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user