feat: add invite rewards and redeem codes
This commit is contained in:
@@ -8,12 +8,6 @@ MeeYao Divination is designed based on traditional oriental culture. Our core go
|
||||
|
||||
---
|
||||
|
||||
## AI Model Disclosure
|
||||
|
||||
MeeYao Divination's AI Analysis feature is powered by DeepSeek's deepseek-v4-flash model.
|
||||
|
||||
---
|
||||
|
||||
## Company Info
|
||||
|
||||
**Developer:** Ann Lee
|
||||
|
||||
@@ -25,7 +25,6 @@ You represent and warrant that you are at least 13 years of age to use this App.
|
||||
|
||||
This App provides AI-assisted cultural interpretation content related to traditional I Ching and Six-Line culture, for daily reference and cultural appreciation only.
|
||||
|
||||
- The AI Analysis feature is powered by DeepSeek's deepseek-v4-flash model.
|
||||
- All AI-generated content and cultural reference materials are for entertainment and personal reference purposes solely.
|
||||
- Content shall not be regarded as professional advice, including without limitation finance, investment, law, medical treatment, career or business decision-making.
|
||||
- I do not guarantee the accuracy, completeness or practicality of any AI-generated content within the App.
|
||||
|
||||
@@ -8,12 +8,6 @@
|
||||
|
||||
---
|
||||
|
||||
## AI 模型披露
|
||||
|
||||
觅爻 MeeYao 的 AI 解卦分析功能由 DeepSeek 的 deepseek-v4-flash 模型提供支持。
|
||||
|
||||
---
|
||||
|
||||
## 开发者信息
|
||||
|
||||
**开发者**:Ann Lee
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
|
||||
本应用提供与传统易经和六爻文化相关的 AI 辅助文化解读内容,仅供日常参考和文化赏析。
|
||||
|
||||
- AI 解卦分析功能由 DeepSeek 的 deepseek-v4-flash 模型提供支持。
|
||||
- 所有 AI 生成内容和文化参考资料仅供娱乐和个人参考目的。
|
||||
- 内容不得视为专业建议,包括但不限于金融、投资、法律、医疗、职业或商业决策。
|
||||
- 我不保证本应用内任何 AI 生成内容的准确性、完整性或实用性。
|
||||
|
||||
@@ -8,12 +8,6 @@
|
||||
|
||||
---
|
||||
|
||||
## AI 模型披露
|
||||
|
||||
覓爻 MeeYao 的 AI 解卦分析功能由 DeepSeek 的 deepseek-v4-flash 模型提供支持。
|
||||
|
||||
---
|
||||
|
||||
## 開發者信息
|
||||
|
||||
**開發者**:Ann Lee
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
|
||||
本應用提供與傳統易經和六爻文化相關的 AI 輔助文化解讀內容,僅供日常參考和文化賞析。
|
||||
|
||||
- AI 解卦分析功能由 DeepSeek 的 deepseek-v4-flash 模型提供支持。
|
||||
- 所有 AI 生成內容和文化參考資料僅供娛樂和個人參考目的。
|
||||
- 內容不得視為專業建議,包括但不限於金融、投資、法律、醫療、職業或商業決策。
|
||||
- 我不保證本應用內任何 AI 生成內容的準確性、完整性或實用性。
|
||||
|
||||
@@ -19,6 +19,7 @@ const ProfileDetailPage = lazy(() => import('./ProfileDetailPage'));
|
||||
const SettingsPage = lazy(() => import('./SettingsPage'));
|
||||
const GeneralSettingsPage = lazy(() => import('./GeneralSettingsPage'));
|
||||
const FeedbackPage = lazy(() => import('./FeedbackPage'));
|
||||
const InvitePage = lazy(() => import('./InvitePage'));
|
||||
const ManualDivinationPage = lazy(() => import('./ManualDivinationPage'));
|
||||
const AutoDivinationPage = lazy(() => import('./AutoDivinationPage'));
|
||||
|
||||
@@ -31,6 +32,7 @@ const APP_PATHS = [
|
||||
'/settings',
|
||||
'/settings/general',
|
||||
'/settings/feedback',
|
||||
'/settings/invite',
|
||||
'/divination/manual',
|
||||
'/divination/auto',
|
||||
'/divination/result',
|
||||
@@ -94,6 +96,7 @@ function DashboardRoutes({ locale, translations }: DashboardAppProps) {
|
||||
<Route path={`/${locale}/settings`} element={<SettingsPage locale={locale} dashboard={dashboard} settings={translations.settings} />} />
|
||||
<Route path={`/${locale}/settings/general`} element={<GeneralSettingsPage locale={locale} general={translations.general} />} />
|
||||
<Route path={`/${locale}/settings/feedback`} element={<FeedbackPage locale={locale} feedback={translations.feedback} />} />
|
||||
<Route path={`/${locale}/settings/invite`} element={<InvitePage locale={locale} />} />
|
||||
<Route path={`/${locale}/divination/manual`} element={<ManualDivinationPage locale={locale} dashboard={dashboard} divination={translations.divination} />} />
|
||||
<Route path={`/${locale}/divination/auto`} element={<AutoDivinationPage locale={locale} dashboard={dashboard} divination={translations.divination} />} />
|
||||
<Route path={`/${locale}/divination/result`} element={<DivinationResultPage locale={locale} translations={translations.result} />} />
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
import { useMemo, useState, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { bindInviteCodeResource, redeemCodeResource, useInvite } from '../lib/resources';
|
||||
|
||||
interface Props {
|
||||
locale: string;
|
||||
}
|
||||
|
||||
interface CopyState {
|
||||
copied: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface ToastState {
|
||||
type: 'success' | 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
zh: {
|
||||
title: '我的邀请',
|
||||
myCode: '我的邀请码',
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
copyFailed: '复制失败',
|
||||
bindTitle: '绑定邀请人',
|
||||
bindPlaceholder: '输入对方邀请码',
|
||||
bindButton: '绑定',
|
||||
boundPrefix: '已绑定邀请码',
|
||||
bindLocked: '绑定后不可解绑,每个账号只能绑定一次。',
|
||||
bindUnavailable: '当前账号不可再绑定邀请码。',
|
||||
rewardTitle: '邀请奖励',
|
||||
invited: '已邀请',
|
||||
rewarded: '已到账',
|
||||
pending: '待充值',
|
||||
rewardProgress: '到账积分',
|
||||
listTitle: '邀请记录',
|
||||
empty: '暂无邀请记录',
|
||||
paid: '已到账',
|
||||
unpaid: '未到账',
|
||||
boundAt: '绑定时间',
|
||||
paidAt: '充值时间',
|
||||
redeem: '兑换卡密',
|
||||
redeemTitle: '兑换卡密',
|
||||
redeemPlaceholder: '输入卡密',
|
||||
cancel: '取消',
|
||||
confirmRedeem: '确认兑换',
|
||||
redeemSuccess: '已激活 {name},获得 {credits} 积分。',
|
||||
redeemAlreadyUsed: '该卡密已被兑换。',
|
||||
ruleTitle: '邀请机制',
|
||||
ruleBind: '你邀请的人绑定你的邀请码后,邀请关系才会生效。',
|
||||
ruleReward: '从绑定时间开始计算,对方之后完成的第一笔充值,会给你和对方各赠送 {points} 积分。',
|
||||
ruleExclude: '绑定前已经完成的充值不会触发奖励;卡密兑换也不算充值。',
|
||||
ruleLock: '每个账号只能绑定一次邀请码,绑定后不能解绑或更换。',
|
||||
loading: '加载中...',
|
||||
loadFailed: '加载失败',
|
||||
},
|
||||
zh_Hant: {
|
||||
title: '我的邀請',
|
||||
myCode: '我的邀請碼',
|
||||
copy: '複製',
|
||||
copied: '已複製',
|
||||
copyFailed: '複製失敗',
|
||||
bindTitle: '綁定邀請人',
|
||||
bindPlaceholder: '輸入對方邀請碼',
|
||||
bindButton: '綁定',
|
||||
boundPrefix: '已綁定邀請碼',
|
||||
bindLocked: '綁定後不可解綁,每個賬號只能綁定一次。',
|
||||
bindUnavailable: '當前賬號不可再綁定邀請碼。',
|
||||
rewardTitle: '邀請獎勵',
|
||||
invited: '已邀請',
|
||||
rewarded: '已到賬',
|
||||
pending: '待充值',
|
||||
rewardProgress: '到賬積分',
|
||||
listTitle: '邀請記錄',
|
||||
empty: '暫無邀請記錄',
|
||||
paid: '已到賬',
|
||||
unpaid: '未到賬',
|
||||
boundAt: '綁定時間',
|
||||
paidAt: '充值時間',
|
||||
redeem: '兌換卡密',
|
||||
redeemTitle: '兌換卡密',
|
||||
redeemPlaceholder: '輸入卡密',
|
||||
cancel: '取消',
|
||||
confirmRedeem: '確認兌換',
|
||||
redeemSuccess: '已激活 {name},獲得 {credits} 積分。',
|
||||
redeemAlreadyUsed: '該卡密已被兌換。',
|
||||
ruleTitle: '邀請機制',
|
||||
ruleBind: '你邀請的人綁定你的邀請碼後,邀請關係才會生效。',
|
||||
ruleReward: '從綁定時間開始計算,對方之後完成的第一筆充值,會給你和對方各贈送 {points} 積分。',
|
||||
ruleExclude: '綁定前已經完成的充值不會觸發獎勵;卡密兌換也不算充值。',
|
||||
ruleLock: '每個賬號只能綁定一次邀請碼,綁定後不能解綁或更換。',
|
||||
loading: '加載中...',
|
||||
loadFailed: '加載失敗',
|
||||
},
|
||||
en: {
|
||||
title: 'My Invites',
|
||||
myCode: 'My Invite Code',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
copyFailed: 'Copy failed',
|
||||
bindTitle: 'Bind Inviter',
|
||||
bindPlaceholder: 'Enter invite code',
|
||||
bindButton: 'Bind',
|
||||
boundPrefix: 'Bound invite code',
|
||||
bindLocked: 'Binding cannot be removed. Each account can bind once.',
|
||||
bindUnavailable: 'This account can no longer bind an invite code.',
|
||||
rewardTitle: 'Invite Rewards',
|
||||
invited: 'Invited',
|
||||
rewarded: 'Credited',
|
||||
pending: 'Pending',
|
||||
rewardProgress: 'Reward Credits',
|
||||
listTitle: 'Invite Records',
|
||||
empty: 'No invite records',
|
||||
paid: 'Credited',
|
||||
unpaid: 'Pending',
|
||||
boundAt: 'Bound At',
|
||||
paidAt: 'Paid At',
|
||||
redeem: 'Redeem Code',
|
||||
redeemTitle: 'Redeem Code',
|
||||
redeemPlaceholder: 'Enter code',
|
||||
cancel: 'Cancel',
|
||||
confirmRedeem: 'Redeem',
|
||||
redeemSuccess: '{name} activated. {credits} credits added.',
|
||||
redeemAlreadyUsed: 'This code has already been redeemed.',
|
||||
ruleTitle: 'Invite Rules',
|
||||
ruleBind: 'An invite relationship starts only after your invitee binds your invite code.',
|
||||
ruleReward: 'Starting from the bind time, the invitee’s next successful payment grants {points} credits to both accounts.',
|
||||
ruleExclude: 'Payments completed before binding do not trigger the reward. Redeem codes do not count as payments.',
|
||||
ruleLock: 'Each account can bind one invite code only. It cannot be removed or changed after binding.',
|
||||
loading: 'Loading...',
|
||||
loadFailed: 'Failed to load',
|
||||
},
|
||||
} as const;
|
||||
|
||||
function t(locale: string) {
|
||||
if (locale === 'zh_Hant') return TEXT.zh_Hant;
|
||||
if (locale === 'en') return TEXT.en;
|
||||
return TEXT.zh;
|
||||
}
|
||||
|
||||
function localeTag(locale: string): string {
|
||||
if (locale === 'zh_Hant') return 'zh-Hant';
|
||||
if (locale === 'zh') return 'zh-CN';
|
||||
return 'en-US';
|
||||
}
|
||||
|
||||
function formatDate(locale: string, value: string | null): string {
|
||||
if (!value) return '-';
|
||||
return new Intl.DateTimeFormat(localeTag(locale), {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | null {
|
||||
if (typeof error !== 'object' || error === null || !('code' in error)) return null;
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return typeof code === 'string' ? code : null;
|
||||
}
|
||||
|
||||
function errorDetail(error: unknown): string | null {
|
||||
if (typeof error === 'object' && error !== null && 'detail' in error) {
|
||||
const detail = (error as { detail?: unknown }).detail;
|
||||
if (typeof detail === 'string' && detail.trim()) return detail;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function errorText(error: unknown, fallback: string): string {
|
||||
const detail = errorDetail(error);
|
||||
if (detail) return detail;
|
||||
if (error instanceof Error) return error.message;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export default function InvitePage({ locale }: Props) {
|
||||
const copyInitial = useMemo<CopyState>(() => ({ copied: false, error: null }), []);
|
||||
const s = t(locale);
|
||||
const navigate = useNavigate();
|
||||
const inviteState = useInvite();
|
||||
const overview = inviteState.data;
|
||||
const [copyState, setCopyState] = useState<CopyState>(copyInitial);
|
||||
const [bindCode, setBindCode] = useState('');
|
||||
const [bindLoading, setBindLoading] = useState(false);
|
||||
const [bindError, setBindError] = useState<string | null>(null);
|
||||
const [redeemOpen, setRedeemOpen] = useState(false);
|
||||
const [redeemCode, setRedeemCode] = useState('');
|
||||
const [redeemLoading, setRedeemLoading] = useState(false);
|
||||
const [toast, setToast] = useState<ToastState | null>(null);
|
||||
|
||||
const copyCode = async () => {
|
||||
if (!overview?.myCode) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(overview.myCode);
|
||||
setCopyState({ copied: true, error: null });
|
||||
} catch {
|
||||
setCopyState({ copied: false, error: s.copyFailed });
|
||||
}
|
||||
};
|
||||
|
||||
const submitBind = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!bindCode.trim() || bindLoading) return;
|
||||
setBindLoading(true);
|
||||
setBindError(null);
|
||||
try {
|
||||
await bindInviteCodeResource(bindCode);
|
||||
setBindCode('');
|
||||
} catch (error) {
|
||||
setBindError(errorText(error, s.bindUnavailable));
|
||||
} finally {
|
||||
setBindLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitRedeem = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!redeemCode.trim() || redeemLoading) return;
|
||||
setRedeemLoading(true);
|
||||
setToast(null);
|
||||
try {
|
||||
const result = await redeemCodeResource(redeemCode);
|
||||
setRedeemCode('');
|
||||
setRedeemOpen(false);
|
||||
setToast({
|
||||
type: 'success',
|
||||
message: s.redeemSuccess
|
||||
.replace('{name}', result.packageName)
|
||||
.replace('{credits}', String(result.credits)),
|
||||
});
|
||||
} catch (error) {
|
||||
setRedeemOpen(false);
|
||||
setToast({
|
||||
type: 'error',
|
||||
message: errorCode(error) === 'REDEEM_CODE_ALREADY_REDEEMED'
|
||||
? s.redeemAlreadyUsed
|
||||
: errorText(error, s.redeemTitle),
|
||||
});
|
||||
} finally {
|
||||
setRedeemLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (inviteState.loading && !overview) {
|
||||
return <div className="flex min-h-[320px] items-center justify-center text-slate-500 text-sm">{s.loading}</div>;
|
||||
}
|
||||
|
||||
if (inviteState.error && !overview) {
|
||||
return <div className="rounded-2xl border border-red-200 bg-white p-5 text-red-600 text-sm">{s.loadFailed}</div>;
|
||||
}
|
||||
|
||||
const summary = overview?.summary;
|
||||
const progressText = summary ? `${summary.rewardedPoints}/${summary.totalPotentialRewardPoints}` : '0/0';
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col gap-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/${locale}/settings`)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 bg-white transition-colors hover:bg-slate-50"
|
||||
aria-label={s.cancel}
|
||||
>
|
||||
<span className="material-symbols-rounded text-lg text-slate-500">arrow_back</span>
|
||||
</button>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-xl font-bold text-slate-900">{s.title}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRedeemOpen(true);
|
||||
setToast(null);
|
||||
}}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-slate-800"
|
||||
>
|
||||
<span className="material-symbols-rounded text-[18px]">confirmation_number</span>
|
||||
{s.redeem}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{toast && (
|
||||
<div className={`fixed right-6 top-6 z-[60] max-w-[360px] rounded-xl border px-4 py-3 text-sm shadow-lg ${toast.type === 'success' ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-red-200 bg-red-50 text-red-600'}`}>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[360px_1fr]">
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-400">{s.myCode}</p>
|
||||
<p className="mt-1 font-mono text-3xl font-bold tracking-wider text-slate-900">{overview?.myCode ?? '-'}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyCode}
|
||||
className="inline-flex h-10 items-center gap-1.5 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
<span className="material-symbols-rounded text-[18px]">content_copy</span>
|
||||
{copyState.copied ? s.copied : s.copy}
|
||||
</button>
|
||||
</div>
|
||||
{copyState.error && <p className="mt-3 text-sm text-red-600">{copyState.error}</p>}
|
||||
|
||||
<div className="mt-6 border-t border-slate-100 pt-5">
|
||||
<h2 className="text-base font-bold text-slate-900">{s.bindTitle}</h2>
|
||||
{overview?.binding.boundInviteCode ? (
|
||||
<div className="mt-3 rounded-xl bg-slate-50 p-4">
|
||||
<p className="text-sm text-slate-500">{s.boundPrefix}</p>
|
||||
<p className="mt-1 font-mono text-lg font-bold text-slate-900">{overview.binding.boundInviteCode}</p>
|
||||
<p className="mt-2 text-xs text-slate-400">{s.bindLocked}</p>
|
||||
</div>
|
||||
) : overview?.binding.canBind ? (
|
||||
<form onSubmit={submitBind} className="mt-3 flex flex-col gap-3">
|
||||
<input
|
||||
value={bindCode}
|
||||
onChange={(event) => setBindCode(event.target.value.toUpperCase())}
|
||||
placeholder={s.bindPlaceholder}
|
||||
maxLength={32}
|
||||
className="h-11 rounded-xl border border-slate-200 bg-white px-3 font-mono text-sm uppercase outline-none transition-colors focus:border-violet-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={bindLoading || !bindCode.trim()}
|
||||
className="inline-flex h-11 items-center justify-center rounded-xl bg-violet-600 px-4 text-sm font-semibold text-white transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{s.bindButton}
|
||||
</button>
|
||||
{bindError && <p className="text-sm text-red-600">{bindError}</p>}
|
||||
</form>
|
||||
) : (
|
||||
<p className="mt-3 rounded-xl bg-slate-50 p-4 text-sm text-slate-500">{s.bindUnavailable}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-slate-900">{s.rewardTitle}</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{s.rewardProgress}: <span className="font-semibold text-slate-900">{progressText}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
[s.invited, summary?.invitedCount ?? 0],
|
||||
[s.rewarded, summary?.rewardedCount ?? 0],
|
||||
[s.pending, summary?.pendingCount ?? 0],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="min-w-[88px] rounded-xl bg-slate-50 px-3 py-2 text-center">
|
||||
<p className="text-lg font-bold text-slate-900">{value}</p>
|
||||
<p className="text-xs text-slate-400">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 overflow-hidden rounded-xl border border-slate-100">
|
||||
<div className="grid grid-cols-[1fr_120px_150px] bg-slate-50 px-4 py-3 text-xs font-semibold text-slate-500 max-md:hidden">
|
||||
<span>{s.boundAt}</span>
|
||||
<span>{s.rewarded}</span>
|
||||
<span>{s.paidAt}</span>
|
||||
</div>
|
||||
{overview?.items.length ? (
|
||||
overview.items.map((item) => (
|
||||
<div key={item.referralId} className="grid grid-cols-1 gap-2 border-t border-slate-100 px-4 py-3 text-sm md:grid-cols-[1fr_120px_150px] md:items-center">
|
||||
<div>
|
||||
<p className="text-slate-900">{formatDate(locale, item.boundAt)}</p>
|
||||
<p className="mt-0.5 font-mono text-xs text-slate-400">{item.inviteCode}</p>
|
||||
</div>
|
||||
<span className={`inline-flex w-fit items-center rounded-full px-2.5 py-1 text-xs font-semibold ${item.rewardGranted ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'}`}>
|
||||
{item.rewardGranted ? s.paid : s.unpaid}
|
||||
</span>
|
||||
<span className="text-slate-500">{formatDate(locale, item.firstCreemPaidAt)}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="border-t border-slate-100 px-4 py-10 text-center text-sm text-slate-400">{s.empty}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-slate-900">{s.ruleTitle}</h2>
|
||||
<p className="mt-2 max-w-[760px] text-sm leading-6 text-slate-600">
|
||||
{s.ruleBind}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-violet-50 px-4 py-3 text-sm font-semibold text-violet-700">
|
||||
+{summary?.rewardPoints ?? 40} / +{summary?.rewardPoints ?? 40}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-1 gap-3 lg:grid-cols-3">
|
||||
{[
|
||||
s.ruleReward.replace('{points}', String(summary?.rewardPoints ?? 40)),
|
||||
s.ruleExclude,
|
||||
s.ruleLock,
|
||||
].map((item) => (
|
||||
<div key={item} className="rounded-xl bg-slate-50 px-4 py-3 text-sm leading-6 text-slate-600">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{redeemOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 px-4">
|
||||
<div className="w-full max-w-[420px] rounded-2xl border border-slate-200 bg-white p-5 shadow-xl">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-bold text-slate-900">{s.redeemTitle}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRedeemOpen(false)}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl hover:bg-slate-100"
|
||||
aria-label={s.cancel}
|
||||
>
|
||||
<span className="material-symbols-rounded text-[20px] text-slate-500">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={submitRedeem} className="mt-4 flex flex-col gap-3">
|
||||
<input
|
||||
value={redeemCode}
|
||||
onChange={(event) => setRedeemCode(event.target.value.toUpperCase())}
|
||||
placeholder={s.redeemPlaceholder}
|
||||
maxLength={64}
|
||||
className="h-12 rounded-xl border border-slate-200 px-3 font-mono text-sm uppercase outline-none transition-colors focus:border-violet-500"
|
||||
/>
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRedeemOpen(false)}
|
||||
className="h-10 rounded-xl border border-slate-200 px-4 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
{s.cancel}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={redeemLoading || !redeemCode.trim()}
|
||||
className="h-10 rounded-xl bg-slate-900 px-4 text-sm font-semibold text-white hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{s.confirmRedeem}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -117,7 +117,7 @@ export default function SettingsPage({ locale, settings: s }: Props) {
|
||||
{/* Account Settings Panel */}
|
||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4">
|
||||
<h3 className="text-slate-900 text-lg font-bold">{s.accountTitle}</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3.5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3.5">
|
||||
{/* General Settings */}
|
||||
<a
|
||||
href={`/${locale}/settings/general`}
|
||||
@@ -148,6 +148,21 @@ export default function SettingsPage({ locale, settings: s }: Props) {
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
{/* Invite */}
|
||||
<a
|
||||
href={`/${locale}/settings/invite`}
|
||||
className="bg-slate-50 rounded-xl p-4 flex flex-col gap-2.5 hover:bg-slate-100 transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="material-symbols-rounded text-violet-600 text-xl">group_add</span>
|
||||
<div>
|
||||
<p className="text-slate-900 text-[15px] font-bold">
|
||||
{locale === 'en' ? 'Invites' : locale === 'zh_Hant' ? '我的邀請' : '我的邀请'}
|
||||
</p>
|
||||
<p className="text-slate-400 text-xs">
|
||||
{locale === 'en' ? 'Rewards, redeem codes' : locale === 'zh_Hant' ? '邀請獎勵、卡密兌換' : '邀请奖励、卡密兑换'}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
{/* Account Data */}
|
||||
<a
|
||||
href={`/${locale}/profile`}
|
||||
|
||||
+49
-3
@@ -12,6 +12,52 @@ export function getLocaleFromUrl(url: URL): Locale {
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
function normalizeLocaleTag(tag: string): string {
|
||||
return tag.trim().replace(/_/g, '-').toLowerCase();
|
||||
}
|
||||
|
||||
export function resolvePreferredLocale(
|
||||
preferredLanguages: readonly string[],
|
||||
fallbackLocale: Locale = defaultLocale,
|
||||
): Locale {
|
||||
for (const rawLanguage of preferredLanguages) {
|
||||
const language = normalizeLocaleTag(rawLanguage);
|
||||
if (!language) continue;
|
||||
|
||||
if (language.startsWith('zh')) {
|
||||
if (
|
||||
language.includes('-hant') ||
|
||||
language.includes('-tw') ||
|
||||
language.includes('-hk') ||
|
||||
language.includes('-mo')
|
||||
) {
|
||||
return 'zh_Hant';
|
||||
}
|
||||
return 'zh';
|
||||
}
|
||||
|
||||
if (language.startsWith('en')) {
|
||||
return 'en';
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackLocale;
|
||||
}
|
||||
|
||||
export function resolveLocaleFromAcceptLanguage(
|
||||
acceptLanguage: string | null,
|
||||
fallbackLocale: Locale = defaultLocale,
|
||||
): Locale {
|
||||
if (!acceptLanguage) return fallbackLocale;
|
||||
|
||||
const preferredLanguages = acceptLanguage
|
||||
.split(',')
|
||||
.map((entry) => entry.split(';')[0]?.trim() ?? '')
|
||||
.filter(Boolean);
|
||||
|
||||
return resolvePreferredLocale(preferredLanguages, fallbackLocale);
|
||||
}
|
||||
|
||||
export function getLocaleLabel(locale: Locale): string {
|
||||
const labels: Record<Locale, string> = { zh: '简体中文', zh_Hant: '繁體中文', en: 'English' };
|
||||
return labels[locale];
|
||||
@@ -47,7 +93,7 @@ const translations: Record<Locale, Translations> = {
|
||||
testimonials: { title: '用户心声', t1Quote: '在最迷茫的时候,觅爻给了我一个方向。不管结果如何,那种静下心来的过程本身就很有帮助。', t1Name: '林小姐 · 产品经理', t2Quote: '界面很清爽,没有乱七八糟的广告。每次签问都像是一次心灵的短暂旅行。', t2Name: '张先生 · 创业者', t3Quote: '我是一个程序员,原本不信这些。但试了几次后发现,这种随机性反而让我看到平时忽略的可能性。', t3Name: '王先生 · 软件工程师' },
|
||||
cta: { title: '开始你的第一次签问', subtitle: '无需注册,立即体验。让古老的智慧,为现代的你指引方向。', button: '免费开始 →' },
|
||||
footer: { brandName: '觅爻签问', desc: '以古老智慧,解读今时困惑。让每一次签问,都成为与自己对话的机会。', col1Title: '产品', col1Link1: '功能介绍', col1Link2: '定价', col2Title: '支持', col2Link1: '帮助中心', col2Link2: '联系我们', col3Title: '法律', col3Link1: '隐私政策', col3Link2: '服务条款' },
|
||||
features: { title: '功能特性', subtitle: '以古老智慧解读今时困惑,觅爻签问提供完整的易学体验', tagline: '从起卦到解读,每一步都精心设计', c1Title: '两种起卦方式', c1Desc: '手动起卦与自动起卦,灵活选择最适合你的方式。推荐使用手动起卦,卦象解读更准确。', c2Title: 'AI 解卦分析', c2Desc: '基于传统六爻卦象与周易哲学体系,结合 AI 智能分析提供深度卦象解读与建议。本功能由 DeepSeek 的 deepseek-v4-flash 模型提供支持。', c3Title: '九类问题覆盖', c3Desc: '事业、情感、财富、运势、解梦、健康、学业、寻物等九大领域,全面覆盖日常生活所问。', c4Title: '追问互动', c4Desc: '每次解卦后可深入追问一次,针对卦象细节获取更多洞见,让解读更加全面深入。', c5Title: '历史记录', c5Desc: '自动保存所有解卦记录,包括卦象详情与AI解读。随时回顾历史,追踪问题变化趋势。', c6Title: '点数系统', c6Desc: '提供灵活积分套餐:新人专享、入门补充、常用加量、高频进阶。按需购买,自由使用。' },
|
||||
features: { title: '功能特性', subtitle: '以古老智慧解读今时困惑,觅爻签问提供完整的易学体验', tagline: '从起卦到解读,每一步都精心设计', c1Title: '两种起卦方式', c1Desc: '手动起卦与自动起卦,灵活选择最适合你的方式。推荐使用手动起卦,卦象解读更准确。', c2Title: 'AI 解卦分析', c2Desc: '基于传统六爻卦象与周易哲学体系,结合 AI 智能分析提供深度卦象解读与建议。', c3Title: '九类问题覆盖', c3Desc: '事业、情感、财富、运势、解梦、健康、学业、寻物等九大领域,全面覆盖日常生活所问。', c4Title: '追问互动', c4Desc: '每次解卦后可深入追问一次,针对卦象细节获取更多洞见,让解读更加全面深入。', c5Title: '历史记录', c5Desc: '自动保存所有解卦记录,包括卦象详情与AI解读。随时回顾历史,追踪问题变化趋势。', c6Title: '点数系统', c6Desc: '提供灵活积分套餐:新人专享、入门补充、常用加量、高频进阶。按需购买,自由使用。' },
|
||||
pricing: { title: '选择适合你的套餐', subtitle: '灵活积分套餐,按需选择,随时可用', p1Name: '新人专享包', p1Badge: '限购一次', p1Price: '$1.00', p1Credits: '60 积分', p1Desc: '最适合初次体验', p2Name: '入门补充包', p2Price: '$4.99', p2Credits: '100 积分', p2Desc: '日常解卦补充', p2Detail: '适量点数补充,经济实惠之选', p3Name: '常用加量包', p3Badge: '推荐', p3Price: '$7.99', p3Credits: '210 积分', p3Desc: '最适合日常使用', p4Name: '高频进阶包', p4Price: '$12.99', p4Credits: '415 积分', p4Desc: '重度使用优选', p4Detail: '大量点数储备,超值单价', buyNow: '立即购买' },
|
||||
login: { welcome: '欢迎', subtitle: '使用邮箱验证码快速登录或注册', emailLabel: '邮箱地址', emailPlaceholder: '请输入邮箱地址', codeLabel: '验证码', codePlaceholder: '请输入验证码', sendCode: '获取验证码', submit: '登录 / 注册', agreePrefix: '我已阅读并同意', privacy: '《隐私政策》', agreeAnd: '和', terms: '《服务条款》' },
|
||||
dashboard: { brandName: '觅爻签问', navHome: '首页', navStore: '商店', navDivination: '起卦', navManual: '手动起卦', navAuto: '自动起卦', navHistory: '历史解卦', navLanguage: '语言', navSettings: '设置', greeting: '下午好', greetingSub: '今天想要探寻什么方向?', heroTitle: '开始您的卦象之旅', heroDesc: '借助AI智能,探索未来的可能。心中有问,起卦便知。', heroCta: '立即起卦', historyTitle: '历史解卦', historyViewAll: '查看全部 →', logout: '退出登录' },
|
||||
@@ -68,7 +114,7 @@ const translations: Record<Locale, Translations> = {
|
||||
testimonials: { title: '用戶心聲', t1Quote: '在最迷茫的時候,覓爻給了我一個方向。不管結果如何,那種靜下心來的過程本身就很有幫助。', t1Name: '林小姐 · 產品經理', t2Quote: '界面很清爽,沒有亂七八糟的廣告。每次簽問都像是一次心靈的短暫旅行。', t2Name: '張先生 · 創業者', t3Quote: '我是一個程序員,原本不信這些。但試了幾次後發現,這種隨機性反而讓我看到平時忽略的可能性。', t3Name: '王先生 · 軟件工程師' },
|
||||
cta: { title: '開始你的第一次簽問', subtitle: '無需註冊,立即體驗。讓古老的智慧,為現代的你指引方向。', button: '免費開始 →' },
|
||||
footer: { brandName: '覓爻簽問', desc: '以古老智慧,解讀今時困惑。讓每一次簽問,都成為與自己對話的機會。', col1Title: '產品', col1Link1: '功能介紹', col1Link2: '定價', col2Title: '支持', col2Link1: '幫助中心', col2Link2: '聯繫我們', col3Title: '法律', col3Link1: '隱私政策', col3Link2: '服務條款' },
|
||||
features: { title: '功能特性', subtitle: '以古老智慧解讀今時困惑,覓爻簽問提供完整的易學體驗', tagline: '從起卦到解讀,每一步都精心設計', c1Title: '兩種起卦方式', c1Desc: '手動起卦與自動起卦,靈活選擇最適合你的方式。推薦使用手動起卦,卦象解讀更準確。', c2Title: 'AI 解卦分析', c2Desc: '基於傳統六爻卦象與周易哲學體系,結合 AI 智能分析提供深度卦象解讀與建議。本功能由 DeepSeek 的 deepseek-v4-flash 模型提供支持。', c3Title: '九類問題覆蓋', c3Desc: '事業、情感、財富、運勢、解夢、健康、學業、尋物等九大領域,全面覆蓋日常生活所問。', c4Title: '追問互動', c4Desc: '每次解卦後可深入追問一次,針對卦象細節獲取更多洞見,讓解讀更加全面深入。', c5Title: '歷史記錄', c5Desc: '自動保存所有解卦記錄,包括卦象詳情與AI解讀。隨時回顧歷史,追蹤問題變化趨勢。', c6Title: '點數系統', c6Desc: '提供靈活積分套餐:新人專享、入門補充、常用加量、高頻進階。按需購買,自由使用。' },
|
||||
features: { title: '功能特性', subtitle: '以古老智慧解讀今時困惑,覓爻簽問提供完整的易學體驗', tagline: '從起卦到解讀,每一步都精心設計', c1Title: '兩種起卦方式', c1Desc: '手動起卦與自動起卦,靈活選擇最適合你的方式。推薦使用手動起卦,卦象解讀更準確。', c2Title: 'AI 解卦分析', c2Desc: '基於傳統六爻卦象與周易哲學體系,結合 AI 智能分析提供深度卦象解讀與建議。', c3Title: '九類問題覆蓋', c3Desc: '事業、情感、財富、運勢、解夢、健康、學業、尋物等九大領域,全面覆蓋日常生活所問。', c4Title: '追問互動', c4Desc: '每次解卦後可深入追問一次,針對卦象細節獲取更多洞見,讓解讀更加全面深入。', c5Title: '歷史記錄', c5Desc: '自動保存所有解卦記錄,包括卦象詳情與AI解讀。隨時回顧歷史,追蹤問題變化趨勢。', c6Title: '點數系統', c6Desc: '提供靈活積分套餐:新人專享、入門補充、常用加量、高頻進階。按需購買,自由使用。' },
|
||||
pricing: { title: '選擇適合你的套餐', subtitle: '靈活積分套餐,按需選擇,隨時可用', p1Name: '新人專享包', p1Badge: '限購一次', p1Price: '$1.00', p1Credits: '60 積分', p1Desc: '最適合初次體驗', p2Name: '入門補充包', p2Price: '$4.99', p2Credits: '100 積分', p2Desc: '日常解卦補充', p2Detail: '適量點數補充,經濟實惠之選', p3Name: '常用加量包', p3Badge: '推薦', p3Price: '$7.99', p3Credits: '210 積分', p3Desc: '最適合日常使用', p4Name: '高頻進階包', p4Price: '$12.99', p4Credits: '415 積分', p4Desc: '重度使用優選', p4Detail: '大量點數儲備,超值單價', buyNow: '立即購買' },
|
||||
login: { welcome: '歡迎', subtitle: '使用郵箱驗證碼快速登錄或註冊', emailLabel: '郵箱地址', emailPlaceholder: '請輸入郵箱地址', codeLabel: '驗證碼', codePlaceholder: '請輸入驗證碼', sendCode: '獲取驗證碼', submit: '登錄 / 註冊', agreePrefix: '我已閱讀並同意', privacy: '《隱私政策》', agreeAnd: '和', terms: '《服務條款》' },
|
||||
dashboard: { brandName: '覓爻簽問', navHome: '首頁', navStore: '商店', navDivination: '起卦', navManual: '手動起卦', navAuto: '自動起卦', navHistory: '歷史解卦', navLanguage: '語言', navSettings: '設置', greeting: '下午好', greetingSub: '今天想要探尋什麼方向?', heroTitle: '開始您的卦象之旅', heroDesc: '借助AI智能,探索未來的可能。心中有問,起卦便知。', heroCta: '立即起卦', historyTitle: '歷史解卦', historyViewAll: '查看全部 →', logout: '退出登錄' },
|
||||
@@ -89,7 +135,7 @@ const translations: Record<Locale, Translations> = {
|
||||
testimonials: { title: 'What Users Say', t1Quote: 'When I was most lost, MeeYao gave me direction. Regardless of the result, the process of calming down was itself very helpful.', t1Name: 'Ms. Lin · Product Manager', t2Quote: 'The interface is clean, no annoying ads. Each divination feels like a brief journey for the soul.', t2Name: 'Mr. Zhang · Entrepreneur', t3Quote: "I'm a programmer and didn't believe in this stuff. But after trying it a few times, the randomness actually helped me see possibilities I'd been overlooking.", t3Name: 'Mr. Wang · Software Engineer' },
|
||||
cta: { title: 'Begin Your First Divination', subtitle: 'No registration needed. Let ancient wisdom guide your modern life.', button: 'Start Free →' },
|
||||
footer: { brandName: 'MeeYao Divination', desc: 'Using ancient wisdom to interpret modern confusion. Let every divination become a chance to dialogue with yourself.', col1Title: 'Product', col1Link1: 'Features', col1Link2: 'Pricing', col2Title: 'Support', col2Link1: 'Help Center', col2Link2: 'Contact Us', col3Title: 'Legal', col3Link1: 'Privacy Policy', col3Link2: 'Terms of Service' },
|
||||
features: { title: 'Features', subtitle: 'Ancient wisdom meets modern confusion, MeeYao provides a complete I Ching experience', tagline: 'From casting to interpretation, every step is carefully designed', c1Title: 'Two Casting Methods', c1Desc: 'Manual and auto casting — choose what suits you best. Manual casting is recommended for more accurate readings.', c2Title: 'AI Analysis', c2Desc: 'Combining traditional Six-Line hexagrams with AI intelligence for in-depth interpretation and suggestions. This feature is powered by DeepSeek\'s deepseek-v4-flash model.', c3Title: '9 Question Categories', c3Desc: 'Career, love, wealth, fortune, dreams, health, study, lost items, and more — covering all aspects of daily life.', c4Title: 'Follow-up Questions', c4Desc: 'Ask one follow-up question after each reading for deeper insights into specific hexagram details.', c5Title: 'History', c5Desc: 'All readings are automatically saved with full hexagram details and AI interpretations. Review anytime.', c6Title: 'Credits System', c6Desc: 'Flexible credit packages: starter, basic, popular, and premium. Purchase as needed, use freely.' },
|
||||
features: { title: 'Features', subtitle: 'Ancient wisdom meets modern confusion, MeeYao provides a complete I Ching experience', tagline: 'From casting to interpretation, every step is carefully designed', c1Title: 'Two Casting Methods', c1Desc: 'Manual and auto casting — choose what suits you best. Manual casting is recommended for more accurate readings.', c2Title: 'AI Analysis', c2Desc: 'Combining traditional Six-Line hexagrams with AI intelligence for in-depth interpretation and suggestions.', c3Title: '9 Question Categories', c3Desc: 'Career, love, wealth, fortune, dreams, health, study, lost items, and more — covering all aspects of daily life.', c4Title: 'Follow-up Questions', c4Desc: 'Ask one follow-up question after each reading for deeper insights into specific hexagram details.', c5Title: 'History', c5Desc: 'All readings are automatically saved with full hexagram details and AI interpretations. Review anytime.', c6Title: 'Credits System', c6Desc: 'Flexible credit packages: starter, basic, popular, and premium. Purchase as needed, use freely.' },
|
||||
pricing: { title: 'Choose Your Plan', subtitle: 'Flexible credit packages, pay as you go', p1Name: 'Starter Pack', p1Badge: 'Once Only', p1Price: '$1.00', p1Credits: '60 credits', p1Desc: 'Best for first-timers', p2Name: 'Basic Pack', p2Price: '$4.99', p2Credits: '100 credits', p2Desc: 'Daily supplement', p2Detail: 'Affordable credit refill', p3Name: 'Popular Pack', p3Badge: 'Popular', p3Price: '$7.99', p3Credits: '210 credits', p3Desc: 'Best for daily use', p4Name: 'Premium Pack', p4Price: '$12.99', p4Credits: '415 credits', p4Desc: 'Best value per credit', p4Detail: 'Bulk credits at best unit price', buyNow: 'Buy Now' },
|
||||
login: { welcome: 'Welcome', subtitle: 'Sign in or sign up with email verification code', emailLabel: 'Email', emailPlaceholder: 'Enter your email', codeLabel: 'Verification Code', codePlaceholder: 'Enter code', sendCode: 'Send Code', submit: 'Sign In / Sign Up', agreePrefix: 'I have read and agree to the ', privacy: 'Privacy Policy', agreeAnd: ' and ', terms: 'Terms of Service' },
|
||||
dashboard: { brandName: 'MeeYao Divination', navHome: 'Home', navStore: 'Store', navDivination: 'Divination', navManual: 'Manual Cast', navAuto: 'Auto Cast', navHistory: 'History', navLanguage: 'Language', navSettings: 'Settings', greeting: 'Good afternoon', greetingSub: 'What would you like to explore today?', heroTitle: 'Begin Your Hexagram Journey', heroDesc: 'Explore future possibilities with AI. Ask your question, cast your hexagram.', heroCta: 'Start Divination', historyTitle: 'Recent Readings', historyViewAll: 'View All →', logout: 'Sign Out' },
|
||||
|
||||
@@ -15,6 +15,11 @@ export const API_ROUTES = {
|
||||
points: {
|
||||
balance: '/api/v1/points/balance',
|
||||
packages: '/api/v1/points/packages',
|
||||
redeemCode: '/api/v1/points/redeem-codes/redeem',
|
||||
},
|
||||
invite: {
|
||||
me: '/api/v1/invite/me',
|
||||
bind: '/api/v1/invite/bind',
|
||||
},
|
||||
payments: {
|
||||
creemCheckout: '/api/v1/payments/creem/checkouts',
|
||||
|
||||
@@ -151,6 +151,45 @@ export interface CreateCheckoutResponse {
|
||||
checkoutUrl: string;
|
||||
}
|
||||
|
||||
export interface RedeemCodeResponse {
|
||||
packageProductCode: string;
|
||||
packageName: string;
|
||||
credits: number;
|
||||
balanceAfter: number;
|
||||
redeemedAt: string;
|
||||
}
|
||||
|
||||
export interface InviteBindingInfo {
|
||||
canBind: boolean;
|
||||
boundInviteCode: string | null;
|
||||
boundAt: string | null;
|
||||
}
|
||||
|
||||
export interface InviteSummary {
|
||||
rewardPoints: number;
|
||||
invitedCount: number;
|
||||
rewardedCount: number;
|
||||
pendingCount: number;
|
||||
rewardedPoints: number;
|
||||
totalPotentialRewardPoints: number;
|
||||
}
|
||||
|
||||
export interface InviteReferralItem {
|
||||
referralId: string;
|
||||
inviteCode: string;
|
||||
boundAt: string;
|
||||
firstCreemPaidAt: string | null;
|
||||
rewardGranted: boolean;
|
||||
rewardGrantedAt: string | null;
|
||||
}
|
||||
|
||||
export interface InviteOverview {
|
||||
myCode: string;
|
||||
binding: InviteBindingInfo;
|
||||
summary: InviteSummary;
|
||||
items: InviteReferralItem[];
|
||||
}
|
||||
|
||||
export function getPointsBalance(): Promise<PointsBalance> {
|
||||
return authFetch<PointsBalance>(API_ROUTES.points.balance);
|
||||
}
|
||||
@@ -170,6 +209,24 @@ export function createCheckout(productCode: string): Promise<CreateCheckoutRespo
|
||||
});
|
||||
}
|
||||
|
||||
export function redeemCode(code: string): Promise<RedeemCodeResponse> {
|
||||
return authFetch<RedeemCodeResponse>(API_ROUTES.points.redeemCode, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
}
|
||||
|
||||
export function getInviteOverview(): Promise<InviteOverview> {
|
||||
return authFetch<InviteOverview>(API_ROUTES.invite.me);
|
||||
}
|
||||
|
||||
export function bindInviteCode(code: string): Promise<InviteOverview> {
|
||||
return authFetch<InviteOverview>(API_ROUTES.invite.bind, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Notifications ---
|
||||
|
||||
export interface NotificationPayloadNone {
|
||||
|
||||
+5
-5
@@ -6,6 +6,7 @@
|
||||
import { apiRequest, apiUrl, jsonHeaders, toApiError, ApiError } from './api-client';
|
||||
import { API_ROUTES } from './api-routes';
|
||||
import { clearAll as clearDataCache } from './data-client';
|
||||
import { resolvePreferredLocale } from '../i18n/utils';
|
||||
|
||||
const STORAGE_KEY = 'meeyao_auth';
|
||||
|
||||
@@ -112,14 +113,13 @@ function toAuthData(response: SessionResponse): AuthData {
|
||||
};
|
||||
}
|
||||
|
||||
function getLocaleFromPath(): string {
|
||||
if (typeof window === 'undefined') return 'zh';
|
||||
const match = window.location.pathname.match(/^\/(zh|zh_Hant|en)(?:\/|$)/);
|
||||
return match ? match[1] : 'zh';
|
||||
function getPreferredBrowserLocale(): string {
|
||||
if (typeof window === 'undefined') return 'en';
|
||||
return resolvePreferredLocale(window.navigator.languages);
|
||||
}
|
||||
|
||||
export function loginPath(): string {
|
||||
const locale = getLocaleFromPath();
|
||||
const locale = getPreferredBrowserLocale();
|
||||
return `/${locale}/login`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
bindInviteCode,
|
||||
getAgentHistory,
|
||||
getAgentHistoryByThread,
|
||||
getInviteOverview,
|
||||
getNotifications,
|
||||
getPackages,
|
||||
getPointsBalance,
|
||||
@@ -9,16 +11,19 @@ import {
|
||||
getUserProfile,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
redeemCode,
|
||||
updateUserProfile,
|
||||
updateUserSettings,
|
||||
uploadAvatar,
|
||||
type HistorySnapshot,
|
||||
type InviteOverview,
|
||||
type NotificationItem,
|
||||
type NotificationListResponse,
|
||||
type PackageInfo,
|
||||
type PackagesResponse,
|
||||
type PointsBalance,
|
||||
type ProfileSettings,
|
||||
type RedeemCodeResponse,
|
||||
type UnreadCount,
|
||||
type UpdateProfileRequest,
|
||||
type UserProfile,
|
||||
@@ -37,6 +42,7 @@ import {
|
||||
const PROFILE_TTL = 5 * 60_000;
|
||||
const POINTS_TTL = 60_000;
|
||||
const PACKAGES_TTL = 30 * 60_000;
|
||||
const INVITE_TTL = 60_000;
|
||||
const HISTORY_TTL = 60_000;
|
||||
const HISTORY_THREAD_TTL = 5 * 60_000;
|
||||
const NOTIFICATIONS_TTL = 60_000;
|
||||
@@ -45,6 +51,7 @@ const UNREAD_TTL = 30_000;
|
||||
export const profileKey = ['profile'] as const;
|
||||
export const pointsBalanceKey = ['points', 'balance'] as const;
|
||||
export const packagesKey = ['points', 'packages'] as const;
|
||||
export const inviteOverviewKey = ['invite', 'overview'] as const;
|
||||
export const historyListKey = ['history', 'list'] as const;
|
||||
export const historySummaryKey = historyListKey;
|
||||
export const historyThreadKey = (threadId: string) => ['history', 'thread', threadId] as const;
|
||||
@@ -206,6 +213,41 @@ export function invalidatePoints(): void {
|
||||
invalidate(pointsBalanceKey);
|
||||
}
|
||||
|
||||
export function getInviteResource(force = false): Promise<InviteOverview> {
|
||||
return query({
|
||||
key: inviteOverviewKey,
|
||||
ttlMs: INVITE_TTL,
|
||||
fetcher: getInviteOverview,
|
||||
staleWhileRevalidate: true,
|
||||
force,
|
||||
});
|
||||
}
|
||||
|
||||
export function useInvite(): ResourceState<InviteOverview> {
|
||||
return useResource({
|
||||
key: inviteOverviewKey,
|
||||
ttlMs: INVITE_TTL,
|
||||
fetcher: getInviteOverview,
|
||||
staleWhileRevalidate: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateInvite(): void {
|
||||
invalidate(inviteOverviewKey);
|
||||
}
|
||||
|
||||
export async function bindInviteCodeResource(code: string): Promise<InviteOverview> {
|
||||
const overview = await bindInviteCode(code);
|
||||
set(inviteOverviewKey, overview, INVITE_TTL);
|
||||
return overview;
|
||||
}
|
||||
|
||||
export async function redeemCodeResource(code: string): Promise<RedeemCodeResponse> {
|
||||
const result = await redeemCode(code);
|
||||
invalidatePoints();
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getPackagesResource(force = false): Promise<PackagesResponse> {
|
||||
return query({
|
||||
key: packagesKey,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'en' as const;
|
||||
---
|
||||
|
||||
<DashboardAppPage locale={locale} />
|
||||
@@ -1,3 +1,9 @@
|
||||
---
|
||||
return Astro.redirect('/en/');
|
||||
import { localePath, resolveLocaleFromAcceptLanguage } from '../i18n/utils';
|
||||
|
||||
const locale = resolveLocaleFromAcceptLanguage(
|
||||
Astro.request.headers.get('accept-language'),
|
||||
);
|
||||
|
||||
return Astro.redirect(localePath(locale, '/'));
|
||||
---
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
import { localePath, resolveLocaleFromAcceptLanguage } from '../i18n/utils';
|
||||
|
||||
const locale = resolveLocaleFromAcceptLanguage(
|
||||
Astro.request.headers.get('accept-language'),
|
||||
);
|
||||
|
||||
return Astro.redirect(localePath(locale, '/login'));
|
||||
---
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'zh' as const;
|
||||
---
|
||||
|
||||
<DashboardAppPage locale={locale} />
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'zh_Hant' as const;
|
||||
---
|
||||
|
||||
<DashboardAppPage locale={locale} />
|
||||
Reference in New Issue
Block a user