sync: update dist-site from subtree

This commit is contained in:
abingyyds committed 2026-05-03 23:47:23 +08:00
1 parent 423911d367
commit fa940f8a51
31 files changed
+2445 -800

No files matched your search

+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Noto+Sans+SC:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<title>AI Platform</title>
<script type="module" crossorigin src="/assets/index-By8pKJte.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DJ3hXS61.css">
<script type="module" crossorigin src="/assets/index-BL8-VQh7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CiZXKDhI.css">
</head>
<body class="antialiased">
<script>
+74 -3
View File
@@ -3,6 +3,60 @@ import toast from 'react-hot-toast';
export const Q = 500000; // QuotaPerUnit — single source of truth
const previewModels = [
{ id: 'preview-1', model_name: 'gpt-4o-mini', display_name: 'GPT-4o Mini', enabled: true },
{ id: 'preview-2', model_name: 'claude-sonnet-4-5', display_name: 'Claude Sonnet 4.5', enabled: true },
{ id: 'preview-3', model_name: 'gemini-2.5-pro', display_name: 'Gemini 2.5 Pro', enabled: true },
{ id: 'preview-4', model_name: 'deepseek-chat', display_name: 'DeepSeek Chat', enabled: true },
{ id: 'preview-5', model_name: 'qwen-max', display_name: 'Qwen Max', enabled: true },
{ id: 'preview-6', model_name: 'grok-4', display_name: 'Grok 4', enabled: true },
{ id: 'preview-7', model_name: 'claude-haiku-4-5', display_name: 'Claude Haiku 4.5', enabled: true },
{ id: 'preview-8', model_name: 'gpt-5-mini', display_name: 'GPT-5 Mini', enabled: true },
];
const previewPackages = [
{
id: 'preview-basic',
name: 'Starter Pack',
description: '适合个人试用和轻量 API 调用。',
price: 29,
original_price: 49,
duration: 30,
quota_amount: Q * 6,
quota_reset_period: 'never',
enabled: true,
},
{
id: 'preview-pro',
name: 'Pro Relay',
description: '高频调用、自动路由、失败重试的主力套餐。',
price: 99,
original_price: 149,
duration: 30,
quota_amount: Q * 24,
quota_reset_period: 'never',
enabled: true,
},
{
id: 'preview-team',
name: 'Team Scale',
description: '适合团队共享密钥、模型分组和稳定生产调用。',
price: 299,
original_price: 399,
duration: 30,
quota_amount: Q * 90,
quota_reset_period: 'never',
enabled: true,
},
];
const getPreviewTheme = () => {
if (!import.meta.env.DEV || typeof window === 'undefined') return '';
return new URLSearchParams(window.location.search).get('preview_theme') || '';
};
const previewResponse = (data) => Promise.resolve({ data: { success: true, data } });
const api = axios.create({
baseURL: '',
timeout: 30000,
@@ -52,10 +106,27 @@ api.interceptors.response.use(
);
// ===== Public =====
export const getSiteInfo = () => api.get('/api/dist/site/info');
export const getSiteModels = () => api.get('/api/dist/site/models');
export const getSiteInfo = () => {
const theme = getPreviewTheme();
if (theme) {
return previewResponse({
name: 'SubRouter Preview',
theme_template: theme,
enable_topup: true,
allow_sub_dist: true,
currency: {
code: 'CNY',
symbol: '¥',
exchange_rate: 7,
usd_exchange_rate: 7,
},
});
}
return api.get('/api/dist/site/info');
};
export const getSiteModels = () => getPreviewTheme() ? previewResponse(previewModels) : api.get('/api/dist/site/models');
export const getSitePricing = () => api.get('/api/dist/site/pricing');
export const getSitePackages = () => api.get('/api/dist/site/packages');
export const getSitePackages = () => getPreviewTheme() ? previewResponse(previewPackages) : api.get('/api/dist/site/packages');
export const getSiteKeyGroups = () => api.get('/api/dist/site/key-groups');
export const getSiteKeyGroupPricing = (id) => api.get(`/api/dist/site/key-groups/${id}/pricing`);
export const getSubDistributorInfo = () => api.get('/api/dist/site/sub-distributor/info');
+98 -26
View File
@@ -8,8 +8,16 @@ const TOOLS = [
{ id: 'claudecode', name: 'Claude Code', path: '~/.claude/settings.json' },
{ id: 'hermes', name: 'Hermes', path: 'hermes-subrouter.sh' },
{ id: 'openclaw', name: 'OpenClaw', path: '~/.openclaw/openclaw.json' },
{ id: 'opencode', name: 'OpenCode', path: '~/.config/opencode/opencode.json' },
{ id: 'cursor', name: 'Cursor', path: 'Settings -> Models -> OpenAI API Key' },
{
id: 'opencode',
name: 'OpenCode',
path: '~/.config/opencode/opencode.json',
},
{
id: 'cursor',
name: 'Cursor',
path: 'Settings -> Models -> OpenAI API Key',
},
{ id: 'curl', name: 'cURL', path: 'Terminal' },
{ id: 'python', name: 'Python SDK', path: 'main.py' },
{ id: 'anthropic', name: 'Anthropic SDK', path: 'main.py' },
@@ -22,6 +30,21 @@ const CCSWITCH_APPS = [
{ id: 'openclaw', name: 'OpenClaw', endpointType: 'openclaw' },
];
const API_ENDPOINTS = [
{
id: 'china',
url: 'https://china.claudecoder.me',
nameKey: 'config.apiEndpointChinaName',
descKey: 'config.apiEndpointChinaDesc',
},
{
id: 'world',
url: 'https://world.claudecoder.me',
nameKey: 'config.apiEndpointWorldName',
descKey: 'config.apiEndpointWorldDesc',
},
];
const CCSWITCH_RELEASE_URL =
'https://github.com/farion1231/cc-switch/releases/latest';
const CCSWITCH_REPO_URL = 'https://github.com/farion1231/cc-switch';
@@ -73,7 +96,9 @@ function ThemedSelect({
const selectedOption = options.find((option) => option.value === value);
const displayLabel = selectedOption
? (renderValue ? renderValue(selectedOption) : selectedOption.label)
? renderValue
? renderValue(selectedOption)
: selectedOption.label
: placeholder;
return (
@@ -173,6 +198,7 @@ const ConfigExporter = ({ tokens = [] }) => {
const [selectedModel, setSelectedModel] = useState('');
const [selectedTool, setSelectedTool] = useState('claudecode');
const [selectedCCSwitchApp, setSelectedCCSwitchApp] = useState('codex');
const [selectedEndpointId, setSelectedEndpointId] = useState('china');
const [loadingModels, setLoadingModels] = useState(false);
const [modelsError, setModelsError] = useState(false);
const [copied, setCopied] = useState(false);
@@ -180,6 +206,13 @@ const ConfigExporter = ({ tokens = [] }) => {
const [showCCSwitchDownload, setShowCCSwitchDownload] = useState(false);
const serverAddress = window.location.origin;
const selectedEndpoint = useMemo(
() =>
API_ENDPOINTS.find((endpoint) => endpoint.id === selectedEndpointId) ||
API_ENDPOINTS[0],
[selectedEndpointId],
);
const apiServerAddress = selectedEndpoint.url;
const selectedToken = useMemo(
() => tokens.find((token) => token.id === selectedTokenId) || null,
@@ -271,7 +304,7 @@ const ConfigExporter = ({ tokens = [] }) => {
if (lower.includes('claude')) {
return {
family: 'anthropic',
baseUrl: serverAddress,
baseUrl: apiServerAddress,
openclawApi: 'anthropic-messages',
openclawProviderId: 'subrouter-anthropic',
opencodeProviderId: 'anthropic',
@@ -279,7 +312,7 @@ const ConfigExporter = ({ tokens = [] }) => {
}
return {
family: 'openai',
baseUrl: `${serverAddress}/v1`,
baseUrl: `${apiServerAddress}/v1`,
openclawApi: 'openai-completions',
openclawProviderId: 'subrouter-openai',
opencodeProviderId: 'openai',
@@ -287,17 +320,15 @@ const ConfigExporter = ({ tokens = [] }) => {
};
const getCCSwitchEndpoint = () => {
const app = CCSWITCH_APPS.find(
(item) => item.id === selectedCCSwitchApp,
);
const app = CCSWITCH_APPS.find((item) => item.id === selectedCCSwitchApp);
const preset = getModelConnectionPreset(selectedModel);
if (app?.endpointType === 'anthropic') {
return serverAddress;
return apiServerAddress;
}
if (app?.id === 'openclaw' || app?.id === 'opencode') {
return preset.baseUrl;
}
return `${serverAddress}/v1`;
return `${apiServerAddress}/v1`;
};
const generateCCSwitchLink = () => {
@@ -326,7 +357,7 @@ const ConfigExporter = ({ tokens = [] }) => {
return `{
"env": {
"ANTHROPIC_API_KEY": "${apiKey}",
"ANTHROPIC_BASE_URL": "${serverAddress}",
"ANTHROPIC_BASE_URL": "${apiServerAddress}",
"ANTHROPIC_MODEL": "${selectedModel}"
}
}`;
@@ -349,7 +380,7 @@ cat > "$PROFILE_DIR/config.yaml" <<'YAML'
model:
default: ${selectedModel}
provider: custom
base_url: ${serverAddress}/v1
base_url: ${apiServerAddress}/v1
api_key: ${apiKey}
YAML
@@ -419,10 +450,10 @@ echo "Hermes profile exported to ./$PROFILE_NAME.tar.gz"`;
}
case 'cursor':
return `API Key: ${apiKey}
Base URL: ${serverAddress}/v1
Base URL: ${apiServerAddress}/v1
Model: ${selectedModel}`;
case 'curl':
return `curl ${serverAddress}/v1/chat/completions \\
return `curl ${apiServerAddress}/v1/chat/completions \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer ${apiKey}" \\
-d '{
@@ -436,7 +467,7 @@ Model: ${selectedModel}`;
client = OpenAI(
api_key="${apiKey}",
base_url="${serverAddress}/v1"
base_url="${apiServerAddress}/v1"
)
response = client.chat.completions.create(
@@ -452,7 +483,7 @@ print(response.choices[0].message.content)`;
client = anthropic.Anthropic(
api_key="${apiKey}",
base_url="${serverAddress}"
base_url="${apiServerAddress}"
)
message = client.messages.create(
@@ -482,7 +513,8 @@ print(message.content[0].text)`;
return 'cursor-config.txt';
default:
return (
selectedToolMeta.path.split('/').pop() || `${selectedToolMeta.id}.json`
selectedToolMeta.path.split('/').pop() ||
`${selectedToolMeta.id}.json`
);
}
};
@@ -505,12 +537,12 @@ print(message.content[0].text)`;
const getSelectedToolBaseUrl = () => {
const preset = getModelConnectionPreset(selectedModel);
if (selectedTool === 'claudecode' || selectedTool === 'anthropic') {
return serverAddress;
return apiServerAddress;
}
if (selectedTool === 'openclaw' || selectedTool === 'opencode') {
return preset.baseUrl;
}
return `${serverAddress}/v1`;
return `${apiServerAddress}/v1`;
};
const handleCopyValue = async (text, successKey = 'config.copied') => {
@@ -709,6 +741,49 @@ print(message.content[0].text)`;
/>
</div>
<div>
<label className="flex items-center gap-2 text-xs font-medium text-page-label mb-2">
<svg
className="w-3 h-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
{t('config.selectApiEndpoint')}
</label>
<div className="grid gap-2 md:grid-cols-2">
{API_ENDPOINTS.map((endpoint) => (
<button
key={endpoint.id}
type="button"
onClick={() => setSelectedEndpointId(endpoint.id)}
className={`rounded-xl border px-3 py-2 text-left transition-all ${
selectedEndpointId === endpoint.id
? 'border-brand-500 bg-brand-500/10 text-page'
: 'border-page-divider bg-page-inset/40 text-page-secondary hover:bg-page-surface-hover'
}`}
>
<div className="text-xs font-semibold">
{t(endpoint.nameKey)}
</div>
<div className="mt-1 text-[11px] text-page-muted">
{t(endpoint.descKey)}
</div>
<code className="mt-1 block break-all text-[11px] text-page-muted">
{endpoint.url}
</code>
</button>
))}
</div>
</div>
<div className="rounded-xl border border-page-divider bg-page-surface/50 px-4 py-4 space-y-3">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div>
@@ -748,7 +823,7 @@ print(message.content[0].text)`;
<button
onClick={() =>
handleCopyValue(
`${serverAddress}/v1`,
`${apiServerAddress}/v1`,
'config.apiUrlCopied',
)
}
@@ -758,15 +833,12 @@ print(message.content[0].text)`;
{t('config.openaiApiUrl')}
</div>
<code className="block mt-1 text-[11px] text-page-muted break-all">
{serverAddress}/v1
{apiServerAddress}/v1
</code>
</button>
<button
onClick={() =>
handleCopyValue(
`${serverAddress}/`,
'config.apiUrlCopied',
)
handleCopyValue(apiServerAddress, 'config.apiUrlCopied')
}
className="rounded-lg border border-page-divider bg-page-inset/40 px-3 py-2 text-left hover:bg-page-surface-hover transition-colors"
>
@@ -774,7 +846,7 @@ print(message.content[0].text)`;
{t('config.anthropicApiUrl')}
</div>
<code className="block mt-1 text-[11px] text-page-muted break-all">
{serverAddress}/
{apiServerAddress}
</code>
</button>
</div>
+31 -2
View File
@@ -5,13 +5,16 @@ const SiteContext = createContext(null);
// Map theme template name → CSS class(es) to apply on <body>
const themeClassMap = {
starter: '',
default: 'theme-default',
starter: 'theme-light theme-starter',
default: 'theme-light theme-starter',
dark: 'theme-dark',
minimal: 'theme-minimal',
clean: 'theme-light',
corporate: 'theme-light',
claude: 'theme-light theme-claude',
aurora: 'theme-light theme-aurora',
terminal: 'theme-terminal',
market: 'theme-light theme-market',
};
function applyThemeClass(themeName) {
@@ -20,11 +23,37 @@ function applyThemeClass(themeName) {
try { localStorage.setItem('dist-theme-class', cls); } catch(e) {}
}
function getDevPreviewTheme() {
if (!import.meta.env.DEV || typeof window === 'undefined') return '';
return new URLSearchParams(window.location.search).get('preview_theme') || '';
}
export function SiteProvider({ children }) {
const [site, setSite] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const previewTheme = getDevPreviewTheme();
if (previewTheme) {
const previewSite = {
name: 'SubRouter Preview',
theme_template: previewTheme,
enable_topup: true,
allow_sub_dist: true,
currency: {
code: 'CNY',
symbol: '¥',
exchange_rate: 7,
usd_exchange_rate: 7,
},
};
setSite(previewSite);
applyThemeClass(previewTheme);
document.title = `${previewSite.name} · ${previewTheme}`;
setLoading(false);
return;
}
getSiteInfo()
.then((res) => {
if (res.data.success) {
+12
View File
@@ -33,6 +33,18 @@ const themeRegistry = {
Home: React.lazy(() => import('../themes/claude/Home')),
Layout: React.lazy(() => import('../themes/claude/Layout')),
},
aurora: {
Home: React.lazy(() => import('../themes/aurora/Home')),
Layout: React.lazy(() => import('../themes/aurora/Layout')),
},
terminal: {
Home: React.lazy(() => import('../themes/terminal/Home')),
Layout: React.lazy(() => import('../themes/terminal/Layout')),
},
market: {
Home: React.lazy(() => import('../themes/market/Home')),
Layout: React.lazy(() => import('../themes/market/Layout')),
},
};
// Full-screen loading spinner shown while site info is being fetched
+29 -21
View File
@@ -11,6 +11,7 @@
"nav.contact": "Contact",
"common.close": "Close",
"home.heroTagline": "AI API Platform",
"home.heroSubtitle": "Access the world's most powerful AI models through a single API endpoint. Simple, affordable, reliable.",
"home.getStarted": "Get Started Free",
"home.viewPricing": "View Pricing",
@@ -105,52 +106,52 @@
"dashboard.apiDocs": "API Docs",
"dashboard.comingSoon": "Coming soon",
"subDist.nav": "Launch Site",
"subDist.dashboardEntry": "Pay once and manage your own branded site",
"subDist.nav": "Relay Site Setup",
"subDist.dashboardEntry": "Pay once and manage your own relay site",
"subDist.badge": "Brand Plan",
"subDist.title": "Launch Your Own Site",
"subDist.subtitle": "Quickly launch and configure your own branded site through {{name}}. After payment, management access will be enabled automatically and the setup flow will continue.",
"subDist.title": "Build Your Own Relay Site",
"subDist.subtitle": "Quickly build and configure your own relay site through {{name}}. After payment, management access will be enabled automatically and the setup flow will continue.",
"subDist.defaultSiteName": "this site",
"subDist.notAvailable": "Site launch is currently unavailable",
"subDist.notAvailable": "Relay site setup is currently unavailable",
"subDist.disabledFallback": "Please contact the site administrator to enable this feature",
"subDist.loginHint": "Please log in or register on this site first. After successful payment, your account will receive management access automatically.",
"subDist.goLogin": "Log In",
"subDist.goRegister": "Register",
"subDist.siteName": "Site Name",
"subDist.siteNamePlaceholder": "e.g. My AI Site",
"subDist.siteSlug": "Site Slug",
"subDist.siteSlugHelp": "This will become your site identifier and cannot be changed after payment.",
"subDist.siteName": "Relay Site Name",
"subDist.siteNamePlaceholder": "e.g. My AI Relay",
"subDist.siteSlug": "Relay Site Slug",
"subDist.siteSlugHelp": "This will become your relay site identifier and cannot be changed after payment.",
"subDist.paymentMethod": "Payment Method",
"subDist.chain": "Blockchain",
"subDist.token": "Token",
"subDist.payAndOpen": "Launch Now {{symbol}}{{price}}",
"subDist.payAndOpen": "Build Now {{symbol}}{{price}}",
"subDist.currentUserHint": "Current paying account: {{user}}, payment method: {{method}}",
"subDist.postPayHint": "After payment, your management access will be activated automatically. If login is required, continue with the same account to finish setup.",
"subDist.cryptoTitle": "Crypto Order Created",
"subDist.cryptoHint": "Please transfer using the details below. Your site will be activated automatically after confirmation and setup will begin.",
"subDist.cryptoHint": "Please transfer using the details below. Your relay site will be activated automatically after confirmation and setup will begin.",
"subDist.wallet": "Wallet",
"subDist.amount": "Amount",
"subDist.tradeNo": "Trade No",
"subDist.priceCardTitle": "Pricing",
"subDist.openPrice": "Launch Price",
"subDist.priceSummary": "This page only shows the final launch price. After payment succeeds, you can continue directly into site setup and configuration.",
"subDist.ruleTitle": "Launch Details",
"subDist.rule1": "You are paying the launch price shown on this page, and activation will be completed automatically after payment succeeds.",
"subDist.rule2": "Once activated, you can continue configuring domains, themes, pricing, models, and other site settings.",
"subDist.priceSummary": "This page only shows the final setup price. After payment succeeds, you can continue directly into relay site setup and configuration.",
"subDist.ruleTitle": "Setup Details",
"subDist.rule1": "You are paying the setup price shown on this page, and activation will be completed automatically after payment succeeds.",
"subDist.rule2": "Once activated, you can continue configuring domains, themes, pricing, models, and other relay site settings.",
"subDist.rule3": "If login is required later, continue with the same account to enter the management console.",
"subDist.loginRequired": "Please log in before launching your site",
"subDist.loginRequired": "Please log in before building your relay site",
"subDist.fillRequired": "Please fill in the site name, slug, and payment method",
"subDist.selectPayment": "Please select a payment method",
"subDist.createFailed": "Failed to create order",
"subDist.paymentPageOpened": "Order created. Please complete payment on the opened payment page.",
"subDist.paymentPageOpened": "Setup order created. Please complete payment on the opened payment page.",
"subDist.paymentPageFailed": "Failed to open the payment page. Please try again later or contact the site administrator.",
"subDist.popupBlocked": "The browser blocked the payment window. Please allow pop-ups and try again.",
"subDist.cryptoOrderCreated": "Crypto order created. Please complete the transfer using the details on this page.",
"subDist.confirmingPayment": "Confirming payment result...",
"subDist.paymentPending": "Returned to this page. If you have completed payment, activation will finish after the callback is confirmed. Please refresh shortly.",
"subDist.openedSuccess": "Site activated successfully",
"subDist.alreadyOpenTitle": "Your site is already activated",
"subDist.alreadyOpenDesc": "Site {{name}} has been activated. Use the same account in the main console to continue setup.",
"subDist.paymentPending": "Returned to this page. If you have completed payment, relay site activation will finish after the callback is confirmed. Please refresh shortly.",
"subDist.openedSuccess": "Relay site activated successfully",
"subDist.alreadyOpenTitle": "Your relay site is already built",
"subDist.alreadyOpenDesc": "Relay site {{name}} has been activated. Use the same account in the main console to continue setup.",
"tokens.title": "API Keys",
"tokens.subtitle": "Manage your API access tokens",
@@ -236,6 +237,11 @@
"config.loadingModels": "Loading models...",
"config.apiUrlTitle": "API Endpoints",
"config.apiUrlHint": "After creating an API key, you can connect directly with the Base URLs below. Click any address to copy it.",
"config.selectApiEndpoint": "Choose API route",
"config.apiEndpointChinaName": "China calls",
"config.apiEndpointChinaDesc": "Best for mainland China networks",
"config.apiEndpointWorldName": "Global acceleration",
"config.apiEndpointWorldDesc": "Best for overseas or globally accelerated networks",
"config.copyCurrentApiUrl": "Copy current tool URL",
"config.currentToolApiUrl": "Recommended Base URL for current tool",
"config.openaiApiUrl": "OpenAI-compatible Base URL",
@@ -263,6 +269,8 @@
"pricing.outputPrice": "Output / 1M tokens",
"pricing.cacheReadPrice": "Cache read / 1M tokens",
"pricing.cacheCreationPrice": "Cache creation / 1M tokens",
"pricing.officialPrice": "Official in/out",
"pricing.savings": "Savings",
"pricing.cacheCreation5m": "5m",
"pricing.cacheCreation1h": "1h",
"pricing.perCall": "Per call",
+29 -21
View File
@@ -11,6 +11,7 @@
"nav.contact": "联系我们",
"common.close": "关闭",
"home.heroTagline": "AI API 平台",
"home.heroSubtitle": "通过单一 API 端点访问全球最强大的 AI 模型。简单、实惠、可靠。",
"home.getStarted": "免费开始",
"home.viewPricing": "查看定价",
@@ -105,52 +106,52 @@
"dashboard.apiDocs": "API 文档",
"dashboard.comingSoon": "即将推出",
"subDist.nav": "开通站点",
"subDist.dashboardEntry": "付费开通并管理自己的专属站点",
"subDist.nav": "中转站搭建",
"subDist.dashboardEntry": "付费搭建并管理自己的专属中转站",
"subDist.badge": "品牌计划",
"subDist.title": "开通你的专属站点",
"subDist.subtitle": "基于 {{name}} 快速开通并配置自己的专属站点。支付完成后,系统会自动为您开通管理权限并进入初始化流程。",
"subDist.title": "搭建你的专属中转站",
"subDist.subtitle": "基于 {{name}} 快速搭建并配置自己的专属中转站。支付完成后,系统会自动为您开通管理权限并进入初始化流程。",
"subDist.defaultSiteName": "当前站点",
"subDist.notAvailable": "当前暂未开放站点开通",
"subDist.notAvailable": "当前暂未开放中转站搭建",
"subDist.disabledFallback": "请联系站点管理员开启该功能",
"subDist.loginHint": "请先登录或注册当前站点账户,支付成功后会自动获得管理权限。",
"subDist.goLogin": "去登录",
"subDist.goRegister": "去注册",
"subDist.siteName": "站点名称",
"subDist.siteNamePlaceholder": "例如:我的 AI 站点",
"subDist.siteSlug": "站点标识",
"subDist.siteSlugHelp": "将作为您的专属站点标识,支付成功后不可修改。",
"subDist.siteName": "中转站名称",
"subDist.siteNamePlaceholder": "例如:我的 AI 中转站",
"subDist.siteSlug": "中转站标识",
"subDist.siteSlugHelp": "将作为您的专属中转站标识,支付成功后不可修改。",
"subDist.paymentMethod": "支付方式",
"subDist.chain": "区块链",
"subDist.token": "代币",
"subDist.payAndOpen": "立即开通 {{symbol}}{{price}}",
"subDist.payAndOpen": "立即搭建 {{symbol}}{{price}}",
"subDist.currentUserHint": "当前支付账号:{{user}},支付方式:{{method}}",
"subDist.postPayHint": "支付完成后系统会自动为您开通管理权限;如需补登录,请使用同一账号继续完成初始化。",
"subDist.cryptoTitle": "加密货币订单已创建",
"subDist.cryptoHint": "请按照以下信息转账,到账后系统会自动开通并开始初始化站点。",
"subDist.cryptoHint": "请按照以下信息转账,到账后系统会自动开通并开始初始化中转站。",
"subDist.wallet": "收款地址",
"subDist.amount": "支付金额",
"subDist.tradeNo": "订单号",
"subDist.priceCardTitle": "费用说明",
"subDist.openPrice": "当前开通价",
"subDist.priceSummary": "当前页面仅展示您的实际开通价格。支付成功后,您即可进入站点初始化与配置流程。",
"subDist.ruleTitle": "开通说明",
"subDist.rule1": "您支付的是当前页面展示的开通价格,支付成功后系统会自动完成开通。",
"subDist.rule2": "开通成功后,您可以继续配置域名、主题、价格和模型等站点信息。",
"subDist.priceSummary": "当前页面仅展示您的实际搭建价格。支付成功后,您即可进入中转站初始化与配置流程。",
"subDist.ruleTitle": "搭建说明",
"subDist.rule1": "您支付的是当前页面展示的搭建价格,支付成功后系统会自动完成开通。",
"subDist.rule2": "开通成功后,您可以继续配置域名、主题、价格和模型等中转站信息。",
"subDist.rule3": "如需补登录或继续设置,请使用同一账号进入管理后台。",
"subDist.loginRequired": "请先登录后再开通站点",
"subDist.loginRequired": "请先登录后再搭建中转站",
"subDist.fillRequired": "请填写站点名称、标识和支付方式",
"subDist.selectPayment": "请选择支付方式",
"subDist.createFailed": "创建订单失败",
"subDist.paymentPageOpened": "订单已创建,请在打开的支付页面完成付款",
"subDist.paymentPageOpened": "搭建订单已创建,请在打开的支付页面完成付款",
"subDist.paymentPageFailed": "支付页面打开失败,请稍后重试或联系站点管理员",
"subDist.popupBlocked": "浏览器拦截了支付窗口,请允许弹窗后重试",
"subDist.cryptoOrderCreated": "加密货币订单已创建,请按页面信息完成转账",
"subDist.confirmingPayment": "正在确认支付结果...",
"subDist.paymentPending": "已返回当前页面。若您已完成付款,系统会在回调确认后自动开通,请稍后刷新查看。",
"subDist.openedSuccess": "站点已开通成功",
"subDist.alreadyOpenTitle": "您已开通站点",
"subDist.alreadyOpenDesc": "站点 {{name}} 已完成开通,请使用同一账号进入主站控制台继续配置。",
"subDist.paymentPending": "已返回当前页面。若您已完成付款,系统会在回调确认后自动开通中转站,请稍后刷新查看。",
"subDist.openedSuccess": "中转站已开通成功",
"subDist.alreadyOpenTitle": "您已搭建中转站",
"subDist.alreadyOpenDesc": "中转站 {{name}} 已完成开通,请使用同一账号进入主站控制台继续配置。",
"tokens.title": "API 密钥",
"tokens.subtitle": "管理您的 API 访问令牌",
@@ -236,6 +237,11 @@
"config.loadingModels": "正在加载模型...",
"config.apiUrlTitle": "API 接入地址",
"config.apiUrlHint": "创建好 API Key 后,可直接使用下面的 Base URL 接入。点击地址即可复制。",
"config.selectApiEndpoint": "选择调用线路",
"config.apiEndpointChinaName": "中国调用",
"config.apiEndpointChinaDesc": "适合中国大陆网络",
"config.apiEndpointWorldName": "海外全球加速",
"config.apiEndpointWorldDesc": "适合海外或全球加速网络",
"config.copyCurrentApiUrl": "复制当前工具地址",
"config.currentToolApiUrl": "当前工具推荐 Base URL",
"config.openaiApiUrl": "OpenAI 兼容 Base URL",
@@ -263,6 +269,8 @@
"pricing.outputPrice": "输出 / 百万 token",
"pricing.cacheReadPrice": "缓存读取 / 百万 token",
"pricing.cacheCreationPrice": "缓存创建 / 百万 token",
"pricing.officialPrice": "官方 输入/输出",
"pricing.savings": "节省",
"pricing.cacheCreation5m": "5m",
"pricing.cacheCreation1h": "1h",
"pricing.perCall": "按次计费",
+376
View File
@@ -106,6 +106,90 @@
--page-inset: rgba(61,48,36,0.06);
}
.theme-starter {
--brand: 79 70 229;
--page-bg: #f7f8fb;
--page-spinner: rgba(79,70,229,0.8);
--page-spinner-track: rgba(79,70,229,0.18);
--page-link: 67 56 202;
--page-card-bg: rgba(255,255,255,0.92);
--page-card-border: rgb(226,232,240);
--page-input-bg: #ffffff;
--page-input-border: rgb(203,213,225);
--page-select-bg: #ffffff;
--page-select-panel-bg: #ffffff;
--page-surface: rgba(15,23,42,0.04);
--page-surface-hover: rgba(15,23,42,0.07);
--page-divider: rgb(226,232,240);
--page-inset: rgba(15,23,42,0.05);
}
.theme-aurora {
--brand: 37 99 235;
--page-bg: #f6f8fb;
--page-spinner: rgba(37,99,235,0.8);
--page-spinner-track: rgba(37,99,235,0.18);
--page-link: 29 78 216;
--page-card-bg: rgba(255,255,255,0.94);
--page-card-border: rgb(226,232,240);
--page-input-bg: #ffffff;
--page-input-border: rgb(203,213,225);
--page-select-bg: #ffffff;
--page-select-panel-bg: #ffffff;
--page-surface: rgba(15,23,42,0.04);
--page-surface-hover: rgba(15,23,42,0.07);
--page-divider: rgb(226,232,240);
--page-inset: rgba(15,23,42,0.05);
}
.theme-market {
--brand: 234 88 12;
--page-bg: #fbfaf7;
--page-spinner: rgba(234,88,12,0.82);
--page-spinner-track: rgba(234,88,12,0.18);
--page-link: 194 65 12;
--page-card-bg: rgba(255,255,255,0.94);
--page-card-border: rgb(231,229,228);
--page-input-bg: #ffffff;
--page-input-border: rgb(214,211,209);
--page-select-bg: #ffffff;
--page-select-panel-bg: #ffffff;
--page-surface: rgba(41,37,36,0.04);
--page-surface-hover: rgba(41,37,36,0.07);
--page-divider: rgb(231,229,228);
--page-inset: rgba(41,37,36,0.05);
}
.theme-terminal {
--brand: 52 211 153;
--page-bg: #050807;
--page-spinner: rgba(52,211,153,0.82);
--page-spinner-track: rgba(52,211,153,0.18);
--page-text: 236 253 245;
--page-text-secondary: 167 243 208;
--page-text-muted: 110 231 183;
--page-label: 209 250 229;
--page-link: 52 211 153;
--page-card-bg: rgba(6,17,13,0.82);
--page-card-border: rgba(52,211,153,0.18);
--page-input-bg: rgba(6,17,13,0.9);
--page-input-border: rgba(52,211,153,0.28);
--page-select-bg: rgba(6,17,13,0.98);
--page-select-panel-bg: rgba(6,17,13,0.98);
--page-surface: rgba(52,211,153,0.06);
--page-surface-hover: rgba(52,211,153,0.12);
--page-divider: rgba(52,211,153,0.18);
--page-inset: rgba(0,0,0,0.32);
--page-success: 52 211 153;
--page-success-text: 52 211 153;
--page-warning: 251 191 36;
--page-warning-text: 251 191 36;
--page-danger: 248 113 113;
--page-danger-text: 248 113 113;
--page-info: 45 212 191;
--page-info-text: 45 212 191;
}
/* Per-dark-theme background overrides */
.theme-default { --page-bg: #09090b; }
.theme-dark { --page-bg: #030712; }
@@ -272,6 +356,298 @@
background-color: rgba(217,119,87,0.05);
}
/* ═══════════════════════════════════════════════
New distributor theme shared-page skins
═══════════════════════════════════════════════ */
.theme-page-shell {
position: relative;
isolation: isolate;
}
.theme-page-shell::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
}
.theme-aurora .theme-page-shell::before {
background:
radial-gradient(circle at 18% 0%, rgba(37,99,235,0.12), transparent 34%),
radial-gradient(circle at 78% 8%, rgba(20,184,166,0.12), transparent 30%),
linear-gradient(180deg, #f6f8fb 0%, #ffffff 56%, #f6f8fb 100%);
}
.theme-aurora .theme-page-shell::after {
content: '';
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
opacity: 0.32;
background-image:
linear-gradient(rgba(148,163,184,0.32) 1px, transparent 1px),
linear-gradient(90deg, rgba(148,163,184,0.32) 1px, transparent 1px);
background-size: 48px 48px;
}
.theme-aurora .glass,
.theme-aurora .glass-sm {
border-color: #dbe3ee;
background: rgba(255,255,255,0.92);
box-shadow: 0 18px 48px rgba(15,23,42,0.08);
backdrop-filter: blur(18px);
}
.theme-aurora .glass {
border-radius: 16px;
}
.theme-aurora .glass-sm {
border-radius: 12px;
box-shadow: 0 8px 24px rgba(15,23,42,0.06);
}
.theme-aurora .btn-primary {
border-radius: 10px;
background-color: #0f172a;
box-shadow: 0 10px 24px rgba(15,23,42,0.14);
}
.theme-aurora .btn-primary:hover {
background-color: #1d4ed8;
}
.theme-aurora .btn-secondary {
border-color: #cbd5e1;
color: #475569;
}
.theme-aurora .btn-secondary:hover {
background-color: rgba(37,99,235,0.06);
}
.theme-aurora .input:focus {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37,99,235,0.14);
}
.theme-aurora .bg-brand-500 {
background-color: #2563eb !important;
}
.theme-aurora .border-brand-500\/30 {
border-color: rgba(37,99,235,0.3) !important;
}
.theme-aurora .border-t-brand-500 {
border-top-color: #2563eb !important;
}
.theme-aurora .from-brand-500 {
--tw-gradient-from: #2563eb var(--tw-gradient-from-position) !important;
--tw-gradient-to: rgb(37 99 235 / 0) var(--tw-gradient-to-position) !important;
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to) !important;
}
.theme-aurora .to-purple-500 {
--tw-gradient-to: #14b8a6 var(--tw-gradient-to-position) !important;
}
.theme-aurora .bg-purple-500\/10 {
background-color: rgba(37,99,235,0.08) !important;
}
.theme-aurora .border-purple-500\/20 {
border-color: rgba(37,99,235,0.18) !important;
}
.theme-aurora .shadow-brand-500\/25 {
--tw-shadow-color: rgba(37,99,235,0.24) !important;
}
.theme-terminal .theme-page-shell::before {
background:
radial-gradient(circle at 50% -8%, rgba(52,211,153,0.18), transparent 36%),
linear-gradient(180deg, #050807 0%, #07110d 48%, #050807 100%);
}
.theme-terminal .theme-page-shell::after {
content: '';
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
opacity: 0.18;
background-image:
linear-gradient(rgba(52,211,153,0.32) 1px, transparent 1px),
linear-gradient(90deg, rgba(52,211,153,0.32) 1px, transparent 1px);
background-size: 32px 32px;
}
.theme-terminal .glass,
.theme-terminal .glass-sm {
border-color: rgba(52,211,153,0.18);
background: rgba(3,10,7,0.82);
box-shadow: 0 18px 56px rgba(0,0,0,0.38);
backdrop-filter: blur(14px);
}
.theme-terminal .glass {
border-radius: 14px;
}
.theme-terminal .glass-sm {
border-radius: 10px;
}
.theme-terminal .btn-primary,
.theme-terminal .bg-brand-500 {
background-color: #34d399 !important;
color: #020617 !important;
box-shadow: 0 10px 28px rgba(52,211,153,0.16);
}
.theme-terminal .btn-primary:hover {
background-color: #6ee7b7 !important;
}
.theme-terminal .btn-secondary {
border-color: rgba(52,211,153,0.26);
color: rgb(167 243 208);
}
.theme-terminal .btn-secondary:hover {
background-color: rgba(52,211,153,0.08);
}
.theme-terminal .input:focus {
border-color: rgba(52,211,153,0.7);
box-shadow: 0 0 0 3px rgba(52,211,153,0.16);
}
.theme-terminal .border-brand-500\/30 {
border-color: rgba(52,211,153,0.3) !important;
}
.theme-terminal .border-t-brand-500 {
border-top-color: #34d399 !important;
}
.theme-terminal .from-brand-500 {
--tw-gradient-from: #34d399 var(--tw-gradient-from-position) !important;
--tw-gradient-to: rgb(52 211 153 / 0) var(--tw-gradient-to-position) !important;
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to) !important;
}
.theme-terminal .to-purple-500 {
--tw-gradient-to: #14b8a6 var(--tw-gradient-to-position) !important;
}
.theme-terminal .bg-purple-500\/10 {
background-color: rgba(52,211,153,0.08) !important;
}
.theme-terminal .border-purple-500\/20 {
border-color: rgba(52,211,153,0.2) !important;
}
.theme-terminal .shadow-brand-500\/25 {
--tw-shadow-color: rgba(52,211,153,0.18) !important;
}
.theme-market .theme-page-shell::before {
background:
radial-gradient(circle at 16% 0%, rgba(234,88,12,0.08), transparent 28%),
linear-gradient(180deg, #fbfaf7 0%, #ffffff 54%, #fbfaf7 100%);
}
.theme-market .theme-page-shell::after {
content: '';
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
opacity: 0.16;
background-image:
linear-gradient(#d6d3d1 1px, transparent 1px),
linear-gradient(90deg, #d6d3d1 1px, transparent 1px);
background-size: 48px 48px;
}
.theme-market .glass,
.theme-market .glass-sm {
border-color: #e7e5e4;
background: rgba(255,255,255,0.94);
box-shadow: 0 18px 46px rgba(41,37,36,0.08);
backdrop-filter: blur(14px);
}
.theme-market .glass {
border-radius: 16px;
}
.theme-market .glass-sm {
border-radius: 12px;
box-shadow: 0 8px 22px rgba(41,37,36,0.05);
}
.theme-market .btn-primary,
.theme-market .bg-brand-500 {
background-color: #292524 !important;
color: #ffffff !important;
box-shadow: 0 10px 24px rgba(41,37,36,0.13);
}
.theme-market .btn-primary:hover {
background-color: #ea580c !important;
}
.theme-market .btn-secondary {
border-color: #d6d3d1;
color: #57534e;
}
.theme-market .btn-secondary:hover {
background-color: rgba(234,88,12,0.06);
}
.theme-market .input:focus {
border-color: #ea580c;
box-shadow: 0 0 0 3px rgba(234,88,12,0.14);
}
.theme-market .border-brand-500\/30 {
border-color: rgba(234,88,12,0.3) !important;
}
.theme-market .border-t-brand-500 {
border-top-color: #ea580c !important;
}
.theme-market .from-brand-500 {
--tw-gradient-from: #292524 var(--tw-gradient-from-position) !important;
--tw-gradient-to: rgb(41 37 36 / 0) var(--tw-gradient-to-position) !important;
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to) !important;
}
.theme-market .to-purple-500 {
--tw-gradient-to: #ea580c var(--tw-gradient-to-position) !important;
}
.theme-market .bg-purple-500\/10 {
background-color: rgba(234,88,12,0.08) !important;
}
.theme-market .border-purple-500\/20 {
border-color: rgba(234,88,12,0.2) !important;
}
.theme-market .shadow-brand-500\/25 {
--tw-shadow-color: rgba(234,88,12,0.18) !important;
}
/* ═══════════════════════════════════════════════
Theme-aware utility classes for surfaces/dividers
═══════════════════════════════════════════════ */
+1 -1
View File
@@ -270,7 +270,7 @@ export default function Packages() {
<button
onClick={() => handleSubscribe(pkg)}
disabled={subscribing === pkg.id}
className="btn-primary w-full text-center"
className="btn-primary mt-2 w-full text-center"
>
{subscribing === pkg.id ? t('packages.processing') : user ? t('packages.subscribeNow') : t('packages.signUpToSubscribe')}
</button>
+74 -29
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { getSiteModels } from '../api';
import { useCurrency } from '../context/SiteContext';
import { getOfficialPrice } from '../utils/officialEquiv';
export default function Pricing() {
const { t } = useTranslation();
@@ -74,6 +75,27 @@ export default function Pricing() {
? `${symbol}${(Number(price) * rate).toFixed(4)}/${t('pricing.perCallUnit')}`
: '-';
const formatUsdPrice = (price) => {
if (price == null) return '-';
const value = Number(price);
if (!Number.isFinite(value)) return '-';
const decimals = value >= 1 ? 2 : value >= 0.01 ? 3 : 4;
return `$${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(decimals).replace(/0+$/, '').replace(/\.$/, '')}`;
};
const formatOfficialPrice = (official) => {
if (!official) return '-';
return `${formatUsdPrice(official.inputPerMtok)} / ${formatUsdPrice(official.outputPerMtok)}`;
};
const formatSavings = (model, official) => {
if (!official || model.is_per_call) return null;
const siteInputPerMtok = Number(model.input_price) * 1000;
if (!Number.isFinite(siteInputPerMtok) || siteInputPerMtok <= 0 || !official.inputPerMtok) return null;
const savings = Math.round((siteInputPerMtok / official.inputPerMtok - 1) * 100);
return savings < 0 ? `${savings}%` : null;
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
@@ -150,39 +172,62 @@ export default function Pricing() {
<th className="text-right px-5 py-3.5 font-medium text-page-secondary">{t('pricing.outputPrice')}</th>
<th className="text-right px-5 py-3.5 font-medium text-page-secondary">{t('pricing.cacheReadPrice')}</th>
<th className="text-right px-5 py-3.5 font-medium text-page-secondary">{t('pricing.cacheCreationPrice')}</th>
<th className="text-right px-5 py-3.5 font-medium text-page-secondary whitespace-nowrap">{t('pricing.officialPrice')}</th>
<th className="text-right px-5 py-3.5 font-medium text-page-secondary whitespace-nowrap">{t('pricing.savings')}</th>
<th className="text-center px-5 py-3.5 font-medium text-page-secondary">{t('pricing.status')}</th>
</tr>
</thead>
<tbody>
{filtered.map((m, i) => (
<tr key={m.model_name || i} className="border-b border-page-divider last:border-0 hover:bg-page-surface transition-colors">
<td className="px-5 py-3.5">
<span className="font-mono text-page">{m.display_name || m.model_name}</span>
</td>
<td className="px-5 py-3.5 text-right font-mono text-page-label">
{m.is_per_call ? t('pricing.perCall') : formatTokenPrice(m.input_price)}
</td>
<td className="px-5 py-3.5 text-right font-mono text-page-label">
{m.is_per_call ? formatPerCallPrice(m.fixed_price) : formatTokenPrice(m.output_price)}
</td>
<td className="px-5 py-3.5 text-right font-mono text-page-label">
{m.is_per_call ? '-' : formatTokenPrice(m.cache_read_price)}
</td>
<td className="px-5 py-3.5 text-right font-mono text-page-label whitespace-nowrap">
{m.is_per_call ? '-' : formatCacheCreationPrice(m.model_name, m.cache_creation_price, m.cache_creation_price_1h)}
</td>
<td className="px-5 py-3.5 text-center">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs border ${
m.status === 'healthy'
? 'bg-green-500/10 text-page-success border-green-500/20'
: 'bg-page-surface text-page-secondary border-page-divider'
}`}>
<span className={`w-1.5 h-1.5 rounded-full ${m.status === 'healthy' ? 'bg-green-500' : 'bg-neutral-500'}`} />
{m.status === 'healthy' ? t('pricing.online') : t('pricing.unknown')}
</span>
</td>
</tr>
))}
{filtered.map((m, i) => {
const official = getOfficialPrice(m);
const savings = formatSavings(m, official);
return (
<tr key={m.model_name || i} className="border-b border-page-divider last:border-0 hover:bg-page-surface transition-colors">
<td className="px-5 py-3.5">
<span className="font-mono text-page">{m.display_name || m.model_name}</span>
</td>
<td className="px-5 py-3.5 text-right font-mono text-page-label">
{m.is_per_call ? t('pricing.perCall') : formatTokenPrice(m.input_price)}
</td>
<td className="px-5 py-3.5 text-right font-mono text-page-label">
{m.is_per_call ? formatPerCallPrice(m.fixed_price) : formatTokenPrice(m.output_price)}
</td>
<td className="px-5 py-3.5 text-right font-mono text-page-label">
{m.is_per_call ? '-' : formatTokenPrice(m.cache_read_price)}
</td>
<td className="px-5 py-3.5 text-right font-mono text-page-label whitespace-nowrap">
{m.is_per_call ? '-' : formatCacheCreationPrice(m.model_name, m.cache_creation_price, m.cache_creation_price_1h)}
</td>
<td className="px-5 py-3.5 text-right font-mono text-page-label whitespace-nowrap">
{formatOfficialPrice(official)}
</td>
<td className="px-5 py-3.5 text-right">
{savings ? (
<span className={`inline-flex justify-end rounded-full px-2 py-0.5 font-mono text-xs font-semibold ${
savings.startsWith('-')
? 'bg-green-500/10 text-page-success'
: 'bg-amber-500/10 text-amber-600'
}`}>
{savings}
</span>
) : (
<span className="font-mono text-page-muted">-</span>
)}
</td>
<td className="px-5 py-3.5 text-center">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs border ${
m.status === 'healthy'
? 'bg-green-500/10 text-page-success border-green-500/20'
: 'bg-page-surface text-page-secondary border-page-divider'
}`}>
<span className={`w-1.5 h-1.5 rounded-full ${m.status === 'healthy' ? 'bg-green-500' : 'bg-neutral-500'}`} />
{m.status === 'healthy' ? t('pricing.online') : t('pricing.unknown')}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
+1 -1
View File
@@ -172,7 +172,7 @@ export default function Tokens() {
const handleToggleSupportedModels = async (tokenId) => {
const isExpanded = !!expandedTokens[tokenId];
setExpandedTokens((prev) => ({ ...prev, [tokenId]: !isExpanded }));
if (isExpanded || tokenModels[tokenId]) return;
if (isExpanded) return;
setTokenModels((prev) => ({
...prev,
+339
View File
@@ -0,0 +1,339 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
ArrowRight,
CheckCircle2,
Cpu,
Gauge,
KeyRound,
Layers3,
Route,
ShieldCheck,
Sparkles,
WalletCards,
} from 'lucide-react';
import { useAuth } from '../../context/AuthContext';
import { useSite, useCurrency } from '../../context/SiteContext';
import { calcOfficialEquivList } from '../../utils/officialEquiv';
import { packageQuotaDollars, useHomeData } from '../shared/useHomeData';
import Aurora from '../../components/bits/Aurora';
import CountUp from '../../components/bits/CountUp';
import FadeContent from '../../components/bits/FadeContent';
import RotatingEquiv from '../../components/bits/RotatingEquiv';
import ApiEndpoints from '../../components/ApiEndpoints';
import { getHomeContent } from '../../utils/siteContent';
import HomeHeroImage from '../shared/HomeHeroImage';
const featureCards = [
{ icon: Gauge, tone: 'border-blue-200 bg-blue-50 text-blue-700', key: 'lightningFast', desc: 'lightningFastDesc' },
{ icon: ShieldCheck, tone: 'border-teal-200 bg-teal-50 text-teal-700', key: 'securePrivate', desc: 'securePrivateDesc' },
{ icon: WalletCards, tone: 'border-slate-200 bg-slate-50 text-slate-700', key: 'payAsYouGo', desc: 'payAsYouGoDesc' },
];
export default function AuroraHome() {
const { t } = useTranslation();
const { user } = useAuth();
const { site } = useSite();
const { fmtCNY } = useCurrency();
const { enabledModels, visiblePackages } = useHomeData();
const models = enabledModels.slice(0, 8);
const homeContent = getHomeContent(site, t);
return (
<div className="relative overflow-hidden bg-[#f6f8fb] text-slate-950">
<section className="relative border-b border-slate-200 bg-[#f6f8fb]">
<div className="absolute inset-x-0 top-0 h-[360px] opacity-55">
<Aurora colorStops={['#2563eb', '#0f766e', '#94a3b8']} amplitude={0.28} blend={0.86} speed={0.18} />
</div>
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(246,248,251,0.16)_0%,#f6f8fb_68%)]" />
<div className="absolute inset-x-0 bottom-0 h-px bg-gradient-to-r from-transparent via-slate-300 to-transparent" />
<div className="relative mx-auto grid min-h-[calc(100vh-4rem)] max-w-7xl gap-12 px-4 pb-16 pt-14 sm:px-6 lg:grid-cols-[0.92fr_1.08fr] lg:items-center lg:pb-20 lg:pt-20">
<FadeContent blur duration={700} delay={80}>
<div className="max-w-2xl">
<div className="mb-5 inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white/90 px-3 py-1.5 text-sm font-bold text-slate-600 shadow-sm">
<Sparkles className="h-4 w-4 text-teal-600" />
{homeContent.heroTagline}
</div>
<h1 className="max-w-2xl text-4xl font-black tracking-tight text-slate-950 sm:text-5xl lg:text-6xl">
{site?.name || t('home.defaultHeroTitle')}
</h1>
<p className="mt-6 max-w-xl text-base leading-8 text-slate-600 sm:text-lg">
{homeContent.heroSubtitle}
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<PrimaryLink to={user ? '/dashboard' : '/register'}>
{user ? t('home.goToDashboard') : t('home.getStarted')}
</PrimaryLink>
<Link
to="/pricing"
className="inline-flex items-center justify-center rounded-lg border border-slate-300 bg-white px-6 py-3 text-sm font-bold text-slate-800 shadow-sm transition-colors hover:border-slate-400 hover:bg-slate-50"
>
{t('home.viewPricing')}
</Link>
</div>
<div className="mt-10 grid max-w-xl grid-cols-3 gap-3">
<Metric value={enabledModels.length || 50} suffix="+" label={t('home.aiModels')} />
<Metric value={99.9} suffix="%" label={t('home.uptime')} />
<Metric value={50} prefix="<" suffix="ms" label={t('home.latency')} />
</div>
</div>
</FadeContent>
<FadeContent blur duration={700} delay={180}>
{homeContent.heroImage ? (
<HomeHeroImage src={homeContent.heroImage} alt={site?.name} className="aspect-[4/3]" />
) : (
<RoutingWorkbench models={models} t={t} />
)}
</FadeContent>
</div>
</section>
<ApiEndpoints />
<section className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:py-20">
<SectionTitle title={t('home.whyChooseUs')} desc={t('home.whyChooseUsDesc')} />
<div className="grid gap-4 md:grid-cols-3">
{featureCards.map((item) => {
const Icon = item.icon;
return (
<div key={item.key} className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md">
<div className={`mb-5 flex h-11 w-11 items-center justify-center rounded-lg border ${item.tone}`}>
<Icon className="h-5 w-5" />
</div>
<h3 className="font-bold text-slate-950">{t(`home.${item.key}`)}</h3>
<p className="mt-2 text-sm leading-6 text-slate-600">{t(`home.${item.desc}`)}</p>
</div>
);
})}
</div>
</section>
{enabledModels.length > 0 && (
<section className="border-y border-slate-200 bg-white">
<div className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:py-20">
<div className="mb-8 flex flex-col justify-between gap-4 md:flex-row md:items-end">
<SectionTitle title={t('home.availableModels')} desc={t('home.availableModelsDesc', { count: enabledModels.length })} compact />
{enabledModels.length > 8 && (
<Link to="/pricing" className="inline-flex items-center gap-1.5 text-sm font-bold text-blue-700 hover:text-blue-900">
{t('home.viewAllModels', { count: enabledModels.length })}
<ArrowRight className="h-4 w-4" />
</Link>
)}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
{models.map((model, index) => (
<ModelTile key={model.id || index} model={model} index={index} />
))}
</div>
</div>
</section>
)}
{visiblePackages.length > 0 && (
<section className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:py-20">
<SectionTitle title={t('home.plansPackages')} desc={t('home.choosePlan')} />
<div className="grid gap-4 lg:grid-cols-3">
{visiblePackages.slice(0, 3).map((pkg, index) => (
<PackageCard key={pkg.id} pkg={pkg} index={index} models={enabledModels} fmtCNY={fmtCNY} t={t} user={user} />
))}
</div>
</section>
)}
<section className="border-t border-slate-200 bg-slate-950">
<div className="mx-auto flex max-w-7xl flex-col gap-6 px-4 py-14 text-white sm:px-6 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-2xl font-black tracking-tight">{t('home.readyToStart')}</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-300">{t('home.readyToStartDesc')}</p>
</div>
<PrimaryLink to={user ? '/dashboard' : '/register'} light>
{user ? t('home.goToDashboard') : t('home.createFreeAccount')}
</PrimaryLink>
</div>
</section>
</div>
);
}
function PrimaryLink({ to, children, light = false }) {
return (
<Link
to={to}
className={`inline-flex items-center justify-center gap-2 rounded-lg px-6 py-3 text-sm font-black shadow-sm transition-colors ${
light ? 'bg-white text-slate-950 hover:bg-slate-100' : 'bg-slate-950 text-white hover:bg-blue-700'
}`}
>
{children}
<ArrowRight className="h-4 w-4" />
</Link>
);
}
function Metric({ value, label, prefix = '', suffix = '' }) {
return (
<div className="rounded-xl border border-slate-200 bg-white/95 p-4 shadow-sm">
<div className="text-xl font-black text-slate-950">{prefix}<CountUp from={0} to={value} duration={2} />{suffix}</div>
<p className="mt-1 truncate text-xs font-semibold text-slate-500">{label}</p>
</div>
);
}
function SectionTitle({ title, desc, compact = false }) {
return (
<div className={compact ? 'max-w-2xl' : 'mb-8 max-w-2xl'}>
<h2 className="text-2xl font-black tracking-tight text-slate-950">{title}</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">{desc}</p>
</div>
);
}
function RoutingWorkbench({ models, t }) {
const preview = models.length ? models : [
{ display_name: 'gpt-4o-mini' },
{ display_name: 'claude-sonnet' },
{ display_name: 'gemini-pro' },
{ display_name: 'deepseek-chat' },
];
const rows = [
{ label: 'latency', value: '42ms', tone: 'text-blue-200' },
{ label: 'fallback', value: 'armed', tone: 'text-teal-200' },
{ label: 'providers', value: `${preview.length}+`, tone: 'text-slate-100' },
];
return (
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-[0_28px_80px_rgba(15,23,42,0.14)]">
<div className="rounded-xl border border-slate-200 bg-slate-950 p-4 text-white">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-white text-slate-950">
<Route className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-black">Routing Workbench</p>
<p className="mt-1 text-xs font-medium text-slate-400">live model orchestration</p>
</div>
</div>
<span className="w-fit rounded-md bg-teal-400/15 px-2.5 py-1 text-xs font-black text-teal-200 ring-1 ring-teal-300/20">HEALTHY</span>
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-3">
{rows.map((item) => (
<div key={item.label} className="rounded-lg border border-white/10 bg-white/[0.06] p-3">
<p className="text-[11px] font-bold uppercase tracking-wide text-slate-400">{item.label}</p>
<p className={`mt-1 font-mono text-sm font-black ${item.tone}`}>{item.value}</p>
</div>
))}
</div>
<div className="mt-5 rounded-xl border border-white/10 bg-[#070b12] p-4">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm font-bold text-slate-100">
<Layers3 className="h-4 w-4 text-blue-300" />
route decision
</div>
<span className="font-mono text-xs text-slate-500">POST /v1/chat</span>
</div>
<div className="space-y-3">
{preview.slice(0, 4).map((model, index) => (
<RouteRow
key={model.id || index}
model={model}
index={index}
active={index === 0}
/>
))}
</div>
</div>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-[1.1fr_0.9fr]">
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4">
<div className="mb-3 flex items-center gap-2 text-sm font-bold text-slate-950">
<KeyRound className="h-4 w-4 text-teal-700" />
{t('home.availableModels')}
</div>
<div className="grid gap-2 sm:grid-cols-2">
{preview.slice(0, 4).map((model, index) => (
<div key={model.id || index} className="flex items-center justify-between gap-3 rounded-lg border border-slate-200 bg-white px-3 py-2">
<span className="truncate font-mono text-xs font-semibold text-slate-700">{model.display_name || model.model_name}</span>
<CheckCircle2 className="h-4 w-4 shrink-0 text-teal-600" />
</div>
))}
</div>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-4">
<p className="text-xs font-bold uppercase tracking-wide text-slate-400">policy</p>
<p className="mt-2 text-sm font-black text-slate-950">best price + low latency</p>
<p className="mt-2 text-xs leading-5 text-slate-500">fallback enabled across healthy providers</p>
</div>
</div>
</div>
);
}
function RouteRow({ model, index, active }) {
const width = ['82%', '64%', '52%', '38%'][index] || '44%';
return (
<div className={`rounded-lg border p-3 ${active ? 'border-blue-400/40 bg-blue-400/10' : 'border-white/10 bg-white/[0.035]'}`}>
<div className="flex items-center justify-between gap-3">
<span className="truncate font-mono text-xs font-semibold text-slate-200">{model.display_name || model.model_name}</span>
<span className={active ? 'text-xs font-black text-blue-200' : 'text-xs font-semibold text-slate-500'}>
{active ? 'PRIMARY' : `R${index + 1}`}
</span>
</div>
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-white/10">
<div className="h-full rounded-full bg-gradient-to-r from-blue-400 to-teal-300" style={{ width }} />
</div>
</div>
);
}
function ModelTile({ model, index }) {
const active = index % 3 === 0;
return (
<div className="group rounded-xl border border-slate-200 bg-slate-50 p-4 transition-colors hover:border-blue-200 hover:bg-white">
<div className="mb-4 flex items-center justify-between">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-white text-blue-700 ring-1 ring-slate-200">
<Cpu className="h-4 w-4" />
</div>
<span className={`rounded-md px-2 py-1 text-xs font-bold ${active ? 'bg-blue-50 text-blue-700' : 'bg-teal-50 text-teal-700'}`}>
{active ? 'PRIMARY' : 'ONLINE'}
</span>
</div>
<p className="truncate font-mono text-sm font-semibold text-slate-900">{model.display_name || model.model_name}</p>
<p className="mt-2 text-xs font-semibold text-slate-500">balanced route pool</p>
</div>
);
}
function PackageCard({ pkg, index, models, fmtCNY, t, user }) {
const equiv = calcOfficialEquivList(models, packageQuotaDollars(pkg));
const featured = index === 1;
return (
<div className={`flex min-h-[280px] flex-col rounded-xl border p-6 shadow-sm ${featured ? 'border-blue-200 bg-blue-50' : 'border-slate-200 bg-white'}`}>
{featured && <span className="mb-3 w-fit rounded-md bg-blue-700 px-2.5 py-1 text-xs font-black uppercase tracking-wide text-white">{t('home.popular')}</span>}
<h3 className="text-lg font-black text-slate-950">{pkg.name}</h3>
{pkg.description && <p className="mt-2 text-sm leading-6 text-slate-600">{pkg.description}</p>}
<div className="mt-auto pt-6">
<span className="text-3xl font-black text-slate-950">{fmtCNY(pkg.price)}</span>
{pkg.original_price > pkg.price && <span className="ml-2 text-sm text-slate-400 line-through">{fmtCNY(pkg.original_price)}</span>}
{pkg.duration > 0 && <p className="mt-1 text-xs font-semibold text-slate-500">{t('home.days', { count: pkg.duration })}</p>}
{equiv.length > 0 && (
<p className="mt-3 text-xs font-semibold text-amber-700">
<RotatingEquiv items={equiv} text={(item) => t('packages.officialEquiv', { model: item.label, amount: item.equivDollars })} />
</p>
)}
</div>
<div className="mt-5">
<PrimaryLink to={user ? '/packages' : '/register'}>{user ? t('home.subscribe') : t('home.getStarted')}</PrimaryLink>
</div>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
import React from 'react';
import BrandLayout from '../shared/BrandLayout';
export default function AuroraLayout() {
return <BrandLayout variant="aurora" />;
}
+11 -1
View File
@@ -9,6 +9,8 @@ import RotatingEquiv from '../../components/bits/RotatingEquiv';
import CountUp from '../../components/bits/CountUp';
import FadeContent from '../../components/bits/FadeContent';
import ApiEndpoints from '../../components/ApiEndpoints';
import { getHomeContent } from '../../utils/siteContent';
import HomeHeroImage from '../shared/HomeHeroImage';
export default function ClaudeHome() {
const { t } = useTranslation();
@@ -24,6 +26,7 @@ export default function ClaudeHome() {
}, []);
const enabledModels = models.filter(m => m.enabled !== false);
const homeContent = getHomeContent(site, t);
return (
<div>
@@ -31,11 +34,14 @@ export default function ClaudeHome() {
<section className="max-w-5xl mx-auto px-6 pt-32 pb-20">
<FadeContent blur duration={800} delay={100}>
<div className="max-w-3xl">
<p className="mb-5 text-sm font-semibold tracking-wide text-[#D97757]">
{homeContent.heroTagline}
</p>
<h1 className="text-5xl md:text-6xl font-heading font-bold text-[#3D3024] leading-[1.1] tracking-tight">
{site?.name || t('home.defaultHeroTitle')}
</h1>
<p className="text-lg text-[#6B5D4F] mt-6 leading-relaxed max-w-xl">
{t('home.heroSubtitle')}
{homeContent.heroSubtitle}
</p>
<div className="flex items-center gap-4 mt-10">
@@ -54,6 +60,10 @@ export default function ClaudeHome() {
</>
)}
</div>
{homeContent.heroImage && (
<HomeHeroImage src={homeContent.heroImage} alt={site?.name} className="mt-12 aspect-[16/7]" />
)}
</div>
</FadeContent>
+9 -2
View File
@@ -9,6 +9,8 @@ import RotatingEquiv from '../../components/bits/RotatingEquiv';
import CountUp from '../../components/bits/CountUp';
import FadeContent from '../../components/bits/FadeContent';
import ApiEndpoints from '../../components/ApiEndpoints';
import { getHomeContent } from '../../utils/siteContent';
import HomeHeroImage from '../shared/HomeHeroImage';
export default function CleanHome() {
const { t } = useTranslation();
@@ -24,6 +26,7 @@ export default function CleanHome() {
}, []);
const enabledModels = models.filter(m => m.enabled !== false);
const homeContent = getHomeContent(site, t);
return (
<div>
@@ -31,12 +34,12 @@ export default function CleanHome() {
<section className="max-w-5xl mx-auto px-6 pt-32 pb-20">
<FadeContent blur duration={800} delay={100}>
<div className="text-center max-w-3xl mx-auto">
<p className="text-sm font-medium text-blue-600 mb-4 tracking-wide">{t('home.heroTagline') || 'AI API Platform'}</p>
<p className="text-sm font-medium text-blue-600 mb-4 tracking-wide">{homeContent.heroTagline}</p>
<h1 className="text-5xl md:text-6xl font-heading font-bold text-gray-900 leading-[1.1] tracking-tight">
{site?.name || t('home.defaultHeroTitle')}
</h1>
<p className="text-lg text-gray-500 mt-6 leading-relaxed max-w-xl mx-auto">
{t('home.heroSubtitle')}
{homeContent.heroSubtitle}
</p>
<div className="flex items-center justify-center gap-4 mt-10">
@@ -55,6 +58,10 @@ export default function CleanHome() {
</>
)}
</div>
{homeContent.heroImage && (
<HomeHeroImage src={homeContent.heroImage} alt={site?.name} className="mt-12 aspect-[16/7]" />
)}
</div>
</FadeContent>
+11 -1
View File
@@ -9,6 +9,8 @@ import RotatingEquiv from '../../components/bits/RotatingEquiv';
import CountUp from '../../components/bits/CountUp';
import FadeContent from '../../components/bits/FadeContent';
import ApiEndpoints from '../../components/ApiEndpoints';
import { getHomeContent } from '../../utils/siteContent';
import HomeHeroImage from '../shared/HomeHeroImage';
export default function CorporateHome() {
const { t } = useTranslation();
@@ -24,6 +26,7 @@ export default function CorporateHome() {
}, []);
const enabledModels = models.filter(m => m.enabled !== false);
const homeContent = getHomeContent(site, t);
return (
<div>
@@ -31,11 +34,14 @@ export default function CorporateHome() {
<section className="max-w-6xl mx-auto px-6 lg:px-8 pt-32 pb-20">
<FadeContent blur duration={800} delay={100}>
<div className="max-w-3xl">
<p className="mb-5 text-sm font-semibold uppercase tracking-wide text-slate-500">
{homeContent.heroTagline}
</p>
<h1 className="text-5xl md:text-6xl font-bold text-slate-900 leading-[1.1] tracking-tight">
{site?.name || t('home.defaultHeroTitle')}
</h1>
<p className="text-lg text-slate-500 mt-6 leading-relaxed max-w-xl">
{t('home.heroSubtitle')}
{homeContent.heroSubtitle}
</p>
<div className="flex items-center gap-4 mt-10">
@@ -54,6 +60,10 @@ export default function CorporateHome() {
</>
)}
</div>
{homeContent.heroImage && (
<HomeHeroImage src={homeContent.heroImage} alt={site?.name} className="mt-12 aspect-[16/7]" />
)}
</div>
</FadeContent>
+9 -2
View File
@@ -9,6 +9,8 @@ import RotatingEquiv from '../../components/bits/RotatingEquiv';
import CountUp from '../../components/bits/CountUp';
import FadeContent from '../../components/bits/FadeContent';
import ApiEndpoints from '../../components/ApiEndpoints';
import { getHomeContent } from '../../utils/siteContent';
import HomeHeroImage from '../shared/HomeHeroImage';
export default function DarkHome() {
const { t } = useTranslation();
@@ -24,6 +26,7 @@ export default function DarkHome() {
}, []);
const enabledModels = models.filter(m => m.enabled !== false);
const homeContent = getHomeContent(site, t);
return (
<div className="relative">
@@ -33,14 +36,14 @@ export default function DarkHome() {
<div className="max-w-3xl">
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full border border-emerald-500/20 mb-8">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
<span className="text-xs text-emerald-400/80 font-medium tracking-wide">{t('home.heroTagline') || 'All Systems Operational'}</span>
<span className="text-xs text-emerald-400/80 font-medium tracking-wide">{homeContent.heroTagline}</span>
</div>
<h1 className="text-5xl md:text-6xl font-heading font-bold text-white leading-[1.1] tracking-tight">
{site?.name || t('home.defaultHeroTitle')}
</h1>
<p className="text-lg text-neutral-400 mt-6 leading-relaxed max-w-xl">
{t('home.heroSubtitle')}
{homeContent.heroSubtitle}
</p>
<div className="flex items-center gap-4 mt-10">
@@ -59,6 +62,10 @@ export default function DarkHome() {
</>
)}
</div>
{homeContent.heroImage && (
<HomeHeroImage src={homeContent.heroImage} alt={site?.name} variant="dark" className="mt-12 aspect-[16/7]" />
)}
</div>
</FadeContent>
+283 -151
View File
@@ -1,6 +1,16 @@
import React, { useState, useEffect } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Activity,
ArrowRight,
CheckCircle2,
Cpu,
Gauge,
KeyRound,
ShieldCheck,
WalletCards,
} from 'lucide-react';
import { useAuth } from '../../context/AuthContext';
import { useSite, useCurrency } from '../../context/SiteContext';
import { getSiteModels, getSitePackages, Q } from '../../api';
@@ -9,12 +19,14 @@ import RotatingEquiv from '../../components/bits/RotatingEquiv';
import CountUp from '../../components/bits/CountUp';
import FadeContent from '../../components/bits/FadeContent';
import ApiEndpoints from '../../components/ApiEndpoints';
import { getHomeContent } from '../../utils/siteContent';
import HomeHeroImage from '../shared/HomeHeroImage';
export default function DefaultHome() {
const { t } = useTranslation();
const { user } = useAuth();
const { site } = useSite();
const { symbol, rate, fmtCNY } = useCurrency();
const { fmtCNY } = useCurrency();
const [models, setModels] = useState([]);
const [packages, setPackages] = useState([]);
@@ -23,141 +35,172 @@ export default function DefaultHome() {
getSitePackages().then(r => { if (r.data.success) setPackages(r.data.data || []); }).catch(() => {});
}, []);
const enabledModels = models.filter(m => m.enabled !== false);
const enabledModels = useMemo(() => models.filter(m => m.enabled !== false), [models]);
const visiblePackages = useMemo(() => packages.filter(p => p.enabled), [packages]);
const modelPreview = enabledModels.slice(0, 6);
const homeContent = getHomeContent(site, t);
const features = [
{
title: t('home.lightningFast'),
desc: t('home.lightningFastDesc'),
icon: Gauge,
tone: 'bg-indigo-50 text-indigo-700 ring-indigo-100',
},
{
title: t('home.securePrivate'),
desc: t('home.securePrivateDesc'),
icon: ShieldCheck,
tone: 'bg-emerald-50 text-emerald-700 ring-emerald-100',
},
{
title: t('home.payAsYouGo'),
desc: t('home.payAsYouGoDesc'),
icon: WalletCards,
tone: 'bg-amber-50 text-amber-700 ring-amber-100',
},
];
return (
<div className="relative">
{/* Subtle gradient */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-40 left-1/4 w-[600px] h-[600px] bg-violet-600/10 rounded-full blur-3xl" />
<div className="absolute top-20 right-1/4 w-[400px] h-[400px] bg-blue-600/8 rounded-full blur-3xl" />
</div>
<div className="relative overflow-hidden">
<section className="relative border-b border-slate-200 bg-[linear-gradient(180deg,#ffffff_0%,#f7f8fb_100%)]">
<div className="absolute inset-0 pointer-events-none opacity-[0.55] [background-image:linear-gradient(#e5e7eb_1px,transparent_1px),linear-gradient(90deg,#e5e7eb_1px,transparent_1px)] [background-size:44px_44px]" />
{/* Hero — centered, editorial */}
<section className="relative max-w-5xl mx-auto px-6 pt-32 pb-20">
<FadeContent blur duration={800} delay={100}>
<div className="text-center max-w-3xl mx-auto">
<h1 className="text-5xl md:text-7xl font-heading font-extrabold text-white leading-[1.08] tracking-tight">
{site?.name || t('home.defaultHeroTitle')}
</h1>
<p className="text-lg text-neutral-400 mt-6 leading-relaxed max-w-xl mx-auto">
{t('home.heroSubtitle')}
</p>
<div className="flex items-center justify-center gap-4 mt-10">
{user ? (
<Link to="/dashboard" className="px-8 py-3.5 rounded-2xl bg-white text-black font-semibold text-sm hover:bg-neutral-100 transition-all">
{t('home.goToDashboard')}
</Link>
) : (
<>
<Link to="/register" className="px-8 py-3.5 rounded-2xl bg-white text-black font-semibold text-sm hover:bg-neutral-100 transition-all">
{t('home.getStarted')}
</Link>
<Link to="/pricing" className="px-8 py-3.5 rounded-2xl border border-white/15 text-white font-medium text-sm hover:bg-white/5 transition-all">
{t('home.viewPricing')}
</Link>
</>
)}
</div>
</div>
</FadeContent>
{/* Stats */}
<FadeContent blur duration={800} delay={400}>
<div className="grid grid-cols-3 gap-6 max-w-xl mx-auto mt-24">
{[
{ value: enabledModels.length || 50, suffix: '+', label: t('home.aiModels') },
{ value: 99.9, suffix: '%', label: t('home.uptime') },
{ value: 50, suffix: 'ms', label: t('home.latency'), prefix: '<' },
].map((stat, i) => (
<div key={i} className="text-center p-5 rounded-2xl bg-white/[0.03] border border-white/[0.06]">
<div className="text-2xl font-bold text-white">
{stat.prefix}<CountUp from={0} to={stat.value} duration={2} />{stat.suffix}
</div>
<p className="text-sm text-neutral-500 mt-1">{stat.label}</p>
<div className="relative mx-auto grid max-w-7xl gap-12 px-4 pb-16 pt-16 sm:px-6 lg:grid-cols-[1.02fr_0.98fr] lg:items-center lg:pb-20 lg:pt-20">
<FadeContent blur duration={700} delay={80}>
<div className="max-w-2xl">
<div className="mb-5 inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-600 shadow-sm">
<Activity className="h-4 w-4 text-emerald-600" />
{homeContent.heroTagline}
</div>
))}
</div>
</FadeContent>
<h1 className="text-4xl font-black tracking-tight text-slate-950 sm:text-5xl lg:text-6xl">
{site?.name || t('home.defaultHeroTitle')}
</h1>
<p className="mt-6 max-w-xl text-base leading-8 text-slate-600 sm:text-lg">
{homeContent.heroSubtitle}
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
{user ? (
<Link
to="/dashboard"
className="inline-flex items-center justify-center gap-2 rounded-lg bg-slate-950 px-6 py-3 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-indigo-700"
>
{t('home.goToDashboard')}
<ArrowRight className="h-4 w-4" />
</Link>
) : (
<>
<Link
to="/register"
className="inline-flex items-center justify-center gap-2 rounded-lg bg-slate-950 px-6 py-3 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-indigo-700"
>
{t('home.getStarted')}
<ArrowRight className="h-4 w-4" />
</Link>
<Link
to="/pricing"
className="inline-flex items-center justify-center rounded-lg border border-slate-300 bg-white px-6 py-3 text-sm font-semibold text-slate-800 shadow-sm transition-colors hover:border-slate-400 hover:bg-slate-50"
>
{t('home.viewPricing')}
</Link>
</>
)}
</div>
<div className="mt-10 grid max-w-xl grid-cols-3 gap-3">
<Metric value={enabledModels.length || 50} suffix="+" label={t('home.aiModels')} />
<Metric value={99.9} suffix="%" label={t('home.uptime')} />
<Metric value={50} prefix="<" suffix="ms" label={t('home.latency')} />
</div>
</div>
</FadeContent>
<FadeContent blur duration={700} delay={220}>
{homeContent.heroImage ? (
<HomeHeroImage src={homeContent.heroImage} alt={site?.name} className="aspect-[4/3]" />
) : (
<HeroConsole models={modelPreview} t={t} />
)}
</FadeContent>
</div>
</section>
<ApiEndpoints />
{/* Features */}
<section className="max-w-5xl mx-auto px-6 py-20">
<FadeContent blur duration={800} delay={100}>
<div className="text-center mb-14">
<h2 className="text-2xl font-heading font-bold text-white mb-3">{t('home.whyChooseUs')}</h2>
<p className="text-neutral-500">{t('home.whyChooseUsDesc')}</p>
<section className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:py-20">
<FadeContent blur duration={700} delay={80}>
<div className="mb-8 flex flex-col justify-between gap-3 md:flex-row md:items-end">
<div>
<h2 className="text-2xl font-black tracking-tight text-slate-950">{t('home.whyChooseUs')}</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">{t('home.whyChooseUsDesc')}</p>
</div>
</div>
<div className="grid md:grid-cols-3 gap-5">
{[
{ title: t('home.lightningFast'), desc: t('home.lightningFastDesc'), color: 'violet', icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
)},
{ title: t('home.securePrivate'), desc: t('home.securePrivateDesc'), color: 'blue', icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
)},
{ title: t('home.payAsYouGo'), desc: t('home.payAsYouGoDesc'), color: 'cyan', icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)},
].map((f, i) => (
<div key={i} className="p-6 rounded-xl bg-white/[0.02] border border-white/[0.06] hover:border-white/[0.12] transition-colors">
<div className={`w-10 h-10 rounded-lg bg-${f.color}-500/10 text-${f.color}-400 flex items-center justify-center mb-4`}>
{f.icon}
<div className="grid gap-4 md:grid-cols-3">
{features.map((feature) => {
const Icon = feature.icon;
return (
<div key={feature.title} className="rounded-lg border border-slate-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md">
<div className={`mb-5 flex h-11 w-11 items-center justify-center rounded-lg ring-1 ${feature.tone}`}>
<Icon className="h-5 w-5" />
</div>
<h3 className="text-base font-bold text-slate-950">{feature.title}</h3>
<p className="mt-2 text-sm leading-6 text-slate-600">{feature.desc}</p>
</div>
<h3 className="text-base font-semibold text-white mb-2">{f.title}</h3>
<p className="text-sm text-neutral-400 leading-relaxed">{f.desc}</p>
</div>
))}
);
})}
</div>
</FadeContent>
</section>
{/* Models */}
{enabledModels.length > 0 && (
<section className="max-w-5xl mx-auto px-6 py-20">
<FadeContent blur duration={800} delay={100}>
<div className="flex items-end justify-between mb-8">
<div>
<h2 className="text-2xl font-heading font-bold text-white mb-2">{t('home.availableModels')}</h2>
<p className="text-neutral-500">{t('home.availableModelsDesc', { count: enabledModels.length })}</p>
</div>
{enabledModels.length > 8 && (
<Link to="/pricing" className="text-sm text-violet-400 hover:text-violet-300 transition-colors">
{t('home.viewAllModels', { count: enabledModels.length })} →
</Link>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{enabledModels.slice(0, 8).map((m, i) => (
<div key={m.id || i} className="px-4 py-3 rounded-lg border border-white/[0.06] hover:border-white/[0.12] transition-colors">
<span className="text-sm text-neutral-300 font-mono">{m.display_name || m.model_name}</span>
<section className="border-y border-slate-200 bg-white">
<div className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:py-20">
<FadeContent blur duration={700} delay={80}>
<div className="mb-8 flex flex-col justify-between gap-4 md:flex-row md:items-end">
<div>
<h2 className="text-2xl font-black tracking-tight text-slate-950">{t('home.availableModels')}</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
{t('home.availableModelsDesc', { count: enabledModels.length })}
</p>
</div>
))}
</div>
</FadeContent>
{enabledModels.length > 8 && (
<Link to="/pricing" className="inline-flex items-center gap-1.5 text-sm font-semibold text-indigo-700 hover:text-indigo-900">
{t('home.viewAllModels', { count: enabledModels.length })}
<ArrowRight className="h-4 w-4" />
</Link>
)}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
{enabledModels.slice(0, 8).map((m, i) => (
<div key={m.id || i} className="flex min-h-16 items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 px-4 py-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-white text-indigo-700 ring-1 ring-slate-200">
<Cpu className="h-4 w-4" />
</div>
<span className="min-w-0 truncate font-mono text-sm font-medium text-slate-800">
{m.display_name || m.model_name}
</span>
</div>
))}
</div>
</FadeContent>
</div>
</section>
)}
{/* Packages */}
{packages.length > 0 && (
<section className="max-w-5xl mx-auto px-6 py-20">
<FadeContent blur duration={800} delay={100}>
<h2 className="text-2xl font-heading font-bold text-white mb-2">{t('home.plansPackages')}</h2>
<p className="text-neutral-500 mb-10">{t('home.choosePlan')}</p>
{visiblePackages.length > 0 && (
<section className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:py-20">
<FadeContent blur duration={700} delay={80}>
<div className="mb-8">
<h2 className="text-2xl font-black tracking-tight text-slate-950">{t('home.plansPackages')}</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">{t('home.choosePlan')}</p>
</div>
<div className="grid md:grid-cols-3 gap-4 max-w-4xl">
{packages.filter(p => p.enabled).slice(0, 3).map((pkg, i) => {
<div className="grid gap-4 lg:grid-cols-3">
{visiblePackages.slice(0, 3).map((pkg, i) => {
const quotaDollars = pkg.quota_amount > 0 ? pkg.quota_amount / Q : 0;
const rp = pkg.quota_reset_period || 'never';
let tqd = quotaDollars;
@@ -167,62 +210,151 @@ export default function DefaultHome() {
tqd = quotaDollars * n;
}
const equiv = calcOfficialEquivList(enabledModels, tqd);
const featured = i === 1;
return (
<div key={pkg.id} className={`rounded-2xl p-6 flex flex-col border transition-colors ${
i === 1 ? 'bg-white/[0.04] border-white/[0.12]' : 'bg-white/[0.02] border-white/[0.06] hover:border-white/[0.1]'
}`}>
{i === 1 && <span className="text-xs text-violet-400 font-medium mb-2 uppercase tracking-wider">{t('home.popular') || 'Popular'}</span>}
<h3 className="text-base font-semibold text-white">{pkg.name}</h3>
{pkg.description && <p className="text-sm text-neutral-500 mt-1">{pkg.description}</p>}
<div className="mt-auto pt-6">
<span className="text-3xl font-bold text-white">{fmtCNY(pkg.price)}</span>
{pkg.original_price > pkg.price && (
<span className="text-sm text-neutral-600 line-through ml-2">{fmtCNY(pkg.original_price)}</span>
<div
key={pkg.id}
className={`flex min-h-[280px] flex-col rounded-lg border p-6 shadow-sm ${
featured ? 'border-indigo-200 bg-indigo-50/70' : 'border-slate-200 bg-white'
}`}
>
{featured && (
<span className="mb-3 w-fit rounded-full bg-indigo-700 px-2.5 py-1 text-xs font-bold uppercase tracking-wide text-white">
{t('home.popular')}
</span>
)}
{pkg.duration > 0 && <p className="text-xs text-neutral-600 mt-1">{t('home.days', { count: pkg.duration })}</p>}
<h3 className="text-lg font-bold text-slate-950">{pkg.name}</h3>
{pkg.description && <p className="mt-2 text-sm leading-6 text-slate-600">{pkg.description}</p>}
<div className="mt-auto pt-6">
<div className="flex items-end gap-2">
<span className="text-3xl font-black tracking-tight text-slate-950">{fmtCNY(pkg.price)}</span>
{pkg.original_price > pkg.price && (
<span className="pb-1 text-sm text-slate-400 line-through">{fmtCNY(pkg.original_price)}</span>
)}
</div>
{pkg.duration > 0 && <p className="mt-1 text-xs font-medium text-slate-500">{t('home.days', { count: pkg.duration })}</p>}
{equiv.length > 0 && (
<p className="mt-3 text-xs font-medium text-amber-700">
<RotatingEquiv items={equiv} text={(item) => t('packages.officialEquiv', { model: item.label, amount: item.equivDollars })} />
</p>
)}
</div>
<Link
to={user ? '/packages' : '/register'}
className={`mt-5 inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold transition-colors ${
featured ? 'bg-indigo-700 text-white hover:bg-indigo-800' : 'bg-slate-950 text-white hover:bg-indigo-700'
}`}
>
{user ? t('home.subscribe') : t('home.getStarted')}
<ArrowRight className="h-4 w-4" />
</Link>
</div>
{equiv.length > 0 && (
<p className="text-xs text-amber-300/80 mt-2">🔥 <RotatingEquiv items={equiv} text={(item) => t('packages.officialEquiv', { model: item.label, amount: item.equivDollars })} /></p>
)}
<Link to={user ? '/packages' : '/register'} className={`mt-4 py-2.5 rounded-xl font-medium text-sm text-center transition-colors ${
i === 1
? 'bg-white text-black hover:bg-neutral-100'
: 'bg-white/[0.06] text-white hover:bg-white/[0.1]'
}`}>
{user ? t('home.subscribe') : t('home.getStarted')}
</Link>
</div>
)})}
);
})}
</div>
</FadeContent>
</section>
)}
{/* CTA */}
<section className="max-w-5xl mx-auto px-6 py-24">
<FadeContent blur duration={800} delay={100}>
<div className="border-t border-white/[0.06] pt-16 text-center">
<h2 className="text-2xl font-heading font-bold text-white mb-3">{t('home.readyToStart')}</h2>
<p className="text-neutral-500 mb-8 max-w-md mx-auto">{t('home.readyToStartDesc')}</p>
<div className="flex items-center justify-center gap-4">
<section className="border-t border-slate-200 bg-slate-950">
<div className="mx-auto flex max-w-7xl flex-col gap-8 px-4 py-14 text-white sm:px-6 md:flex-row md:items-center md:justify-between">
<FadeContent blur duration={700} delay={80}>
<div>
<h2 className="text-2xl font-black tracking-tight">{t('home.readyToStart')}</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-300">{t('home.readyToStartDesc')}</p>
</div>
</FadeContent>
<FadeContent blur duration={700} delay={160}>
<div className="flex flex-col gap-3 sm:flex-row">
{user ? (
<Link to="/dashboard" className="px-8 py-3.5 rounded-2xl bg-white text-black font-semibold text-sm hover:bg-neutral-100 transition-all">
<Link to="/dashboard" className="inline-flex items-center justify-center gap-2 rounded-lg bg-white px-5 py-3 text-sm font-bold text-slate-950 hover:bg-slate-100">
{t('home.goToDashboard')}
<ArrowRight className="h-4 w-4" />
</Link>
) : (
<>
<Link to="/register" className="px-8 py-3.5 rounded-2xl bg-white text-black font-semibold text-sm hover:bg-neutral-100 transition-all">
<Link to="/register" className="inline-flex items-center justify-center gap-2 rounded-lg bg-white px-5 py-3 text-sm font-bold text-slate-950 hover:bg-slate-100">
{t('home.createFreeAccount')}
<ArrowRight className="h-4 w-4" />
</Link>
<Link to="/login" className="px-8 py-3.5 rounded-2xl border border-white/15 text-white font-medium text-sm hover:bg-white/5 transition-all">
<Link to="/login" className="inline-flex items-center justify-center rounded-lg border border-white/20 px-5 py-3 text-sm font-bold text-white hover:bg-white/10">
{t('home.signIn')}
</Link>
</>
)}
</div>
</div>
</FadeContent>
</FadeContent>
</div>
</section>
</div>
);
}
function Metric({ value, label, prefix = '', suffix = '' }) {
return (
<div className="rounded-lg border border-slate-200 bg-white/90 p-4 shadow-sm">
<div className="text-xl font-black tracking-tight text-slate-950">
{prefix}<CountUp from={0} to={value} duration={2} />{suffix}
</div>
<p className="mt-1 truncate text-xs font-medium text-slate-500">{label}</p>
</div>
);
}
function HeroConsole({ models, t }) {
const rows = models.length > 0 ? models : [
{ display_name: 'gpt-4o-mini' },
{ display_name: 'claude-3.5-sonnet' },
{ display_name: 'gemini-1.5-pro' },
];
return (
<div className="rounded-2xl border border-slate-200 bg-white p-3 shadow-2xl shadow-slate-200/80">
<div className="overflow-hidden rounded-xl border border-slate-200 bg-slate-950">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-red-400" />
<span className="h-2.5 w-2.5 rounded-full bg-amber-400" />
<span className="h-2.5 w-2.5 rounded-full bg-emerald-400" />
</div>
<span className="font-mono text-xs text-slate-400">/v1/chat/completions</span>
</div>
<div className="grid gap-0 lg:grid-cols-[1fr_0.86fr]">
<div className="border-b border-white/10 p-5 lg:border-b-0 lg:border-r">
<div className="mb-4 flex items-center gap-2 text-sm font-semibold text-white">
<KeyRound className="h-4 w-4 text-emerald-400" />
{t('nav.apiKeys')}
</div>
<pre className="overflow-hidden rounded-lg bg-black/40 p-4 text-xs leading-6 text-slate-300">
{`curl https://api.example.com/v1/chat/completions
-H "Authorization: Bearer sk-..."
-d '{"model":"${rows[0]?.display_name || rows[0]?.model_name}"}'`}
</pre>
</div>
<div className="p-5">
<div className="mb-4 flex items-center gap-2 text-sm font-semibold text-white">
<CheckCircle2 className="h-4 w-4 text-indigo-300" />
{t('home.availableModels')}
</div>
<div className="space-y-2">
{rows.slice(0, 4).map((model, index) => (
<div key={model.id || model.model_name || model.display_name || index} className="flex items-center justify-between rounded-lg bg-white/[0.06] px-3 py-2">
<span className="min-w-0 truncate font-mono text-xs text-slate-200">
{model.display_name || model.model_name}
</span>
<span className="ml-3 rounded-full bg-emerald-400/10 px-2 py-0.5 text-[11px] font-semibold text-emerald-300">OK</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}
+99 -60
View File
@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Menu, X, LogOut, UserCircle, ArrowRight } from 'lucide-react';
import { useAuth } from '../../context/AuthContext';
import { useSite } from '../../context/SiteContext';
import LanguageSwitch from '../../components/LanguageSwitch';
@@ -28,109 +29,134 @@ export default function DefaultLayout() {
const visibleNavItems = navItems.filter((n) => !n.auth || user);
const handleLogout = async () => {
await logout();
navigate('/');
};
return (
<div className="min-h-screen flex flex-col bg-[#09090b]">
{/* Announcement Banner */}
<div className="theme-light theme-starter min-h-screen flex flex-col bg-[#f7f8fb] text-slate-950">
{site?.announcement && (
<div className="bg-gradient-to-r from-violet-600/10 via-blue-600/10 to-cyan-600/10 border-b border-white/[0.06] text-center py-2.5 px-4">
<p className="text-sm text-neutral-300">{site.announcement}</p>
<div className="border-b border-slate-200 bg-slate-950 px-4 py-2.5 text-center">
<p className="text-sm font-medium text-white/90">{site.announcement}</p>
</div>
)}
{/* Header */}
<header className="sticky top-0 z-50 backdrop-blur-2xl bg-[#09090b]/80 border-b border-white/[0.06]">
<div className="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
{/* Logo */}
<Link to="/" className="flex items-center gap-3 group">
<header className="sticky top-0 z-50 border-b border-slate-200/80 bg-white/90 backdrop-blur-xl">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6">
<Link to="/" className="flex min-w-0 items-center gap-3 group">
{site?.logo ? (
<img src={site.logo} alt={siteName} className="h-8 w-auto" />
<img src={site.logo} alt={siteName} className="h-8 w-auto max-w-[150px] object-contain" />
) : (
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-violet-500 via-blue-500 to-cyan-500 flex items-center justify-center text-white font-bold text-sm shadow-lg shadow-blue-500/20">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-slate-950 text-sm font-bold text-white shadow-sm">
{siteName.charAt(0)}
</div>
)}
<span className="text-lg font-semibold text-white group-hover:text-blue-400 transition-colors">
<span className="truncate text-base font-bold tracking-tight text-slate-950 group-hover:text-indigo-700 sm:text-lg">
{siteName}
</span>
</Link>
{/* Desktop Nav */}
<nav className="hidden md:flex items-center gap-1 bg-white/[0.04] rounded-full px-1.5 py-1 border border-white/[0.06]">
<nav className="hidden items-center gap-1 rounded-full border border-slate-200 bg-slate-50 p-1 md:flex">
{visibleNavItems.map((n) => (
<Link
key={n.to}
to={n.to}
className={`px-4 py-1.5 text-sm rounded-full transition-all ${
location.pathname === n.to
? 'text-white bg-white/[0.08]'
: 'text-neutral-400 hover:text-white'
}`}
>
<NavLink key={n.to} to={n.to} active={location.pathname === n.to}>
{n.label}
</Link>
</NavLink>
))}
</nav>
{/* Auth + Lang + Mobile toggle */}
<div className="flex items-center gap-2">
<LanguageSwitch className="text-neutral-400 hover:text-white hover:bg-white/5" />
<LanguageSwitch className="text-slate-500 hover:bg-slate-100 hover:text-slate-950" />
{user ? (
<div className="flex items-center gap-3">
<span className="text-sm text-neutral-400 hidden sm:block">
{user.display_name || user.username}
</span>
<div className="hidden items-center gap-2 sm:flex">
<div className="flex max-w-[180px] items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-600 shadow-sm">
<UserCircle className="h-4 w-4 shrink-0 text-slate-400" />
<span className="truncate">{user.display_name || user.username}</span>
</div>
<button
onClick={async () => { await logout(); navigate('/'); }}
className="text-sm text-neutral-400 hover:text-white transition-colors"
onClick={handleLogout}
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-950"
aria-label={t('nav.logout')}
title={t('nav.logout')}
>
{t('nav.logout')}
<LogOut className="h-4 w-4" />
</button>
</div>
) : (
<>
<Link to="/login" className="text-sm text-neutral-400 hover:text-white transition-colors px-3 py-1.5 hidden sm:block">
<div className="hidden items-center gap-2 sm:flex">
<Link to="/login" className="rounded-full px-3 py-2 text-sm font-medium text-slate-600 hover:text-slate-950">
{t('nav.login')}
</Link>
<Link to="/register" className="px-5 py-2 rounded-full bg-white text-black text-sm font-medium hover:bg-neutral-100 transition-all">
<Link
to="/register"
className="inline-flex items-center gap-1.5 rounded-full bg-slate-950 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-indigo-700"
>
{t('nav.signUp')}
<ArrowRight className="h-4 w-4" />
</Link>
</>
</div>
)}
<button
className="md:hidden p-1.5 rounded-lg hover:bg-white/5 transition-colors"
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-950 md:hidden"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label="Toggle menu"
>
<svg className="w-5 h-5 text-neutral-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{mobileMenuOpen ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
)}
</svg>
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div>
</div>
{mobileMenuOpen && (
<div className="md:hidden border-t border-white/[0.06] bg-[#09090b]/95 backdrop-blur-2xl">
<nav className="max-w-7xl mx-auto px-6 py-3 flex flex-col gap-1">
<div className="border-t border-slate-200 bg-white md:hidden">
<nav className="mx-auto flex max-w-7xl flex-col gap-1 px-4 py-3 sm:px-6">
{visibleNavItems.map((n) => (
<Link
key={n.to}
to={n.to}
onClick={() => setMobileMenuOpen(false)}
className={`px-3 py-2 text-sm rounded-lg transition-colors ${
className={`rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
location.pathname === n.to
? 'text-blue-400 bg-blue-500/10'
: 'text-neutral-400 hover:text-white hover:bg-white/5'
? 'bg-indigo-50 text-indigo-700'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-950'
}`}
>
{n.label}
</Link>
))}
<div className="mt-2 border-t border-slate-200 pt-3 sm:hidden">
{user ? (
<button
onClick={async () => {
setMobileMenuOpen(false);
await handleLogout();
}}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium text-slate-600 hover:bg-slate-50 hover:text-slate-950"
>
<LogOut className="h-4 w-4" />
{t('nav.logout')}
</button>
) : (
<div className="grid grid-cols-2 gap-2">
<Link
to="/login"
onClick={() => setMobileMenuOpen(false)}
className="rounded-lg border border-slate-200 px-3 py-2 text-center text-sm font-medium text-slate-700"
>
{t('nav.login')}
</Link>
<Link
to="/register"
onClick={() => setMobileMenuOpen(false)}
className="rounded-lg bg-slate-950 px-3 py-2 text-center text-sm font-semibold text-white"
>
{t('nav.signUp')}
</Link>
</div>
)}
</div>
</nav>
</div>
)}
@@ -140,18 +166,31 @@ export default function DefaultLayout() {
<Outlet />
</main>
<footer className="border-t border-white/[0.06] mt-auto bg-[#09090b]">
<div className="max-w-7xl mx-auto px-6 py-8 flex flex-col md:flex-row items-center justify-between gap-4">
<p className="text-sm text-neutral-500">&copy; {new Date().getFullYear()} {siteName}.</p>
<div className="flex items-center gap-4">
{site?.contact_email && (
<a href={`mailto:${site.contact_email}`} className="text-sm text-neutral-500 hover:text-neutral-300 transition-colors">
{t('nav.contact')}
</a>
)}
</div>
<footer className="mt-auto border-t border-slate-200 bg-white">
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-8 text-sm text-slate-500 sm:px-6 md:flex-row md:items-center md:justify-between">
<p>&copy; {new Date().getFullYear()} {siteName}</p>
{site?.contact_email && (
<a href={`mailto:${site.contact_email}`} className="font-medium text-slate-500 hover:text-slate-950">
{t('nav.contact')}
</a>
)}
</div>
</footer>
</div>
);
}
function NavLink({ to, children, active }) {
return (
<Link
to={to}
className={`rounded-full px-3.5 py-1.5 text-sm font-medium transition-colors ${
active
? 'bg-white text-slate-950 shadow-sm ring-1 ring-slate-200'
: 'text-slate-600 hover:text-slate-950'
}`}
>
{children}
</Link>
);
}
+272
View File
@@ -0,0 +1,272 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowRight, BadgeCheck, Boxes, Cpu, Gem, Layers3, PackageCheck, ShoppingBag, Tags } from 'lucide-react';
import { useAuth } from '../../context/AuthContext';
import { useSite, useCurrency } from '../../context/SiteContext';
import { calcOfficialEquivList } from '../../utils/officialEquiv';
import { packageQuotaDollars, useHomeData } from '../shared/useHomeData';
import CountUp from '../../components/bits/CountUp';
import FadeContent from '../../components/bits/FadeContent';
import RotatingEquiv from '../../components/bits/RotatingEquiv';
import ApiEndpoints from '../../components/ApiEndpoints';
import { getHomeContent } from '../../utils/siteContent';
import HomeHeroImage from '../shared/HomeHeroImage';
const accents = [
{ border: 'border-orange-200', soft: 'bg-orange-50', text: 'text-orange-700', line: 'bg-orange-500' },
{ border: 'border-amber-200', soft: 'bg-amber-50', text: 'text-amber-700', line: 'bg-amber-400' },
{ border: 'border-emerald-200', soft: 'bg-emerald-50', text: 'text-emerald-700', line: 'bg-emerald-500' },
{ border: 'border-sky-200', soft: 'bg-sky-50', text: 'text-sky-700', line: 'bg-sky-500' },
{ border: 'border-violet-200', soft: 'bg-violet-50', text: 'text-violet-700', line: 'bg-violet-500' },
{ border: 'border-rose-200', soft: 'bg-rose-50', text: 'text-rose-700', line: 'bg-rose-500' },
];
export default function MarketHome() {
const { t } = useTranslation();
const { user } = useAuth();
const { site } = useSite();
const { fmtCNY } = useCurrency();
const { enabledModels, visiblePackages } = useHomeData();
const models = enabledModels.slice(0, 8);
const homeContent = getHomeContent(site, t);
return (
<div className="relative overflow-hidden bg-[#fbfaf7] text-stone-950">
<section className="relative border-b border-stone-200 bg-[#fbfaf7]">
<div className="absolute inset-0 bg-[linear-gradient(180deg,#fbfaf7_0%,#ffffff_100%)]" />
<div className="absolute inset-0 opacity-[0.16] [background-image:linear-gradient(#d6d3d1_1px,transparent_1px),linear-gradient(90deg,#d6d3d1_1px,transparent_1px)] [background-size:48px_48px]" />
<div className="relative mx-auto grid min-h-[calc(100vh-4rem)] max-w-7xl gap-12 px-4 pb-16 pt-16 sm:px-6 lg:grid-cols-[0.95fr_1.05fr] lg:items-center lg:pb-20 lg:pt-20">
<FadeContent blur duration={700} delay={80}>
<div className="max-w-2xl">
<div className="mb-5 inline-flex items-center gap-2 rounded-lg border border-stone-200 bg-white px-3 py-1.5 text-sm font-bold text-stone-700 shadow-sm">
<ShoppingBag className="h-4 w-4 text-orange-600" />
{homeContent.heroTagline}
</div>
<h1 className="text-4xl font-black tracking-tight text-stone-950 sm:text-5xl lg:text-6xl">
{site?.name || t('home.defaultHeroTitle')}
</h1>
<p className="mt-6 max-w-xl text-base leading-8 text-stone-600 sm:text-lg">
{homeContent.heroSubtitle}
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<MarketButton to={user ? '/dashboard' : '/register'}>
{user ? t('home.goToDashboard') : t('home.getStarted')}
</MarketButton>
<Link to="/packages" className="inline-flex items-center justify-center rounded-lg border border-stone-300 bg-white px-6 py-3 text-sm font-bold text-stone-800 shadow-sm transition-colors hover:border-stone-400 hover:bg-stone-50">
{t('home.plansPackages')}
</Link>
</div>
<div className="mt-10 grid max-w-xl grid-cols-3 gap-3">
<Metric value={enabledModels.length || 50} suffix="+" label={t('home.aiModels')} />
<Metric value={99.9} suffix="%" label={t('home.uptime')} />
<Metric value={50} prefix="<" suffix="ms" label={t('home.latency')} />
</div>
</div>
</FadeContent>
<FadeContent blur duration={700} delay={180}>
{homeContent.heroImage ? (
<HomeHeroImage src={homeContent.heroImage} alt={site?.name} className="aspect-[4/3]" />
) : (
<MarketBoard models={models} t={t} />
)}
</FadeContent>
</div>
</section>
<ApiEndpoints />
<section className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:py-20">
<SectionTitle title={t('home.whyChooseUs')} desc={t('home.whyChooseUsDesc')} />
<div className="grid gap-4 md:grid-cols-3">
{[
{ icon: Layers3, title: t('home.lightningFast'), desc: t('home.lightningFastDesc'), accent: accents[3] },
{ icon: BadgeCheck, title: t('home.securePrivate'), desc: t('home.securePrivateDesc'), accent: accents[2] },
{ icon: Tags, title: t('home.payAsYouGo'), desc: t('home.payAsYouGoDesc'), accent: accents[0] },
].map((item) => {
const Icon = item.icon;
return (
<div key={item.title} className="rounded-xl border border-stone-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md">
<div className={`mb-5 flex h-11 w-11 items-center justify-center rounded-lg ${item.accent.soft} ${item.accent.text}`}>
<Icon className="h-5 w-5" />
</div>
<h3 className="font-black text-stone-950">{item.title}</h3>
<p className="mt-2 text-sm leading-6 text-stone-600">{item.desc}</p>
</div>
);
})}
</div>
</section>
{enabledModels.length > 0 && (
<section className="border-y border-stone-200 bg-white">
<div className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:py-20">
<div className="mb-8 flex flex-col justify-between gap-4 md:flex-row md:items-end">
<SectionTitle title={t('home.availableModels')} desc={t('home.availableModelsDesc', { count: enabledModels.length })} compact />
{enabledModels.length > 8 && (
<Link to="/pricing" className="inline-flex items-center gap-1.5 text-sm font-black text-orange-700 hover:text-stone-950">
{t('home.viewAllModels', { count: enabledModels.length })}
<ArrowRight className="h-4 w-4" />
</Link>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{models.map((model, index) => <ModelCard key={model.id || index} model={model} index={index} />)}
</div>
</div>
</section>
)}
{visiblePackages.length > 0 && (
<section className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:py-20">
<SectionTitle title={t('home.plansPackages')} desc={t('home.choosePlan')} />
<div className="grid gap-4 lg:grid-cols-3">
{visiblePackages.slice(0, 3).map((pkg, index) => (
<PackageCard key={pkg.id} pkg={pkg} index={index} models={enabledModels} fmtCNY={fmtCNY} t={t} user={user} />
))}
</div>
</section>
)}
<section className="border-t border-stone-200 bg-stone-950">
<div className="mx-auto flex max-w-7xl flex-col gap-6 px-4 py-14 text-white sm:px-6 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-2xl font-black tracking-tight">{t('home.readyToStart')}</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-stone-300">{t('home.readyToStartDesc')}</p>
</div>
<MarketButton to={user ? '/dashboard' : '/register'} light>
{user ? t('home.goToDashboard') : t('home.createFreeAccount')}
</MarketButton>
</div>
</section>
</div>
);
}
function MarketButton({ to, children, light = false }) {
return (
<Link
to={to}
className={`inline-flex items-center justify-center gap-2 rounded-lg px-6 py-3 text-sm font-black shadow-sm transition-colors ${
light ? 'bg-white text-stone-950 hover:bg-stone-100' : 'bg-stone-950 text-white hover:bg-orange-600'
}`}
>
{children}
<ArrowRight className="h-4 w-4" />
</Link>
);
}
function Metric({ value, label, prefix = '', suffix = '' }) {
return (
<div className="rounded-xl border border-stone-200 bg-white p-4 shadow-sm">
<div className="text-xl font-black text-stone-950">{prefix}<CountUp from={0} to={value} duration={2} />{suffix}</div>
<p className="mt-1 truncate text-xs font-bold text-stone-500">{label}</p>
</div>
);
}
function SectionTitle({ title, desc, compact = false }) {
return (
<div className={compact ? 'max-w-2xl' : 'mb-8 max-w-2xl'}>
<h2 className="text-2xl font-black tracking-tight text-stone-950">{title}</h2>
<p className="mt-2 text-sm leading-6 text-stone-600">{desc}</p>
</div>
);
}
function MarketBoard({ models, t }) {
const rows = models.length ? models : [
{ display_name: 'gpt-4o-mini' },
{ display_name: 'claude-sonnet' },
{ display_name: 'gemini-pro' },
{ display_name: 'deepseek-chat' },
];
return (
<div className="rounded-2xl border border-stone-200 bg-white p-4 shadow-[0_28px_80px_rgba(41,37,36,0.12)]">
<div className="mb-4 flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-stone-950 text-white">
<Boxes className="h-5 w-5" />
</div>
<div className="min-w-0">
<p className="truncate font-black text-stone-950">{t('home.availableModels')}</p>
<p className="text-xs font-bold text-stone-500">curated model shelf</p>
</div>
</div>
<span className="shrink-0 rounded-md bg-orange-50 px-2.5 py-1 text-xs font-black text-orange-700">{rows.length}+ live</span>
</div>
<div className="rounded-xl border border-stone-200 bg-[#fbfaf7] p-3">
<div className="grid gap-3 sm:grid-cols-2">
{rows.slice(0, 6).map((model, index) => <ModelCard key={model.id || index} model={model} index={index} compact />)}
</div>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-3">
{[
{ label: 'Catalog', value: `${rows.length}+` },
{ label: 'Routing', value: 'Auto' },
{ label: 'Billing', value: 'Clear' },
].map((item) => (
<div key={item.label} className="rounded-xl border border-stone-200 bg-white p-3">
<p className="text-[11px] font-bold uppercase tracking-wide text-stone-400">{item.label}</p>
<p className="mt-1 font-mono text-sm font-black text-stone-950">{item.value}</p>
</div>
))}
</div>
</div>
);
}
function ModelCard({ model, index, compact = false }) {
const accent = accents[index % accents.length];
return (
<div className={`group relative overflow-hidden rounded-xl border ${compact ? 'bg-white' : 'bg-[#fbfaf7]'} ${accent.border} p-4 shadow-sm transition-colors hover:bg-white`}>
<div className={`absolute inset-x-0 top-0 h-1 ${accent.line}`} />
<div className="flex items-start justify-between gap-3">
<div className={`flex ${compact ? 'h-9 w-9' : 'h-10 w-10'} shrink-0 items-center justify-center rounded-lg ${accent.soft} ${accent.text}`}>
<Cpu className="h-4 w-4" />
</div>
<span className={`rounded-md ${accent.soft} px-2 py-1 text-[10px] font-black ${accent.text}`}>LIVE</span>
</div>
<p className="mt-4 truncate font-mono text-sm font-black text-stone-950">{model.display_name || model.model_name}</p>
{!compact && <p className="mt-2 text-xs font-semibold text-stone-500">provider ready / transparent price</p>}
</div>
);
}
function PackageCard({ pkg, index, models, fmtCNY, t, user }) {
const accent = accents[index % accents.length];
const equiv = calcOfficialEquivList(models, packageQuotaDollars(pkg));
const featured = index === 1;
return (
<div className={`flex min-h-[300px] flex-col rounded-xl border bg-white p-6 shadow-sm transition-shadow hover:shadow-md ${featured ? 'border-orange-200 ring-1 ring-orange-100' : 'border-stone-200'}`}>
<div className="mb-4 flex items-center justify-between">
<div className={`flex h-11 w-11 items-center justify-center rounded-lg ${accent.soft} ${accent.text}`}>
{featured ? <Gem className="h-5 w-5" /> : <PackageCheck className="h-5 w-5" />}
</div>
{featured && <span className="rounded-md bg-stone-950 px-2.5 py-1 text-xs font-black text-white">{t('home.popular')}</span>}
</div>
<h3 className="text-lg font-black text-stone-950">{pkg.name}</h3>
{pkg.description && <p className="mt-2 text-sm leading-6 text-stone-600">{pkg.description}</p>}
<div className="mt-auto pt-6">
<span className="text-3xl font-black text-stone-950">{fmtCNY(pkg.price)}</span>
{pkg.original_price > pkg.price && <span className="ml-2 text-sm text-stone-400 line-through">{fmtCNY(pkg.original_price)}</span>}
{pkg.duration > 0 && <p className="mt-1 text-xs font-bold text-stone-500">{t('home.days', { count: pkg.duration })}</p>}
{equiv.length > 0 && (
<p className="mt-3 text-xs font-bold text-orange-700">
<RotatingEquiv items={equiv} text={(item) => t('packages.officialEquiv', { model: item.label, amount: item.equivDollars })} />
</p>
)}
</div>
<div className="mt-5">
<MarketButton to={user ? '/packages' : '/register'}>{user ? t('home.subscribe') : t('home.getStarted')}</MarketButton>
</div>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
import React from 'react';
import BrandLayout from '../shared/BrandLayout';
export default function MarketLayout() {
return <BrandLayout variant="market" />;
}
+11 -1
View File
@@ -9,6 +9,8 @@ import RotatingEquiv from '../../components/bits/RotatingEquiv';
import CountUp from '../../components/bits/CountUp';
import FadeContent from '../../components/bits/FadeContent';
import ApiEndpoints from '../../components/ApiEndpoints';
import { getHomeContent } from '../../utils/siteContent';
import HomeHeroImage from '../shared/HomeHeroImage';
export default function MinimalHome() {
const { t } = useTranslation();
@@ -24,6 +26,7 @@ export default function MinimalHome() {
}, []);
const enabledModels = models.filter(m => m.enabled !== false);
const homeContent = getHomeContent(site, t);
return (
<div className="relative">
@@ -31,11 +34,14 @@ export default function MinimalHome() {
<section className="max-w-5xl mx-auto px-6 pt-32 pb-24">
<FadeContent blur duration={800} delay={100}>
<div className="max-w-3xl">
<p className="mb-5 text-sm font-medium tracking-wide text-neutral-400">
{homeContent.heroTagline}
</p>
<h1 className="text-5xl md:text-6xl font-heading font-bold text-white leading-[1.1] tracking-tight">
{site?.name || t('home.defaultHeroTitle')}
</h1>
<p className="text-lg text-neutral-400 mt-6 leading-relaxed max-w-xl">
{t('home.heroSubtitle')}
{homeContent.heroSubtitle}
</p>
<div className="flex items-center gap-4 mt-10">
@@ -54,6 +60,10 @@ export default function MinimalHome() {
</>
)}
</div>
{homeContent.heroImage && (
<HomeHeroImage src={homeContent.heroImage} alt={site?.name} variant="dark" className="mt-12 aspect-[16/7]" />
)}
</div>
</FadeContent>
+206
View File
@@ -0,0 +1,206 @@
import React, { useState } from 'react';
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowRight, LogOut, Menu, UserCircle, X } from 'lucide-react';
import { useAuth } from '../../context/AuthContext';
import { useSite } from '../../context/SiteContext';
import LanguageSwitch from '../../components/LanguageSwitch';
const configs = {
aurora: {
root: 'theme-light theme-aurora min-h-screen flex flex-col bg-[#f6f8fb] text-slate-950',
main: 'theme-page-shell aurora-page-shell flex-1',
announcement: 'border-b border-slate-800 bg-slate-950 px-4 py-2.5 text-center text-sm font-medium text-slate-100',
header: 'sticky top-0 z-50 border-b border-slate-200 bg-[#f6f8fb]/88 backdrop-blur-xl',
logo: 'bg-slate-950 text-white shadow-lg shadow-slate-900/10',
navWrap: 'hidden items-center gap-1 rounded-lg border border-slate-200 bg-white/80 p-1 shadow-sm md:flex',
navActive: 'bg-slate-950 text-white shadow-sm',
navIdle: 'text-slate-600 hover:bg-white hover:text-slate-950',
language: 'text-slate-500 hover:bg-white/80 hover:text-slate-950',
primary: 'bg-slate-950 text-white hover:bg-indigo-700',
mobileActive: 'bg-indigo-50 text-indigo-700',
mobileIdle: 'text-slate-600 hover:bg-slate-50 hover:text-slate-950',
footer: 'mt-auto border-t border-slate-200 bg-white',
},
terminal: {
root: 'theme-terminal min-h-screen flex flex-col bg-[#050807] text-emerald-50',
main: 'theme-page-shell terminal-page-shell flex-1',
announcement: 'border-b border-emerald-400/20 bg-emerald-400/10 px-4 py-2.5 text-center font-mono text-sm text-emerald-200',
header: 'sticky top-0 z-50 border-b border-emerald-400/15 bg-[#050807]/88 backdrop-blur-xl',
logo: 'bg-emerald-400 text-black shadow-lg shadow-emerald-400/20',
navWrap: 'hidden items-center gap-1 rounded-lg border border-emerald-400/15 bg-emerald-400/[0.04] p-1 md:flex',
navActive: 'bg-emerald-400 text-black shadow-sm',
navIdle: 'text-emerald-200/70 hover:bg-emerald-400/10 hover:text-emerald-100',
language: 'text-emerald-200/70 hover:bg-emerald-400/10 hover:text-emerald-100',
primary: 'bg-emerald-400 text-black hover:bg-emerald-300',
mobileActive: 'bg-emerald-400/10 text-emerald-200',
mobileIdle: 'text-emerald-200/70 hover:bg-emerald-400/10 hover:text-emerald-100',
footer: 'mt-auto border-t border-emerald-400/15 bg-[#050807]',
},
market: {
root: 'theme-light theme-market min-h-screen flex flex-col bg-[#fbfaf7] text-stone-950',
main: 'theme-page-shell market-page-shell flex-1',
announcement: 'border-b border-stone-800 bg-stone-950 px-4 py-2.5 text-center text-sm font-semibold text-stone-100',
header: 'sticky top-0 z-50 border-b border-stone-200 bg-[#fbfaf7]/90 backdrop-blur-xl',
logo: 'bg-stone-950 text-white shadow-lg shadow-stone-900/10',
navWrap: 'hidden items-center gap-1 rounded-lg border border-stone-200 bg-white/80 p-1 shadow-sm md:flex',
navActive: 'bg-stone-950 text-white shadow-sm',
navIdle: 'text-stone-600 hover:bg-white hover:text-stone-950',
language: 'text-stone-500 hover:bg-white hover:text-stone-950',
primary: 'bg-stone-950 text-white hover:bg-orange-600',
mobileActive: 'bg-orange-50 text-orange-700',
mobileIdle: 'text-stone-600 hover:bg-white hover:text-stone-950',
footer: 'mt-auto border-t border-stone-200 bg-[#fbfaf7]',
},
};
export default function BrandLayout({ variant }) {
const cfg = configs[variant] || configs.aurora;
const { t } = useTranslation();
const { user, logout } = useAuth();
const { site } = useSite();
const navigate = useNavigate();
const location = useLocation();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const siteName = site?.name || 'AI Platform';
const navItems = [
{ to: '/', label: t('nav.home'), auth: false },
{ to: '/pricing', label: t('nav.pricing'), auth: false },
{ to: '/packages', label: t('nav.packages'), auth: false },
...(site?.allow_sub_dist ? [{ to: '/sub-site', label: t('subDist.nav'), auth: false }] : []),
{ to: '/dashboard', label: t('nav.dashboard'), auth: true },
{ to: '/tokens', label: t('nav.apiKeys'), auth: true },
{ to: '/logs', label: t('nav.logs'), auth: true },
...(site?.enable_topup ? [{ to: '/topup', label: t('nav.topup'), auth: true }] : []),
];
const visibleNavItems = navItems.filter((n) => !n.auth || user);
const handleLogout = async () => {
await logout();
navigate('/');
};
return (
<div className={cfg.root}>
{site?.announcement && <div className={cfg.announcement}>{site.announcement}</div>}
<header className={cfg.header}>
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6">
<Link to="/" className="flex min-w-0 items-center gap-3 group">
{site?.logo ? (
<img src={site.logo} alt={siteName} className="h-8 w-auto max-w-[150px] object-contain" />
) : (
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-sm font-black ${cfg.logo}`}>
{siteName.charAt(0)}
</div>
)}
<span className="truncate text-base font-black tracking-tight sm:text-lg">{siteName}</span>
</Link>
<nav className={cfg.navWrap}>
{visibleNavItems.map((n) => (
<Link
key={n.to}
to={n.to}
className={`rounded-full px-3.5 py-1.5 text-sm font-semibold transition-colors ${
location.pathname === n.to ? cfg.navActive : cfg.navIdle
}`}
>
{n.label}
</Link>
))}
</nav>
<div className="flex items-center gap-2">
<LanguageSwitch className={cfg.language} />
{user ? (
<div className="hidden items-center gap-2 sm:flex">
<div className="flex max-w-[180px] items-center gap-2 rounded-full border border-current/10 bg-current/[0.04] px-3 py-1.5 text-sm">
<UserCircle className="h-4 w-4 shrink-0 opacity-60" />
<span className="truncate opacity-80">{user.display_name || user.username}</span>
</div>
<button
onClick={handleLogout}
className="inline-flex h-9 w-9 items-center justify-center rounded-full transition-colors hover:bg-current/10"
aria-label={t('nav.logout')}
title={t('nav.logout')}
>
<LogOut className="h-4 w-4" />
</button>
</div>
) : (
<div className="hidden items-center gap-2 sm:flex">
<Link to="/login" className="rounded-full px-3 py-2 text-sm font-semibold opacity-75 hover:opacity-100">
{t('nav.login')}
</Link>
<Link to="/register" className={`inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-sm font-bold shadow-sm transition-colors ${cfg.primary}`}>
{t('nav.signUp')}
<ArrowRight className="h-4 w-4" />
</Link>
</div>
)}
<button
className="inline-flex h-9 w-9 items-center justify-center rounded-full transition-colors hover:bg-current/10 md:hidden"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label="Toggle menu"
>
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div>
</div>
{mobileMenuOpen && (
<div className="border-t border-current/10 bg-inherit md:hidden">
<nav className="mx-auto flex max-w-7xl flex-col gap-1 px-4 py-3 sm:px-6">
{visibleNavItems.map((n) => (
<Link
key={n.to}
to={n.to}
onClick={() => setMobileMenuOpen(false)}
className={`rounded-lg px-3 py-2 text-sm font-semibold transition-colors ${
location.pathname === n.to ? cfg.mobileActive : cfg.mobileIdle
}`}
>
{n.label}
</Link>
))}
<div className="mt-2 border-t border-current/10 pt-3 sm:hidden">
{user ? (
<button onClick={handleLogout} className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold">
<LogOut className="h-4 w-4" />
{t('nav.logout')}
</button>
) : (
<div className="grid grid-cols-2 gap-2">
<Link to="/login" onClick={() => setMobileMenuOpen(false)} className="rounded-lg border border-current/10 px-3 py-2 text-center text-sm font-semibold">
{t('nav.login')}
</Link>
<Link to="/register" onClick={() => setMobileMenuOpen(false)} className={`rounded-lg px-3 py-2 text-center text-sm font-bold ${cfg.primary}`}>
{t('nav.signUp')}
</Link>
</div>
)}
</div>
</nav>
</div>
)}
</header>
<main className={cfg.main || 'flex-1'}>
<Outlet />
</main>
<footer className={cfg.footer}>
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-8 text-sm opacity-70 sm:px-6 md:flex-row md:items-center md:justify-between">
<p>&copy; {new Date().getFullYear()} {siteName}</p>
{site?.contact_email && (
<a href={`mailto:${site.contact_email}`} className="font-semibold hover:opacity-100">
{t('nav.contact')}
</a>
)}
</div>
</footer>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import React from 'react';
export default function HomeHeroImage({ src, alt, variant = 'light', className = '' }) {
if (!src) return null;
const chrome =
variant === 'dark'
? 'border-white/10 bg-white/[0.04] shadow-2xl shadow-black/40'
: 'border-slate-200 bg-white shadow-2xl shadow-slate-900/10';
return (
<div className={`overflow-hidden rounded-2xl border ${chrome} ${className}`}>
<img
src={src}
alt={alt || ''}
className="block h-full min-h-[260px] w-full object-cover"
loading="eager"
/>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { useEffect, useMemo, useState } from 'react';
import { getSiteModels, getSitePackages, Q } from '../../api';
const previewModels = [
{ id: 'preview-1', model_name: 'gpt-4o-mini', display_name: 'GPT-4o Mini', enabled: true },
{ id: 'preview-2', model_name: 'claude-sonnet-4-5', display_name: 'Claude Sonnet 4.5', enabled: true },
{ id: 'preview-3', model_name: 'gemini-2.5-pro', display_name: 'Gemini 2.5 Pro', enabled: true },
{ id: 'preview-4', model_name: 'deepseek-chat', display_name: 'DeepSeek Chat', enabled: true },
{ id: 'preview-5', model_name: 'qwen-max', display_name: 'Qwen Max', enabled: true },
{ id: 'preview-6', model_name: 'grok-4', display_name: 'Grok 4', enabled: true },
{ id: 'preview-7', model_name: 'claude-haiku-4-5', display_name: 'Claude Haiku 4.5', enabled: true },
{ id: 'preview-8', model_name: 'gpt-5-mini', display_name: 'GPT-5 Mini', enabled: true },
];
const previewPackages = [
{
id: 'preview-basic',
name: 'Starter Pack',
description: '适合个人试用和轻量 API 调用。',
price: 29,
original_price: 49,
duration: 30,
quota_amount: Q * 6,
quota_reset_period: 'never',
enabled: true,
},
{
id: 'preview-pro',
name: 'Pro Relay',
description: '高频调用、自动路由、失败重试的主力套餐。',
price: 99,
original_price: 149,
duration: 30,
quota_amount: Q * 24,
quota_reset_period: 'never',
enabled: true,
},
{
id: 'preview-team',
name: 'Team Scale',
description: '适合团队共享密钥、模型分组和稳定生产调用。',
price: 299,
original_price: 399,
duration: 30,
quota_amount: Q * 90,
quota_reset_period: 'never',
enabled: true,
},
];
const devPreviewTheme =
import.meta.env.DEV && typeof window !== 'undefined'
? new URLSearchParams(window.location.search).get('preview_theme')
: '';
export function useHomeData() {
const [models, setModels] = useState(devPreviewTheme ? previewModels : []);
const [packages, setPackages] = useState(devPreviewTheme ? previewPackages : []);
useEffect(() => {
if (devPreviewTheme) return;
getSiteModels().then(r => { if (r.data.success) setModels(r.data.data || []); }).catch(() => {});
getSitePackages().then(r => { if (r.data.success) setPackages(r.data.data || []); }).catch(() => {});
}, []);
const enabledModels = useMemo(() => models.filter(m => m.enabled !== false), [models]);
const visiblePackages = useMemo(() => packages.filter(p => p.enabled), [packages]);
return { models, packages, enabledModels, visiblePackages };
}
export function packageQuotaDollars(pkg) {
const quotaDollars = pkg.quota_amount > 0 ? pkg.quota_amount / Q : 0;
const period = pkg.quota_reset_period || 'never';
if (period === 'never' || pkg.duration <= 0 || quotaDollars <= 0) {
return quotaDollars;
}
let count = 1;
if (period === 'daily') count = pkg.duration;
if (period === 'weekly') count = Math.floor(pkg.duration / 7);
if (period === 'monthly') count = Math.floor(pkg.duration / 30);
return quotaDollars * Math.max(count, 1);
}
+1 -234
View File
@@ -1,234 +1 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../../context/AuthContext';
import { useSite, useCurrency } from '../../context/SiteContext';
import { getSiteModels, getSitePackages, Q } from '../../api';
import { calcOfficialEquivList } from '../../utils/officialEquiv';
import RotatingEquiv from '../../components/bits/RotatingEquiv';
import CountUp from '../../components/bits/CountUp';
import FadeContent from '../../components/bits/FadeContent';
import ApiEndpoints from '../../components/ApiEndpoints';
export default function StarterHome() {
const { t } = useTranslation();
const { user } = useAuth();
const { site } = useSite();
const { symbol, rate, fmtCNY } = useCurrency();
const [models, setModels] = useState([]);
const [packages, setPackages] = useState([]);
useEffect(() => {
getSiteModels().then(r => { if (r.data.success) setModels(r.data.data || []); }).catch(() => {});
getSitePackages().then(r => { if (r.data.success) setPackages(r.data.data || []); }).catch(() => {});
}, []);
const enabledModels = models.filter(m => m.enabled !== false);
return (
<div className="relative">
{/* Subtle gradient background */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[900px] h-[500px] bg-gradient-radial from-brand-600/15 to-transparent blur-3xl opacity-60" />
</div>
{/* Hero */}
<section className="relative max-w-5xl mx-auto px-6 pt-32 pb-20">
<FadeContent blur duration={800} delay={100}>
<div className="text-center max-w-3xl mx-auto">
<p className="text-sm font-medium text-brand-400 mb-4 tracking-wide uppercase">{t('home.heroTagline') || 'AI API Platform'}</p>
<h1 className="text-5xl md:text-6xl font-heading font-bold text-white leading-[1.1] tracking-tight">
{site?.name || t('home.defaultHeroTitle')}
</h1>
<p className="text-lg text-neutral-400 mt-6 leading-relaxed max-w-xl mx-auto">
{t('home.heroSubtitle')}
</p>
<div className="flex items-center justify-center gap-4 mt-10">
{user ? (
<Link to="/dashboard" className="px-7 py-3 rounded-xl bg-brand-600 text-white font-medium text-sm hover:bg-brand-500 transition-colors shadow-lg shadow-brand-600/20">
{t('home.goToDashboard')} →
</Link>
) : (
<>
<Link to="/register" className="px-7 py-3 rounded-xl bg-brand-600 text-white font-medium text-sm hover:bg-brand-500 transition-colors shadow-lg shadow-brand-600/20">
{t('home.getStarted')}
</Link>
<Link to="/pricing" className="px-7 py-3 text-neutral-400 font-medium text-sm hover:text-white transition-colors">
{t('home.viewPricing')} →
</Link>
</>
)}
</div>
</div>
</FadeContent>
{/* Stats */}
<FadeContent blur duration={800} delay={400}>
<div className="grid grid-cols-3 gap-8 max-w-lg mx-auto mt-20 pt-10 border-t border-neutral-800/60">
<div className="text-center">
<div className="text-2xl font-bold text-white">
<CountUp from={0} to={enabledModels.length || 50} duration={2} />+
</div>
<p className="text-sm text-neutral-500 mt-0.5">{t('home.aiModels')}</p>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-white">
<CountUp from={0} to={99.9} duration={2.5} />%
</div>
<p className="text-sm text-neutral-500 mt-0.5">{t('home.uptime')}</p>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-white">
&lt;<CountUp from={200} to={50} duration={2} direction="down" />ms
</div>
<p className="text-sm text-neutral-500 mt-0.5">{t('home.latency')}</p>
</div>
</div>
</FadeContent>
</section>
<ApiEndpoints />
{/* Features */}
<section className="max-w-5xl mx-auto px-6 py-20">
<FadeContent blur duration={800} delay={100}>
<div className="text-center mb-14">
<h2 className="text-2xl font-heading font-bold text-white mb-3">{t('home.whyChooseUs')}</h2>
<p className="text-neutral-500">{t('home.whyChooseUsDesc')}</p>
</div>
<div className="grid md:grid-cols-3 gap-5">
{[
{ title: t('home.lightningFast'), desc: t('home.lightningFastDesc'), icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
)},
{ title: t('home.securePrivate'), desc: t('home.securePrivateDesc'), icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
)},
{ title: t('home.payAsYouGo'), desc: t('home.payAsYouGoDesc'), icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)},
].map((f, i) => (
<div key={i} className="p-6 rounded-xl border border-neutral-800/60 hover:border-brand-500/25 transition-colors group">
<div className="w-10 h-10 rounded-lg bg-brand-500/10 text-brand-400 flex items-center justify-center mb-4">
{f.icon}
</div>
<h3 className="text-base font-semibold text-white mb-2">{f.title}</h3>
<p className="text-sm text-neutral-400 leading-relaxed">{f.desc}</p>
</div>
))}
</div>
</FadeContent>
</section>
{/* Models */}
{enabledModels.length > 0 && (
<section className="max-w-5xl mx-auto px-6 py-20">
<FadeContent blur duration={800} delay={100}>
<div className="flex items-end justify-between mb-8">
<div>
<h2 className="text-2xl font-heading font-bold text-white mb-2">{t('home.availableModels')}</h2>
<p className="text-neutral-500">{t('home.availableModelsDesc', { count: enabledModels.length })}</p>
</div>
{enabledModels.length > 8 && (
<Link to="/pricing" className="text-sm text-brand-400 hover:text-brand-300 transition-colors">
{t('home.viewAllModels', { count: enabledModels.length })} →
</Link>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{enabledModels.slice(0, 8).map((m, i) => (
<div key={m.id || i} className="px-4 py-3 rounded-lg border border-neutral-800/60 hover:border-neutral-700 transition-colors">
<span className="text-sm text-neutral-300 font-mono">{m.display_name || m.model_name}</span>
</div>
))}
</div>
</FadeContent>
</section>
)}
{/* Packages */}
{packages.length > 0 && (
<section className="max-w-5xl mx-auto px-6 py-20">
<FadeContent blur duration={800} delay={100}>
<h2 className="text-2xl font-heading font-bold text-white mb-2">{t('home.plansPackages')}</h2>
<p className="text-neutral-500 mb-10">{t('home.choosePlan')}</p>
<div className="grid md:grid-cols-3 gap-4 max-w-4xl">
{packages.filter(p => p.enabled).slice(0, 3).map((pkg, i) => {
const quotaDollars = pkg.quota_amount > 0 ? pkg.quota_amount / Q : 0;
const rp = pkg.quota_reset_period || 'never';
let tqd = quotaDollars;
if (rp !== 'never' && pkg.duration > 0 && quotaDollars > 0) {
let n = rp === 'daily' ? pkg.duration : rp === 'weekly' ? Math.floor(pkg.duration / 7) : rp === 'monthly' ? Math.floor(pkg.duration / 30) : 1;
if (n < 1) n = 1;
tqd = quotaDollars * n;
}
const equiv = calcOfficialEquivList(enabledModels, tqd);
return (
<div key={pkg.id} className={`rounded-xl p-6 flex flex-col border transition-colors ${
i === 1 ? 'border-brand-500/30 bg-brand-500/[0.04]' : 'border-neutral-800/60 hover:border-neutral-700'
}`}>
{i === 1 && <span className="text-xs text-brand-400 font-medium mb-2 uppercase tracking-wider">{t('home.popular') || 'Popular'}</span>}
<h3 className="text-base font-semibold text-white">{pkg.name}</h3>
{pkg.description && <p className="text-sm text-neutral-500 mt-1">{pkg.description}</p>}
<div className="mt-auto pt-6">
<span className="text-3xl font-bold text-white">{fmtCNY(pkg.price)}</span>
{pkg.original_price > pkg.price && (
<span className="text-sm text-neutral-600 line-through ml-2">{fmtCNY(pkg.original_price)}</span>
)}
{pkg.duration > 0 && <p className="text-xs text-neutral-600 mt-1">{t('home.days', { count: pkg.duration })}</p>}
</div>
{equiv.length > 0 && (
<p className="text-xs text-amber-300/80 mt-2">🔥 <RotatingEquiv items={equiv} text={(item) => t('packages.officialEquiv', { model: item.label, amount: item.equivDollars })} /></p>
)}
<Link to={user ? '/packages' : '/register'} className={`mt-4 py-2.5 rounded-lg font-medium text-sm text-center transition-colors ${
i === 1
? 'bg-brand-600 text-white hover:bg-brand-500'
: 'bg-neutral-800 text-white hover:bg-neutral-700'
}`}>
{user ? t('home.subscribe') : t('home.getStarted')}
</Link>
</div>
)})}
</div>
</FadeContent>
</section>
)}
{/* CTA */}
<section className="max-w-5xl mx-auto px-6 py-24">
<FadeContent blur duration={800} delay={100}>
<div className="border-t border-neutral-800 pt-16 text-center">
<h2 className="text-2xl font-heading font-bold text-white mb-3">{t('home.readyToStart')}</h2>
<p className="text-neutral-500 mb-8 max-w-md mx-auto">{t('home.readyToStartDesc')}</p>
<div className="flex items-center justify-center gap-4">
{user ? (
<Link to="/dashboard" className="px-7 py-3 rounded-xl bg-brand-600 text-white font-medium text-sm hover:bg-brand-500 transition-colors shadow-lg shadow-brand-600/20">
{t('home.goToDashboard')} →
</Link>
) : (
<>
<Link to="/register" className="px-7 py-3 rounded-xl bg-brand-600 text-white font-medium text-sm hover:bg-brand-500 transition-colors shadow-lg shadow-brand-600/20">
{t('home.createFreeAccount')} →
</Link>
<Link to="/login" className="px-7 py-3 text-neutral-400 font-medium text-sm hover:text-white transition-colors">
{t('home.signIn')}
</Link>
</>
)}
</div>
</div>
</FadeContent>
</section>
</div>
);
}
export { default } from '../default/Home';
+1 -179
View File
@@ -1,179 +1 @@
import React, { useState } from 'react';
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../../context/AuthContext';
import { useSite } from '../../context/SiteContext';
import LanguageSwitch from '../../components/LanguageSwitch';
export default function StarterLayout() {
const { t } = useTranslation();
const { user, logout } = useAuth();
const { site } = useSite();
const navigate = useNavigate();
const location = useLocation();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const siteName = site?.name || 'AI Platform';
const navItems = [
{ to: '/', label: t('nav.home'), auth: false },
{ to: '/pricing', label: t('nav.pricing'), auth: false },
{ to: '/packages', label: t('nav.packages'), auth: false },
...(site?.allow_sub_dist ? [{ to: '/sub-site', label: t('subDist.nav'), auth: false }] : []),
{ to: '/dashboard', label: t('nav.dashboard'), auth: true },
{ to: '/tokens', label: t('nav.apiKeys'), auth: true },
{ to: '/logs', label: t('nav.logs'), auth: true },
...(site?.enable_topup ? [{ to: '/topup', label: t('nav.topup'), auth: true }] : []),
];
const visibleNavItems = navItems.filter((n) => !n.auth || user);
return (
<div className="min-h-screen flex flex-col">
{/* Announcement Banner */}
{site?.announcement && (
<div className="bg-brand-600/20 border-b border-brand-500/20 text-center py-2 px-4">
<p className="text-sm text-brand-200">{site.announcement}</p>
</div>
)}
{/* Header */}
<header className="sticky top-0 z-50 backdrop-blur-xl bg-black/60 border-b border-white/[0.06]">
<div className="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
{/* Logo */}
<Link to="/" className="flex items-center gap-3 group">
{site?.logo ? (
<img src={site.logo} alt={siteName} className="h-8 w-auto" />
) : (
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-brand-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm">
{siteName.charAt(0)}
</div>
)}
<span className="text-lg font-semibold text-white group-hover:text-brand-400 transition-colors">
{siteName}
</span>
</Link>
{/* Desktop Nav */}
<nav className="hidden md:flex items-center gap-1">
{visibleNavItems.map((n) => (
<NavLink key={n.to} to={n.to} active={location.pathname === n.to}>
{n.label}
</NavLink>
))}
</nav>
{/* Auth + Lang + Mobile toggle */}
<div className="flex items-center gap-2">
<LanguageSwitch className="text-neutral-400 hover:text-white hover:bg-white/5" />
{user ? (
<div className="flex items-center gap-3">
<span className="text-sm text-neutral-400 hidden sm:block">
{user.display_name || user.username}
</span>
<button
onClick={async () => { await logout(); navigate('/'); }}
className="text-sm text-neutral-400 hover:text-white transition-colors"
>
{t('nav.logout')}
</button>
</div>
) : (
<>
<Link to="/login" className="text-sm text-neutral-300 hover:text-white transition-colors px-3 py-1.5 hidden sm:block">
{t('nav.login')}
</Link>
<Link to="/register" className="btn-primary text-sm !px-4 !py-1.5">
{t('nav.signUp')}
</Link>
</>
)}
{/* Mobile hamburger */}
<button
className="md:hidden p-1.5 rounded-lg hover:bg-white/5 transition-colors"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label="Toggle menu"
>
<svg className="w-5 h-5 text-neutral-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{mobileMenuOpen ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
)}
</svg>
</button>
</div>
</div>
{/* Mobile Nav Dropdown */}
{mobileMenuOpen && (
<div className="md:hidden border-t border-white/[0.06] bg-black/90 backdrop-blur-xl">
<nav className="max-w-7xl mx-auto px-6 py-3 flex flex-col gap-1">
{visibleNavItems.map((n) => (
<Link
key={n.to}
to={n.to}
onClick={() => setMobileMenuOpen(false)}
className={`px-3 py-2 text-sm rounded-lg transition-colors ${
location.pathname === n.to
? 'text-brand-400 bg-brand-500/10'
: 'text-neutral-400 hover:text-white hover:bg-white/5'
}`}
>
{n.label}
</Link>
))}
{!user && (
<Link
to="/login"
onClick={() => setMobileMenuOpen(false)}
className="px-3 py-2 text-sm text-neutral-400 hover:text-white rounded-lg hover:bg-white/5 transition-colors sm:hidden"
>
{t('nav.login')}
</Link>
)}
</nav>
</div>
)}
</header>
{/* Content */}
<main className="flex-1">
<Outlet />
</main>
{/* Footer */}
<footer className="border-t border-white/[0.06] mt-auto">
<div className="max-w-7xl mx-auto px-6 py-8 flex flex-col md:flex-row items-center justify-between gap-4">
<p className="text-sm text-neutral-500">
&copy; {new Date().getFullYear()} {siteName}.
</p>
<div className="flex items-center gap-4">
{site?.contact_email && (
<a href={`mailto:${site.contact_email}`} className="text-sm text-neutral-500 hover:text-neutral-300 transition-colors">
{t('nav.contact')}
</a>
)}
</div>
</div>
</footer>
</div>
);
}
function NavLink({ to, children, active }) {
return (
<Link
to={to}
className={`px-3 py-1.5 text-sm rounded-lg transition-all ${
active
? 'text-brand-400 bg-brand-500/10'
: 'text-neutral-400 hover:text-white hover:bg-white/5'
}`}
>
{children}
</Link>
);
}
export { default } from '../default/Layout';
+248
View File
@@ -0,0 +1,248 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowRight, CheckCircle2, Cpu, KeyRound, LockKeyhole, RadioTower, ShieldCheck, TerminalSquare, Zap } from 'lucide-react';
import { useAuth } from '../../context/AuthContext';
import { useSite, useCurrency } from '../../context/SiteContext';
import { calcOfficialEquivList } from '../../utils/officialEquiv';
import { packageQuotaDollars, useHomeData } from '../shared/useHomeData';
import DecryptedText from '../../components/bits/DecryptedText';
import ShinyText from '../../components/bits/ShinyText';
import CountUp from '../../components/bits/CountUp';
import FadeContent from '../../components/bits/FadeContent';
import RotatingEquiv from '../../components/bits/RotatingEquiv';
import ApiEndpoints from '../../components/ApiEndpoints';
import { getHomeContent } from '../../utils/siteContent';
import HomeHeroImage from '../shared/HomeHeroImage';
export default function TerminalHome() {
const { t } = useTranslation();
const { user } = useAuth();
const { site } = useSite();
const { fmtCNY } = useCurrency();
const { enabledModels, visiblePackages } = useHomeData();
const previewModels = enabledModels.slice(0, 7);
const homeContent = getHomeContent(site, t);
return (
<div className="relative overflow-hidden bg-[#050807] text-emerald-50">
<div className="pointer-events-none absolute inset-0 opacity-[0.16] [background-image:linear-gradient(rgba(52,211,153,.45)_1px,transparent_1px),linear-gradient(90deg,rgba(52,211,153,.45)_1px,transparent_1px)] [background-size:32px_32px]" />
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(16,185,129,.22),transparent_40%),linear-gradient(180deg,transparent,rgba(5,8,7,.9)_75%)]" />
<section className="relative mx-auto grid min-h-[calc(100vh-4rem)] max-w-7xl gap-12 px-4 pb-16 pt-16 sm:px-6 lg:grid-cols-[0.96fr_1.04fr] lg:items-center lg:pb-20 lg:pt-20">
<FadeContent blur duration={700} delay={80}>
<div className="max-w-2xl">
<div className="mb-5 inline-flex items-center gap-2 rounded-md border border-emerald-400/25 bg-emerald-400/[0.06] px-3 py-1.5 font-mono text-xs font-bold uppercase tracking-[0.22em] text-emerald-200">
<TerminalSquare className="h-4 w-4" />
<ShinyText text={homeContent.heroTagline} color="#a7f3d0" shineColor="#ffffff" speed={3} />
</div>
<h1 className="font-mono text-4xl font-black tracking-tight text-emerald-50 sm:text-5xl lg:text-6xl">
<DecryptedText
text={site?.name || t('home.defaultHeroTitle')}
animateOn="view"
speed={28}
maxIterations={14}
sequential
revealDirection="start"
encryptedClassName="text-emerald-500"
/>
</h1>
<p className="mt-6 max-w-xl text-base leading-8 text-emerald-100/70 sm:text-lg">
{homeContent.heroSubtitle}
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<TerminalButton to={user ? '/dashboard' : '/register'}>
{user ? t('home.goToDashboard') : t('home.getStarted')}
</TerminalButton>
<Link
to="/pricing"
className="inline-flex items-center justify-center rounded-lg border border-emerald-400/25 bg-emerald-400/[0.04] px-6 py-3 font-mono text-sm font-bold text-emerald-100 transition-colors hover:bg-emerald-400/10"
>
{t('home.viewPricing')}
</Link>
</div>
<div className="mt-10 grid max-w-xl grid-cols-3 gap-3">
<Metric value={enabledModels.length || 50} suffix="+" label={t('home.aiModels')} />
<Metric value={99.9} suffix="%" label={t('home.uptime')} />
<Metric value={50} prefix="<" suffix="ms" label={t('home.latency')} />
</div>
</div>
</FadeContent>
<FadeContent blur duration={700} delay={180}>
{homeContent.heroImage ? (
<HomeHeroImage src={homeContent.heroImage} alt={site?.name} variant="dark" className="aspect-[4/3]" />
) : (
<RouteConsole models={previewModels} t={t} />
)}
</FadeContent>
</section>
<ApiEndpoints />
<section className="relative mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:py-20">
<SectionTitle title={t('home.whyChooseUs')} desc={t('home.whyChooseUsDesc')} />
<div className="grid gap-4 md:grid-cols-3">
{[
{ icon: Zap, title: t('home.lightningFast'), desc: t('home.lightningFastDesc') },
{ icon: ShieldCheck, title: t('home.securePrivate'), desc: t('home.securePrivateDesc') },
{ icon: LockKeyhole, title: t('home.payAsYouGo'), desc: t('home.payAsYouGoDesc') },
].map((item) => {
const Icon = item.icon;
return (
<div key={item.title} className="rounded-lg border border-emerald-400/15 bg-emerald-400/[0.035] p-6 transition-colors hover:border-emerald-400/35">
<div className="mb-5 flex h-11 w-11 items-center justify-center rounded-lg border border-emerald-400/25 bg-black/30 text-emerald-300">
<Icon className="h-5 w-5" />
</div>
<h3 className="font-mono text-base font-black text-emerald-50">{item.title}</h3>
<p className="mt-2 text-sm leading-6 text-emerald-100/62">{item.desc}</p>
</div>
);
})}
</div>
</section>
{enabledModels.length > 0 && (
<section className="relative border-y border-emerald-400/15 bg-black/20">
<div className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:py-20">
<SectionTitle title={t('home.availableModels')} desc={t('home.availableModelsDesc', { count: enabledModels.length })} />
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
{enabledModels.slice(0, 8).map((model, index) => (
<div key={model.id || index} className="rounded-lg border border-emerald-400/15 bg-[#07110d] p-4 font-mono transition-colors hover:border-emerald-400/35">
<div className="mb-3 flex items-center justify-between">
<Cpu className="h-4 w-4 text-emerald-300" />
<span className="text-[10px] font-black text-emerald-400">READY</span>
</div>
<p className="truncate text-sm font-bold text-emerald-50">{model.display_name || model.model_name}</p>
<p className="mt-2 text-xs text-emerald-100/45">route.auto=true</p>
</div>
))}
</div>
</div>
</section>
)}
{visiblePackages.length > 0 && (
<section className="relative mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:py-20">
<SectionTitle title={t('home.plansPackages')} desc={t('home.choosePlan')} />
<div className="grid gap-4 lg:grid-cols-3">
{visiblePackages.slice(0, 3).map((pkg, index) => (
<PackageCard key={pkg.id} pkg={pkg} index={index} models={enabledModels} fmtCNY={fmtCNY} t={t} user={user} />
))}
</div>
</section>
)}
<section className="relative border-t border-emerald-400/15 bg-emerald-400/[0.05]">
<div className="mx-auto flex max-w-7xl flex-col gap-6 px-4 py-14 sm:px-6 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="font-mono text-2xl font-black text-emerald-50">{t('home.readyToStart')}</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-emerald-100/62">{t('home.readyToStartDesc')}</p>
</div>
<TerminalButton to={user ? '/dashboard' : '/register'}>
{user ? t('home.goToDashboard') : t('home.createFreeAccount')}
</TerminalButton>
</div>
</section>
</div>
);
}
function TerminalButton({ to, children }) {
return (
<Link to={to} className="inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-400 px-6 py-3 font-mono text-sm font-black text-black shadow-lg shadow-emerald-400/15 transition-colors hover:bg-emerald-300">
{children}
<ArrowRight className="h-4 w-4" />
</Link>
);
}
function Metric({ value, label, prefix = '', suffix = '' }) {
return (
<div className="rounded-lg border border-emerald-400/15 bg-black/25 p-4 font-mono">
<div className="text-xl font-black text-emerald-200">{prefix}<CountUp from={0} to={value} duration={2} />{suffix}</div>
<p className="mt-1 truncate text-xs font-bold text-emerald-100/45">{label}</p>
</div>
);
}
function SectionTitle({ title, desc }) {
return (
<div className="mb-8 max-w-2xl">
<h2 className="font-mono text-2xl font-black tracking-tight text-emerald-50">{title}</h2>
<p className="mt-2 text-sm leading-6 text-emerald-100/62">{desc}</p>
</div>
);
}
function RouteConsole({ models, t }) {
const rows = models.length ? models : [{ display_name: 'gpt-4o-mini' }, { display_name: 'claude-sonnet' }, { display_name: 'gemini-pro' }];
return (
<div className="rounded-xl border border-emerald-400/20 bg-[#030504] shadow-2xl shadow-emerald-950/60">
<div className="flex items-center justify-between border-b border-emerald-400/15 px-4 py-3 font-mono">
<div className="flex items-center gap-2 text-xs font-black text-emerald-300">
<RadioTower className="h-4 w-4" />
subrouter-live
</div>
<span className="text-xs text-emerald-100/45">200 OK</span>
</div>
<div className="grid gap-0 lg:grid-cols-[1fr_0.92fr]">
<div className="border-b border-emerald-400/15 p-5 lg:border-b-0 lg:border-r">
<div className="mb-4 flex items-center gap-2 font-mono text-sm font-black text-emerald-50">
<KeyRound className="h-4 w-4 text-emerald-300" />
{t('nav.apiKeys')}
</div>
<pre className="overflow-hidden rounded-lg border border-emerald-400/10 bg-emerald-400/[0.04] p-4 font-mono text-xs leading-6 text-emerald-100/72">
{`$ curl /v1/chat/completions
> model=${rows[0]?.display_name || rows[0]?.model_name}
> strategy=latency_first
< routed=true
< fallback=armed`}
</pre>
</div>
<div className="p-5">
<div className="mb-4 flex items-center gap-2 font-mono text-sm font-black text-emerald-50">
<CheckCircle2 className="h-4 w-4 text-emerald-300" />
{t('home.availableModels')}
</div>
<div className="space-y-2">
{rows.slice(0, 5).map((model, index) => (
<div key={model.id || index} className="flex items-center justify-between rounded-lg border border-emerald-400/10 bg-emerald-400/[0.035] px-3 py-2 font-mono">
<span className="truncate text-xs text-emerald-100">{model.display_name || model.model_name}</span>
<span className="ml-3 text-[10px] font-black text-emerald-400">UP</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}
function PackageCard({ pkg, index, models, fmtCNY, t, user }) {
const equiv = calcOfficialEquivList(models, packageQuotaDollars(pkg));
const featured = index === 1;
return (
<div className={`flex min-h-[280px] flex-col rounded-lg border p-6 font-mono ${featured ? 'border-emerald-400/50 bg-emerald-400/[0.08]' : 'border-emerald-400/15 bg-black/20'}`}>
{featured && <span className="mb-3 w-fit rounded bg-emerald-400 px-2 py-1 text-xs font-black uppercase tracking-wide text-black">{t('home.popular')}</span>}
<h3 className="text-lg font-black text-emerald-50">{pkg.name}</h3>
{pkg.description && <p className="mt-2 text-sm leading-6 text-emerald-100/55">{pkg.description}</p>}
<div className="mt-auto pt-6">
<span className="text-3xl font-black text-emerald-200">{fmtCNY(pkg.price)}</span>
{pkg.original_price > pkg.price && <span className="ml-2 text-sm text-emerald-100/35 line-through">{fmtCNY(pkg.original_price)}</span>}
{pkg.duration > 0 && <p className="mt-1 text-xs font-bold text-emerald-100/45">{t('home.days', { count: pkg.duration })}</p>}
{equiv.length > 0 && (
<p className="mt-3 text-xs font-bold text-amber-200">
<RotatingEquiv items={equiv} text={(item) => t('packages.officialEquiv', { model: item.label, amount: item.equivDollars })} />
</p>
)}
</div>
<div className="mt-5">
<TerminalButton to={user ? '/packages' : '/register'}>{user ? t('home.subscribe') : t('home.getStarted')}</TerminalButton>
</div>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
import React from 'react';
import BrandLayout from '../shared/BrandLayout';
export default function TerminalLayout() {
return <BrandLayout variant="terminal" />;
}
+44 -63
View File
@@ -1,50 +1,26 @@
import { Q } from '../api';
// Official prices (input $/1M tokens) for reference models.
// These are the REAL official API prices from Anthropic, OpenAI, Google etc.
// IMPORTANT: More specific patterns MUST come before less specific ones
// (e.g. "claude-opus-4-5" before "claude-opus-4") to avoid false matches.
// Patterns must match actual backend model_name format (e.g. "claude-opus-4-5-20251101").
const OFFICIAL_PRICES = [
// Claude — order matters: longer/more-specific first
{ pattern: 'claude-opus-4-7', label: 'Claude Opus 4.7', inputPerMtok: 5, family: 'claudecode' },
{ pattern: 'claude-opus-4-5', label: 'Claude Opus 4.5', inputPerMtok: 5, family: 'claudecode' },
{ pattern: 'claude-opus-4-6', label: 'Claude Opus 4.6', inputPerMtok: 5, family: 'claudecode' },
{ pattern: 'claude-opus-4-1', label: 'Claude Opus 4.1', inputPerMtok: 15, family: 'claudecode' },
{ pattern: 'claude-opus-4', label: 'Claude Opus 4', inputPerMtok: 15, family: 'claudecode' },
{ pattern: 'claude-3-opus', label: 'Claude 3 Opus', inputPerMtok: 15, family: 'claudecode' },
{ pattern: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5', inputPerMtok: 3, family: 'claudecode' },
{ pattern: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', inputPerMtok: 3, family: 'claudecode' },
{ pattern: 'claude-sonnet-4', label: 'Claude Sonnet 4', inputPerMtok: 3, family: 'claudecode' },
{ pattern: 'claude-3-7-sonnet', label: 'Claude 3.7 Sonnet', inputPerMtok: 3, family: 'claudecode' },
{ pattern: 'claude-3-5-sonnet', label: 'Claude 3.5 Sonnet', inputPerMtok: 3, family: 'claudecode' },
{ pattern: 'claude-haiku-4-5', label: 'Claude Haiku 4.5', inputPerMtok: 1, family: 'claudecode' },
{ pattern: 'claude-3-5-haiku', label: 'Claude 3.5 Haiku', inputPerMtok: 0.8, family: 'claudecode' },
{ pattern: 'claude-3-haiku', label: 'Claude 3 Haiku', inputPerMtok: 0.25, family: 'claudecode' },
// OpenAI — more specific first
{ pattern: 'gpt-4o-mini', label: 'GPT-4o Mini', inputPerMtok: 0.15, family: 'gpt' },
{ pattern: 'gpt-4o-2024-05-13', label: 'GPT-4o', inputPerMtok: 5, family: 'gpt' },
{ pattern: 'chatgpt-4o-latest', label: 'GPT-4o', inputPerMtok: 5, family: 'gpt' },
{ pattern: 'gpt-4o', label: 'GPT-4o', inputPerMtok: 2.5, family: 'gpt' },
{ pattern: 'gpt-4.1-mini', label: 'GPT-4.1 Mini', inputPerMtok: 0.4, family: 'gpt' },
{ pattern: 'gpt-4.1-nano', label: 'GPT-4.1 Nano', inputPerMtok: 0.1, family: 'gpt' },
{ pattern: 'gpt-4.1', label: 'GPT-4.1', inputPerMtok: 2, family: 'gpt' },
{ pattern: 'gpt-5.4', label: 'GPT-5.4', inputPerMtok: 2.5, family: 'gpt' },
{ pattern: 'gpt-5.3', label: 'GPT-5.3', inputPerMtok: 1.75, family: 'gpt' },
{ pattern: 'gpt-5.2', label: 'GPT-5.2', inputPerMtok: 1.75, family: 'gpt' },
{ pattern: 'gpt-5.1', label: 'GPT-5.1', inputPerMtok: 1.25, family: 'gpt' },
// Gemini — more specific first
{ pattern: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite', inputPerMtok: 0.04, family: 'gemini' },
{ pattern: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash', inputPerMtok: 0.15, family: 'gemini' },
{ pattern: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', inputPerMtok: 1.25, family: 'gemini' },
{ pattern: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash', inputPerMtok: 0.1, family: 'gemini' },
{ pattern: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro', inputPerMtok: 1.25, family: 'gemini' },
{ pattern: 'gemini-1.5-flash', label: 'Gemini 1.5 Flash', inputPerMtok: 0.075, family: 'gemini' },
// DeepSeek
{ pattern: 'deepseek-r1', label: 'DeepSeek R1', inputPerMtok: 0.55 },
{ pattern: 'deepseek-chat', label: 'DeepSeek V3', inputPerMtok: 0.27 },
{ pattern: 'deepseek-v3', label: 'DeepSeek V3', inputPerMtok: 0.27 },
];
function numberOrNull(value) {
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
export function getOfficialPrice(model) {
if (!model || typeof model !== 'object') return null;
const inputPerMtok = Number(model.official_input_price);
const outputPerMtok = Number(model.official_output_price);
if (!Number.isFinite(inputPerMtok) || inputPerMtok <= 0) return null;
return {
label: model.display_name || model.model_name,
inputPerMtok,
outputPerMtok: Number.isFinite(outputPerMtok) && outputPerMtok > 0 ? outputPerMtok : null,
cacheReadPerMtok: numberOrNull(model.official_cache_read_price),
cacheCreationPerMtok: numberOrNull(model.official_cache_creation_price),
cacheCreation1hPerMtok: numberOrNull(model.official_cache_creation_price_1h),
};
}
/**
* Find official equivalents for the three headline families only:
@@ -61,27 +37,24 @@ export function calcOfficialEquivList(models, quotaDollars) {
const siteInputPrice = Number(m.input_price);
if (!siteInputPrice || siteInputPrice <= 0) continue;
const modelName = (m.model_name || '').toLowerCase();
const official = getOfficialPrice(m);
if (!official) continue;
for (const official of OFFICIAL_PRICES) {
if (modelName.includes(official.pattern.toLowerCase())) {
if (!official.family || !familyOrder.includes(official.family)) break;
const family = getOfficialFamily(m.model_name || m.display_name || '');
if (!family || !familyOrder.includes(family)) continue;
const sitePricePerMtok = siteInputPrice * 1000;
const ratio = official.inputPerMtok / sitePricePerMtok;
const equivDollars = quotaDollars * ratio;
const sitePricePerMtok = siteInputPrice * 1000;
const ratio = official.inputPerMtok / sitePricePerMtok;
const equivDollars = quotaDollars * ratio;
if (equivDollars > quotaDollars) {
const currentBest = bestByFamily[official.family];
if (!currentBest || equivDollars > currentBest.equivDollars) {
bestByFamily[official.family] = {
family: official.family,
label: official.label,
equivDollars,
};
}
}
break;
if (equivDollars > quotaDollars) {
const currentBest = bestByFamily[family];
if (!currentBest || equivDollars > currentBest.equivDollars) {
bestByFamily[family] = {
family,
label: official.label,
equivDollars,
};
}
}
}
@@ -95,6 +68,14 @@ export function calcOfficialEquivList(models, quotaDollars) {
}));
}
function getOfficialFamily(modelName) {
const name = String(modelName).toLowerCase();
if (name.includes('gpt')) return 'gpt';
if (name.includes('claude')) return 'claudecode';
if (name.includes('gemini')) return 'gemini';
return null;
}
/**
* Legacy single-result API (kept for compatibility).
*/
+51
View File
@@ -0,0 +1,51 @@
const parseCustomConfig = (raw) => {
if (!raw) return {};
if (typeof raw === 'object') return raw;
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (e) {
return {};
}
};
const firstText = (...values) => {
for (const value of values) {
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
}
return '';
};
export function getHomeContent(site, t) {
const config = parseCustomConfig(site?.custom_config);
const home = config.home && typeof config.home === 'object' ? config.home : {};
return {
heroTagline: firstText(
home.heroTagline,
home.hero_tagline,
config.heroTagline,
config.hero_tagline,
t('home.heroTagline'),
),
heroSubtitle: firstText(
home.heroSubtitle,
home.hero_subtitle,
config.heroSubtitle,
config.hero_subtitle,
t('home.heroSubtitle'),
),
heroImage: firstText(
home.heroImage,
home.hero_image,
home.heroImageUrl,
home.hero_image_url,
config.heroImage,
config.hero_image,
config.heroImageUrl,
config.hero_image_url,
),
};
}