Files
pangolin/design/ui_kits/mobile/flows.jsx
T
wangjia a642bf16a2 feat: 同步 design/ 设计系统(含 iPad tablet kit) + 架构任务拆分(todo/)
design/ 同步自最新设计导出,新增 ui_kits/tablet/ 平板分栏布局;todo/ 录入 18 个并行实施任务。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:57:57 +08:00

270 lines
20 KiB
React

/* flows.jsx — 穿山甲 VPN mobile · auth, onboarding, redeem/buy */
const { useState: useStateF } = React;
/* shared field */
function Field({ icon, label, children }) {
return (
<label style={{ display: 'block' }}>
<span style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--fg2)', marginBottom: 7 }}>{label}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, background: 'var(--surface)', border: '1.5px solid var(--border-strong)', borderRadius: 'var(--radius-md)', padding: '12px 14px' }}>
{icon && <Icon name={icon} size={18} color="var(--fg3)" />}
{children}
</div>
</label>
);
}
const inputStyle = { border: 'none', background: 'transparent', outline: 'none', flex: 1, fontFamily: 'var(--font-sans)', fontSize: 15, color: 'var(--fg1)', minWidth: 0 };
/* ───────── AUTH (login + email-code register) ───────── */
function AuthFlow({ t, lang, onDone }) {
const [mode, setMode] = useStateF('login'); // login | register
const [step, setStep] = useStateF(0); // register: 0 email+code, 1 set password
const [email, setEmail] = useStateF('');
const [code, setCode] = useStateF('');
const [pw, setPw] = useStateF('');
const [sent, setSent] = useStateF(false);
const [totp, setTotp] = useStateF(false); // 2FA step after login (PRO)
const [otp, setOtp] = useStateF('');
const valid = /\S+@\S+\.\S+/.test(email);
const tab = (id, label) => (
<button onClick={() => { setMode(id); setStep(0); setSent(false); }} style={{
flex: 1, border: 'none', cursor: 'pointer', background: 'transparent', padding: '10px 0',
fontFamily: 'var(--font-sans)', fontSize: 15, fontWeight: mode === id ? 700 : 500,
color: mode === id ? 'var(--fg1)' : 'var(--fg3)', borderBottom: mode === id ? '2px solid var(--accent)' : '2px solid transparent',
}}>{label}</button>
);
const primaryBtn = (label, onClick, enabled) => (
<button onClick={enabled ? onClick : undefined} disabled={!enabled} style={{
width: '100%', border: 'none', borderRadius: 'var(--radius-full)', padding: '14px',
fontFamily: 'var(--font-sans)', fontWeight: 700, fontSize: 15, cursor: enabled ? 'pointer' : 'not-allowed',
background: 'var(--accent)', color: 'var(--fg-on-accent)', opacity: enabled ? 1 : 0.45, marginTop: 4,
}}>{label}</button>
);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: '0 28px', background: 'var(--bg)' }}>
{/* brand header */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12, paddingTop: 18, paddingBottom: 28 }}>
<Mark size={56} />
<div style={{ textAlign: 'center' }}>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 24, color: 'var(--fg1)' }}>{t('brand')}</div>
<div style={{ fontSize: 13, color: 'var(--accent)', fontWeight: 600, letterSpacing: '0.04em', marginTop: 4 }}>{t('authTagline')}</div>
</div>
</div>
{/* tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid var(--border)', marginBottom: 22 }}>
{tab('login', t('tabLogin'))}
{tab('register', t('tabRegister'))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, flex: 1 }}>
{mode === 'login' && !totp && (<>
<Field icon="mail" label={t('emailLabel')}><input style={inputStyle} value={email} onChange={e => setEmail(e.target.value)} placeholder={t('emailPh')} /></Field>
<Field icon="lock" label={t('pwLabel')}><input style={inputStyle} type="password" value={pw} onChange={e => setPw(e.target.value)} placeholder={t('pwPh')} /></Field>
<div style={{ textAlign: 'right', marginTop: -6 }}><span style={{ fontSize: 13, color: 'var(--accent)', fontWeight: 600 }}>{t('forgotPw')}</span></div>
{primaryBtn(t('doLogin'), () => setTotp(true), valid && pw.length > 0)}
</>)}
{mode === 'login' && totp && (<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Icon name="shield-check" size={19} color="var(--accent)" />
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 18, color: 'var(--fg1)' }}>{t('twoFATitle')}</span>
<span style={{ fontSize: 10, fontWeight: 700, color: 'var(--accent)', background: 'var(--accent-subtle)', border: '1px solid var(--accent-border)', padding: '2px 8px', borderRadius: 999 }}>PRO</span>
</div>
<div style={{ fontSize: 12.5, color: 'var(--fg3)', lineHeight: 1.6, marginTop: -6 }}>{t('twoFAHint')}</div>
<Field icon="shield-check" label={t('twoFATitle')}>
<input style={{ ...inputStyle, fontFamily: 'var(--font-mono)', fontSize: 20, letterSpacing: '0.5em', textAlign: 'center' }} value={otp} onChange={e => setOtp(e.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="······" />
</Field>
{primaryBtn(t('twoFAConfirm'), onDone, otp.length === 6)}
<div onClick={() => setTotp(false)} style={{ textAlign: 'center', fontSize: 12.5, color: 'var(--fg3)', cursor: 'pointer' }}>{t('twoFABack')}</div>
</>)}
{mode === 'register' && step === 0 && (<>
<Field icon="mail" label={t('emailLabel')}>
<input style={inputStyle} value={email} onChange={e => setEmail(e.target.value)} placeholder={t('emailPh')} />
<button onClick={() => valid && setSent(true)} style={{ border: 'none', background: 'transparent', cursor: valid ? 'pointer' : 'default', color: valid ? 'var(--accent)' : 'var(--fg3)', fontWeight: 700, fontSize: 13, whiteSpace: 'nowrap', flexShrink: 0 }}>{sent ? t('resend') : t('sendCode')}</button>
</Field>
{sent && <div style={{ fontSize: 12, color: 'var(--success)', marginTop: -8, display: 'flex', alignItems: 'center', gap: 6 }}><Icon name="check-circle" size={14} color="var(--success)" />{t('codeSentTo')} {email}</div>}
<Field icon="shield-check" label={t('codeLabel')}>
<input style={{ ...inputStyle, fontFamily: 'var(--font-mono)', letterSpacing: '0.3em' }} value={code} onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="······" />
</Field>
{primaryBtn(t('doNext'), () => setStep(1), sent && code.length === 6)}
</>)}
{mode === 'register' && step === 1 && (<>
<div style={{ fontSize: 13, color: 'var(--fg2)', display: 'flex', alignItems: 'center', gap: 7, marginBottom: -4 }}><Icon name="check-circle" size={15} color="var(--success)" />{email}</div>
<Field icon="lock" label={t('pwLabel')}><input style={inputStyle} type="password" value={pw} onChange={e => setPw(e.target.value)} placeholder={t('setPwPh')} /></Field>
{primaryBtn(t('doCreate'), onDone, pw.length >= 6)}
</>)}
</div>
<div style={{ fontSize: 11, color: 'var(--fg3)', textAlign: 'center', lineHeight: 1.5, padding: '18px 8px 24px' }}>{t('tos')}</div>
</div>
);
}
/* ───────── ONBOARDING (3 screens) ───────── */
function Onboarding({ t, lang, onDone }) {
const [i, setI] = useStateF(0);
const slides = [
{ icon: 'globe', tint: 'var(--accent)', bg: 'var(--accent-subtle)', title: t('ob1Title'), sub: t('ob1Sub'), cta: t('obNext') },
{ icon: 'shield', tint: 'var(--accent)', bg: 'var(--accent-subtle)', title: t('ob2Title'), sub: t('ob2Sub'), cta: t('obAllow') },
{ icon: 'shield-check', tint: 'var(--success)', bg: 'var(--success-subtle)', title: t('ob3Title'), sub: t('ob3Sub'), cta: t('obStart') },
];
const s = slides[i];
const next = () => (i < 2 ? setI(i + 1) : onDone());
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: '0 28px', background: 'var(--bg)' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 10 }}>
<button onClick={onDone} style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--fg3)', fontSize: 14, fontWeight: 600, padding: 8 }}>{t('obSkip')}</button>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 28, textAlign: 'center' }}>
<div style={{ width: 120, height: 120, borderRadius: '50%', background: s.bg, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Icon name={s.icon} size={52} stroke={1.6} color={s.tint} />
</div>
<div>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 26, color: 'var(--fg1)' }}>{s.title}</div>
<div style={{ fontSize: 15, color: 'var(--fg2)', lineHeight: 1.6, marginTop: 12, maxWidth: 280 }}>{s.sub}</div>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginBottom: 22 }}>
{slides.map((_, k) => <div key={k} style={{ width: k === i ? 22 : 7, height: 7, borderRadius: 999, background: k === i ? 'var(--accent)' : 'var(--border-strong)', transition: 'all 220ms var(--ease-out)' }} />)}
</div>
<button onClick={next} style={{ width: '100%', border: 'none', borderRadius: 'var(--radius-full)', padding: '15px', fontFamily: 'var(--font-sans)', fontWeight: 700, fontSize: 15, cursor: 'pointer', background: 'var(--accent)', color: 'var(--fg-on-accent)', marginBottom: 26 }}>{s.cta}</button>
</div>
);
}
/* ───────── REDEEM & BUY ───────── */
function RedeemScreen({ t, lang, onBack }) {
const [code, setCode] = useStateF('');
const [ok, setOk] = useStateF(false);
const channels = [
{ icon: 'shopping-bag', name: t('chStore'), sub: t('chStoreSub'), accent: true },
{ icon: 'credit-card', name: 'USDT (TRC20)', sub: t('chUsdtSub') },
{ icon: 'send', name: 'Telegram', sub: t('chTgSub') },
{ icon: 'message-circle', name: 'LINE', sub: t('chLineSub') },
{ icon: 'mail', name: t('chEmail'), sub: t('chEmailSub') },
];
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 16px 14px' }}>
<button onClick={onBack} style={{ border: 'none', background: 'transparent', cursor: 'pointer', padding: 6, display: 'flex' }}><Icon name="arrow-left" size={22} color="var(--fg1)" /></button>
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 18, color: 'var(--fg1)', whiteSpace: 'nowrap' }}>{t('redeemTitle')}</span>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '0 20px 20px' }}>
{/* redeem code */}
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-sm)', padding: 18, marginBottom: 22 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--fg1)', marginBottom: 12 }}>{t('redeemCodeTitle')}</div>
{ok ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 9, color: 'var(--success)', fontWeight: 600, fontSize: 15, padding: '6px 0' }}><Icon name="check-circle" size={20} color="var(--success)" />{t('redeemOk')}</div>
) : (<>
<div style={{ display: 'flex', gap: 10 }}>
<input value={code} onChange={e => setCode(e.target.value.toUpperCase())} placeholder={t('redeemPh')} style={{ flex: 1, minWidth: 0, border: '1.5px solid var(--border-strong)', borderRadius: 'var(--radius-md)', padding: '12px 14px', fontFamily: 'var(--font-mono)', fontSize: 14, letterSpacing: '0.08em', color: 'var(--fg1)', background: 'var(--bg)', outline: 'none' }} />
<button onClick={() => code.length >= 4 && setOk(true)} style={{ border: 'none', borderRadius: 'var(--radius-md)', padding: '0 20px', background: 'var(--accent)', color: '#fff', fontWeight: 700, fontSize: 14, cursor: 'pointer', flexShrink: 0 }}>{t('redeemBtn')}</button>
</div>
</>)}
</div>
{/* buy channels */}
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--fg1)', marginBottom: 6 }}>{t('buyTitle')}</div>
<div style={{ fontSize: 12.5, color: 'var(--fg3)', lineHeight: 1.5, marginBottom: 14 }}>{t('buySub')}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{channels.map(c => (
<button key={c.name} style={{ display: 'flex', alignItems: 'center', gap: 13, width: '100%', textAlign: 'left', cursor: 'pointer',
background: c.accent ? 'var(--accent-subtle)' : 'var(--surface)', border: `1px solid ${c.accent ? 'var(--accent-border)' : 'var(--border)'}`,
borderRadius: 'var(--radius-lg)', padding: '14px 16px', boxShadow: 'var(--shadow-sm)' }}>
<div style={{ width: 40, height: 40, borderRadius: 'var(--radius-md)', background: c.accent ? 'var(--accent)' : 'var(--bg-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Icon name={c.icon} size={20} color={c.accent ? '#fff' : 'var(--accent)'} />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--fg1)' }}>{c.name}</div>
<div style={{ fontSize: 12.5, color: 'var(--fg3)', fontFamily: c.icon === 'send' || c.icon === 'mail' || c.icon === 'message-circle' ? 'var(--font-mono)' : 'var(--font-sans)' }}>{c.sub}</div>
</div>
<Icon name="external-link" size={17} color="var(--fg3)" />
</button>
))}
</div>
</div>
</div>
);
}
/* ───────── PLANS (shown on upgrade) ───────── */
function PlansScreen({ t, lang, onBack, onChoose }) {
const plans = [
{ id: 'free', name: t('free'), price: '¥0', feats: t('feFree'), cta: t('current'), variant: 'cur' },
{ id: 'pro', name: t('proPlan'), price: '¥25', feats: t('fePro'), cta: t('upgrade'), variant: 'pro' },
{ id: 'team', name: t('team'), price: '¥99', feats: t('feTeam'), cta: t('choose'), variant: 'plain' },
];
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 16px 14px' }}>
<button onClick={onBack} style={{ border: 'none', background: 'transparent', cursor: 'pointer', padding: 6, display: 'flex' }}><Icon name="arrow-left" size={22} color="var(--fg1)" /></button>
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 18, color: 'var(--fg1)', whiteSpace: 'nowrap' }}>{t('choosePlan')}</span>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '4px 20px 20px', display: 'flex', flexDirection: 'column', gap: 14 }}>
{plans.map(p => {
const isPro = p.variant === 'pro';
return (
<div key={p.id} style={{ position: 'relative', borderRadius: 'var(--radius-xl)', padding: '18px 20px',
...(isPro ? { background: 'linear-gradient(155deg,var(--clay-600),var(--clay-800))', color: '#fff', boxShadow: 'var(--shadow-md)' } : { background: 'var(--surface)', border: '1px solid var(--border)', boxShadow: 'var(--shadow-sm)' }) }}>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}>
<div style={{ fontSize: 14, fontWeight: 600, color: isPro ? '#fff' : 'var(--fg2)', opacity: isPro ? 0.9 : 1 }}>{p.name}{isPro && <span style={{ marginLeft: 8, fontSize: 10.5, fontWeight: 700, background: '#fff', color: 'var(--clay-700)', padding: '2px 8px', borderRadius: 999 }}>{t('mostPopular')}</span>}</div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 26, fontWeight: 700, color: isPro ? '#fff' : 'var(--fg1)' }}>{p.price}<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.7 }}>{t('perMonth')}</span></div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '7px 16px', margin: '14px 0 16px' }}>
{p.feats.map((f, i) => (<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 7, fontSize: 12.5, color: isPro ? 'rgba(255,255,255,.92)' : 'var(--fg2)' }}><Icon name="check" size={14} color={isPro ? '#fff' : 'var(--success)'} stroke={2.4} />{f}</div>))}
</div>
<button onClick={p.variant !== 'cur' ? onChoose : undefined} style={{ width: '100%', border: p.variant === 'cur' ? '1.5px solid var(--border-strong)' : 'none', cursor: p.variant === 'cur' ? 'default' : 'pointer', borderRadius: 'var(--radius-full)', padding: '12px', fontWeight: 700, fontSize: 14,
background: isPro ? '#fff' : (p.variant === 'cur' ? 'transparent' : 'var(--accent)'), color: isPro ? 'var(--clay-700)' : (p.variant === 'cur' ? 'var(--fg2)' : '#fff') }}>{p.cta}</button>
</div>
);
})}
</div>
</div>
);
}
/* ───────── CONTACT US ───────── */
function ContactScreen({ t, lang, onBack }) {
const channels = [
{ icon: 'send', name: 'Telegram', sub: '@PangolinVPN_bot', accent: true },
{ icon: 'message-circle', name: 'LINE', sub: '@pangolinvpn' },
{ icon: 'mail', name: t('contactEmail'), sub: 'support@pangolin.vpn' },
{ icon: 'shopping-bag', name: t('contactStore'), sub: 'shop.pangolin.vpn' },
];
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 16px 14px' }}>
<button onClick={onBack} style={{ border: 'none', background: 'transparent', cursor: 'pointer', padding: 6, display: 'flex' }}><Icon name="arrow-left" size={22} color="var(--fg1)" /></button>
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 18, color: 'var(--fg1)', whiteSpace: 'nowrap' }}>{t('contactTitle')}</span>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '0 20px 20px' }}>
<div style={{ fontSize: 13, color: 'var(--fg2)', lineHeight: 1.6, marginBottom: 16 }}>{t('contactIntro')}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 18 }}>
{channels.map(c => (
<button key={c.name} style={{ display: 'flex', alignItems: 'center', gap: 13, width: '100%', textAlign: 'left', cursor: 'pointer',
background: c.accent ? 'var(--accent-subtle)' : 'var(--surface)', border: `1px solid ${c.accent ? 'var(--accent-border)' : 'var(--border)'}`,
borderRadius: 'var(--radius-lg)', padding: '14px 16px', boxShadow: 'var(--shadow-sm)' }}>
<div style={{ width: 40, height: 40, borderRadius: 'var(--radius-md)', background: c.accent ? 'var(--accent)' : 'var(--bg-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}><Icon name={c.icon} size={20} color={c.accent ? '#fff' : 'var(--accent)'} /></div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--fg1)' }}>{c.name}</div>
<div style={{ fontSize: 12.5, color: 'var(--fg3)', fontFamily: 'var(--font-mono)' }}>{c.sub}</div>
</div>
<Icon name="external-link" size={17} color="var(--fg3)" />
</button>
))}
</div>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-sm)', padding: '14px 16px' }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--fg3)' }}>{t('contactHoursTitle')}</div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--fg1)', marginTop: 5, fontFamily: 'var(--font-mono)' }}>{t('contactHours')}</div>
</div>
</div>
</div>
);
}
Object.assign(window, { AuthFlow, Onboarding, RedeemScreen, PlansScreen, ContactScreen });