import { useEffect, useRef, useState } from 'react'; import { Satellite, Loader2 } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; import { useI18n } from '@/lib/i18n'; export interface RecentSpotQSO { callsign: string; freqKHz: number; mode: string; band?: string; // Award references logged for this QSO, each one complete and ready to read // on a cluster line ("POTA FR-11553"). Empty for an ordinary contact. refs?: string[]; } // SPOT_COMMENT_MAX is the DX cluster comment field: 30 characters, and nodes // truncate what does not fit without saying so. Only the comment BUILT here is // held to it — whatever the operator types is their own business. const SPOT_COMMENT_MAX = 30; // spotComment puts the award references after the mode, the order a cluster // line is read in: "CW POTA FR-11553". // // A reference goes in whole or not at all. Truncation would leave "CW POTA" or // half a park number on the air, which is worse than saying nothing: a spot // nobody can act on still costs everyone who reads it. export function spotComment(mode: string, refs?: string[]): string { let out = (mode || '').trim(); for (const ref of refs ?? []) { const one = ref.trim(); if (!one) continue; const next = out ? `${out} ${one}` : one; if (next.length > SPOT_COMMENT_MAX) continue; // a shorter one may still fit out = next; } return out; } interface Props { open: boolean; onClose: () => void; // Pre-fill values: callsign from the QSO entry (or last logged), the // current TX freq in kHz, and the current mode (goes into the comment). defaultCall: string; defaultFreqKHz: number; defaultMode: string; // Award references on the QSO being entered (or on the last logged one), // appended to the comment after the mode. defaultRefs?: string[]; // Master cluster name, shown so the user knows where the spot goes. targetName?: string; recent: RecentSpotQSO[]; onSend: (call: string, freqKHz: number, comment: string) => Promise; } // SendSpotModal — Log4OM-style "Send Spot" window. Announces a DX spot on // the master cluster: callsign + frequency (kHz) + a free message (defaults // to the mode). A "Latest QSOs" list lets the operator one-click a recent // contact into the form. export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defaultMode, defaultRefs, targetName, recent, onSend }: Props) { const { t } = useI18n(); const [call, setCall] = useState(''); const [freqKHz, setFreqKHz] = useState(''); const [message, setMessage] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const [ok, setOk] = useState(false); const callRef = useRef(null); // (Re)initialise the form each time the dialog opens. useEffect(() => { if (!open) return; setCall((defaultCall || '').toUpperCase()); setFreqKHz(defaultFreqKHz > 0 ? trimKHz(defaultFreqKHz) : ''); setMessage(spotComment(defaultMode, defaultRefs)); setError(''); setOk(false); // Focus the freq if the call is already known, else the call. setTimeout(() => callRef.current?.focus(), 50); }, [open, defaultCall, defaultFreqKHz, defaultMode, defaultRefs]); async function send() { const c = call.trim().toUpperCase(); const f = parseFloat(freqKHz); if (!c) { setError(t('spm.callRequired')); return; } if (!f || f <= 0) { setError(t('spm.freqRequired')); return; } setBusy(true); setError(''); try { await onSend(c, f, message.trim()); setOk(true); // Brief success flash, then close. setTimeout(() => { setOk(false); onClose(); }, 700); } catch (e: any) { setError(String(e?.message ?? e)); } finally { setBusy(false); } } function pick(q: RecentSpotQSO) { setCall(q.callsign.toUpperCase()); if (q.freqKHz > 0) setFreqKHz(trimKHz(q.freqKHz)); setMessage(spotComment(q.mode, q.refs)); setError(''); } return ( { if (!o) onClose(); }}> {t('spm.title')}
setCall(e.target.value.toUpperCase())} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }} placeholder={t('spm.callPh')} />
setFreqKHz(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }} placeholder="14205" />
setMessage(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }} placeholder={t('spm.messagePh')} />
{recent.length > 0 && (
{recent.map((q, i) => ( ))}
)} {error &&
{error}
}
{ok ? t('spm.spotSent') : targetName ? `→ ${targetName}` : t('spm.masterCluster')}
); } // trimKHz formats a kHz value without a trailing ".0" (14205) but keeps // sub-kHz precision when present (10138.7). function trimKHz(khz: number): string { return String(Math.round(khz * 10) / 10).replace(/\.0$/, ''); }