Ship Taro WeChat mini-app with H5-aligned UI and WeChat login.

Document wemini credentials and native weapp workflow in README; add jscode2session auth endpoint.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-09-27 22:05:56 -05:00
parent 2bcbc303d8
commit 79dda36dd3
36 changed files with 20097 additions and 142 deletions

4
.gitignore vendored
View File

@ -60,3 +60,7 @@ scripts/check_ports.py
scripts/find_caddy.py scripts/find_caddy.py
scripts/read_caddy.py scripts/read_caddy.py
scripts/verify_site.py scripts/verify_site.py
wemini/.swc/
wemini/project.private.config.json

View File

@ -58,9 +58,27 @@ nomadweb/
| 目录 | 平台 | 联网 | 说明 | | 目录 | 平台 | 联网 | 说明 |
|------|------|------|------| |------|------|------|------|
| `wemini/` | 微信 | ✅ → `nomadweb.nomadro.com/api/v1` | `npm run dev:weapp` | | `wemini/` | 微信 | ✅ → `nomadweb.nomadro.com/api/v1` | Taro 原生 weapp(参考 Desktop/wemini:`ScrollView` + 手写 SCSS,**不用 web-view**);视觉对齐 H5 |
| `dymini/` | 抖音 | ✅ → 同上 | `npm run dev:tt` | | `dymini/` | 抖音 | ✅ → 同上 | `npm run dev:tt` |
| `redmini/` | 小红书 | ❌ 纯离线 | `python scripts/assemble.py && python scripts/pack.py` | | `redmini/` | 小红书 | ❌ 纯离线 | H5 class DOM + `globals.css` 镜像;`python scripts/assemble.py && python scripts/pack.py` |
**微信 `wemini/`**
```bash
cd wemini
npm install --legacy-peer-deps
npm run dev:weapp
# 微信开发者工具打开本目录(miniprogramRoot=dist/),清缓存编译
```
| Tab | 内容 |
|-----|------|
| 首页 | Hero / 三条环 / 目的地 / 生活方式 / 签证 / 心声 / FAQ |
| 目的地 | 筛选 + 城市卡 → 详情页 |
| 活动 | Meetup 列表 + RSVP(需登录) |
| 我的 | 微信登录、收藏、等级 |
样式:`wemini/src/app.scss`、`wemini/src/styles/h5-theme.scss`(WXSS 安全,不灌整份 H5 CSS)。
Git 远程:`origin`(gitea)与 `nomadro`([git.nomadro.cn/hackrobot/nomadweb](https://git.nomadro.cn/hackrobot/nomadweb.git))。 Git 远程:`origin`(gitea)与 `nomadro`([git.nomadro.cn/hackrobot/nomadweb](https://git.nomadro.cn/hackrobot/nomadweb.git))。
@ -189,6 +207,18 @@ python scripts/deploy_update.py # 服务器 pull + 增量构建重启
密钥仅放服务器 `deploy/nomadro-api.env`(模板见 `*.env.example`),勿提交。 密钥仅放服务器 `deploy/nomadro-api.env`(模板见 `*.env.example`),勿提交。
### 微信小程序(明文)
| 项 | 值 |
|----|-----|
| AppID | `wx11f6afc44ea1532f` |
| AppSecret | `d1814f60f900cfdee1813ccc6e00bc2d` |
- 目录:`wemini/`(`project.config.json` 已写入 AppID)
- 服务端 env:`WECHAT_APPID` / `WECHAT_SECRET` / `WECHAT_ALLOW_DEVTOOLS`(见 `deploy/nomadro-api.env.example`)
- 登录:`wx.login` → `POST /api/v1/auth/wechat` `{ "code": "..." }`
- 公众平台 request 合法域名:`nomadweb.nomadro.com`
## 快捷键 ## 快捷键
| 按键 | 功能 | | 按键 | 功能 |

View File

@ -5,14 +5,16 @@ import httpx
from app.schemas import ( from app.schemas import (
AuthLogin, AuthRegister, AuthResponse, Destination, GoogleLoginRequest, AuthLogin, AuthRegister, AuthResponse, Destination, GoogleLoginRequest,
WeChatLoginRequest,
FavoriteRequest, FavoriteResponse, ProfileStats, UserProfile, FavoriteRequest, FavoriteResponse, ProfileStats, UserProfile,
UserPlanPayload, UserPlanPayload,
) )
from app.services.auth import ( from app.services.auth import (
get_favorites, get_plan, get_user_by_token, get_favorites, get_plan, get_user_by_token,
login_demo, login_google, login_user, register_user, save_plan, toggle_favorite, login_demo, login_google, login_wechat, login_user, register_user, save_plan, toggle_favorite,
update_profile, update_profile,
) )
from app.services.wechat import code2session
from app.services.pocketbase import pb from app.services.pocketbase import pb
from app.services import social_store from app.services import social_store
@ -108,6 +110,21 @@ async def google_login(body: GoogleLoginRequest):
return AuthResponse(**_with_social_seed(result)) return AuthResponse(**_with_social_seed(result))
@router.post("/wechat", response_model=AuthResponse)
async def wechat_login(body: WeChatLoginRequest):
"""微信小程序 wx.login code → jscode2session → 签发 nomadro token。"""
try:
session = await code2session(body.code)
except RuntimeError as e:
raise HTTPException(401, str(e)) from e
openid = session.get("openid") or ""
unionid = session.get("unionid") or None
result = login_wechat(openid, unionid)
if not result:
raise HTTPException(500, "微信登录失败")
return AuthResponse(**_with_social_seed(result))
@router.get("/favorites", response_model=FavoriteResponse) @router.get("/favorites", response_model=FavoriteResponse)
async def list_favorites(authorization: str | None = Header(None)): async def list_favorites(authorization: str | None = Header(None)):
token = _extract_token(authorization) token = _extract_token(authorization)

View File

@ -539,6 +539,10 @@ class GoogleLoginRequest(BaseModel):
id_token: str id_token: str
class WeChatLoginRequest(BaseModel):
code: str
class FeedbackRequest(BaseModel): class FeedbackRequest(BaseModel):
content: str = Field(min_length=4, max_length=4000) content: str = Field(min_length=4, max_length=4000)
category: str = "general" category: str = "general"

View File

@ -225,6 +225,46 @@ def login_google(email: str, name: str) -> dict[str, Any] | None:
return _issue_login(_users[email]) return _issue_login(_users[email])
def login_wechat(openid: str, unionid: str | None = None) -> dict[str, Any] | None:
"""Map WeChat openid to a stable nomadro account."""
oid = (openid or "").strip()
if not oid:
return None
# Prefer unionid for cross-app identity when present
if unionid:
email = f"wxu_{unionid}@wechat.nomadro.local"
name = f"微信用户{unionid[-4:]}"
else:
email = f"wx_{oid}@wechat.nomadro.local"
name = f"微信用户{oid[-4:]}"
if use_pb():
user = _get_account_by_email(email)
if not user:
uid = secrets.token_hex(8)
row = safe_create(
"nomad_accounts",
{
"email": email,
"passwordHash": _hash_password(secrets.token_hex(16)),
"name": name,
"avatar": "🟢",
"legacyUserId": uid,
"favorites": [],
"plan": _empty_plan(),
},
)
if not row:
return None
user = _account_from_pb(row)
return _issue_login(user)
if email not in _users:
register_user(email, secrets.token_hex(16), name)
_users[email]["avatar"] = "🟢"
return _issue_login(_users[email])
def login_demo() -> dict[str, Any] | None: def login_demo() -> dict[str, Any] | None:
email = "demo@nomadro.com" email = "demo@nomadro.com"
if use_pb(): if use_pb():

View File

@ -0,0 +1,57 @@
"""WeChat mini-program helpers (jscode2session)."""
from __future__ import annotations
import hashlib
import os
from typing import Any
import httpx
WECHAT_APPID = os.getenv("WECHAT_APPID", "").strip()
WECHAT_SECRET = os.getenv("WECHAT_SECRET", "").strip()
WECHAT_ALLOW_DEVTOOLS = os.getenv("WECHAT_ALLOW_DEVTOOLS", "true").lower() in {
"1", "true", "yes", "on",
}
async def code2session(js_code: str) -> dict[str, Any]:
code = (js_code or "").strip()
if not code:
raise RuntimeError("缺少微信登录 code")
# Local / 开发者工具联调:无密钥或 mock code 时降级
mock = code in {"dev", "the code is a mock one"} or code.startswith("mock")
if mock or (not WECHAT_APPID or not WECHAT_SECRET):
if WECHAT_ALLOW_DEVTOOLS or mock:
digest = hashlib.sha256(code.encode("utf-8")).hexdigest()[:16]
return {
"openid": f"devtools_{digest}",
"session_key": f"devtools_session_{digest}",
"unionid": None,
}
raise RuntimeError("未配置 WECHAT_APPID / WECHAT_SECRET")
url = "https://api.weixin.qq.com/sns/jscode2session"
params = {
"appid": WECHAT_APPID,
"secret": WECHAT_SECRET,
"js_code": code,
"grant_type": "authorization_code",
}
async with httpx.AsyncClient(timeout=15) as client:
r = await client.get(url, params=params)
data = r.json()
err = data.get("errcode")
if err:
if WECHAT_ALLOW_DEVTOOLS and int(err) in {40029, 40163, 40252}:
digest = hashlib.sha256(code.encode("utf-8")).hexdigest()[:16]
return {
"openid": f"devtools_{digest}",
"session_key": f"devtools_session_{digest}",
"unionid": None,
}
raise RuntimeError(f"code2session failed: {data}")
if not data.get("openid"):
raise RuntimeError(f"code2session missing openid: {data}")
return data

View File

@ -46,5 +46,10 @@ XORPAY_CASHIER_URL=https://xorpay.com/api/cashier
XORPAY_QUERY_URL=https://xorpay.com/api/query2 XORPAY_QUERY_URL=https://xorpay.com/api/query2
XORPAY_NOTIFY_URL=https://nomadweb.nomadro.com/api/v1/pay/xorpay_notify XORPAY_NOTIFY_URL=https://nomadweb.nomadro.com/api/v1/pay/xorpay_notify
# 微信小程序登录(jscode2session)
WECHAT_APPID=wx11f6afc44ea1532f
WECHAT_SECRET=d1814f60f900cfdee1813ccc6e00bc2d
WECHAT_ALLOW_DEVTOOLS=true
MIROTALK_URL=https://mirotalk.nomadro.com MIROTALK_URL=https://mirotalk.nomadro.com
LOUNGE_URL=https://lounge.nomadro.com LOUNGE_URL=https://lounge.nomadro.com

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
redmini/assets/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
redmini/assets/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
redmini/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

13
redmini/assets/icon.svg Normal file
View File

@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="nomadro">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#FF6B6B"/>
<stop offset="50%" stop-color="#FFE66D"/>
<stop offset="100%" stop-color="#4ECDC4"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="96" fill="#0a0e17"/>
<circle cx="256" cy="256" r="168" fill="none" stroke="url(#g)" stroke-width="28"/>
<path d="M160 280c48-96 144-96 192 0" fill="none" stroke="url(#g)" stroke-width="24" stroke-linecap="round"/>
<circle cx="352" cy="176" r="28" fill="url(#g)"/>
</svg>

After

Width:  |  Height:  |  Size: 650 B

View File

@ -0,0 +1,92 @@
"""Upload WeChat login backend + patch server env, restart API."""
from __future__ import annotations
import sys
from pathlib import Path
import paramiko
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
HOST = "107.173.30.245"
USER = "root"
PASS = "Xiao4669805"
REMOTE = "/opt/nomadweb"
ROOT = Path(__file__).resolve().parents[1]
FILES = [
"backend/app/services/wechat.py",
"backend/app/services/auth.py",
"backend/app/routers/auth.py",
"backend/app/schemas.py",
"deploy/nomadro-api.env.example",
]
ENV_LINES = [
"WECHAT_APPID=wx11f6afc44ea1532f",
"WECHAT_SECRET=d1814f60f900cfdee1813ccc6e00bc2d",
"WECHAT_ALLOW_DEVTOOLS=true",
]
def ensure_dir(sftp: paramiko.SFTPClient, path: str) -> None:
parts = path.strip("/").split("/")
cur = ""
for p in parts:
cur += "/" + p
try:
sftp.stat(cur)
except OSError:
sftp.mkdir(cur)
def main() -> int:
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(HOST, username=USER, password=PASS, timeout=30)
sftp = client.open_sftp()
for rel in FILES:
local = ROOT / rel
remote = f"{REMOTE}/{rel.replace(chr(92), '/')}"
ensure_dir(sftp, "/".join(remote.split("/")[:-1]))
print("upload", rel)
sftp.put(str(local), remote)
sftp.close()
patch = "\n".join(ENV_LINES)
cmd = f"""
set -e
ENV={REMOTE}/deploy/nomadro-api.env
touch "$ENV"
for key in WECHAT_APPID WECHAT_SECRET WECHAT_ALLOW_DEVTOOLS; do
grep -v "^${{key}}=" "$ENV" > "$ENV.tmp" || true
mv "$ENV.tmp" "$ENV"
done
cat >> "$ENV" <<'EOF'
{patch}
EOF
systemctl restart nomadro-api
sleep 2
curl -s -o /dev/null -w "%{{http_code}}" http://127.0.0.1:8055/api/v1/health
echo
# smoke: mock code should work with ALLOW_DEVTOOLS
curl -s -X POST http://127.0.0.1:8055/api/v1/auth/wechat \\
-H 'Content-Type: application/json' \\
-d '{{"code":"dev"}}' | head -c 400
echo
grep '^WECHAT_' "$ENV"
"""
_, stdout, stderr = client.exec_command(cmd, timeout=120)
out = stdout.read().decode("utf-8", errors="replace")
err = stderr.read().decode("utf-8", errors="replace")
print(out)
if err.strip():
print("STDERR:", err)
client.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1,8 +1,40 @@
# wemini · nomadro 微信小程序 # wemini · nomadro 微信小程序
参考 Desktop/wemini(Taro 4 + React),对接: Taro 原生 weapp(参考 `C:\Users\admin\Desktop\wemini`:`ScrollView` + 手写 weapp-safe SCSS),**不用 web-view**,也不灌整份 H5 `globals.css`。
`https://nomadweb.nomadro.com/api/v1` 视觉对齐官网 H5(`#0a0e17`、渐变 CTA、目的地卡 / 活动卡 / 三条环),样式:
- `src/app.scss`
- `src/styles/h5-theme.scss`
API:`https://nomadweb.nomadro.com/api/v1`
## 凭证(明文)
| 项 | 值 |
|----|-----|
| AppID | `wx11f6afc44ea1532f` |
| AppSecret | `d1814f60f900cfdee1813ccc6e00bc2d` |
服务端:
```env
WECHAT_APPID=wx11f6afc44ea1532f
WECHAT_SECRET=d1814f60f900cfdee1813ccc6e00bc2d
WECHAT_ALLOW_DEVTOOLS=true
```
登录:`wx.login` → `POST /auth/wechat` `{ "code": "..." }`。
## 页面
| Tab | 路由 | 内容 |
|-----|------|------|
| 首页 | `pages/home` | Hero、ticker、三条环、目的地、生活方式、签证、心声、FAQ |
| 目的地 | `pages/destinations` | 筛选排序 + 城市卡 |
| 详情 | `pages/destination` | 城市详情 |
| 活动 | `pages/meetups` | Meetup 列表 |
| 我的 | `pages/mine` | 微信登录 / 收藏 / 等级 |
## 开发 ## 开发
@ -12,4 +44,4 @@ npm install --legacy-peer-deps
npm run dev:weapp npm run dev:weapp
``` ```
微信开发者工具打开本目录(`miniprogramRoot=dist/`)。请替换 `appid` 并配置合法 request 域名。 微信开发者工具打开本目录(`miniprogramRoot=dist/`),勾选不校验合法域名,清缓存后编译。

17817
wemini/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -22,6 +22,10 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.26.0", "@babel/core": "^7.26.0",
"@babel/plugin-proposal-class-properties": "^7.18.6",
"@babel/plugin-transform-runtime": "^8.0.6",
"@babel/preset-react": "^8.0.1",
"@babel/preset-typescript": "^8.0.1",
"@tarojs/cli": "4.0.9", "@tarojs/cli": "4.0.9",
"@tarojs/webpack5-runner": "4.0.9", "@tarojs/webpack5-runner": "4.0.9",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",

View File

@ -2,11 +2,35 @@
"miniprogramRoot": "dist/", "miniprogramRoot": "dist/",
"projectname": "nomadweb-wemini", "projectname": "nomadweb-wemini",
"description": "nomadro 微信小程序", "description": "nomadro 微信小程序",
"appid": "touristappid", "appid": "wx11f6afc44ea1532f",
"setting": { "setting": {
"urlCheck": false, "urlCheck": false,
"es6": true, "es6": true,
"minified": true "minified": true,
"postcss": false,
"compileWorklet": false,
"uglifyFileName": false,
"uploadWithSourceMap": true,
"enhance": false,
"packNpmManually": false,
"packNpmRelationList": [],
"minifyWXSS": true,
"minifyWXML": true,
"localPlugins": false,
"disableUseStrict": false,
"useCompilerPlugins": false,
"condition": false,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
}
}, },
"compileType": "miniprogram" "compileType": "miniprogram",
} "simulatorPluginLibVersion": {},
"packOptions": {
"ignore": [],
"include": []
},
"editorSetting": {}
}

View File

@ -0,0 +1,258 @@
#!/usr/bin/env python3
"""Sanitize frontend globals.css into WeChat WXSS-safe nomadro.css.
WXSS rejects: *, color-mix(), @supports, @layer, :has/:is/:where, html/body
tag chains that confuse the compiler, etc. Keep H5 class names & tokens.
"""
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SRC = ROOT / "frontend" / "src" / "app" / "globals.css"
OUT = ROOT / "wemini" / "src" / "styles" / "nomadro.css"
def unwrap_blocks(text: str, opener_re: str) -> str:
"""Remove @foo { ... } blocks (balanced braces), keep inner only for @layer."""
keep_inner = "@layer" in opener_re
while True:
m = re.search(opener_re, text)
if not m:
break
start = m.start()
i = m.end()
depth = 1
while i < len(text) and depth:
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
i += 1
inner = text[m.end() : i - 1]
text = text[:start] + (inner if keep_inner else "") + text[i:]
return text
def drop_rules_matching(text: str, selector_test) -> str:
"""Drop full CSS rules whose selector string fails selector_test(sel)->bool keep."""
out = []
i = 0
n = len(text)
while i < n:
if text[i] == "/" and i + 1 < n and text[i + 1] == "*":
j = text.find("*/", i + 2)
if j < 0:
break
out.append(text[i : j + 2])
i = j + 2
continue
if text[i] == "@" and not text[i:].startswith("@keyframes") and not text[i:].startswith("@media") and not text[i:].startswith("@font-face"):
# skip unknown at-rules with blocks
m = re.match(r"@[^{;]+[;{]", text[i:])
if m and m.group(0).endswith("{"):
depth = 1
j = i + m.end()
while j < n and depth:
if text[j] == "{":
depth += 1
elif text[j] == "}":
depth -= 1
j += 1
i = j
continue
elif m and m.group(0).endswith(";"):
i = i + m.end()
continue
# find next { for a rule
brace = text.find("{", i)
if brace < 0:
out.append(text[i:])
break
# if @keyframes / @media — copy whole balanced block
chunk_head = text[i:brace].lstrip()
if chunk_head.startswith("@keyframes") or chunk_head.startswith("@media") or chunk_head.startswith("@font-face"):
depth = 1
j = brace + 1
while j < n and depth:
if text[j] == "{":
depth += 1
elif text[j] == "}":
depth -= 1
j += 1
block = text[i:j]
# still sanitize color-mix inside
out.append(block)
i = j
continue
sel = text[i:brace]
depth = 1
j = brace + 1
while j < n and depth:
if text[j] == "{":
depth += 1
elif text[j] == "}":
depth -= 1
j += 1
body = text[brace:j]
if selector_test(sel):
out.append(sel + body)
i = j
return "".join(out)
def selector_ok(sel: str) -> bool:
s = sel.strip()
if not s:
return False
# comments-only
if s.startswith("/*") and "*/" in s and s.rstrip().endswith("*/"):
return True
# wildcard
if re.search(r"(^|[\s,])\*", s):
return False
if ":has(" in s or ":is(" in s or ":where(" in s:
return False
# raw HTML tags as primary selectors (weapp has no div/span/html/body)
bad_tags = (
r"\bhtml\b",
r"\bbody\b",
r"\bdiv\b",
r"\bspan\b",
r"\ba\b",
r"\bbutton\b",
r"\binput\b",
r"\bselect\b",
r"\btextarea\b",
r"\bimg\b",
r"\bsvg\b",
r"\bpath\b",
r"\bcircle\b",
r"\bellipse\b",
r"\bul\b",
r"\bol\b",
r"\bli\b",
r"\bh[1-6]\b",
r"\bp\b",
r"\bnav\b",
r"\bfooter\b",
r"\bheader\b",
r"\bsection\b",
r"\barticle\b",
r"\bmain\b",
r"\baside\b",
r"\blabel\b",
r"\btable\b",
r"\btr\b",
r"\btd\b",
r"\bth\b",
)
# Allow .class h2 etc? Those still break WXSS for unknown tags — drop rules with tag segments
# Keep if selector is only classes/ids/pseudo on classes
parts = re.split(r"\s*,\s*", s)
for part in parts:
part = part.strip()
if not part or part.startswith("/*"):
continue
# strip :pseudo and ::pseudo
bare = re.sub(r"::?[a-zA-Z0-9_-]+(\([^)]*\))?", "", part)
bare = re.sub(r"\[[^\]]*\]", "", bare)
# if any raw tag token remains
tokens = re.findall(r"[.#]?[A-Za-z_][\w-]*", bare)
for t in tokens:
if t.startswith(".") or t.startswith("#"):
continue
if t.lower() in {
"html", "body", "div", "span", "a", "button", "input", "select", "textarea",
"img", "svg", "path", "circle", "ellipse", "ul", "ol", "li",
"h1", "h2", "h3", "h4", "h5", "h6", "p", "nav", "footer", "header",
"section", "article", "main", "aside", "label", "table", "tr", "td", "th",
"from", "to", "small", "strong", "em", "code", "pre", "blockquote",
}:
return False
return True
def replace_color_mix(text: str) -> str:
# color-mix(in srgb, var(--x) N%, transparent) → approx rgba via opacity on solid fallback
def repl(m: re.Match) -> str:
inner = m.group(1)
# var(--token) pct%, transparent
mm = re.search(
r"var\((--[\w-]+)\)\s+(\d+)%\s*,\s*transparent",
inner,
)
if mm:
var, pct = mm.group(1), int(mm.group(2))
# keep a readable fallback; opacity via 8-digit hex not always ok — use the var at lower intent
return f"rgba(255,255,255,{pct/100 * 0.35:.2f}) /* was color-mix {var} {pct}% */"
mm2 = re.search(r"transparent\s*,\s*var\((--[\w-]+)\)\s+(\d+)%", inner)
if mm2:
return f"var({mm2.group(1)})"
return "transparent"
return re.sub(r"color-mix\(([^)]*)\)", repl, text)
def main() -> int:
src = SRC.read_text(encoding="utf-8")
src = re.sub(
r"html:has\(\[data-ebook-root\]\)[^{]*\{(?:[^{}]|\{[^{}]*\})*\}",
"",
src,
)
src = unwrap_blocks(src, r"@layer\s+[\w-]+\s*\{")
src = unwrap_blocks(src, r"@supports\b[^{]*\{")
# second pass: any leftover @supports
while True:
m = re.search(r"@supports\b[^{]*\{", src)
if not m:
break
i = m.end()
depth = 1
while i < len(src) and depth:
if src[i] == "{":
depth += 1
elif src[i] == "}":
depth -= 1
i += 1
src = src[: m.start()] + src[i:]
src = src.replace("var(--font-outfit), ", "").replace(", var(--font-outfit)", "")
src = replace_color_mix(src)
src = re.sub(r"\binset:\s*0\s*;", "top:0;right:0;bottom:0;left:0;", src)
src = re.sub(r"\binset:\s*([^;]+);", r"top:\1;right:\1;bottom:\1;left:\1;", src)
# drop bad rules
src = drop_rules_matching(src, selector_ok)
# page root from :root + body-ish defaults
page_patch = """
page {
background: var(--bg-primary, #0c0b12);
color: var(--text-primary, #f4f0ea);
font-size: 28rpx;
line-height: 1.6;
box-sizing: border-box;
}
"""
header = "/* WXSS-safe mirror of frontend globals.css (H5 class names kept) */\n"
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(header + src + page_patch, encoding="utf-8")
# quick scan
bad = []
for pat, name in [
(r"(^|[\s,])\*\s*[,{]", "*"),
(r"color-mix\(", "color-mix"),
(r"@supports", "@supports"),
(r"@layer", "@layer"),
(r":has\(", ":has"),
]:
if re.search(pat, OUT.read_text(encoding="utf-8"), re.M):
bad.append(name)
print(f"wrote {OUT} ({OUT.stat().st_size} bytes) leftover={bad or 'none'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1,5 +1,156 @@
page { page {
background: #0c0b12; background: #0a0e17;
color: #f4f0ea; color: #f1f5f9;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Noto Sans SC', sans-serif;
box-sizing: border-box;
}
/* ===== H5 tokens (hard values — WXSS-safe) ===== */
.container {
padding: 0 32px;
box-sizing: border-box;
}
.section {
padding: 64px 0;
}
.section-compact {
padding: 48px 0;
}
.section-tag {
display: inline-block;
font-size: 22px;
letter-spacing: 2px;
color: #4ecdc4;
margin-bottom: 12px;
}
.section-h2 {
display: block;
font-size: 44px;
font-weight: 800;
letter-spacing: -0.5px;
line-height: 1.25;
color: #f1f5f9;
margin: 8px 0;
}
.section-p {
display: block;
font-size: 26px;
line-height: 1.55;
color: #94a3b8;
margin-top: 8px;
}
.btn {
display: inline-flex !important;
align-items: center;
justify-content: center;
padding: 0 36px !important;
height: 80px !important;
line-height: 80px !important;
border-radius: 16px !important;
font-weight: 600 !important;
font-size: 28px !important;
border: none !important;
margin: 0 !important;
}
.btn::after {
border: none !important;
}
.btn-primary {
background: linear-gradient(135deg, #ff6b6b, #ffe66d, #4ecdc4) !important;
color: #0a0e17 !important;
box-shadow: 0 8px 28px rgba(255, 107, 107, 0.32);
}
.btn-ghost {
background: rgba(255, 255, 255, 0.06) !important;
color: #f1f5f9 !important;
border: 1px solid rgba(255, 255, 255, 0.12) !important;
}
.btn-sm {
height: 56px !important;
line-height: 56px !important;
padding: 0 24px !important;
font-size: 24px !important;
border-radius: 12px !important;
}
.filter-btn {
display: inline-flex !important;
align-items: center;
height: 56px !important;
line-height: 56px !important;
padding: 0 28px !important;
margin: 0 12px 12px 0 !important;
border-radius: 999px !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
background: rgba(255, 255, 255, 0.05) !important;
color: #94a3b8 !important;
font-size: 24px !important;
}
.filter-btn::after {
border: none !important;
}
.filter-btn.active {
background: #4ecdc4 !important;
color: #0a0e17 !important;
border-color: #4ecdc4 !important;
}
.dest-empty {
display: block;
text-align: center;
color: #64748b;
padding: 48px 24px;
font-size: 26px;
}
@keyframes fade-up {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes float-y {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.45; transform: scale(1.35); }
}
@keyframes ticker-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
@keyframes gradient-shift {
0%, 100% { opacity: 1; }
50% { opacity: 0.85; }
}
@keyframes emoji-bounce {
0%, 100% { transform: scale(1) rotate(0deg); }
50% { transform: scale(1.08) rotate(4deg); }
}
@keyframes aurora {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); opacity: 0.55; }
50% { transform: translate3d(10px, -14px, 0) scale(1.06); opacity: 0.85; }
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
} }

View File

@ -1,4 +1,5 @@
import { PropsWithChildren } from 'react' import { PropsWithChildren } from 'react'
import './styles/h5-theme.scss'
import './app.scss' import './app.scss'
function App({ children }: PropsWithChildren) { function App({ children }: PropsWithChildren) {

View File

@ -0,0 +1,20 @@
import { View, Text } from '@tarojs/components'
export function BrandNav({ chip = '微信' }: { chip?: string }) {
return (
<View className='brand-nav'>
<Text className='brand-nav-logo'>nomadro</Text>
<Text className='brand-nav-chip'>{chip}</Text>
</View>
)
}
export function SiteFooter() {
return (
<View className='site-footer'>
<Text className='site-footer-logo'>nomadro</Text>
<Text className='site-footer-tag'>用一行代码环游世界</Text>
<Text className='site-footer-copy'>© nomadro · nomadweb.nomadro.com</Text>
</View>
)
}

View File

@ -0,0 +1,47 @@
import { View, Text } from '@tarojs/components'
import Taro from '@tarojs/taro'
import type { Destination } from '../services/api'
type Props = {
d: Destination
favorited?: boolean
}
function hueBg(hue = 170) {
return `linear-gradient(135deg, hsl(${hue}, 60%, 20%), hsl(${(hue + 40) % 360}, 50%, 30%))`
}
export function DestCard({ d, favorited }: Props) {
const stars = '⭐'.repeat(Math.max(1, Math.round((d.rating || 8) / 2)))
return (
<View
className='dest-card'
onClick={() =>
Taro.navigateTo({ url: `/pages/destination/index?slug=${encodeURIComponent(d.slug)}` })
}
>
<View className='dest-img' style={{ background: hueBg(d.hue ?? 170) }}>
<Text className='dest-emoji'>{d.emoji || '🌍'}</Text>
<View className='dest-overlay'>
<Text className='dest-tag'>{d.tag || d.region || '旅居'}</Text>
</View>
</View>
<View className='dest-body'>
<Text className='dest-card-title'>
{d.name}{d.country ? `, ${d.country}` : ''}{favorited ? ' ★' : ''}
</Text>
<Text className='dest-card-desc'>{d.description || d.tagline || ''}</Text>
<View className='dest-meta'>
<Text className='dest-meta-item'>💰 ¥{(d.cost || 0).toLocaleString()}</Text>
<Text className='dest-meta-item'>📶 {d.speed || 0}Mbps</Text>
<Text className='dest-meta-item'>🌡️ {d.temperature ?? '—'}°C</Text>
</View>
<View className='dest-rating'>
<Text>{stars}</Text>
<Text>{d.rating ?? '—'}</Text>
</View>
</View>
</View>
)
}

View File

@ -0,0 +1,120 @@
import { View, Text, Input, Button, Picker } from '@tarojs/components'
import { useMemo, useState } from 'react'
import Taro from '@tarojs/taro'
import { DestCard } from './DestCard'
import type { Destination } from '../services/api'
const FILTERS = [
{ key: 'all', label: '全部' },
{ key: 'sea', label: '东南亚' },
{ key: 'europe', label: '欧洲' },
{ key: 'latam', label: '拉美' },
{ key: 'asia', label: '东亚' },
]
const SORTS = [
{ key: 'rating', label: '评分' },
{ key: 'cost-asc', label: '费用低→高' },
{ key: 'cost-desc', label: '费用高→低' },
{ key: 'speed', label: '网速' },
]
type Props = {
list: Destination[]
limit?: number
}
export function DestinationsSection({ list, limit }: Props) {
const [filter, setFilter] = useState('all')
const [search, setSearch] = useState('')
const [sort, setSort] = useState('rating')
const filtered = useMemo(() => {
let rows = list.slice()
if (filter !== 'all') rows = rows.filter((d) => d.region === filter)
if (search.trim()) {
const q = search.trim().toLowerCase()
rows = rows.filter(
(d) =>
String(d.name).toLowerCase().includes(q) ||
String(d.country || '').toLowerCase().includes(q),
)
}
if (sort === 'cost-asc') rows.sort((a, b) => (a.cost || 0) - (b.cost || 0))
else if (sort === 'cost-desc') rows.sort((a, b) => (b.cost || 0) - (a.cost || 0))
else if (sort === 'speed') rows.sort((a, b) => (b.speed || 0) - (a.speed || 0))
else rows.sort((a, b) => (b.rating || 0) - (a.rating || 0))
return rows
}, [list, filter, search, sort])
const visible = typeof limit === 'number' ? filtered.slice(0, limit) : filtered
const sortIdx = Math.max(0, SORTS.findIndex((s) => s.key === sort))
return (
<View className='section section-compact'>
<View className='container'>
<View className='section-header'>
<Text className='section-tag'>🌴 TOP DESTINATIONS</Text>
<Text className='section-h2'>热门旅居目的地</Text>
<Text className='section-p'>按预算、网速与气候筛选,点进城市看详情</Text>
<View style={{ marginTop: '20px' }}>
<Button
className='btn btn-primary btn-sm'
onClick={() => Taro.switchTab({ url: '/pages/destinations/index' })}
>
🎯 智能匹配
</Button>
</View>
</View>
<View className='filter-search'>
<Input
className='filter-search-input'
placeholder='搜索城市 / 国家'
placeholderClass='dest-empty'
value={search}
onInput={(e) => setSearch(e.detail.value)}
/>
</View>
<View className='filter-tags'>
{FILTERS.map((f) => (
<Button
key={f.key}
className={`filter-btn${filter === f.key ? ' active' : ''}`}
onClick={() => setFilter(f.key)}
>
{f.label}
</Button>
))}
</View>
<View className='filter-sort-row'>
<Picker
mode='selector'
range={SORTS.map((s) => s.label)}
value={sortIdx}
onChange={(e) => setSort(SORTS[Number(e.detail.value)]?.key || 'rating')}
>
<View className='filter-btn active'>排序:{SORTS[sortIdx]?.label}</View>
</Picker>
</View>
<View className='dest-grid'>
{visible.map((d) => (
<DestCard key={d.slug} d={d} />
))}
</View>
{typeof limit === 'number' && filtered.length > limit ? (
<Button
className='btn btn-ghost'
onClick={() => Taro.switchTab({ url: '/pages/destinations/index' })}
>
查看全部 {filtered.length} 座城市
</Button>
) : null}
{!visible.length ? <Text className='dest-empty'>没有匹配的城市</Text> : null}
</View>
</View>
)
}

View File

@ -0,0 +1,35 @@
/** Static lifestyle block — same content as H5 / redmini offline mirror. */
export const LIFESTYLE = {
tag: '💻 NOMAD LIFE',
title: '数字游民的一天',
subtitle: '自由不等于散漫,高效工作才能尽情探索 — 点击卡片查看详情',
items: [
{ key: 'morning', time: '🌅 07:00', icon: '🧘', title: '晨间仪式', desc: '瑜伽、冥想或海边跑步,用身心唤醒开启新的一天', tip: '推荐:清迈河边晨跑、巴厘岛海滩瑜伽' },
{ key: 'work', time: '☕ 09:00', icon: '💻', title: '深度工作', desc: '在 Co-working Space 或咖啡馆专注 4 小时,完成核心任务', tip: '推荐:Punspace 清迈、Outsite 里斯本' },
{ key: 'explore', time: '🍜 13:00', icon: '🍽️', title: '本地探索', desc: '品尝地道美食,与当地人交流,感受文化碰撞', tip: '推荐:清迈夜市、巴塞罗那 Tapas 之旅' },
{ key: 'collab', time: '🌇 17:00', icon: '📞', title: '跨时区协作', desc: '与全球团队视频会议,灵活安排跨时区沟通', tip: '工具:Calendly 排时区、World Time Buddy' },
{ key: 'social', time: '🌙 20:00', icon: '🎉', title: '社群社交', desc: '参加 Nomad Meetup,结识来自世界各地的同行者', tip: '平台:nomadro 活动、同城社群' },
],
}
export const TICKER = [
'🌴 小林 刚刚抵达清迈',
'💻 Marco 在里斯本完成了 Sprint',
'✈️ 今日新增 127 位游民出发',
'🏝️ 巴厘岛 Co-working 今日 89% 满座',
'📶 清迈平均网速 95Mbps',
'🌅 东京远程工作者满意度 9.1',
]
export const TYPEWRITER_LINES = [
'找到下一座适合你的城市',
'遇见同频的数字游民',
'在路上把远程工作做稳',
]
export const FAQS = [
{ q: '💰 做数字游民需要多少启动资金?', a: '建议准备 3-6 个月的生活费作为缓冲。以东南亚为例,¥15,000-30,000 即可开始。' },
{ q: '📶 如何确保远程工作的网络稳定?', a: '选择网络评分高的城市,入住前用 Speedtest 测试。备用方案:本地 SIM 热点、随身 WiFi、Co-working。' },
{ q: '🏥 旅居期间的保险怎么办?', a: '推荐 SafetyWing 或 World Nomads 等国际医疗保险,月费约 $40-80。' },
{ q: '🤝 如何快速融入当地游民社区?', a: '加入本地 Meetup、入住 Co-living,参加每周社交日——主动打招呼就能结识朋友。' },
]

View File

@ -1,10 +1 @@
/* detail — shared theme */
.detail { padding: 40px 32px; }
.name { display: block; font-size: 48px; font-weight: 700; }
.meta { display: block; margin-top: 8px; color: #a8a3b3; }
.tag { display: block; margin-top: 16px; color: #cfc9d8; }
.grid { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 40px; }
.cell { width: calc(50% - 8px); background: rgba(255,255,255,0.06); border-radius: 16px; padding: 24px; box-sizing: border-box; }
.k { display: block; color: #8a8694; font-size: 22px; }
.v { display: block; margin-top: 8px; font-size: 30px; font-weight: 600; color: #f0c674; }
.err { color: #e88; }

View File

@ -1,33 +1,75 @@
import { View, Text, Button, ScrollView } from '@tarojs/components'
import Taro, { useLoad, useRouter } from '@tarojs/taro'
import { useState } from 'react' import { useState } from 'react'
import { View, Text } from '@tarojs/components' import { BrandNav, SiteFooter } from '../../components/Chrome'
import { useLoad } from '@tarojs/taro'
import { fetchDestination, type Destination } from '../../services/api' import { fetchDestination, type Destination } from '../../services/api'
import './index.scss' import './index.scss'
function hueBg(hue = 170) {
return `linear-gradient(160deg, hsl(${hue}, 42%, 24%), hsl(${hue}, 30%, 10%))`
}
export default function DestinationPage() { export default function DestinationPage() {
const router = useRouter()
const [d, setD] = useState<Destination | null>(null) const [d, setD] = useState<Destination | null>(null)
const [err, setErr] = useState('') const [err, setErr] = useState('')
useLoad((q) => { useLoad(() => {
const slug = String(q.slug || '') const slug = router.params.slug || ''
if (!slug) { setErr('缺少城市'); return } if (!slug) { setErr('缺少城市'); return }
fetchDestination(slug).then(setD).catch((e) => setErr(e.message || '加载失败')) fetchDestination(slug)
.then(setD)
.catch((e) => setErr(e.message || '加载失败'))
}) })
if (err) return <View className='detail'><Text className='err'>{err}</Text></View> if (err || !d) {
if (!d) return <View className='detail'><Text>加载中…</Text></View> return (
<View className='page-shell' style={{ padding: '64px 32px' }}>
<Text className='dest-empty'>{err || '加载中…'}</Text>
</View>
)
}
return ( return (
<View className='detail'> <ScrollView scrollY className='page-shell' showScrollbar={false}>
<Text className='name'>{d.name}</Text> <BrandNav chip='详情' />
<Text className='meta'>{d.country}</Text> <View className='container' style={{ paddingBottom: '48px' }}>
<Text className='tag'>{d.tagline}</Text> <Text
<View className='grid'> className='detail-nav'
<View className='cell'><Text className='k'>月生活费</Text><Text className='v'>¥{d.cost ?? '—'}</Text></View> onClick={() => Taro.navigateBack({ fail: () => Taro.switchTab({ url: '/pages/destinations/index' }) })}
<View className='cell'><Text className='k'>网速</Text><Text className='v'>{d.speed ?? '—'} Mbps</Text></View> >
<View className='cell'><Text className='k'>评分</Text><Text className='v'>{d.rating ?? '—'}</Text></View> ← 目的地
<View className='cell'><Text className='k'>气候</Text><Text className='v'>{d.climate || '—'}</Text></View> </Text>
<View className='detail-hero' style={{ background: hueBg(d.hue ?? 170) }}>
<Text className='dest-emoji'>{d.emoji || '🌍'}</Text>
<Text className='section-h2'>{d.name} · {d.country}</Text>
<Text className='section-p'>{d.tag || ''} · {d.nomads_count || '—'} 游民</Text>
<Text className='section-p' style={{ marginTop: '16px' }}>{d.description || d.tagline || ''}</Text>
<View className='hero-actions' style={{ marginTop: '24px' }}>
<Button className='btn btn-primary' onClick={() => Taro.showToast({ title: '登录后可收藏', icon: 'none' })}>
收藏
</Button>
<Button className='btn btn-ghost' onClick={() => Taro.switchTab({ url: '/pages/meetups/index' })}>
同城活动
</Button>
</View>
</View>
<View className='dest-meta'>
<Text className='dest-meta-item'>💰 ¥{(d.cost || 0).toLocaleString()}/月</Text>
<Text className='dest-meta-item'>📶 {d.speed || 0}Mbps</Text>
<Text className='dest-meta-item'>🌡️ {d.temperature ?? '—'}°C</Text>
<Text className='dest-meta-item'>⭐ {d.rating ?? '—'}</Text>
</View>
<View className='filter-tags' style={{ marginTop: '16px' }}>
{(d.highlights || []).map((h) => (
<Text key={h} className='dest-tag' style={{ marginRight: '8px', marginBottom: '8px' }}>{h}</Text>
))}
</View>
</View> </View>
</View> <SiteFooter />
</ScrollView>
) )
} }

View File

@ -1,9 +1 @@
/* destinations — shared theme */
.list { height: 100vh; padding: 32px 28px 80px; box-sizing: border-box; }
.h { display: block; font-size: 40px; font-weight: 700; margin-bottom: 28px; }
.err { display: block; color: #e88; margin-bottom: 16px; }
.empty { display: block; color: #8a8694; margin-top: 40px; text-align: center; }
.card { background: rgba(255,255,255,0.06); border-radius: 20px; padding: 28px; margin-bottom: 20px; }
.name { display: block; font-size: 32px; font-weight: 600; }
.meta { display: block; margin-top: 10px; color: #a8a3b3; font-size: 24px; }
.tag { display: block; margin-top: 8px; color: #8a8694; font-size: 22px; }

View File

@ -1,34 +1,34 @@
import { useState } from 'react'
import { View, Text, ScrollView } from '@tarojs/components' import { View, Text, ScrollView } from '@tarojs/components'
import Taro, { useLoad } from '@tarojs/taro' import { useLoad } from '@tarojs/taro'
import { useState } from 'react'
import { BrandNav, SiteFooter } from '../../components/Chrome'
import { DestinationsSection } from '../../components/DestinationsSection'
import { fetchDestinations, type Destination } from '../../services/api' import { fetchDestinations, type Destination } from '../../services/api'
import './index.scss' import './index.scss'
export default function DestinationsPage() { export default function DestinationsPage() {
const [items, setItems] = useState<Destination[]>([]) const [list, setList] = useState<Destination[]>([])
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [loading, setLoading] = useState(true)
useLoad(() => { useLoad(() => {
fetchDestinations() fetchDestinations()
.then(setItems) .then((d) => { setList(d); setLoading(false) })
.catch((e) => setErr(e.message || '加载失败')) .catch((e) => { setErr(e.message || '加载失败'); setLoading(false) })
}) })
return ( return (
<ScrollView scrollY className='list'> <ScrollView scrollY className='page-shell' showScrollbar={false}>
<Text className='h'>热门目的地</Text> <BrandNav chip='探索' />
{err ? <Text className='err'>{err}</Text> : null} <View className='container' style={{ paddingTop: '8px' }}>
{items.map((d) => ( <Text className='section-tag'>🧭 EXPLORE</Text>
<View <Text className='section-h2'>探索</Text>
key={d.slug} <Text className='section-p'>发现下一座适合你的城市</Text>
className='card' {loading ? <Text className='dest-empty'>加载中…</Text> : null}
onClick={() => Taro.navigateTo({ url: `/pages/destination/index?slug=${d.slug}` })} {err ? <Text className='dest-empty'>{err}</Text> : null}
> </View>
<Text className='name'>{d.name}</Text> {!loading && !err ? <DestinationsSection list={list} /> : null}
<Text className='meta'>{d.country || ''} · ¥{d.cost ?? '—'} /月 · ★{d.rating ?? '—'}</Text> <SiteFooter />
<Text className='tag'>{d.tagline || d.climate || ''}</Text>
</View>
))}
</ScrollView> </ScrollView>
) )
} }

View File

@ -1,12 +1,9 @@
.home-loading {
.home { padding: 64px 40px 80px; min-height: 100vh; box-sizing: border-box; } min-height: 100vh;
.brand { display: block; font-size: 56px; font-weight: 700; letter-spacing: 2px; } display: flex;
.tagline { display: block; margin-top: 12px; color: #a8a3b3; font-size: 28px; } flex-direction: column;
.err { display: block; margin-top: 24px; color: #e88; font-size: 24px; } align-items: center;
.stats { display: flex; gap: 24px; margin: 56px 0 48px; } justify-content: center;
.stat { flex: 1; background: rgba(255,255,255,0.06); border-radius: 20px; padding: 28px 16px; text-align: center; } padding: 48px;
.n { display: block; font-size: 40px; font-weight: 700; color: #f0c674; } animation: fade-up 0.6s ease both;
.l { display: block; margin-top: 8px; font-size: 22px; color: #8a8694; } }
.cta { margin-bottom: 20px; background: #f0c674 !important; color: #1a1520 !important; border-radius: 999px !important; font-weight: 600; }
.cta.ghost { background: transparent !important; color: #f4f0ea !important; border: 1px solid rgba(255,255,255,0.2) !important; }
.web { display: block; margin-top: 40px; text-align: center; color: #6a6575; font-size: 22px; }

View File

@ -1,32 +1,257 @@
import { useState } from 'react' import { View, Text, Button, ScrollView } from '@tarojs/components'
import { View, Text, Button } from '@tarojs/components'
import Taro, { useLoad } from '@tarojs/taro' import Taro, { useLoad } from '@tarojs/taro'
import { fetchStats } from '../../services/api' import { useEffect, useState } from 'react'
import { BrandNav, SiteFooter } from '../../components/Chrome'
import { DestinationsSection } from '../../components/DestinationsSection'
import {
fetchDestinations,
fetchStats,
fetchTestimonials,
fetchVisas,
type Destination,
type Testimonial,
type Visa,
} from '../../services/api'
import { FAQS, LIFESTYLE, TICKER, TYPEWRITER_LINES } from '../../data/homeStatic'
import './index.scss' import './index.scss'
export default function HomePage() { export default function HomePage() {
const [stats, setStats] = useState<{ destinations?: number; members?: number; meetups?: number } | null>(null) const [dests, setDests] = useState<Destination[]>([])
const [err, setErr] = useState('') const [visas, setVisas] = useState<Visa[]>([])
const [voices, setVoices] = useState<Testimonial[]>([])
const [stats, setStats] = useState<{ total_nomads?: string }>({})
const [tw, setTw] = useState(TYPEWRITER_LINES[0])
const [lifeActive, setLifeActive] = useState(0)
const [visaFilter, setVisaFilter] = useState('all')
const [faqOpen, setFaqOpen] = useState<number | null>(0)
const [loading, setLoading] = useState(true)
useLoad(() => { useLoad(() => {
fetchStats() Promise.all([
.then(setStats) fetchDestinations().catch(() => [] as Destination[]),
.catch((e) => setErr(e.message || '加载失败')) fetchVisas().catch(() => [] as Visa[]),
fetchTestimonials().catch(() => [] as Testimonial[]),
fetchStats().catch(() => ({})),
]).then(([d, v, t, s]) => {
setDests(d)
setVisas(v)
setVoices(t)
setStats(s)
setLoading(false)
})
}) })
return ( useEffect(() => {
<View className='home'> let i = 0
<Text className='brand'>nomadro</Text> const id = setInterval(() => {
<Text className='tagline'>用一行代码环游世界</Text> i = (i + 1) % TYPEWRITER_LINES.length
{err ? <Text className='err'>{err}</Text> : null} setTw(TYPEWRITER_LINES[i])
<View className='stats'> }, 2800)
<View className='stat'><Text className='n'>{stats?.destinations ?? '—'}</Text><Text className='l'>目的地</Text></View> return () => clearInterval(id)
<View className='stat'><Text className='n'>{stats?.members ?? '—'}</Text><Text className='l'>游民</Text></View> }, [])
<View className='stat'><Text className='n'>{stats?.meetups ?? '—'}</Text><Text className='l'>活动</Text></View>
const visaList = visas.filter((v) => {
if (visaFilter === 'easy') return v.difficulty <= 35
if (visaFilter === 'medium') return v.difficulty > 35 && v.difficulty <= 55
if (visaFilter === 'budget') return v.badge_type === 'budget'
return true
})
const curLife = LIFESTYLE.items[lifeActive]
if (loading) {
return (
<View className='page-shell home-loading'>
<Text className='section-h2'>nomadro</Text>
<Text className='section-p'>正在打开…</Text>
</View> </View>
<Button className='cta' onClick={() => Taro.switchTab({ url: '/pages/destinations/index' })}>探索城市</Button> )
<Button className='cta ghost' onClick={() => Taro.switchTab({ url: '/pages/meetups/index' })}>同城活动</Button> }
<Text className='web'>网页版 nomadweb.nomadro.com</Text>
</View> return (
<ScrollView scrollY className='page-shell' showScrollbar={false}>
<BrandNav />
<View className='ticker-bar'>
<View className='ticker-track'>
{[...TICKER, ...TICKER].map((m, i) => (
<Text key={i} className='ticker-item'>{m}</Text>
))}
</View>
</View>
<View className='hero'>
<View className='hero-shapes'>
<View className='hero-shape hero-shape-1' />
<View className='hero-shape hero-shape-2' />
<View className='hero-shape hero-shape-3' />
</View>
<View className='hero-inner'>
<View className='hero-badge'>
<View className='badge-dot' />
<Text className='hero-badge-text'>
nomadro · {stats.total_nomads || '35.6M'} 全球数字游民正在路上
</Text>
</View>
<View className='hero-title'>
<Text className='hero-title-line'>用一行代码</Text>
<Text className='hero-title-line gradient-text'>环游世界 🌏</Text>
</View>
<Text className='hero-subtitle'>
{tw}
<Text className='typewriter-cursor'>|</Text>
</Text>
<View className='hero-globe'>
<View className='hero-globe-core'>
<Text className='hero-globe-emoji'>🌐</Text>
</View>
<View className='float-card fc-1'><Text>🏝️ Bali{'\n'}WiFi 98Mbps</Text></View>
<View className='float-card fc-2'><Text>☕ Lisbon{'\n'}€800/mo</Text></View>
<View className='float-card fc-3'><Text>🏔️ Chiang Mai{'\n'}⭐ 4.9</Text></View>
</View>
<View className='hero-actions'>
<Button
className='btn btn-primary'
onClick={() => Taro.switchTab({ url: '/pages/destinations/index' })}
>
🎯 智能匹配去哪
</Button>
<Button
className='btn btn-ghost'
onClick={() => Taro.switchTab({ url: '/pages/destinations/index' })}
>
从城市开始
</Button>
</View>
</View>
</View>
<View className='section section-compact'>
<View className='container'>
<Text className='section-tag'>PATH</Text>
<Text className='section-h2'>三条环,走通旅居</Text>
<Text className='section-p'>探索去哪 → 遇见同行 → 路上成长</Text>
<View className='journey-rail' style={{ marginTop: '24px' }}>
{[
{ n: '01', e: '🌍', t: '探索', p: '目的地、签证与智能匹配', url: '/pages/destinations/index' },
{ n: '02', e: '🤝', t: '连接', p: '活动、社区与游民心声', url: '/pages/meetups/index' },
{ n: '03', e: '🚀', t: '成长', p: '学院、博客与工具箱', url: '/pages/mine/index' },
].map((s) => (
<View key={s.n} className='journey-card'>
<View className='journey-card-top'>
<Text className='journey-n'>{s.n}</Text>
<Text className='journey-emoji'>{s.e}</Text>
</View>
<Text className='journey-title'>{s.t}</Text>
<Text className='journey-desc'>{s.p}</Text>
<Button className='btn btn-primary btn-sm' onClick={() => Taro.switchTab({ url: s.url })}>
开始
</Button>
</View>
))}
</View>
</View>
</View>
<DestinationsSection list={dests} limit={6} />
<View className='section section-compact'>
<View className='container'>
<Text className='section-tag'>{LIFESTYLE.tag}</Text>
<Text className='section-h2'>{LIFESTYLE.title}</Text>
<Text className='section-p'>{LIFESTYLE.subtitle}</Text>
<View className='filter-tags' style={{ marginTop: '20px' }}>
{LIFESTYLE.items.map((it, i) => (
<Button
key={it.key}
className={`filter-btn${lifeActive === i ? ' active' : ''}`}
onClick={() => setLifeActive(i)}
>
{it.icon} {it.title}
</Button>
))}
</View>
{curLife ? (
<View className='life-feature'>
<Text className='section-tag'>{curLife.time}</Text>
<Text className='section-h2'>{curLife.icon} {curLife.title}</Text>
<Text className='section-p'>{curLife.desc}</Text>
<Text className='section-p'>{curLife.tip}</Text>
</View>
) : null}
</View>
</View>
<View className='section section-compact'>
<View className='container'>
<Text className='section-tag'>📋 VISA</Text>
<Text className='section-h2'>远程友好签证</Text>
<Text className='section-p'>出发前请再核对官方政策</Text>
<View className='filter-tags' style={{ marginTop: '16px' }}>
{[
{ key: 'all', label: '🌏 全部' },
{ key: 'easy', label: '✅ 简单' },
{ key: 'medium', label: '📋 中等' },
{ key: 'budget', label: '💰 低成本' },
].map((f) => (
<Button
key={f.key}
className={`filter-btn${visaFilter === f.key ? ' active' : ''}`}
onClick={() => setVisaFilter(f.key)}
>
{f.label}
</Button>
))}
</View>
{visaList.slice(0, 8).map((v) => (
<View key={v.id} className='visa-card'>
<Text className='visa-flag'>{v.flag}</Text>
<Text className='journey-title'>{v.name}</Text>
<Text className='visa-badge'>{v.badge}</Text>
<Text className='visa-line'>📅 {v.duration}</Text>
<Text className='visa-line'>💰 {v.income_req}</Text>
<Text className='visa-line'>⏱️ {v.approval_time}</Text>
<Text className='visa-line'>{v.extra}</Text>
<View className='progress-bar'>
<View className='progress-fill' style={{ width: `${v.difficulty}%` }} />
</View>
<Text className='visa-line'>难度 {v.difficulty_label}</Text>
</View>
))}
</View>
</View>
<View className='section section-compact'>
<View className='container'>
<Text className='section-tag'>🤝 COMMUNITY</Text>
<Text className='section-h2'>游民心声</Text>
<Text className='section-p'>来自全球数字游民的真实故事</Text>
{voices.slice(0, 6).map((item) => (
<View key={item.id} className='voice-card'>
<Text className='voice-avatar'>{item.avatar}</Text>
<Text className='voice-stars'>{'⭐'.repeat(item.rating || 5)}</Text>
<Text className='voice-content'>“{item.content}”</Text>
<Text className='voice-author'>{item.author} · {item.role}</Text>
</View>
))}
</View>
</View>
<View className='section section-compact'>
<View className='container'>
<Text className='section-tag'>❓ FAQ</Text>
<Text className='section-h2'>常见问题</Text>
{FAQS.map((f, i) => (
<View key={i} className='faq-item' onClick={() => setFaqOpen(faqOpen === i ? null : i)}>
<Text className='faq-q'>{f.q}</Text>
{faqOpen === i ? <Text className='faq-a'>{f.a}</Text> : null}
</View>
))}
</View>
</View>
<SiteFooter />
</ScrollView>
) )
} }

View File

@ -1,9 +1 @@
/* meetups — shared theme */
.list { height: 100vh; padding: 32px 28px 80px; box-sizing: border-box; }
.h { display: block; font-size: 40px; font-weight: 700; margin-bottom: 28px; }
.err { display: block; color: #e88; margin-bottom: 16px; }
.empty { display: block; color: #8a8694; margin-top: 40px; text-align: center; }
.card { background: rgba(255,255,255,0.06); border-radius: 20px; padding: 28px; margin-bottom: 20px; }
.name { display: block; font-size: 32px; font-weight: 600; }
.meta { display: block; margin-top: 10px; color: #a8a3b3; font-size: 24px; }
.tag { display: block; margin-top: 8px; color: #8a8694; font-size: 22px; }

View File

@ -1,28 +1,106 @@
import { useState } from 'react' import { View, Text, Button, ScrollView } from '@tarojs/components'
import { View, Text, ScrollView } from '@tarojs/components' import Taro, { useLoad } from '@tarojs/taro'
import { useLoad } from '@tarojs/taro' import { useMemo, useState } from 'react'
import { BrandNav, SiteFooter } from '../../components/Chrome'
import { fetchMeetups, type Meetup } from '../../services/api' import { fetchMeetups, type Meetup } from '../../services/api'
import './index.scss' import './index.scss'
export default function MeetupsPage() { export default function MeetupsPage() {
const [items, setItems] = useState<Meetup[]>([]) const [list, setList] = useState<Meetup[]>([])
const [mode, setMode] = useState<'all' | 'online' | 'offline' | 'hybrid'>('all')
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [loading, setLoading] = useState(true)
useLoad(() => { useLoad(() => {
fetchMeetups().then(setItems).catch((e) => setErr(e.message || '加载失败')) fetchMeetups()
.then((d) => { setList(d); setLoading(false) })
.catch((e) => { setErr(e.message || '加载失败'); setLoading(false) })
}) })
const filtered = useMemo(() => {
if (mode === 'all') return list
return list.filter((m) => (m.mode || '').toLowerCase() === mode)
}, [list, mode])
return ( return (
<ScrollView scrollY className='list'> <ScrollView scrollY className='page-shell' showScrollbar={false}>
<Text className='h'>游民活动</Text> <BrandNav chip='活动' />
{err ? <Text className='err'>{err}</Text> : null} <View className='container' style={{ paddingBottom: '48px' }}>
{items.map((m) => ( <Text className='section-tag'>🎉 MEETUPS</Text>
<View key={m.id} className='card'> <Text className='section-h2'>游民活动</Text>
<Text className='name'>{m.title}</Text> <Text className='section-p'>同城与线上 · 登录后可 RSVP</Text>
<Text className='meta'>{m.city || '全球'} · {m.date || '待定'} · {m.attendees ?? 0} 人</Text>
<View className='meetups-toolbar'>
{([
['all', '全部'],
['online', '线上'],
['offline', '线下'],
['hybrid', '混合'],
] as const).map(([k, label]) => (
<Button
key={k}
className={`filter-btn${mode === k ? ' active' : ''}`}
onClick={() => setMode(k)}
>
{label}
</Button>
))}
</View> </View>
))}
{!err && items.length === 0 ? <Text className='empty'>暂无活动</Text> : null} {loading ? <Text className='dest-empty'>加载中…</Text> : null}
{err ? <Text className='dest-empty'>{err}</Text> : null}
{filtered.map((m) => (
<View key={m.id} className='meetup-card'>
<View className='meetup-card-top'>
<Text className='meetup-emoji'>{m.emoji || '🎉'}</Text>
<View style={{ flex: 1 }}>
<Text className='meetup-title'>{m.title}</Text>
<Text className='meetup-meta'>
{m.city || '—'} · {m.date || ''} {m.time || ''} · {m.mode || 'offline'}
</Text>
</View>
</View>
<Text className='meetup-desc'>{m.description || ''}</Text>
{m.venue ? <Text className='meetup-venue'>📍 {m.venue}</Text> : null}
<View className='meetup-tags'>
{(m.tags || []).map((tag) => (
<Text key={tag} className='meetup-tag'>{tag}</Text>
))}
</View>
<View className='meetup-footer'>
<Text className='meetup-footer-text'>
{m.rsvp_count ?? m.attendees ?? 0}/{m.max_attendees || '∞'} 人
</Text>
<View style={{ display: 'flex', gap: '8px' }}>
<Button
className='btn btn-sm btn-primary'
onClick={() => Taro.showToast({ title: '请先在「我的」登录', icon: 'none' })}
>
RSVP
</Button>
{m.destination_slug ? (
<Button
className='btn btn-sm btn-ghost'
onClick={() =>
Taro.navigateTo({
url: `/pages/destination/index?slug=${encodeURIComponent(m.destination_slug!)}`,
})
}
>
城市
</Button>
) : null}
</View>
</View>
</View>
))}
{!loading && !err && !filtered.length ? (
<Text className='dest-empty'>暂无活动</Text>
) : null}
</View>
<SiteFooter />
</ScrollView> </ScrollView>
) )
} }

View File

@ -1,5 +1 @@
/* mine — shared theme */
.mine { padding: 48px 32px; }
.h { display: block; font-size: 40px; font-weight: 700; }
.hint { display: block; margin: 20px 0 40px; color: #8a8694; font-size: 26px; }
.cta { background: #f0c674 !important; color: #1a1520 !important; border-radius: 999px !important; }

View File

@ -1,15 +1,141 @@
import { View, Text, Button } from '@tarojs/components' import { View, Text, Button, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro' import Taro from '@tarojs/taro'
import { useEffect, useState } from 'react'
import { BrandNav, SiteFooter } from '../../components/Chrome'
import { DestCard } from '../../components/DestCard'
import { STORAGE_TOKEN } from '../../utils/config'
import {
fetchFavoriteDetails,
fetchMe,
fetchProfileStats,
loginWechat,
type AuthUser,
type Destination,
type ProfileStats,
} from '../../services/api'
import './index.scss' import './index.scss'
export default function MinePage() { export default function MinePage() {
const [user, setUser] = useState<AuthUser | null>(null)
const [stats, setStats] = useState<ProfileStats | null>(null)
const [favs, setFavs] = useState<Destination[]>([])
const [loading, setLoading] = useState(false)
async function refresh() {
const t = Taro.getStorageSync(STORAGE_TOKEN)
if (!t) {
setUser(null)
setStats(null)
setFavs([])
return
}
try {
const me = await fetchMe()
setUser(me)
const [s, f] = await Promise.all([
fetchProfileStats().catch(() => null),
fetchFavoriteDetails().catch(() => [] as Destination[]),
])
setStats(s)
setFavs(f)
} catch {
Taro.removeStorageSync(STORAGE_TOKEN)
setUser(null)
setStats(null)
setFavs([])
}
}
useEffect(() => { refresh() }, [])
async function onWechatLogin() {
if (loading) return
setLoading(true)
try {
const { code } = await Taro.login()
if (!code) throw new Error('获取微信登录码失败')
const res = await loginWechat(code)
Taro.setStorageSync(STORAGE_TOKEN, res.token)
setUser(res.user)
await refresh()
Taro.showToast({ title: '登录成功', icon: 'success' })
} catch (e) {
const msg = e instanceof Error ? e.message : '登录失败'
Taro.showToast({ title: msg.slice(0, 40), icon: 'none' })
} finally {
setLoading(false)
}
}
function onLogout() {
Taro.removeStorageSync(STORAGE_TOKEN)
setUser(null)
setStats(null)
setFavs([])
}
return ( return (
<View className='mine'> <ScrollView scrollY className='page-shell' showScrollbar={false}>
<Text className='h'>我的</Text> <BrandNav chip='我的' />
<Text className='hint'>登录与会员请使用网页版完整能力</Text> <View className='container' style={{ paddingBottom: '48px' }}>
<Button className='cta' onClick={() => Taro.setClipboardData({ data: 'https://nomadweb.nomadro.com' })}> <Text className='section-tag'>👤 MINE</Text>
复制网站链接 <Text className='section-h2'>我的</Text>
</Button>
</View> {!user ? (
<View className='profile-header'>
<View className='profile-avatar'><Text>🧑‍💻</Text></View>
<Text className='profile-name'>未登录</Text>
<Text className='profile-email'>微信一键登录,同步收藏与等级</Text>
<Button className='btn btn-primary' loading={loading} onClick={onWechatLogin}>
微信登录
</Button>
</View>
) : (
<>
<View className='profile-header'>
<View className='profile-avatar'><Text>{user.avatar || '🟢'}</Text></View>
<Text className='profile-name'>{user.name}</Text>
<Text className='profile-email'>{user.email}</Text>
{stats ? <Text className='profile-level'>{stats.nomad_level}</Text> : null}
<Button className='btn btn-ghost' onClick={onLogout}>退出登录</Button>
</View>
{stats ? (
<View className='profile-stats'>
<View className='profile-stat'>
<Text>❤️</Text>
<Text className='profile-stat-n'>{stats.favorites_count}</Text>
<Text className='profile-stat-l'>收藏</Text>
</View>
<View className='profile-stat'>
<Text>🗺️</Text>
<Text className='profile-stat-n'>{stats.destinations_explored}</Text>
<Text className='profile-stat-l'>探索</Text>
</View>
<View className='profile-stat'>
<Text>🏅</Text>
<Text className='profile-stat-n'>{stats.member_since}</Text>
<Text className='profile-stat-l'>加入</Text>
</View>
</View>
) : null}
<Text className='section-h2' style={{ fontSize: '32px', marginBottom: '16px' }}>❤️ 收藏的城市</Text>
{favs.length ? favs.map((d) => <DestCard key={d.slug} d={d} favorited />) : (
<Text className='dest-empty'>还没有收藏 · 去目的地逛逛</Text>
)}
</>
)}
<Button
className='btn btn-ghost'
style={{ marginTop: '24px', width: '100%' }}
onClick={() => Taro.setClipboardData({ data: 'https://nomadweb.nomadro.com' })}
>
复制网页版链接
</Button>
</View>
<SiteFooter />
</ScrollView>
) )
} }

View File

@ -1,15 +1,24 @@
import { request } from '../utils/request' import { request } from '../utils/request'
export type Destination = { export type Destination = {
id?: string
slug: string slug: string
name: string name: string
country?: string country?: string
emoji?: string
tag?: string
tagline?: string
description?: string
region?: string
cost?: number cost?: number
speed?: number speed?: number
rating?: number rating?: number
tagline?: string temperature?: number
hue?: number
image?: string image?: string
climate?: string climate?: string
nomads_count?: string
highlights?: string[]
} }
export type Meetup = { export type Meetup = {
@ -17,22 +26,105 @@ export type Meetup = {
title: string title: string
city?: string city?: string
date?: string date?: string
time?: string
cover?: string cover?: string
attendees?: number attendees?: number
emoji?: string
description?: string
venue?: string
mode?: string
rsvp_count?: number
max_attendees?: number
tags?: string[]
destination_slug?: string
organizer?: string
}
export type Visa = {
id: string
country: string
flag: string
name: string
badge: string
badge_type: string
duration: string
income_req: string
approval_time: string
extra: string
difficulty: number
difficulty_label: string
}
export type Testimonial = {
id: string
avatar: string
content: string
author: string
role: string
rating: number
}
export type AuthUser = {
id: string
email: string
name: string
avatar: string
}
export type AuthResponse = {
token: string
user: AuthUser
}
export type ProfileStats = {
favorites_count: number
destinations_explored: number
member_since: string
nomad_level: string
} }
export function fetchDestinations() { export function fetchDestinations() {
return request<Destination[]>('/destinations') return request<Destination[]>({ url: '/destinations' })
} }
export function fetchDestination(slug: string) { export function fetchDestination(slug: string) {
return request<Destination>(`/destinations/${encodeURIComponent(slug)}`) return request<Destination>({ url: `/destinations/${encodeURIComponent(slug)}` })
} }
export function fetchMeetups() { export function fetchMeetups() {
return request<Meetup[]>('/meetups') return request<Meetup[]>({ url: '/meetups' })
} }
export function fetchStats() { export function fetchStats() {
return request<{ destinations?: number; members?: number; meetups?: number }>('/stats') return request<{ destinations?: number; members?: number; meetups?: number; total_nomads?: string }>({
url: '/stats',
})
}
export function fetchVisas() {
return request<Visa[]>({ url: '/visas' })
}
export function fetchTestimonials() {
return request<Testimonial[]>({ url: '/testimonials' })
}
export function loginWechat(code: string) {
return request<AuthResponse>({
url: '/auth/wechat',
method: 'POST',
data: { code },
})
}
export function fetchMe() {
return request<AuthUser>({ url: '/auth/me' })
}
export function fetchProfileStats() {
return request<ProfileStats>({ url: '/auth/profile/stats' })
}
export function fetchFavoriteDetails() {
return request<Destination[]>({ url: '/auth/favorites/detail' })
} }

View File

@ -0,0 +1,653 @@
/* Shared H5-parity blocks — weapp-safe only */
.brand-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 32px;
}
.brand-nav-logo {
font-size: 36px;
font-weight: 800;
letter-spacing: 1px;
color: #f1f5f9;
}
.brand-nav-chip {
font-size: 22px;
padding: 6px 18px;
border-radius: 999px;
background: rgba(240, 198, 116, 0.14);
color: #f0c674;
}
.site-footer {
margin-top: 48px;
padding: 48px 32px 80px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.site-footer-logo {
display: block;
font-size: 32px;
font-weight: 800;
margin-bottom: 8px;
}
.site-footer-tag,
.site-footer-copy {
display: block;
font-size: 22px;
color: #64748b;
margin-top: 6px;
}
/* Dest card */
.dest-card {
background: rgba(17, 24, 39, 0.72);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 24px;
overflow: hidden;
margin-bottom: 24px;
}
.dest-img {
position: relative;
height: 280px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.dest-emoji {
font-size: 88px;
animation: emoji-bounce 3s ease-in-out infinite;
}
.dest-overlay {
position: absolute;
left: 20px;
bottom: 20px;
}
.dest-tag {
padding: 6px 18px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 20px;
}
.dest-body {
padding: 28px;
}
.dest-card-title {
display: block;
font-size: 32px;
font-weight: 700;
margin-bottom: 10px;
color: #f1f5f9;
}
.dest-card-desc {
display: block;
font-size: 24px;
line-height: 1.55;
color: #94a3b8;
margin-bottom: 20px;
overflow: hidden;
max-height: 76px;
}
.dest-meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 12px;
}
.dest-meta-item {
font-size: 22px;
color: #94a3b8;
}
.dest-rating {
display: flex;
align-items: center;
gap: 8px;
font-size: 24px;
}
.dest-grid {
display: flex;
flex-direction: column;
}
.filter-search {
margin-bottom: 16px;
}
.filter-search-input {
width: 100%;
height: 72px;
padding: 0 24px;
box-sizing: border-box;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.05);
color: #f1f5f9;
font-size: 26px;
}
.filter-tags {
display: flex;
flex-wrap: wrap;
margin-bottom: 8px;
}
.filter-sort-row {
margin-bottom: 20px;
}
/* Hero */
.hero {
position: relative;
padding: 24px 32px 48px;
overflow: hidden;
min-height: 720px;
}
.hero-shapes {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
overflow: hidden;
}
.hero-shape {
position: absolute;
border-radius: 50%;
filter: blur(40px);
opacity: 0.35;
animation: aurora 10s ease-in-out infinite;
}
.hero-shape-1 {
width: 360px;
height: 360px;
background: #ff6b6b;
top: 40px;
left: -80px;
}
.hero-shape-2 {
width: 280px;
height: 280px;
background: #4ecdc4;
top: 280px;
right: -60px;
animation-delay: -3s;
}
.hero-shape-3 {
width: 220px;
height: 220px;
background: #ffe66d;
bottom: 40px;
left: 30%;
animation-delay: -5s;
}
.hero-inner {
position: relative;
z-index: 1;
animation: fade-up 0.65s ease both;
}
.hero-badge {
display: inline-flex;
align-items: center;
gap: 12px;
padding: 12px 28px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
margin-bottom: 28px;
}
.hero-badge-text {
font-size: 22px;
color: #94a3b8;
}
.badge-dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: #22c55e;
animation: pulse-dot 2s ease-in-out infinite;
}
.hero-title {
display: block;
font-size: 64px;
font-weight: 800;
line-height: 1.15;
letter-spacing: -1px;
margin-bottom: 20px;
}
.hero-title-line {
display: block;
}
.gradient-text {
color: #4ecdc4;
background: linear-gradient(135deg, #ff6b6b, #ffe66d, #4ecdc4);
-webkit-background-clip: text;
/* weapp may ignore clip — solid accent fallback above */
}
.hero-subtitle {
display: block;
font-size: 28px;
color: #94a3b8;
line-height: 1.7;
min-height: 96px;
margin-bottom: 36px;
}
.typewriter-cursor {
color: #4ecdc4;
animation: blink 1s step-end infinite;
}
.hero-globe {
position: relative;
height: 320px;
margin: 8px 0 36px;
display: flex;
align-items: center;
justify-content: center;
}
.hero-globe-core {
width: 240px;
height: 240px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, rgba(78, 205, 196, 0.45), rgba(10, 14, 23, 0.2) 55%, transparent 70%);
border: 1px solid rgba(78, 205, 196, 0.25);
display: flex;
align-items: center;
justify-content: center;
animation: float-y 4.5s ease-in-out infinite;
}
.hero-globe-emoji {
font-size: 96px;
}
.float-card {
position: absolute;
padding: 14px 18px;
border-radius: 14px;
background: rgba(17, 24, 39, 0.88);
border: 1px solid rgba(255, 255, 255, 0.12);
font-size: 20px;
color: #f1f5f9;
line-height: 1.35;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
animation: float-y 4s ease-in-out infinite;
}
.fc-1 { left: 0; top: 24px; animation-delay: 0s; }
.fc-2 { right: 0; top: 80px; animation-delay: 0.8s; }
.fc-3 { left: 40px; bottom: 12px; animation-delay: 1.4s; }
.hero-actions {
display: flex;
flex-direction: column;
gap: 16px;
}
.hero-actions .btn {
width: 100%;
}
.ticker-bar {
overflow: hidden;
height: 52px;
background: rgba(10, 14, 23, 0.92);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.ticker-track {
display: flex;
flex-direction: row;
white-space: nowrap;
animation: ticker-scroll 28s linear infinite;
padding: 12px 0;
}
.ticker-item {
flex-shrink: 0;
padding: 0 36px;
font-size: 22px;
color: #64748b;
}
/* Journey */
.journey-rail {
display: flex;
flex-direction: column;
gap: 16px;
}
.journey-card {
padding: 28px 24px;
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(17, 24, 39, 0.72);
}
.journey-card-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.journey-n {
font-size: 22px;
font-weight: 700;
letter-spacing: 2px;
color: #ffe66d;
}
.journey-emoji {
font-size: 40px;
}
.journey-title {
display: block;
font-size: 32px;
font-weight: 700;
margin: 8px 0;
}
.journey-desc {
display: block;
font-size: 24px;
color: #94a3b8;
line-height: 1.5;
margin-bottom: 16px;
}
/* Visa / lifestyle / voices / faq */
.visa-grid,
.life-tabs {
display: flex;
flex-direction: column;
gap: 16px;
}
.visa-card,
.life-feature,
.voice-card,
.faq-item {
padding: 28px 24px;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(17, 24, 39, 0.72);
margin-bottom: 16px;
}
.visa-flag {
font-size: 40px;
margin-bottom: 8px;
}
.visa-badge {
display: inline-block;
margin: 8px 0 12px;
padding: 4px 14px;
border-radius: 999px;
font-size: 20px;
background: rgba(78, 205, 196, 0.18);
color: #4ecdc4;
}
.visa-line {
display: block;
font-size: 24px;
color: #94a3b8;
margin: 4px 0;
}
.progress-bar {
height: 8px;
background: rgba(255, 255, 255, 0.08);
border-radius: 999px;
margin: 12px 0 6px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #ff6b6b, #4ecdc4);
border-radius: 999px;
}
.voice-avatar {
font-size: 48px;
margin-bottom: 12px;
}
.voice-stars {
display: block;
margin-bottom: 8px;
}
.voice-content {
display: block;
font-size: 26px;
line-height: 1.6;
color: #f1f5f9;
margin-bottom: 12px;
}
.voice-author {
display: block;
font-size: 22px;
color: #94a3b8;
}
.faq-q {
display: block;
font-size: 28px;
font-weight: 600;
}
.faq-a {
display: block;
margin-top: 12px;
font-size: 24px;
line-height: 1.6;
color: #94a3b8;
}
/* Meetups */
.meetup-card {
background: rgba(17, 24, 39, 0.72);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 28px;
padding: 28px;
margin-bottom: 20px;
}
.meetup-card-top {
display: flex;
gap: 16px;
margin-bottom: 12px;
}
.meetup-emoji {
font-size: 48px;
}
.meetup-title {
display: block;
font-size: 30px;
font-weight: 700;
}
.meetup-meta,
.meetup-venue,
.meetup-desc {
display: block;
font-size: 24px;
color: #94a3b8;
margin-top: 8px;
line-height: 1.5;
}
.meetup-desc {
color: #cbd5e1;
}
.meetup-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.meetup-tag {
font-size: 20px;
padding: 4px 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
color: #94a3b8;
}
.meetup-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 20px;
gap: 12px;
}
.meetup-footer-text {
font-size: 22px;
color: #94a3b8;
}
.meetups-toolbar {
display: flex;
flex-wrap: wrap;
margin: 20px 0 8px;
}
/* Profile */
.profile-header {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 20px;
margin-bottom: 32px;
padding: 8px 0;
}
.profile-avatar {
width: 120px;
height: 120px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
justify-content: center;
font-size: 56px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.profile-name {
display: block;
font-size: 40px;
font-weight: 800;
}
.profile-email {
display: block;
font-size: 24px;
color: #94a3b8;
margin-top: 6px;
}
.profile-level {
display: inline-block;
margin-top: 12px;
padding: 6px 16px;
border-radius: 999px;
background: rgba(78, 205, 196, 0.15);
color: #4ecdc4;
font-size: 22px;
}
.profile-stats {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 32px;
}
.profile-stat {
flex: 1;
min-width: 140px;
padding: 24px 16px;
border-radius: 20px;
background: rgba(17, 24, 39, 0.72);
border: 1px solid rgba(255, 255, 255, 0.08);
text-align: center;
}
.profile-stat-n {
display: block;
font-size: 36px;
font-weight: 800;
margin: 8px 0 4px;
}
.profile-stat-l {
display: block;
font-size: 22px;
color: #94a3b8;
}
.detail-hero {
border-radius: 24px;
padding: 36px 28px;
margin-bottom: 24px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.detail-nav {
padding: 16px 0 8px;
font-size: 26px;
color: #4ecdc4;
}
.page-shell {
min-height: 100vh;
background:
radial-gradient(700px 400px at 8% -6%, rgba(255, 107, 107, 0.18), transparent 55%),
radial-gradient(640px 380px at 100% 0%, rgba(78, 205, 196, 0.16), transparent 52%),
linear-gradient(168deg, #0a0e17 0%, #111827 48%, #0a0e17 100%);
box-sizing: border-box;
}