From 2cae299b23ecff7cb99d7e15f22475a95468ec60 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Mon, 11 May 2026 22:40:32 -0500 Subject: [PATCH] fix(new-contact): refresh path and QRZ panel on every callsign search Two issues on the Log a contact page when searching a callsign: 1. Stale data persisted across searches. The lookup handler merged results with `prev` (`data.grid_locator || prev.gridLocator`), so when a new operator's QRZ record returned no grid, the previous operator's grid (and lat/lng) silently survived and the path map never moved. The callsign field also held the old name/qth/grid while the next lookup was in flight. Now the lookup-derived fields reset on every callsign change and are replaced (not merged) when the new lookup returns. 2. The "verified" panel only displayed initials + name + grid + country + qth, throwing away the rest of QRZ's response. The panel now shows the operator's QRZ profile image (with initials fallback on missing or broken image), nickname, MapPin'd address line, license-class chip (Extra/General/Tech/Club/...), LoTW/eQSL participation chips, QSL via, and a View on QRZ link. lib/qrz.ts now parses image, nickname, aliases, city, state, class, lotw, eqsl from the XML. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/new-contact/page.tsx | 192 ++++++++++++++++++++++++++++------- src/lib/qrz.ts | 22 +++- 2 files changed, 178 insertions(+), 36 deletions(-) diff --git a/src/app/new-contact/page.tsx b/src/app/new-contact/page.tsx index 6290753..23d317b 100644 --- a/src/app/new-contact/page.tsx +++ b/src/app/new-contact/page.tsx @@ -9,6 +9,8 @@ import { AlertCircle, Radio, Plus, + ExternalLink, + MapPin, } from 'lucide-react'; import Navbar from '@/components/Navbar'; @@ -80,6 +82,55 @@ function freqToBand(freq: number): string { return ''; } +// QRZ XML license-class codes — used to give the chip a human-readable label. +const LICENSE_CLASS_LABELS: Record = { + E: 'Extra', + A: 'Advanced', + G: 'General', + P: 'Tech Plus', + T: 'Tech', + N: 'Novice', + C: 'Club', +}; + +function CallsignAvatar({ + image, + fallback, +}: { + image?: string; + fallback: string; +}) { + const [errored, setErrored] = useState(false); + const showImage = image && !errored; + return ( + + ); +} + function rstToBars(rst: string | undefined, mode: string): number { if (!rst) return 0; // Voice/CW RST values like 59, 599, 57 — readability is the first digit (1-5) @@ -125,11 +176,21 @@ export default function NewContactPage() { const [lookupResult, setLookupResult] = useState<{ found: boolean; name?: string; + nickname?: string; + aliases?: string; qth?: string; + city?: string; + state?: string; grid_locator?: string; latitude?: number; longitude?: number; country?: string; + class?: string; + lotw?: boolean; + eqsl?: boolean; + image?: string; + qslmgr?: string; + url?: string; error?: string; } | null>(null); @@ -204,11 +265,14 @@ export default function NewContactPage() { if (response.ok) { setLookupResult(data); if (data.found) { + // Replace lookup-derived fields outright. Falling back to `prev` here + // would leave stale grid/coords/QTH on the form when the new callsign + // resolves but doesn't carry one of those fields. setFormData((prev) => ({ ...prev, - name: data.name || prev.name, - qth: data.qth || prev.qth, - gridLocator: data.grid_locator || prev.gridLocator, + name: data.name || '', + qth: data.qth || '', + gridLocator: data.grid_locator || '', latitude: data.latitude, longitude: data.longitude, })); @@ -314,9 +378,24 @@ export default function NewContactPage() { const handleChange = (e: React.ChangeEvent) => { const { name, value } = e.target; - setFormData((prev) => ({ ...prev, [name]: value })); + if (name === 'callsign') { + // Reset lookup-derived state alongside the callsign so the path map and + // grid input don't continue showing the previous operator's data while + // the next lookup is in flight. + setFormData((prev) => ({ + ...prev, + callsign: value, + name: '', + qth: '', + gridLocator: '', + latitude: undefined, + longitude: undefined, + })); + setLookupResult(null); + } else { + setFormData((prev) => ({ ...prev, [name]: value })); + } validateField(name, value); - if (name === 'callsign') setLookupResult(null); }; const handleSelectMode = (value: string) => { @@ -498,43 +577,86 @@ export default function NewContactPage() { {lookupResult ? ( lookupResult.found ? (
- -
- {lookupResult.name ? ( -

- {lookupResult.name} -

- ) : null} -
- {[lookupResult.grid_locator, lookupResult.country] - .filter(Boolean) - .join(' · ')} -
- {lookupResult.qth ? ( -
- {lookupResult.qth} +
+ +
+
+
+

+ {lookupResult.name || formData.callsign.toUpperCase()} +

+ {lookupResult.nickname && + lookupResult.nickname !== lookupResult.name ? ( +
+ “{lookupResult.nickname}” +
+ ) : null} +
+ + + Verified +
- ) : null} + {(lookupResult.city || + lookupResult.state || + lookupResult.country) ? ( +
+ + + {[ + lookupResult.city, + lookupResult.state, + lookupResult.country, + ] + .filter(Boolean) + .join(', ')} + +
+ ) : null} + {lookupResult.grid_locator ? ( +
+ Grid {lookupResult.grid_locator} +
+ ) : null} +
+ {lookupResult.class && + LICENSE_CLASS_LABELS[lookupResult.class] ? ( + + {LICENSE_CLASS_LABELS[lookupResult.class]} + + ) : null} + {lookupResult.lotw ? ( + LoTW user + ) : null} + {lookupResult.eqsl ? ( + eQSL user + ) : null} + {lookupResult.qslmgr ? ( + QSL: {lookupResult.qslmgr} + ) : null} +
+ + View on QRZ + + +
- - - Verified -
) : (
diff --git a/src/lib/qrz.ts b/src/lib/qrz.ts index 7b8e498..78f5b4a 100644 --- a/src/lib/qrz.ts +++ b/src/lib/qrz.ts @@ -2,11 +2,20 @@ export interface QRZLookupResult { callsign: string; name?: string; + nickname?: string; + aliases?: string; qth?: string; + city?: string; + state?: string; grid_locator?: string; latitude?: number; longitude?: number; country?: string; + // QRZ XML license-class code: A/E/G/P/T/N/C + class?: string; + lotw?: boolean; + eqsl?: boolean; + image?: string; email?: string; url?: string; qslmgr?: string; @@ -175,14 +184,25 @@ export async function lookupCallsign(callsign: string, username: string, passwor } } + const lotwField = parseXmlField(lookupXml, 'lotw'); + const eqslField = parseXmlField(lookupXml, 'eqsl'); + return { callsign: callsign.toUpperCase(), name, + nickname: parseXmlField(lookupXml, 'nickname'), + aliases: parseXmlField(lookupXml, 'aliases'), qth: qth || undefined, + city: addr2, + state, grid_locator: gridLocator, latitude, longitude, - country: parseXmlField(lookupXml, 'country'), + country, + class: parseXmlField(lookupXml, 'class'), + lotw: lotwField ? lotwField === '1' : undefined, + eqsl: eqslField ? eqslField === '1' : undefined, + image: parseXmlField(lookupXml, 'image'), email: parseXmlField(lookupXml, 'email'), url: parseXmlField(lookupXml, 'url'), qslmgr: parseXmlField(lookupXml, 'qslmgr'),