You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

532 lines
16 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"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<string, string | undefined>;
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<AddressSuggestion[]>([]);
const refreshSuggestions = () => {
const list = searchAddressSuggestions(street, city);
setSuggestions(list);
};
return (
<section className="space-y-4">
<h3 className="text-base font-semibold text-text-primary">{title}</h3>
<InputField
label="街道"
name={`${prefix}_street`}
value={street}
disabled={disabled}
error={errors[`${prefix}_street`]}
onChange={(e) => onStreetChange(e.target.value)}
onBlur={() => {
onBlurField(`${prefix}_street`);
refreshSuggestions();
}}
/>
<div className="grid grid-cols-2 gap-4">
<InputField
label="城市"
name={`${prefix}_city`}
value={city}
disabled={disabled}
error={errors[`${prefix}_city`]}
onChange={(e) => onCityChange(e.target.value)}
onBlur={() => {
onBlurField(`${prefix}_city`);
refreshSuggestions();
}}
/>
<StateComboboxField
label="州"
name={`${prefix}_state`}
value={state}
disabled={disabled}
error={errors[`${prefix}_state`]}
onChange={onStateChange}
onBlur={() => onBlurField(`${prefix}_state`)}
/>
</div>
{suggestions.length > 0 && !selected && (
<ul className="rounded-md border border-border bg-surface shadow-card">
{suggestions.map((item) => (
<li key={item.place_id}>
<button
type="button"
className="w-full px-3 py-2 text-left text-sm hover:bg-bg"
onClick={() => onSelect(toAddressInput(item))}
>
{formatAddressDisplayLabel(item)}
</button>
</li>
))}
</ul>
)}
{confirmed?.selected_from_mothership && (
<p className="text-xs text-success">
{formatAddressDisplayLabel(confirmed)}
</p>
)}
{errors[`${prefix}_address`] && (
<p className="text-xs text-error">{errors[`${prefix}_address`]}</p>
)}
</section>
);
}
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<FormState>(DEFAULT_FORM);
const [pickup, setPickup] = useState<AddressInput | null>(null);
const [delivery, setDelivery] = useState<AddressInput | null>(null);
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
const notifyCargoChange = () => {
onCargoChange?.();
};
const set = <K extends keyof FormState>(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<string, string | undefined> = {};
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 (
<form id={formId} onSubmit={handleSubmit} className="space-y-6">
<fieldset disabled={disabled} className="space-y-6">
<AddressSection
title="提货地址"
prefix="p"
street={form.p_street}
city={form.p_city}
state={form.p_state}
selected={pickup}
confirmed={confirmedPickup}
errors={errors}
disabled={disabled}
onStreetChange={(v) => set("p_street", v)}
onCityChange={(v) => set("p_city", v)}
onStateChange={(v) => set("p_state", v)}
onSelect={setPickup}
onBlurField={handleBlur}
/>
<AddressSection
title="派送地址"
prefix="d"
street={form.d_street}
city={form.d_city}
state={form.d_state}
selected={delivery}
confirmed={confirmedDelivery}
errors={errors}
disabled={disabled}
onStreetChange={(v) => set("d_street", v)}
onCityChange={(v) => set("d_city", v)}
onStateChange={(v) => set("d_state", v)}
onSelect={setDelivery}
onBlurField={handleBlur}
/>
<section className="space-y-4">
<h3 className="text-base font-semibold text-text-primary"></h3>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<InputField
label="单托重量"
name="weight"
type="number"
value={form.weight}
error={errors.weight}
onChange={(e) => set("weight", e.target.value)}
onBlur={() => handleBlur("weight")}
/>
<SelectField
label="重量单位"
name="weight_unit"
value={form.weight_unit}
options={[
{ value: "lb", label: "磅 (lb)" },
{ value: "kg", label: "千克 (kg)" },
]}
onChange={(e) => handleWeightUnitChange(e.target.value as UnitWeight)}
/>
<InputField
label="托盘数"
name="pallet_count"
type="number"
value={form.pallet_count}
error={errors.pallet_count}
onChange={(e) => set("pallet_count", e.target.value)}
onBlur={() => handleBlur("pallet_count")}
/>
</div>
{weightHint ? (
<p className="text-xs text-text-secondary">{weightHint}</p>
) : null}
<div className="grid grid-cols-3 gap-4">
<InputField
label="长"
name="length"
type="number"
value={form.length}
error={errors.length}
onChange={(e) => set("length", e.target.value)}
onBlur={() => handleBlur("length")}
/>
<InputField
label="宽"
name="width"
type="number"
value={form.width}
error={errors.width}
onChange={(e) => set("width", e.target.value)}
onBlur={() => handleBlur("width")}
/>
<InputField
label="高"
name="height"
type="number"
value={form.height}
error={errors.height}
onChange={(e) => set("height", e.target.value)}
onBlur={() => handleBlur("height")}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<SelectField
label="尺寸单位"
name="dim_unit"
value={form.dim_unit}
options={[
{ value: "in", label: "英寸 (in)" },
{ value: "cm", label: "厘米 (cm)" },
]}
onChange={(e) => handleDimUnitChange(e.target.value as UnitDim)}
/>
<SelectField
label="显示单位制"
name="display_unit"
value={form.display_unit}
options={[
{ value: "imperial", label: "英制" },
{ value: "metric", label: "公制" },
]}
onChange={(e) =>
handleDisplayUnitChange(e.target.value as DisplayUnit)
}
/>
</div>
{dimHint ? (
<p className="text-xs text-text-secondary">{dimHint}</p>
) : null}
<p className="text-xs text-text-secondary">
</p>
</section>
</fieldset>
</form>
);
}