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.

528 lines
17 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.

"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: "125 票/月" },
{ value: "26-50", label: "2650 票/月" },
{ value: "51-100", label: "51100 票/月" },
{ 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>
);
}