sync: update dist-site from subtree
This commit is contained in:
1 parent
423911d367
commit
fa940f8a51
31 files changed
+2445
-800
No files matched your search
Vendored
+2
-2
@@ -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
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
═══════════════════════════════════════════════ */
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import BrandLayout from '../shared/BrandLayout';
|
||||
|
||||
export default function AuroraLayout() {
|
||||
return <BrandLayout variant="aurora" />;
|
||||
}
|
||||
@@ -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,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>
|
||||
|
||||
|
||||
@@ -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,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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">© {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>© {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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import BrandLayout from '../shared/BrandLayout';
|
||||
|
||||
export default function MarketLayout() {
|
||||
return <BrandLayout variant="market" />;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>© {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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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">
|
||||
<<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 +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">
|
||||
© {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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import BrandLayout from '../shared/BrandLayout';
|
||||
|
||||
export default function TerminalLayout() {
|
||||
return <BrandLayout variant="terminal" />;
|
||||
}
|
||||
+44
-63
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user