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/flock/flock-logged-in-quote-form.tsx

2175 lines
76 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.

/**
* Flock Freight 登录后 Quick 查价界面Let's build a quote
* 对齐录制 flock-logged-in-20260715-143321.js + 官网截图;中文文案供嵌入宿主展示
*/
"use client";
import { useId, useMemo, useState, useEffect, type FormEvent, type ReactNode } from "react";
import { Plus, Trash, CaretDown } from "@phosphor-icons/react";
import {
defaultFlockPickupDateIso,
flockPickupDateValidationMessageFromIso,
minFlockPickupDateIso,
} from "@/lib/flock/pickup-date";
import { MothershipWeekdayDatePicker } from "@/components/mothership/mothership-weekday-date-picker";
import {
FLOCK_LIMITS,
FLOCK_VALIDATION_MESSAGES,
} from "@/lib/constants/flock-limits";
import {
evaluateFlockOptionCompat,
flockOptionHasBlock,
isFlockReeferOnlyWithoutTemp,
isFlockSprinterOnly,
isFlockTempWithoutReefer,
} from "@/lib/flock/option-compat";
import {
FlockSavedFreightSearch,
FlockSaveFreightButton,
} from "@/components/flock/flock-saved-freight-controls";
import type { FlockSavedFreightPreset } from "@/lib/flock/saved-freight-presets";
import {
QuoteFieldTooltip,
QuoteFloatHint,
QuoteNoticeCollapse,
QuotePageTitle,
QuoteRequiredMark,
QuoteSectionTitle,
QuoteStepProgress,
QuoteTipDefs,
quoteAddRowCls,
quoteCtaCls,
quoteInputCls,
quoteInputErrCls,
} from "@/components/quote/quote-form-chrome";
export const FLOCK_LOCATION_TYPES = [
{ id: "business_with_dock", label: "商业地址(有月台)" },
{ id: "business_without_dock", label: "商业地址(无月台)" },
{ id: "residential", label: "住宅地址" },
{ id: "trade_show", label: "展会 / 展览" },
{ id: "limited_construction", label: "施工工地" },
{ id: "limited_farm", label: "农场 / 种养殖地" },
{ id: "limited_airport", label: "受限通道 · 机场" },
{ id: "limited_port", label: "受限通道 · 港口码头" },
{ id: "limited_military", label: "受限通道 · 军事基地" },
{ id: "limited_worship", label: "受限通道 · 宗教场所" },
{ id: "limited_school", label: "受限通道 · 学校" },
{ id: "limited_other", label: "受限通道 · 其他" },
] as const;
/** 各地点类型可见的附加选项(对齐 Flock 官网:类别不同选项不同) */
export type FlockLocationAccessorialFlags = {
liftgate: boolean;
inside: boolean;
palletJack: boolean;
};
export const FLOCK_LOCATION_ACCESSORIALS: Record<
(typeof FLOCK_LOCATION_TYPES)[number]["id"],
FlockLocationAccessorialFlags
> = {
// 有卸货台:无附加勾选项(官网无 Liftgate / Inside / Pallet Jack
business_with_dock: { liftgate: false, inside: false, palletJack: false },
// 无卸货台:三项全开
business_without_dock: { liftgate: true, inside: true, palletJack: true },
// 住宅:仅尾板 + 室内
residential: { liftgate: true, inside: true, palletJack: false },
// 展会:仅托盘车
trade_show: { liftgate: false, inside: false, palletJack: true },
// 施工现场:尾板 + 托盘车(无室内)
limited_construction: { liftgate: true, inside: false, palletJack: true },
// 农场:三项
limited_farm: { liftgate: true, inside: true, palletJack: true },
limited_airport: { liftgate: true, inside: true, palletJack: true },
limited_port: { liftgate: true, inside: true, palletJack: true },
limited_military: { liftgate: true, inside: true, palletJack: true },
// 宗教场所:仅尾板 + 室内(截图 Limited Access Church
limited_worship: { liftgate: true, inside: true, palletJack: false },
limited_school: { liftgate: true, inside: true, palletJack: true },
limited_other: { liftgate: true, inside: true, palletJack: true },
};
export function getFlockLocationAccessorials(
typeId: (typeof FLOCK_LOCATION_TYPES)[number]["id"],
): FlockLocationAccessorialFlags {
return FLOCK_LOCATION_ACCESSORIALS[typeId];
}
export const FLOCK_PACKAGING_TYPES = [
{ id: "pallets_48x40", label: "标准托盘 48×40", length: 48, width: 40 },
{ id: "pallets_48x48", label: "标准托盘 48×48", length: 48, width: 48 },
{ id: "pallets_60x48", label: "标准托盘 60×48", length: 60, width: 48 },
{ id: "pallets_custom", label: "自定义托盘尺寸", length: null, width: null },
{ id: "bags", label: "袋装", length: null, width: null },
{ id: "bales", label: "捆包", length: null, width: null },
{ id: "boxes", label: "纸箱 / 纸盒", length: null, width: null },
{ id: "bundles", label: "捆扎件", length: null, width: null },
{ id: "coils", label: "卷料", length: null, width: null },
{ id: "crates", label: "木箱 / 板条箱", length: null, width: null },
{ id: "cylinders", label: "钢瓶 / 气瓶", length: null, width: null },
{ id: "drums", label: "铁桶 / 圆桶", length: null, width: null },
{ id: "pails", label: "小桶 / 提桶", length: null, width: null },
{ id: "reels", label: "线盘 / 卷盘", length: null, width: null },
{ id: "rolls", label: "卷筒", length: null, width: null },
{ id: "slipsheets", label: "滑托板", length: null, width: null },
{ id: "tubes_pipes", label: "管材", length: null, width: null },
{ id: "units", label: "单件 / 整机", length: null, width: null },
] as const;
export const FLOCK_FREIGHT_CLASSES = [
"density",
"50",
"55",
"60",
"65",
"70",
"77.5",
"85",
"92.5",
"100",
"110",
"125",
"150",
"175",
"200",
"250",
"300",
"400",
"500",
] as const;
export const FLOCK_ADDITIONAL_SERVICES = [
{ id: "blind_shipment", label: "盲运(隐藏发/收货方)" },
{ id: "food_grade", label: "食品级车厢" },
{ id: "load_to_ride", label: "全程直达(不换装)" },
{ id: "temperature_control", label: "温控 / 冷链" },
{ id: "unloading", label: "到站卸货服务" },
] as const;
export const FLOCK_VEHICLE_TYPES = [
{ id: "box_truck", label: "厢式货车" },
{ id: "dry_van", label: "干货挂车" },
{ id: "refrigerated", label: "冷藏车" },
{ id: "sprinter", label: "小型厢车" },
] as const;
export const FLOCK_DEFAULT_VEHICLES = ["box_truck", "dry_van", "refrigerated"] as const;
/** 官网提示文案(货运行业惯用译,对齐全票提货/派送语境) */
export const FLOCK_LOCATION_TIP_INTRO =
"请准确选择地点类型,便于承运商安排提货与派送车辆,减少压车与拒收。";
export const FLOCK_LOCATION_TIP_ITEMS = [
{
title: "商业地址(有月台):",
body: "具备标准高度装卸月台,车辆可直接对接上下货的商业场所。",
},
{
title: "商业地址(无月台):",
body: "无标准装卸月台,通常需尾板或地面装卸的商业场所。",
},
{
title: "住宅地址:",
body: "按当地区划认定的住宅地点,含住家办公、宅基地建筑等。",
},
{
title: "展会 / 展览:",
body: "进出展馆、展台及展会临时仓的货件。",
},
{
title: "施工工地:",
body: "正在施工、进出可能受限的工地现场。",
},
{
title: "农场 / 种养殖地:",
body: "农业种植、养殖或农产品生产用地。",
},
{
title: "受限通道地点:",
body: "进出不便,或需额外通行证/安检手续的地点(机场、港口、军营、校园等)。",
},
] as const;
export const FLOCK_FREIGHT_CLASS_TIP =
"填写件数、外尺寸(长×宽×高)与重量后,可据此估算或选择 NMFC 运价等级。";
export const FLOCK_NMFC_NOTICE =
"仅按密度推算的等级可能不准确。正式分等还要考虑装卸难度、积载性与承运责任。托运人须选用正确的 NMFC 代码与等级,否则可能被重新分等并产生改费、延误与追加费用。";
export const FLOCK_STACKABLE_TIP =
"货件可堆码或可调向摆放时,有助于提高装载率,运费最多约可省 20%。";
export const FLOCK_ADDITIONAL_SERVICE_TIP_ITEMS = [
{
title: "盲运:",
body: "提单及签收单据对发货方和/或收货方信息进行隐匿,适用于中转贸易等场景。",
},
{
title: "食品级车厢:",
body: "要求车厢洁净、无异味、无破损渗透,适用于食品及敏感货物。",
},
{
title: "全程直达(不换装):",
body: "要求货件途中不经其他站点翻装换车,降低破损与延误风险。",
},
{
title: "温控 / 冷链:",
body: "车厢需按指定温度区间控温运输。",
},
{
title: "到站卸货服务:",
body: "含到站分拣、装卸工协助等;不等于白手套式送货上门。",
},
] as const;
export const FLOCK_VEHICLE_TIP_ITEMS = [
{
title: "干货挂车:",
body: "提货或派送场地可停靠约 53 英尺干货厢式挂车时勾选。",
},
{
title: "冷藏车:",
body: "货件可装入各类尺寸冷藏车(如 48、53时勾选若需设定温度请同时勾选「温控 / 冷链」。",
},
{
title: "厢式货车:",
body: "场地可停靠各尺寸厢货(如 1828 城市配送车)时勾选。",
},
{
title: "小型厢车:",
body: "场地可停靠厢式面包车/小型厢货,且无需叉车或坡道装卸时勾选。",
},
] as const;
export const FLOCK_INSURANCE_YES_TIP =
"若需在承运人责任限额之外另行加保,请选「是」。";
export const FLOCK_INSURANCE_NO_TIP =
"货件已有保险,或不需超出承运人责任限额的额外保额时,请选「否」。";
export const FLOCK_INSURANCE_WAIVE_HINT = "您已选择不投保额外货运险。";
export const FLOCK_INSURANCE_FOOTER =
"如需了解货运保险细则,可联系 support@flockfreight.com。";
/** 整票总重未超过阈值时,官网调度区部分选项为灰色不可选 */
export function isFlockSchedulingOptionLocked(
totalWeightLb: number,
weightLocked: boolean,
): boolean {
if (!weightLocked) return false;
return !Number.isFinite(totalWeightLb) || totalWeightLb <= FLOCK_LIMITS.schedulingWeightMinLb;
}
export function flockSchedulingWeightLockedHint(): string {
return `整票总重须大于 ${FLOCK_LIMITS.schedulingWeightMinLb} lb 才可选用`;
}
/** 官网时段下拉12h半小时步进 */
export const FLOCK_SCHEDULING_TIME_OPTIONS: string[] = (() => {
const opts: string[] = [];
for (let h = 0; h < 24; h += 1) {
for (const m of [0, 30] as const) {
const hour12 = h % 12 === 0 ? 12 : h % 12;
const ampm = h < 12 ? "am" : "pm";
opts.push(`${hour12}:${m === 0 ? "00" : "30"} ${ampm}`);
}
}
return opts;
})();
/** Quick 调度weightLocked=true 表示总重 ≤5000 时灰色禁用 */
export const FLOCK_PICKUP_SERVICES = [
{
id: "standard_fcfs",
label: "标准提货FCFS",
hint: "在地点营业时间内提货",
detail: "",
weightLocked: false,
},
{
id: "during_window",
label: "指定提货时段",
hint: "",
detail:
"提货地点有预设时段(如 2PM4PM可供提货。",
weightLocked: true,
},
{
id: "have_appointment",
label: "已有提货预约",
hint: "",
detail: "",
weightLocked: true,
},
{
id: "need_appointment",
label: "需要提货预约",
hint: "",
detail: "",
weightLocked: true,
},
] as const;
/** 提货前致电:总重 ≤5000 时灰色 */
export const FLOCK_PICKUP_CALL_BEFORE_WEIGHT_LOCKED = true;
export const FLOCK_DELIVERY_SERVICES = [
{
id: "standard_fcfs",
label: "标准派送FCFS",
hint: "在地点营业时间内派送",
detail: "",
weightLocked: false,
},
{
id: "during_window",
label: "指定派送时段",
hint: "",
detail:
"派送地点有预设时段(如 2PM4PM可供派送。",
weightLocked: true,
},
{
id: "have_appointment",
label: "已有派送预约",
hint: "",
detail: "",
weightLocked: true,
},
{
id: "need_appointment",
label: "需要派送预约",
hint: "",
detail: "",
/** 官网小批量下仍可选 */
weightLocked: false,
},
{
id: "must_arrive_by",
label: "须在指定日期前到达",
hint: "",
detail: "",
weightLocked: true,
},
] as const;
export const FLOCK_CALL_FOR_DELIVERY_APPOINTMENT_HINT =
"Flock Freight 将与派送地点及承运商协调预约。";
export type FlockLocationTypeId = (typeof FLOCK_LOCATION_TYPES)[number]["id"];
export type FlockPackagingTypeId = (typeof FLOCK_PACKAGING_TYPES)[number]["id"];
export type FlockFreightClass = (typeof FLOCK_FREIGHT_CLASSES)[number];
export type FlockAdditionalServiceId =
(typeof FLOCK_ADDITIONAL_SERVICES)[number]["id"];
export type FlockVehicleTypeId = (typeof FLOCK_VEHICLE_TYPES)[number]["id"];
export type FlockLoggedInItem = {
key: string;
quantity: string;
packagingType: FlockPackagingTypeId;
lengthIn: string;
widthIn: string;
heightIn: string;
totalWeightLb: string;
freightClass: FlockFreightClass;
description: string;
stackable: boolean;
turnable: boolean;
};
export type FlockLoggedInQuotePayload = {
mode: "quick";
pickupDate: string;
pickupZip: string;
pickupType: FlockLocationTypeId;
pickupLiftgate: boolean;
pickupInside: boolean;
pickupPalletJack: boolean;
deliveryZip: string;
deliveryType: FlockLocationTypeId;
deliveryLiftgate: boolean;
deliveryInside: boolean;
deliveryPalletJack: boolean;
items: Array<{
quantity: number;
packagingType: FlockPackagingTypeId;
lengthIn: number;
widthIn: number;
heightIn: number;
totalWeightLb: number;
freightClass: FlockFreightClass;
description: string;
stackable: boolean;
turnable: boolean;
}>;
additionalServices: FlockAdditionalServiceId[];
vehicleTypes: FlockVehicleTypeId[];
pickupService: string;
deliveryService: string;
/** 指定提货时段 */
pickupWindowStartTime?: string;
pickupWindowEndTime?: string;
/** 已有提货预约 */
pickupAppointmentTime?: string;
/** 指定派送时段 */
deliveryWindowStartTime?: string;
deliveryWindowEndTime?: string;
deliveryWindowDate?: string;
/** 已有派送预约 */
deliveryAppointmentTime?: string;
deliveryAppointmentDate?: string;
/** 须在指定日期前到达 */
deliveryMustArriveByDate?: string;
/** Must arrive by 下的「预约派送致电」 */
callForDeliveryAppointment?: boolean;
callBeforePickup: boolean;
callBeforeDelivery: boolean;
additionalInsurance: boolean;
};
export interface FlockLoggedInQuoteFormProps {
formId?: string;
disabled?: boolean;
submitLocked?: boolean;
/** 按客户隔离常用货件localStorage */
customerId?: string;
onValidSubmit?: (payload: FlockLoggedInQuotePayload) => void;
}
function RequiredMark() {
return <QuoteRequiredMark />;
}
function newItem(): FlockLoggedInItem {
return {
key: `fi-${Math.random().toString(36).slice(2, 10)}`,
quantity: "",
packagingType: "pallets_48x40",
lengthIn: "48",
widthIn: "40",
heightIn: "",
totalWeightLb: "",
freightClass: "density",
description: "",
stackable: false,
turnable: false,
};
}
function applyPackagingDims(
packagingType: FlockPackagingTypeId,
item: FlockLoggedInItem,
): FlockLoggedInItem {
const pack = FLOCK_PACKAGING_TYPES.find((p) => p.id === packagingType);
if (!pack) return { ...item, packagingType };
return {
...item,
packagingType,
lengthIn: pack.length != null ? String(pack.length) : item.lengthIn,
widthIn: pack.width != null ? String(pack.width) : item.widthIn,
};
}
function applySavedFreightPreset(
item: FlockLoggedInItem,
preset: FlockSavedFreightPreset,
): FlockLoggedInItem {
const packOk = FLOCK_PACKAGING_TYPES.some((p) => p.id === preset.packagingType);
const classOk = (FLOCK_FREIGHT_CLASSES as readonly string[]).includes(
preset.freightClass,
);
return {
...item,
quantity: preset.quantity || item.quantity,
packagingType: packOk
? (preset.packagingType as FlockPackagingTypeId)
: item.packagingType,
lengthIn: preset.lengthIn || item.lengthIn,
widthIn: preset.widthIn || item.widthIn,
heightIn: preset.heightIn || item.heightIn,
totalWeightLb: preset.totalWeightLb || item.totalWeightLb,
freightClass: classOk
? (preset.freightClass as FlockFreightClass)
: item.freightClass,
description: preset.description || preset.name,
stackable: Boolean(preset.stackable),
turnable: Boolean(preset.turnable),
};
}
function OutlinedField({
label,
required,
error,
tip,
children,
}: {
label: string;
required?: boolean;
error?: string;
tip?: ReactNode;
children: ReactNode;
}) {
return (
<label className="block space-y-1">
<span className="inline-flex items-center gap-1 text-xs text-[#4B5563]">
{label}
{required ? <RequiredMark /> : null}
{tip ? (
<QuoteFieldTooltip tip={tip} label={`${label}说明`} wide />
) : null}
</span>
{children}
{error ? (
<span className="block text-xs text-[#EF4444]">{error}</span>
) : null}
</label>
);
}
const inputCls = quoteInputCls;
const inputErrCls = quoteInputErrCls;
function LocationAccessorialChecks({
side,
typeId,
disabled,
liftgate,
inside,
palletJack,
onLiftgate,
onInside,
onPalletJack,
}: {
side: "pickup" | "delivery";
typeId: FlockLocationTypeId;
disabled?: boolean;
liftgate: boolean;
inside: boolean;
palletJack: boolean;
onLiftgate: (v: boolean) => void;
onInside: (v: boolean) => void;
onPalletJack: (v: boolean) => void;
}) {
const flags = getFlockLocationAccessorials(typeId);
if (!flags.liftgate && !flags.inside && !flags.palletJack) return null;
const liftgateLabel =
side === "pickup"
? "提货需要尾板Liftgate"
: "派送需要尾板Liftgate";
const insideLabel =
side === "pickup"
? "室内提货Inside Pickup"
: "室内派送Inside Delivery";
return (
<div className="mt-4 space-y-2 md:ml-[50%] md:pl-2">
{flags.liftgate ? (
<label className="flex items-center gap-2 text-sm text-text-primary">
<input
type="checkbox"
disabled={disabled}
checked={liftgate}
onChange={(e) => onLiftgate(e.target.checked)}
/>
{liftgateLabel}
</label>
) : null}
{flags.inside ? (
<label className="flex items-center gap-2 text-sm text-text-primary">
<input
type="checkbox"
disabled={disabled}
checked={inside}
onChange={(e) => onInside(e.target.checked)}
/>
{insideLabel}
</label>
) : null}
{flags.palletJack ? (
<label className="flex items-start gap-2 text-sm text-text-primary">
<input
type="checkbox"
className="mt-1"
disabled={disabled}
checked={palletJack}
onChange={(e) => onPalletJack(e.target.checked)}
/>
<span>
<span className="mt-0.5 block text-xs text-text-secondary">
FlockDirect®
</span>
</span>
</label>
) : null}
</div>
);
}
function ChipToggle({
selected,
disabled,
label,
onClick,
}: {
selected: boolean;
disabled?: boolean;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={[
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-left text-sm transition-colors",
selected
? "border-text-primary bg-bg font-medium text-text-primary"
: "border-border bg-surface text-text-primary hover:border-text-secondary",
disabled ? "cursor-not-allowed opacity-50" : "",
].join(" ")}
>
{selected ? <span aria-hidden></span> : null}
{label}
</button>
);
}
/** 登录态货物行尺寸/重量硬限(同源 FLOCK_LIMITS */
export function validateFlockLoggedInCargoLine(input: {
quantity: number;
lengthIn: number;
widthIn: number;
heightIn: number;
totalWeightLb: number;
description: string;
}): Record<string, string> {
const err: Record<string, string> = {};
const { min, lengthMax, widthMax, heightMax } = FLOCK_LIMITS.dimIn;
if (!Number.isFinite(input.quantity) || input.quantity <= 0) {
err.qty = "件数必填";
}
if (!Number.isFinite(input.lengthIn) || input.lengthIn < min) {
err.len = "长度必填";
} else if (input.lengthIn > lengthMax) {
err.len = FLOCK_VALIDATION_MESSAGES.length;
}
if (!Number.isFinite(input.widthIn) || input.widthIn < min) {
err.wid = "宽度必填";
} else if (input.widthIn > widthMax) {
err.wid = FLOCK_VALIDATION_MESSAGES.width;
}
if (!Number.isFinite(input.heightIn) || input.heightIn < min) {
err.hei = "高度必填";
} else if (input.heightIn > heightMax) {
err.hei = FLOCK_VALIDATION_MESSAGES.height;
}
if (!Number.isFinite(input.totalWeightLb) || input.totalWeightLb <= 0) {
err.wt = "重量必填";
} else if (input.totalWeightLb > FLOCK_LIMITS.totalWeightLbMax) {
err.wt = FLOCK_VALIDATION_MESSAGES.totalWeight;
}
if (!input.description.trim()) {
err.desc = "货物描述必填";
}
return err;
}
/** 将登录态 payload 映射为 Flock API flock_input含登录态 RPA 扩展字段) */
export function mapFlockLoggedInToApiInput(payload: FlockLoggedInQuotePayload): {
pickup_date: string;
pickup_zip: string;
delivery_zip: string;
pallet_count: number;
total_weight: { value: number; unit: "lb" };
dimensions: { length: number; width: number; height: number; unit: "in" };
form_mode: "logged_in_quick";
pickup_location_type: string;
delivery_location_type: string;
packaging_type: string;
freight_class: string;
description: string;
stackable: boolean;
turnable: boolean;
additional_services: string[];
vehicle_types: string[];
call_before_pickup: boolean;
call_before_delivery: boolean;
pickup_service: string;
delivery_service: string;
pickup_window_start_time?: string;
pickup_window_end_time?: string;
pickup_appointment_time?: string;
delivery_window_start_time?: string;
delivery_window_end_time?: string;
delivery_window_date?: string;
delivery_appointment_time?: string;
delivery_appointment_date?: string;
delivery_must_arrive_by_date?: string;
call_for_delivery_appointment?: boolean;
pickup_liftgate: boolean;
pickup_inside: boolean;
pickup_pallet_jack: boolean;
delivery_liftgate: boolean;
delivery_inside: boolean;
delivery_pallet_jack: boolean;
} {
const [y, m, d] = payload.pickupDate.split("-");
const pickup_date =
y && m && d ? `${m}/${d}/${y}` : payload.pickupDate;
const first = payload.items[0];
const pallet_count = payload.items.reduce((s, it) => s + it.quantity, 0);
const total_weight = payload.items.reduce((s, it) => s + it.totalWeightLb, 0);
const opt = (v?: string) => {
const t = (v ?? "").trim();
return t || undefined;
};
return {
pickup_date,
pickup_zip: payload.pickupZip,
delivery_zip: payload.deliveryZip,
pallet_count: Math.max(1, pallet_count),
total_weight: { value: total_weight || first?.totalWeightLb || 1, unit: "lb" },
dimensions: {
length: first?.lengthIn ?? 48,
width: first?.widthIn ?? 40,
height: first?.heightIn ?? 48,
unit: "in",
},
form_mode: "logged_in_quick",
pickup_location_type: payload.pickupType,
delivery_location_type: payload.deliveryType,
packaging_type: first?.packagingType ?? "pallets_48x40",
freight_class: first?.freightClass ?? "density",
description: first?.description?.trim() || "General freight",
stackable: Boolean(first?.stackable),
turnable: Boolean(first?.turnable),
additional_services: [...payload.additionalServices],
vehicle_types: [...payload.vehicleTypes],
call_before_pickup:
FLOCK_PICKUP_CALL_BEFORE_WEIGHT_LOCKED &&
isFlockSchedulingOptionLocked(
payload.items.reduce((s, it) => s + it.totalWeightLb, 0),
true,
)
? false
: Boolean(payload.callBeforePickup),
call_before_delivery: Boolean(payload.callBeforeDelivery),
pickup_service: payload.pickupService,
delivery_service: payload.deliveryService,
pickup_window_start_time: opt(payload.pickupWindowStartTime),
pickup_window_end_time: opt(payload.pickupWindowEndTime),
pickup_appointment_time: opt(payload.pickupAppointmentTime),
delivery_window_start_time: opt(payload.deliveryWindowStartTime),
delivery_window_end_time: opt(payload.deliveryWindowEndTime),
delivery_window_date: opt(payload.deliveryWindowDate),
delivery_appointment_time: opt(payload.deliveryAppointmentTime),
delivery_appointment_date: opt(payload.deliveryAppointmentDate),
delivery_must_arrive_by_date: opt(payload.deliveryMustArriveByDate),
call_for_delivery_appointment:
payload.deliveryService === "must_arrive_by"
? Boolean(payload.callForDeliveryAppointment)
: undefined,
pickup_liftgate: Boolean(payload.pickupLiftgate),
pickup_inside: Boolean(payload.pickupInside),
pickup_pallet_jack: Boolean(payload.pickupPalletJack),
delivery_liftgate: Boolean(payload.deliveryLiftgate),
delivery_inside: Boolean(payload.deliveryInside),
delivery_pallet_jack: Boolean(payload.deliveryPalletJack),
};
}
export function FlockLoggedInQuoteForm({
formId = "flock-logged-in-quote-form",
disabled,
submitLocked,
customerId = "anon",
onValidSubmit,
}: FlockLoggedInQuoteFormProps) {
const baseId = useId();
const [pickupDate, setPickupDate] = useState(defaultFlockPickupDateIso);
const [pickupZip, setPickupZip] = useState("");
const [pickupType, setPickupType] =
useState<FlockLocationTypeId>("business_with_dock");
const [pickupLiftgate, setPickupLiftgate] = useState(false);
const [pickupInside, setPickupInside] = useState(false);
const [pickupPalletJack, setPickupPalletJack] = useState(false);
const [deliveryZip, setDeliveryZip] = useState("");
const [deliveryType, setDeliveryType] =
useState<FlockLocationTypeId>("business_with_dock");
const [deliveryLiftgate, setDeliveryLiftgate] = useState(false);
const [deliveryInside, setDeliveryInside] = useState(false);
const [deliveryPalletJack, setDeliveryPalletJack] = useState(false);
const [items, setItems] = useState<FlockLoggedInItem[]>(() => [newItem()]);
const [savedFreightRevision, setSavedFreightRevision] = useState(0); const [additionalServices, setAdditionalServices] = useState<
FlockAdditionalServiceId[]
>(["unloading"]);
const [vehicleTypes, setVehicleTypes] = useState<FlockVehicleTypeId[]>([
...FLOCK_DEFAULT_VEHICLES,
]);
const [pickupService, setPickupService] = useState("standard_fcfs");
const [deliveryService, setDeliveryService] = useState("standard_fcfs");
const [pickupWindowStartTime, setPickupWindowStartTime] = useState("8:00 am");
const [pickupWindowEndTime, setPickupWindowEndTime] = useState("5:00 pm");
const [pickupAppointmentTime, setPickupAppointmentTime] = useState("8:00 am");
const [deliveryWindowStartTime, setDeliveryWindowStartTime] =
useState("8:00 am");
const [deliveryWindowEndTime, setDeliveryWindowEndTime] = useState("5:00 pm");
const [deliveryWindowDate, setDeliveryWindowDate] = useState("");
const [deliveryAppointmentTime, setDeliveryAppointmentTime] =
useState("8:00 am");
const [deliveryAppointmentDate, setDeliveryAppointmentDate] = useState("");
const [deliveryMustArriveByDate, setDeliveryMustArriveByDate] = useState("");
const [callForDeliveryAppointment, setCallForDeliveryAppointment] =
useState(false);
const [callBeforePickup, setCallBeforePickup] = useState(false);
const [callBeforeDelivery, setCallBeforeDelivery] = useState(false);
const [additionalInsurance, setAdditionalInsurance] = useState(false);
const [showAdditional, setShowAdditional] = useState(true);
const [showScheduling, setShowScheduling] = useState(true);
const [showInsurance, setShowInsurance] = useState(true);
const [errors, setErrors] = useState<Record<string, string>>({});
const [localError, setLocalError] = useState<string | null>(null);
const servicesSelected = additionalServices.length;
const vehiclesSelected = vehicleTypes.length;
const shipmentWeightLb = useMemo(
() =>
items.reduce((sum, it) => {
const w = Number(it.totalWeightLb);
return sum + (Number.isFinite(w) && w > 0 ? w : 0);
}, 0),
[items],
);
const schedulingLockedHint = flockSchedulingWeightLockedHint();
const optionCompatIssues = useMemo(
() =>
evaluateFlockOptionCompat({
pickupLocationType: pickupType,
deliveryLocationType: deliveryType,
packagingTypes: items.map((it) => it.packagingType),
vehicleTypes,
additionalServices,
shipmentWeightLb,
}),
[
pickupType,
deliveryType,
items,
vehicleTypes,
additionalServices,
shipmentWeightLb,
],
);
useEffect(() => {
const pickupSvc = FLOCK_PICKUP_SERVICES.find((s) => s.id === pickupService);
if (
pickupSvc &&
isFlockSchedulingOptionLocked(shipmentWeightLb, pickupSvc.weightLocked)
) {
setPickupService("standard_fcfs");
}
const deliverySvc = FLOCK_DELIVERY_SERVICES.find(
(s) => s.id === deliveryService,
);
if (
deliverySvc &&
isFlockSchedulingOptionLocked(shipmentWeightLb, deliverySvc.weightLocked)
) {
setDeliveryService("standard_fcfs");
}
if (
FLOCK_PICKUP_CALL_BEFORE_WEIGHT_LOCKED &&
isFlockSchedulingOptionLocked(shipmentWeightLb, true) &&
callBeforePickup
) {
setCallBeforePickup(false);
}
}, [
shipmentWeightLb,
pickupService,
deliveryService,
callBeforePickup,
]);
const canSubmit = useMemo(
() => !disabled && !submitLocked,
[disabled, submitLocked],
);
const updateItem = (key: string, patch: Partial<FlockLoggedInItem>) => {
setItems((rows) =>
rows.map((row) => (row.key === key ? { ...row, ...patch } : row)),
);
};
const toggleService = (id: FlockAdditionalServiceId) => {
setAdditionalServices((prev) => {
const next = prev.includes(id)
? prev.filter((x) => x !== id)
: [...prev, id];
if (
isFlockReeferOnlyWithoutTemp({
vehicleTypes,
additionalServices: next,
})
) {
setLocalError(
"仅允许「冷藏车」时须同时勾选「温控 / 冷链」,否则官网无法提交询价",
);
} else if (
isFlockTempWithoutReefer({
vehicleTypes,
additionalServices: next,
})
) {
setLocalError("已勾选「温控 / 冷链」时,允许车型中须包含「冷藏车」");
} else {
setLocalError(null);
}
return next;
});
};
const toggleVehicle = (id: FlockVehicleTypeId) => {
setVehicleTypes((prev) => {
const next = prev.includes(id)
? prev.filter((x) => x !== id)
: [...prev, id];
if (next.length === 0) {
setLocalError("请至少选择一种车型");
return prev.includes(id) ? prev : next;
}
if (
isFlockReeferOnlyWithoutTemp({
vehicleTypes: next,
additionalServices,
})
) {
setLocalError(
"仅允许「冷藏车」时须同时勾选「温控 / 冷链」,否则官网无法提交询价",
);
} else if (
isFlockTempWithoutReefer({
vehicleTypes: next,
additionalServices,
})
) {
setLocalError("已勾选「温控 / 冷链」时,允许车型中须包含「冷藏车」");
} else if (isFlockSprinterOnly(next)) {
setLocalError(
"仅选「小型厢车」时易无报价,建议同时勾选厢式货车或干货挂车",
);
} else {
setLocalError(null);
}
return next;
});
};
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!canSubmit) return;
setLocalError(null);
const nextErr: Record<string, string> = {};
if (!/^\d{5}(-\d{4})?$/.test(pickupZip.trim())) {
nextErr.pickupZip = "提货邮编必填5 位)";
}
if (!/^\d{5}(-\d{4})?$/.test(deliveryZip.trim())) {
nextErr.deliveryZip = "派送邮编必填5 位)";
}
const pickupDateError = flockPickupDateValidationMessageFromIso(pickupDate);
if (pickupDateError) {
nextErr.pickupDate = pickupDateError;
}
if (vehicleTypes.length === 0) {
nextErr.vehicles = "请至少选择一种车型";
}
if (flockOptionHasBlock(optionCompatIssues)) {
const block = optionCompatIssues.find((i) => i.severity === "block")!;
setLocalError(block.message);
setErrors(nextErr);
return;
}
const parsedItems: FlockLoggedInQuotePayload["items"] = [];
for (let i = 0; i < items.length; i += 1) {
const it = items[i]!;
const quantity = Number(it.quantity);
const lengthIn = Number(it.lengthIn);
const widthIn = Number(it.widthIn);
const heightIn = Number(it.heightIn);
const totalWeightLb = Number(it.totalWeightLb);
const lineErr = validateFlockLoggedInCargoLine({
quantity,
lengthIn,
widthIn,
heightIn,
totalWeightLb,
description: it.description,
});
if (lineErr.qty) nextErr[`item-${i}-qty`] = lineErr.qty;
if (lineErr.len) nextErr[`item-${i}-len`] = lineErr.len;
if (lineErr.wid) nextErr[`item-${i}-wid`] = lineErr.wid;
if (lineErr.hei) nextErr[`item-${i}-hei`] = lineErr.hei;
if (lineErr.wt) nextErr[`item-${i}-wt`] = lineErr.wt;
if (lineErr.desc) nextErr[`item-${i}-desc`] = lineErr.desc;
if (Object.keys(nextErr).some((k) => k.startsWith(`item-${i}-`))) {
continue;
}
parsedItems.push({
quantity,
packagingType: it.packagingType,
lengthIn,
widthIn,
heightIn,
totalWeightLb,
freightClass: it.freightClass,
description: it.description.trim(),
stackable: it.stackable,
turnable: it.turnable,
});
}
const shipmentWeight = parsedItems.reduce(
(s, it) => s + it.totalWeightLb,
0,
);
if (
parsedItems.length > 0 &&
shipmentWeight > FLOCK_LIMITS.totalWeightLbMax
) {
nextErr.shipmentWeight = FLOCK_VALIDATION_MESSAGES.totalWeight;
}
if (pickupService === "during_window") {
if (!pickupWindowStartTime.trim()) {
nextErr.pickupWindowStart = "请选择提货时段开始时间";
}
if (!pickupWindowEndTime.trim()) {
nextErr.pickupWindowEnd = "请选择提货时段结束时间";
}
}
if (pickupService === "have_appointment" && !pickupAppointmentTime.trim()) {
nextErr.pickupApptTime = "请选择提货预约时间";
}
if (deliveryService === "during_window") {
if (!deliveryWindowStartTime.trim()) {
nextErr.deliveryWindowStart = "请选择派送时段开始时间";
}
if (!deliveryWindowEndTime.trim()) {
nextErr.deliveryWindowEnd = "请选择派送时段结束时间";
}
if (!deliveryWindowDate.trim()) {
nextErr.deliveryWindowDate = "请选择派送时段日期";
}
}
if (deliveryService === "have_appointment") {
if (!deliveryAppointmentTime.trim()) {
nextErr.deliveryApptTime = "请选择派送预约时间";
}
if (!deliveryAppointmentDate.trim()) {
nextErr.deliveryApptDate = "请选择派送预约日期";
}
}
if (
deliveryService === "must_arrive_by" &&
!deliveryMustArriveByDate.trim()
) {
nextErr.deliveryMustArrive = "请选择须到达日期";
}
setErrors(nextErr);
if (Object.keys(nextErr).length > 0 || parsedItems.length === 0) {
const firstMsg =
nextErr.pickupZip ||
nextErr.deliveryZip ||
nextErr.pickupDate ||
nextErr.vehicles ||
nextErr.shipmentWeight ||
Object.values(nextErr)[0] ||
"请完善必填项后再生成报价";
setLocalError(firstMsg);
return;
}
onValidSubmit?.({
mode: "quick",
pickupDate,
pickupZip: pickupZip.trim(),
pickupType,
pickupLiftgate,
pickupInside,
pickupPalletJack,
deliveryZip: deliveryZip.trim(),
deliveryType,
deliveryLiftgate,
deliveryInside,
deliveryPalletJack,
items: parsedItems,
additionalServices,
vehicleTypes,
pickupService,
deliveryService,
pickupWindowStartTime:
pickupService === "during_window" ? pickupWindowStartTime : undefined,
pickupWindowEndTime:
pickupService === "during_window" ? pickupWindowEndTime : undefined,
pickupAppointmentTime:
pickupService === "have_appointment"
? pickupAppointmentTime
: undefined,
deliveryWindowStartTime:
deliveryService === "during_window"
? deliveryWindowStartTime
: undefined,
deliveryWindowEndTime:
deliveryService === "during_window" ? deliveryWindowEndTime : undefined,
deliveryWindowDate:
deliveryService === "during_window" ? deliveryWindowDate : undefined,
deliveryAppointmentTime:
deliveryService === "have_appointment"
? deliveryAppointmentTime
: undefined,
deliveryAppointmentDate:
deliveryService === "have_appointment"
? deliveryAppointmentDate
: undefined,
deliveryMustArriveByDate:
deliveryService === "must_arrive_by"
? deliveryMustArriveByDate
: undefined,
callForDeliveryAppointment:
deliveryService === "must_arrive_by"
? callForDeliveryAppointment
: undefined,
callBeforePickup:
FLOCK_PICKUP_CALL_BEFORE_WEIGHT_LOCKED &&
isFlockSchedulingOptionLocked(shipmentWeight, true)
? false
: callBeforePickup,
callBeforeDelivery,
additionalInsurance,
});
};
return (
<form id={formId} onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-3">
<QuoteStepProgress current={1} />
<QuotePageTitle></QuotePageTitle>
</div>
<QuoteFloatHint message={localError} />
{optionCompatIssues.length > 0 ? (
<div className="space-y-1.5 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">
{optionCompatIssues.map((issue) => (
<li
key={issue.code}
className={
issue.severity === "block" ? "font-medium text-[#EF4444]" : ""
}
>
{issue.severity === "block" ? "不可提交:" : ""}
{issue.message}
</li>
))}
</ul>
</div>
) : null}
{/* 提货日期 */}
<section className="rounded-lg border border-border bg-surface p-4 shadow-card md:p-5">
<div className="mb-4">
<QuoteSectionTitle></QuoteSectionTitle>
</div>
<OutlinedField
label="提货日期"
required
error={errors.pickupDate}
>
<MothershipWeekdayDatePicker
value={pickupDate}
minIso={minFlockPickupDateIso()}
disabled={disabled}
className={errors.pickupDate ? inputErrCls : inputCls}
onChange={setPickupDate}
/>
</OutlinedField>
</section>
{/* 提货与派送地点 */}
<section className="rounded-lg border border-border bg-surface p-4 shadow-card md:p-5">
<div className="mb-4 flex items-center gap-2">
<QuoteSectionTitle></QuoteSectionTitle>
<QuoteFieldTooltip
wide
position="below"
label="地点类型说明"
tip={
<QuoteTipDefs
intro={FLOCK_LOCATION_TIP_INTRO}
items={FLOCK_LOCATION_TIP_ITEMS}
/>
}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<OutlinedField
label="提货邮编"
required
error={errors.pickupZip}
>
<input
disabled={disabled}
value={pickupZip}
onChange={(e) => setPickupZip(e.target.value)}
placeholder="如 60611"
className={errors.pickupZip ? inputErrCls : inputCls}
inputMode="numeric"
/>
</OutlinedField>
<OutlinedField label="提货地点类型" required>
<select
disabled={disabled}
value={pickupType}
onChange={(e) => {
const next = e.target.value as FlockLocationTypeId;
const flags = getFlockLocationAccessorials(next);
setPickupType(next);
// 对齐官网:切换类型时默认勾选该类可见项(住宅→尾板+室内)
setPickupLiftgate(flags.liftgate);
setPickupInside(flags.inside);
setPickupPalletJack(flags.palletJack);
}}
className={inputCls}
>
{FLOCK_LOCATION_TYPES.map((t) => (
<option key={t.id} value={t.id}>
{t.label}
</option>
))}
</select>
</OutlinedField>
</div>
<LocationAccessorialChecks
side="pickup"
typeId={pickupType}
disabled={disabled}
liftgate={pickupLiftgate}
inside={pickupInside}
palletJack={pickupPalletJack}
onLiftgate={setPickupLiftgate}
onInside={setPickupInside}
onPalletJack={setPickupPalletJack}
/>
<div className="mt-6 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
<OutlinedField
label="派送邮编"
required
error={errors.deliveryZip}
>
<input
disabled={disabled}
value={deliveryZip}
onChange={(e) => setDeliveryZip(e.target.value)}
placeholder="如 78701"
className={errors.deliveryZip ? inputErrCls : inputCls}
inputMode="numeric"
/>
</OutlinedField>
<OutlinedField label="派送地点类型" required>
<select
disabled={disabled}
value={deliveryType}
onChange={(e) => {
const next = e.target.value as FlockLocationTypeId;
const flags = getFlockLocationAccessorials(next);
setDeliveryType(next);
// 对齐官网:切换类型时默认勾选该类可见项(农场→三项全选)
setDeliveryLiftgate(flags.liftgate);
setDeliveryInside(flags.inside);
setDeliveryPalletJack(flags.palletJack);
}}
className={inputCls}
>
{FLOCK_LOCATION_TYPES.map((t) => (
<option key={t.id} value={t.id}>
{t.label}
</option>
))}
</select>
</OutlinedField>
</div>
<LocationAccessorialChecks
side="delivery"
typeId={deliveryType}
disabled={disabled}
liftgate={deliveryLiftgate}
inside={deliveryInside}
palletJack={deliveryPalletJack}
onLiftgate={setDeliveryLiftgate}
onInside={setDeliveryInside}
onPalletJack={setDeliveryPalletJack}
/>
<div className="mt-6 border-t border-border pt-4">
<OutlinedField
label="搜索常用货件(可选)"
tip="将货品描述存为常用后,可在此按名称快速回填件数、尺寸与描述"
>
<FlockSavedFreightSearch
customerId={customerId}
disabled={disabled}
revision={savedFreightRevision}
onSelect={(preset) => {
setItems((rows) => {
if (!rows.length) return [applySavedFreightPreset(newItem(), preset)];
const [first, ...rest] = rows;
return [applySavedFreightPreset(first, preset), ...rest];
});
}}
/>
</OutlinedField>
</div>
</section>
{/* 货物行 */}
{items.map((item, idx) => (
<section
key={item.key}
className="rounded-lg border border-border bg-surface p-4 shadow-card md:p-5"
>
<div className="mb-4 flex items-center justify-between">
<QuoteSectionTitle> {idx + 1}</QuoteSectionTitle>
{items.length > 1 ? (
<button
type="button"
disabled={disabled}
aria-label="删除货物行"
onClick={() =>
setItems((rows) => rows.filter((r) => r.key !== item.key))
}
className="rounded p-1 text-text-secondary hover:bg-bg hover:text-[#EF4444] disabled:opacity-50"
>
<Trash size={18} />
</button>
) : null}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<OutlinedField
label="件数"
required
error={errors[`item-${idx}-qty`]}
>
<input
disabled={disabled}
value={item.quantity}
onChange={(e) =>
updateItem(item.key, { quantity: e.target.value })
}
className={
errors[`item-${idx}-qty`] ? inputErrCls : inputCls
}
inputMode="numeric"
/>
</OutlinedField>
<OutlinedField label="包装类型" required>
<select
disabled={disabled}
value={item.packagingType}
onChange={(e) =>
updateItem(
item.key,
applyPackagingDims(
e.target.value as FlockPackagingTypeId,
item,
),
)
}
className={inputCls}
>
{FLOCK_PACKAGING_TYPES.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
</option>
))}
</select>
</OutlinedField>
</div>
<div className="mt-4 grid grid-cols-3 gap-4">
<OutlinedField
label="长(英寸)"
required
tip={`上限 ${FLOCK_LIMITS.dimIn.lengthMax} in`}
error={errors[`item-${idx}-len`]}
>
<input
disabled={disabled}
value={item.lengthIn}
min={FLOCK_LIMITS.dimIn.min}
max={FLOCK_LIMITS.dimIn.lengthMax}
onChange={(e) =>
updateItem(item.key, { lengthIn: e.target.value })
}
className={
errors[`item-${idx}-len`] ? inputErrCls : inputCls
}
inputMode="decimal"
/>
</OutlinedField>
<OutlinedField
label="宽(英寸)"
required
tip={`上限 ${FLOCK_LIMITS.dimIn.widthMax} in`}
error={errors[`item-${idx}-wid`]}
>
<input
disabled={disabled}
value={item.widthIn}
min={FLOCK_LIMITS.dimIn.min}
max={FLOCK_LIMITS.dimIn.widthMax}
onChange={(e) =>
updateItem(item.key, { widthIn: e.target.value })
}
className={
errors[`item-${idx}-wid`] ? inputErrCls : inputCls
}
inputMode="decimal"
/>
</OutlinedField>
<OutlinedField
label="高(英寸)"
required
tip={`上限 ${FLOCK_LIMITS.dimIn.heightMax} in`}
error={errors[`item-${idx}-hei`]}
>
<input
disabled={disabled}
value={item.heightIn}
min={FLOCK_LIMITS.dimIn.min}
max={FLOCK_LIMITS.dimIn.heightMax}
onChange={(e) =>
updateItem(item.key, { heightIn: e.target.value })
}
className={
errors[`item-${idx}-hei`] ? inputErrCls : inputCls
}
inputMode="decimal"
/>
</OutlinedField>
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<OutlinedField
label="本行总重(磅)"
required
tip={`上限 ${FLOCK_LIMITS.totalWeightLbMax} lb`}
error={errors[`item-${idx}-wt`]}
>
<input
disabled={disabled}
value={item.totalWeightLb}
min={0.01}
max={FLOCK_LIMITS.totalWeightLbMax}
onChange={(e) =>
updateItem(item.key, { totalWeightLb: e.target.value })
}
className={
errors[`item-${idx}-wt`] ? inputErrCls : inputCls
}
inputMode="decimal"
/>
</OutlinedField>
<OutlinedField
label="NMFC 运价等级"
required
tip={FLOCK_FREIGHT_CLASS_TIP}
>
<select
disabled={disabled}
value={item.freightClass}
onChange={(e) =>
updateItem(item.key, {
freightClass: e.target.value as FlockFreightClass,
})
}
className={inputCls}
>
{FLOCK_FREIGHT_CLASSES.map((c) => (
<option key={c} value={c}>
{c === "density" ? "按密度估算" : c}
</option>
))}
</select>
</OutlinedField>
</div>
{item.freightClass === "density" ? (
<div className="mt-4">
<QuoteNoticeCollapse title="注意事项">
{FLOCK_NMFC_NOTICE}
</QuoteNoticeCollapse>
</div>
) : null}
<div className="mt-4">
<OutlinedField
label="货品描述"
required
error={errors[`item-${idx}-desc`]}
>
<input
disabled={disabled}
value={item.description}
onChange={(e) =>
updateItem(item.key, { description: e.target.value })
}
className={
errors[`item-${idx}-desc`] ? inputErrCls : inputCls
}
/>
</OutlinedField>
<FlockSaveFreightButton
customerId={customerId}
disabled={disabled}
source={{
description: item.description,
quantity: item.quantity,
packagingType: item.packagingType,
lengthIn: item.lengthIn,
widthIn: item.widthIn,
heightIn: item.heightIn,
totalWeightLb: item.totalWeightLb,
freightClass: item.freightClass,
stackable: item.stackable,
turnable: item.turnable,
}}
onSaved={() => setSavedFreightRevision((n) => n + 1)}
/>
</div>
<div className="mt-4">
<p className="mb-2 text-xs text-[#4B5563]"></p>
<div className="flex flex-wrap gap-4">
<label className="flex h-12 items-center gap-2 text-sm">
<input
type="checkbox"
disabled={disabled}
checked={item.stackable}
onChange={(e) =>
updateItem(item.key, { stackable: e.target.checked })
}
/>
</label>
<label className="flex h-12 items-center gap-2 text-sm">
<input
type="checkbox"
disabled={disabled}
checked={item.turnable}
onChange={(e) =>
updateItem(item.key, { turnable: e.target.checked })
}
/>
</label>
</div>
<p className="mt-3 rounded-md border border-[#1890FF]/20 bg-[#1890FF]/5 px-3 py-2 text-xs text-[#4B5563]">
{FLOCK_STACKABLE_TIP}
</p>
</div>
</section>
))}
<button
type="button"
disabled={disabled}
onClick={() => setItems((rows) => [...rows, newItem()])}
className={`${quoteAddRowCls} h-11 w-full rounded-full`}
>
<Plus size={16} weight="bold" />
</button>
{/* 附加服务 */}
<section className="rounded-lg border border-border bg-surface p-4 shadow-card md:p-5">
<button
type="button"
className="mb-3 flex w-full items-center justify-between text-left"
onClick={() => setShowAdditional((v) => !v)}
>
<div className="flex items-center gap-2">
<QuoteSectionTitle></QuoteSectionTitle>
<QuoteFieldTooltip
wide
position="below"
label="附加服务说明"
tip={<QuoteTipDefs items={FLOCK_ADDITIONAL_SERVICE_TIP_ITEMS} />}
/>
</div>
<CaretDown
size={16}
className={[
"text-text-secondary transition-transform",
showAdditional ? "rotate-180" : "",
].join(" ")}
/>
</button>
{showAdditional ? (
<>
<p className="mb-3 text-xs text-[#4B5563]">
{servicesSelected}
</p>
<div className="flex flex-wrap gap-2">
{FLOCK_ADDITIONAL_SERVICES.map((s) => (
<ChipToggle
key={s.id}
disabled={disabled}
selected={additionalServices.includes(s.id)}
label={s.label}
onClick={() => toggleService(s.id)}
/>
))}
</div>
<div className="mt-6 border-t border-border pt-4">
<div className="mb-3 flex flex-wrap items-center gap-1 text-xs text-[#4B5563]">
使 · {vehiclesSelected}
<QuoteFieldTooltip
wide
position="below"
label="车型说明"
tip={<QuoteTipDefs items={FLOCK_VEHICLE_TIP_ITEMS} />}
/>
{errors.vehicles ? (
<span className="ml-2 text-[#EF4444]">{errors.vehicles}</span>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{FLOCK_VEHICLE_TYPES.map((v) => (
<ChipToggle
key={v.id}
disabled={disabled}
selected={vehicleTypes.includes(v.id)}
label={v.label}
onClick={() => toggleVehicle(v.id)}
/>
))}
</div>
</div>
</>
) : null}
</section>
{/* 调度服务 */}
<section className="rounded-lg border border-border bg-surface p-4 shadow-card md:p-5">
<button
type="button"
className="mb-3 flex w-full items-center justify-between text-left"
onClick={() => setShowScheduling((v) => !v)}
>
<QuoteSectionTitle></QuoteSectionTitle>
<CaretDown
size={16}
className={[
"text-text-secondary transition-transform",
showScheduling ? "rotate-180" : "",
].join(" ")}
/>
</button>
{showScheduling ? (
<div className="space-y-6">
<p className="text-xs text-text-secondary">
{FLOCK_LIMITS.schedulingWeightMinLb} lb
{FLOCK_LIMITS.schedulingWeightMinLb} lb
{shipmentWeightLb > 0 ? (
<span className="ml-1 font-medium text-text-primary">
{shipmentWeightLb} lb
</span>
) : null}
</p>
<div>
<p className="mb-3 text-sm font-medium text-[#4B5563]"></p>
<div className="grid gap-4 sm:grid-cols-2">
{FLOCK_PICKUP_SERVICES.map((s) => {
const locked = isFlockSchedulingOptionLocked(
shipmentWeightLb,
s.weightLocked,
);
return (
<label
key={s.id}
className={[
"flex min-h-12 items-start gap-2 text-sm",
locked
? "cursor-not-allowed text-text-disabled opacity-60"
: "text-text-primary",
].join(" ")}
>
<input
type="radio"
name={`${baseId}-pickup-svc`}
disabled={disabled || locked}
checked={pickupService === s.id}
onChange={() => {
if (!locked) setPickupService(s.id);
}}
className="mt-0.5"
/>
<span>
<span className="inline-flex items-center gap-1">
{s.label}
{!locked && s.hint ? (
<QuoteFieldTooltip tip={s.hint} />
) : null}
</span>
{locked ? (
<span className="mt-0.5 block text-xs text-text-disabled">
{schedulingLockedHint}
</span>
) : null}
{!locked && s.detail && pickupService === s.id ? (
<span className="mt-0.5 block text-xs text-text-secondary">
{s.detail}
</span>
) : null}
</span>
</label>
);
})}
</div>
{pickupService === "during_window" &&
!isFlockSchedulingOptionLocked(shipmentWeightLb, true) ? (
<div className="mt-4 space-y-3 rounded-md border border-border bg-[#FAFAFA] p-3">
<p className="text-sm font-medium text-text-primary">
</p>
<div className="grid gap-3 sm:grid-cols-2">
<OutlinedField
label="开始时间"
required
error={errors.pickupWindowStart}
>
<select
disabled={disabled}
value={pickupWindowStartTime}
onChange={(e) =>
setPickupWindowStartTime(e.target.value)
}
className={
errors.pickupWindowStart ? inputErrCls : inputCls
}
>
{FLOCK_SCHEDULING_TIME_OPTIONS.map((t) => (
<option key={`pw-s-${t}`} value={t}>
{t}
</option>
))}
</select>
</OutlinedField>
<OutlinedField
label="结束时间"
required
error={errors.pickupWindowEnd}
>
<select
disabled={disabled}
value={pickupWindowEndTime}
onChange={(e) => setPickupWindowEndTime(e.target.value)}
className={
errors.pickupWindowEnd ? inputErrCls : inputCls
}
>
{FLOCK_SCHEDULING_TIME_OPTIONS.map((t) => (
<option key={`pw-e-${t}`} value={t}>
{t}
</option>
))}
</select>
</OutlinedField>
</div>
</div>
) : null}
{pickupService === "have_appointment" &&
!isFlockSchedulingOptionLocked(shipmentWeightLb, true) ? (
<div className="mt-4 space-y-3 rounded-md border border-border bg-[#FAFAFA] p-3">
<p className="text-sm font-medium text-text-primary">
</p>
<OutlinedField
label="时间"
required
error={errors.pickupApptTime}
>
<select
disabled={disabled}
value={pickupAppointmentTime}
onChange={(e) =>
setPickupAppointmentTime(e.target.value)
}
className={errors.pickupApptTime ? inputErrCls : inputCls}
>
{FLOCK_SCHEDULING_TIME_OPTIONS.map((t) => (
<option key={`pa-${t}`} value={t}>
{t}
</option>
))}
</select>
</OutlinedField>
</div>
) : null}
{(() => {
const callBeforeLocked = isFlockSchedulingOptionLocked(
shipmentWeightLb,
FLOCK_PICKUP_CALL_BEFORE_WEIGHT_LOCKED,
);
return (
<label
className={[
"mt-3 flex min-h-12 items-start gap-2 text-sm",
callBeforeLocked || disabled
? "cursor-not-allowed text-text-disabled opacity-60"
: "items-center text-text-primary",
].join(" ")}
>
<input
type="checkbox"
className="mt-0.5"
disabled={disabled || callBeforeLocked}
checked={callBeforeLocked ? false : callBeforePickup}
onChange={(e) => {
if (!callBeforeLocked) {
setCallBeforePickup(e.target.checked);
}
}}
/>
<span>
<span className="inline-flex items-center gap-1">
{!callBeforeLocked ? (
<QuoteFieldTooltip tip="勾选后,承运商须在提货前 12 小时联系提货人" />
) : null}
</span>
{callBeforeLocked ? (
<span className="mt-0.5 block text-xs text-text-disabled">
{schedulingLockedHint}
</span>
) : null}
</span>
</label>
);
})()}
</div>
<div className="border-t border-border pt-4">
<p className="mb-3 text-sm font-medium text-[#4B5563]"></p>
<div className="grid gap-4 sm:grid-cols-2">
{FLOCK_DELIVERY_SERVICES.map((s) => {
const locked = isFlockSchedulingOptionLocked(
shipmentWeightLb,
s.weightLocked,
);
return (
<label
key={s.id}
className={[
"flex min-h-12 items-start gap-2 text-sm",
locked
? "cursor-not-allowed text-text-disabled opacity-60"
: "text-text-primary",
].join(" ")}
>
<input
type="radio"
name={`${baseId}-delivery-svc`}
disabled={disabled || locked}
checked={deliveryService === s.id}
onChange={() => {
if (!locked) setDeliveryService(s.id);
}}
className="mt-0.5"
/>
<span>
<span className="inline-flex items-center gap-1">
{s.label}
{!locked && s.hint ? (
<QuoteFieldTooltip tip={s.hint} />
) : null}
</span>
{locked ? (
<span className="mt-0.5 block text-xs text-text-disabled">
{schedulingLockedHint}
</span>
) : null}
{!locked && s.detail && deliveryService === s.id ? (
<span className="mt-0.5 block text-xs text-text-secondary">
{s.detail}
</span>
) : null}
</span>
</label>
);
})}
</div>
{deliveryService === "during_window" &&
!isFlockSchedulingOptionLocked(shipmentWeightLb, true) ? (
<div className="mt-4 space-y-3 rounded-md border border-border bg-[#FAFAFA] p-3">
<p className="text-sm font-medium text-text-primary">
</p>
<div className="grid gap-3 sm:grid-cols-2">
<OutlinedField
label="开始时间"
required
error={errors.deliveryWindowStart}
>
<select
disabled={disabled}
value={deliveryWindowStartTime}
onChange={(e) =>
setDeliveryWindowStartTime(e.target.value)
}
className={
errors.deliveryWindowStart ? inputErrCls : inputCls
}
>
{FLOCK_SCHEDULING_TIME_OPTIONS.map((t) => (
<option key={`dw-s-${t}`} value={t}>
{t}
</option>
))}
</select>
</OutlinedField>
<OutlinedField
label="结束时间"
required
error={errors.deliveryWindowEnd}
>
<select
disabled={disabled}
value={deliveryWindowEndTime}
onChange={(e) =>
setDeliveryWindowEndTime(e.target.value)
}
className={
errors.deliveryWindowEnd ? inputErrCls : inputCls
}
>
{FLOCK_SCHEDULING_TIME_OPTIONS.map((t) => (
<option key={`dw-e-${t}`} value={t}>
{t}
</option>
))}
</select>
</OutlinedField>
</div>
<OutlinedField
label="日期"
required
error={errors.deliveryWindowDate}
>
<input
type="date"
disabled={disabled}
value={deliveryWindowDate}
min={pickupDate || defaultFlockPickupDateIso()}
onChange={(e) => setDeliveryWindowDate(e.target.value)}
className={
errors.deliveryWindowDate ? inputErrCls : inputCls
}
/>
</OutlinedField>
</div>
) : null}
{deliveryService === "have_appointment" &&
!isFlockSchedulingOptionLocked(shipmentWeightLb, true) ? (
<div className="mt-4 space-y-3 rounded-md border border-border bg-[#FAFAFA] p-3">
<p className="text-sm font-medium text-text-primary">
</p>
<div className="grid gap-3 sm:grid-cols-2">
<OutlinedField
label="时间"
required
error={errors.deliveryApptTime}
>
<select
disabled={disabled}
value={deliveryAppointmentTime}
onChange={(e) =>
setDeliveryAppointmentTime(e.target.value)
}
className={
errors.deliveryApptTime ? inputErrCls : inputCls
}
>
{FLOCK_SCHEDULING_TIME_OPTIONS.map((t) => (
<option key={`da-t-${t}`} value={t}>
{t}
</option>
))}
</select>
</OutlinedField>
<OutlinedField
label="日期"
required
error={errors.deliveryApptDate}
>
<input
type="date"
disabled={disabled}
value={deliveryAppointmentDate}
min={pickupDate || defaultFlockPickupDateIso()}
onChange={(e) =>
setDeliveryAppointmentDate(e.target.value)
}
className={
errors.deliveryApptDate ? inputErrCls : inputCls
}
/>
</OutlinedField>
</div>
</div>
) : null}
{deliveryService === "must_arrive_by" &&
!isFlockSchedulingOptionLocked(shipmentWeightLb, true) ? (
<div className="mt-4 space-y-3 rounded-md border border-border bg-[#FAFAFA] p-3">
<p className="text-sm font-medium text-text-primary">
</p>
<OutlinedField
label="日期"
required
error={errors.deliveryMustArrive}
>
<input
type="date"
disabled={disabled}
value={deliveryMustArriveByDate}
min={pickupDate || defaultFlockPickupDateIso()}
onChange={(e) =>
setDeliveryMustArriveByDate(e.target.value)
}
className={
errors.deliveryMustArrive ? inputErrCls : inputCls
}
/>
</OutlinedField>
<label className="flex min-h-12 items-start gap-2 text-sm text-text-primary">
<input
type="checkbox"
className="mt-0.5"
disabled={disabled}
checked={callForDeliveryAppointment}
onChange={(e) =>
setCallForDeliveryAppointment(e.target.checked)
}
/>
<span>
<span></span>
<span className="mt-0.5 block text-xs text-text-secondary">
{FLOCK_CALL_FOR_DELIVERY_APPOINTMENT_HINT}
</span>
</span>
</label>
</div>
) : null}
<label className="mt-3 flex h-12 items-center gap-2 text-sm text-text-primary">
<input
type="checkbox"
disabled={disabled}
checked={callBeforeDelivery}
onChange={(e) => setCallBeforeDelivery(e.target.checked)}
/>
<span className="inline-flex items-center gap-1">
<QuoteFieldTooltip tip="勾选后,承运商须在派送前 12 小时联系收货人" />
</span>
</label>
</div>
</div>
) : null}
</section>
{/* 额外保险 */}
<section className="rounded-lg border border-border bg-surface p-4 shadow-card md:p-5">
<button
type="button"
className="mb-3 flex w-full items-center justify-between text-left"
onClick={() => setShowInsurance((v) => !v)}
>
<QuoteSectionTitle></QuoteSectionTitle>
<CaretDown
size={16}
className={[
"text-text-secondary transition-transform",
showInsurance ? "rotate-180" : "",
].join(" ")}
/>
</button>
{showInsurance ? (
<div className="space-y-3">
<label className="flex h-12 items-center gap-2 text-sm text-text-primary">
<input
type="radio"
name={`${baseId}-insurance`}
disabled={disabled}
checked={additionalInsurance}
onChange={() => setAdditionalInsurance(true)}
/>
<span className="inline-flex items-center gap-1">
<QuoteFieldTooltip tip={FLOCK_INSURANCE_YES_TIP} wide />
</span>
</label>
<label className="flex h-12 items-center gap-2 text-sm text-text-primary">
<input
type="radio"
name={`${baseId}-insurance`}
disabled={disabled}
checked={!additionalInsurance}
onChange={() => setAdditionalInsurance(false)}
/>
<span className="inline-flex items-center gap-1">
<QuoteFieldTooltip tip={FLOCK_INSURANCE_NO_TIP} wide />
</span>
</label>
{!additionalInsurance ? (
<p className="text-xs text-text-secondary">
{FLOCK_INSURANCE_WAIVE_HINT}
</p>
) : null}
<p className="text-xs text-text-secondary">{FLOCK_INSURANCE_FOOTER}</p>
</div>
) : null}
</section>
<button
type="submit"
disabled={!canSubmit}
data-testid="flock-logged-in-generate-quote"
className={`${quoteCtaCls} h-12 w-full rounded-full disabled:bg-border disabled:text-text-disabled`}
>
{submitLocked ? "提交中…" : "生成我的报价"}
</button>
</form>
);
}