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.

322 lines
9.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 { useCallback, useEffect, useState } from "react";
import { Card } from "@/components/ui/card";
import {
PrimaryButton,
SecondaryButton,
} from "@/components/ui/primary-button";
import { InputField } from "@/components/ui/input-field";
import { ErrorBanner } from "@/components/ui/error-banner";
import { Skeleton } from "@/components/ui/skeleton";
import { EmbeddedQuoteWidget } from "@/components/embed/embedded-quote-widget";
import {
embedGetProviderCredentials,
embedSaveProviderCredential,
type EmbedProviderCredential,
} from "@/lib/frontend/api-client";
type QuoteProvider = "mothership" | "flock";
type QuoteMode = "anon" | "login";
interface ModuleDef {
provider: QuoteProvider;
title: string;
desc: string;
}
/** 门户仅两模块MotherShip / Flock进入后可选免账号或登录后 */
const MODULES: ModuleDef[] = [
{
provider: "mothership",
title: "MotherShip",
desc: "免账号实时报价,或使用官网账号登录后查价",
},
{
provider: "flock",
title: "Flock Freight",
desc: "ZIP Direct 匿名询价,或使用官网账号登录后查价",
},
];
interface QuoteRouteHubProps {
customerId: string;
apiBaseUrl?: string;
}
export function QuoteRouteHub({
customerId,
apiBaseUrl = "",
}: QuoteRouteHubProps) {
const [loading, setLoading] = useState(true);
const [creds, setCreds] = useState<
Record<QuoteProvider, EmbedProviderCredential>
>({
mothership: {
provider: "mothership",
email: null,
password: null,
has_password: false,
updated_at: null,
},
flock: {
provider: "flock",
email: null,
password: null,
has_password: false,
updated_at: null,
},
});
const [selected, setSelected] = useState<ModuleDef | null>(null);
const [mode, setMode] = useState<QuoteMode>("anon");
const loadCreds = useCallback(async () => {
setLoading(true);
const res = await embedGetProviderCredentials(apiBaseUrl);
if (res.code === 0) {
setCreds((prev) => {
const next = { ...prev };
for (const item of res.data.list) {
if (item.provider === "mothership" || item.provider === "flock") {
next[item.provider] = item;
}
}
return next;
});
}
setLoading(false);
}, [apiBaseUrl]);
useEffect(() => {
void loadCreds();
}, [loadCreds]);
const handleSaved = useCallback((saved: EmbedProviderCredential) => {
setCreds((prev) => ({ ...prev, [saved.provider]: saved }));
}, []);
const openModule = (mod: ModuleDef) => {
setSelected(mod);
// 已配置账密时默认进登录后;否则免账号
setMode(creds[mod.provider].has_password ? "login" : "anon");
};
if (loading) {
return <Skeleton className="h-64 w-full" />;
}
if (!selected) {
return (
<div className="grid gap-4 sm:grid-cols-2">
{MODULES.map((mod) => {
const hasPassword = creds[mod.provider].has_password;
return (
<Card
key={mod.provider}
className="flex cursor-pointer flex-col gap-2 transition-colors hover:border-primary"
onClick={() => openModule(mod)}
data-testid={`quote-route-module-${mod.provider}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-base font-semibold text-text-primary">
{mod.title}
</span>
<span
className={`shrink-0 rounded-full px-2 py-0.5 text-xs ${
hasPassword
? "bg-green-50 text-green-600"
: "bg-bg text-text-secondary"
}`}
>
{hasPassword ? "已配置官网账号" : "可免账号查价"}
</span>
</div>
<span className="text-sm text-text-secondary">{mod.desc}</span>
</Card>
);
})}
</div>
);
}
const providerCred = creds[selected.provider];
const requiresAccount = mode === "login";
const canQuote = !requiresAccount || providerCred.has_password;
return (
<div>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm text-text-secondary"></p>
<p className="text-lg font-semibold text-text-primary">
{selected.title}
</p>
</div>
<SecondaryButton onClick={() => setSelected(null)}>
</SecondaryButton>
</div>
<div className="mb-4 flex flex-wrap gap-1 rounded-md bg-bg p-1">
{(
[
{ id: "anon" as const, label: "免账号", desc: "无需官网登录" },
{ id: "login" as const, label: "登录后", desc: "使用官网账密" },
] as const
).map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setMode(tab.id)}
className={`flex min-w-[40%] flex-1 flex-col items-center rounded-sm px-3 py-2 text-center transition-colors sm:flex-row sm:justify-center sm:gap-2 ${
mode === tab.id
? "bg-surface text-primary shadow-card"
: "text-text-secondary hover:text-text-primary"
}`}
>
<span className="text-sm font-semibold">{tab.label}</span>
<span className="text-xs text-text-secondary">{tab.desc}</span>
</button>
))}
</div>
{requiresAccount && (
<ProviderCredentialPanel
provider={selected.provider}
apiBaseUrl={apiBaseUrl}
current={providerCred}
onSaved={handleSaved}
/>
)}
{canQuote ? (
<EmbeddedQuoteWidget
key={`${selected.provider}-${mode}`}
customerId={customerId}
apiBaseUrl={apiBaseUrl}
forcedProvider={selected.provider}
hideProviderSwitch
mothershipLoggedInUi={
selected.provider === "mothership" && requiresAccount
}
flockLoggedInUi={selected.provider === "flock" && requiresAccount}
/>
) : (
<Card className="mt-4">
<p className="text-sm text-text-secondary">
</p>
</Card>
)}
</div>
);
}
interface ProviderCredentialPanelProps {
provider: QuoteProvider;
apiBaseUrl: string;
current: EmbedProviderCredential;
onSaved: (saved: EmbedProviderCredential) => void;
}
function ProviderCredentialPanel({
provider,
apiBaseUrl,
current,
onSaved,
}: ProviderCredentialPanelProps) {
const [email, setEmail] = useState(current.email ?? "");
const [password, setPassword] = useState(current.password ?? "");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [savedHint, setSavedHint] = useState<string | null>(null);
useEffect(() => {
setEmail(current.email ?? "");
setPassword(current.password ?? "");
setError(null);
setSavedHint(null);
}, [provider, current.email, current.password]);
const handleSave = async () => {
if (saving) return;
if (!email.trim()) {
setError("请填写登录邮箱");
return;
}
if (!password.trim()) {
setError("请填写登录密码(明文展示便于核对,修改后请点保存)");
return;
}
setSaving(true);
setError(null);
setSavedHint(null);
const res = await embedSaveProviderCredential(
apiBaseUrl,
provider,
email.trim(),
password,
);
setSaving(false);
if (res.code !== 0) {
setError(res.message || "保存失败");
return;
}
setEmail(res.data.email ?? email.trim());
setPassword(res.data.password ?? password);
setSavedHint(
"账号密码已保存。若官网已改密,请确认此处已同步更新后再查价。",
);
onSaved(res.data);
};
return (
<Card className="mb-4">
<h3 className="mb-1 text-base font-semibold text-text-primary">
</h3>
<p className="mb-4 text-sm text-text-secondary">
{current.has_password
? "以下为当前已保存的明文账密,请核对是否与官网一致。官网改密后须在此即时保存,否则查价会卡在登录页并报错。"
: "首次使用请填写官网账号邮箱与密码,保存后方可查价。"}
</p>
{error && (
<div className="mb-4">
<ErrorBanner>{error}</ErrorBanner>
</div>
)}
{savedHint && (
<p className="mb-4 text-sm text-green-600">{savedHint}</p>
)}
<div className="space-y-4">
<InputField
label="登录邮箱"
name="provider_email"
placeholder="账号邮箱"
autoComplete="off"
value={email}
disabled={saving}
onChange={(e) => setEmail(e.target.value)}
/>
<InputField
label="登录密码(明文)"
name="provider_password"
type="text"
autoComplete="off"
placeholder="官网登录密码"
value={password}
disabled={saving}
onChange={(e) => setPassword(e.target.value)}
/>
<PrimaryButton
type="button"
loading={saving}
onClick={() => void handleSave()}
>
{current.has_password ? "保存修改" : "保存"}
</PrimaryButton>
</div>
</Card>
);
}