feat(website): 独立注册页 /register(邮箱/邀请码预填+验证码注册+平台匹配下载)+ 主页/落地页跳转接入 + 登录态隐藏注册入口
ci-pangolin / Lint — shellcheck (pull_request) Successful in 13s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 25s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 23s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 34s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 21s
ci-pangolin / Flutter — analyze + test (pull_request) Successful in 34s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 8s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 8s
ci-pangolin / Go — build + test (pull_request) Failing after 13s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 10s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 4m34s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 19s
ci-pangolin / Lint — shellcheck (pull_request) Successful in 13s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 25s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 23s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 34s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 21s
ci-pangolin / Flutter — analyze + test (pull_request) Successful in 34s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 8s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 8s
ci-pangolin / Go — build + test (pull_request) Failing after 13s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 10s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 4m34s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 19s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
# Cloudflare Pages 安全响应头 — 由 public/_headers 原样下发到产物根。
|
||||
# 设计:严格 CSP(白名单仅 self + 自托管资源)、HSTS、隐私无第三方。
|
||||
# 站点完全静态、自托管字体与脚本、无第三方统计/CDN,故所有 *-src 收敛到 'self'。
|
||||
# 站点完全静态、自托管字体与脚本、无第三方统计/CDN,故大部分 *-src 收敛到 'self';
|
||||
# 唯一例外 connect-src 放行 api.yanmeiai.com(控制面 API,Cloudflare Tunnel 出口,
|
||||
# CORS 已放行本站 origin)——落地页/注册页/首页注册入口浏览器端直连该域发验证码、
|
||||
# 注册(见 src/components/RegisterCard.jsx),不经站内代理。
|
||||
|
||||
/*
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://api.yanmeiai.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests
|
||||
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
|
||||
X-Content-Type-Options: nosniff
|
||||
X-Frame-Options: DENY
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
/**
|
||||
* AnnouncementBar.jsx — 顶部公告条(React island)。迁自原型 .ann + site.js __closeAnn。
|
||||
*
|
||||
* CTA 直接跳独立注册页 /register(不再锚点滚动到 hero 的 #signup —— 那边现在本身也只是
|
||||
* 跳 /register 的一层转发,直达更省一跳)。已登录(见 lib/authState.js)时整条隐藏:
|
||||
* 老用户不需要看到「新用户注册」的横幅。无 JS 时默认展示(渐进增强)。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Gift, X } from 'lucide-react';
|
||||
import { isLoggedIn } from '../lib/authState';
|
||||
|
||||
export default function AnnouncementBar({ text, cta }) {
|
||||
const [closed, setClosed] = useState(false);
|
||||
if (closed) return null;
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoggedIn(isLoggedIn());
|
||||
}, []);
|
||||
|
||||
if (closed || loggedIn) return null;
|
||||
return (
|
||||
<div class="ann" id="ann">
|
||||
<div class="wrap ann-in">
|
||||
<Gift />
|
||||
<span>{text}</span>
|
||||
<a href="#signup">{cta}</a>
|
||||
<a href="/register">{cta}</a>
|
||||
<button class="ann-x" onClick={() => setClosed(true)} aria-label="close">
|
||||
<X />
|
||||
</button>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Download, Menu } from 'lucide-react';
|
||||
import { SITE } from '../config/site';
|
||||
import { isLoggedIn, getStoredEmail, clearStoredSession } from '../lib/authState';
|
||||
|
||||
function Mark() {
|
||||
return (
|
||||
@@ -42,24 +43,15 @@ export default function Header({ lang = 'zh', t = {} }) {
|
||||
const displayName = (email && email.split('@')[0]) || 'Account';
|
||||
|
||||
// 与用户中心同源:登录后 localStorage 存 pg_uc_refresh(+ pg_uc_email)→ 显示用户名下拉。
|
||||
// 判断逻辑单一实现见 lib/authState.js(AnnouncementBar/SignupForm/RegisterCard/InviteCard 同源复用)。
|
||||
useEffect(() => {
|
||||
try {
|
||||
setLoggedIn(!!localStorage.getItem('pg_uc_refresh'));
|
||||
setEmail(localStorage.getItem('pg_uc_email') || '');
|
||||
} catch {
|
||||
setLoggedIn(false);
|
||||
}
|
||||
setLoggedIn(isLoggedIn());
|
||||
setEmail(getStoredEmail());
|
||||
}, []);
|
||||
|
||||
// 清登录态并跳转(切换用户 = 回登录页;退出 = 回主页)。
|
||||
const clearSession = () => {
|
||||
try {
|
||||
localStorage.removeItem('pg_uc_refresh');
|
||||
localStorage.removeItem('pg_uc_email');
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
const onSwitch = () => { clearSession(); window.location.href = loginHref; };
|
||||
const onLogout = () => { clearSession(); window.location.href = '/'; };
|
||||
const onSwitch = () => { clearStoredSession(); window.location.href = loginHref; };
|
||||
const onLogout = () => { clearStoredSession(); window.location.href = '/'; };
|
||||
|
||||
useEffect(() => {
|
||||
if (!langOpen) return;
|
||||
|
||||
@@ -17,9 +17,9 @@ const { t } = Astro.props;
|
||||
client:idle
|
||||
ph={t('su.ph')}
|
||||
btn={t('su.btn')}
|
||||
ok={t('su.ok')}
|
||||
hint={t('su.hint')}
|
||||
dl={t('su.dl')}
|
||||
centerLabel={t('menu.center')}
|
||||
/>
|
||||
|
||||
<div class="trust">
|
||||
|
||||
@@ -9,13 +9,19 @@
|
||||
* 开关,此前只有原型用过、构建期路由化后闲置)做「单显 + 切换」:任意时刻只渲染
|
||||
* 一种语言的文本,不并排堆叠,语义上与全站单显原则一致,只是切换时机在运行时。
|
||||
* Header/Footer 仍按全站惯例整页单显中文(面向主力市场:中国大陆用户)。
|
||||
*
|
||||
* 主 CTA 是「注册领取 3 天」按钮,跳去独立注册页 /register(?invite=<码> 预填,见
|
||||
* RegisterCard.jsx),本页不再内联注册表单。已登录(同源 localStorage 里有
|
||||
* pg_uc_refresh,见 lib/authState.js)时把 CTA 换成提示文案——邀请码只在新账号
|
||||
* 注册时生效,老账号无法补绑。
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Copy, Check, Download, UserPlus, Gift, ArrowRight } from 'lucide-react';
|
||||
import { isLoggedIn } from '../lib/authState';
|
||||
import { INVITE_CODE_RE } from '../lib/inviteCode';
|
||||
|
||||
// 邀请码字母表:A-Z 去 O/I,2-9 去 0/1(与 server/internal/reward/service.go
|
||||
// 的 inviteAlphabet 一致),固定 8 位。仅做「像不像邀请码」的格式校验,不联网核验。
|
||||
const CODE_RE = /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{8}$/;
|
||||
// 邀请码字母表定义单一实现在 lib/inviteCode.js(与 RegisterCard.jsx 的 ?invite=
|
||||
// 解析共用,避免两处定义漂移)。
|
||||
|
||||
function extractCode(pathname) {
|
||||
const m = /\/i\/([^/]+)/i.exec(pathname || '');
|
||||
@@ -27,7 +33,7 @@ function extractCode(pathname) {
|
||||
raw = m[1];
|
||||
}
|
||||
raw = raw.trim().toUpperCase();
|
||||
return CODE_RE.test(raw) ? raw : null;
|
||||
return INVITE_CODE_RE.test(raw) ? raw : null;
|
||||
}
|
||||
|
||||
const COPY = {
|
||||
@@ -47,6 +53,8 @@ const COPY = {
|
||||
step2: '注册时在「邀请码(选填)」填入此码',
|
||||
step3: '双方到账,天数自动加到账户',
|
||||
dl: '免费下载',
|
||||
ctaRegister: '注册领取 3 天',
|
||||
ctaLoggedIn: '你已登录,邀请码仅新账号注册时可用',
|
||||
},
|
||||
en: {
|
||||
eyebrow: 'Invite',
|
||||
@@ -64,6 +72,8 @@ const COPY = {
|
||||
step2: 'Enter this code under “Invite code (optional)” at sign-up',
|
||||
step3: 'Days are credited to both accounts automatically',
|
||||
dl: 'Download free',
|
||||
ctaRegister: 'Register & claim +3 days',
|
||||
ctaLoggedIn: "You're already signed in — invite codes only apply at sign-up",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -71,9 +81,11 @@ export default function InviteCard() {
|
||||
const [code, setCode] = useState(null);
|
||||
const [lang, setLang] = useState('zh');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setCode(extractCode(window.location.pathname));
|
||||
setLoggedIn(isLoggedIn());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -82,6 +94,7 @@ export default function InviteCard() {
|
||||
|
||||
const t = COPY[lang];
|
||||
const dlHref = useMemo(() => `${lang === 'zh' ? '/zh/' : '/'}#download`, [lang]);
|
||||
const registerHref = useMemo(() => (code ? `/register?invite=${code}` : '/register'), [code]);
|
||||
|
||||
async function onCopy() {
|
||||
if (!code) return;
|
||||
@@ -126,6 +139,16 @@ export default function InviteCard() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{loggedIn ? (
|
||||
<div class="invite-loggedin-note">{t.ctaLoggedIn}</div>
|
||||
) : (
|
||||
<a class="btn btn-primary btn-lg invite-register-btn" href={registerHref}>
|
||||
<UserPlus />
|
||||
<span>{t.ctaRegister}</span>
|
||||
<ArrowRight />
|
||||
</a>
|
||||
)}
|
||||
|
||||
<div class="invite-rewards">
|
||||
<div class="invite-rewards-title">
|
||||
<Gift />
|
||||
@@ -161,10 +184,9 @@ export default function InviteCard() {
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<a class="btn btn-primary btn-lg invite-dl-btn" href={dlHref}>
|
||||
<a class="btn btn-secondary invite-dl-btn" href={dlHref}>
|
||||
<Download />
|
||||
<span>{t.dl}</span>
|
||||
<ArrowRight />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* RegisterCard.jsx — 独立注册页 `/register` 正文(React island)。
|
||||
*
|
||||
* 承接两处入口:① 首页 hero「免费注册」表单跳转(?email= 预填);② 邀请落地页
|
||||
* `/i/<code>`「注册领取 3 天」跳转(?invite= 预填)。二者共用同一套「验证码 + 设密码」
|
||||
* 注册流程,行为与后端契约见 server/internal/auth/handler.go:
|
||||
*
|
||||
* POST /v1/auth/code body {email} → 204
|
||||
* POST /v1/auth/register body {email,code,password,invite_code?}
|
||||
* (device 整个省略——web 无设备指纹,后端零值容忍)
|
||||
* → 200 {access_token,refresh_token,...}(网页不存,注册成功即弃)
|
||||
*
|
||||
* 错误统一 {code,message_zh,message_en},按当前语言态取对应文案;网络错用通用双语兜底。
|
||||
*
|
||||
* 双语处理与 InviteCard.jsx 同一路数:不走全站 i18n/strings.ts 的「构建期按路由单显」
|
||||
* (那个模式要求语言在构建期已知),而是本组件内嵌 zh/en 两份文案、用 `.langseg`
|
||||
* 单显 + 运行时切换 —— 任意时刻只渲染一种语言。
|
||||
*
|
||||
* 已登录(同源 localStorage 里有 pg_uc_refresh,见 lib/authState.js)直接短路成
|
||||
* 「你已登录」提示,不渲染注册表单(无 JS 时默认展示表单,渐进增强)。
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Mail, KeyRound, Lock, Loader2, AlertCircle, CheckCircle2, Download, ArrowRight } from 'lucide-react';
|
||||
import { SITE } from '../config/site';
|
||||
import { isLoggedIn } from '../lib/authState';
|
||||
import { normalizeInviteCode } from '../lib/inviteCode';
|
||||
|
||||
const API_BASE = import.meta.env.PUBLIC_API_BASE || SITE.api;
|
||||
const RESEND_SECS = 60;
|
||||
const REDIRECT_SECS = 3;
|
||||
|
||||
const COPY = {
|
||||
zh: {
|
||||
eyebrow: '注册',
|
||||
titleInvite: '注册穿山甲,双方各得 3 天',
|
||||
titlePlain: '创建你的穿山甲账户',
|
||||
sub: '一个邮箱即可开始,免费注册,无需任何付款信息。',
|
||||
emailLabel: '邮箱',
|
||||
emailPh: '你的邮箱',
|
||||
sendCode: '发送验证码',
|
||||
resend: '重新发送',
|
||||
resendIn: (n) => `${n}s 后可重发`,
|
||||
sending: '发送中…',
|
||||
codeLabel: '验证码',
|
||||
codePh: '6 位验证码',
|
||||
pwLabel: '设置密码',
|
||||
pwPh: '至少 8 位',
|
||||
pwHint: '密码至少 8 位',
|
||||
submitInvite: '注册并领取 3 天',
|
||||
submitPlain: '注册',
|
||||
submitting: '注册中…',
|
||||
netErr: '网络异常,请检查网络后重试',
|
||||
emailInvalid: '请输入正确的邮箱地址',
|
||||
formIncomplete: '请填写完整的验证码与密码(密码至少 8 位)',
|
||||
successTitle: '注册成功',
|
||||
successSubInvite: '3 天已到账,用刚注册的邮箱登录穿山甲即可查收。',
|
||||
successSubPlain: '请下载穿山甲,用刚注册的邮箱登录。',
|
||||
dlMac: '下载 macOS 版',
|
||||
dlWin: '下载 Windows 版',
|
||||
dlAndroid: '下载 Android 版',
|
||||
dlGeneric: '前往下载',
|
||||
backHome: '返回首页',
|
||||
redirecting: (n) => `${n} 秒后自动返回首页`,
|
||||
loggedInTitle: '你已登录',
|
||||
loggedInSub: '无需重复注册,直接返回首页或前往用户中心即可。',
|
||||
loggedInHome: '返回首页',
|
||||
loggedInCenter: '打开用户中心',
|
||||
},
|
||||
en: {
|
||||
eyebrow: 'Register',
|
||||
titleInvite: 'Sign up — you and your friend each get +3 days',
|
||||
titlePlain: 'Create your Pangolin account',
|
||||
sub: 'Just an email to start. Free to join, no payment details.',
|
||||
emailLabel: 'Email',
|
||||
emailPh: 'Your email',
|
||||
sendCode: 'Send code',
|
||||
resend: 'Resend',
|
||||
resendIn: (n) => `Resend in ${n}s`,
|
||||
sending: 'Sending…',
|
||||
codeLabel: 'Verification code',
|
||||
codePh: '6-digit code',
|
||||
pwLabel: 'Set a password',
|
||||
pwPh: 'At least 8 characters',
|
||||
pwHint: 'Password must be at least 8 characters',
|
||||
submitInvite: 'Register & claim +3 days',
|
||||
submitPlain: 'Register',
|
||||
submitting: 'Registering…',
|
||||
netErr: 'Network error, please check your connection and retry',
|
||||
emailInvalid: 'Please enter a valid email address',
|
||||
formIncomplete: 'Please fill in the code and a password (at least 8 characters)',
|
||||
successTitle: 'Registration complete',
|
||||
successSubInvite: 'Your +3 days are credited. Sign in to the app with this email to see them.',
|
||||
successSubPlain: 'Download Pangolin and sign in with this email.',
|
||||
dlMac: 'Download for macOS',
|
||||
dlWin: 'Download for Windows',
|
||||
dlAndroid: 'Download for Android',
|
||||
dlGeneric: 'Go to downloads',
|
||||
backHome: 'Back to home',
|
||||
redirecting: (n) => `Returning to home in ${n}s`,
|
||||
loggedInTitle: "You're already signed in",
|
||||
loggedInSub: 'No need to register again — head back home or open the account center.',
|
||||
loggedInHome: 'Back to home',
|
||||
loggedInCenter: 'Open account center',
|
||||
},
|
||||
};
|
||||
|
||||
function detectPlatform() {
|
||||
if (typeof navigator === 'undefined') return 'unknown';
|
||||
const ua = navigator.userAgent || '';
|
||||
const platform = navigator.platform || '';
|
||||
// iPadOS 13+ 把 UA 伪装成 Mac,靠触点数区分(真 Mac 无多点触控)。
|
||||
const isIOS = /iPhone|iPad|iPod/.test(ua) || (platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
if (isIOS) return 'ios';
|
||||
if (/Android/.test(ua)) return 'android';
|
||||
if (/Win(dows)?|Win32|Win64/.test(ua) || /Win32|Win64/.test(platform)) return 'windows';
|
||||
if (/Mac/.test(platform) || /Macintosh/.test(ua)) return 'macos';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
async function callApi(path, body) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(API_BASE + path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch {
|
||||
return { ok: false, network: true };
|
||||
}
|
||||
let data = null;
|
||||
try {
|
||||
const text = await res.text();
|
||||
if (text) data = JSON.parse(text);
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
if (!res.ok) return { ok: false, network: false, error: data };
|
||||
return { ok: true, data };
|
||||
}
|
||||
|
||||
export default function RegisterCard() {
|
||||
const [lang, setLang] = useState('zh');
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const [checkedAuth, setCheckedAuth] = useState(false);
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [invite, setInvite] = useState(null); // 规范化后的邀请码,或 null
|
||||
|
||||
const [code, setCode] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [pwTouched, setPwTouched] = useState(false);
|
||||
|
||||
const [codeSent, setCodeSent] = useState(false);
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [codeErr, setCodeErr] = useState('');
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
const cooldownRef = useRef(null);
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [regErr, setRegErr] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [redirectIn, setRedirectIn] = useState(REDIRECT_SECS);
|
||||
const redirectRef = useRef(null);
|
||||
const codeInputRef = useRef(null);
|
||||
|
||||
const t = COPY[lang];
|
||||
const msgKey = lang === 'zh' ? 'message_zh' : 'message_en';
|
||||
const homeHref = lang === 'zh' ? '/zh/' : '/';
|
||||
const dlAnchorHref = `${homeHref}#download`;
|
||||
|
||||
// 读 query 参数(?email=&invite=) + 登录态;均只在挂载时读一次。
|
||||
useEffect(() => {
|
||||
try {
|
||||
const qs = new URLSearchParams(window.location.search);
|
||||
const qEmail = qs.get('email');
|
||||
if (qEmail) setEmail(qEmail);
|
||||
setInvite(normalizeInviteCode(qs.get('invite')));
|
||||
} catch {
|
||||
/* ignore malformed query */
|
||||
}
|
||||
document.documentElement.lang = 'zh-CN';
|
||||
setLoggedIn(isLoggedIn());
|
||||
setCheckedAuth(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en';
|
||||
}, [lang]);
|
||||
|
||||
useEffect(() => () => clearInterval(cooldownRef.current), []);
|
||||
useEffect(() => () => clearInterval(redirectRef.current), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (codeSent && codeInputRef.current) codeInputRef.current.focus();
|
||||
}, [codeSent]);
|
||||
|
||||
// 成功后自动倒计时返回首页(点「返回首页」等价立即触发)。
|
||||
useEffect(() => {
|
||||
if (!success) return undefined;
|
||||
setRedirectIn(REDIRECT_SECS);
|
||||
redirectRef.current = setInterval(() => {
|
||||
setRedirectIn((s) => {
|
||||
if (s <= 1) {
|
||||
clearInterval(redirectRef.current);
|
||||
window.location.href = homeHref;
|
||||
return 0;
|
||||
}
|
||||
return s - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(redirectRef.current);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [success]);
|
||||
|
||||
function startCooldown() {
|
||||
setCooldown(RESEND_SECS);
|
||||
clearInterval(cooldownRef.current);
|
||||
cooldownRef.current = setInterval(() => {
|
||||
setCooldown((s) => {
|
||||
if (s <= 1) {
|
||||
clearInterval(cooldownRef.current);
|
||||
return 0;
|
||||
}
|
||||
return s - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function onSendCode(e) {
|
||||
e.preventDefault();
|
||||
setCodeErr('');
|
||||
const trimmed = email.trim();
|
||||
if (!/\S+@\S+\.\S+/.test(trimmed)) {
|
||||
setCodeErr(t.emailInvalid);
|
||||
return;
|
||||
}
|
||||
setSendingCode(true);
|
||||
const r = await callApi('/v1/auth/code', { email: trimmed });
|
||||
setSendingCode(false);
|
||||
if (!r.ok) {
|
||||
setCodeErr(r.network ? t.netErr : (r.error && r.error[msgKey]) || t.netErr);
|
||||
return;
|
||||
}
|
||||
setCodeSent(true);
|
||||
startCooldown();
|
||||
}
|
||||
|
||||
async function onRegister(e) {
|
||||
e.preventDefault();
|
||||
setRegErr('');
|
||||
const trimmedEmail = email.trim();
|
||||
if (!/\S+@\S+\.\S+/.test(trimmedEmail) || code.length !== 6 || password.length < 8) {
|
||||
setPwTouched(true);
|
||||
setRegErr(t.formIncomplete);
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
const body = { email: trimmedEmail, code, password };
|
||||
if (invite) body.invite_code = invite;
|
||||
const r = await callApi('/v1/auth/register', body);
|
||||
setSubmitting(false);
|
||||
if (!r.ok) {
|
||||
setRegErr(r.network ? t.netErr : (r.error && r.error[msgKey]) || t.netErr);
|
||||
return;
|
||||
}
|
||||
setSuccess(true);
|
||||
}
|
||||
|
||||
const plat = useMemo(() => detectPlatform(), []);
|
||||
const dl = useMemo(() => {
|
||||
if (plat === 'android') return { href: SITE.downloads.android, label: t.dlAndroid };
|
||||
if (plat === 'windows') return { href: SITE.downloads.windows, label: t.dlWin };
|
||||
if (plat === 'macos') return { href: SITE.downloads.macos, label: t.dlMac };
|
||||
return { href: dlAnchorHref, label: t.dlGeneric };
|
||||
}, [plat, t, dlAnchorHref]);
|
||||
|
||||
const pwInvalid = pwTouched && password.length > 0 && password.length < 8;
|
||||
const canSubmit = /\S+@\S+\.\S+/.test(email.trim()) && code.length === 6 && password.length >= 8 && !submitting;
|
||||
|
||||
const LangSeg = (
|
||||
<div class="langseg invite-langseg" role="group" aria-label="Language">
|
||||
<button type="button" class={lang === 'zh' ? 'on' : undefined} onClick={() => setLang('zh')}>中文</button>
|
||||
<button type="button" class={lang === 'en' ? 'on' : undefined} onClick={() => setLang('en')}>EN</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 登录态检测前先不下结论(避免闪一下已登录态又变表单);未挂载完成时按未登录渲染,
|
||||
// 与「无 JS 时默认展示注册表单」的渐进增强保持一致。
|
||||
if (checkedAuth && loggedIn) {
|
||||
return (
|
||||
<section class="invite-page register-page">
|
||||
<div class="wrap">
|
||||
<div class="invite-card register-card">
|
||||
{LangSeg}
|
||||
<div class="register-loggedin">
|
||||
<CheckCircle2 class="register-success-icon" />
|
||||
<h1 class="h-sec center register-hero">{t.loggedInTitle}</h1>
|
||||
<p class="sub-sec center">{t.loggedInSub}</p>
|
||||
<div class="register-loggedin-actions">
|
||||
<a class="btn btn-primary btn-lg" href={homeHref}>{t.loggedInHome}</a>
|
||||
<a class="btn btn-secondary btn-lg" href={SITE.usercenter}>{t.loggedInCenter}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<section class="invite-page register-page">
|
||||
<div class="wrap">
|
||||
<div class="invite-card register-card">
|
||||
{LangSeg}
|
||||
<div class="register-success">
|
||||
<CheckCircle2 class="register-success-icon" />
|
||||
<h1 class="h-sec center register-hero">{t.successTitle}</h1>
|
||||
<p class="sub-sec center">{invite ? t.successSubInvite : t.successSubPlain}</p>
|
||||
<a class="btn btn-primary btn-lg invite-dl-btn" href={dl.href}>
|
||||
<Download />
|
||||
<span>{dl.label}</span>
|
||||
<ArrowRight />
|
||||
</a>
|
||||
<a class="register-small" href={homeHref}>{t.backHome}</a>
|
||||
<div class="register-countdown">{t.redirecting(redirectIn)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section class="invite-page register-page">
|
||||
<div class="wrap">
|
||||
<div class="invite-card register-card">
|
||||
{LangSeg}
|
||||
|
||||
<div class="eyebrow center">{t.eyebrow}</div>
|
||||
<h1 class="h-sec center register-hero">{invite ? t.titleInvite : t.titlePlain}</h1>
|
||||
<p class="sub-sec center">{t.sub}</p>
|
||||
|
||||
<form class="register-form" onSubmit={codeSent ? onRegister : onSendCode}>
|
||||
<div>
|
||||
<div class="register-field-label">{t.emailLabel}</div>
|
||||
<div class="register-row">
|
||||
<div class="su-field">
|
||||
<Mail />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
placeholder={t.emailPh}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
{!codeSent && (
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary register-resend-btn"
|
||||
disabled={sendingCode || cooldown > 0}
|
||||
onClick={onSendCode}
|
||||
>
|
||||
{sendingCode ? <Loader2 class="register-spin" /> : null}
|
||||
<span>{sendingCode ? t.sending : cooldown > 0 ? t.resendIn(cooldown) : t.sendCode}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{codeErr && (
|
||||
<div class="register-err">
|
||||
<AlertCircle />
|
||||
<span>{codeErr}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{codeSent && (
|
||||
<>
|
||||
<div>
|
||||
<div class="register-field-label">{t.codeLabel}</div>
|
||||
<div class="register-row">
|
||||
<div class="su-field">
|
||||
<KeyRound />
|
||||
<input
|
||||
ref={codeInputRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.currentTarget.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder={t.codePh}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary register-resend-btn"
|
||||
disabled={sendingCode || cooldown > 0}
|
||||
onClick={onSendCode}
|
||||
>
|
||||
{sendingCode ? <Loader2 class="register-spin" /> : null}
|
||||
<span>{sendingCode ? t.sending : cooldown > 0 ? t.resendIn(cooldown) : t.resend}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="register-field-label">{t.pwLabel}</div>
|
||||
<div class="su-field">
|
||||
<Lock />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
onBlur={() => setPwTouched(true)}
|
||||
placeholder={t.pwPh}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
{pwInvalid && <div class="register-hint register-hint--warn">{t.pwHint}</div>}
|
||||
</div>
|
||||
|
||||
{regErr && (
|
||||
<div class="register-err">
|
||||
<AlertCircle />
|
||||
<span>{regErr}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg invite-dl-btn" disabled={!canSubmit}>
|
||||
{submitting ? <Loader2 class="register-spin" /> : null}
|
||||
<span>{submitting ? t.submitting : invite ? t.submitInvite : t.submitPlain}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +1,54 @@
|
||||
/**
|
||||
* SignupForm.jsx — Hero 内联邮箱注册(演示,React island)。迁自原型 .signup + site.js __signup。
|
||||
* 注意(铁律 10):这不是支付表单;仅演示「发送验证码」交互,真实实现由后端 auth 接管。
|
||||
* SignupForm.jsx — Hero 内联邮箱入口(React island)。迁自原型 .signup + site.js __signup。
|
||||
*
|
||||
* 不在这里发验证码 / 建账户:提交只是把邮箱透传给独立注册页 /register(?email= 预填),
|
||||
* 真正的「验证码 + 设密码」注册流程由 RegisterCard.jsx 承接(见该组件顶部注释)。
|
||||
* 空邮箱也允许跳转——/register 页留空即可,用户到那边再填。
|
||||
*
|
||||
* 已登录(同源 localStorage 里有 pg_uc_refresh,见 lib/authState.js)时,表单换成
|
||||
* 「打开用户中心」按钮:老用户不需要再看到注册入口。无 JS 时默认展示表单(渐进增强)。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Mail, CheckCircle, Download } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Mail, Download } from 'lucide-react';
|
||||
import { SITE } from '../config/site';
|
||||
import { isLoggedIn } from '../lib/authState';
|
||||
|
||||
export default function SignupForm({ ph, btn, ok, hint, dl }) {
|
||||
const [done, setDone] = useState(false);
|
||||
export default function SignupForm({ ph, btn, hint, dl, centerLabel }) {
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoggedIn(isLoggedIn());
|
||||
}, []);
|
||||
|
||||
const onSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const email = e.currentTarget.elements['su-email'];
|
||||
if (!/\S+@\S+\.\S+/.test(email.value)) {
|
||||
email.focus();
|
||||
return;
|
||||
}
|
||||
setDone(true);
|
||||
const emailEl = e.currentTarget.elements['su-email'];
|
||||
const email = (emailEl && emailEl.value ? emailEl.value : '').trim();
|
||||
const qs = email ? `?email=${encodeURIComponent(email)}` : '';
|
||||
window.location.href = `/register${qs}`;
|
||||
};
|
||||
|
||||
if (loggedIn) {
|
||||
return (
|
||||
<div class="signup" id="signup">
|
||||
<a class="btn btn-primary btn-lg su-btn" href={SITE.usercenter}>
|
||||
<span>{centerLabel}</span>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form class="signup" id="signup" onSubmit={onSubmit}>
|
||||
<div class="su-row">
|
||||
<div class="su-field">
|
||||
<Mail />
|
||||
<input type="email" id="su-email" name="su-email" placeholder={ph} required />
|
||||
<input type="email" id="su-email" name="su-email" placeholder={ph} />
|
||||
</div>
|
||||
<button class="btn btn-primary btn-lg su-btn" type="submit">
|
||||
<span>{btn}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="su-ok" id="su-ok" hidden={!done}>
|
||||
<CheckCircle />
|
||||
<span>{ok}</span>
|
||||
</div>
|
||||
<div class="su-hint">
|
||||
<span>{hint}</span>
|
||||
<a class="su-dl" href="#download">
|
||||
|
||||
@@ -11,6 +11,13 @@ export const SITE = {
|
||||
/** 联系邮箱。Cloudflare Email Routing 转发到个人 Gmail。 */
|
||||
email: 'pangolin@yanmeiai.com',
|
||||
|
||||
/**
|
||||
* 控制面 API 基址(Cloudflare Tunnel 出口,见 docs/control-plane-tls-tunnel.html)。
|
||||
* 官网/落地页/注册页浏览器端直连(跨域,CORS 已放行 pangolin.yanmeiai.com,见
|
||||
* server/internal/httpapi/cors.go);PUBLIC_API_BASE 环境变量可覆盖,供本地联调改指向。
|
||||
*/
|
||||
api: 'https://api.yanmeiai.com',
|
||||
|
||||
/** Telegram 公开频道(群为私有,公开页不放)。 */
|
||||
telegram: { handle: '@pangolin_app', url: 'https://t.me/pangolin_app' },
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* authState.js — 官网判断「用户中心登录态」的单一实现。
|
||||
*
|
||||
* usercenter(web/usercenter/lib/api/session.ts)与官网同源部署
|
||||
* (pangolin.yanmeiai.com 根路径 + /user/ 子路径共享同一个源),localStorage 按源
|
||||
* 共享,无需跨域请求即可判断登录态:有 pg_uc_refresh(refresh token)即视为已登录
|
||||
* (仅存在性判断,不校验有效期/签名——过期由用户中心自己的静默续期/登出收敛,官网
|
||||
* 只做「有没有」这层粗粒度判断,用来决定要不要展示注册入口)。
|
||||
*
|
||||
* SSR/构建期没有 window,一律回退「未登录」(渐进增强:默认显示注册相关 UI,
|
||||
* 客户端水合后各处 useEffect 再按真实登录态切换/隐藏,无 JS 时行为不变)。
|
||||
*/
|
||||
const REFRESH_KEY = 'pg_uc_refresh';
|
||||
const EMAIL_KEY = 'pg_uc_email';
|
||||
|
||||
export function isLoggedIn() {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
return !!window.localStorage.getItem(REFRESH_KEY);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredEmail() {
|
||||
if (typeof window === 'undefined') return '';
|
||||
try {
|
||||
return window.localStorage.getItem(EMAIL_KEY) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredSession() {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.removeItem(REFRESH_KEY);
|
||||
window.localStorage.removeItem(EMAIL_KEY);
|
||||
} catch {
|
||||
/* ignore quota / privacy mode */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* inviteCode.js — 邀请码格式校验单一实现。
|
||||
*
|
||||
* 字母表须与 server/internal/reward/service.go 的 inviteAlphabet 一致:
|
||||
* A-Z 去 O/I,2-9 去 0/1,固定 8 位。供 InviteCard.jsx(落地页从 URL 路径解析)与
|
||||
* RegisterCard.jsx(/register 页从 ?invite= 查询参数解析)共用,避免两处定义漂移。
|
||||
* 仅做「像不像邀请码」的格式校验,不联网核验(是否真实存在/已核销由服务端注册接口判定)。
|
||||
*/
|
||||
export const INVITE_CODE_RE = /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{8}$/;
|
||||
|
||||
/** 规范化并校验;不合法返回 null。 */
|
||||
export function normalizeInviteCode(raw) {
|
||||
if (!raw) return null;
|
||||
const code = String(raw).trim().toUpperCase();
|
||||
return INVITE_CODE_RE.test(code) ? code : null;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
/**
|
||||
* register.astro — 独立注册页 `/register`(产出 dist/register/index.html)。
|
||||
*
|
||||
* 两处入口跳转到这里:① 首页 hero 免费注册表单(?email= 预填,见 SignupForm.jsx);
|
||||
* ② 邀请落地页 /i/<code> 的「注册领取 3 天」CTA(?invite= 预填,见 InviteCard.jsx)。
|
||||
* 二者共用同一套「验证码 + 设密码」注册流程(RegisterCard.jsx)。
|
||||
*
|
||||
* 页面骨架复用全站 Header/Footer + tokens/website/site-extra 样式,与 i.astro 同一路数;
|
||||
* Header/Footer 走全站 i18n 惯例整页单显中文,正文卡片(RegisterCard.jsx)另有独立的
|
||||
* zh/en 运行时切换,理由见该组件顶部注释。
|
||||
*/
|
||||
import '@fontsource/sora/500.css';
|
||||
import '@fontsource/sora/600.css';
|
||||
import '@fontsource/sora/700.css';
|
||||
import '@fontsource/manrope/400.css';
|
||||
import '@fontsource/manrope/500.css';
|
||||
import '@fontsource/manrope/600.css';
|
||||
import '@fontsource/manrope/700.css';
|
||||
import '@fontsource/noto-sans-sc/400.css';
|
||||
import '@fontsource/noto-sans-sc/500.css';
|
||||
import '@fontsource/noto-sans-sc/700.css';
|
||||
import '@fontsource/jetbrains-mono/400.css';
|
||||
import '@fontsource/jetbrains-mono/500.css';
|
||||
|
||||
import '../styles/tokens.gen.css';
|
||||
import '../styles/website.css';
|
||||
import '../styles/site-extra.css';
|
||||
|
||||
import { createT } from '../i18n/strings';
|
||||
import Header from '../components/Header.jsx';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import RegisterCard from '../components/RegisterCard.jsx';
|
||||
|
||||
const t = createT('zh');
|
||||
|
||||
const headerT = {
|
||||
product: t('nav.product'),
|
||||
pricing: t('nav.pricing'),
|
||||
download: t('nav.download'),
|
||||
docs: t('nav.docs'),
|
||||
blog: t('nav.blog'),
|
||||
login: t('nav.login'),
|
||||
center: t('nav.center'),
|
||||
brand: t('nav.brand'),
|
||||
mcenter: t('menu.center'),
|
||||
mswitch: t('menu.switch'),
|
||||
mlogout: t('menu.logout'),
|
||||
get: t('nav.get'),
|
||||
suBtn: t('su.btn'),
|
||||
};
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>注册 · {t('nav.brand')}</title>
|
||||
<meta name="description" content="创建你的穿山甲账户:一个邮箱即可开始,免费注册,无需任何付款信息。" />
|
||||
<meta name="robots" content="noindex,follow" />
|
||||
<meta name="theme-color" content="#B96A3D" />
|
||||
</head>
|
||||
<body>
|
||||
<Header client:load lang="zh" t={headerT} />
|
||||
<main>
|
||||
<RegisterCard client:load />
|
||||
</main>
|
||||
<Footer t={t} />
|
||||
</body>
|
||||
</html>
|
||||
@@ -331,6 +331,123 @@
|
||||
justify-content: center;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.invite-register-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.invite-loggedin-note {
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--fg3);
|
||||
}
|
||||
|
||||
/* 全局:无障碍禁用态视觉反馈(此前没有产品化按钮用到过 disabled,注册页倒计时/
|
||||
提交中会用到,顺手补成通用规则,不影响任何既有按钮——目前没有生产页在 disabled
|
||||
态下渲染过 .btn)。 */
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.register-spin {
|
||||
animation: register-spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes register-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ---------- register page (/register) ----------
|
||||
复用上面 .invite-page/.invite-card 的「白卡」容器视觉;字段挪用首页 signup 表单
|
||||
现成的 .su-field 胶囊输入(website.css),错误提示挪用 /buy 联调页 .buy-err 的
|
||||
配色语汇(var(--danger),非新造),本节只补步骤展开/倒计时/成功态/已登录态专属的
|
||||
最小必要样式。 */
|
||||
.register-hero {
|
||||
margin-top: 8px;
|
||||
max-width: 420px;
|
||||
}
|
||||
.register-form {
|
||||
width: 100%;
|
||||
margin-top: 26px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.register-field-label {
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: var(--fg3);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.register-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.register-row .su-field {
|
||||
flex: 1;
|
||||
}
|
||||
.register-resend-btn {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.register-hint {
|
||||
font-size: 12.5px;
|
||||
color: var(--fg3);
|
||||
margin-top: 6px;
|
||||
}
|
||||
.register-hint--warn {
|
||||
color: var(--danger);
|
||||
}
|
||||
.register-err {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--danger);
|
||||
background: var(--danger-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 14px;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.register-err svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.register-small {
|
||||
margin-top: 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
.register-success,
|
||||
.register-loggedin {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.register-success-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--success);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.register-countdown {
|
||||
margin-top: 10px;
|
||||
font-size: 12.5px;
|
||||
color: var(--fg3);
|
||||
}
|
||||
.register-loggedin-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 22px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.invite-card {
|
||||
@@ -339,4 +456,11 @@
|
||||
.invite-code-value {
|
||||
font-size: 24px;
|
||||
}
|
||||
.register-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.register-resend-btn {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user