/** * MotherShip 登录后二级 Details:提货 / 送货 / 货物明细 * 字段与一级 Create shipment 一一对应并预填 */ "use client"; import { useMemo, useState } from "react"; import { Plus, X } from "@phosphor-icons/react"; import { MS_CARGO_TYPES, MS_DEFAULT_READY_TIME, MS_DELIVERY_ACCESSORIALS, MS_PICKUP_ACCESSORIALS, MS_PIECE_COUNT_TYPES, MS_READY_TIMES, type MothershipLoggedInShipmentPayload, type MsCargoTypeId, } from "@/components/mothership/mothership-logged-in-shipment-form"; import { MothershipWeekdayDatePicker } from "@/components/mothership/mothership-weekday-date-picker"; import { snapMothershipReadyDateToWeekday, } from "@/lib/mothership/logged-in-constraints"; import { evaluateMothershipAccessorialCompat, MS_PICKUP_RISK_ACCESSORIALS, toggleMothershipAccessorial, } from "@/lib/mothership/option-compat"; import { QuoteFieldTooltip } from "@/components/quote/quote-form-chrome"; import { MS_DETAILS_FIELD_TIPS } from "@/lib/mothership/ms-details-field-tips"; import { collectMsDetailsRefineInvalidFields, MS_EMAILS_MUST_DIFFER_MESSAGE, MS_OPENS_NOT_BEFORE_CLOSES_MESSAGE, MS_READY_BEFORE_OPENS_MESSAGE, parseMsClockToMinutes, validateMsDetailsForRefine, type MsDetailsFieldKey, } from "@/lib/mothership/ms-details-refine-validation"; import { ErrorBanner } from "@/components/ui/error-banner"; export type MsLocationDetails = { address: string; companyName: string; suite: string; contactFirst: string; contactLast: string; contactEmail: string; contactPhone: string; extension: string; reference: string; accessorials: string[]; notes: string; opensAt: string; closesAt: string; readyDate: string; readyTime: string; }; export type MsDetailsCargoLine = { key: string; cargoType: MsCargoTypeId; quantity: string; freightClass: string; weightLb: string; lengthIn: string; widthIn: string; heightIn: string; pieceCountType: string; pieceCountQty: string; description: string; nmfc: string; hazmat: boolean; alcohol: boolean; tobacco: boolean; }; export type MothershipLoggedInDetailsState = { pickup: MsLocationDetails; delivery: MsLocationDetails; requestDeliveryAppointment: boolean; fbaNumber: string; fbaPoNumber: string; cargo: MsDetailsCargoLine[]; }; export type MothershipLoggedInDetailsPayload = { pickup: { companyName: string; suite: string; contactFirst: string; contactLast: string; contactEmail: string; contactPhone: string; reference: string; notes: string; opensAt: string; closesAt: string; accessorials: string[]; }; delivery: { companyName: string; suite: string; contactFirst: string; contactLast: string; contactEmail: string; contactPhone: string; reference: string; notes: string; opensAt: string; closesAt: string; accessorials: string[]; }; requestDeliveryAppointment: boolean; fbaNumber: string; fbaPoNumber: string; cargo: Array<{ pieceCountType: string; pieceCountQty: number; description: string; nmfc: string; hazmat: boolean; alcohol: boolean; tobacco: boolean; }>; }; /** 二级表单状态 → 提交 payload(含附加服务) */ export function detailsStateToPayload( state: MothershipLoggedInDetailsState, ): MothershipLoggedInDetailsPayload { return { pickup: { companyName: state.pickup.companyName.trim(), suite: state.pickup.suite.trim(), contactFirst: state.pickup.contactFirst.trim(), contactLast: state.pickup.contactLast.trim(), contactEmail: state.pickup.contactEmail.trim(), contactPhone: state.pickup.contactPhone.trim(), reference: state.pickup.reference.trim(), notes: state.pickup.notes.trim(), opensAt: state.pickup.opensAt.trim(), closesAt: state.pickup.closesAt.trim(), accessorials: [...state.pickup.accessorials], }, delivery: { companyName: state.delivery.companyName.trim(), suite: state.delivery.suite.trim(), contactFirst: state.delivery.contactFirst.trim(), contactLast: state.delivery.contactLast.trim(), contactEmail: state.delivery.contactEmail.trim(), contactPhone: state.delivery.contactPhone.trim(), reference: state.delivery.reference.trim(), notes: state.delivery.notes.trim(), opensAt: state.delivery.opensAt.trim(), closesAt: state.delivery.closesAt.trim(), accessorials: [...state.delivery.accessorials], }, requestDeliveryAppointment: state.requestDeliveryAppointment, fbaNumber: state.fbaNumber.trim(), fbaPoNumber: state.fbaPoNumber.trim(), cargo: state.cargo.map((line) => ({ pieceCountType: line.pieceCountType.trim(), pieceCountQty: Number(line.pieceCountQty || 0), description: line.description.trim(), nmfc: line.nmfc.trim(), hazmat: line.hazmat, alcohol: line.alcohol, tobacco: line.tobacco, })), }; } function RequiredMark() { return *; } function FieldLabel({ children, required, tip, }: { children: React.ReactNode; required?: boolean; /** 官网 ⓘ 提示中文文案;有值则显示可点击/悬停说明 */ tip?: string; }) { return ( {children} {required ? : null} {tip ? ( ) : null} ); } const INPUT_OK_CLS = "h-10 w-full rounded-md border border-border bg-surface px-3 text-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:opacity-60"; const INPUT_ERR_CLS = "h-10 w-full rounded-md border-2 border-error bg-error/5 px-3 text-sm text-text-primary focus:border-error focus:outline-none focus:ring-2 focus:ring-error/25 disabled:opacity-60"; const SELECT_OK_CLS = "h-10 min-w-[8rem] rounded-md border border-border px-2 text-sm"; const SELECT_ERR_CLS = "h-10 min-w-[8rem] rounded-md border-2 border-error bg-error/5 px-2 text-sm focus:border-error focus:outline-none focus:ring-2 focus:ring-error/25"; function TextInput({ value, onChange, placeholder, disabled, className = "", required, type = "text", name, invalid, "data-testid": testId, }: { value: string; onChange: (v: string) => void; placeholder?: string; disabled?: boolean; className?: string; required?: boolean; type?: string; name?: string; /** 必填空/错:明显红边提醒 */ invalid?: boolean; "data-testid"?: string; }) { return ( onChange(e.target.value)} aria-invalid={invalid || undefined} data-testid={testId} data-invalid={invalid ? "true" : undefined} className={`${invalid ? INPUT_ERR_CLS : INPUT_OK_CLS} ${className}`} /> ); } type AccessorialOption = { id: string; label: string; description?: string; }; function AccessorialCardGrid({ options, selected, disabled, onChange, side, }: { options: readonly AccessorialOption[]; selected: string[]; disabled?: boolean; onChange: (next: string[]) => void; side: "pickup" | "delivery"; }) { const [showDetails, setShowDetails] = useState(true); const [hint, setHint] = useState(null); const issues = useMemo( () => evaluateMothershipAccessorialCompat({ side, selected }), [side, selected], ); return ( 选择提货/送货服务,避免追收附加费 查看服务详情 setShowDetails((v) => !v)} className={[ "relative h-5 w-9 shrink-0 rounded-full transition-colors", showDetails ? "bg-primary" : "bg-border", disabled ? "opacity-50" : "", ].join(" ")} > {options.map((opt) => { const checked = selected.includes(opt.id); const pickupRisk = side === "pickup" && Boolean(MS_PICKUP_RISK_ACCESSORIALS[opt.id]); return ( { const r = toggleMothershipAccessorial({ side, selected, id: opt.id, }); if (!r.applied) { setHint(r.message ?? null); return; } setHint(r.message ?? null); onChange(r.next); }} className={[ "rounded-lg border px-3 py-2.5 text-left transition-colors", checked ? "border-primary bg-primary/5 ring-1 ring-primary/30" : "border-border bg-surface hover:border-primary/40", disabled ? "cursor-not-allowed opacity-55" : "cursor-pointer", ].join(" ")} > {checked ? ( ✓ ) : null} {opt.label} {showDetails && opt.description ? ( {opt.description} ) : null} {pickupRisk ? ( {MS_PICKUP_RISK_ACCESSORIALS[opt.id]} ) : null} ); })} {hint ? ( {hint} ) : null} {issues.length > 0 ? ( {issues.map((i) => ( {i.message} ))} ) : null} ); } function locationFromL1( address: string, accessorials: string[], readyDate: string, readyTime: string, ): MsLocationDetails { return { address, companyName: "", suite: "", contactFirst: "", contactLast: "", contactEmail: "", contactPhone: "", extension: "", reference: "", accessorials: [...accessorials], notes: "", opensAt: "", closesAt: "", readyDate: snapMothershipReadyDateToWeekday(readyDate), readyTime, }; } function locationFromConfirmed( confirmed: { formatted_address: string; display_label: string; street: string; city: string; state: string; zip: string; }, fallbackQuery: string, accessorials: string[], readyDate: string, readyTime: string, ): MsLocationDetails { const label = confirmed.formatted_address || confirmed.display_label || fallbackQuery; const composed = [confirmed.street, confirmed.city, confirmed.state, confirmed.zip] .map((p) => p?.trim()) .filter(Boolean) .join(", "); return { ...locationFromL1(label || composed || fallbackQuery, accessorials, readyDate, readyTime), }; } export function buildDetailsStateFromL1( payload: MothershipLoggedInShipmentPayload, ): MothershipLoggedInDetailsState { const wantFba = payload.deliveryAccessorials.includes("fbaAppointment"); return { pickup: locationFromConfirmed( payload.pickupConfirmed, payload.pickupQuery, payload.pickupAccessorials, payload.readyDate, payload.readyTime, ), delivery: locationFromConfirmed( payload.deliveryConfirmed, payload.deliveryQuery, payload.deliveryAccessorials, payload.readyDate, payload.readyTime, ), requestDeliveryAppointment: wantFba || payload.deliveryAccessorials.includes("appointment"), fbaNumber: "", fbaPoNumber: "", cargo: payload.cargo.map((c, i) => ({ key: `d-${i}-${c.cargoType}`, cargoType: c.cargoType, quantity: String(c.quantity), freightClass: "", weightLb: String(c.weightLb), lengthIn: String(c.lengthIn), widthIn: String(c.widthIn), heightIn: String(c.heightIn), pieceCountType: "", pieceCountQty: "0", description: "", nmfc: "", hazmat: false, alcohol: false, tobacco: false, })), }; } function LocationSection({ title, side, value, disabled, accessorialOptions, hintPickUp, showFreightReady, invalidFields, peerEmail, onChange, }: { title: string; side: "pickup" | "delivery"; value: MsLocationDetails; disabled?: boolean; accessorialOptions: readonly AccessorialOption[]; hintPickUp?: boolean; showFreightReady?: boolean; invalidFields?: ReadonlySet; /** 对侧联系人邮箱:用于即时提示不得相同 */ peerEmail?: string; onChange: (next: MsLocationDetails) => void; }) { const set = ( key: K, v: MsLocationDetails[K], ) => onChange({ ...value, [key]: v }); const bad = (field: string) => Boolean(invalidFields?.has(`${side}.${field}` as MsDetailsFieldKey)); const openMin = parseMsClockToMinutes(value.opensAt); const closeMin = parseMsClockToMinutes(value.closesAt); const hoursConflict = openMin != null && closeMin != null && openMin >= closeMin; const readyMin = showFreightReady ? parseMsClockToMinutes(value.readyTime || MS_DEFAULT_READY_TIME) : null; const readyConflict = Boolean(showFreightReady) && readyMin != null && openMin != null && readyMin < openMin; const emailDup = Boolean(peerEmail?.trim()) && Boolean(value.contactEmail.trim()) && peerEmail!.trim().toLowerCase() === value.contactEmail.trim().toLowerCase(); return ( {title} 地址 set("address", v)} placeholder="街道、城市、州、邮编" /> 完整公司名 set("companyName", v)} placeholder="Acme Co." /> 非住宅件请勿用人名作公司名,以免承运商加收附加费。 {bad("companyName") ? ( 请填写完整公司名 ) : null} 套房 / 单元 set("suite", v)} placeholder="Ste 301" /> 现场联系人名 set("contactFirst", v)} placeholder="名" /> 现场联系人姓 set("contactLast", v)} placeholder="姓" /> 现场联系人邮箱 set("contactEmail", v)} placeholder="email@company.com" /> {emailDup ? ( {MS_EMAILS_MUST_DIFFER_MESSAGE} ) : bad("contactEmail") ? ( 请填写有效的现场联系人邮箱 ) : ( {hintPickUp ? "填写提货方邮箱以接收提单与跟踪更新;须与送货方邮箱不同。" : "填写收货方邮箱以接收跟踪更新;须与提货方邮箱不同。"} )} 现场联系人电话 set("contactPhone", v)} placeholder="(555) 555-5555" /> {bad("contactPhone") ? ( 请填写现场联系人电话 ) : null} 分机 set("extension", v)} placeholder="分机" /> 参考号 set("reference", v)} placeholder="采购单或参考号" /> set("accessorials", next)} /> 备注 set("notes", e.target.value)} placeholder="例如:便于司机找到正确位置的关键信息" rows={2} className="w-full rounded-md border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" /> 营业时间 开门 set("opensAt", e.target.value)} className={ bad("opensAt") || hoursConflict ? SELECT_ERR_CLS : SELECT_OK_CLS } > 选择时间 {MS_READY_TIMES.map((t) => ( {t} ))} {hoursConflict ? ( {MS_OPENS_NOT_BEFORE_CLOSES_MESSAGE} ) : bad("opensAt") ? ( 请选择开门时间 ) : null} EDT 关门 set("closesAt", e.target.value)} className={ bad("closesAt") || hoursConflict ? SELECT_ERR_CLS : SELECT_OK_CLS } > 选择时间 {MS_READY_TIMES.map((t) => ( {t} ))} {hoursConflict ? ( {MS_OPENS_NOT_BEFORE_CLOSES_MESSAGE} ) : bad("closesAt") ? ( 请选择关门时间 ) : null} EDT {showFreightReady && ( 货运就绪时间(不可选周六、周日) set("readyDate", iso)} className="h-10 min-w-[11rem] rounded-md border border-border bg-surface px-3 text-sm" /> 之后 set("readyTime", e.target.value)} className={ bad("readyTime") || readyConflict ? "h-10 min-w-[7.5rem] rounded-md border-2 border-error bg-error/5 px-2 text-sm" : "h-10 min-w-[7.5rem] rounded-md border border-border px-2 text-sm" } > {MS_READY_TIMES.map((t) => ( {t} ))} EDT {readyConflict ? MS_READY_BEFORE_OPENS_MESSAGE : "就绪时刻须不早于提货点开门时间,且开门须早于关门。"} )} ); } export interface MothershipLoggedInDetailsFormProps { formId?: string; initial: MothershipLoggedInShipmentPayload; disabled?: boolean; /** 确认补填后:空/错必填项立即红边高亮 */ highlightRequiredGaps?: boolean; onChange?: (state: MothershipLoggedInDetailsState) => void; onValidSubmit?: (payload: MothershipLoggedInDetailsPayload) => void; } export function MothershipLoggedInDetailsForm({ formId = "ms-logged-in-details-form", initial, disabled, highlightRequiredGaps = false, onChange, onValidSubmit, }: MothershipLoggedInDetailsFormProps) { const [state, setState] = useState(() => buildDetailsStateFromL1(initial)); const [formError, setFormError] = useState(null); const [forceShowFieldErrors, setForceShowFieldErrors] = useState(false); const patch = (next: MothershipLoggedInDetailsState) => { setState(next); setFormError(null); onChange?.(next); }; const showFieldErrors = highlightRequiredGaps || forceShowFieldErrors; const invalidFields = useMemo(() => { if (!showFieldErrors) return new Set(); const payload = detailsStateToPayload(state); return collectMsDetailsRefineInvalidFields(payload, { pickupAddress: state.pickup.address, deliveryAddress: state.delivery.address, readyTime: state.pickup.readyTime || initial.readyTime, }); }, [showFieldErrors, state, initial.readyTime]); const showFba = useMemo( () => state.requestDeliveryAppointment || state.delivery.accessorials.includes("fbaAppointment"), [state], ); const toPayload = (): MothershipLoggedInDetailsPayload => detailsStateToPayload(state); return ( { e.preventDefault(); const payload = toPayload(); const check = validateMsDetailsForRefine(payload, { pickupAddress: state.pickup.address, deliveryAddress: state.delivery.address, readyTime: state.pickup.readyTime || initial.readyTime, }); if (!check.ok) { setForceShowFieldErrors(true); setFormError(check.message); return; } setFormError(null); setForceShowFieldErrors(false); onValidSubmit?.(payload); }} > {formError ? ( {formError} ) : null} 报价 · 详情 · 复核 货件详情 patch({ ...state, pickup })} /> {showFba && ( patch({ ...state, requestDeliveryAppointment: e.target.checked, }) } className="mt-1" /> 申请送货预约 自动为住宅/商业地址安排送货预约 {state.requestDeliveryAppointment && ( 填写 Fulfillment by Amazon(FBA)编号 FBA 编号 patch({ ...state, fbaNumber: v })} placeholder="输入 FBA 编号" /> 采购单号 patch({ ...state, fbaPoNumber: v })} placeholder="输入采购单号" /> FBA 编号将写入提单 FBA 预约建议填写下方的{" "} 件数类型 / 件数数量 (如纸箱、箱) )} )} patch({ ...state, delivery })} /> 3. 货物 patch({ ...state, cargo: [ ...state.cargo, { key: `d-${Date.now()}`, cargoType: "pallet", quantity: "", freightClass: "", weightLb: "", lengthIn: "", widthIn: "", heightIn: "", pieceCountType: "", pieceCountQty: "0", description: "", nmfc: "", hazmat: false, alcohol: false, tobacco: false, }, ], }) } className="inline-flex h-9 items-center gap-1.5 rounded-md bg-text-primary px-3 text-sm font-medium text-white" > 添加另一行 {state.cargo.map((line, idx) => ( {idx + 1} patch({ ...state, cargo: state.cargo.filter((c) => c.key !== line.key), }) } className="inline-flex h-8 w-8 items-center justify-center rounded border border-border text-text-secondary disabled:opacity-40" > 货物类型 { const cargo = state.cargo.map((c) => c.key === line.key ? { ...c, cargoType: e.target.value as MsCargoTypeId, } : c, ); patch({ ...state, cargo }); }} className="h-10 w-full rounded-md border border-border px-2 text-sm" > {MS_CARGO_TYPES.map((t) => ( {t.label} ))} 数量 { const cargo = state.cargo.map((c) => c.key === line.key ? { ...c, quantity: v } : c, ); patch({ ...state, cargo }); }} /> 运费等级(可选) { const cargo = state.cargo.map((c) => c.key === line.key ? { ...c, freightClass: v } : c, ); patch({ ...state, cargo }); }} placeholder="选择" /> {( [ ["weightLb", "单件重量", "lbs"], ["lengthIn", "单件长", "inch"], ["widthIn", "单件宽", "inch"], ["heightIn", "单件高", "inch"], ] as const ).map(([key, label, ph]) => ( {label} { const cargo = state.cargo.map((c) => c.key === line.key ? { ...c, [key]: v } : c, ); patch({ ...state, cargo }); }} placeholder={ph} /> ))} 件数类型 { const cargo = state.cargo.map((c) => c.key === line.key ? { ...c, pieceCountType: e.target.value } : c, ); patch({ ...state, cargo }); }} className="h-10 w-full rounded-md border border-border px-2 text-sm" > 选择 {MS_PIECE_COUNT_TYPES.map((t) => ( {t.label} ))} 件数数量 { const cargo = state.cargo.map((c) => c.key === line.key ? { ...c, pieceCountQty: v } : c, ); patch({ ...state, cargo }); }} placeholder="该类型数量" /> 货物描述 { const cargo = state.cargo.map((c) => c.key === line.key ? { ...c, description: e.target.value } : c, ); patch({ ...state, cargo }); }} rows={2} placeholder="准确描述货物以避免查验与加收。例如:用「地毯」而非「一般商品」。" className={ invalidFields.has(`cargo.${idx}.description`) ? "w-full rounded-md border-2 border-error bg-error/5 px-3 py-2 text-sm focus:border-error focus:outline-none focus:ring-2 focus:ring-error/25" : "w-full rounded-md border border-border px-3 py-2 text-sm" } /> NMFC 编码(可选) { const cargo = state.cargo.map((c) => c.key === line.key ? { ...c, nmfc: v } : c, ); patch({ ...state, cargo }); }} placeholder="输入 NMFC 编码" /> {MS_DETAILS_FIELD_TIPS.nmfc} 受管制货物声明 {( [ ["hazmat", "危险品(Hazmat)"], ["alcohol", "啤酒/葡萄酒/烈酒"], ["tobacco", "尼古丁/烟草"], ] as const ).map(([key, label]) => ( { const cargo = state.cargo.map((c) => c.key === line.key ? { ...c, [key]: e.target.checked } : c, ); patch({ ...state, cargo }); }} /> {label} ))} ))} ); }
选择提货/送货服务,避免追收附加费
{hint}
非住宅件请勿用人名作公司名,以免承运商加收附加费。
请填写完整公司名
{MS_EMAILS_MUST_DIFFER_MESSAGE}
请填写有效的现场联系人邮箱
{hintPickUp ? "填写提货方邮箱以接收提单与跟踪更新;须与送货方邮箱不同。" : "填写收货方邮箱以接收跟踪更新;须与提货方邮箱不同。"}
请填写现场联系人电话
营业时间
{MS_OPENS_NOT_BEFORE_CLOSES_MESSAGE}
请选择开门时间
请选择关门时间
{readyConflict ? MS_READY_BEFORE_OPENS_MESSAGE : "就绪时刻须不早于提货点开门时间,且开门须早于关门。"}
报价 · 详情 · 复核
填写 Fulfillment by Amazon(FBA)编号
{MS_DETAILS_FIELD_TIPS.nmfc}
受管制货物声明