Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a8fec2e9c | |||
| 101b31e073 | |||
| 11ad888ee7 | |||
| 53d8ded7f6 | |||
| e3e1d43d5d | |||
| 09b1944376 | |||
| e0912dd819 | |||
| f4c5f93370 | |||
| 6dcf582225 | |||
| 9ed7588ec0 | |||
| 87647cae34 | |||
| 620d8148f3 |
@@ -20,6 +20,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
return (
|
||||
<html lang="en" data-theme="light" suppressHydrationWarning>
|
||||
<head>
|
||||
{/* 首屏渲染前据 localStorage 设好 data-theme/lang,避免明暗主题(整页配色)在
|
||||
挂载后才应用造成的 FOUC 闪烁。/user/* CSP 允许 'unsafe-inline' 脚本,故直接内联。
|
||||
与官网 Site.astro 的 no-FOUC 脚本同法(键名用用户中心的 pg_uc_theme/pg_uc_lang)。 */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
"(function(){try{var t=localStorage.getItem('pg_uc_theme');if(t==='dark'||t==='light')document.documentElement.dataset.theme=t;var l=localStorage.getItem('pg_uc_lang');if(l)document.documentElement.lang=l;}catch(e){}})()",
|
||||
}}
|
||||
/>
|
||||
{/* 设计令牌单一真相源,原样链入(SRI 由构建期注入) */}
|
||||
{/* eslint-disable-next-line @next/next/no-css-tags */}
|
||||
<link rel="stylesheet" href={`${BASE_PATH}/colors_and_type.css`} />
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function Invite({ t }: { t: TFn }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, maxWidth: 680 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 25, fontWeight: 700, color: 'var(--fg1)' }}>{t('inviteTitle')}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--fg3)', marginTop: 5, lineHeight: 1.6 }}>{t('inviteSub')}</div>
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function Overview({
|
||||
const free = me.plan === 'free';
|
||||
const vals = me.weeklyGB;
|
||||
const max = Math.max(...vals, 0.1);
|
||||
const labels = lang === 'zh' ? ['一', '二', '三', '四', '五', '六', '日'] : ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
|
||||
const labels = lang === 'zh' ? ['一', '二', '三', '四', '五', '六', '日'] : ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
const stats: [string, string, string, string][] = [
|
||||
['clock', t('quotaToday'), free ? String(me.quotaTodayMin ?? 0) : '∞', free ? 'min' : ''],
|
||||
@@ -76,13 +76,13 @@ export default function Overview({
|
||||
</div>
|
||||
|
||||
{/* usage chart + quick actions */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr' : '1.6fr 1fr', gap: 14, alignItems: 'start' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr' : '1.6fr 1fr', gap: 14, alignItems: 'stretch' }}>
|
||||
<div style={{ ...card, padding: '18px 22px' }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--fg1)', marginBottom: 16 }}>{t('usageTitle')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 14, height: 110 }}>
|
||||
{vals.map((v, i) => (
|
||||
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 9.5, color: 'var(--fg3)' }}>{v}</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 9.5, color: 'var(--fg3)' }}>{Number(v.toPrecision(2))}</div>
|
||||
<div style={{ width: '100%', maxWidth: 30, height: `${(v / max) * 76}px`, background: 'var(--accent)', borderRadius: '5px 5px 0 0', opacity: 0.85 }} />
|
||||
<div style={{ fontSize: 10.5, color: 'var(--fg3)' }}>{labels[i]}</div>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function Redeem({ t, lang, onRedeemed }: { t: TFn; lang: Lang; on
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, maxWidth: 680 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 25, fontWeight: 700, color: 'var(--fg1)' }}>{t('navRedeem')}</div>
|
||||
<div style={{ ...card, padding: '20px 22px' }}>
|
||||
<div style={{ fontSize: 14.5, fontWeight: 700, color: 'var(--fg1)', marginBottom: 12 }}>{t('redeemTitle')}</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ import React, { useEffect, useState } from 'react';
|
||||
import { Icon } from './icons';
|
||||
import { card, input } from './shared';
|
||||
import { ErrorLine } from './Login';
|
||||
import { useUI } from '../lib/theme';
|
||||
import type { TFn, Lang } from '../lib/i18n';
|
||||
import { getClient } from '../lib/api/client';
|
||||
import { bilingual } from '../lib/api/errors';
|
||||
@@ -13,9 +12,8 @@ import type { Device, TotpSetup } from '../lib/api/types';
|
||||
|
||||
export default function Settings({ t, lang, totpEnabled, onTotpChange }: { t: TFn; lang: Lang; totpEnabled: boolean; onTotpChange: () => void }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, maxWidth: 680 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 25, fontWeight: 700, color: 'var(--fg1)' }}>{t('settingsTitle')}</div>
|
||||
<Preferences t={t} />
|
||||
<TotpSection t={t} lang={lang} enabled={totpEnabled} onChange={onTotpChange} />
|
||||
<Devices t={t} lang={lang} />
|
||||
</div>
|
||||
@@ -24,57 +22,6 @@ export default function Settings({ t, lang, totpEnabled, onTotpChange }: { t: TF
|
||||
|
||||
const sectionTitle: React.CSSProperties = { fontSize: 14.5, fontWeight: 700, color: 'var(--fg1)' };
|
||||
|
||||
function Preferences({ t }: { t: TFn }) {
|
||||
const { lang, setLang, theme, setTheme } = useUI();
|
||||
return (
|
||||
<div style={{ ...card, padding: '18px 20px', display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={sectionTitle}>{t('prefTitle')}</div>
|
||||
<Row label={t('prefLang')}>
|
||||
<Seg
|
||||
value={lang}
|
||||
options={[['zh', '中文'], ['en', 'EN']]}
|
||||
onPick={(v) => setLang(v as Lang)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label={t('prefTheme')}>
|
||||
<Seg
|
||||
value={theme}
|
||||
options={[['light', t('themeLight')], ['dark', t('themeDark')]]}
|
||||
icons={{ light: 'sun', dark: 'moon' }}
|
||||
onPick={(v) => setTheme(v as 'light' | 'dark')}
|
||||
/>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<span style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--fg2)' }}>{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Seg({ value, options, onPick, icons }: { value: string; options: [string, string][]; onPick: (v: string) => void; icons?: Record<string, string> }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', background: 'var(--bg-subtle)', borderRadius: 999, padding: 3, gap: 2 }}>
|
||||
{options.map(([v, l]) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => onPick(v)}
|
||||
aria-pressed={value === v}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, border: 'none', cursor: 'pointer', borderRadius: 999, padding: '6px 14px', fontFamily: 'var(--font-sans)', fontSize: 12.5, fontWeight: 700, background: value === v ? 'var(--accent)' : 'transparent', color: value === v ? 'var(--fg-on-accent)' : 'var(--fg3)' }}
|
||||
>
|
||||
{icons && <Icon name={icons[v]} size={14} color={value === v ? 'var(--fg-on-accent)' : 'var(--fg3)'} />}
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── TOTP 2FA ───────── */
|
||||
function TotpSection({ t, lang, enabled, onChange }: { t: TFn; lang: Lang; enabled: boolean; onChange: () => void }) {
|
||||
const api = getClient();
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function Subscription({ t, mobile }: { t: TFn; mobile: boolean })
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, maxWidth: 720 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 25, fontWeight: 700, color: 'var(--fg1)' }}>{t('subTitle')}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--fg3)', marginTop: 5, lineHeight: 1.6 }}>{t('subDesc')}</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ import Settings from './Settings';
|
||||
import { useUI } from '../lib/theme';
|
||||
import { makeT } from '../lib/i18n';
|
||||
import { apiMode, getClient } from '../lib/api/client';
|
||||
import { hasRefresh } from '../lib/api/session';
|
||||
import { hasRefresh, clearSession } from '../lib/api/session';
|
||||
import type { Me } from '../lib/api/types';
|
||||
|
||||
type View = 'overview' | 'sub' | 'redeem' | 'invite' | 'settings';
|
||||
@@ -65,6 +65,10 @@ export default function UserCenter() {
|
||||
await api.refresh();
|
||||
if (alive) setAuthed(true);
|
||||
} catch {
|
||||
// 续期失败(refresh token 过期/被撤销/Redis 丢 JTI):清掉本地会话,
|
||||
// 否则 pg_uc_refresh 残留 → 官网仍显示"用户中心" → 点进来又续期失败 →
|
||||
// 来回刷登录页。清了官网会回到"Log in",状态一致。
|
||||
clearSession();
|
||||
if (alive) setAuthed(false);
|
||||
}
|
||||
}
|
||||
@@ -120,12 +124,6 @@ export default function UserCenter() {
|
||||
setView('overview');
|
||||
}
|
||||
|
||||
// 静态导出无服务端会话:首屏(!ready)与未登录一律直接渲染登录页,避免出现空白
|
||||
// 背景(慢网络下用户会看到"空的")。已登录用户(有 refresh)会话续期完成后再切面板。
|
||||
if (!ready || !authed) {
|
||||
return <Login onDone={onLoginDone} />;
|
||||
}
|
||||
|
||||
const nav: [View, string, string][] = [
|
||||
['overview', 'layout-dashboard', t('navOverview')],
|
||||
['sub', 'link', t('navSub')],
|
||||
@@ -161,7 +159,10 @@ export default function UserCenter() {
|
||||
</button>
|
||||
));
|
||||
|
||||
return (
|
||||
// 应用外壳(顶栏 + 导航 + 内容区);content 由调用方决定 —— 已就绪传真实视图,
|
||||
// 引导期传加载占位。关键:外壳在「引导中(乐观)」与「已登录」两态都渲染,导航/顶栏
|
||||
// 始终在位,只有内容区替换 → 刷新时不再"整页重绘",消除"刷两次"的观感。
|
||||
const appShell = (content: React.ReactNode) => (
|
||||
<div style={{ minHeight: '100vh', background: 'var(--bg)', fontFamily: 'var(--font-sans)' }}>
|
||||
{/* top bar */}
|
||||
<div style={{ position: 'sticky', top: 0, zIndex: 10, background: 'color-mix(in srgb, var(--bg) 85%, transparent)', backdropFilter: 'blur(12px)', borderBottom: '1px solid var(--border)' }}>
|
||||
@@ -180,7 +181,7 @@ export default function UserCenter() {
|
||||
<Icon name={theme === 'dark' ? 'sun' : 'moon'} size={17} color="var(--fg3)" />
|
||||
</button>
|
||||
<LangSeg lang={lang} setLang={setLang} />
|
||||
<button onClick={signOut} style={{ display: 'inline-flex', alignItems: 'center', gap: 7, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--fg2)', fontSize: 13, fontWeight: 600, padding: 6 }}>
|
||||
<button onClick={signOut} style={{ display: 'inline-flex', alignItems: 'center', gap: 7, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--fg2)', fontSize: 13, fontWeight: 600, padding: 6, whiteSpace: 'nowrap' }}>
|
||||
<Icon name="log-out" size={15} color="var(--fg3)" />
|
||||
{!mobile && t('signOut')}
|
||||
</button>
|
||||
@@ -193,9 +194,20 @@ export default function UserCenter() {
|
||||
style={{ maxWidth: 1000, margin: '0 auto', padding: mobile ? '20px 16px 40px' : '30px 24px 48px' }}
|
||||
>
|
||||
<div key={view} style={{ animation: dir !== 0 ? `uc-in-${dir === 1 ? 'l' : 'r'} 200ms var(--ease-out)` : 'none' }}>
|
||||
{main}
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const spinner = <div style={{ minHeight: '40vh', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--fg3)', fontSize: 14 }}>{t('loading')}</div>;
|
||||
|
||||
// 引导(会话续期)期间一律渲染**应用外壳骨架**(顶栏/导航 + 内容区加载态)。关键:
|
||||
// · SSR 与客户端首帧渲染同一份外壳(不在 render 期读 localStorage/hasRefresh)→ 无水合
|
||||
// 不一致,首帧(静态 HTML)即外壳。
|
||||
// · 引导完成后:已登录→外壳 + 真实视图(**只换内容区**,顶栏/导航原地不动),未登录→登录页。
|
||||
// 于是刷新时不再"裸屏 spinner → 外壳 → 内容"多段重绘,只有内容区一次替换。
|
||||
if (!ready) return appShell(spinner);
|
||||
if (!authed) return <Login onDone={onLoginDone} />;
|
||||
return appShell(main);
|
||||
}
|
||||
|
||||
@@ -161,11 +161,14 @@ export class HttpClient implements ApiClient {
|
||||
async refresh(): Promise<Session> {
|
||||
const rt = getRefreshToken();
|
||||
if (!rt) throw new ApiError({ code: 'unauthorized', message_zh: '登录已失效', message_en: 'Session expired' });
|
||||
// 服务端 /v1/auth/refresh 从 **请求体** {refresh_token} 读取(与原生 Flutter 客户端
|
||||
// auth_api.dart 一致);此处曾误用 X-Refresh-Token 头(仅 /auth/logout 读头),导致
|
||||
// 服务端拿到空 token → 400 invalid_request → 每次刷新页面即被登出。改回 body。
|
||||
const r = await this.request<RawTokenPair>('/v1/auth/refresh', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
allowRefresh: false,
|
||||
refreshToken: rt,
|
||||
body: { refresh_token: rt },
|
||||
});
|
||||
const session = mapSession(r);
|
||||
setSession(session);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 语言切换由原型的 JS 文本替换改为「路由跳转」(zh=/, en=/en/),单显不并排(铁律 6)。
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Download, Menu } from 'lucide-react';
|
||||
import { Download, Menu, Sun, Moon } from 'lucide-react';
|
||||
import { SITE } from '../config/site';
|
||||
|
||||
function Mark() {
|
||||
@@ -33,6 +33,8 @@ export default function Header({ lang = 'zh', t = {} }) {
|
||||
const [userOpen, setUserOpen] = useState(false);
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
// 明/暗主题:默认浅色(不跟随系统),持久化 localStorage key pg_site_theme。
|
||||
const [theme, setTheme] = useState('light');
|
||||
const langRef = useRef(null);
|
||||
const userRef = useRef(null);
|
||||
|
||||
@@ -61,6 +63,24 @@ export default function Header({ lang = 'zh', t = {} }) {
|
||||
const onSwitch = () => { clearSession(); window.location.href = loginHref; };
|
||||
const onLogout = () => { clearSession(); window.location.href = '/'; };
|
||||
|
||||
// 挂载时读 localStorage 同步按钮态(首屏无 FOUC 脚本已在 <head> 设好 data-theme)。
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('pg_site_theme');
|
||||
setTheme(saved === 'dark' ? 'dark' : 'light');
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
// 切换主题:写 <html data-theme> + localStorage,图标随态变。
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => {
|
||||
const next = prev === 'dark' ? 'light' : 'dark';
|
||||
try { localStorage.setItem('pg_site_theme', next); } catch { /* ignore */ }
|
||||
document.documentElement.dataset.theme = next;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!langOpen) return;
|
||||
const onDoc = (e) => { if (langRef.current && !langRef.current.contains(e.target)) setLangOpen(false); };
|
||||
@@ -113,6 +133,14 @@ export default function Header({ lang = 'zh', t = {} }) {
|
||||
))}
|
||||
</nav>
|
||||
<div class="right">
|
||||
<button
|
||||
type="button"
|
||||
class="themetoggle"
|
||||
onClick={toggleTheme}
|
||||
aria-label={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
|
||||
>
|
||||
{theme === 'dark' ? <Sun /> : <Moon />}
|
||||
</button>
|
||||
<div ref={langRef} class="langwrap">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -23,6 +23,7 @@ const plansData = {
|
||||
feats: [t('pf.free1'), t('pf.free2'), t('pf.free3'), t('pf.free4')],
|
||||
cta: t('price.cta_free'),
|
||||
ctaClass: 'pcta-out',
|
||||
href: '#download',
|
||||
highlight: false,
|
||||
},
|
||||
{
|
||||
@@ -33,6 +34,7 @@ const plansData = {
|
||||
feats: [t('pf.pro1'), t('pf.pro2'), t('pf.pro3'), t('pf.pro4'), t('pf.pro5')],
|
||||
cta: t('price.cta_pro'),
|
||||
ctaClass: 'pcta-white',
|
||||
href: '#get-code',
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
@@ -43,6 +45,7 @@ const plansData = {
|
||||
feats: [t('pf.team1'), t('pf.team2'), t('pf.team3'), t('pf.team4')],
|
||||
cta: t('price.cta_team'),
|
||||
ctaClass: 'pcta-fill',
|
||||
href: '#get-code',
|
||||
highlight: false,
|
||||
},
|
||||
],
|
||||
@@ -75,7 +78,7 @@ function cell(v: string) {
|
||||
|
||||
<div class="wrap">
|
||||
<!-- payment note -->
|
||||
<div class="pay-note">
|
||||
<div class="pay-note" id="get-code">
|
||||
<Icon name="shield-check" />
|
||||
<div>
|
||||
<b>{t('pay.title')}</b><br>
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function PricingPlans({ data }) {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button class={`pcta ${p.ctaClass}`}>{p.cta}</button>
|
||||
<a href={p.href || '#get-code'} class={`pcta ${p.ctaClass}`}>{p.cta}</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -57,6 +57,8 @@ const metaTitle = `${title} · ${t('nav.brand')}`;
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
{/* 首屏渲染前设好 data-theme,避免明/暗切换闪烁(FOUC)。postbuild 会给此内联脚本加 CSP hash。 */}
|
||||
<script is:inline>(function(){try{var t=localStorage.getItem('pg_site_theme');if(t==='dark'||t==='light')document.documentElement.dataset.theme=t;}catch(e){}})()</script>
|
||||
<title>{metaTitle}</title>
|
||||
{desc && <meta name="description" content={desc} />}
|
||||
<meta name="robots" content="index,follow" />
|
||||
|
||||
@@ -70,6 +70,8 @@ const headerT = {
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
{/* 首屏渲染前设好 data-theme,避免明/暗切换闪烁(FOUC)。postbuild 会给此内联脚本加 CSP hash。 */}
|
||||
<script is:inline>(function(){try{var t=localStorage.getItem('pg_site_theme');if(t==='dark'||t==='light')document.documentElement.dataset.theme=t;}catch(e){}})()</script>
|
||||
<title>{t('meta.title')}</title>
|
||||
<meta name="description" content={t('meta.desc')} />
|
||||
<meta name="robots" content="index,follow" />
|
||||
|
||||
@@ -42,6 +42,11 @@ img,svg{display:block}
|
||||
.linklogin{font-size:14.5px;font-weight:600;color:var(--fg1);cursor:pointer}
|
||||
.linklogin:hover{color:var(--accent)}
|
||||
|
||||
/* 明/暗主题切换(顶栏图标按钮,随 token 自适配) */
|
||||
.themetoggle{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;border:none;background:transparent;color:var(--fg2);cursor:pointer;padding:6px;transition:color var(--dur-fast) var(--ease-out)}
|
||||
.themetoggle:hover{color:var(--accent)}
|
||||
.themetoggle svg{width:17px;height:17px}
|
||||
|
||||
/* ---------- language dropdown ---------- */
|
||||
.langwrap{position:relative;display:inline-block}
|
||||
.langsel{display:inline-flex;align-items:center;gap:6px;border:1.5px solid var(--border-strong);border-radius:var(--radius-full);padding:8px 14px;background:var(--surface);color:var(--fg1);font-family:var(--font-sans);font-size:14px;font-weight:600;cursor:pointer;transition:border-color var(--dur-fast) var(--ease-out),color var(--dur-fast) var(--ease-out)}
|
||||
@@ -166,7 +171,7 @@ section{padding:88px 0}
|
||||
.plan.feat-plan .feats li{color:rgba(255,255,255,.92)}
|
||||
.plan .feats svg{width:16px;height:16px;color:var(--success);flex-shrink:0;margin-top:1px}
|
||||
.plan.feat-plan .feats svg{color:#fff}
|
||||
.plan .pcta{width:100%;border:none;border-radius:var(--radius-full);padding:13px;font-family:var(--font-sans);font-weight:700;font-size:14.5px;cursor:pointer}
|
||||
.plan .pcta{display:block;width:100%;box-sizing:border-box;text-align:center;text-decoration:none;border:none;border-radius:var(--radius-full);padding:13px;font-family:var(--font-sans);font-weight:700;font-size:14.5px;cursor:pointer}
|
||||
.pcta-fill{background:var(--accent);color:#fff}
|
||||
.pcta-white{background:#fff;color:var(--clay-700)}
|
||||
.pcta-out{background:transparent;color:var(--fg2);border:1.5px solid var(--border-strong)!important}
|
||||
|
||||
Reference in New Issue
Block a user