Compare commits
No commits in common. "4bfb3f01491004afc2624645dc22f227f26c7805" and "faa9e8ae4954e4dcef10793ec5dd26a6ce195e9e" have entirely different histories.
4bfb3f0149
...
faa9e8ae49
11 changed files with 10 additions and 557 deletions
41
.gitignore
vendored
41
.gitignore
vendored
|
|
@ -1,45 +1,20 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
# env
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# tesina (file troppo grandi per il remote)
|
||||
/tesina_finale/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
# vercel
|
||||
.vercel
|
||||
n8n_Tavily_API_KEY*.txt
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
node_modules
|
||||
.env
|
||||
i m m a g i n i /
|
||||
|
||||
|
||||
.specstory/
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
@echo-off
|
||||
cd /d D:\maga\magricambi2
|
||||
cd /d D:\maga\magricambi
|
||||
git status
|
||||
echo.
|
||||
set /p MSG=Messaggio commit:
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
import { NextResponse } from "next/server";
|
||||
|
||||
import { requirePermission } from "@/src/lib/auth/requireSession";
|
||||
import { searchPriceOffers } from "@/src/lib/price-search";
|
||||
import type { PriceSearchRequest } from "@/src/types/priceSearch";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await requirePermission(
|
||||
(p) => p.canSearchPrices,
|
||||
"Non hai i permessi per la ricerca prezzi",
|
||||
);
|
||||
if (session instanceof NextResponse) return session;
|
||||
|
||||
try {
|
||||
const body = (await request.json()) as Partial<PriceSearchRequest>;
|
||||
|
||||
const descrizione = String(body.descrizione ?? "").trim();
|
||||
if (!descrizione) {
|
||||
return NextResponse.json(
|
||||
{ error: "La descrizione è obbligatoria per la ricerca" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const payload: PriceSearchRequest = {
|
||||
descrizione,
|
||||
barcode: body.barcode ?? null,
|
||||
codice: body.codice ?? null,
|
||||
quantita:
|
||||
body.quantita !== undefined && body.quantita !== null
|
||||
? Number(body.quantita)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const result = await searchPriceOffers(payload, {
|
||||
userId: session.user.id,
|
||||
userName: session.user.utente || session.user.name || session.user.email || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error("[API ricerca-prezzi]", error);
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Errore ricerca prezzi";
|
||||
|
||||
const status =
|
||||
message.includes("non configurata") ? 503 : 502;
|
||||
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 286 KiB |
|
|
@ -1,9 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import PriceSearchPanel from "@/src/components/PriceSearchPanel";
|
||||
import { usePermissions } from "@/src/contexts/PermissionsContext";
|
||||
import { usePriceSearch } from "@/src/hooks/usePriceSearch";
|
||||
import type { Article, ArticleInput, ArticleFamily } from "@/src/types/article";
|
||||
import type { Stabilimento, Magazzino } from "@/src/types/stabilimento";
|
||||
|
||||
|
|
@ -22,9 +19,6 @@ export default function ArticleModal({
|
|||
}: ArticleModalProps) {
|
||||
const isEdit = article !== null;
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const { permissions } = usePermissions();
|
||||
const { result, error: searchError, search, cancel, reset, isLoading } =
|
||||
usePriceSearch();
|
||||
|
||||
const [formData, setFormData] = useState<ArticleInput>({
|
||||
codice: "",
|
||||
|
|
@ -179,36 +173,11 @@ export default function ArticleModal({
|
|||
setSelectedMagazzino(value);
|
||||
};
|
||||
|
||||
const handlePriceSearch = async () => {
|
||||
const descrizione = formData.descrizione.trim();
|
||||
if (!descrizione) {
|
||||
setError("Inserisci una descrizione prima di cercare i prezzi");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
await search({
|
||||
descrizione,
|
||||
barcode: formData.barcode || null,
|
||||
codice: formData.codice || null,
|
||||
});
|
||||
};
|
||||
|
||||
const handleApplyPrice = (price: number) => {
|
||||
setFormData((prev) => ({ ...prev, prezzo: price }));
|
||||
};
|
||||
|
||||
const showPriceSearchPanel =
|
||||
permissions.canSearchPrices &&
|
||||
(isLoading || searchError !== null || result !== null);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={`w-full bg-zinc-900 rounded-2xl shadow-2xl border border-zinc-800 overflow-hidden max-h-[90vh] flex flex-col ${
|
||||
showPriceSearchPanel ? "max-w-2xl" : "max-w-lg"
|
||||
}`}
|
||||
className="w-full max-w-lg bg-zinc-900 rounded-2xl shadow-2xl border border-zinc-800 overflow-hidden max-h-[90vh] flex flex-col"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800 flex-shrink-0">
|
||||
|
|
@ -330,65 +299,6 @@ export default function ArticleModal({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{permissions.canSearchPrices && (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePriceSearch}
|
||||
disabled={isLoading || !formData.descrizione.trim()}
|
||||
className="px-4 py-2 rounded-lg bg-blue-600/20 border border-blue-500/40 text-blue-400 text-sm font-medium hover:bg-blue-600/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
Cerca prezzi online
|
||||
</button>
|
||||
{result && !isLoading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
Chiudi risultati
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{permissions.canSearchPrices && (
|
||||
<PriceSearchPanel
|
||||
isLoading={isLoading}
|
||||
error={searchError}
|
||||
result={result}
|
||||
onApplyPrice={handleApplyPrice}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Giacenza iniziale (solo per nuovo articolo) */}
|
||||
{!isEdit && (
|
||||
<div className="p-4 rounded-lg bg-green-500/5 border border-green-500/30">
|
||||
|
|
|
|||
|
|
@ -1,162 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import type { PriceOffer, PriceSearchResponse } from "@/src/types/priceSearch";
|
||||
|
||||
const CONFIDENCE_LABELS: Record<PriceOffer["confidence"], string> = {
|
||||
high: "Alta",
|
||||
medium: "Media",
|
||||
low: "Bassa",
|
||||
};
|
||||
|
||||
const CONFIDENCE_STYLES: Record<PriceOffer["confidence"], string> = {
|
||||
high: "bg-green-500/15 text-green-400 border-green-500/30",
|
||||
medium: "bg-yellow-500/15 text-yellow-400 border-yellow-500/30",
|
||||
low: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
|
||||
};
|
||||
|
||||
function formatPrice(price: number | null, currency: string) {
|
||||
if (price === null) return "N/D";
|
||||
return new Intl.NumberFormat("it-IT", {
|
||||
style: "currency",
|
||||
currency,
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
interface PriceSearchPanelProps {
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
result: PriceSearchResponse | null;
|
||||
onApplyPrice: (price: number) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function PriceSearchPanel({
|
||||
isLoading,
|
||||
error,
|
||||
result,
|
||||
onApplyPrice,
|
||||
onCancel,
|
||||
}: PriceSearchPanelProps) {
|
||||
if (!isLoading && !error && !result) return null;
|
||||
|
||||
return (
|
||||
<div className="p-4 rounded-lg bg-blue-500/5 border border-blue-500/30 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-blue-400 flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
Ricerca prezzi online
|
||||
</h3>
|
||||
{isLoading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="text-xs text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
Annulla
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-3 text-sm text-zinc-400">
|
||||
<svg className="w-4 h-4 animate-spin text-blue-400" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Ricerca in corso… può richiedere fino a 30 secondi
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/50 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Query: <span className="text-zinc-400">{result.query}</span>
|
||||
</p>
|
||||
|
||||
{result.offers.length === 0 ? (
|
||||
<p className="text-sm text-zinc-400">Nessuna offerta trovata.</p>
|
||||
) : (
|
||||
<ul className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{result.offers.map((offer, index) => (
|
||||
<li
|
||||
key={`${offer.vendor}-${offer.url}-${index}`}
|
||||
className="p-3 rounded-lg bg-zinc-800/80 border border-zinc-700"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-white truncate">
|
||||
{offer.vendor}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded border ${CONFIDENCE_STYLES[offer.confidence]}`}
|
||||
>
|
||||
{CONFIDENCE_LABELS[offer.confidence]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400 mt-0.5 line-clamp-2">{offer.title}</p>
|
||||
{offer.shippingNote && (
|
||||
<p className="text-[11px] text-zinc-500 mt-1">{offer.shippingNote}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 flex-shrink-0">
|
||||
<span className="text-sm font-semibold text-green-400">
|
||||
{formatPrice(offer.price, offer.currency)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={offer.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[11px] text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
Apri
|
||||
</a>
|
||||
{offer.price !== null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onApplyPrice(offer.price as number)}
|
||||
className="text-[11px] px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-500 transition-colors"
|
||||
>
|
||||
Applica
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-zinc-500 leading-relaxed">{result.disclaimer}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
import type { PriceSearchRequest, PriceSearchResponse } from "@/src/types/priceSearch";
|
||||
|
||||
type SearchStatus = "idle" | "loading" | "success" | "error";
|
||||
|
||||
export function usePriceSearch() {
|
||||
const [status, setStatus] = useState<SearchStatus>("idle");
|
||||
const [result, setResult] = useState<PriceSearchResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const search = useCallback(async (input: PriceSearchRequest) => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/articoli/ricerca-prezzi", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
typeof data.error === "string" ? data.error : "Errore ricerca prezzi",
|
||||
);
|
||||
}
|
||||
|
||||
const response = data as PriceSearchResponse;
|
||||
setResult(response);
|
||||
setStatus("success");
|
||||
return response;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
setStatus("idle");
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : "Errore sconosciuto";
|
||||
setError(message);
|
||||
setStatus("error");
|
||||
return null;
|
||||
} finally {
|
||||
if (abortRef.current === controller) {
|
||||
abortRef.current = null;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
setStatus("idle");
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
cancel();
|
||||
setResult(null);
|
||||
setError(null);
|
||||
}, [cancel]);
|
||||
|
||||
return {
|
||||
status,
|
||||
result,
|
||||
error,
|
||||
search,
|
||||
cancel,
|
||||
reset,
|
||||
isLoading: status === "loading",
|
||||
};
|
||||
}
|
||||
|
|
@ -37,7 +37,6 @@ export interface AppPermissions {
|
|||
canAssegnaArticoliMacchina: boolean;
|
||||
canEditMacchine: boolean;
|
||||
canRemoveArticoloMacchina: boolean;
|
||||
canSearchPrices: boolean;
|
||||
}
|
||||
|
||||
export function getPermissions(ruolo: UserRole): AppPermissions {
|
||||
|
|
@ -50,7 +49,6 @@ export function getPermissions(ruolo: UserRole): AppPermissions {
|
|||
canAssegnaArticoliMacchina: true,
|
||||
canEditMacchine: true,
|
||||
canRemoveArticoloMacchina: true,
|
||||
canSearchPrices: true,
|
||||
};
|
||||
case "tecnico":
|
||||
return {
|
||||
|
|
@ -60,7 +58,6 @@ export function getPermissions(ruolo: UserRole): AppPermissions {
|
|||
canAssegnaArticoliMacchina: true,
|
||||
canEditMacchine: true,
|
||||
canRemoveArticoloMacchina: true,
|
||||
canSearchPrices: true,
|
||||
};
|
||||
case "operatore":
|
||||
return {
|
||||
|
|
@ -70,7 +67,6 @@ export function getPermissions(ruolo: UserRole): AppPermissions {
|
|||
canAssegnaArticoliMacchina: false,
|
||||
canEditMacchine: false,
|
||||
canRemoveArticoloMacchina: false,
|
||||
canSearchPrices: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
import type { PriceSearchRequest, PriceSearchResponse } from "@/src/types/priceSearch";
|
||||
import { searchPriceOffersViaN8n } from "@/src/lib/price-search/n8n";
|
||||
|
||||
export async function searchPriceOffers(
|
||||
input: PriceSearchRequest,
|
||||
meta?: { userId?: string; userName?: string },
|
||||
): Promise<PriceSearchResponse> {
|
||||
const backend = (process.env.PRICE_SEARCH_BACKEND ?? "n8n").toLowerCase();
|
||||
|
||||
if (backend === "n8n") {
|
||||
return searchPriceOffersViaN8n(input, meta);
|
||||
}
|
||||
|
||||
throw new Error(`Backend ricerca prezzi non supportato: ${backend}`);
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
import type { PriceSearchRequest, PriceSearchResponse } from "@/src/types/priceSearch";
|
||||
|
||||
const DISCLAIMER =
|
||||
"Prezzi indicativi da fonti web: verificare su ordine fornitore prima dell'acquisto.";
|
||||
|
||||
function getWebhookConfig() {
|
||||
const url =
|
||||
process.env.N8N_PRICE_SEARCH_WEBHOOK_URL?.trim() ??
|
||||
process.env.N8N_PRICE_WEBHOOK_URL?.trim();
|
||||
const secret = process.env.N8N_PRICE_WEBHOOK_SECRET?.trim();
|
||||
const timeoutMs = Number(process.env.N8N_PRICE_WEBHOOK_TIMEOUT_MS ?? 35_000);
|
||||
|
||||
if (!url || !secret) {
|
||||
throw new Error("Ricerca prezzi n8n non configurata (URL o secret mancanti)");
|
||||
}
|
||||
|
||||
return { url, secret, timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : 35_000 };
|
||||
}
|
||||
|
||||
function isPriceSearchResponse(value: unknown): value is PriceSearchResponse {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof v.query === "string" &&
|
||||
typeof v.searchedAt === "string" &&
|
||||
Array.isArray(v.offers) &&
|
||||
typeof v.disclaimer === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export async function searchPriceOffersViaN8n(
|
||||
input: PriceSearchRequest,
|
||||
meta?: { userId?: string; userName?: string },
|
||||
): Promise<PriceSearchResponse> {
|
||||
const { url, secret, timeoutMs } = getWebhookConfig();
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Webhook-Secret": secret,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...input,
|
||||
meta: {
|
||||
source: "magricambi",
|
||||
userId: meta?.userId ?? null,
|
||||
userName: meta?.userName ?? null,
|
||||
requestedAt: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
let data: unknown;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
throw new Error("Risposta non valida dal workflow n8n");
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err =
|
||||
data && typeof data === "object" && "error" in data
|
||||
? String((data as { error: unknown }).error)
|
||||
: `Errore n8n (${res.status})`;
|
||||
throw new Error(err);
|
||||
}
|
||||
|
||||
if (!isPriceSearchResponse(data)) {
|
||||
throw new Error("Formato risposta n8n non conforme a PriceSearchResponse");
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new Error("Timeout ricerca prezzi: riprova tra poco");
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export { DISCLAIMER };
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
export type PriceOfferConfidence = "high" | "medium" | "low";
|
||||
export type PriceOfferSource = "page" | "snippet" | "estimate";
|
||||
|
||||
export interface PriceOffer {
|
||||
vendor: string;
|
||||
title: string;
|
||||
price: number | null;
|
||||
currency: "EUR";
|
||||
url: string;
|
||||
shippingNote?: string;
|
||||
confidence: PriceOfferConfidence;
|
||||
source: PriceOfferSource;
|
||||
}
|
||||
|
||||
export interface PriceSearchRequest {
|
||||
descrizione: string;
|
||||
barcode?: string | null;
|
||||
codice?: string | null;
|
||||
quantita?: number;
|
||||
}
|
||||
|
||||
export interface PriceSearchResponse {
|
||||
query: string;
|
||||
searchedAt: string;
|
||||
offers: PriceOffer[];
|
||||
disclaimer: string;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue