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/components/flock/flock-logged-in-details-for...

604 lines
19 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 { useImperativeHandle, forwardRef, useState, type ReactNode } from "react";
import {
FLOCK_CHECKOUT_FIELD_HINTS,
FLOCK_CHECKOUT_HOURS,
FLOCK_CHECKOUT_ROLE_OPTIONS,
emptyFlockCheckoutDetailsDraft,
flockDetailsHasErrors,
toFlockCheckoutApiDetails,
validateFlockCheckoutDetailsDraft,
type FlockCheckoutDetailsDraft,
type FlockCheckoutLocationDraft,
type FlockCheckoutRole,
type FlockDetailsFieldErrors,
} from "@/lib/flock/flock-checkout-details-rules";
import { ErrorBanner } from "@/components/ui/error-banner";
export type FlockLoggedInDetailsFormHandle = {
validateAndGetDetails: () => ReturnType<
typeof toFlockCheckoutApiDetails
> | null;
};
type Props = {
disabled?: boolean;
pickupZip?: string;
deliveryZip?: string;
/** 首价货件摘要(只读),对齐官网 Shipment details 货件行 */
shipmentSummary?: string;
};
const inputCls =
"w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-text-primary outline-none focus:border-primary disabled:opacity-60";
const inputErrCls =
"w-full rounded-md border border-error bg-surface px-3 py-2 text-sm text-text-primary outline-none focus:border-error disabled:opacity-60";
function Field({
label,
required,
error,
hint,
children,
}: {
label: string;
required?: boolean;
error?: string;
hint?: string;
children: ReactNode;
}) {
return (
<label className="block space-y-1">
<span className="text-xs font-medium text-text-secondary">
{label}
{required ? <span className="text-error"> *</span> : null}
</span>
{children}
{error ? <p className="text-[11px] text-error">{error}</p> : null}
{!error && hint ? (
<p className="text-[11px] text-text-disabled">{hint}</p>
) : null}
</label>
);
}
function LocationAddressFields({
side,
loc,
errors,
disabled,
addressLocked,
onPatch,
}: {
side: "pickup" | "delivery";
loc: FlockCheckoutLocationDraft;
errors?: Partial<Record<keyof FlockCheckoutLocationDraft, string>>;
disabled?: boolean;
addressLocked: boolean;
onPatch: (patch: Partial<FlockCheckoutLocationDraft>) => void;
}) {
const locked = Boolean(disabled || addressLocked);
return (
<>
<Field
label="地址 1"
required={!addressLocked}
error={errors?.address1}
hint={
addressLocked
? FLOCK_CHECKOUT_FIELD_HINTS.useBilling
: FLOCK_CHECKOUT_FIELD_HINTS.addressMismatch
}
>
<input
disabled={locked}
className={errors?.address1 ? inputErrCls : inputCls}
value={loc.address1}
onChange={(e) => onPatch({ address1: e.target.value })}
/>
</Field>
<Field label="地址 2">
<input
disabled={locked}
className={inputCls}
value={loc.address2}
onChange={(e) => onPatch({ address2: e.target.value })}
/>
</Field>
<Field label="城市" required={!addressLocked} error={errors?.city}>
<input
disabled={locked}
className={errors?.city ? inputErrCls : inputCls}
value={loc.city}
onChange={(e) => onPatch({ city: e.target.value })}
/>
</Field>
<Field label="州" required={!addressLocked} error={errors?.state}>
<input
disabled={locked}
className={errors?.state ? inputErrCls : inputCls}
value={loc.state}
onChange={(e) => onPatch({ state: e.target.value })}
/>
</Field>
<Field label="邮编" required={!addressLocked} error={errors?.zip}>
<input
disabled={locked}
className={errors?.zip ? inputErrCls : inputCls}
value={loc.zip}
onChange={(e) => onPatch({ zip: e.target.value })}
/>
</Field>
</>
);
}
/** 二级结账详情:对齐官网 Pickup / Delivery / Shipment details */
export const FlockLoggedInDetailsForm = forwardRef<
FlockLoggedInDetailsFormHandle,
Props
>(function FlockLoggedInDetailsForm(
{
disabled = false,
pickupZip = "",
deliveryZip = "",
shipmentSummary = "",
},
ref,
) {
const [draft, setDraft] = useState<FlockCheckoutDetailsDraft>(() =>
emptyFlockCheckoutDetailsDraft({ pickupZip, deliveryZip }),
);
const [errors, setErrors] = useState<FlockDetailsFieldErrors>({});
const [formError, setFormError] = useState<string | null>(null);
useImperativeHandle(ref, () => ({
validateAndGetDetails: () => {
const next = validateFlockCheckoutDetailsDraft(draft);
setErrors(next);
if (flockDetailsHasErrors(next)) {
setFormError("请先修正结账详情中的必填项与格式错误");
return null;
}
setFormError(null);
return toFlockCheckoutApiDetails(draft);
},
}));
const setRole = (role: FlockCheckoutRole) => {
setDraft((d) => ({ ...d, role }));
setErrors((e) => ({ ...e, role: undefined }));
};
const patchPickup = (patch: Partial<FlockCheckoutLocationDraft>) => {
setDraft((d) => ({ ...d, pickup: { ...d.pickup, ...patch } }));
};
const patchDelivery = (patch: Partial<FlockCheckoutLocationDraft>) => {
setDraft((d) => ({ ...d, delivery: { ...d.delivery, ...patch } }));
};
return (
<div className="space-y-5">
<div>
<h2 className="text-lg font-semibold text-text-primary">结账详情</h2>
<p className="mt-1 text-xs text-text-secondary">
{FLOCK_CHECKOUT_FIELD_HINTS.review}
</p>
</div>
{formError ? <ErrorBanner>{formError}</ErrorBanner> : null}
<section className="space-y-3 rounded-lg border border-border bg-surface p-4">
<h3 className="text-sm font-semibold text-text-primary">您的角色</h3>
<div className="flex flex-wrap gap-2">
{FLOCK_CHECKOUT_ROLE_OPTIONS.map((opt) => (
<button
key={opt.id}
type="button"
disabled={disabled}
onClick={() => setRole(opt.id)}
className={
"rounded-md border px-3 py-1.5 text-sm " +
(draft.role === opt.id
? "border-primary bg-primary/10 font-medium text-primary"
: "border-border text-text-secondary hover:border-primary/40")
}
>
{opt.label}
</button>
))}
</div>
{errors.role ? (
<p className="text-[11px] text-error">{errors.role}</p>
) : null}
</section>
<section className="space-y-3 rounded-lg border border-border bg-surface p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-text-primary">提货地点</h3>
<label className="flex items-center gap-2 text-xs text-text-secondary">
<input
type="checkbox"
disabled={disabled}
checked={draft.use_billing_for_pickup}
onChange={(e) =>
setDraft((d) => ({
...d,
use_billing_for_pickup: e.target.checked,
}))
}
/>
使用账单地址
</label>
</div>
<p className="text-[11px] text-text-disabled">
{FLOCK_CHECKOUT_FIELD_HINTS.useBilling}
</p>
<div className="grid gap-3 md:grid-cols-2">
<Field
label="公司名称"
required
error={errors.pickup?.company_name}
hint={FLOCK_CHECKOUT_FIELD_HINTS.addressSync}
>
<input
disabled={disabled}
className={
errors.pickup?.company_name ? inputErrCls : inputCls
}
value={draft.pickup.company_name}
onChange={(e) => patchPickup({ company_name: e.target.value })}
/>
</Field>
<LocationAddressFields
side="pickup"
loc={draft.pickup}
errors={errors.pickup}
disabled={disabled}
addressLocked={draft.use_billing_for_pickup}
onPatch={patchPickup}
/>
<Field
label="联系人"
required
error={errors.pickup?.contact_name}
hint={FLOCK_CHECKOUT_FIELD_HINTS.contactHelper}
>
<input
disabled={disabled}
className={
errors.pickup?.contact_name ? inputErrCls : inputCls
}
value={draft.pickup.contact_name}
onChange={(e) => patchPickup({ contact_name: e.target.value })}
/>
</Field>
<Field
label="电话"
required
error={errors.pickup?.contact_phone}
hint={FLOCK_CHECKOUT_FIELD_HINTS.phone}
>
<input
disabled={disabled}
className={
errors.pickup?.contact_phone ? inputErrCls : inputCls
}
value={draft.pickup.contact_phone}
onChange={(e) => patchPickup({ contact_phone: e.target.value })}
placeholder="(626) 595-1180"
/>
</Field>
<Field
label="邮箱"
required
error={errors.pickup?.contact_email}
hint={FLOCK_CHECKOUT_FIELD_HINTS.email}
>
<input
disabled={disabled}
className={
errors.pickup?.contact_email ? inputErrCls : inputCls
}
value={draft.pickup.contact_email}
onChange={(e) => patchPickup({ contact_email: e.target.value })}
/>
</Field>
<Field
label="开门时间"
required
error={errors.pickup?.opens_at}
hint={FLOCK_CHECKOUT_FIELD_HINTS.hours}
>
<select
disabled={disabled}
className={errors.pickup?.opens_at ? inputErrCls : inputCls}
value={draft.pickup.opens_at}
onChange={(e) => patchPickup({ opens_at: e.target.value })}
>
{FLOCK_CHECKOUT_HOURS.map((h) => (
<option key={h} value={h}>
{h}
</option>
))}
</select>
</Field>
<Field label="关门时间" required error={errors.pickup?.closes_at}>
<select
disabled={disabled}
className={errors.pickup?.closes_at ? inputErrCls : inputCls}
value={draft.pickup.closes_at}
onChange={(e) => patchPickup({ closes_at: e.target.value })}
>
{FLOCK_CHECKOUT_HOURS.map((h) => (
<option key={h} value={h}>
{h}
</option>
))}
</select>
</Field>
</div>
</section>
<section className="space-y-3 rounded-lg border border-border bg-surface p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-text-primary">派送地点</h3>
<label className="flex items-center gap-2 text-xs text-text-secondary">
<input
type="checkbox"
disabled={disabled}
checked={draft.use_billing_for_delivery}
onChange={(e) =>
setDraft((d) => ({
...d,
use_billing_for_delivery: e.target.checked,
}))
}
/>
使用账单地址
</label>
</div>
<p className="text-[11px] text-text-disabled">
{FLOCK_CHECKOUT_FIELD_HINTS.useBilling}
</p>
<div className="grid gap-3 md:grid-cols-2">
<Field
label="公司名称"
required
error={errors.delivery?.company_name}
hint={FLOCK_CHECKOUT_FIELD_HINTS.addressSync}
>
<input
disabled={disabled}
className={
errors.delivery?.company_name ? inputErrCls : inputCls
}
value={draft.delivery.company_name}
onChange={(e) =>
patchDelivery({ company_name: e.target.value })
}
/>
</Field>
<LocationAddressFields
side="delivery"
loc={draft.delivery}
errors={errors.delivery}
disabled={disabled}
addressLocked={draft.use_billing_for_delivery}
onPatch={patchDelivery}
/>
<Field
label="联系人"
required
error={errors.delivery?.contact_name}
>
<input
disabled={disabled}
className={
errors.delivery?.contact_name ? inputErrCls : inputCls
}
value={draft.delivery.contact_name}
onChange={(e) =>
patchDelivery({ contact_name: e.target.value })
}
/>
</Field>
<Field
label="电话"
required
error={errors.delivery?.contact_phone}
hint={FLOCK_CHECKOUT_FIELD_HINTS.phone}
>
<input
disabled={disabled}
className={
errors.delivery?.contact_phone ? inputErrCls : inputCls
}
value={draft.delivery.contact_phone}
onChange={(e) =>
patchDelivery({ contact_phone: e.target.value })
}
placeholder="(415) 621-8840"
/>
</Field>
<Field
label="邮箱"
required
error={errors.delivery?.contact_email}
>
<input
disabled={disabled}
className={
errors.delivery?.contact_email ? inputErrCls : inputCls
}
value={draft.delivery.contact_email}
onChange={(e) =>
patchDelivery({ contact_email: e.target.value })
}
/>
</Field>
<Field label="开门时间" required error={errors.delivery?.opens_at}>
<select
disabled={disabled}
className={
errors.delivery?.opens_at ? inputErrCls : inputCls
}
value={draft.delivery.opens_at}
onChange={(e) => patchDelivery({ opens_at: e.target.value })}
>
{FLOCK_CHECKOUT_HOURS.map((h) => (
<option key={h} value={h}>
{h}
</option>
))}
</select>
</Field>
<Field
label="关门时间"
required
error={errors.delivery?.closes_at}
>
<select
disabled={disabled}
className={
errors.delivery?.closes_at ? inputErrCls : inputCls
}
value={draft.delivery.closes_at}
onChange={(e) => patchDelivery({ closes_at: e.target.value })}
>
{FLOCK_CHECKOUT_HOURS.map((h) => (
<option key={h} value={h}>
{h}
</option>
))}
</select>
</Field>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-text-secondary">
该地点是否允许周末派送?
</p>
<div className="flex gap-2">
{([true, false] as const).map((yes) => (
<button
key={String(yes)}
type="button"
disabled={disabled}
onClick={() => patchDelivery({ weekend_delivery: yes })}
className={
"rounded-md border px-3 py-1.5 text-sm " +
(draft.delivery.weekend_delivery === yes
? "border-primary bg-primary/10 font-medium text-primary"
: "border-border text-text-secondary")
}
>
{yes ? "是" : "否"}
</button>
))}
</div>
<p className="text-[11px] text-text-disabled">
{FLOCK_CHECKOUT_FIELD_HINTS.weekend}
</p>
</div>
</section>
<section className="space-y-3 rounded-lg border border-border bg-surface p-4">
<h3 className="text-sm font-semibold text-text-primary">运输详情</h3>
{shipmentSummary ? (
<div className="rounded-md border border-border bg-bg px-3 py-2 text-sm text-text-primary">
<p className="text-xs font-medium text-text-secondary">货件摘要</p>
<p className="mt-1">{shipmentSummary}</p>
<p className="mt-1 text-[11px] text-text-disabled">
{FLOCK_CHECKOUT_FIELD_HINTS.shipmentItems}
</p>
</div>
) : null}
<div className="grid gap-3 md:grid-cols-2">
<Field label="NMFC" hint={FLOCK_CHECKOUT_FIELD_HINTS.nmfc}>
<input
disabled={disabled}
className={inputCls}
value={draft.nmfc}
onChange={(e) =>
setDraft((d) => ({ ...d, nmfc: e.target.value }))
}
placeholder="如 100240-01"
/>
</Field>
<Field label="PO 单号" hint={FLOCK_CHECKOUT_FIELD_HINTS.po}>
<input
disabled={disabled}
className={inputCls}
value={draft.po_number}
onChange={(e) =>
setDraft((d) => ({ ...d, po_number: e.target.value }))
}
/>
</Field>
</div>
<Field
label="宣示声明"
hint={FLOCK_CHECKOUT_FIELD_HINTS.declaration}
>
<textarea
disabled={disabled}
rows={2}
className={inputCls}
value={draft.declaration_statement}
onChange={(e) =>
setDraft((d) => ({
...d,
declaration_statement: e.target.value,
}))
}
placeholder="联系人确认、特殊要求等"
/>
</Field>
<Field label="BOL 备注" hint={FLOCK_CHECKOUT_FIELD_HINTS.bol}>
<input
disabled={disabled}
className={inputCls}
value={draft.bol_remarks}
onChange={(e) =>
setDraft((d) => ({ ...d, bol_remarks: e.target.value }))
}
/>
</Field>
<Field label="Flock 备注" hint={FLOCK_CHECKOUT_FIELD_HINTS.notes}>
<textarea
disabled={disabled}
rows={3}
className={inputCls}
value={draft.notes}
onChange={(e) =>
setDraft((d) => ({ ...d, notes: e.target.value }))
}
/>
</Field>
<label className="flex items-start gap-2 text-sm text-text-primary">
<input
type="checkbox"
disabled={disabled}
className="mt-0.5"
checked={draft.documentation_required}
onChange={(e) =>
setDraft((d) => ({
...d,
documentation_required: e.target.checked,
}))
}
/>
<span>
提货需要单据
<span className="mt-0.5 block text-[11px] text-text-disabled">
{FLOCK_CHECKOUT_FIELD_HINTS.documentation}
</span>
</span>
</label>
</section>
</div>
);
});