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/lib/flock/flock-checkout-details-rule...

321 lines
10 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 结账二级详情校验(对齐官网结账页: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,
},
};
}