/** * 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>; delivery?: Partial>; 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> { const err: Partial> = {}; 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; delivery: Record; 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 = { 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, 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, }, }; }