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.
chajia/components/mothership/mothership-logged-in-shipme...

767 lines
26 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.

/**
* MotherShip 登录后一级查价界面Create a new shipment
* 对齐官网 dashboard「创建新货件」字段结构中文文案供嵌入宿主展示
*/
"use client";
import { useCallback, useId, useMemo, useState, type FormEvent } from "react";
import { Plus, X, CaretDown } 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,
isMothershipWeightEachAllowed,
MOTHERSHIP_MAX_WEIGHT_EACH_LB,
normalizeMothershipReadyDateIso,
} from "@/lib/mothership/logged-in-constraints";
import {
evaluateMothershipAccessorialCompat,
mothershipAccessorialHasBlock,
MS_PICKUP_BLOCKED_ACCESSORIALS,
toggleMothershipAccessorial,
type MsCompatIssue,
} from "@/lib/mothership/option-compat";
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;
/** 官网 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 = {
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: "pallet",
quantity: "",
weightLb: "",
lengthIn: "",
widthIn: "",
heightIn: "",
};
}
function RequiredMark() {
return <QuoteRequiredMark />;
}
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<string | null>(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 (
<div className="relative space-y-1">
<div className="flex items-center gap-1 text-xs text-[#4B5563]">
<span>{label}</span>
{tip ? <QuoteFieldTooltip tip={tip} /> : null}
</div>
<button
type="button"
disabled={disabled}
onClick={() => setOpen((v) => !v)}
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"
>
<span
className={
selected.length === 0 ? "text-text-disabled" : "text-text-primary"
}
>
{summary}
</span>
<CaretDown size={16} className="text-text-secondary" />
</button>
{open && !disabled && (
<div className="absolute z-20 mt-1 max-h-56 w-full overflow-auto rounded-md border border-border bg-surface py-1 shadow-modal">
{options.map((opt) => {
const checked = selected.includes(opt.id);
const pickupBlocked =
side === "pickup" &&
Boolean(MS_PICKUP_BLOCKED_ACCESSORIALS[opt.id]);
const blockedReason = pickupBlocked
? MS_PICKUP_BLOCKED_ACCESSORIALS[opt.id]
: undefined;
return (
<label
key={opt.id}
className={[
"flex items-start gap-2 px-3 py-2 text-sm",
pickupBlocked && !checked
? "cursor-not-allowed opacity-60"
: "cursor-pointer hover:bg-bg",
].join(" ")}
title={blockedReason}
>
<input
type="checkbox"
checked={checked}
disabled={pickupBlocked && !checked}
onChange={() => {
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"
/>
<span>
<span className="block">{opt.label}</span>
{blockedReason ? (
<span className="mt-0.5 block text-[11px] text-[#EF4444]">
{blockedReason}
</span>
) : null}
</span>
</label>
);
})}
</div>
)}
{hint ? (
<p className="text-[11px] leading-snug text-[#B45309]">{hint}</p>
) : null}
{selected.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{selected.map((id) => {
const opt = options.find((o) => o.id === id);
return (
<span
key={id}
className="inline-flex items-center gap-1 rounded-full border border-border bg-bg px-2 py-0.5 text-xs text-text-secondary"
>
{opt?.label ?? id}
</span>
);
})}
</div>
)}
</div>
);
}
/** MotherShip 登录后一级:创建新货件 */
export function MothershipLoggedInShipmentForm({
formId = "ms-logged-in-shipment-form",
disabled,
customerId,
apiBaseUrl = "",
serviceToken,
onValidSubmit,
}: MothershipLoggedInShipmentFormProps) {
const baseId = useId();
const [pickupQuery, setPickupQuery] = useState("");
const [deliveryQuery, setDeliveryQuery] = useState("");
const [pickupConfirmed, setPickupConfirmed] =
useState<MothershipAddressCandidate | null>(null);
const [deliveryConfirmed, setDeliveryConfirmed] =
useState<MothershipAddressCandidate | null>(null);
const [pickupAccessorials, setPickupAccessorials] = useState<string[]>([]);
const [deliveryAccessorials, setDeliveryAccessorials] = useState<string[]>(
[],
);
const [readyDate, setReadyDate] = useState(defaultMothershipReadyDateIso);
const [readyTime, setReadyTime] = useState(MS_DEFAULT_READY_TIME);
const [timezone] = useState("GMT+8");
const [cargoLines, setCargoLines] = useState<MsCargoLine[]>([newCargoLine()]);
const [localError, setLocalError] = useState<string | null>(null);
const [invalidFields, setInvalidFields] = useState<Set<string>>(new Set());
const accessorialHints = useMemo(() => {
return [
...evaluateMothershipAccessorialCompat({
side: "pickup",
selected: pickupAccessorials,
}),
...evaluateMothershipAccessorialCompat({
side: "delivery",
selected: deliveryAccessorials,
}),
];
}, [pickupAccessorials, deliveryAccessorials]);
const updateCargo = useCallback(
(key: string, patch: Partial<MsCargoLine>) => {
setCargoLines((rows) =>
rows.map((r) => (r.key === key ? { ...r, ...patch } : r)),
);
},
[],
);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
setLocalError(null);
const nextInvalid = new Set<string>();
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"] = [];
for (const line of cargoLines) {
const quantity = Number(line.quantity);
const weightLb = Number(line.weightLb);
const lengthIn = Number(line.lengthIn);
const widthIn = Number(line.widthIn);
const heightIn = Number(line.heightIn);
if (!Number.isFinite(quantity) || quantity < 1) {
nextInvalid.add(`${line.key}-quantity`);
}
if (!Number.isFinite(weightLb) || weightLb <= 0) {
nextInvalid.add(`${line.key}-weight`);
} else if (!isMothershipWeightEachAllowed(weightLb)) {
nextInvalid.add(`${line.key}-weight`);
setInvalidFields(nextInvalid);
setLocalError(
`单件重量不能超过 ${MOTHERSHIP_MAX_WEIGHT_EACH_LB}`,
);
return;
}
if (!Number.isFinite(lengthIn) || lengthIn <= 0) {
nextInvalid.add(`${line.key}-length`);
}
if (!Number.isFinite(widthIn) || widthIn <= 0) {
nextInvalid.add(`${line.key}-width`);
}
if (!Number.isFinite(heightIn) || heightIn <= 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;
}
cargo.push({
cargoType: line.cargoType,
quantity,
weightLb,
lengthIn,
widthIn,
heightIn,
});
}
setInvalidFields(new Set());
const normalizedReadyDate = normalizeMothershipReadyDateIso(readyDate) ?? readyDate;
onValidSubmit?.({
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 (
<form id={formId} onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-3">
<QuoteStepProgress current={1} />
<QuotePageTitle></QuotePageTitle>
</div>
{accessorialHints.length > 0 ? (
<div className="space-y-1 rounded-md border border-[#F59E0B]/40 bg-[#FFFBEB] px-3 py-2 text-[12px] leading-snug text-[#92400E]">
<p className="font-medium text-[#B45309]"></p>
<ul className="list-disc space-y-1 pl-4">
{accessorialHints.map((i) => (
<li
key={i.code}
className={i.severity === "block" ? "font-medium text-[#EF4444]" : ""}
>
{i.message}
</li>
))}
</ul>
</div>
) : null}
<QuoteFloatHint message={localError} />
{/* 地址 + 时间 */}
<section className="rounded-lg border border-border bg-surface p-4 shadow-card md:p-5">
<div className="mb-4">
<QuoteSectionTitle></QuoteSectionTitle>
</div>
<div className="grid gap-4 md:grid-cols-[1fr_auto_1fr] md:items-start">
<div className="space-y-4 rounded-md border-l-[3px] border-[#1890FF]/50 bg-[#1890FF]/5 py-1 pl-3">
<MothershipAddressSuggestField
label="提货地址"
required
invalid={invalidFields.has("pickup")}
testId="quote-create-pickup-input-search"
disabled={disabled}
customerId={customerId}
apiBaseUrl={apiBaseUrl}
serviceToken={serviceToken}
value={pickupQuery}
confirmed={pickupConfirmed}
onQueryChange={(q) => {
setPickupQuery(q);
setInvalidFields((prev) => {
const next = new Set(prev);
next.delete("pickup");
return next;
});
}}
onConfirm={(c) => {
setPickupConfirmed(c);
if (c) {
setInvalidFields((prev) => {
const next = new Set(prev);
next.delete("pickup");
return next;
});
setLocalError(null);
}
}}
/>
<AccessorialMultiSelect
label="附加服务(可选)"
tip="提货地点可能产生的附加费用服务;住宅提货承运商不支持"
side="pickup"
options={MS_PICKUP_ACCESSORIALS}
selected={pickupAccessorials}
disabled={disabled}
onChange={(next, meta) => {
setPickupAccessorials(next);
if (meta?.message) setLocalError(meta.message);
else if (
meta?.issues &&
mothershipAccessorialHasBlock(meta.issues)
) {
setLocalError(
meta.issues.find((i) => i.severity === "block")!.message,
);
}
}}
/>
</div>
<div className="hidden items-center justify-center pt-8 text-text-disabled md:flex">
</div>
<div className="space-y-4 rounded-md border-l-[3px] border-[#64748B]/40 bg-bg/60 py-1 pl-3">
<MothershipAddressSuggestField
label="送货地址"
required
invalid={invalidFields.has("delivery")}
testId="quote-create-delivery-input-search"
disabled={disabled}
customerId={customerId}
apiBaseUrl={apiBaseUrl}
serviceToken={serviceToken}
value={deliveryQuery}
confirmed={deliveryConfirmed}
onQueryChange={(q) => {
setDeliveryQuery(q);
setInvalidFields((prev) => {
const next = new Set(prev);
next.delete("delivery");
return next;
});
}}
onConfirm={(c) => {
setDeliveryConfirmed(c);
if (c) {
setInvalidFields((prev) => {
const next = new Set(prev);
next.delete("delivery");
return next;
});
setLocalError(null);
}
}}
/>
<AccessorialMultiSelect
label="附加服务(可选)"
tip="送货地点可能产生的附加费用服务;预约与 Amazon 预约不能同时选"
side="delivery"
options={MS_DELIVERY_ACCESSORIALS}
selected={deliveryAccessorials}
disabled={disabled}
onChange={(next, meta) => {
setDeliveryAccessorials(next);
if (meta?.message) setLocalError(meta.message);
}}
/>
</div>
</div>
<div className="mt-6 flex flex-wrap items-end gap-4 border-t border-border pt-4">
<div className="space-y-1">
<div className="flex items-center gap-1 text-xs text-[#4B5563]">
<span></span>
<QuoteFieldTooltip tip="货物最早可被承运商提走的日期与时刻;周六、周日不可选" />
</div>
<MothershipWeekdayDatePicker
disabled={disabled}
value={readyDate}
onChange={setReadyDate}
/>
</div>
<span className="pb-2 text-sm text-text-secondary"></span>
<div className="space-y-1">
<label className="sr-only" htmlFor={`${baseId}-ready-time`}>
</label>
<select
id={`${baseId}-ready-time`}
data-testid="ready-pickup-time-select"
disabled={disabled}
value={readyTime}
onChange={(e) => setReadyTime(e.target.value)}
className={`${quoteInputCls} min-w-[7.5rem]`}
>
{MS_READY_TIMES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<span className="pb-2 text-sm text-text-secondary">{timezone}</span>
</div>
</section>
{/* 货物 */}
<section className="rounded-lg border border-border bg-surface p-4 shadow-card md:p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<QuoteSectionTitle></QuoteSectionTitle>
<button
type="button"
data-testid="cargo-add-button"
disabled={disabled}
onClick={() => setCargoLines((rows) => [...rows, newCargoLine()])}
className={quoteAddRowCls}
>
<Plus size={16} weight="bold" />
</button>
</div>
<div className="space-y-4">
{cargoLines.map((line, idx) => (
<div
key={line.key}
className="grid min-h-12 gap-4 border-b border-border pb-4 last:border-0 last:pb-0 sm:grid-cols-2 lg:grid-cols-7"
>
<label className="space-y-1 lg:col-span-1">
<span className="flex items-center gap-1 text-xs text-[#4B5563]">
<QuoteFieldTooltip tip="货物包装形态,影响承运商派车与装卸" />
</span>
<select
data-testid={idx === 0 ? "cargo-type-dropdown-input" : undefined}
disabled={disabled}
value={line.cargoType}
onChange={(e) =>
updateCargo(line.key, {
cargoType: e.target.value as MsCargoTypeId,
})
}
className={quoteInputCls}
>
{MS_CARGO_TYPES.map((t) => (
<option key={t.id} value={t.id}>
{t.label}
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-[#4B5563]">
<RequiredMark />
</span>
<input
data-testid={idx === 0 ? "cargo-quantity-input" : undefined}
disabled={disabled}
inputMode="numeric"
placeholder="数量"
value={line.quantity}
onChange={(e) =>
updateCargo(line.key, { quantity: e.target.value })
}
className={fieldCls(`${line.key}-quantity`)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-[#4B5563]">
<RequiredMark />
</span>
<input
data-testid={idx === 0 ? "cargo-weight-input" : undefined}
disabled={disabled}
inputMode="decimal"
placeholder="lbs"
value={line.weightLb}
onChange={(e) =>
updateCargo(line.key, { weightLb: e.target.value })
}
className={fieldCls(`${line.key}-weight`)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-[#4B5563]">
<RequiredMark />
</span>
<input
data-testid={idx === 0 ? "cargo-length-input" : undefined}
disabled={disabled}
inputMode="decimal"
placeholder="inch"
value={line.lengthIn}
onChange={(e) =>
updateCargo(line.key, { lengthIn: e.target.value })
}
className={fieldCls(`${line.key}-length`)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-[#4B5563]">
<RequiredMark />
</span>
<input
data-testid={idx === 0 ? "cargo-width-input" : undefined}
disabled={disabled}
inputMode="decimal"
placeholder="inch"
value={line.widthIn}
onChange={(e) =>
updateCargo(line.key, { widthIn: e.target.value })
}
className={fieldCls(`${line.key}-width`)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-[#4B5563]">
<RequiredMark />
</span>
<input
data-testid={idx === 0 ? "cargo-height-input" : undefined}
disabled={disabled}
inputMode="decimal"
placeholder="inch"
value={line.heightIn}
onChange={(e) =>
updateCargo(line.key, { heightIn: e.target.value })
}
className={fieldCls(`${line.key}-height`)}
/>
</label>
<div className="flex items-end justify-end">
<button
type="button"
data-testid="cargo-remove-button"
disabled={disabled || cargoLines.length <= 1}
onClick={() =>
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="删除该行货物"
>
<X size={16} weight="bold" />
</button>
</div>
</div>
))}
</div>
</section>
</form>
);
}