feat(referrals): add referral attribution and daily login rewards
- Track referral attribution at registration via ?ref code with same-IP and duplicate-pair guards - Add daily login rewards with streak tracking, claim flow and sendCurrency payout backed by RCON with DB fallback - Add admin pages for referral settings and the daily reward schedule - Add migration 0033 with tables, seed schedule, settings and ACL grants - Add admin.referrals.* and admin.dailyrewards.* permission slugs - Localize new copy in en, nl and it
This commit is contained in:
1 parent
463bc2cb47
commit
c3ff497050
30 files changed
+2028
-11
No files matched your search
@@ -12,8 +12,10 @@ import { SurfaceCard } from "@/components/surface-card";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl, slugify } from "@/lib/format";
|
||||
import { withPublicPagePerformance } from "@/lib/public-page-performance";
|
||||
import { loadDailyRewardState } from "@/lib/services/daily-rewards";
|
||||
import { loadUserDashboard } from "@/lib/services/user-dashboard";
|
||||
import CopyReferralButton from "./CopyReferralButton";
|
||||
import DailyRewardCard from "./DailyRewardCard";
|
||||
import DashboardTasks from "./DashboardTasks";
|
||||
import styles from "./dashboard.module.css";
|
||||
|
||||
@@ -25,13 +27,17 @@ export async function generateMetadata() {
|
||||
async function MePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ claimed?: string; error?: string }>;
|
||||
searchParams: Promise<{
|
||||
claimed?: string;
|
||||
error?: string;
|
||||
daily?: string;
|
||||
}>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
const userId = Number(session.user.id);
|
||||
if (!Number.isFinite(userId) || userId <= 0) redirect("/login");
|
||||
const [t, format, { claimed, error }] = await Promise.all([
|
||||
const [t, format, { claimed, error, daily }] = await Promise.all([
|
||||
getTranslations("pages.myDashboard"),
|
||||
getFormatter(),
|
||||
searchParams,
|
||||
@@ -51,6 +57,9 @@ async function MePage({
|
||||
</main>
|
||||
);
|
||||
}
|
||||
// Daily reward state (settings + schedule + the user's last claim). Never
|
||||
// throws: on any DB hiccup it degrades to a disabled state.
|
||||
const dailyState = await loadDailyRewardState(userId);
|
||||
const user = data.userRows[0];
|
||||
if (!user)
|
||||
return (
|
||||
@@ -147,6 +156,24 @@ async function MePage({
|
||||
{t(`errors.${errorKey}`)}
|
||||
</p>
|
||||
)}
|
||||
{daily === "claimed" && (
|
||||
<p role="status" className={styles.feedback}>
|
||||
{t("dailyClaimed")}
|
||||
</p>
|
||||
)}
|
||||
{daily &&
|
||||
daily !== "claimed" &&
|
||||
[
|
||||
"already_claimed",
|
||||
"disabled",
|
||||
"bad_config",
|
||||
"ratelimit",
|
||||
"error",
|
||||
].includes(daily) && (
|
||||
<p role="alert" className={styles.feedback}>
|
||||
{t(`dailyErrors.${daily}`)}
|
||||
</p>
|
||||
)}
|
||||
<SurfaceCard className={styles.hero}>
|
||||
<ProfileImage
|
||||
src={avatarFull}
|
||||
@@ -204,6 +231,9 @@ async function MePage({
|
||||
<DashboardTasks
|
||||
userId={userId}
|
||||
rewardAvailable={referralsRows === null ? null : canClaim}
|
||||
dailyRewardAvailable={
|
||||
dailyState.enabled && !dailyState.alreadyClaimedToday
|
||||
}
|
||||
/>
|
||||
<section aria-label={t("wallet")} className={styles.wallet}>
|
||||
{wallet.map((currency) => (
|
||||
@@ -356,6 +386,7 @@ async function MePage({
|
||||
</SurfaceCard>
|
||||
</div>
|
||||
<aside className={styles.stack}>
|
||||
<DailyRewardCard state={dailyState} />
|
||||
<SurfaceCard title={t("referrals")} bodyClassName="p-5">
|
||||
<div id="referral-reward" />
|
||||
<p>{t("referralHint")}</p>
|
||||
|
||||
Reference in new issue
Block a user