Browse Source

Show a note under the supplier link when Fetch cannot read the page (#568)

* Show a note under the supplier link when Fetch cannot read the page

Fetch on the part form asks our server to read the supplier's page and
prefill the fields. A supplier behind a bot check answered with an
untranslated error dialog titled "Fetch Failed", which read as if the
part could not be saved. Saving was never blocked.

The lookup route now sends a code with every failure and keeps the
English text for other callers. The form shows a translated note under
the link instead of the dialog (blocked, timed out, or unreadable for
any other reason), says the part and the link still save, and stops
offering Fetch for a link that was just turned away until it is edited.

* Accept a supplier link typed without https://

A link typed as www.supplier.com/part/123 made Fetch do nothing, and
saving the part failed with "supplierUrl: Invalid URL". The part form now
puts https:// in front of a link that starts with a host, both when Fetch
is pressed and when the part is saved, and the field shows the result.
Text that is still not a link gets a translated message instead of the
raw validation text. The server keeps the same check, now shared with
the form.

* Add https:// to a supplier link that carries another link in its query

Only a scheme at the start of what was typed now counts, so www.x.com/p?return=https://x.com still gets https:// in front.
Bernt Christian Egeland 4 days ago
parent
commit
9276a35f73

+ 4 - 2
messages/de/inventory.json

@@ -81,9 +81,11 @@
     "addNewPart": "Neues Teil hinzufügen",
     "addNewPart": "Neues Teil hinzufügen",
     "supplierLink": "Lieferantenlink",
     "supplierLink": "Lieferantenlink",
     "supplierLinkPlaceholder": "https://supplier.com/product/...",
     "supplierLinkPlaceholder": "https://supplier.com/product/...",
+    "supplierLinkInvalid": "Der Lieferantenlink ist keine Webadresse. Korrigieren Sie ihn oder leeren Sie das Feld, um ohne Link zu speichern.",
     "fetch": "Abrufen",
     "fetch": "Abrufen",
-    "fetchFailed": "Abruf fehlgeschlagen",
-    "fetchError": "Metadaten konnten nicht von der URL abgerufen werden",
+    "fetchBlocked": "Die Website dieses Lieferanten blockiert den automatischen Abruf. Felder bitte selbst ausfüllen. Teil und Link lassen sich trotzdem speichern.",
+    "fetchTimedOut": "Die Website des Lieferanten hat zu lange für eine Antwort gebraucht. Erneut versuchen oder die Felder selbst ausfüllen. Teil und Link lassen sich trotzdem speichern.",
+    "fetchUnreadable": "Die Teiledaten konnten über diesen Link nicht gelesen werden. Felder bitte selbst ausfüllen. Teil und Link lassen sich trotzdem speichern.",
     "image": "Bild",
     "image": "Bild",
     "dropOrClick": "Bild ablegen oder klicken",
     "dropOrClick": "Bild ablegen oder klicken",
     "replace": "Ersetzen",
     "replace": "Ersetzen",

+ 4 - 2
messages/en/inventory.json

@@ -81,9 +81,11 @@
     "addNewPart": "Add New Part",
     "addNewPart": "Add New Part",
     "supplierLink": "Supplier Link",
     "supplierLink": "Supplier Link",
     "supplierLinkPlaceholder": "https://supplier.com/product/...",
     "supplierLinkPlaceholder": "https://supplier.com/product/...",
+    "supplierLinkInvalid": "The supplier link is not a web address. Correct it, or clear it to save without one.",
     "fetch": "Fetch",
     "fetch": "Fetch",
-    "fetchFailed": "Fetch Failed",
-    "fetchError": "Could not fetch metadata from URL",
+    "fetchBlocked": "This supplier's site blocks automatic lookup. Fill in the fields yourself. The part and the link still save.",
+    "fetchTimedOut": "The supplier's site took too long to answer. Try again, or fill in the fields yourself. The part and the link still save.",
+    "fetchUnreadable": "Could not read the part details from this link. Fill in the fields yourself. The part and the link still save.",
     "image": "Image",
     "image": "Image",
     "dropOrClick": "Drop image or click",
     "dropOrClick": "Drop image or click",
     "replace": "Replace",
     "replace": "Replace",

+ 4 - 2
messages/es/inventory.json

@@ -81,9 +81,11 @@
     "addNewPart": "Añadir nueva pieza",
     "addNewPart": "Añadir nueva pieza",
     "supplierLink": "Enlace del proveedor",
     "supplierLink": "Enlace del proveedor",
     "supplierLinkPlaceholder": "https://proveedor.com/producto/...",
     "supplierLinkPlaceholder": "https://proveedor.com/producto/...",
+    "supplierLinkInvalid": "El enlace del proveedor no es una dirección web. Corrígelo o bórralo para guardar sin enlace.",
     "fetch": "Obtener",
     "fetch": "Obtener",
-    "fetchFailed": "Error al obtener",
-    "fetchError": "No se pudieron obtener los metadatos de la URL",
+    "fetchBlocked": "El sitio web de este proveedor bloquea la consulta automática. Rellena los campos a mano. La pieza y el enlace se pueden guardar igualmente.",
+    "fetchTimedOut": "El sitio web del proveedor tardó demasiado en responder. Inténtalo de nuevo o rellena los campos a mano. La pieza y el enlace se pueden guardar igualmente.",
+    "fetchUnreadable": "No se pudieron leer los datos de la pieza desde este enlace. Rellena los campos a mano. La pieza y el enlace se pueden guardar igualmente.",
     "image": "Imagen",
     "image": "Imagen",
     "dropOrClick": "Arrastre una imagen o haga clic",
     "dropOrClick": "Arrastre una imagen o haga clic",
     "replace": "Reemplazar",
     "replace": "Reemplazar",

+ 4 - 2
messages/fr/inventory.json

@@ -81,9 +81,11 @@
     "addNewPart": "Ajouter une nouvelle pièce",
     "addNewPart": "Ajouter une nouvelle pièce",
     "supplierLink": "Lien fournisseur",
     "supplierLink": "Lien fournisseur",
     "supplierLinkPlaceholder": "https://fournisseur.com/produit/...",
     "supplierLinkPlaceholder": "https://fournisseur.com/produit/...",
+    "supplierLinkInvalid": "Le lien fournisseur n'est pas une adresse web. Corrigez-le, ou videz le champ pour enregistrer sans lien.",
     "fetch": "Récupérer",
     "fetch": "Récupérer",
-    "fetchFailed": "Échec de la récupération",
-    "fetchError": "Impossible de récupérer les métadonnées de l'URL",
+    "fetchBlocked": "Le site de ce fournisseur bloque la recherche automatique. Remplissez les champs vous-même. La pièce et le lien peuvent quand même être enregistrés.",
+    "fetchTimedOut": "Le site du fournisseur a mis trop de temps à répondre. Réessayez ou remplissez les champs vous-même. La pièce et le lien peuvent quand même être enregistrés.",
+    "fetchUnreadable": "Impossible de lire les informations de la pièce à partir de ce lien. Remplissez les champs vous-même. La pièce et le lien peuvent quand même être enregistrés.",
     "image": "Image",
     "image": "Image",
     "dropOrClick": "Déposez une image ou cliquez",
     "dropOrClick": "Déposez une image ou cliquez",
     "replace": "Remplacer",
     "replace": "Remplacer",

+ 4 - 2
messages/it/inventory.json

@@ -81,9 +81,11 @@
     "addNewPart": "Aggiungi nuovo ricambio",
     "addNewPart": "Aggiungi nuovo ricambio",
     "supplierLink": "Link fornitore",
     "supplierLink": "Link fornitore",
     "supplierLinkPlaceholder": "https://supplier.com/product/...",
     "supplierLinkPlaceholder": "https://supplier.com/product/...",
+    "supplierLinkInvalid": "Il link del fornitore non è un indirizzo web. Correggilo, oppure cancellalo per salvare senza link.",
     "fetch": "Recupera",
     "fetch": "Recupera",
-    "fetchFailed": "Recupero non riuscito",
-    "fetchError": "Impossibile recuperare i metadati dall'URL",
+    "fetchBlocked": "Il sito di questo fornitore blocca la ricerca automatica. Compila i campi a mano. Il ricambio e il link si possono salvare comunque.",
+    "fetchTimedOut": "Il sito del fornitore ha impiegato troppo tempo a rispondere. Riprova oppure compila i campi a mano. Il ricambio e il link si possono salvare comunque.",
+    "fetchUnreadable": "Impossibile leggere i dati del ricambio da questo link. Compila i campi a mano. Il ricambio e il link si possono salvare comunque.",
     "image": "Immagine",
     "image": "Immagine",
     "dropOrClick": "Trascina un'immagine o clicca",
     "dropOrClick": "Trascina un'immagine o clicca",
     "replace": "Sostituisci",
     "replace": "Sostituisci",

+ 4 - 2
messages/lt/inventory.json

@@ -81,9 +81,11 @@
     "addNewPart": "Pridėti naują dalį",
     "addNewPart": "Pridėti naują dalį",
     "supplierLink": "Tiekėjo nuoroda",
     "supplierLink": "Tiekėjo nuoroda",
     "supplierLinkPlaceholder": "https://tiekėjas.lt/produktas/...",
     "supplierLinkPlaceholder": "https://tiekėjas.lt/produktas/...",
+    "supplierLinkInvalid": "Tiekėjo nuoroda nėra interneto adresas. Pataisykite ją arba ištrinkite, kad išsaugotumėte be nuorodos.",
     "fetch": "Gauti",
     "fetch": "Gauti",
-    "fetchFailed": "Gavimas nepavyko",
-    "fetchError": "Nepavyko gauti metaduomenų iš URL",
+    "fetchBlocked": "Šio tiekėjo svetainė blokuoja automatinę paiešką. Užpildykite laukus patys. Dalį ir nuorodą vis tiek galima išsaugoti.",
+    "fetchTimedOut": "Tiekėjo svetainė per ilgai neatsakė. Bandykite dar kartą arba užpildykite laukus patys. Dalį ir nuorodą vis tiek galima išsaugoti.",
+    "fetchUnreadable": "Nepavyko nuskaityti dalies duomenų iš šios nuorodos. Užpildykite laukus patys. Dalį ir nuorodą vis tiek galima išsaugoti.",
     "image": "Vaizdas",
     "image": "Vaizdas",
     "dropOrClick": "Nuvilkite vaizdą arba spustelėkite",
     "dropOrClick": "Nuvilkite vaizdą arba spustelėkite",
     "replace": "Pakeisti",
     "replace": "Pakeisti",

+ 4 - 2
messages/nb/inventory.json

@@ -81,9 +81,11 @@
     "addNewPart": "Legg til ny del",
     "addNewPart": "Legg til ny del",
     "supplierLink": "Leverandørlenke",
     "supplierLink": "Leverandørlenke",
     "supplierLinkPlaceholder": "https://supplier.com/product/...",
     "supplierLinkPlaceholder": "https://supplier.com/product/...",
+    "supplierLinkInvalid": "Leverandørlenken er ikke en nettadresse. Rett den, eller tøm feltet for å lagre uten lenke.",
     "fetch": "Hent",
     "fetch": "Hent",
-    "fetchFailed": "Henting mislyktes",
-    "fetchError": "Kunne ikke hente metadata fra URL",
+    "fetchBlocked": "Nettsiden til denne leverandøren blokkerer automatisk oppslag. Fyll inn feltene selv. Delen og lenken kan fortsatt lagres.",
+    "fetchTimedOut": "Nettsiden til leverandøren brukte for lang tid på å svare. Prøv igjen, eller fyll inn feltene selv. Delen og lenken kan fortsatt lagres.",
+    "fetchUnreadable": "Kunne ikke lese opplysningene om delen fra denne lenken. Fyll inn feltene selv. Delen og lenken kan fortsatt lagres.",
     "image": "Bilde",
     "image": "Bilde",
     "dropOrClick": "Slipp bilde eller klikk",
     "dropOrClick": "Slipp bilde eller klikk",
     "replace": "Erstatt",
     "replace": "Erstatt",

+ 4 - 2
messages/nl/inventory.json

@@ -81,9 +81,11 @@
     "addNewPart": "Nieuw onderdeel toevoegen",
     "addNewPart": "Nieuw onderdeel toevoegen",
     "supplierLink": "Leverancierslink",
     "supplierLink": "Leverancierslink",
     "supplierLinkPlaceholder": "https://supplier.com/product/...",
     "supplierLinkPlaceholder": "https://supplier.com/product/...",
+    "supplierLinkInvalid": "De leverancierslink is geen webadres. Verbeter hem, of maak het veld leeg om zonder link op te slaan.",
     "fetch": "Ophalen",
     "fetch": "Ophalen",
-    "fetchFailed": "Ophalen mislukt",
-    "fetchError": "Kan metadata niet ophalen van URL",
+    "fetchBlocked": "De website van deze leverancier blokkeert het automatisch ophalen van gegevens. Vul de velden zelf in. Het onderdeel en de link kun je gewoon opslaan.",
+    "fetchTimedOut": "De website van de leverancier reageerde niet op tijd. Probeer het opnieuw of vul de velden zelf in. Het onderdeel en de link kun je gewoon opslaan.",
+    "fetchUnreadable": "De gegevens van het onderdeel konden niet via deze link worden gelezen. Vul de velden zelf in. Het onderdeel en de link kun je gewoon opslaan.",
     "image": "Afbeelding",
     "image": "Afbeelding",
     "dropOrClick": "Sleep afbeelding of klik",
     "dropOrClick": "Sleep afbeelding of klik",
     "replace": "Vervangen",
     "replace": "Vervangen",

+ 4 - 2
messages/pl/inventory.json

@@ -81,9 +81,11 @@
     "addNewPart": "Dodaj nową część",
     "addNewPart": "Dodaj nową część",
     "supplierLink": "Link do dostawcy",
     "supplierLink": "Link do dostawcy",
     "supplierLinkPlaceholder": "https://dostawca.pl/produkt/...",
     "supplierLinkPlaceholder": "https://dostawca.pl/produkt/...",
+    "supplierLinkInvalid": "Link do dostawcy nie jest adresem strony. Popraw go albo wyczyść pole, aby zapisać bez linku.",
     "fetch": "Pobierz",
     "fetch": "Pobierz",
-    "fetchFailed": "Pobieranie nie powiodło się",
-    "fetchError": "Nie udało się pobrać metadanych z adresu URL",
+    "fetchBlocked": "Strona tego dostawcy blokuje automatyczne pobieranie danych. Wypełnij pola samodzielnie. Część i link nadal można zapisać.",
+    "fetchTimedOut": "Strona dostawcy zbyt długo nie odpowiadała. Spróbuj ponownie albo wypełnij pola samodzielnie. Część i link nadal można zapisać.",
+    "fetchUnreadable": "Nie udało się odczytać danych części z tego linku. Wypełnij pola samodzielnie. Część i link nadal można zapisać.",
     "image": "Zdjęcie",
     "image": "Zdjęcie",
     "dropOrClick": "Upuść zdjęcie lub kliknij",
     "dropOrClick": "Upuść zdjęcie lub kliknij",
     "replace": "Zamień",
     "replace": "Zamień",

+ 4 - 2
messages/pt-BR/inventory.json

@@ -81,9 +81,11 @@
     "addNewPart": "Adicionar nova peça",
     "addNewPart": "Adicionar nova peça",
     "supplierLink": "Link do fornecedor",
     "supplierLink": "Link do fornecedor",
     "supplierLinkPlaceholder": "https://supplier.com/product/...",
     "supplierLinkPlaceholder": "https://supplier.com/product/...",
+    "supplierLinkInvalid": "O link do fornecedor não é um endereço da web. Corrija-o ou apague-o para salvar sem link.",
     "fetch": "Buscar",
     "fetch": "Buscar",
-    "fetchFailed": "Falha ao buscar",
-    "fetchError": "Não foi possível buscar os metadados da URL",
+    "fetchBlocked": "O site deste fornecedor bloqueia a consulta automática. Preencha os campos manualmente. A peça e o link ainda podem ser salvos.",
+    "fetchTimedOut": "O site do fornecedor demorou demais para responder. Tente novamente ou preencha os campos manualmente. A peça e o link ainda podem ser salvos.",
+    "fetchUnreadable": "Não foi possível ler os dados da peça a partir deste link. Preencha os campos manualmente. A peça e o link ainda podem ser salvos.",
     "image": "Imagem",
     "image": "Imagem",
     "dropOrClick": "Arraste uma imagem ou clique",
     "dropOrClick": "Arraste uma imagem ou clique",
     "replace": "Substituir",
     "replace": "Substituir",

+ 4 - 2
messages/pt-PT/inventory.json

@@ -81,9 +81,11 @@
     "addNewPart": "Adicionar nova peça",
     "addNewPart": "Adicionar nova peça",
     "supplierLink": "Link do fornecedor",
     "supplierLink": "Link do fornecedor",
     "supplierLinkPlaceholder": "https://fornecedor.pt/produto/...",
     "supplierLinkPlaceholder": "https://fornecedor.pt/produto/...",
+    "supplierLinkInvalid": "O link do fornecedor não é um endereço web. Corrija-o ou apague-o para guardar sem link.",
     "fetch": "Obter",
     "fetch": "Obter",
-    "fetchFailed": "Não foi possível obter",
-    "fetchError": "Não foi possível obter os metadados do URL",
+    "fetchBlocked": "O site deste fornecedor bloqueia a consulta automática. Preencha os campos manualmente. A peça e o link podem ser guardados na mesma.",
+    "fetchTimedOut": "O site do fornecedor demorou demasiado a responder. Tente novamente ou preencha os campos manualmente. A peça e o link podem ser guardados na mesma.",
+    "fetchUnreadable": "Não foi possível ler os dados da peça a partir deste link. Preencha os campos manualmente. A peça e o link podem ser guardados na mesma.",
     "image": "Imagem",
     "image": "Imagem",
     "dropOrClick": "Largue uma imagem ou clique",
     "dropOrClick": "Largue uma imagem ou clique",
     "replace": "Substituir",
     "replace": "Substituir",

+ 4 - 2
messages/ru/inventory.json

@@ -81,9 +81,11 @@
     "addNewPart": "Добавить новую запчасть",
     "addNewPart": "Добавить новую запчасть",
     "supplierLink": "Ссылка на поставщика",
     "supplierLink": "Ссылка на поставщика",
     "supplierLinkPlaceholder": "https://supplier.com/product/...",
     "supplierLinkPlaceholder": "https://supplier.com/product/...",
+    "supplierLinkInvalid": "Ссылка на поставщика не является веб-адресом. Исправьте её или очистите поле, чтобы сохранить без ссылки.",
     "fetch": "Загрузить",
     "fetch": "Загрузить",
-    "fetchFailed": "Загрузка не удалась",
-    "fetchError": "Не удалось получить метаданные по URL",
+    "fetchBlocked": "Сайт этого поставщика блокирует автоматическую загрузку данных. Заполните поля вручную. Запчасть и ссылку всё равно можно сохранить.",
+    "fetchTimedOut": "Сайт поставщика слишком долго не отвечал. Попробуйте ещё раз или заполните поля вручную. Запчасть и ссылку всё равно можно сохранить.",
+    "fetchUnreadable": "Не удалось прочитать данные о запчасти по этой ссылке. Заполните поля вручную. Запчасть и ссылку всё равно можно сохранить.",
     "image": "Изображение",
     "image": "Изображение",
     "dropOrClick": "Перетащите или нажмите",
     "dropOrClick": "Перетащите или нажмите",
     "replace": "Заменить",
     "replace": "Заменить",

+ 4 - 2
messages/tr/inventory.json

@@ -81,9 +81,11 @@
     "addNewPart": "Yeni parça ekle",
     "addNewPart": "Yeni parça ekle",
     "supplierLink": "Tedarikçi linki",
     "supplierLink": "Tedarikçi linki",
     "supplierLinkPlaceholder": "https://supplier.com/product/...",
     "supplierLinkPlaceholder": "https://supplier.com/product/...",
+    "supplierLinkInvalid": "Tedarikçi linki bir web adresi değil. Düzeltin veya linksiz kaydetmek için alanı temizleyin.",
     "fetch": "Getir",
     "fetch": "Getir",
-    "fetchFailed": "Getirme başarısız",
-    "fetchError": "URL'den meta veriler alınamadı",
+    "fetchBlocked": "Bu tedarikçinin sitesi otomatik sorgulamayı engelliyor. Alanları kendiniz doldurun. Parça ve link yine de kaydedilebilir.",
+    "fetchTimedOut": "Tedarikçinin sitesinin yanıt vermesi çok uzun sürdü. Tekrar deneyin veya alanları kendiniz doldurun. Parça ve link yine de kaydedilebilir.",
+    "fetchUnreadable": "Bu linkten parça bilgileri okunamadı. Alanları kendiniz doldurun. Parça ve link yine de kaydedilebilir.",
     "image": "Görsel",
     "image": "Görsel",
     "dropOrClick": "Görsel bırakın veya tıklayın",
     "dropOrClick": "Görsel bırakın veya tıklayın",
     "replace": "Değiştir",
     "replace": "Değiştir",

+ 56 - 2
src/__tests__/api/fetch-metadata-ssrf.test.ts

@@ -63,7 +63,7 @@ describe('POST /api/protected/fetch-metadata — SSRF guard', () => {
     mockCheck.mockResolvedValue({ ok: false, reason: 'private_ip' })
     mockCheck.mockResolvedValue({ ok: false, reason: 'private_ip' })
     const r = await POST(req('http://169.254.169.254/latest/meta-data/'))
     const r = await POST(req('http://169.254.169.254/latest/meta-data/'))
     expect(r.status).toBe(400)
     expect(r.status).toBe(400)
-    expect(await r.json()).toEqual({ error: 'This URL is not allowed.' })
+    expect(await r.json()).toEqual({ code: 'not_allowed', error: 'This URL is not allowed.' })
     expect(mockCheck).toHaveBeenCalledWith('http://169.254.169.254/latest/meta-data/')
     expect(mockCheck).toHaveBeenCalledWith('http://169.254.169.254/latest/meta-data/')
     expect(mockFetch).not.toHaveBeenCalled()
     expect(mockFetch).not.toHaveBeenCalled()
   })
   })
@@ -116,6 +116,60 @@ describe('POST /api/protected/fetch-metadata — SSRF guard', () => {
     mockFetch.mockImplementation(async () => res(302, { location: `https://hop.example/${n++}` }))
     mockFetch.mockImplementation(async () => res(302, { location: `https://hop.example/${n++}` }))
     const r = await POST(req('http://start.example'))
     const r = await POST(req('http://start.example'))
     expect(r.status).toBe(422)
     expect(r.status).toBe(422)
-    expect(await r.json()).toEqual({ error: 'Too many redirects' })
+    expect(await r.json()).toEqual({ code: 'too_many_redirects', error: 'Too many redirects' })
+  })
+})
+
+/**
+ * The part form shows its own translated note for a lookup that found nothing,
+ * so every failure carries a code it can tell apart; the English `error` is
+ * only there for other callers. A supplier behind a bot check is the one the
+ * form treats differently: it stops offering Fetch for that link.
+ */
+describe('POST /api/protected/fetch-metadata, why a lookup found nothing', () => {
+  beforeEach(() => {
+    mockCheck.mockResolvedValue({ ok: true })
+  })
+
+  it.each([
+    ['a Cloudflare challenge', res(403, { 'cf-mitigated': 'challenge' })],
+    ['a 503 challenge page', res(503, { 'cf-mitigated': 'challenge', server: 'cloudflare' })],
+    ['a 403 served by Cloudflare', res(403, { server: 'cloudflare' })],
+  ])('answers bot_protected for %s', async (_what, response) => {
+    mockFetch.mockResolvedValue(response)
+    const r = await POST(req('https://supplier.example/parts/1'))
+    expect(r.status).toBe(422)
+    expect(await r.json()).toEqual({
+      code: 'bot_protected',
+      error: 'This site has bot protection. Please fill in the fields manually.',
+    })
+  })
+
+  it('answers http_error when the site itself refuses, with no bot check involved', async () => {
+    mockFetch.mockResolvedValue(res(403, { server: 'nginx' }))
+    const r = await POST(req('https://supplier.example/parts/1'))
+    expect(r.status).toBe(422)
+    expect(await r.json()).toEqual({ code: 'http_error', error: 'Failed to fetch URL (403)' })
+  })
+
+  it('answers not_html for a link to something other than a page', async () => {
+    mockFetch.mockResolvedValue(res(200, { 'content-type': 'application/pdf' }))
+    const r = await POST(req('https://supplier.example/datasheet.pdf'))
+    expect(r.status).toBe(422)
+    expect(await r.json()).toEqual({ code: 'not_html', error: 'URL does not return HTML content' })
+  })
+
+  it('answers timeout when the site takes too long', async () => {
+    mockFetch.mockRejectedValue(Object.assign(new Error('aborted'), { name: 'AbortError' }))
+    const r = await POST(req('https://supplier.example/parts/1'))
+    expect(r.status).toBe(422)
+    expect(await r.json()).toEqual({ code: 'timeout', error: 'Request timed out' })
+  })
+
+  it('answers fetch_failed when the site cannot be reached at all', async () => {
+    mockFetch.mockRejectedValue(new TypeError('fetch failed'))
+    const r = await POST(req('https://supplier.example/parts/1'))
+    expect(r.status).toBe(422)
+    expect(await r.json()).toEqual({ code: 'fetch_failed', error: 'Failed to fetch metadata' })
   })
   })
 })
 })

+ 255 - 0
src/__tests__/features/inventory/part-form-supplier-fetch.test.tsx

@@ -0,0 +1,255 @@
+/**
+ * Fetch, next to the supplier link on the part form, is a convenience: the
+ * server reads the supplier's page and prefills the fields. Some suppliers
+ * sit behind a bot check and turn that read away.
+ *
+ * Two people met an error dialog titled "Fetch Failed" there, pressed Fetch
+ * again and again, and concluded the part could not be saved. Saving was
+ * never blocked. A lookup that finds nothing now says so under the link, in
+ * the user's language, and says that the part still saves.
+ */
+
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+// The form asks whether this is a touch device; jsdom has no matchMedia.
+// biome-ignore lint/suspicious/noEmptyBlockStatements: a stub that listens to nothing
+const noop = () => {}
+vi.stubGlobal('matchMedia', (query: string) => ({
+  matches: false,
+  media: query,
+  addEventListener: noop,
+  removeEventListener: noop,
+}))
+
+vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) }))
+vi.mock('sonner', () => ({
+  toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn() },
+}))
+
+const createInventoryPart = vi.fn()
+vi.mock('@/features/inventory/Actions/inventoryActions', () => ({
+  createInventoryPart: (input: unknown) => createInventoryPart(input),
+  updateInventoryPart: vi.fn(),
+  deleteOrphanedUploads: vi.fn(),
+}))
+vi.mock('@/features/inventory/Actions/aiAnalyzePartImage', () => ({
+  aiAnalyzePartImage: vi.fn(),
+}))
+
+import { GlassModal, useGlassModal } from '@/components/glass-modal'
+import { InventoryPartForm } from '@/features/inventory/Components/InventoryPartForm'
+
+const LINK = 'https://supplier.example/parts/brake-pad'
+
+const BLOCKED =
+  "This supplier's site blocks automatic lookup. Fill in the fields yourself. The part and the link still save."
+const TIMED_OUT =
+  "The supplier's site took too long to answer. Try again, or fill in the fields yourself. The part and the link still save."
+const UNREADABLE =
+  'Could not read the part details from this link. Fill in the fields yourself. The part and the link still save.'
+
+/** What the lookup route answers when Cloudflare challenges the server. */
+const BOT_PROTECTED = {
+  code: 'bot_protected',
+  error: 'This site has bot protection. Please fill in the fields manually.',
+}
+
+const lookup = vi.fn()
+
+function answer(status: number, body: unknown) {
+  lookup.mockResolvedValueOnce({
+    ok: status >= 200 && status < 300,
+    status,
+    json: async () => body,
+  })
+}
+
+/** The form with the app's error dialog mounted beside it, as in the real shell. */
+function renderForm() {
+  const onOpenChange = vi.fn()
+  render(
+    <>
+      <InventoryPartForm open onOpenChange={onOpenChange} />
+      <GlassModal />
+    </>
+  )
+  return { onOpenChange }
+}
+
+function typeLink(value: string) {
+  fireEvent.change(screen.getByLabelText('Supplier Link'), { target: { value } })
+}
+
+function fetchButton() {
+  return screen.getByRole('button', { name: 'Fetch' })
+}
+
+function fetchFrom(link: string) {
+  typeLink(link)
+  fireEvent.click(fetchButton())
+}
+
+beforeEach(() => {
+  lookup.mockReset()
+  createInventoryPart.mockReset()
+  vi.stubGlobal('fetch', lookup)
+  // The error dialog is one store for the whole app; start each test with it shut.
+  useGlassModal.getState().close()
+})
+
+describe('Fetch on a supplier link that turns the lookup away', () => {
+  it('says so under the link, opens no error dialog, and the part still saves', async () => {
+    answer(422, BOT_PROTECTED)
+    createInventoryPart.mockResolvedValue({ success: true })
+    const { onOpenChange } = renderForm()
+
+    fetchFrom(LINK)
+
+    expect(await screen.findByText(BLOCKED)).toBeInTheDocument()
+    // The form is still the only dialog: nothing opened over it, and the
+    // server's English sentence is not what the user reads.
+    expect(screen.getAllByRole('dialog', { hidden: true })).toHaveLength(1)
+    expect(screen.getByRole('dialog', { name: 'Add New Part' })).toBeInTheDocument()
+    expect(screen.queryByText(BOT_PROTECTED.error)).toBeNull()
+
+    fireEvent.change(screen.getByLabelText('Name *'), { target: { value: 'Brake pad front' } })
+    fireEvent.click(screen.getByRole('button', { name: 'Add Part' }))
+
+    await waitFor(() =>
+      expect(createInventoryPart).toHaveBeenCalledWith(
+        expect.objectContaining({ name: 'Brake pad front', supplierUrl: LINK })
+      )
+    )
+    await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false))
+  })
+
+  it('stops Fetch for that link, and lets it go again once the link is edited', async () => {
+    answer(422, BOT_PROTECTED)
+    renderForm()
+
+    fetchFrom(LINK)
+
+    expect(await screen.findByText(BLOCKED)).toBeInTheDocument()
+    expect(fetchButton()).toBeDisabled()
+
+    typeLink('https://other-supplier.example/parts/brake-pad')
+
+    expect(screen.queryByText(BLOCKED)).toBeNull()
+    expect(fetchButton()).toBeEnabled()
+  })
+})
+
+describe('Fetch that finds nothing for another reason', () => {
+  const answers: [why: string, body: unknown, note: string][] = [
+    ['the supplier answers too slowly', { code: 'timeout', error: 'Request timed out' }, TIMED_OUT],
+    [
+      'the supplier answers with an error',
+      { code: 'http_error', error: 'Failed to fetch URL (503)' },
+      UNREADABLE,
+    ],
+    ['the answer names no reason', {}, UNREADABLE],
+  ]
+
+  it.each(answers)('says so under the link when %s', async (_why, body, note) => {
+    answer(422, body)
+    renderForm()
+
+    fetchFrom(LINK)
+
+    expect(await screen.findByText(note)).toBeInTheDocument()
+    expect(screen.getAllByRole('dialog', { hidden: true })).toHaveLength(1)
+    // Unlike a site that blocks the lookup, these may answer the next time.
+    expect(fetchButton()).toBeEnabled()
+  })
+
+  it('says so under the link when the request never reaches the server', async () => {
+    lookup.mockRejectedValueOnce(new TypeError('Failed to fetch'))
+    renderForm()
+
+    fetchFrom(LINK)
+
+    expect(await screen.findByText(UNREADABLE)).toBeInTheDocument()
+    expect(screen.getAllByRole('dialog', { hidden: true })).toHaveLength(1)
+  })
+
+  it('takes the note away once a later fetch reads the page', async () => {
+    answer(422, { code: 'http_error', error: 'Failed to fetch URL (503)' })
+    renderForm()
+
+    fetchFrom(LINK)
+    expect(await screen.findByText(UNREADABLE)).toBeInTheDocument()
+
+    answer(200, { name: 'Brake pad front' })
+    fireEvent.click(fetchButton())
+
+    await waitFor(() => expect(screen.getByLabelText('Name *')).toHaveValue('Brake pad front'))
+    expect(screen.queryByText(UNREADABLE)).toBeNull()
+  })
+})
+
+describe('A supplier link typed without https://', () => {
+  const TYPED = 'www.supplier.example/parts/brake-pad'
+  const NORMALISED = 'https://www.supplier.example/parts/brake-pad'
+
+  /** Answers the way the real action does: the server schema checks the input first. */
+  async function saveLikeTheServer(input: unknown) {
+    const { createInventoryPartSchema } = await import(
+      '@/features/inventory/Schema/inventorySchema'
+    )
+    const parsed = createInventoryPartSchema.safeParse(input)
+    if (parsed.success) return { success: true }
+    const message = parsed.error.issues
+      .map((issue) => `${issue.path.join('.')}: ${issue.message}`)
+      .join('. ')
+    return { success: false, error: message }
+  }
+
+  function save(name: string) {
+    fireEvent.change(screen.getByLabelText('Name *'), { target: { value: name } })
+    fireEvent.click(screen.getByRole('button', { name: 'Add Part' }))
+  }
+
+  it('is looked up as an https:// link, and the field shows it', async () => {
+    answer(200, { name: 'Brake pad front' })
+    renderForm()
+
+    fetchFrom(TYPED)
+
+    await waitFor(() => expect(lookup).toHaveBeenCalledTimes(1))
+    const [, init] = lookup.mock.calls[0] as [string, RequestInit]
+    expect(JSON.parse(String(init.body))).toEqual({ url: NORMALISED })
+    expect(screen.getByLabelText('Supplier Link')).toHaveValue(NORMALISED)
+    await waitFor(() => expect(screen.getByLabelText('Name *')).toHaveValue('Brake pad front'))
+  })
+
+  it('saves with https:// in front', async () => {
+    createInventoryPart.mockImplementation(saveLikeTheServer)
+    const { onOpenChange } = renderForm()
+
+    typeLink(TYPED)
+    save('Brake pad front')
+
+    await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false))
+    expect(createInventoryPart).toHaveBeenCalledWith(
+      expect.objectContaining({ name: 'Brake pad front', supplierUrl: NORMALISED })
+    )
+    expect(screen.queryByText(/Invalid URL/)).toBeNull()
+  })
+
+  it('says in plain words when what was typed is not a link', async () => {
+    createInventoryPart.mockImplementation(saveLikeTheServer)
+    const { onOpenChange } = renderForm()
+
+    typeLink('ask at the counter')
+    save('Brake pad front')
+
+    expect(
+      await screen.findByText(
+        'The supplier link is not a web address. Correct it, or clear it to save without one.'
+      )
+    ).toBeInTheDocument()
+    expect(screen.queryByText(/Invalid URL/)).toBeNull()
+    expect(onOpenChange).not.toHaveBeenCalledWith(false)
+  })
+})

+ 45 - 0
src/__tests__/features/inventory/supplier-url.test.ts

@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest'
+import { isSupplierUrl, normalizeSupplierUrl } from '@/features/inventory/Lib/supplierUrl'
+
+describe('normalizeSupplierUrl', () => {
+  it('leaves links that already have a scheme alone', () => {
+    expect(normalizeSupplierUrl('https://www.x.com/a')).toBe('https://www.x.com/a')
+    expect(normalizeSupplierUrl('http://x.com/a?b=1')).toBe('http://x.com/a?b=1')
+  })
+
+  it('trims what was typed around the link', () => {
+    expect(normalizeSupplierUrl('  https://x.com/a \n')).toBe('https://x.com/a')
+    expect(normalizeSupplierUrl('  www.x.com/a  ')).toBe('https://www.x.com/a')
+  })
+
+  it('puts https:// in front of a link typed from its host', () => {
+    expect(normalizeSupplierUrl('www.x.com/a')).toBe('https://www.x.com/a')
+    expect(normalizeSupplierUrl('x.com')).toBe('https://x.com')
+    expect(normalizeSupplierUrl('shop.x.co.uk:8080/p?id=1#top')).toBe(
+      'https://shop.x.co.uk:8080/p?id=1#top'
+    )
+    expect(normalizeSupplierUrl('bildeler.nø/del')).toBe('https://bildeler.nø/del')
+  })
+
+  it('puts https:// in front even when the link carries another link in its query', () => {
+    const typed = 'www.x.com/part/123?return=https://x.com/catalog'
+    expect(normalizeSupplierUrl(typed)).toBe(`https://${typed}`)
+    expect(isSupplierUrl(normalizeSupplierUrl(typed))).toBe(true)
+  })
+
+  it('leaves an empty link empty', () => {
+    expect(normalizeSupplierUrl('')).toBe('')
+    expect(normalizeSupplierUrl('   ')).toBe('')
+  })
+
+  it('does not turn something that is not a host into a link', () => {
+    expect(normalizeSupplierUrl('ask at the counter')).toBe('ask at the counter')
+    expect(normalizeSupplierUrl('brakes')).toBe('brakes')
+    expect(normalizeSupplierUrl('see note. thanks')).toBe('see note. thanks')
+  })
+
+  it('gives the server a link it accepts, and keeps refusing what is not one', () => {
+    expect(isSupplierUrl(normalizeSupplierUrl('www.x.com/a'))).toBe(true)
+    expect(isSupplierUrl(normalizeSupplierUrl('ask at the counter'))).toBe(false)
+  })
+})

+ 26 - 12
src/app/api/protected/fetch-metadata/route.ts

@@ -17,6 +17,23 @@ interface Metadata {
   imageUrl?: string
   imageUrl?: string
 }
 }
 
 
+/**
+ * Why a lookup gave nothing. The part form picks a translated message by this
+ * code; `error` stays as the English text for anything else calling the route.
+ */
+type LookupFailure =
+  | 'not_allowed'
+  | 'too_many_redirects'
+  | 'bot_protected'
+  | 'http_error'
+  | 'not_html'
+  | 'timeout'
+  | 'fetch_failed'
+
+function lookupFailed(code: LookupFailure, error: string, status = 422) {
+  return NextResponse.json({ code, error }, { status })
+}
+
 export async function POST(request: NextRequest) {
 export async function POST(request: NextRequest) {
   const session = await auth.api.getSession({
   const session = await auth.api.getSession({
     headers: await headers(),
     headers: await headers(),
@@ -74,7 +91,7 @@ export async function POST(request: NextRequest) {
       const safety = await checkWebhookUrl(currentUrl)
       const safety = await checkWebhookUrl(currentUrl)
       if (!safety.ok) {
       if (!safety.ok) {
         clearTimeout(timeout)
         clearTimeout(timeout)
-        return NextResponse.json({ error: 'This URL is not allowed.' }, { status: 400 })
+        return lookupFailed('not_allowed', 'This URL is not allowed.', 400)
       }
       }
 
 
       response = await fetch(currentUrl, {
       response = await fetch(currentUrl, {
@@ -95,7 +112,7 @@ export async function POST(request: NextRequest) {
 
 
       if (redirects >= MAX_REDIRECTS) {
       if (redirects >= MAX_REDIRECTS) {
         clearTimeout(timeout)
         clearTimeout(timeout)
-        return NextResponse.json({ error: 'Too many redirects' }, { status: 422 })
+        return lookupFailed('too_many_redirects', 'Too many redirects')
       }
       }
       // Resolve relative redirects against the current URL; drop any fragment.
       // Resolve relative redirects against the current URL; drop any fragment.
       currentUrl = new URL(location, currentUrl).toString().split('#')[0]
       currentUrl = new URL(location, currentUrl).toString().split('#')[0]
@@ -111,20 +128,17 @@ export async function POST(request: NextRequest) {
         cfMitigated === 'challenge' ||
         cfMitigated === 'challenge' ||
         (response.status === 403 && server.includes('cloudflare'))
         (response.status === 403 && server.includes('cloudflare'))
       ) {
       ) {
-        return NextResponse.json(
-          { error: 'This site has bot protection. Please fill in the fields manually.' },
-          { status: 422 }
+        return lookupFailed(
+          'bot_protected',
+          'This site has bot protection. Please fill in the fields manually.'
         )
         )
       }
       }
-      return NextResponse.json(
-        { error: `Failed to fetch URL (${response.status})` },
-        { status: 422 }
-      )
+      return lookupFailed('http_error', `Failed to fetch URL (${response.status})`)
     }
     }
 
 
     const contentType = response.headers.get('content-type') || ''
     const contentType = response.headers.get('content-type') || ''
     if (!contentType.includes('text/html') && !contentType.includes('application/xhtml')) {
     if (!contentType.includes('text/html') && !contentType.includes('application/xhtml')) {
-      return NextResponse.json({ error: 'URL does not return HTML content' }, { status: 422 })
+      return lookupFailed('not_html', 'URL does not return HTML content')
     }
     }
 
 
     const html = await response.text()
     const html = await response.text()
@@ -133,9 +147,9 @@ export async function POST(request: NextRequest) {
     return NextResponse.json(metadata)
     return NextResponse.json(metadata)
   } catch (err) {
   } catch (err) {
     if (err instanceof Error && err.name === 'AbortError') {
     if (err instanceof Error && err.name === 'AbortError') {
-      return NextResponse.json({ error: 'Request timed out' }, { status: 422 })
+      return lookupFailed('timeout', 'Request timed out')
     }
     }
-    return NextResponse.json({ error: 'Failed to fetch metadata' }, { status: 422 })
+    return lookupFailed('fetch_failed', 'Failed to fetch metadata')
   }
   }
 }
 }
 
 

+ 64 - 15
src/features/inventory/Components/InventoryPartForm.tsx

@@ -27,6 +27,7 @@ import {
   deleteOrphanedUploads,
   deleteOrphanedUploads,
 } from '../Actions/inventoryActions'
 } from '../Actions/inventoryActions'
 import { aiAnalyzePartImage } from '../Actions/aiAnalyzePartImage'
 import { aiAnalyzePartImage } from '../Actions/aiAnalyzePartImage'
+import { isSupplierUrl, normalizeSupplierUrl } from '../Lib/supplierUrl'
 import {
 import {
   Camera,
   Camera,
   Check,
   Check,
@@ -34,6 +35,7 @@ import {
   ExternalLink,
   ExternalLink,
   History,
   History,
   ImageIcon,
   ImageIcon,
+  Info,
   Loader2,
   Loader2,
   Plus,
   Plus,
   Sparkles,
   Sparkles,
@@ -111,6 +113,22 @@ interface InventoryPartFormProps {
   }
   }
 }
 }
 
 
+/**
+ * Why Fetch filled nothing in. It is a convenience: the server reads the
+ * supplier's page to prefill the fields, and a page it cannot read never stops
+ * the part from being saved. So the answer is a note under the link, not an
+ * error dialog over the form.
+ */
+type FetchIssue = 'blocked' | 'timedOut' | 'unreadable'
+
+/** Reads the `code` the lookup route sends with a failure. */
+function fetchIssueFrom(body: unknown): FetchIssue {
+  const code = typeof body === 'object' && body !== null && 'code' in body ? body.code : undefined
+  if (code === 'bot_protected') return 'blocked'
+  if (code === 'timeout') return 'timedOut'
+  return 'unreadable'
+}
+
 export function InventoryPartForm({
 export function InventoryPartForm({
   open,
   open,
   onOpenChange,
   onOpenChange,
@@ -138,6 +156,9 @@ export function InventoryPartForm({
   const [uploading, setUploading] = useState(false)
   const [uploading, setUploading] = useState(false)
   const [analyzing, setAnalyzing] = useState(false)
   const [analyzing, setAnalyzing] = useState(false)
   const [supplierUrl, setSupplierUrl] = useState(part?.supplierUrl ?? '')
   const [supplierUrl, setSupplierUrl] = useState(part?.supplierUrl ?? '')
+  const [fetchIssue, setFetchIssue] = useState<{ url: string; issue: FetchIssue } | null>(null)
+  // An answer that arrives after the link was edited says nothing about the new one.
+  const shownIssue = fetchIssue?.url === supplierUrl ? fetchIssue.issue : null
   const [category, setCategory] = useState(part?.category ?? '')
   const [category, setCategory] = useState(part?.category ?? '')
   const [unit, setUnit] = useState(part ? (part.unit ?? '') : (defaultUnit ?? ''))
   const [unit, setUnit] = useState(part ? (part.unit ?? '') : (defaultUnit ?? ''))
   const [categoryOpen, setCategoryOpen] = useState(false)
   const [categoryOpen, setCategoryOpen] = useState(false)
@@ -171,6 +192,7 @@ export function InventoryPartForm({
     if (open) {
     if (open) {
       uploadedUrlsRef.current = []
       uploadedUrlsRef.current = []
       setSupplierUrl(part?.supplierUrl ?? '')
       setSupplierUrl(part?.supplierUrl ?? '')
+      setFetchIssue(null)
       setCategory(part?.category ?? '')
       setCategory(part?.category ?? '')
       setUnit(part ? (part.unit ?? '') : (defaultUnit ?? ''))
       setUnit(part ? (part.unit ?? '') : (defaultUnit ?? ''))
       setCategorySearch('')
       setCategorySearch('')
@@ -248,16 +270,13 @@ export function InventoryPartForm({
 
 
   const handleFetchMetadata = useCallback(
   const handleFetchMetadata = useCallback(
     async (url?: string) => {
     async (url?: string) => {
-      const targetUrl = url ?? supplierUrl
-      if (!targetUrl) return
-
-      try {
-        new URL(targetUrl)
-      } catch {
-        return
-      }
+      // A link typed as `www.supplier.com/...` is looked up, and kept, as https://.
+      const targetUrl = normalizeSupplierUrl(url ?? supplierUrl)
+      if (!targetUrl || !isSupplierUrl(targetUrl)) return
+      if (url === undefined && targetUrl !== supplierUrl) setSupplierUrl(targetUrl)
 
 
       setFetching(true)
       setFetching(true)
+      setFetchIssue(null)
       try {
       try {
         const res = await fetch('/api/protected/fetch-metadata', {
         const res = await fetch('/api/protected/fetch-metadata', {
           method: 'POST',
           method: 'POST',
@@ -266,8 +285,8 @@ export function InventoryPartForm({
         })
         })
 
 
         if (!res.ok) {
         if (!res.ok) {
-          const data = await res.json().catch(() => ({}))
-          modal.open('error', t('form.fetchFailed'), data.error || t('form.fetchError'))
+          const body: unknown = await res.json().catch(() => null)
+          setFetchIssue({ url: targetUrl, issue: fetchIssueFrom(body) })
           return
           return
         }
         }
 
 
@@ -302,12 +321,12 @@ export function InventoryPartForm({
           setGallery([{ url: metadata.imageUrl, sortOrder: 0 }])
           setGallery([{ url: metadata.imageUrl, sortOrder: 0 }])
         }
         }
       } catch {
       } catch {
-        modal.open('error', t('form.fetchFailed'), t('form.fetchError'))
+        setFetchIssue({ url: targetUrl, issue: 'unreadable' })
       } finally {
       } finally {
         setFetching(false)
         setFetching(false)
       }
       }
     },
     },
-    [supplierUrl, gallery, modal]
+    [supplierUrl, gallery]
   )
   )
 
 
   const handleAiAnalyze = useCallback(async () => {
   const handleAiAnalyze = useCallback(async () => {
@@ -394,6 +413,14 @@ export function InventoryPartForm({
       return
       return
     }
     }
 
 
+    // Stored as the server will check it: `www.supplier.com/...` gets https://.
+    const link = normalizeSupplierUrl(supplierUrl)
+    if (link !== supplierUrl) setSupplierUrl(link)
+    if (link && !isSupplierUrl(link)) {
+      modal.open('error', t('errors.error'), t('form.supplierLinkInvalid'))
+      return
+    }
+
     setLoading(true)
     setLoading(true)
     // An emptied field reaches the update action as '' so it can be cleared.
     // An emptied field reaches the update action as '' so it can be cleared.
     const optional = (value: FormDataEntryValue | string | null) =>
     const optional = (value: FormDataEntryValue | string | null) =>
@@ -412,7 +439,7 @@ export function InventoryPartForm({
       supplier: optional(formData.get('supplier')),
       supplier: optional(formData.get('supplier')),
       supplierPhone: optional(formData.get('supplierPhone')),
       supplierPhone: optional(formData.get('supplierPhone')),
       supplierEmail: optional(formData.get('supplierEmail')),
       supplierEmail: optional(formData.get('supplierEmail')),
-      supplierUrl: optional(supplierUrl),
+      supplierUrl: optional(link),
       gallery: gallery.map((g, i) => ({
       gallery: gallery.map((g, i) => ({
         id: g.id,
         id: g.id,
         url: g.url,
         url: g.url,
@@ -485,7 +512,11 @@ export function InventoryPartForm({
                     id="supplierUrl"
                     id="supplierUrl"
                     placeholder={t('form.supplierLinkPlaceholder')}
                     placeholder={t('form.supplierLinkPlaceholder')}
                     value={supplierUrl}
                     value={supplierUrl}
-                    onChange={(e) => setSupplierUrl(e.target.value)}
+                    aria-describedby={shownIssue ? 'supplierUrl-note' : undefined}
+                    onChange={(e) => {
+                      setSupplierUrl(e.target.value)
+                      setFetchIssue(null)
+                    }}
                   />
                   />
                   {supplierUrl &&
                   {supplierUrl &&
                     (() => {
                     (() => {
@@ -510,13 +541,31 @@ export function InventoryPartForm({
                   type="button"
                   type="button"
                   variant="outline"
                   variant="outline"
                   size="sm"
                   size="sm"
-                  disabled={fetching || !supplierUrl}
+                  // A site that turns the lookup away answers the same for the
+                  // same link, so Fetch waits for the link to change.
+                  disabled={fetching || !supplierUrl || shownIssue === 'blocked'}
                   onClick={() => handleFetchMetadata()}
                   onClick={() => handleFetchMetadata()}
                 >
                 >
                   {fetching ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : null}
                   {fetching ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : null}
                   {t('form.fetch')}
                   {t('form.fetch')}
                 </Button>
                 </Button>
               </div>
               </div>
+              {shownIssue && (
+                <p
+                  id="supplierUrl-note"
+                  role="status"
+                  className="flex items-start gap-1.5 text-sm text-muted-foreground"
+                >
+                  <Info className="mt-0.5 h-4 w-4 shrink-0" />
+                  <span>
+                    {shownIssue === 'blocked'
+                      ? t('form.fetchBlocked')
+                      : shownIssue === 'timedOut'
+                        ? t('form.fetchTimedOut')
+                        : t('form.fetchUnreadable')}
+                  </span>
+                </p>
+              )}
             </div>
             </div>
 
 
             {/* Image + fields: stacked on mobile, side-by-side on sm+ */}
             {/* Image + fields: stacked on mobile, side-by-side on sm+ */}

+ 28 - 0
src/features/inventory/Lib/supplierUrl.ts

@@ -0,0 +1,28 @@
+import { z } from 'zod'
+
+/** What the server accepts as a supplier link. The part form checks with the same rule. */
+export const supplierUrlSchema = z.string().url()
+
+// A host with at least one dot (letters in any script), an optional port, then
+// anything after a slash, question mark or hash. Plain words never match.
+const BARE_HOST = /^[\p{L}\p{N}-]+(\.[\p{L}\p{N}-]+)+(:\d+)?([/?#].*)?$/u
+
+// A scheme at the very start, such as `https://`. A link inside the query string does not count.
+const HAS_SCHEME = /^[a-z][a-z\d+.-]*:\/\//i
+
+/**
+ * A supplier link as a person types it. People type `www.supplier.com/part/1`
+ * the way they would into a browser's address bar, so a link that starts with
+ * a host gets `https://` in front. Anything with a scheme already, and anything
+ * that is not a host, is only trimmed.
+ */
+export function normalizeSupplierUrl(value: string): string {
+  const trimmed = value.trim()
+  if (!trimmed || HAS_SCHEME.test(trimmed)) return trimmed
+  return BARE_HOST.test(trimmed) ? `https://${trimmed}` : trimmed
+}
+
+/** Whether the server will take this (already normalised) supplier link. */
+export function isSupplierUrl(value: string): boolean {
+  return supplierUrlSchema.safeParse(value).success
+}

+ 2 - 1
src/features/inventory/Schema/inventorySchema.ts

@@ -1,6 +1,7 @@
 import { z } from 'zod'
 import { z } from 'zod'
 import { imageUrlSchema } from '@/lib/upload-url'
 import { imageUrlSchema } from '@/lib/upload-url'
 import { MAX_PART_TAGS, MAX_TAG_LENGTH } from '../Lib/partTags'
 import { MAX_PART_TAGS, MAX_TAG_LENGTH } from '../Lib/partTags'
+import { supplierUrlSchema } from '../Lib/supplierUrl'
 
 
 export const createInventoryPartSchema = z.object({
 export const createInventoryPartSchema = z.object({
   name: z.string().min(1, 'Part name is required'),
   name: z.string().min(1, 'Part name is required'),
@@ -16,7 +17,7 @@ export const createInventoryPartSchema = z.object({
   supplier: z.string().optional(),
   supplier: z.string().optional(),
   supplierPhone: z.string().optional(),
   supplierPhone: z.string().optional(),
   supplierEmail: z.string().optional(),
   supplierEmail: z.string().optional(),
-  supplierUrl: z.string().url().optional().or(z.literal('')),
+  supplierUrl: supplierUrlSchema.optional().or(z.literal('')),
   gallery: z
   gallery: z
     .array(
     .array(
       z.object({
       z.object({