Six equal columns gave a US county name the same width as a two-digit CQ zone: the county truncated while the zones, the prefix and the DXCC number sat mostly empty. The row is twelve columns now, spans set per field. The bearing and the two distances are read-only and never exceed "12345 km", so they move to a flex row at a fixed width and the address — the one field here that was never wide enough — takes the remainder.
453 lines
21 KiB
TypeScript
453 lines
21 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import {
|
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
|
} from '@/components/ui/select';
|
|
import { cn } from '@/lib/utils';
|
|
import { useI18n } from '@/lib/i18n';
|
|
import { pathBetween, pathBetweenLatLon, gridToLatLon } from '@/lib/maidenhead';
|
|
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
|
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
|
|
|
export interface DetailsState {
|
|
state: string;
|
|
cnty: string;
|
|
address: string;
|
|
lat?: number;
|
|
lon?: number;
|
|
// DXCC entity number + zones (filled from QRZ/HamQTH or cty.dat fallback).
|
|
// Editable so the operator can correct an obviously wrong auto-fill.
|
|
dxcc?: number;
|
|
cqz?: number;
|
|
ituz?: number;
|
|
cont: string;
|
|
qsl_msg: string;
|
|
qsl_via: string;
|
|
ant_az?: number;
|
|
ant_el?: number;
|
|
ant_path: string;
|
|
prop_mode: string;
|
|
my_rig: string;
|
|
my_antenna: string;
|
|
tx_pwr?: number;
|
|
sat_name: string;
|
|
sat_mode: string;
|
|
contest_id: string;
|
|
// Contest exchanges as free text — most are serials (001) but some are
|
|
// alphanumeric (e.g. a section/zone like "ON4"). Stored to ADIF SRX/STX when
|
|
// purely numeric, else to SRX_STRING/STX_STRING (handled in App on save).
|
|
srx_string?: string;
|
|
stx_string?: string;
|
|
email: string;
|
|
// Award references for the contacted station (set via the Awards tab picker).
|
|
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064".
|
|
// App.tsx maps these back to pota_ref/sota_ref/iota when saving the QSO.
|
|
award_refs?: string;
|
|
}
|
|
|
|
interface Props {
|
|
callsign: string;
|
|
prefix: string;
|
|
operatorGrid: string; // station.my_grid — origin for bearing/distance
|
|
remoteGrid: string; // entry-strip Grid value — destination
|
|
// Entry-strip text fields. Live award detection matches on these too — a DARC
|
|
// DOK keys off QTH, not the address — and sending only the address meant such
|
|
// an award stayed silent until the QSO was logged.
|
|
qth?: string;
|
|
name?: string;
|
|
country?: string;
|
|
comment?: string;
|
|
note?: string;
|
|
details: DetailsState;
|
|
onChange: (patch: Partial<DetailsState>) => void;
|
|
// Stats (F1) tab content: the worked-before matrix + optional QRZ image.
|
|
wb?: any;
|
|
wbBusy?: boolean;
|
|
band: string;
|
|
mode: string;
|
|
bands?: string[]; // configured bands for the worked-before matrix columns
|
|
imageUrl?: string;
|
|
onOpenImage?: () => void;
|
|
// Optional controlled active tab (so the app can switch it via keyboard).
|
|
tab?: TabName;
|
|
onTab?: (t: TabName) => void;
|
|
// When the WinKeyer is active, F1-F12 fire macros, so the tab shortcut is
|
|
// shown as Ctrl+F1…F5 instead of F1…F5.
|
|
keyerActive?: boolean;
|
|
}
|
|
|
|
export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
|
|
|
|
// ADIF PROP_MODE: stored value is the code, shown with the full name (Log4OM-style).
|
|
const PROP_MODES: { value: string; label: string }[] = [
|
|
{ value: 'NONE', label: '—' },
|
|
{ value: 'AS', label: 'detp.propAS' },
|
|
{ value: 'AUR', label: 'detp.propAUR' },
|
|
{ value: 'AUE', label: 'detp.propAUE' },
|
|
{ value: 'BS', label: 'detp.propBS' },
|
|
{ value: 'ECH', label: 'EchoLink' },
|
|
{ value: 'EME', label: 'detp.propEME' },
|
|
{ value: 'ES', label: 'detp.propES' },
|
|
{ value: 'FAI', label: 'detp.propFAI' },
|
|
{ value: 'F2', label: 'detp.propF2' },
|
|
{ value: 'GWAVE', label: 'detp.propGWAVE' },
|
|
{ value: 'INTERNET', label: 'detp.propINTERNET' },
|
|
{ value: 'ION', label: 'detp.propION' },
|
|
{ value: 'IRL', label: 'IRLP' },
|
|
{ value: 'LOS', label: 'detp.propLOS' },
|
|
{ value: 'MS', label: 'detp.propMS' },
|
|
{ value: 'RPT', label: 'detp.propRPT' },
|
|
{ value: 'RS', label: 'detp.propRS' },
|
|
{ value: 'SAT', label: 'detp.propSAT' },
|
|
{ value: 'TEP', label: 'detp.propTEP' },
|
|
{ value: 'TR', label: 'detp.propTR' },
|
|
];
|
|
|
|
// ADIF ANT_PATH enum (Grayline, Other, Short Path, Long Path).
|
|
const ANT_PATHS: { value: string; label: string }[] = [
|
|
{ value: 'NONE', label: '—' },
|
|
{ value: 'S', label: 'detp.pathShort' },
|
|
{ value: 'L', label: 'detp.pathLong' },
|
|
{ value: 'G', label: 'detp.pathGrayline' },
|
|
{ value: 'O', label: 'detp.pathOther' },
|
|
];
|
|
|
|
function numOrUndef(v: string): number | undefined {
|
|
if (v === '') return undefined;
|
|
const n = parseFloat(v);
|
|
return isNaN(n) ? undefined : n;
|
|
}
|
|
|
|
// Compact field helper to keep the JSX dense.
|
|
// Written out rather than built as `col-span-${n}`: Tailwind scans for literal
|
|
// class names, so an interpolated one is never emitted.
|
|
const SPAN_CLASS: Record<number, string> = {
|
|
1: '', 2: 'col-span-2', 3: 'col-span-3', 4: 'col-span-4', 5: 'col-span-5',
|
|
6: 'col-span-6', 7: 'col-span-7', 8: 'col-span-8', 9: 'col-span-9',
|
|
10: 'col-span-10', 11: 'col-span-11', 12: 'col-span-12',
|
|
};
|
|
|
|
function Field({ label, span = 1, className, children }: { label: string; span?: number; className?: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className={cn('flex flex-col min-w-0', SPAN_CLASS[span], className)}>
|
|
<Label className="mb-1">{label}</Label>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) {
|
|
const { t } = useI18n();
|
|
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
|
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
|
|
|
// Live award detection: run the SAME engine used at log time over the current
|
|
// contact — callsign, the looked-up address/state/zones AND the entry-strip
|
|
// text (QTH, name, country, comment, note, grid). Sending only the address was
|
|
// a silent hole: a DARC DOK matches on QTH, so "Aachen" produced nothing here
|
|
// while the award editor's Test tab, which passes the whole QSO, matched it.
|
|
// References the QSO will earn — WAPC matching "Beijing" inside the address,
|
|
// a DOK matching the town — are surfaced and auto-added the moment you enter a
|
|
// call or click a spot, instead of only appearing after logging. Pickable
|
|
// matches are merged into award_refs
|
|
// (idempotent; award_refs is NOT a dependency, so removing one by hand sticks).
|
|
const [detected, setDetected] = useState<Array<{ code: string; ref: string; name?: string; pickable?: boolean; ambiguous?: boolean }>>([]);
|
|
useEffect(() => {
|
|
if (open !== 'awards' || !callsign.trim()) { setDetected([]); return; }
|
|
const t = window.setTimeout(async () => {
|
|
try {
|
|
const q: any = {
|
|
callsign, band, mode,
|
|
address: details.address ?? '', state: details.state ?? '', cnty: details.cnty ?? '',
|
|
qth: qth ?? '', name: name ?? '', country: country ?? '',
|
|
comment: comment ?? '', notes: note ?? '', grid: remoteGrid ?? '',
|
|
cont: details.cont ?? '', dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz,
|
|
qso_date: new Date().toISOString(),
|
|
};
|
|
const all = ((await ComputeQSOAwardRefs(q)) ?? []) as any[];
|
|
setDetected(all as any);
|
|
const cur = details.award_refs ?? '';
|
|
const have = new Set(cur.split(';').filter(Boolean));
|
|
let next = cur;
|
|
for (const r of all) {
|
|
// An ambiguous candidate is offered, never auto-added: adding one would
|
|
// be the guess the award's "one reference per QSO" setting exists to refuse.
|
|
if (!r.pickable || r.ambiguous) continue;
|
|
const entry = `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`;
|
|
if (!have.has(entry)) { next = next ? `${next};${entry}` : entry; have.add(entry); }
|
|
}
|
|
if (next !== cur) onChange({ award_refs: next });
|
|
} catch { /* leave detection empty on failure */ }
|
|
}, 400);
|
|
return () => window.clearTimeout(t);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, qth, name, country, comment, note, remoteGrid, band, mode]);
|
|
// Bearing/distance from operator's home grid to the remote station.
|
|
// Recomputed only when either grid actually changes.
|
|
const path = useMemo(() => {
|
|
const byGrid = pathBetween(operatorGrid, remoteGrid);
|
|
if (byGrid) return byGrid;
|
|
// Fall back to lat/lon when the DX has coordinates but no grid (e.g. a
|
|
// cty.dat-only entity like Svalbard: no QRZ grid, but cty.dat coordinates).
|
|
const myLL = gridToLatLon(operatorGrid);
|
|
if (myLL && details.lat != null && details.lon != null) {
|
|
return pathBetweenLatLon(myLL, { lat: details.lat, lon: details.lon });
|
|
}
|
|
return null;
|
|
}, [operatorGrid, remoteGrid, details.lat, details.lon]);
|
|
const fmtDeg = (n: number) => `${Math.round(n)}°`;
|
|
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
|
|
|
|
function toggle(t: TabName) { onTab ? onTab(t) : setInternalOpen(t); }
|
|
const fk = keyerActive ? 'Ctrl+F' : 'F';
|
|
|
|
const satelliteMode = !!details.sat_name || !!details.sat_mode || details.prop_mode === 'SAT';
|
|
function setSatellite(on: boolean) {
|
|
if (on) {
|
|
if (details.prop_mode !== 'SAT') onChange({ prop_mode: 'SAT' });
|
|
} else {
|
|
onChange({
|
|
sat_name: '', sat_mode: '',
|
|
...(details.prop_mode === 'SAT' ? { prop_mode: '' } : {}),
|
|
});
|
|
}
|
|
}
|
|
|
|
const tabs: { key: TabName; label: string }[] = [
|
|
{ key: 'stats', label: `${t('detp.tabStats')} (${fk}1)` },
|
|
{ key: 'info', label: `${t('detp.tabInfo')} (${fk}2)` },
|
|
{ key: 'awards', label: `${t('detp.tabAwards')} (${fk}3)` },
|
|
{ key: 'my', label: `${t('detp.tabMy')} (${fk}4)` },
|
|
{ key: 'extended', label: `${t('detp.tabExtended')} (${fk}5)` },
|
|
];
|
|
|
|
return (
|
|
<section className="bg-card shadow-sm border border-border rounded-lg flex flex-col flex-1 min-h-0 overflow-hidden">
|
|
<nav className="flex items-center gap-1 px-3 bg-muted/40 border-b border-border shrink-0">
|
|
{tabs.map((t) => (
|
|
<button
|
|
key={t.key}
|
|
onClick={() => toggle(t.key)}
|
|
className={cn(
|
|
'px-3 py-1.5 text-xs font-medium border-b-2 border-transparent -mb-px transition-colors',
|
|
open === t.key
|
|
? 'text-primary border-primary font-semibold'
|
|
: 'text-muted-foreground hover:text-foreground',
|
|
)}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
|
|
<div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}>
|
|
{open === 'stats' && (
|
|
<div className="px-3 py-2.5">
|
|
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''} />
|
|
</div>
|
|
)}
|
|
|
|
{open === 'info' && (
|
|
<div className="grid grid-cols-12 gap-2 px-3 py-2.5">
|
|
{/* Twelve columns, not six: the fields hold very different things. A US
|
|
county name is long, a CQ zone is two digits — on an even six-column
|
|
grid they were the same width, so the county truncated while the
|
|
zones sat mostly empty. */}
|
|
<Field label={t('detp.statePref')} span={2}>
|
|
<Input value={details.state} onChange={(e) => onChange({ state: e.target.value })} />
|
|
</Field>
|
|
<Field label={t('detp.county')} span={4}>
|
|
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} />
|
|
</Field>
|
|
<Field label={t('detp.prefix')} span={2}>
|
|
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
|
|
</Field>
|
|
<Field label={t('detp.cqZone')}>
|
|
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.cqz ?? ''} placeholder="—"
|
|
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ cqz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
|
</Field>
|
|
<Field label={t('detp.ituZone')}>
|
|
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.ituz ?? ''} placeholder="—"
|
|
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
|
</Field>
|
|
{/* DXCC # closes the top row (next to the zones); Continent and
|
|
Azimuth SP live in the main entry strip / bandeau. The long-path
|
|
bearing and distances move to the row below. */}
|
|
<Field label={t('detp.dxcc')} span={2}>
|
|
<Input
|
|
readOnly
|
|
tabIndex={-1}
|
|
className="font-mono bg-muted/40 cursor-default"
|
|
value={details.dxcc ?? ''}
|
|
placeholder="—"
|
|
/>
|
|
</Field>
|
|
{/* The bearing and the two distances are read-only and hold at most
|
|
"12345 km", so they take a fixed width and the address — the one
|
|
field here that is never wide enough — gets everything left over. */}
|
|
<div className="col-span-12 flex gap-2">
|
|
<Field label={t('detp.azimuthLp')} className="w-20 shrink-0">
|
|
<Input
|
|
readOnly
|
|
tabIndex={-1}
|
|
className="font-mono bg-muted/40 cursor-default"
|
|
value={path ? fmtDeg(path.bearingLong) : ''}
|
|
placeholder="—"
|
|
/>
|
|
</Field>
|
|
<Field label={t('detp.distanceSp')} className="w-24 shrink-0">
|
|
<Input
|
|
readOnly
|
|
tabIndex={-1}
|
|
className="font-mono bg-muted/40 cursor-default"
|
|
value={path ? fmtKm(path.distanceShort) : ''}
|
|
placeholder="—"
|
|
/>
|
|
</Field>
|
|
<Field label={t('detp.distanceLp')} className="w-24 shrink-0">
|
|
<Input
|
|
readOnly
|
|
tabIndex={-1}
|
|
className="font-mono bg-muted/40 cursor-default"
|
|
value={path ? fmtKm(path.distanceLong) : ''}
|
|
placeholder="—"
|
|
/>
|
|
</Field>
|
|
<Field label={t('detp.address')} className="flex-1 min-w-0">
|
|
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
|
</Field>
|
|
</div>
|
|
<Field label={t('detp.qslMessage')} span={7}>
|
|
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
|
</Field>
|
|
<Field label={t('detp.qslVia')} span={5}>
|
|
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
|
|
</Field>
|
|
</div>
|
|
)}
|
|
|
|
{open === 'awards' && (
|
|
<div className="px-3 py-2.5 h-full flex flex-col min-h-0">
|
|
<AwardRefSelector
|
|
dxcc={details.dxcc}
|
|
value={details.award_refs ?? ''}
|
|
onChange={(v) => onChange({ award_refs: v })}
|
|
fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }}
|
|
heightClass="flex-1 min-h-0"
|
|
/>
|
|
{/* Ambiguous candidates: the award allows one reference per QSO and
|
|
several matched, so none was kept. Click one to settle it while the
|
|
contact is still in front of you. */}
|
|
{detected.some((r) => r.ambiguous) && (
|
|
<div className="mt-2 text-[11px] shrink-0 leading-snug border-t border-warning/40 pt-1.5">
|
|
<span className="font-medium text-warning">{t('detp.ambiguous')}</span>{' '}
|
|
{detected.filter((r) => r.ambiguous).map((r) => (
|
|
<button
|
|
key={`amb-${r.code}@${r.ref}`}
|
|
type="button"
|
|
title={r.name ?? ''}
|
|
onClick={() => {
|
|
const entry = `${r.code.toUpperCase()}@${r.ref.toUpperCase()}`;
|
|
const cur = details.award_refs ?? '';
|
|
// Only one of the candidates can be right — replace any
|
|
// sibling already picked for this award rather than stacking.
|
|
const kept = cur.split(';').filter((e) => e && e.split('@')[0].toUpperCase() !== r.code.toUpperCase());
|
|
onChange({ award_refs: [...kept, entry].join(';') });
|
|
setDetected((d) => d.filter((x) => !x.ambiguous || x.code.toUpperCase() !== r.code.toUpperCase()));
|
|
}}
|
|
className="inline-block mr-1.5 px-1.5 py-0.5 rounded font-mono whitespace-nowrap border border-warning/50 text-warning hover:bg-warning/10"
|
|
>
|
|
{r.code}@{r.ref}{r.name ? ` — ${r.name}` : ''}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
{detected.some((r) => !r.ambiguous) && (
|
|
<div className="mt-2 text-[11px] text-muted-foreground shrink-0 max-h-14 overflow-y-auto leading-snug border-t border-border/50 pt-1.5">
|
|
<span className="font-medium text-foreground/70">{t('detp.detected')}</span>{' '}
|
|
{detected.filter((r) => !r.ambiguous).map((r) => (
|
|
<span key={`${r.code}@${r.ref}`} className="inline-block mr-1.5 font-mono whitespace-nowrap" title={r.name ?? ''}>
|
|
<span className="text-foreground/80">{r.code}{r.ref ? `@${r.ref}` : ''}</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{open === 'my' && (
|
|
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
|
<Field label={t('detp.azimuth')}>
|
|
<Input type="number" value={details.ant_az ?? ''} onChange={(e) => onChange({ ant_az: numOrUndef(e.target.value) })} />
|
|
</Field>
|
|
<Field label={t('detp.elevation')}>
|
|
<Input type="number" value={details.ant_el ?? ''} onChange={(e) => onChange({ ant_el: numOrUndef(e.target.value) })} />
|
|
</Field>
|
|
<Field label={t('detp.txPower')}>
|
|
<Input type="number" value={details.tx_pwr ?? ''} onChange={(e) => onChange({ tx_pwr: numOrUndef(e.target.value) })} />
|
|
</Field>
|
|
<div className="col-span-3 flex items-end pb-1.5">
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
|
<Checkbox checked={satelliteMode} onCheckedChange={(c) => setSatellite(!!c)} />
|
|
{t('detp.satelliteMode')}
|
|
</label>
|
|
</div>
|
|
<Field label={t('detp.antPath')} span={2}>
|
|
<Select value={details.ant_path || 'NONE'} onValueChange={(v) => onChange({ ant_path: v === 'NONE' ? '' : v })}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{ANT_PATHS.map((p) => <SelectItem key={p.value} value={p.value}>{t(p.label)}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</Field>
|
|
<Field label={t('detp.propagation')} span={4}>
|
|
<Select value={details.prop_mode || 'NONE'} onValueChange={(v) => onChange({ prop_mode: v === 'NONE' ? '' : v })}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{PROP_MODES.map((p) => <SelectItem key={p.value} value={p.value}>{t(p.label)}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</Field>
|
|
<Field label={t('detp.rig')} span={3}>
|
|
<Input value={details.my_rig} placeholder="Flex 8600" onChange={(e) => onChange({ my_rig: e.target.value })} />
|
|
</Field>
|
|
<Field label={t('detp.antenna')} span={3}>
|
|
<Input value={details.my_antenna} placeholder="UB640" onChange={(e) => onChange({ my_antenna: e.target.value })} />
|
|
</Field>
|
|
{satelliteMode && (
|
|
<>
|
|
<Field label={t('detp.satName')} span={3}>
|
|
<Input value={details.sat_name} placeholder="AO-91" onChange={(e) => onChange({ sat_name: e.target.value })} />
|
|
</Field>
|
|
<Field label={t('detp.satelliteMode')} span={3}>
|
|
<Input value={details.sat_mode} placeholder="U/V" onChange={(e) => onChange({ sat_mode: e.target.value })} />
|
|
</Field>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{open === 'extended' && (
|
|
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
|
<Field label={t('detp.contestId')} span={2}>
|
|
<Input value={details.contest_id} onChange={(e) => onChange({ contest_id: e.target.value })} />
|
|
</Field>
|
|
<Field label="SRX">
|
|
<Input value={details.srx_string ?? ''} placeholder={t('detp.rcvdExchangePh')} onChange={(e) => onChange({ srx_string: e.target.value })} />
|
|
</Field>
|
|
<Field label="STX">
|
|
<Input value={details.stx_string ?? ''} placeholder={t('detp.sentExchangePh')} onChange={(e) => onChange({ stx_string: e.target.value })} />
|
|
</Field>
|
|
<Field label={t('detp.contactedEmail')} span={3}>
|
|
<Input value={details.email} placeholder="[email protected]" onChange={(e) => onChange({ email: e.target.value })} />
|
|
</Field>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|