implementazione 3 di n8n
This commit is contained in:
parent
b7fd15ffcd
commit
9faf49acfa
11 changed files with 524 additions and 2 deletions
BIN
.gitignore
vendored
BIN
.gitignore
vendored
Binary file not shown.
|
|
@ -1,5 +1,5 @@
|
|||
@echo-off
|
||||
cd /d D:\maga\magricambi
|
||||
cd /d D:\maga\magricambi2
|
||||
git status
|
||||
echo.
|
||||
set /p MSG=Messaggio commit:
|
||||
|
|
|
|||
51
src/app/api/articoli/ricerca-prezzi/route.ts
Normal file
51
src/app/api/articoli/ricerca-prezzi/route.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 286 KiB |
|
|
@ -1,6 +1,9 @@
|
|||
"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";
|
||||
|
||||
|
|
@ -19,6 +22,9 @@ 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: "",
|
||||
|
|
@ -173,11 +179,36 @@ 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 max-w-lg bg-zinc-900 rounded-2xl shadow-2xl border border-zinc-800 overflow-hidden max-h-[90vh] flex flex-col"
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800 flex-shrink-0">
|
||||
|
|
@ -299,6 +330,65 @@ 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">
|
||||
|
|
|
|||
162
src/components/PriceSearchPanel.tsx
Normal file
162
src/components/PriceSearchPanel.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
82
src/hooks/usePriceSearch.ts
Normal file
82
src/hooks/usePriceSearch.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"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,6 +37,7 @@ export interface AppPermissions {
|
|||
canAssegnaArticoliMacchina: boolean;
|
||||
canEditMacchine: boolean;
|
||||
canRemoveArticoloMacchina: boolean;
|
||||
canSearchPrices: boolean;
|
||||
}
|
||||
|
||||
export function getPermissions(ruolo: UserRole): AppPermissions {
|
||||
|
|
@ -49,6 +50,7 @@ export function getPermissions(ruolo: UserRole): AppPermissions {
|
|||
canAssegnaArticoliMacchina: true,
|
||||
canEditMacchine: true,
|
||||
canRemoveArticoloMacchina: true,
|
||||
canSearchPrices: true,
|
||||
};
|
||||
case "tecnico":
|
||||
return {
|
||||
|
|
@ -58,6 +60,7 @@ export function getPermissions(ruolo: UserRole): AppPermissions {
|
|||
canAssegnaArticoliMacchina: true,
|
||||
canEditMacchine: true,
|
||||
canRemoveArticoloMacchina: true,
|
||||
canSearchPrices: true,
|
||||
};
|
||||
case "operatore":
|
||||
return {
|
||||
|
|
@ -67,6 +70,7 @@ export function getPermissions(ruolo: UserRole): AppPermissions {
|
|||
canAssegnaArticoliMacchina: false,
|
||||
canEditMacchine: false,
|
||||
canRemoveArticoloMacchina: false,
|
||||
canSearchPrices: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
src/lib/price-search/index.ts
Normal file
15
src/lib/price-search/index.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
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}`);
|
||||
}
|
||||
91
src/lib/price-search/n8n.ts
Normal file
91
src/lib/price-search/n8n.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
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 };
|
||||
27
src/types/priceSearch.ts
Normal file
27
src/types/priceSearch.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
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