Merge pull request #1 from codex/profile-redeem-code
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
profile redeem code and dev password registration
This commit was merged in pull request #1.
This commit is contained in:
@@ -8,7 +8,6 @@ import {
|
||||
Clock3,
|
||||
Coins,
|
||||
Copy,
|
||||
Crown,
|
||||
House,
|
||||
LogIn,
|
||||
MessageCircle,
|
||||
@@ -34,19 +33,17 @@ import type {
|
||||
PlatformBrowseHistoryEntry,
|
||||
ProfileDashboardCardKey,
|
||||
ProfileDashboardSummary,
|
||||
ProfileRechargeCenterResponse,
|
||||
ProfileRechargeProduct,
|
||||
ProfileReferralInviteCenterResponse,
|
||||
ProfileSaveArchiveSummary,
|
||||
RedeemProfileReferralInviteCodeResponse,
|
||||
RedeemProfileRewardCodeResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
createRpgProfileRechargeOrder,
|
||||
getRpgProfileRechargeCenter,
|
||||
getRpgProfileReferralInviteCenter,
|
||||
redeemRpgProfileReferralInviteCode,
|
||||
redeemRpgProfileRewardCode,
|
||||
} from '../../services/rpg-entry/rpgProfileClient';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
@@ -910,206 +907,68 @@ function ProfileShortcutButton({
|
||||
);
|
||||
}
|
||||
|
||||
function formatRechargePrice(priceCents: number) {
|
||||
const yuan = priceCents / 100;
|
||||
return Number.isInteger(yuan) ? `¥${yuan}` : `¥${yuan.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatMembershipDuration(days: number) {
|
||||
if (days >= 365) {
|
||||
return '365天';
|
||||
}
|
||||
|
||||
return `${days}天`;
|
||||
}
|
||||
|
||||
function AccountRechargeModal({
|
||||
center,
|
||||
activeTab,
|
||||
isLoading,
|
||||
function RewardCodeRedeemModal({
|
||||
value,
|
||||
isSubmitting,
|
||||
error,
|
||||
onTabChange,
|
||||
success,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onClose,
|
||||
onSelectProduct,
|
||||
}: {
|
||||
center: ProfileRechargeCenterResponse | null;
|
||||
activeTab: 'points' | 'membership';
|
||||
isLoading: boolean;
|
||||
isSubmitting: string | null;
|
||||
value: string;
|
||||
isSubmitting: boolean;
|
||||
error: string | null;
|
||||
onTabChange: (tab: 'points' | 'membership') => void;
|
||||
success: string | null;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onClose: () => void;
|
||||
onSelectProduct: (product: ProfileRechargeProduct) => void;
|
||||
}) {
|
||||
const visibleProducts =
|
||||
activeTab === 'points'
|
||||
? (center?.pointProducts ?? [])
|
||||
: (center?.membershipProducts ?? []);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5">
|
||||
<div className="relative max-h-[min(92vh,46rem)] w-full max-w-[32rem] overflow-hidden rounded-[1.35rem] bg-[linear-gradient(180deg,#fff7f8_0%,#ffffff_34%,#f8fafc_100%)] text-zinc-950 shadow-2xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/78 text-[#ff4056] shadow-sm"
|
||||
aria-label="关闭账户充值"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="max-h-[min(92vh,46rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5">
|
||||
<div className="pr-10">
|
||||
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
|
||||
WALLET
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-black">账户充值</div>
|
||||
<div className="mt-2 inline-flex items-center gap-2 rounded-full border border-rose-100 bg-white/70 px-3 py-1.5 text-xs font-bold text-zinc-600">
|
||||
<Coins className="h-3.5 w-3.5 text-[#ff4056]" />
|
||||
<span>
|
||||
{center ? `${center.walletBalance}叙世币` : '叙世币账户'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 rounded-2xl bg-zinc-100/90 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange('points')}
|
||||
className={`rounded-xl px-4 py-3 text-sm font-black transition ${
|
||||
activeTab === 'points'
|
||||
? 'bg-white text-[#ff4056] shadow-sm'
|
||||
: 'text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
叙世币充值
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange('membership')}
|
||||
className={`rounded-xl px-4 py-3 text-sm font-black transition ${
|
||||
activeTab === 'membership'
|
||||
? 'bg-white text-[#ff4056] shadow-sm'
|
||||
: 'text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
会员卡充值
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-50 flex items-center justify-center px-4 py-6">
|
||||
<div className="platform-recharge-modal w-full max-w-sm overflow-hidden rounded-[1.4rem]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div className="text-base font-black">兑换码</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭兑换码"
|
||||
onClick={onClose}
|
||||
className="platform-modal-close flex h-9 w-9 items-center justify-center rounded-full"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3 px-5 py-5">
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
className="platform-profile-input w-full rounded-2xl px-4 py-3 text-sm font-semibold uppercase tracking-normal"
|
||||
placeholder="输入兑换码"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={isSubmitting || !value.trim()}
|
||||
className="platform-primary-button w-full rounded-2xl px-4 py-3 text-sm font-black disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? '兑换中' : '兑换'}
|
||||
</button>
|
||||
{error ? (
|
||||
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
||||
<div className="platform-profile-error rounded-2xl px-3 py-2 text-xs font-semibold">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{Array.from({ length: activeTab === 'points' ? 6 : 3 }).map(
|
||||
(_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-24 animate-pulse rounded-xl bg-zinc-100"
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{success ? (
|
||||
<div className="platform-profile-success rounded-2xl px-3 py-2 text-xs font-semibold">
|
||||
{success}
|
||||
</div>
|
||||
) : activeTab === 'points' ? (
|
||||
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{visibleProducts.map((product) => (
|
||||
<button
|
||||
type="button"
|
||||
key={product.productId}
|
||||
disabled={Boolean(isSubmitting)}
|
||||
onClick={() => onSelectProduct(product)}
|
||||
className="relative min-h-[8.45rem] overflow-hidden rounded-2xl border border-zinc-200 bg-white text-center shadow-sm transition hover:border-[#ff4056] disabled:opacity-70"
|
||||
>
|
||||
<div className="h-8 bg-[#ff4056] px-2 py-1.5 text-xs font-black text-white">
|
||||
{product.badgeLabel}
|
||||
</div>
|
||||
<div className="px-2 py-3">
|
||||
<div className="text-xl font-black">
|
||||
{product.pointsAmount}叙世币
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
金额:{formatRechargePrice(product.priceCents)}
|
||||
</div>
|
||||
<div className="my-2 h-px bg-zinc-100" />
|
||||
<div className="text-sm text-zinc-800">
|
||||
{isSubmitting === product.productId
|
||||
? '处理中'
|
||||
: product.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-3">
|
||||
{visibleProducts.map((product) => (
|
||||
<button
|
||||
type="button"
|
||||
key={product.productId}
|
||||
disabled={Boolean(isSubmitting)}
|
||||
onClick={() => onSelectProduct(product)}
|
||||
className="group relative min-h-[7.75rem] overflow-hidden rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-left shadow-sm transition hover:border-[#ff4056] hover:shadow-md disabled:opacity-70"
|
||||
>
|
||||
<div className="absolute right-0 top-0 h-16 w-16 rounded-bl-[2rem] bg-[#ff4056]/10 transition group-hover:bg-[#ff4056]/16" />
|
||||
<div className="relative">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-lg font-black">
|
||||
{product.title}
|
||||
</div>
|
||||
<div className="mt-1 text-xs font-bold text-zinc-500">
|
||||
{formatMembershipDuration(product.durationDays)}
|
||||
</div>
|
||||
</div>
|
||||
<Crown className="h-5 w-5 shrink-0 text-[#ff4056]" />
|
||||
</div>
|
||||
<div className="mt-4 text-2xl font-black text-[#ff4056]">
|
||||
{formatRechargePrice(product.priceCents)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs font-semibold text-zinc-500">
|
||||
{isSubmitting === product.productId
|
||||
? '处理中'
|
||||
: product.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-5 overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm">
|
||||
<div className="border-b border-zinc-200 px-4 py-3 text-sm font-black">
|
||||
用户等级特权
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="grid min-w-[30rem] grid-cols-5 text-center text-sm">
|
||||
{center?.benefits.map((benefit) => (
|
||||
<div key={benefit.benefitName} className="contents">
|
||||
<div className="border-b border-zinc-100 bg-zinc-50 px-2 py-3 text-left text-zinc-600">
|
||||
{benefit.benefitName}
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 text-zinc-500">
|
||||
{benefit.normalValue}
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-emerald-700">
|
||||
{benefit.monthValue}
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-rose-500">
|
||||
{benefit.seasonValue}
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-amber-600">
|
||||
{benefit.yearValue}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1294,16 +1153,13 @@ export function RpgEntryHomeView({
|
||||
const authUi = useAuthUi();
|
||||
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
|
||||
const [mobileSearchKeyword, setMobileSearchKeyword] = useState('');
|
||||
const [isRechargeOpen, setIsRechargeOpen] = useState(false);
|
||||
const [rechargeTab, setRechargeTab] = useState<'points' | 'membership'>(
|
||||
'points',
|
||||
const [isRewardCodeOpen, setIsRewardCodeOpen] = useState(false);
|
||||
const [rewardCodeInput, setRewardCodeInput] = useState('');
|
||||
const [isSubmittingRewardCode, setIsSubmittingRewardCode] = useState(false);
|
||||
const [rewardCodeError, setRewardCodeError] = useState<string | null>(null);
|
||||
const [rewardCodeSuccess, setRewardCodeSuccess] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [rechargeCenter, setRechargeCenter] =
|
||||
useState<ProfileRechargeCenterResponse | null>(null);
|
||||
const [rechargeError, setRechargeError] = useState<string | null>(null);
|
||||
const [isLoadingRecharge, setIsLoadingRecharge] = useState(false);
|
||||
const [submittingRechargeProductId, setSubmittingRechargeProductId] =
|
||||
useState<string | null>(null);
|
||||
const [profilePopupPanel, setProfilePopupPanel] =
|
||||
useState<ProfilePopupPanel | null>(null);
|
||||
const [referralCenter, setReferralCenter] =
|
||||
@@ -1401,36 +1257,6 @@ export function RpgEntryHomeView({
|
||||
}
|
||||
authUi?.openLoginModal();
|
||||
};
|
||||
const openRechargePanel = () => {
|
||||
setIsRechargeOpen(true);
|
||||
setRechargeError(null);
|
||||
setIsLoadingRecharge(true);
|
||||
void getRpgProfileRechargeCenter()
|
||||
.then(setRechargeCenter)
|
||||
.catch((error: unknown) => {
|
||||
setRechargeCenter(null);
|
||||
setRechargeError(
|
||||
error instanceof Error ? error.message : '读取账户充值失败',
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoadingRecharge(false));
|
||||
};
|
||||
const submitRechargeProduct = (product: ProfileRechargeProduct) => {
|
||||
if (submittingRechargeProductId) {
|
||||
return;
|
||||
}
|
||||
setSubmittingRechargeProductId(product.productId);
|
||||
setRechargeError(null);
|
||||
void createRpgProfileRechargeOrder(product.productId)
|
||||
.then((response) => {
|
||||
setRechargeCenter(response.center);
|
||||
void onRechargeSuccess?.();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setRechargeError(error instanceof Error ? error.message : '充值失败');
|
||||
})
|
||||
.finally(() => setSubmittingRechargeProductId(null));
|
||||
};
|
||||
const openProfilePopupPanel = (panel: ProfilePopupPanel) => {
|
||||
setProfilePopupPanel(panel);
|
||||
setReferralError(null);
|
||||
@@ -1486,6 +1312,30 @@ export function RpgEntryHomeView({
|
||||
})
|
||||
.finally(() => setIsSubmittingReferral(false));
|
||||
};
|
||||
const openRewardCodeModal = () => {
|
||||
setIsRewardCodeOpen(true);
|
||||
setRewardCodeError(null);
|
||||
setRewardCodeSuccess(null);
|
||||
};
|
||||
const submitRewardCode = () => {
|
||||
if (isSubmittingRewardCode || !rewardCodeInput.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmittingRewardCode(true);
|
||||
setRewardCodeError(null);
|
||||
setRewardCodeSuccess(null);
|
||||
void redeemRpgProfileRewardCode(rewardCodeInput)
|
||||
.then((response: RedeemProfileRewardCodeResponse) => {
|
||||
setRewardCodeInput('');
|
||||
setRewardCodeSuccess(`已到账 ${response.amountGranted} 叙世币`);
|
||||
void onRechargeSuccess?.();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setRewardCodeError(error instanceof Error ? error.message : '兑换失败');
|
||||
})
|
||||
.finally(() => setIsSubmittingRewardCode(false));
|
||||
};
|
||||
const submitDesktopSearch = () => {
|
||||
const keyword = desktopSearchKeyword.trim();
|
||||
if (!keyword || !onSearchPublicCode || isSearchingPublicCode) {
|
||||
@@ -1833,17 +1683,13 @@ export function RpgEntryHomeView({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openRechargePanel}
|
||||
onClick={openRewardCodeModal}
|
||||
className="platform-profile-action flex shrink-0 items-center gap-2 rounded-[1.1rem] px-3 py-2 text-left"
|
||||
>
|
||||
<Crown className="h-4 w-4" />
|
||||
<Ticket className="h-4 w-4" />
|
||||
<div>
|
||||
<div className="text-xs font-bold">会员充值</div>
|
||||
<div className="text-[10px] opacity-80">
|
||||
{rechargeCenter?.membership.status === 'active'
|
||||
? '叙世会员'
|
||||
: '普通用户'}
|
||||
</div>
|
||||
<div className="text-xs font-bold">兑换码</div>
|
||||
<div className="text-[10px] opacity-80">叙世币</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 opacity-80" />
|
||||
</button>
|
||||
@@ -2291,18 +2137,6 @@ export function RpgEntryHomeView({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{isRechargeOpen ? (
|
||||
<AccountRechargeModal
|
||||
center={rechargeCenter}
|
||||
activeTab={rechargeTab}
|
||||
isLoading={isLoadingRecharge}
|
||||
isSubmitting={submittingRechargeProductId}
|
||||
error={rechargeError}
|
||||
onTabChange={setRechargeTab}
|
||||
onClose={() => setIsRechargeOpen(false)}
|
||||
onSelectProduct={submitRechargeProduct}
|
||||
/>
|
||||
) : null}
|
||||
{profilePopupPanel ? (
|
||||
<ProfileReferralModal
|
||||
panel={profilePopupPanel}
|
||||
@@ -2395,16 +2229,15 @@ export function RpgEntryHomeView({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isRechargeOpen ? (
|
||||
<AccountRechargeModal
|
||||
center={rechargeCenter}
|
||||
activeTab={rechargeTab}
|
||||
isLoading={isLoadingRecharge}
|
||||
isSubmitting={submittingRechargeProductId}
|
||||
error={rechargeError}
|
||||
onTabChange={setRechargeTab}
|
||||
onClose={() => setIsRechargeOpen(false)}
|
||||
onSelectProduct={submitRechargeProduct}
|
||||
{isRewardCodeOpen ? (
|
||||
<RewardCodeRedeemModal
|
||||
value={rewardCodeInput}
|
||||
isSubmitting={isSubmittingRewardCode}
|
||||
error={rewardCodeError}
|
||||
success={rewardCodeSuccess}
|
||||
onChange={setRewardCodeInput}
|
||||
onSubmit={submitRewardCode}
|
||||
onClose={() => setIsRewardCodeOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{profilePopupPanel ? (
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ProfileSaveArchiveResumeResponse,
|
||||
ProfileWalletLedgerResponse,
|
||||
RedeemProfileReferralInviteCodeResponse,
|
||||
RedeemProfileRewardCodeResponse,
|
||||
RuntimeSettings,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
@@ -125,6 +126,22 @@ export function redeemRpgProfileReferralInviteCode(
|
||||
);
|
||||
}
|
||||
|
||||
export function redeemRpgProfileRewardCode(
|
||||
code: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRpgRuntimeJson<RedeemProfileRewardCodeResponse>(
|
||||
'/profile/redeem-codes/redeem',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code }),
|
||||
},
|
||||
'兑换失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function getRpgProfilePlayStats(options: RuntimeRequestOptions = {}) {
|
||||
return requestRpgRuntimeJson<ProfilePlayStatsResponse>(
|
||||
'/profile/play-stats',
|
||||
|
||||
Reference in New Issue
Block a user