/** * MotherShip 登录后一级查价界面(Create a new shipment) * 对齐官网 dashboard「创建新货件」字段结构;中文文案供嵌入宿主展示 */ "use client"; import { useCallback, useEffect, useId, useMemo, useRef, useState, type FormEvent } from "react"; import { Plus, X, CaretDown, WarningCircle } from "@phosphor-icons/react"; import { MothershipAddressSuggestField } from "@/components/mothership/mothership-address-suggest-field"; import type { MothershipAddressCandidate } from "@/lib/frontend/types"; import { QuoteFieldTooltip, QuoteFloatHint, QuotePageTitle, QuoteRequiredMark, QuoteSectionTitle, QuoteStepProgress, quoteAddRowCls, quoteInputCls, quoteInputErrCls, } from "@/components/quote/quote-form-chrome"; import { MothershipWeekdayDatePicker } from "@/components/mothership/mothership-weekday-date-picker"; import { defaultMothershipReadyDateIso, ceilMothershipNumeric, findMothershipCargoWeightBlockMessage, hasMothershipFractionalPart, isMothershipWeightEachAllowed, MOTHERSHIP_AVG_WEIGHT_BLOCK_MESSAGE, MOTHERSHIP_CARGO_ENTRY_HINT_ITEMS, MOTHERSHIP_CARGO_ENTRY_HINT_TITLE, MOTHERSHIP_INTEGER_HINT, normalizeMothershipReadyDateIso, } from "@/lib/mothership/logged-in-constraints"; import { evaluateMothershipAccessorialCompat, mothershipAccessorialHasBlock, MS_PICKUP_RISK_ACCESSORIALS, toggleMothershipAccessorial, type MsCompatIssue, } from "@/lib/mothership/option-compat"; import { useHostBridgeOptional } from "@/lib/embed/host-bridge-react"; import { msLoggedInAddressQueryText, postToHost, type MsLoggedInFillPayload, } from "@/lib/embed/host-bridge"; export const MS_PICKUP_ACCESSORIALS = [ { id: "cfs", label: "CFS" }, { id: "liftgate", label: "尾板(Liftgate)" }, { id: "limitedAccess", label: "受限通道" }, { id: "inside", label: "LTL 室内服务" }, { id: "residential", label: "住宅" }, { id: "tradeshow", label: "展会" }, ] as const; export const MS_DELIVERY_ACCESSORIALS = [ { id: "fbaAppointment", label: "Amazon 预约" }, { id: "appointment", label: "预约" }, { id: "cfs", label: "CFS" }, { id: "liftgate", label: "尾板(Liftgate)" }, { id: "limitedAccess", label: "受限通道" }, { id: "inside", label: "LTL 室内服务" }, { id: "residential", label: "住宅" }, { id: "tradeshow", label: "展会" }, ] as const; /** 对齐官网 Cargo type 下拉(录制/截图 2026-07-15) */ export const MS_CARGO_TYPES = [ { id: "pallet", label: "托盘(Pallet)" }, { id: "box", label: "箱(Box)" }, { id: "crate", label: "木箱(Crate)" }, { id: "piece", label: "件(Piece)" }, { id: "bale", label: "捆包(Bale)" }, { id: "bucket", label: "桶(Bucket)" }, { id: "carton", label: "纸箱(Carton)" }, { id: "case", label: "箱件(Case)" }, { id: "coil", label: "卷材(Coil)" }, { id: "cylinder", label: "圆筒(Cylinder)" }, { id: "drum", label: "圆桶(Drum)" }, { id: "pail", label: "提桶(Pail)" }, { id: "reel", label: "线盘(Reel)" }, { id: "roll", label: "卷(Roll)" }, { id: "skid", label: "滑托(Skid)" }, { id: "tote", label: "集装袋(Tote)" }, { id: "tube", label: "管(Tube)" }, ] as const; /** * 官网 Piece count type 下拉(可选,非必填) * 选项为内容包装形态,通常与 Cargo type 不同;value 用官网英文便于 RPA 点选 */ export const MS_PIECE_COUNT_TYPES = [ { id: "Pieces", label: "件(Pieces)" }, { id: "Units", label: "单元(Units)" }, { id: "Cartons", label: "纸箱(Cartons)" }, { id: "Boxes", label: "箱(Boxes)" }, { id: "Bags", label: "袋(Bags)" }, { id: "Bales", label: "捆包(Bales)" }, { id: "Bundles", label: "捆(Bundles)" }, { id: "Cases", label: "箱件(Cases)" }, { id: "Coils", label: "卷材(Coils)" }, { id: "Crates", label: "木箱(Crates)" }, { id: "Cylinders", label: "圆筒(Cylinders)" }, { id: "Drums", label: "圆桶(Drums)" }, { id: "Pails", label: "提桶(Pails)" }, { id: "Reels", label: "线盘(Reels)" }, { id: "Rolls", label: "卷(Rolls)" }, { id: "Skids", label: "滑托(Skids)" }, { id: "Totes", label: "集装袋(Totes)" }, { id: "Tubes", label: "管(Tubes)" }, ] as const; /** 官网 Ready for pick-up 时刻:整点、12 小时制、有序无重复 */ export const MS_READY_TIMES: readonly string[] = (() => { const labels: string[] = []; for (let h = 0; h < 24; h += 1) { const hour12 = h % 12 === 0 ? 12 : h % 12; const suffix = h < 12 ? "AM" : "PM"; labels.push(`${hour12}:00 ${suffix}`); } return labels; })(); export const MS_DEFAULT_READY_TIME = "11:00 AM"; export type MsAccessorialId = | (typeof MS_PICKUP_ACCESSORIALS)[number]["id"] | (typeof MS_DELIVERY_ACCESSORIALS)[number]["id"]; export type MsCargoTypeId = (typeof MS_CARGO_TYPES)[number]["id"]; export type MsCargoLine = { key: string; cargoType: MsCargoTypeId; quantity: string; weightLb: string; lengthIn: string; widthIn: string; heightIn: string; }; export type MothershipLoggedInShipmentPayload = { businessCustomerId?: string; /** ccnew 登录账号,缺 BC 时后端反查加价客户 */ businessUserAccount?: string; pickupQuery: string; deliveryQuery: string; /** MotherShip 联想点选结果(继续闸门必填) */ pickupConfirmed: MothershipAddressCandidate; deliveryConfirmed: MothershipAddressCandidate; pickupAccessorials: string[]; deliveryAccessorials: string[]; readyDate: string; readyTime: string; timezone: string; cargo: Array<{ cargoType: MsCargoTypeId; quantity: number; weightLb: number; lengthIn: number; widthIn: number; heightIn: number; }>; }; export interface MothershipLoggedInShipmentFormProps { formId?: string; disabled?: boolean; customerId: string; apiBaseUrl?: string; serviceToken?: string; onValidSubmit?: (payload: MothershipLoggedInShipmentPayload) => void; } function newCargoLine(): MsCargoLine { return { key: `c-${Math.random().toString(36).slice(2, 10)}`, cargoType: "piece", quantity: "", weightLb: "", lengthIn: "", widthIn: "", heightIn: "", }; } function cargoTypeFromFill(raw: string | undefined): MsCargoTypeId { const id = (raw ?? "piece").trim().toLowerCase(); const hit = MS_CARGO_TYPES.find((t) => t.id === id); return hit?.id ?? "piece"; } function cargoLinesFromFill( lines: MsLoggedInFillPayload["cargo_lines"], ): MsCargoLine[] | null { if (!lines || lines.length === 0) return null; return lines.map((line) => ({ key: `c-${Math.random().toString(36).slice(2, 10)}`, cargoType: cargoTypeFromFill(line.cargo_type), quantity: line.quantity != null && Number.isFinite(line.quantity) ? String(line.quantity) : "", weightLb: line.weight_lb != null && Number.isFinite(line.weight_lb) ? String(line.weight_lb) : "", lengthIn: line.length_in != null && Number.isFinite(line.length_in) ? String(line.length_in) : "", widthIn: line.width_in != null && Number.isFinite(line.width_in) ? String(line.width_in) : "", heightIn: line.height_in != null && Number.isFinite(line.height_in) ? String(line.height_in) : "", })); } function RequiredMark() { return ; } function AccessorialMultiSelect({ label, options, selected, disabled, tip, side, onChange, }: { label: string; options: readonly { id: string; label: string }[]; selected: string[]; disabled?: boolean; tip?: string; side: "pickup" | "delivery"; onChange: (next: string[], meta?: { message?: string; issues: MsCompatIssue[] }) => void; }) { const [open, setOpen] = useState(false); const [hint, setHint] = useState(null); const summary = useMemo(() => { if (selected.length === 0) return "选择"; if (selected.length === 1) { return options.find((o) => o.id === selected[0])?.label ?? "已选择"; } const first = options.find((o) => o.id === selected[0])?.label ?? selected[0]; return `${first} 等 ${selected.length} 项`; }, [options, selected]); return ( {label} {tip ? : null} setOpen((v) => !v)} aria-label="Select Chevron down" data-testid="address-book-accessorials-trigger" className="flex h-10 w-full items-center justify-between rounded-md border border-border bg-surface px-3 text-left text-sm transition-colors hover:border-[#1890FF]/40 focus:border-[#1890FF] focus:outline-none focus:ring-2 focus:ring-[#1890FF]/20 disabled:cursor-not-allowed disabled:opacity-60" > {summary} {open && !disabled && ( {options.map((opt) => { const checked = selected.includes(opt.id); const pickupRisk = side === "pickup" && Boolean(MS_PICKUP_RISK_ACCESSORIALS[opt.id]); const riskReason = pickupRisk ? MS_PICKUP_RISK_ACCESSORIALS[opt.id] : undefined; return ( { const r = toggleMothershipAccessorial({ side, selected, id: opt.id, }); if (!r.applied) { setHint(r.message ?? null); return; } setHint(r.message ?? null); onChange(r.next, { message: r.message, issues: r.issues }); }} className="mt-0.5 h-4 w-4 rounded border-border text-[#1890FF] focus:ring-[#1890FF]/30 disabled:cursor-not-allowed" /> {opt.label} {riskReason ? ( {riskReason} ) : null} ); })} )} {hint ? ( {hint} ) : null} {selected.length > 0 && ( {selected.map((id) => { const opt = options.find((o) => o.id === id); return ( {opt?.label ?? id} ); })} )} ); } /** MotherShip 登录后一级:创建新货件 */ export function MothershipLoggedInShipmentForm({ formId = "ms-logged-in-shipment-form", disabled, customerId, apiBaseUrl = "", serviceToken, onValidSubmit, }: MothershipLoggedInShipmentFormProps) { const baseId = useId(); const hostBridge = useHostBridgeOptional(); const appliedFillSeqRef = useRef(null); const [pickupQuery, setPickupQuery] = useState(""); const [deliveryQuery, setDeliveryQuery] = useState(""); const [pickupConfirmed, setPickupConfirmed] = useState(null); const [deliveryConfirmed, setDeliveryConfirmed] = useState(null); const [pickupAccessorials, setPickupAccessorials] = useState([]); const [deliveryAccessorials, setDeliveryAccessorials] = useState( [], ); const [readyDate, setReadyDate] = useState(defaultMothershipReadyDateIso); const [readyTime, setReadyTime] = useState(MS_DEFAULT_READY_TIME); const [timezone] = useState("GMT+8"); const [businessCustomerId, setBusinessCustomerId] = useState( undefined, ); const [businessUserAccount, setBusinessUserAccount] = useState< string | undefined >(undefined); const [cargoLines, setCargoLines] = useState([newCargoLine()]); const [localError, setLocalError] = useState(null); const [invalidFields, setInvalidFields] = useState>(new Set()); const pickupConfirmedRef = useRef(null); const deliveryConfirmedRef = useRef(null); const cargoFilledOnceRef = useRef(false); const cargoUserEditedRef = useRef(false); pickupConfirmedRef.current = pickupConfirmed; deliveryConfirmedRef.current = deliveryConfirmed; // 宿主 chajia:fill(MS_LOGGED_IN):可晚到 / 可重复;写入搜索框 + 货物行 useEffect(() => { const fill = hostBridge?.fill; if (!fill || fill.module !== "MS_LOGGED_IN") return; if (appliedFillSeqRef.current === fill.seq) return; appliedFillSeqRef.current = fill.seq; const form = (hostBridge?.msLoggedInPrefill ?? fill.form) as MsLoggedInFillPayload | null; if (!form || typeof form !== "object") return; setBusinessCustomerId(form.business_customer_id?.trim() || undefined); setBusinessUserAccount(form.business_user_account?.trim() || undefined); const pickupText = msLoggedInAddressQueryText(form.pickup_address); const deliveryText = msLoggedInAddressQueryText(form.delivery_address); const applied: string[] = []; // 用户已点选联想确认后,重复 fill(自动重试/再点填入)不得冲掉确认态与绿色提示 if (pickupText) { if (!pickupConfirmedRef.current) { setPickupQuery(pickupText); setPickupConfirmed(null); applied.push("pickup_address"); } else { applied.push("pickup_address(kept-confirmed)"); } } if (deliveryText) { if (!deliveryConfirmedRef.current) { setDeliveryQuery(deliveryText); setDeliveryConfirmed(null); applied.push("delivery_address"); } else { applied.push("delivery_address(kept-confirmed)"); } } if (Array.isArray(form.pickup_accessorials)) { setPickupAccessorials( form.pickup_accessorials.map((x) => String(x).trim()).filter(Boolean), ); applied.push("pickup_accessorials"); } if (Array.isArray(form.delivery_accessorials)) { setDeliveryAccessorials( form.delivery_accessorials.map((x) => String(x).trim()).filter(Boolean), ); applied.push("delivery_accessorials"); } if (form.ready_date?.trim()) { const normalized = normalizeMothershipReadyDateIso(form.ready_date.trim()); if (normalized) { setReadyDate(normalized); applied.push("ready_date"); } } if (form.ready_time?.trim()) { setReadyTime(form.ready_time.trim()); applied.push("ready_time"); } const nextCargo = cargoLinesFromFill(form.cargo_lines); // 预填只灌一次;用户手改后禁止再覆盖,保证可手动改参数 if (nextCargo) { if (!cargoUserEditedRef.current && !cargoFilledOnceRef.current) { setCargoLines(nextCargo); cargoFilledOnceRef.current = true; applied.push("cargo_lines"); } else { applied.push( cargoUserEditedRef.current ? "cargo_lines(kept-user-edit)" : "cargo_lines(kept-once)", ); } } postToHost("chajia:fill-ack", { request_id: fill.requestId, module: "MS_LOGGED_IN", payload: { ok: true, module: "MS_LOGGED_IN", applied_keys: applied, message: applied.length > 0 ? "已写入创建新货件表单(地址为搜索框文本,请点选联想确认后再继续)" : "已收到 fill,但无可写入字段", ui_applied: true, }, }); }, [hostBridge?.fill, hostBridge?.msLoggedInPrefill]); const updateCargo = useCallback( (key: string, patch: Partial) => { cargoUserEditedRef.current = true; setCargoLines((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r)), ); }, [], ); const handleSubmit = (e: FormEvent) => { e.preventDefault(); setLocalError(null); const nextInvalid = new Set(); if (!pickupConfirmed) { nextInvalid.add("pickup"); setInvalidFields(nextInvalid); setLocalError("请填写提货地址"); return; } if (!deliveryConfirmed) { nextInvalid.add("delivery"); setInvalidFields(nextInvalid); setLocalError("请填写送货地址"); return; } const pickupIssues = evaluateMothershipAccessorialCompat({ side: "pickup", selected: pickupAccessorials, }); const deliveryIssues = evaluateMothershipAccessorialCompat({ side: "delivery", selected: deliveryAccessorials, }); const blockMsg = [...pickupIssues, ...deliveryIssues].find( (i) => i.severity === "block", )?.message; if (blockMsg) { setLocalError(blockMsg); return; } const cargo: MothershipLoggedInShipmentPayload["cargo"] = []; const ceilNotes: string[] = []; for (const line of cargoLines) { const quantity = Number(line.quantity); const weightLbRaw = Number(line.weightLb); const lengthInRaw = Number(line.lengthIn); const widthInRaw = Number(line.widthIn); const heightInRaw = Number(line.heightIn); if (!Number.isFinite(quantity) || quantity < 1) { nextInvalid.add(`${line.key}-quantity`); } if (!Number.isFinite(weightLbRaw) || weightLbRaw <= 0) { nextInvalid.add(`${line.key}-weight`); } else if (!isMothershipWeightEachAllowed(weightLbRaw)) { nextInvalid.add(`${line.key}-weight`); setInvalidFields(nextInvalid); setLocalError(MOTHERSHIP_AVG_WEIGHT_BLOCK_MESSAGE); return; } if (!Number.isFinite(lengthInRaw) || lengthInRaw <= 0) { nextInvalid.add(`${line.key}-length`); } if (!Number.isFinite(widthInRaw) || widthInRaw <= 0) { nextInvalid.add(`${line.key}-width`); } if (!Number.isFinite(heightInRaw) || heightInRaw <= 0) { nextInvalid.add(`${line.key}-height`); } if ( nextInvalid.has(`${line.key}-quantity`) || nextInvalid.has(`${line.key}-weight`) || nextInvalid.has(`${line.key}-length`) || nextInvalid.has(`${line.key}-width`) || nextInvalid.has(`${line.key}-height`) ) { setInvalidFields(nextInvalid); setLocalError("请完整填写每行货物的数量、重量与尺寸"); return; } const weightLb = ceilMothershipNumeric(weightLbRaw); const lengthIn = ceilMothershipNumeric(lengthInRaw); const widthIn = ceilMothershipNumeric(widthInRaw); const heightIn = ceilMothershipNumeric(heightInRaw); if ( hasMothershipFractionalPart(weightLbRaw) || hasMothershipFractionalPart(lengthInRaw) || hasMothershipFractionalPart(widthInRaw) || hasMothershipFractionalPart(heightInRaw) ) { ceilNotes.push( `重量 ${weightLbRaw}→${weightLb} lb,尺寸 ${lengthInRaw}×${widthInRaw}×${heightInRaw}→${lengthIn}×${widthIn}×${heightIn} in`, ); } cargo.push({ cargoType: line.cargoType, quantity, weightLb, lengthIn, widthIn, heightIn, }); } // 回写表单显示进位后的整数,避免用户误以为仍带小数提交 if (ceilNotes.length > 0) { setCargoLines((prev) => prev.map((row, idx) => { const c = cargo[idx]; if (!c) return row; return { ...row, weightLb: String(c.weightLb), lengthIn: String(c.lengthIn), widthIn: String(c.widthIn), heightIn: String(c.heightIn), }; }), ); setLocalError( `${MOTHERSHIP_INTEGER_HINT}:${ceilNotes.join(";")}`, ); } else { setLocalError(null); } const weightBlock = findMothershipCargoWeightBlockMessage( cargo.map((row) => ({ weightLb: row.weightLb, quantity: row.quantity })), ); if (weightBlock) { setLocalError(weightBlock); return; } setInvalidFields(new Set()); const normalizedReadyDate = normalizeMothershipReadyDateIso(readyDate) ?? readyDate; onValidSubmit?.({ businessCustomerId, businessUserAccount, pickupQuery: pickupConfirmed.display_label || pickupConfirmed.formatted_address || pickupQuery.trim(), deliveryQuery: deliveryConfirmed.display_label || deliveryConfirmed.formatted_address || deliveryQuery.trim(), pickupConfirmed, deliveryConfirmed, pickupAccessorials, deliveryAccessorials, readyDate: normalizedReadyDate, readyTime, timezone, cargo, }); }; const fieldCls = (key: string) => invalidFields.has(key) ? quoteInputErrCls : quoteInputCls; return ( 创建新货件 {/* 地址 + 时间 */} 提货与送货 提货信息 仅保留询价实际需要的地址搜索与附加服务。 { setPickupQuery(q); setInvalidFields((prev) => { const next = new Set(prev); next.delete("pickup"); return next; }); }} onConfirm={(c) => { pickupConfirmedRef.current = c; setPickupConfirmed(c); if (c) { setInvalidFields((prev) => { const next = new Set(prev); next.delete("pickup"); return next; }); setLocalError(null); } }} /> { setPickupAccessorials(next); if (meta?.message) setLocalError(meta.message); else if ( meta?.issues && mothershipAccessorialHasBlock(meta.issues) ) { setLocalError( meta.issues.find((i) => i.severity === "block")!.message, ); } }} /> 送货信息 地址确认后再继续,右侧报价会基于已确认地址生成。 { setDeliveryQuery(q); setInvalidFields((prev) => { const next = new Set(prev); next.delete("delivery"); return next; }); }} onConfirm={(c) => { deliveryConfirmedRef.current = c; setDeliveryConfirmed(c); if (c) { setInvalidFields((prev) => { const next = new Set(prev); next.delete("delivery"); return next; }); setLocalError(null); } }} /> { setDeliveryAccessorials(next); if (meta?.message) setLocalError(meta.message); }} /> 可提货时间 之后 可提货时刻 setReadyTime(e.target.value)} className={`${quoteInputCls} min-w-[7.5rem]`} > {MS_READY_TIMES.map((t) => ( {t} ))} {timezone} {/* 货物 */} 货物 {/* 右上角红 !:内联上移,避免 absolute 被父级裁切 */} ! {MOTHERSHIP_CARGO_ENTRY_HINT_TITLE} {MOTHERSHIP_CARGO_ENTRY_HINT_ITEMS.map((line) => ( · {line} ))} { cargoUserEditedRef.current = true; setCargoLines((rows) => [...rows, newCargoLine()]); }} className={quoteAddRowCls} > 添加货物行 {MOTHERSHIP_INTEGER_HINT} {cargoLines.map((line, idx) => ( 货物类型 updateCargo(line.key, { cargoType: e.target.value as MsCargoTypeId, }) } className={quoteInputCls} > {MS_CARGO_TYPES.map((t) => ( {t.label} ))} 数量 updateCargo(line.key, { quantity: e.target.value }) } className={fieldCls(`${line.key}-quantity`)} /> 单件重量 updateCargo(line.key, { weightLb: e.target.value }) } className={fieldCls(`${line.key}-weight`)} /> 单件长 updateCargo(line.key, { lengthIn: e.target.value }) } className={fieldCls(`${line.key}-length`)} /> 单件宽 updateCargo(line.key, { widthIn: e.target.value }) } className={fieldCls(`${line.key}-width`)} /> 单件高 updateCargo(line.key, { heightIn: e.target.value }) } className={fieldCls(`${line.key}-height`)} /> { cargoUserEditedRef.current = true; setCargoLines((rows) => rows.filter((r) => r.key !== line.key), ); }} className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-border text-text-secondary transition-colors hover:border-[#EF4444] hover:text-[#EF4444] disabled:cursor-not-allowed disabled:opacity-40" aria-label="删除该行货物" > ))} ); }
{hint}
提货信息
仅保留询价实际需要的地址搜索与附加服务。
送货信息
地址确认后再继续,右侧报价会基于已确认地址生成。
{MOTHERSHIP_INTEGER_HINT}