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-quote-sideb...

550 lines
20 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 { useState } from "react";
import { FLOCK_UI } from "@/lib/flock/ui-labels";
import type { FlockDisplayLine } from "@/modules/flock/quote-storage";
import type {
FlockCarrierOption,
FlockCheckoutTier,
FlockFlexibilityKey,
FlockFlexibilityOption,
FlockPricingOptionsType,
} from "@/lib/flock/flock-checkout-selection";
import { flockFlexibilityKeyLabel } from "@/lib/flock/flock-checkout-selection";
import { ErrorBanner } from "@/components/ui/error-banner";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
export type FlockLoggedInQuoteSidebarStep = "select" | "details";
export type FlockFlexibilityByTier = Partial<
Record<FlockCheckoutTier, FlockFlexibilityOption[]>
>;
export type FlockCarriersByTier = Partial<
Record<FlockCheckoutTier, FlockCarrierOption[]>
>;
export interface FlockLoggedInQuoteSidebarProps {
lines: FlockDisplayLine[];
reference?: string | null;
/** 首价同步的两侧灵活性价 */
flexibilityByTier?: FlockFlexibilityByTier;
/** 首价同步的承运商列表(Standard LTL) */
carriersByTier?: FlockCarriersByTier;
selectedTier: FlockCheckoutTier | null;
selectedFlexibility?: FlockFlexibilityKey | null;
selectedCarrier?: string | null;
flexBusy?: boolean;
flexMessage?: string | null;
checkoutBusy?: boolean;
checkoutMessage?: string | null;
/** 已确认继续填写二级(或无 hold)后可结账 */
detailsUnlocked?: boolean;
onSelectTier: (tier: FlockCheckoutTier) => void;
onSelectFlexibility: (key: FlockFlexibilityKey) => void;
onSelectCarrier: (carrierName: string) => void;
onCheckout: () => void;
onReset?: () => void;
}
function formatUsd(n: number): string {
return n.toLocaleString("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
}
function formatUsdExact(n: number): string {
return n.toLocaleString("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
function FeatureRow({
ok,
text,
}: {
ok: boolean;
text: string;
}) {
return (
<li className="flex gap-2 text-sm leading-snug text-text-primary">
<span
className={
"mt-0.5 shrink-0 text-base font-bold " +
(ok ? "text-emerald-600" : "text-text-disabled")
}
aria-hidden
>
{ok ? "✓" : "×"}
</span>
<span className={ok ? "" : "text-text-secondary"}>{text}</span>
</li>
);
}
function PricingOptionsModal({
tier,
reference,
optionsType,
options,
carrierOptions,
selectedFlexibility,
selectedCarrier,
busy,
message,
onClose,
onSelectFlexibility,
onSelectCarrier,
}: {
tier: FlockCheckoutTier;
reference?: string | null;
optionsType: FlockPricingOptionsType;
options: FlockFlexibilityOption[];
carrierOptions: FlockCarrierOption[];
selectedFlexibility?: FlockFlexibilityKey | null;
selectedCarrier?: string | null;
busy?: boolean;
message?: string | null;
onClose: () => void;
onSelectFlexibility: (key: FlockFlexibilityKey) => void;
onSelectCarrier: (carrierName: string) => void;
}) {
const tierName =
tier === "flock_direct" ? FLOCK_UI.directName : FLOCK_UI.standardName;
const isCarriers = optionsType === "carriers";
const empty =
!busy &&
(isCarriers ? carrierOptions.length === 0 : options.length === 0);
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/45 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="flock-pricing-options-title"
data-testid="flock-pricing-options-modal"
>
<div className="max-h-[90vh] w-full max-w-3xl overflow-y-auto rounded-xl border border-border bg-white shadow-xl">
<div className="border-b border-border px-5 py-4">
<h2
id="flock-pricing-options-title"
className="text-base font-semibold text-text-primary"
>
{reference
? `#${reference} ${FLOCK_UI.pricingOptionsHeading}`
: FLOCK_UI.pricingOptionsHeading}
</h2>
<button
type="button"
onClick={onClose}
className="mt-3 text-sm font-medium text-text-primary underline-offset-2 hover:underline"
data-testid="flock-pricing-go-back"
>
← {FLOCK_UI.goBackToShipping}
</button>
</div>
<div className="space-y-4 px-5 py-5">
<h3 className="text-lg font-semibold text-text-primary">
{isCarriers
? FLOCK_UI.selectPreferredCarrier
: FLOCK_UI.isPickupFlexible}
</h3>
{!isCarriers ? (
<div className="rounded-md bg-[#1a1a1a] px-4 py-2.5 text-sm font-semibold text-white">
{tierName}
</div>
) : null}
{busy ? (
<div className="flex items-center gap-2 rounded-md border border-border bg-surface px-3 py-4 text-sm text-text-secondary">
<LoadingSpinner />
{message ||
(isCarriers
? "正在加载承运商报价…"
: "正在加载灵活性报价…")}
</div>
) : null}
{empty ? (
<p className="text-sm text-error">
{message ||
(isCarriers
? "暂无承运商报价,请稍后重试"
: "暂无该档灵活性报价,请稍后重试")}
</p>
) : null}
{!busy && !isCarriers && options.length > 0 ? (
<div className="overflow-hidden rounded-lg border border-border">
<div className="grid grid-cols-[1.3fr_1.2fr_1fr_auto] gap-2 border-b border-border bg-surface px-3 py-2 text-[11px] font-medium uppercase tracking-wide text-text-secondary">
<span>{FLOCK_UI.flexColFlexibility}</span>
<span>{FLOCK_UI.flexColPickup}</span>
<span>{FLOCK_UI.flexColDeliver}</span>
<span className="text-right">{FLOCK_UI.flexColRate}</span>
</div>
<ul className="divide-y divide-border">
{options.map((opt) => {
const active = selectedFlexibility === opt.key;
return (
<li key={opt.key}>
<button
type="button"
data-testid={`flock-flex-option-${opt.key}`}
onClick={() => onSelectFlexibility(opt.key)}
className={
"grid w-full grid-cols-[1.3fr_1.2fr_1fr_auto] items-center gap-2 px-3 py-3.5 text-left text-sm transition " +
(active
? "bg-emerald-50 ring-1 ring-inset ring-emerald-400"
: "hover:bg-surface")
}
>
<span className="flex items-center gap-2 font-medium text-text-primary">
<span
className={
"inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border " +
(active
? "border-emerald-600 bg-emerald-600"
: "border-border bg-white")
}
aria-hidden
>
{active ? (
<span className="h-1.5 w-1.5 rounded-full bg-white" />
) : null}
</span>
{flockFlexibilityKeyLabel(opt.key)}
</span>
<span className="text-text-secondary">
{opt.pickupWindow || "—"}
</span>
<span className="text-text-secondary">
{opt.deliverBy || "—"}
</span>
<span className="justify-self-end font-bold tabular-nums text-text-primary">
{formatUsdExact(opt.rateUsd)}
</span>
</button>
</li>
);
})}
</ul>
</div>
) : null}
{!busy && isCarriers && carrierOptions.length > 0 ? (
<div className="overflow-hidden rounded-lg border border-border">
<div className="grid grid-cols-[1.6fr_1fr_1fr_1fr_auto_auto] gap-2 border-b border-border bg-surface px-3 py-2 text-[11px] font-medium uppercase tracking-wide text-text-secondary">
<span>{FLOCK_UI.carrierColName}</span>
<span>{FLOCK_UI.carrierColTransit}</span>
<span>{FLOCK_UI.carrierColPickup}</span>
<span>{FLOCK_UI.carrierColDeliver}</span>
<span className="text-right">{FLOCK_UI.carrierColRate}</span>
<span className="sr-only">{FLOCK_UI.carrierSelect}</span>
</div>
<ul className="divide-y divide-border" data-testid="flock-carrier-list">
{carrierOptions.map((opt) => {
const active = selectedCarrier === opt.carrierName;
return (
<li key={`${opt.carrierName}-${opt.rateUsd}`}>
<button
type="button"
data-testid={`flock-carrier-option-${opt.carrierName}`}
onClick={() => onSelectCarrier(opt.carrierName)}
className={
"grid w-full grid-cols-[1.6fr_1fr_1fr_1fr_auto_auto] items-center gap-2 px-3 py-3.5 text-left text-sm transition " +
(active
? "bg-emerald-50 ring-1 ring-inset ring-emerald-400"
: "hover:bg-surface")
}
>
<span className="font-medium text-text-primary">
{opt.carrierName}
</span>
<span className="text-text-secondary">
{opt.transitDays || "—"}
</span>
<span className="text-text-secondary">
{opt.pickupDate || "—"}
</span>
<span className="text-text-secondary">
{opt.deliveryEstimate || "—"}
</span>
<span className="justify-self-end font-bold tabular-nums text-text-primary">
{formatUsdExact(opt.rateUsd)}
</span>
<span className="justify-self-end text-xs font-semibold text-text-primary">
{FLOCK_UI.carrierSelect} →
</span>
</button>
</li>
);
})}
</ul>
</div>
) : null}
</div>
</div>
</div>
);
}
/** 右栏:对齐官网 — 双卡「查看报价选项」→ 弹窗选灵活价或承运商 */
export function FlockLoggedInQuoteSidebar({
lines,
reference,
flexibilityByTier = {},
carriersByTier = {},
selectedTier,
selectedFlexibility = null,
selectedCarrier = null,
flexBusy = false,
flexMessage = null,
checkoutBusy = false,
checkoutMessage = null,
detailsUnlocked = false,
onSelectTier,
onSelectFlexibility,
onSelectCarrier,
onCheckout,
onReset,
}: FlockLoggedInQuoteSidebarProps) {
const [pricingModalTier, setPricingModalTier] =
useState<FlockCheckoutTier | null>(null);
const direct = lines.find((l) => l.tier === "flock_direct");
const standard = lines.find((l) => l.tier === "standard");
const selectedFlexOptions =
(selectedTier && flexibilityByTier[selectedTier]) || [];
const selectedCarrierOptions =
(selectedTier && carriersByTier[selectedTier]) || [];
const selectedFlex = selectedFlexOptions.find(
(o) => o.key === selectedFlexibility,
);
const selectedCarrierOpt = selectedCarrierOptions.find(
(o) => o.carrierName === selectedCarrier,
);
const checkoutAmount =
selectedFlex?.rateUsd ??
selectedCarrierOpt?.rateUsd ??
(selectedTier === "flock_direct"
? direct?.final_total_usd
: selectedTier === "standard"
? standard?.final_total_usd
: undefined);
const hasSelection = Boolean(selectedFlexibility || selectedCarrier);
const canCheckout =
detailsUnlocked && !!selectedTier && hasSelection && !checkoutBusy;
const openPricing = (tier: FlockCheckoutTier) => {
onSelectTier(tier);
setPricingModalTier(tier);
};
const modalFlexOptions =
pricingModalTier != null
? (flexibilityByTier[pricingModalTier] ?? [])
: [];
const modalCarrierOptions =
pricingModalTier != null
? (carriersByTier[pricingModalTier] ?? [])
: [];
const modalOptionsType: FlockPricingOptionsType =
modalCarrierOptions.length > 0 && modalFlexOptions.length === 0
? "carriers"
: "flexibility";
return (
<aside className="space-y-4">
<div className="flex flex-wrap items-end justify-between gap-2">
<div>
<h2 className="text-lg font-semibold text-text-primary">
{reference
? `#${reference} ${FLOCK_UI.pricingOptionsHeading}`
: FLOCK_UI.resultTitle}
</h2>
<p className="mt-1 text-xs text-text-secondary">
{FLOCK_UI.selectShippingBelow}
</p>
</div>
{onReset ? (
<button
type="button"
onClick={onReset}
className="rounded-md border border-border bg-surface px-3 py-1.5 text-sm text-text-primary"
>
{FLOCK_UI.buildAnother}
</button>
) : null}
</div>
<div className="grid gap-3 sm:grid-cols-2">
{direct ? (
<article
className="flex flex-col overflow-hidden rounded-xl border border-border bg-white shadow-card"
data-testid="flock-tier-card-flock_direct"
>
<div className="bg-[#1a1a1a] px-4 py-3">
<h3 className="text-base font-semibold text-white">
{FLOCK_UI.directName}
</h3>
</div>
<div className="flex flex-1 flex-col gap-3 p-4">
<p className="text-sm text-text-secondary">
{FLOCK_UI.pricesFrom}{" "}
<span className="text-xl font-bold text-text-primary">
{formatUsd(direct.final_total_usd)}
</span>
</p>
<p className="text-sm text-text-secondary">
{FLOCK_UI.transit}
<span className="ml-1 font-semibold text-text-primary">
{direct.transitDescription || direct.transitDays}
</span>
</p>
<ul className="space-y-2">
{FLOCK_UI.directFeatures.map((t) => (
<FeatureRow key={t} ok text={t} />
))}
</ul>
<div className="rounded-md border border-sky-200 bg-sky-50 px-3 py-2 text-xs leading-relaxed text-sky-950">
{FLOCK_UI.directCallout}
</div>
<button
type="button"
data-testid="flock-see-pricing-flock_direct"
onClick={() => openPricing("flock_direct")}
className="mt-auto w-full rounded-full bg-[#1a1a1a] px-4 py-2.5 text-sm font-semibold text-white hover:bg-black"
>
{FLOCK_UI.seePricingOptions}
</button>
</div>
</article>
) : null}
{standard ? (
<article
className="flex flex-col overflow-hidden rounded-xl border border-border bg-white shadow-card"
data-testid="flock-tier-card-standard"
>
<div className="bg-[#e8e8e8] px-4 py-3">
<h3 className="text-base font-semibold text-text-primary">
{FLOCK_UI.standardName}
</h3>
</div>
<div className="flex flex-1 flex-col gap-3 p-4">
<p className="text-sm text-text-secondary">
{FLOCK_UI.pricesFrom}{" "}
<span className="text-xl font-bold text-text-primary">
{formatUsd(standard.final_total_usd)}
</span>
</p>
<p className="text-sm text-text-secondary">
{FLOCK_UI.transit}
<span className="ml-1 font-semibold text-text-primary">
{standard.transitDescription || standard.transitDays}
</span>
</p>
<ul className="space-y-2">
{FLOCK_UI.standardFeatures.map((t) => (
<FeatureRow key={t} ok={false} text={t} />
))}
</ul>
<p className="text-xs text-text-secondary">{FLOCK_UI.standardNote}</p>
<button
type="button"
data-testid="flock-see-pricing-standard"
onClick={() => openPricing("standard")}
className="mt-auto w-full rounded-full border-2 border-[#1a1a1a] bg-white px-4 py-2.5 text-sm font-semibold text-text-primary hover:bg-surface"
>
{FLOCK_UI.seePricingOptions}
</button>
</div>
</article>
) : null}
</div>
<div className="space-y-3 border-t border-border pt-4">
{selectedFlex ? (
<p className="text-center text-xs text-success">
{FLOCK_UI.checkoutSelected}:
{selectedTier === "flock_direct"
? FLOCK_UI.directName
: FLOCK_UI.standardName}{" "}
· {flockFlexibilityKeyLabel(selectedFlex.key)} ·{" "}
{formatUsdExact(selectedFlex.rateUsd)}
</p>
) : null}
{selectedCarrierOpt ? (
<p className="text-center text-xs text-success">
{FLOCK_UI.checkoutSelected}:
{selectedTier === "flock_direct"
? FLOCK_UI.directName
: FLOCK_UI.standardName}{" "}
· {selectedCarrierOpt.carrierName} ·{" "}
{formatUsdExact(selectedCarrierOpt.rateUsd)}
</p>
) : null}
{!detailsUnlocked ? (
<p className="text-center text-xs text-text-secondary">
{FLOCK_UI.holdThenPickFlex}
</p>
) : !hasSelection ? (
<p className="text-center text-xs text-text-secondary">
{FLOCK_UI.pickFlexThenDetails}
</p>
) : null}
{checkoutMessage ? <ErrorBanner>{checkoutMessage}</ErrorBanner> : null}
<button
type="button"
disabled={!canCheckout}
onClick={onCheckout}
className="flex h-12 w-full items-center justify-center rounded-md bg-success px-4 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{checkoutBusy
? FLOCK_UI.checkoutSyncing
: checkoutAmount != null
? `${FLOCK_UI.checkoutCta} — ${formatUsd(checkoutAmount)}`
: FLOCK_UI.checkoutCta}
</button>
<p className="text-center text-[11px] text-text-disabled">
{FLOCK_UI.checkoutFootnote}
</p>
</div>
{pricingModalTier ? (
<PricingOptionsModal
tier={pricingModalTier}
reference={reference}
optionsType={modalOptionsType}
options={modalFlexOptions}
carrierOptions={modalCarrierOptions}
selectedFlexibility={
selectedTier === pricingModalTier ? selectedFlexibility : null
}
selectedCarrier={
selectedTier === pricingModalTier ? selectedCarrier : null
}
busy={flexBusy && selectedTier === pricingModalTier}
message={flexMessage}
onClose={() => setPricingModalTier(null)}
onSelectFlexibility={(key) => {
onSelectFlexibility(key);
setPricingModalTier(null);
}}
onSelectCarrier={(name) => {
onSelectCarrier(name);
setPricingModalTier(null);
}}
/>
) : null}
</aside>
);
}