"use client"; import { useState } from "react"; import { CARGO_LIMITS } from "@/lib/constants/cargo-limits"; import type { AddressInput, CargoType, DisplayUnit, QuoteRequestBody, ServiceLevel, UnitDim, UnitWeight, } from "@/lib/frontend/types"; import { uuidV4 } from "@/lib/frontend/format"; import { searchAddressSuggestions, toAddressInput, type AddressSuggestion, } from "@/lib/frontend/address-suggestions"; import { formatAddressDisplayLabel } from "@/lib/frontend/format-address"; import { InputField } from "@/components/ui/input-field"; import { SelectField } from "@/components/ui/select-field"; import { StateComboboxField } from "@/components/ui/state-combobox-field"; import { isValidUsStateCode } from "@/lib/frontend/us-states"; import { describeDimEquivalent, describeWeightEquivalent, displayUnitToCargoUnits, } from "@/lib/frontend/cargo-form-units"; import { cmToIn, kgToLb, toAxelWholeInches, toAxelWholePounds, } from "@/modules/quote/unit-converter"; interface AddressSectionProps { title: string; prefix: string; street: string; city: string; state: string; selected: AddressInput | null; /** 用户已在 MotherShip 弹窗确认的地址(仅此来源展示「已选择」) */ confirmed: AddressInput | null; errors: Record; disabled?: boolean; onStreetChange: (v: string) => void; onCityChange: (v: string) => void; onStateChange: (v: string) => void; onSelect: (addr: AddressInput) => void; onBlurField: (key: string) => void; } function AddressSection({ title, prefix, street, city, state, selected, confirmed, errors, disabled, onStreetChange, onCityChange, onStateChange, onSelect, onBlurField, }: AddressSectionProps) { const [suggestions, setSuggestions] = useState([]); const refreshSuggestions = () => { const list = searchAddressSuggestions(street, city); setSuggestions(list); }; return (

{title}

onStreetChange(e.target.value)} onBlur={() => { onBlurField(`${prefix}_street`); refreshSuggestions(); }} />
onCityChange(e.target.value)} onBlur={() => { onBlurField(`${prefix}_city`); refreshSuggestions(); }} /> onBlurField(`${prefix}_state`)} />
{suggestions.length > 0 && !selected && (
    {suggestions.map((item) => (
  • ))}
)} {confirmed?.selected_from_mothership && (

已选择:{formatAddressDisplayLabel(confirmed)}

)} {errors[`${prefix}_address`] && (

{errors[`${prefix}_address`]}

)}
); } interface QuoteFormProps { customerId: string; disabled?: boolean; formId: string; onValidSubmit: (body: QuoteRequestBody) => void; /** MotherShip 弹窗确认后的提货地址(用于展示「已选择」) */ confirmedPickup?: AddressInput | null; /** MotherShip 弹窗确认后的派送地址 */ confirmedDelivery?: AddressInput | null; /** 用户修改地址草稿时回调(用于清除已过期的确认展示) */ onAddressDraftChange?: () => void; /** 货物信息/单位变更时回调(用于清除旧报价) */ onCargoChange?: () => void; } interface FormState { p_street: string; p_city: string; p_state: string; d_street: string; d_city: string; d_state: string; weight: string; weight_unit: UnitWeight; length: string; width: string; height: string; dim_unit: UnitDim; pallet_count: string; cargo_type: CargoType; display_unit: DisplayUnit; service_level: ServiceLevel; } const DEFAULT_FORM: FormState = { p_street: "1234 Warehouse Blvd", p_city: "Los Angeles", p_state: "CA", d_street: "5678 Distribution Dr", d_city: "Dallas", d_state: "TX", weight: "500", weight_unit: "lb", length: "48", width: "40", height: "48", dim_unit: "in", pallet_count: "2", cargo_type: "general_freight", display_unit: "imperial", service_level: "standard", }; function toQueryLb(value: number, unit: UnitWeight): number { const rawLb = unit === "kg" ? kgToLb(value) : value; return toAxelWholePounds(rawLb); } function toQueryIn(value: number, unit: UnitDim): number { const rawIn = unit === "cm" ? cmToIn(value) : value; return toAxelWholeInches(rawIn); } export function QuoteForm({ customerId, disabled, formId, onValidSubmit, confirmedPickup = null, confirmedDelivery = null, onAddressDraftChange, onCargoChange, }: QuoteFormProps) { const [form, setForm] = useState(DEFAULT_FORM); const [pickup, setPickup] = useState(null); const [delivery, setDelivery] = useState(null); const [errors, setErrors] = useState>({}); const notifyCargoChange = () => { onCargoChange?.(); }; const set = (key: K, value: FormState[K]) => { setForm((f) => ({ ...f, [key]: value })); if (key.startsWith("p_") || key.startsWith("d_")) { if (key.startsWith("p_")) setPickup(null); if (key.startsWith("d_")) setDelivery(null); onAddressDraftChange?.(); } if ( key === "weight" || key === "weight_unit" || key === "length" || key === "width" || key === "height" || key === "dim_unit" || key === "display_unit" || key === "pallet_count" ) { notifyCargoChange(); } }; const handleWeightUnitChange = (nextUnit: UnitWeight) => { set("weight_unit", nextUnit); }; const handleDimUnitChange = (nextUnit: UnitDim) => { set("dim_unit", nextUnit); }; const handleDisplayUnitChange = (nextDisplay: DisplayUnit) => { const targets = displayUnitToCargoUnits(nextDisplay); setForm((prev) => ({ ...prev, display_unit: nextDisplay, weight_unit: targets.weight, dim_unit: targets.dim, })); notifyCargoChange(); }; const weightHint = describeWeightEquivalent( Number(form.weight), form.weight_unit, ); const dimHint = describeDimEquivalent( Number(form.length), Number(form.width), Number(form.height), form.dim_unit, ); const validateField = (key: string): string | undefined => { const { palletCount, dimIn, weightLbPerPallet } = CARGO_LIMITS; if (key === "weight") { const lb = toQueryLb(Number(form.weight), form.weight_unit); if (!form.weight || Number.isNaN(lb) || lb < weightLbPerPallet.min || lb > weightLbPerPallet.max) { return `单托重量须在 ${weightLbPerPallet.min}–${weightLbPerPallet.max} lb 之间`; } } if (key === "pallet_count") { const n = Number(form.pallet_count); if (!Number.isInteger(n) || n < palletCount.min || n > palletCount.max) { return `托盘数须在 ${palletCount.min}–${palletCount.max} 之间`; } } if (key === "length") { const v = toQueryIn(Number(form.length), form.dim_unit); if (!form.length || Number.isNaN(v) || v < dimIn.min || v > dimIn.lengthMax) { return `长度须在 ${dimIn.min}–${dimIn.lengthMax} in 之间`; } } if (key === "width") { const v = toQueryIn(Number(form.width), form.dim_unit); if (!form.width || Number.isNaN(v) || v < dimIn.min || v > dimIn.widthMax) { return `宽度须在 ${dimIn.min}–${dimIn.widthMax} in 之间`; } } if (key === "height") { const v = toQueryIn(Number(form.height), form.dim_unit); if (!form.height || Number.isNaN(v) || v < dimIn.min || v > dimIn.heightMax) { return `高度须在 ${dimIn.min}–${dimIn.heightMax} in 之间`; } } if ( ["p_street", "p_city", "p_state", "d_street", "d_city", "d_state", "length", "width", "height"].includes( key, ) ) { const v = form[key as keyof FormState]; if (typeof v === "string" && !v.trim()) return "请填写该字段"; } if (key === "p_state" || key === "d_state") { const v = form[key as "p_state" | "d_state"]; if (!isValidUsStateCode(v)) return "请填写有效的美国州码(2 位字母)"; } return undefined; }; const handleBlur = (key: string) => { const err = validateField(key); setErrors((e) => ({ ...e, [key]: err })); }; const validateAll = (): boolean => { const keys = [ "p_street", "p_city", "p_state", "d_street", "d_city", "d_state", "weight", "length", "width", "height", "pallet_count", ]; const next: Record = {}; keys.forEach((k) => { const err = validateField(k); if (err) next[k] = err; }); // 不再强制要求从本地 mock 列表选择 // MotherShip 候选 API 会返回精准地址供用户在弹窗中确认 setErrors(next); return Object.values(next).every((v) => !v); }; /** 从表单字段构建地址(用户未从本地列表选择时) */ const buildAddressFromForm = (prefix: "p" | "d"): AddressInput => { const street = prefix === "p" ? form.p_street : form.d_street; const city = prefix === "p" ? form.p_city : form.d_city; const state = prefix === "p" ? form.p_state : form.d_state; return { place_id: `draft_${prefix}_${Date.now()}`, formatted_address: `${street}, ${city}, ${state}`, street, city, state, zip: "", selected_from_suggestions: true, // 允许提交,MotherShip 会返回精准候选 }; }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (disabled) return; if (!validateAll()) return; // 优先使用用户从本地列表选择的地址,否则从表单字段构建 const finalPickup = pickup ?? buildAddressFromForm("p"); const finalDelivery = delivery ?? buildAddressFromForm("d"); onValidSubmit({ request_id: uuidV4(), customer_id: customerId, pickup_address: finalPickup, delivery_address: finalDelivery, weight: { value: Number(form.weight), unit: form.weight_unit }, dimensions: { length: Number(form.length), width: Number(form.width), height: Number(form.height), unit: form.dim_unit, }, pallet_count: Number(form.pallet_count), cargo_type: form.cargo_type, service_level: form.service_level, display_unit: form.display_unit, }); }; return (
set("p_street", v)} onCityChange={(v) => set("p_city", v)} onStateChange={(v) => set("p_state", v)} onSelect={setPickup} onBlurField={handleBlur} /> set("d_street", v)} onCityChange={(v) => set("d_city", v)} onStateChange={(v) => set("d_state", v)} onSelect={setDelivery} onBlurField={handleBlur} />

货物信息

set("weight", e.target.value)} onBlur={() => handleBlur("weight")} /> handleWeightUnitChange(e.target.value as UnitWeight)} /> set("pallet_count", e.target.value)} onBlur={() => handleBlur("pallet_count")} />
{weightHint ? (

{weightHint}

) : null}
set("length", e.target.value)} onBlur={() => handleBlur("length")} /> set("width", e.target.value)} onBlur={() => handleBlur("width")} /> set("height", e.target.value)} onBlur={() => handleBlur("height")} />
handleDimUnitChange(e.target.value as UnitDim)} /> handleDisplayUnitChange(e.target.value as DisplayUnit) } />
{dimHint ? (

{dimHint}

) : null}

切换单位时会自动换算数值;修改货物后请重新点击「获取报价」。

); }