|
|
"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>
|
|
|
);
|
|
|
}
|