|
|
"use client";
|
|
|
|
|
|
import { useMemo, useState, type FormEvent } from "react";
|
|
|
import { SelectField } from "@/components/ui/select-field";
|
|
|
import {
|
|
|
FLOCK_LIMITS,
|
|
|
FLOCK_VALIDATION_MESSAGES,
|
|
|
} from "@/lib/constants/flock-limits";
|
|
|
import {
|
|
|
describeDimEquivalent,
|
|
|
describeWeightEquivalent,
|
|
|
parseCargoInputNumber,
|
|
|
} from "@/lib/frontend/cargo-form-units";
|
|
|
import type { UnitDim, UnitWeight } from "@/lib/frontend/types";
|
|
|
import { FLOCK_UI } from "@/lib/flock/ui-labels";
|
|
|
import {
|
|
|
defaultFlockPickupDateIso,
|
|
|
flockPickupDateValidationMessageFromIso,
|
|
|
minFlockPickupDateIso,
|
|
|
} from "@/lib/flock/pickup-date";
|
|
|
import { MothershipWeekdayDatePicker } from "@/components/mothership/mothership-weekday-date-picker";
|
|
|
import { defaultFlockPickupDate } from "@/modules/flock/validation";
|
|
|
import {
|
|
|
cmToIn,
|
|
|
kgToLb,
|
|
|
toAxelWholeInches,
|
|
|
toAxelWholePounds,
|
|
|
} from "@/modules/quote/unit-converter";
|
|
|
|
|
|
export type FlockFormSubmitPayload = {
|
|
|
pickup_date: string;
|
|
|
pickup_zip: string;
|
|
|
delivery_zip: string;
|
|
|
pallet_count: number;
|
|
|
total_weight: { value: number; unit: "lb" | "kg" };
|
|
|
dimensions: {
|
|
|
length: number;
|
|
|
width: number;
|
|
|
height: number;
|
|
|
unit: "in" | "cm";
|
|
|
};
|
|
|
registration?: {
|
|
|
first_name?: string;
|
|
|
last_name?: string;
|
|
|
company?: string;
|
|
|
email?: string;
|
|
|
phone?: string;
|
|
|
shipments_per_month?: string;
|
|
|
};
|
|
|
};
|
|
|
|
|
|
export interface FlockQuoteFormProps {
|
|
|
disabled?: boolean;
|
|
|
submitLocked?: boolean;
|
|
|
onSubmit: (payload: FlockFormSubmitPayload) => void;
|
|
|
}
|
|
|
|
|
|
function toMmDdYyyy(isoDate: string): string {
|
|
|
const [y, m, d] = isoDate.split("-");
|
|
|
if (!y || !m || !d) return defaultFlockPickupDate();
|
|
|
return `${m}/${d}/${y}`;
|
|
|
}
|
|
|
|
|
|
/** Flock 查价表单:单位切换不改数值,服务端自动换算进一(对齐 MotherShip) */
|
|
|
export function FlockQuoteForm({
|
|
|
disabled,
|
|
|
submitLocked,
|
|
|
onSubmit,
|
|
|
}: FlockQuoteFormProps) {
|
|
|
const [weightUnit, setWeightUnit] = useState<UnitWeight>("lb");
|
|
|
const [dimUnit, setDimUnit] = useState<UnitDim>("in");
|
|
|
|
|
|
const [pickupDate, setPickupDate] = useState(defaultFlockPickupDateIso);
|
|
|
const [pickupZip, setPickupZip] = useState("");
|
|
|
const [deliveryZip, setDeliveryZip] = useState("");
|
|
|
const [palletCount, setPalletCount] = useState("6");
|
|
|
const [totalWeight, setTotalWeight] = useState("3500");
|
|
|
const [length, setLength] = useState(
|
|
|
String(FLOCK_LIMITS.defaultDimsIn.length),
|
|
|
);
|
|
|
const [width, setWidth] = useState(String(FLOCK_LIMITS.defaultDimsIn.width));
|
|
|
const [height, setHeight] = useState(
|
|
|
String(FLOCK_LIMITS.defaultDimsIn.height),
|
|
|
);
|
|
|
const [localError, setLocalError] = useState<string | null>(null);
|
|
|
const [showRegistration, setShowRegistration] = useState(false);
|
|
|
const [firstName, setFirstName] = useState("");
|
|
|
const [lastName, setLastName] = useState("");
|
|
|
const [company, setCompany] = useState("");
|
|
|
const [workEmail, setWorkEmail] = useState("");
|
|
|
const [phone, setPhone] = useState("");
|
|
|
const [shipmentsPerMonth, setShipmentsPerMonth] = useState("1-25");
|
|
|
|
|
|
const weightHint = useMemo(() => {
|
|
|
const v = parseCargoInputNumber(totalWeight);
|
|
|
return v == null ? null : describeWeightEquivalent(v, weightUnit);
|
|
|
}, [totalWeight, weightUnit]);
|
|
|
|
|
|
const dimHint = useMemo(() => {
|
|
|
const l = parseCargoInputNumber(length);
|
|
|
const w = parseCargoInputNumber(width);
|
|
|
const h = parseCargoInputNumber(height);
|
|
|
if (l == null || w == null || h == null) return null;
|
|
|
return describeDimEquivalent(l, w, h, dimUnit);
|
|
|
}, [length, width, height, dimUnit]);
|
|
|
|
|
|
const handleSubmit = (e: FormEvent) => {
|
|
|
e.preventDefault();
|
|
|
setLocalError(null);
|
|
|
|
|
|
if (!pickupDate) {
|
|
|
setLocalError(FLOCK_VALIDATION_MESSAGES.pickupDate);
|
|
|
return;
|
|
|
}
|
|
|
const pickupDateError = flockPickupDateValidationMessageFromIso(pickupDate);
|
|
|
if (pickupDateError) {
|
|
|
setLocalError(pickupDateError);
|
|
|
return;
|
|
|
}
|
|
|
if (!pickupZip.trim()) {
|
|
|
setLocalError(FLOCK_VALIDATION_MESSAGES.pickupZip);
|
|
|
return;
|
|
|
}
|
|
|
if (!deliveryZip.trim()) {
|
|
|
setLocalError(FLOCK_VALIDATION_MESSAGES.deliveryZip);
|
|
|
return;
|
|
|
}
|
|
|
const pallets = Number(palletCount);
|
|
|
if (
|
|
|
!Number.isInteger(pallets) ||
|
|
|
pallets < FLOCK_LIMITS.palletCount.min ||
|
|
|
pallets > FLOCK_LIMITS.palletCount.max
|
|
|
) {
|
|
|
setLocalError(FLOCK_VALIDATION_MESSAGES.palletCount);
|
|
|
return;
|
|
|
}
|
|
|
const weightVal = parseCargoInputNumber(totalWeight);
|
|
|
const l = parseCargoInputNumber(length);
|
|
|
const w = parseCargoInputNumber(width);
|
|
|
const h = parseCargoInputNumber(height);
|
|
|
if (weightVal == null || l == null || w == null || h == null) {
|
|
|
setLocalError("请完整填写重量与尺寸");
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const totalWeightLb = toAxelWholePounds(
|
|
|
weightUnit === "kg" ? kgToLb(weightVal) : weightVal,
|
|
|
);
|
|
|
if (totalWeightLb > FLOCK_LIMITS.totalWeightLbMax) {
|
|
|
setLocalError(FLOCK_VALIDATION_MESSAGES.totalWeight);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const toIn = (v: number) =>
|
|
|
toAxelWholeInches(dimUnit === "cm" ? cmToIn(v) : v);
|
|
|
const lengthIn = toIn(l);
|
|
|
const widthIn = toIn(w);
|
|
|
const heightIn = toIn(h);
|
|
|
if (lengthIn > FLOCK_LIMITS.dimIn.lengthMax) {
|
|
|
setLocalError(FLOCK_VALIDATION_MESSAGES.length);
|
|
|
return;
|
|
|
}
|
|
|
if (widthIn > FLOCK_LIMITS.dimIn.widthMax) {
|
|
|
setLocalError(FLOCK_VALIDATION_MESSAGES.width);
|
|
|
return;
|
|
|
}
|
|
|
if (heightIn > FLOCK_LIMITS.dimIn.heightMax) {
|
|
|
setLocalError(FLOCK_VALIDATION_MESSAGES.height);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
let registration: FlockFormSubmitPayload["registration"];
|
|
|
if (showRegistration) {
|
|
|
const regEmail = workEmail.trim();
|
|
|
const regPhone = phone.replace(/\D/g, "");
|
|
|
if (regEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(regEmail)) {
|
|
|
setLocalError("请填写有效的工作邮箱");
|
|
|
return;
|
|
|
}
|
|
|
if (regPhone && regPhone.slice(-10).length !== 10) {
|
|
|
setLocalError("请填写有效的 10 位美国电话号码");
|
|
|
return;
|
|
|
}
|
|
|
const reg = {
|
|
|
first_name: firstName.trim() || undefined,
|
|
|
last_name: lastName.trim() || undefined,
|
|
|
company: company.trim() || undefined,
|
|
|
email: regEmail || undefined,
|
|
|
phone: regPhone ? regPhone.slice(-10) : undefined,
|
|
|
shipments_per_month: shipmentsPerMonth || undefined,
|
|
|
};
|
|
|
if (
|
|
|
reg.first_name ||
|
|
|
reg.last_name ||
|
|
|
reg.company ||
|
|
|
reg.email ||
|
|
|
reg.phone
|
|
|
) {
|
|
|
registration = reg;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
onSubmit({
|
|
|
pickup_date: toMmDdYyyy(pickupDate),
|
|
|
pickup_zip: pickupZip.trim(),
|
|
|
delivery_zip: deliveryZip.trim(),
|
|
|
pallet_count: pallets,
|
|
|
total_weight: { value: weightVal, unit: weightUnit },
|
|
|
dimensions: {
|
|
|
length: l,
|
|
|
width: w,
|
|
|
height: h,
|
|
|
unit: dimUnit,
|
|
|
},
|
|
|
registration,
|
|
|
});
|
|
|
};
|
|
|
|
|
|
const fieldClass =
|
|
|
"w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-text-primary outline-none focus:border-primary";
|
|
|
const labelClass = "mb-1 block text-sm font-medium text-text-primary";
|
|
|
|
|
|
return (
|
|
|
<form
|
|
|
onSubmit={handleSubmit}
|
|
|
className="rounded-lg border border-border bg-surface p-5 shadow-card"
|
|
|
>
|
|
|
<div className="mb-4">
|
|
|
<h2 className="text-lg font-semibold text-text-primary">
|
|
|
{FLOCK_UI.formTitle}
|
|
|
</h2>
|
|
|
<p className="mt-1 text-sm text-text-secondary">{FLOCK_UI.formHint}</p>
|
|
|
</div>
|
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
|
<div className="sm:col-span-2">
|
|
|
<label className={labelClass} htmlFor="flock-pickup-date">
|
|
|
{FLOCK_UI.pickupDate} *
|
|
|
</label>
|
|
|
<MothershipWeekdayDatePicker
|
|
|
value={pickupDate}
|
|
|
minIso={minFlockPickupDateIso()}
|
|
|
disabled={disabled}
|
|
|
className={fieldClass}
|
|
|
onChange={setPickupDate}
|
|
|
/>
|
|
|
<p className="mt-1 text-xs text-text-secondary">
|
|
|
{FLOCK_UI.pickupDateHint}
|
|
|
</p>
|
|
|
</div>
|
|
|
<div>
|
|
|
<label className={labelClass} htmlFor="flock-pickup-zip">
|
|
|
{FLOCK_UI.pickupZip} *
|
|
|
</label>
|
|
|
<input
|
|
|
id="flock-pickup-zip"
|
|
|
className={fieldClass}
|
|
|
value={pickupZip}
|
|
|
disabled={disabled}
|
|
|
onChange={(e) => setPickupZip(e.target.value)}
|
|
|
placeholder="例如 90001"
|
|
|
inputMode="numeric"
|
|
|
required
|
|
|
/>
|
|
|
</div>
|
|
|
<div>
|
|
|
<label className={labelClass} htmlFor="flock-delivery-zip">
|
|
|
{FLOCK_UI.deliveryZip} *
|
|
|
</label>
|
|
|
<input
|
|
|
id="flock-delivery-zip"
|
|
|
className={fieldClass}
|
|
|
value={deliveryZip}
|
|
|
disabled={disabled}
|
|
|
onChange={(e) => setDeliveryZip(e.target.value)}
|
|
|
placeholder="例如 75201"
|
|
|
inputMode="numeric"
|
|
|
required
|
|
|
/>
|
|
|
</div>
|
|
|
<div>
|
|
|
<label className={labelClass} htmlFor="flock-pallets">
|
|
|
{FLOCK_UI.palletCount} *
|
|
|
</label>
|
|
|
<input
|
|
|
id="flock-pallets"
|
|
|
className={fieldClass}
|
|
|
value={palletCount}
|
|
|
disabled={disabled}
|
|
|
onChange={(e) => setPalletCount(e.target.value)}
|
|
|
inputMode="numeric"
|
|
|
required
|
|
|
/>
|
|
|
<p className="mt-1 text-xs text-text-secondary">
|
|
|
{FLOCK_UI.palletCountHint}
|
|
|
</p>
|
|
|
</div>
|
|
|
<div>
|
|
|
<label className={labelClass} htmlFor="flock-weight">
|
|
|
{FLOCK_UI.totalWeight} *
|
|
|
</label>
|
|
|
<input
|
|
|
id="flock-weight"
|
|
|
className={fieldClass}
|
|
|
value={totalWeight}
|
|
|
disabled={disabled}
|
|
|
onChange={(e) => setTotalWeight(e.target.value)}
|
|
|
inputMode="decimal"
|
|
|
required
|
|
|
/>
|
|
|
<p className="mt-1 text-xs text-text-secondary">
|
|
|
{FLOCK_UI.totalWeightHint}
|
|
|
</p>
|
|
|
{weightHint && (
|
|
|
<p className="mt-1 text-xs text-primary">{weightHint}</p>
|
|
|
)}
|
|
|
</div>
|
|
|
<SelectField
|
|
|
label={FLOCK_UI.weightUnit}
|
|
|
name="flock-weight-unit"
|
|
|
value={weightUnit}
|
|
|
disabled={disabled}
|
|
|
options={[
|
|
|
{ value: "lb", label: FLOCK_UI.weightLb },
|
|
|
{ value: "kg", label: FLOCK_UI.weightKg },
|
|
|
]}
|
|
|
onChange={(e) => setWeightUnit(e.target.value as UnitWeight)}
|
|
|
/>
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-4">
|
|
|
<p className="mb-2 text-sm font-medium text-text-primary">
|
|
|
{FLOCK_UI.dimsTitle}({dimUnit})
|
|
|
</p>
|
|
|
<div className="grid gap-3 sm:grid-cols-3">
|
|
|
<div>
|
|
|
<label className={labelClass} htmlFor="flock-length">
|
|
|
{FLOCK_UI.length}
|
|
|
</label>
|
|
|
<input
|
|
|
id="flock-length"
|
|
|
className={fieldClass}
|
|
|
value={length}
|
|
|
disabled={disabled}
|
|
|
onChange={(e) => setLength(e.target.value)}
|
|
|
inputMode="decimal"
|
|
|
/>
|
|
|
<p className="mt-1 text-xs text-text-secondary">
|
|
|
{FLOCK_VALIDATION_MESSAGES.length}
|
|
|
</p>
|
|
|
</div>
|
|
|
<div>
|
|
|
<label className={labelClass} htmlFor="flock-width">
|
|
|
{FLOCK_UI.width}
|
|
|
</label>
|
|
|
<input
|
|
|
id="flock-width"
|
|
|
className={fieldClass}
|
|
|
value={width}
|
|
|
disabled={disabled}
|
|
|
onChange={(e) => setWidth(e.target.value)}
|
|
|
inputMode="decimal"
|
|
|
/>
|
|
|
<p className="mt-1 text-xs text-text-secondary">
|
|
|
{FLOCK_VALIDATION_MESSAGES.width}
|
|
|
</p>
|
|
|
</div>
|
|
|
<div>
|
|
|
<label className={labelClass} htmlFor="flock-height">
|
|
|
{FLOCK_UI.height}
|
|
|
</label>
|
|
|
<input
|
|
|
id="flock-height"
|
|
|
className={fieldClass}
|
|
|
value={height}
|
|
|
disabled={disabled}
|
|
|
onChange={(e) => setHeight(e.target.value)}
|
|
|
inputMode="decimal"
|
|
|
/>
|
|
|
<p className="mt-1 text-xs text-text-secondary">
|
|
|
{FLOCK_VALIDATION_MESSAGES.height}
|
|
|
</p>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
|
|
<SelectField
|
|
|
label={FLOCK_UI.dimUnit}
|
|
|
name="flock-dim-unit"
|
|
|
value={dimUnit}
|
|
|
disabled={disabled}
|
|
|
options={[
|
|
|
{ value: "in", label: FLOCK_UI.dimIn },
|
|
|
{ value: "cm", label: FLOCK_UI.dimCm },
|
|
|
]}
|
|
|
onChange={(e) => setDimUnit(e.target.value as UnitDim)}
|
|
|
/>
|
|
|
</div>
|
|
|
{dimHint && <p className="mt-2 text-xs text-primary">{dimHint}</p>}
|
|
|
<p className="mt-2 text-xs text-text-secondary">{FLOCK_UI.unitSwitchHint}</p>
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-5 rounded-md border border-border bg-bg/50 p-4">
|
|
|
<button
|
|
|
type="button"
|
|
|
disabled={disabled}
|
|
|
onClick={() => setShowRegistration((v) => !v)}
|
|
|
className="flex w-full items-center justify-between text-left text-sm font-medium text-text-primary"
|
|
|
>
|
|
|
<span>{FLOCK_UI.registrationSectionTitle}</span>
|
|
|
<span className="text-xs text-text-secondary">
|
|
|
{showRegistration
|
|
|
? FLOCK_UI.useSystemRegistration
|
|
|
: FLOCK_UI.useCustomRegistration}
|
|
|
</span>
|
|
|
</button>
|
|
|
{showRegistration && (
|
|
|
<>
|
|
|
<p className="mt-2 text-xs text-text-secondary">
|
|
|
{FLOCK_UI.registrationSectionHint}
|
|
|
</p>
|
|
|
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
|
|
<div>
|
|
|
<label className={labelClass} htmlFor="flock-first-name">
|
|
|
{FLOCK_UI.firstName}
|
|
|
</label>
|
|
|
<input
|
|
|
id="flock-first-name"
|
|
|
className={fieldClass}
|
|
|
value={firstName}
|
|
|
disabled={disabled}
|
|
|
onChange={(e) => setFirstName(e.target.value)}
|
|
|
autoComplete="given-name"
|
|
|
/>
|
|
|
</div>
|
|
|
<div>
|
|
|
<label className={labelClass} htmlFor="flock-last-name">
|
|
|
{FLOCK_UI.lastName}
|
|
|
</label>
|
|
|
<input
|
|
|
id="flock-last-name"
|
|
|
className={fieldClass}
|
|
|
value={lastName}
|
|
|
disabled={disabled}
|
|
|
onChange={(e) => setLastName(e.target.value)}
|
|
|
autoComplete="family-name"
|
|
|
/>
|
|
|
</div>
|
|
|
<div className="sm:col-span-2">
|
|
|
<label className={labelClass} htmlFor="flock-company">
|
|
|
{FLOCK_UI.company}
|
|
|
</label>
|
|
|
<input
|
|
|
id="flock-company"
|
|
|
className={fieldClass}
|
|
|
value={company}
|
|
|
disabled={disabled}
|
|
|
onChange={(e) => setCompany(e.target.value)}
|
|
|
autoComplete="organization"
|
|
|
/>
|
|
|
</div>
|
|
|
<div>
|
|
|
<label className={labelClass} htmlFor="flock-work-email">
|
|
|
{FLOCK_UI.workEmail}
|
|
|
</label>
|
|
|
<input
|
|
|
id="flock-work-email"
|
|
|
type="email"
|
|
|
className={fieldClass}
|
|
|
value={workEmail}
|
|
|
disabled={disabled}
|
|
|
onChange={(e) => setWorkEmail(e.target.value)}
|
|
|
placeholder="例如 name@company.com"
|
|
|
autoComplete="email"
|
|
|
/>
|
|
|
</div>
|
|
|
<div>
|
|
|
<label className={labelClass} htmlFor="flock-phone">
|
|
|
{FLOCK_UI.phone}
|
|
|
</label>
|
|
|
<input
|
|
|
id="flock-phone"
|
|
|
className={fieldClass}
|
|
|
value={phone}
|
|
|
disabled={disabled}
|
|
|
onChange={(e) => setPhone(e.target.value)}
|
|
|
placeholder="例如 7536407420"
|
|
|
inputMode="tel"
|
|
|
autoComplete="tel"
|
|
|
/>
|
|
|
<p className="mt-1 text-xs text-text-secondary">{FLOCK_UI.phoneHint}</p>
|
|
|
</div>
|
|
|
<div className="sm:col-span-2">
|
|
|
<SelectField
|
|
|
label={FLOCK_UI.shipmentsPerMonth}
|
|
|
name="flock-shipments"
|
|
|
value={shipmentsPerMonth}
|
|
|
disabled={disabled}
|
|
|
options={[
|
|
|
{ value: "1-25", label: "1–25 票/月" },
|
|
|
{ value: "26-50", label: "26–50 票/月" },
|
|
|
{ value: "51-100", label: "51–100 票/月" },
|
|
|
{ value: "101+", label: "101+ 票/月" },
|
|
|
]}
|
|
|
onChange={(e) => setShipmentsPerMonth(e.target.value)}
|
|
|
/>
|
|
|
</div>
|
|
|
</div>
|
|
|
</>
|
|
|
)}
|
|
|
</div>
|
|
|
|
|
|
{localError && (
|
|
|
<p className="mt-4 text-sm text-error" role="alert">
|
|
|
{localError}
|
|
|
</p>
|
|
|
)}
|
|
|
|
|
|
<button
|
|
|
type="submit"
|
|
|
disabled={disabled || submitLocked}
|
|
|
className="mt-5 w-full rounded-md bg-primary px-4 py-2.5 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
|
|
|
>
|
|
|
{submitLocked ? FLOCK_UI.submitting : FLOCK_UI.submit}
|
|
|
</button>
|
|
|
</form>
|
|
|
);
|
|
|
}
|