|
|
/**
|
|
|
* Flock 结账二级详情校验(对齐官网结账页:Pickup / Delivery / Shipment details)
|
|
|
*/
|
|
|
|
|
|
export type FlockCheckoutRole = "shipper" | "receiver" | "other";
|
|
|
|
|
|
export type FlockCheckoutLocationDraft = {
|
|
|
company_name: string;
|
|
|
address1: string;
|
|
|
address2: string;
|
|
|
city: string;
|
|
|
state: string;
|
|
|
zip: string;
|
|
|
contact_name: string;
|
|
|
contact_phone: string;
|
|
|
contact_email: string;
|
|
|
opens_at: string;
|
|
|
closes_at: string;
|
|
|
/** 派送:是否允许周末派送 */
|
|
|
weekend_delivery?: boolean;
|
|
|
};
|
|
|
|
|
|
export type FlockCheckoutDetailsDraft = {
|
|
|
role: FlockCheckoutRole;
|
|
|
/** 提货使用账单地址(官网 Use billing address) */
|
|
|
use_billing_for_pickup: boolean;
|
|
|
/** 派送使用账单地址 */
|
|
|
use_billing_for_delivery: boolean;
|
|
|
pickup: FlockCheckoutLocationDraft;
|
|
|
delivery: FlockCheckoutLocationDraft;
|
|
|
/** 运输详情(官网 Shipment details) */
|
|
|
nmfc: string;
|
|
|
po_number: string;
|
|
|
bol_remarks: string;
|
|
|
declaration_statement: string;
|
|
|
notes: string;
|
|
|
documentation_required: boolean;
|
|
|
};
|
|
|
|
|
|
export type FlockDetailsFieldErrors = {
|
|
|
role?: string;
|
|
|
pickup?: Partial<Record<keyof FlockCheckoutLocationDraft, string>>;
|
|
|
delivery?: Partial<Record<keyof FlockCheckoutLocationDraft, string>>;
|
|
|
nmfc?: string;
|
|
|
form?: string;
|
|
|
};
|
|
|
|
|
|
const US_PHONE_RE =
|
|
|
/^(\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}$/;
|
|
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
|
|
|
/** 555 号段官网常拒;禁止全 0/1 假号 */
|
|
|
export function isPlausibleUsPhone(raw: string): boolean {
|
|
|
const digits = raw.replace(/\D/g, "");
|
|
|
const ten =
|
|
|
digits.length === 11 && digits.startsWith("1")
|
|
|
? digits.slice(1)
|
|
|
: digits;
|
|
|
if (ten.length !== 10) return false;
|
|
|
if (!US_PHONE_RE.test(raw.trim())) return false;
|
|
|
const area = ten.slice(0, 3);
|
|
|
const exchange = ten.slice(3, 6);
|
|
|
if (area === "555" || exchange === "555") return false;
|
|
|
if (/^(\d)\1{9}$/.test(ten)) return false;
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
export function normalizeUsPhoneDisplay(raw: string): string {
|
|
|
const digits = raw.replace(/\D/g, "");
|
|
|
const ten =
|
|
|
digits.length === 11 && digits.startsWith("1")
|
|
|
? digits.slice(1)
|
|
|
: digits;
|
|
|
if (ten.length !== 10) return raw.trim();
|
|
|
return `(${ten.slice(0, 3)}) ${ten.slice(3, 6)}-${ten.slice(6)}`;
|
|
|
}
|
|
|
|
|
|
export const FLOCK_CHECKOUT_HOURS = [
|
|
|
"6:00 AM",
|
|
|
"7:00 AM",
|
|
|
"8:00 AM",
|
|
|
"9:00 AM",
|
|
|
"10:00 AM",
|
|
|
"11:00 AM",
|
|
|
"12:00 PM",
|
|
|
"1:00 PM",
|
|
|
"2:00 PM",
|
|
|
"3:00 PM",
|
|
|
"4:00 PM",
|
|
|
"5:00 PM",
|
|
|
"6:00 PM",
|
|
|
"7:00 PM",
|
|
|
"8:00 PM",
|
|
|
] as const;
|
|
|
|
|
|
export const FLOCK_CHECKOUT_ROLE_OPTIONS: Array<{
|
|
|
id: FlockCheckoutRole;
|
|
|
label: string;
|
|
|
}> = [
|
|
|
{ id: "shipper", label: "我是托运人" },
|
|
|
{ id: "receiver", label: "我是收货人" },
|
|
|
{ id: "other", label: "其他" },
|
|
|
];
|
|
|
|
|
|
/** 字段旁限制提示(非阻断) */
|
|
|
export const FLOCK_CHECKOUT_FIELD_HINTS = {
|
|
|
useBilling:
|
|
|
"勾选后将使用账单地址同步官网;地址行仍可见但不可改。若与询价邮编不一致,官网 Next 可能不可点。",
|
|
|
addressMismatch:
|
|
|
"所选地址须与邮编一致,否则官网提示「The selected address does not match the postal code」。",
|
|
|
addressSync:
|
|
|
"将同步到官网公司名 / 地址字段(官网可能提供已存地址下拉,嵌入侧请手动填写)。",
|
|
|
contactHelper: "Flock 可通过此联系人协调提货 / 派送。",
|
|
|
phone: "请填写有效美国号码(勿用 555 测试号段),如 (626) 595-1180。",
|
|
|
email: "须为可送达的工作邮箱;格式错误官网会红字拦截。",
|
|
|
hours: "开门须早于关门;默认 9:00 AM – 5:00 PM。",
|
|
|
weekend: "该地点是否允许周末派送。",
|
|
|
po: "仅填一个 PO;更多说明写入 BOL 备注。",
|
|
|
bol: "将显示在承运商 Rate Confirmation。",
|
|
|
declaration: "联系人确认 / 特殊要求等声明,对应官网 Declaration Statement。",
|
|
|
notes: "对应官网 Notes for Flock Freight。",
|
|
|
nmfc: "FlockDirect® / FTL 通常可不填;建议仍填写以覆盖全场景。",
|
|
|
documentation: "勾选表示提货需额外单据(Documentation Required for Pickup)。",
|
|
|
shipmentItems: "来自首价货件信息,仅供核对;有误请「再查一单」。",
|
|
|
review:
|
|
|
"请核对货件详情;若有误请「再查一单」,错误详情可能导致额外费用。",
|
|
|
} as const;
|
|
|
|
|
|
function requireTrimmed(
|
|
|
value: string | undefined,
|
|
|
label: string,
|
|
|
): string | undefined {
|
|
|
if (!value?.trim()) return `请填写${label}`;
|
|
|
return undefined;
|
|
|
}
|
|
|
|
|
|
function validateLocation(
|
|
|
loc: FlockCheckoutLocationDraft,
|
|
|
side: "pickup" | "delivery",
|
|
|
opts?: { skipAddress?: boolean },
|
|
|
): Partial<Record<keyof FlockCheckoutLocationDraft, string>> {
|
|
|
const err: Partial<Record<keyof FlockCheckoutLocationDraft, string>> = {};
|
|
|
const prefix = side === "pickup" ? "提货" : "派送";
|
|
|
const company = requireTrimmed(loc.company_name, `${prefix}公司名称`);
|
|
|
if (company) err.company_name = company;
|
|
|
|
|
|
if (!opts?.skipAddress) {
|
|
|
const a1 = requireTrimmed(loc.address1, `${prefix}地址`);
|
|
|
if (a1) err.address1 = a1;
|
|
|
const city = requireTrimmed(loc.city, `${prefix}城市`);
|
|
|
if (city) err.city = city;
|
|
|
const state = requireTrimmed(loc.state, `${prefix}州`);
|
|
|
if (state) err.state = state;
|
|
|
const zip = requireTrimmed(loc.zip, `${prefix}邮编`);
|
|
|
if (zip) err.zip = zip;
|
|
|
else if (!/^\d{5}(-\d{4})?$/.test(loc.zip.trim())) {
|
|
|
err.zip = "邮编须为 5 位美国 ZIP";
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const contact = requireTrimmed(loc.contact_name, `${prefix}联系人`);
|
|
|
if (contact) err.contact_name = contact;
|
|
|
|
|
|
const phoneEmpty = requireTrimmed(loc.contact_phone, `${prefix}电话`);
|
|
|
if (phoneEmpty) {
|
|
|
err.contact_phone = phoneEmpty;
|
|
|
} else if (!isPlausibleUsPhone(loc.contact_phone)) {
|
|
|
err.contact_phone = FLOCK_CHECKOUT_FIELD_HINTS.phone;
|
|
|
}
|
|
|
|
|
|
const emailEmpty = requireTrimmed(loc.contact_email, `${prefix}邮箱`);
|
|
|
if (emailEmpty) {
|
|
|
err.contact_email = emailEmpty;
|
|
|
} else if (!EMAIL_RE.test(loc.contact_email.trim())) {
|
|
|
err.contact_email = "邮箱格式无效";
|
|
|
}
|
|
|
|
|
|
const opens = requireTrimmed(loc.opens_at, "开门时间");
|
|
|
if (opens) err.opens_at = opens;
|
|
|
const closes = requireTrimmed(loc.closes_at, "关门时间");
|
|
|
if (closes) err.closes_at = closes;
|
|
|
if (
|
|
|
loc.opens_at?.trim() &&
|
|
|
loc.closes_at?.trim() &&
|
|
|
loc.opens_at.trim() === loc.closes_at.trim()
|
|
|
) {
|
|
|
err.closes_at = "关门时间须晚于开门时间";
|
|
|
}
|
|
|
|
|
|
return err;
|
|
|
}
|
|
|
|
|
|
/** 返回字段错误;空对象 = 通过 */
|
|
|
export function validateFlockCheckoutDetailsDraft(
|
|
|
draft: FlockCheckoutDetailsDraft,
|
|
|
): FlockDetailsFieldErrors {
|
|
|
const errors: FlockDetailsFieldErrors = {};
|
|
|
if (!draft.role) {
|
|
|
errors.role = "请选择您的角色";
|
|
|
}
|
|
|
const pickup = validateLocation(draft.pickup, "pickup", {
|
|
|
skipAddress: draft.use_billing_for_pickup,
|
|
|
});
|
|
|
if (Object.keys(pickup).length) errors.pickup = pickup;
|
|
|
const delivery = validateLocation(draft.delivery, "delivery", {
|
|
|
skipAddress: draft.use_billing_for_delivery,
|
|
|
});
|
|
|
if (Object.keys(delivery).length) errors.delivery = delivery;
|
|
|
return errors;
|
|
|
}
|
|
|
|
|
|
export function flockDetailsHasErrors(
|
|
|
errors: FlockDetailsFieldErrors,
|
|
|
): boolean {
|
|
|
if (errors.role || errors.form || errors.nmfc) return true;
|
|
|
if (errors.pickup && Object.keys(errors.pickup).length) return true;
|
|
|
if (errors.delivery && Object.keys(errors.delivery).length) return true;
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
/** 转为 API flock_details */
|
|
|
export function toFlockCheckoutApiDetails(
|
|
|
draft: FlockCheckoutDetailsDraft,
|
|
|
): {
|
|
|
role: FlockCheckoutRole;
|
|
|
pickup: Record<string, string>;
|
|
|
delivery: Record<string, string | boolean>;
|
|
|
nmfc?: string;
|
|
|
po_number?: string;
|
|
|
bol_remarks?: string;
|
|
|
declaration_statement?: string;
|
|
|
notes?: string;
|
|
|
documentation_required: boolean;
|
|
|
use_billing_for_pickup: boolean;
|
|
|
use_billing_for_delivery: boolean;
|
|
|
} {
|
|
|
const mapLoc = (
|
|
|
loc: FlockCheckoutLocationDraft,
|
|
|
side: "pickup" | "delivery",
|
|
|
) => {
|
|
|
const out: Record<string, string | boolean> = {
|
|
|
company_name: loc.company_name.trim(),
|
|
|
contact_name: loc.contact_name.trim(),
|
|
|
contact_phone: normalizeUsPhoneDisplay(loc.contact_phone),
|
|
|
contact_email: loc.contact_email.trim(),
|
|
|
};
|
|
|
if (loc.opens_at?.trim()) out.opens_at = loc.opens_at.trim();
|
|
|
if (loc.closes_at?.trim()) out.closes_at = loc.closes_at.trim();
|
|
|
if (loc.address1?.trim()) out.address1 = loc.address1.trim();
|
|
|
if (loc.address2?.trim()) out.address2 = loc.address2.trim();
|
|
|
if (loc.city?.trim()) out.city = loc.city.trim();
|
|
|
if (loc.state?.trim()) out.state = loc.state.trim();
|
|
|
if (loc.zip?.trim()) out.zip = loc.zip.trim();
|
|
|
if (side === "delivery") {
|
|
|
out.weekend_delivery = loc.weekend_delivery === true;
|
|
|
}
|
|
|
return out;
|
|
|
};
|
|
|
return {
|
|
|
role: draft.role,
|
|
|
pickup: mapLoc(draft.pickup, "pickup") as Record<string, string>,
|
|
|
delivery: mapLoc(draft.delivery, "delivery"),
|
|
|
...(draft.nmfc.trim() ? { nmfc: draft.nmfc.trim() } : {}),
|
|
|
...(draft.po_number.trim() ? { po_number: draft.po_number.trim() } : {}),
|
|
|
...(draft.bol_remarks.trim()
|
|
|
? { bol_remarks: draft.bol_remarks.trim() }
|
|
|
: {}),
|
|
|
...(draft.declaration_statement.trim()
|
|
|
? { declaration_statement: draft.declaration_statement.trim() }
|
|
|
: {}),
|
|
|
...(draft.notes.trim() ? { notes: draft.notes.trim() } : {}),
|
|
|
documentation_required: draft.documentation_required,
|
|
|
use_billing_for_pickup: draft.use_billing_for_pickup,
|
|
|
use_billing_for_delivery: draft.use_billing_for_delivery,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
export function emptyFlockCheckoutDetailsDraft(opts?: {
|
|
|
pickupZip?: string;
|
|
|
deliveryZip?: string;
|
|
|
}): FlockCheckoutDetailsDraft {
|
|
|
return {
|
|
|
role: "shipper",
|
|
|
use_billing_for_pickup: true,
|
|
|
use_billing_for_delivery: false,
|
|
|
nmfc: "",
|
|
|
po_number: "",
|
|
|
bol_remarks: "",
|
|
|
declaration_statement: "",
|
|
|
notes: "",
|
|
|
documentation_required: false,
|
|
|
pickup: {
|
|
|
company_name: "",
|
|
|
address1: "",
|
|
|
address2: "",
|
|
|
city: "",
|
|
|
state: "",
|
|
|
zip: opts?.pickupZip ?? "",
|
|
|
contact_name: "",
|
|
|
contact_phone: "",
|
|
|
contact_email: "",
|
|
|
opens_at: "9:00 AM",
|
|
|
closes_at: "5:00 PM",
|
|
|
},
|
|
|
delivery: {
|
|
|
company_name: "",
|
|
|
address1: "",
|
|
|
address2: "",
|
|
|
city: "",
|
|
|
state: "",
|
|
|
zip: opts?.deliveryZip ?? "",
|
|
|
contact_name: "",
|
|
|
contact_phone: "",
|
|
|
contact_email: "",
|
|
|
opens_at: "9:00 AM",
|
|
|
closes_at: "5:00 PM",
|
|
|
weekend_delivery: false,
|
|
|
},
|
|
|
};
|
|
|
}
|