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.

185 lines
5.5 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 { useEffect, useMemo, useState } from "react";
import { CheckCircle, Database, ClockClockwise } from "@phosphor-icons/react";
import type {
QuoteDetail,
QuoteItem,
RateOption,
ServiceLevel,
} from "@/lib/frontend/types";
import { formatUSD, formatPercent } from "@/lib/frontend/format";
import { Card } from "@/components/ui/card";
const LEVEL_LABEL: Record<ServiceLevel, string> = {
standard: "标准",
guaranteed: "保证送达",
};
const RATE_LABEL: Record<RateOption, string> = {
lowest: "最低价",
fastest: "最快",
bestValue: "最优性价比",
};
const RATE_ORDER: RateOption[] = ["lowest", "bestValue", "fastest"];
interface QuoteCardProps {
quote: QuoteDetail;
defaultLevel?: ServiceLevel;
defaultRate?: RateOption;
}
function findTier(
quotes: QuoteItem[],
level: ServiceLevel,
rate: RateOption,
): QuoteItem | undefined {
return quotes.find(
(q) => q.service_level === level && q.rate_option === rate,
);
}
function SourceBadge({ quote }: { quote: QuoteDetail }) {
if (quote.source_type === "cache") {
return (
<span className="inline-flex items-center gap-1 rounded-sm bg-primary/10 px-2 py-0.5 text-xs text-primary">
<Database size={12} weight="fill" />
</span>
);
}
if (quote.source_type === "stale") {
return (
<span className="inline-flex items-center gap-1 rounded-sm bg-amber-100 px-2 py-0.5 text-xs text-warning">
<ClockClockwise size={12} weight="fill" />
</span>
);
}
return (
<span className="inline-flex items-center gap-1 rounded-sm bg-green-100 px-2 py-0.5 text-xs text-success">
<CheckCircle size={12} weight="fill" />
</span>
);
}
export function QuoteCard({
quote,
defaultLevel = "standard",
defaultRate = "lowest",
}: QuoteCardProps) {
const [level, setLevel] = useState<ServiceLevel>(defaultLevel);
const [rate, setRate] = useState<RateOption>(defaultRate);
const quotes = quote.quotes ?? [];
const item = findTier(quotes, level, rate) ?? quotes[0];
const availableRates = useMemo(
() =>
RATE_ORDER.filter((rt) =>
quotes.some((q) => q.service_level === level && q.rate_option === rt),
),
[quotes, level],
);
useEffect(() => {
if (availableRates.length > 0 && !availableRates.includes(rate)) {
setRate(availableRates[0]!);
}
}, [availableRates, rate]);
if (!item) {
return (
<Card>
<p className="text-sm text-text-secondary"></p>
</Card>
);
}
return (
<Card className="relative">
{!quote.is_realtime && (
<span className="absolute right-4 top-4 rounded-sm bg-amber-100 px-2 py-0.5 text-xs text-warning">
</span>
)}
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex gap-1 rounded-md bg-bg p-1">
{(["standard", "guaranteed"] as ServiceLevel[]).map((lv) => (
<button
key={lv}
type="button"
onClick={() => setLevel(lv)}
className={`rounded-sm px-3 py-1.5 text-sm font-medium transition-colors ${
level === lv
? "bg-surface text-primary shadow-card"
: "text-text-secondary"
}`}
>
{LEVEL_LABEL[lv]}
</button>
))}
</div>
<SourceBadge quote={quote} />
</div>
<div className="mb-3 flex gap-1 rounded-md bg-bg p-1">
{availableRates.map((rt) => (
<button
key={rt}
type="button"
onClick={() => setRate(rt)}
className={`rounded-sm px-3 py-1.5 text-sm font-medium transition-colors ${
rate === rt
? "bg-surface text-primary shadow-card"
: "text-text-secondary"
}`}
>
{RATE_LABEL[rt]}
</button>
))}
</div>
<div className="flex items-end justify-between gap-4">
<div>
<p className="text-sm text-text-secondary">
{item.carrier} · {item.transit_description}
</p>
<p className="mt-1 font-mono text-3xl font-semibold text-text-primary num">
{formatUSD(item.final_total)}
</p>
</div>
<div className="text-right text-sm text-text-secondary">
<p> {item.transit_days} </p>
<p className="num"> {formatUSD(item.raw_total)}</p>
</div>
</div>
<div className="mt-5 border-t border-border pt-4">
<p className="mb-2 text-sm font-medium text-text-secondary"></p>
<ul className="space-y-1.5 text-sm">
{item.breakdown.map((b) => (
<li key={b.item} className="flex justify-between">
<span className="text-text-secondary">{b.item}</span>
<span className="num font-mono text-text-primary">
{formatUSD(b.amount)}
</span>
</li>
))}
<li className="flex justify-between border-t border-border pt-1.5 font-medium">
<span> {formatPercent(item.markup_percent)}</span>
<span className="num font-mono text-primary">
{formatUSD(item.final_total)}
</span>
</li>
</ul>
</div>
<p className="mt-3 font-mono text-xs text-text-disabled">
{quote.quote_id}
</p>
</Card>
);
}