feat(referrals): add referral attribution and daily login rewards
CI / check (push) Successful in 4m25s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m54s

- 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:
openhands committed 2026-09-20 12:29:01 +02:00
1 parent 463bc2cb47
commit c3ff497050
30 files changed
+2028 -11

No files matched your search

+94
View File
@@ -0,0 +1,94 @@
import { getTranslations } from "next-intl/server";
import { claimDailyReward } from "@/actions/daily-reward";
import { CurrencyIcon } from "@/components/shared/currency-icon";
import { SurfaceCard } from "@/components/surface-card";
import {
type DailyRewardState,
rewardForStreak,
} from "@/lib/services/daily-rewards";
import styles from "./dashboard.module.css";
export default async function DailyRewardCard({
state,
}: {
state: DailyRewardState;
}) {
const t = await getTranslations("pages.myDashboard");
if (!state.enabled) {
return (
<SurfaceCard title={t("dailyRewardTitle")} bodyClassName="p-5">
<p className="muted" role="status">
{t("dailyRewardDisabled")}
</p>
</SurfaceCard>
);
}
if (state.rewards.length === 0) {
return (
<SurfaceCard title={t("dailyRewardTitle")} bodyClassName="p-5">
<p className="muted" role="status">
{t("dailyRewardUnavailable")}
</p>
</SurfaceCard>
);
}
const upcoming = state.alreadyClaimedToday
? rewardForStreak(state.rewards, state.nextStreak + 1)
: state.nextReward;
return (
<SurfaceCard title={t("dailyRewardTitle")} bodyClassName="p-5">
<div id="daily-reward" />
<p>{t("dailyRewardHint")}</p>
<ul className={styles.dailyTrack}>
{state.rewards.map((reward) => (
<li
key={reward.id}
className={`${styles.dailyCell}${
!state.alreadyClaimedToday && upcoming?.day === reward.day
? ` ${styles.dailyCellNext}`
: ""
}`}
>
<span>{t("dailyRewardDay", { day: reward.day })}</span>
<CurrencyIcon kind={reward.currency} size={20} alt="" />
<strong>{reward.amount}</strong>
</li>
))}
</ul>
<p className={styles.dailyStreak}>
{t("dailyRewardStreak", { count: state.nextStreak })}
</p>
{upcoming && (
<p className={styles.reward}>
<CurrencyIcon kind={upcoming.currency} size={20} alt="" />
{t(
state.alreadyClaimedToday
? "dailyRewardTomorrow"
: "dailyRewardNextClaim",
{
amount: upcoming.amount,
currency: t.has(`currencies.${upcoming.currency}`)
? t(`currencies.${upcoming.currency}`)
: upcoming.currency,
},
)}
</p>
)}
{state.alreadyClaimedToday ? (
<p className="muted" role="status">
{t("dailyRewardClaimedToday")}
</p>
) : (
<form action={claimDailyReward} className={styles.claim}>
<button type="submit" className="btn btn-primary">
{t("dailyRewardClaim")}
</button>
</form>
)}
</SurfaceCard>
);
}
+5
View File
@@ -8,9 +8,12 @@ import styles from "./dashboard.module.css";
export default async function DashboardTasks({
userId,
rewardAvailable,
dailyRewardAvailable,
}: {
userId: number;
rewardAvailable: boolean | null;
/** Claimable daily login reward (false when claimed/disabled or loader down). */
dailyRewardAvailable?: boolean;
}) {
const [data, t, format] = await Promise.all([
loadDashboardTasks(userId),
@@ -41,6 +44,8 @@ export default async function DashboardTasks({
});
if (rewardAvailable)
items.push({ href: "#referral-reward", label: t("reward") });
if (dailyRewardAvailable)
items.push({ href: "#daily-reward", label: t("dailyReward") });
const unavailable =
data.support === null ||
data.friendRequests === null ||
+44
View File
@@ -342,3 +342,47 @@
.tasks a:hover {
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
}
.dailyTrack {
list-style: none;
margin: 12px 0 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(58px, 1fr));
gap: 8px;
}
.dailyCell {
display: grid;
justify-items: center;
gap: 4px;
padding: 10px 6px;
border: 1px solid var(--color-border);
border-radius: 10px;
font-size: 0.75rem;
min-width: 0;
}
.dailyCell span {
color: var(--color-text-muted);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.dailyCell strong {
font-size: 0.9rem;
font-variant-numeric: tabular-nums;
}
.dailyCellNext {
border-color: var(--color-primary);
background: color-mix(
in srgb,
var(--color-primary) 10%,
var(--color-surface)
);
}
.dailyStreak {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
font-size: 0.9rem;
font-weight: 600;
}
+33 -2
View File
@@ -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>