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
@@ -0,0 +1,113 @@
|
||||
import { desc, inArray, sql } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import {
|
||||
ClaimedReferralLogs,
|
||||
db,
|
||||
Referrals,
|
||||
User,
|
||||
UserReferrals,
|
||||
} from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { type ReferralRow, ReferralsClient } from "./referrals-client";
|
||||
|
||||
export default async function AdminReferrals() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.REFERRALS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
const canEdit = canAccess(
|
||||
permissions,
|
||||
PERMS.REFERRALS_EDIT,
|
||||
session.user.rank,
|
||||
);
|
||||
|
||||
const settings = await siteSettings.getMany([
|
||||
"referrals_needed",
|
||||
"referral_reward_amount",
|
||||
"referral_reward_currency_type",
|
||||
"referrals_block_same_ip",
|
||||
]);
|
||||
|
||||
let rows: ReferralRow[] = [];
|
||||
let stats: {
|
||||
total: number | null;
|
||||
today: number | null;
|
||||
claimed: number | null;
|
||||
invited: number | null;
|
||||
} = { total: null, today: null, claimed: null, invited: null };
|
||||
|
||||
try {
|
||||
const [[total], [today], [claimed], [invited]] = await Promise.all([
|
||||
db.select({ value: sql<number>`COUNT(*)` }).from(Referrals),
|
||||
db
|
||||
.select({ value: sql<number>`COUNT(*)` })
|
||||
.from(Referrals)
|
||||
.where(sql`${Referrals.createdAt} >= CURRENT_DATE`),
|
||||
db.select({ value: sql<number>`COUNT(*)` }).from(ClaimedReferralLogs),
|
||||
db
|
||||
.select({
|
||||
value: sql<number>`COALESCE(SUM(${UserReferrals.referralsTotal}), 0)`,
|
||||
})
|
||||
.from(UserReferrals),
|
||||
]);
|
||||
stats = {
|
||||
total: Number(total?.value ?? 0),
|
||||
today: Number(today?.value ?? 0),
|
||||
claimed: Number(claimed?.value ?? 0),
|
||||
invited: Number(invited?.value ?? 0),
|
||||
};
|
||||
|
||||
const referralRows = await db
|
||||
.select({
|
||||
id: Referrals.id,
|
||||
userId: Referrals.userId,
|
||||
referredUserId: Referrals.referredUserId,
|
||||
referredUserIp: Referrals.referredUserIp,
|
||||
createdAt: Referrals.createdAt,
|
||||
})
|
||||
.from(Referrals)
|
||||
.orderBy(desc(Referrals.id))
|
||||
.limit(100);
|
||||
|
||||
const userIds = new Set<number>();
|
||||
for (const r of referralRows) {
|
||||
userIds.add(Number(r.userId));
|
||||
userIds.add(Number(r.referredUserId));
|
||||
}
|
||||
const names = new Map<number, string>();
|
||||
if (userIds.size > 0) {
|
||||
const userRows = await db
|
||||
.select({ id: User.id, username: User.username })
|
||||
.from(User)
|
||||
.where(inArray(User.id, [...userIds]));
|
||||
for (const u of userRows) names.set(Number(u.id), u.username);
|
||||
}
|
||||
rows = referralRows.map((r) => ({
|
||||
id: String(r.id),
|
||||
inviter: names.get(Number(r.userId)) ?? String(r.userId),
|
||||
invitee: names.get(Number(r.referredUserId)) ?? String(r.referredUserId),
|
||||
ip: r.referredUserIp,
|
||||
createdAt: r.createdAt?.toISOString() ?? null,
|
||||
}));
|
||||
} catch {
|
||||
rows = [];
|
||||
stats = { total: null, today: null, claimed: null, invited: null };
|
||||
}
|
||||
|
||||
return (
|
||||
<ReferralsClient
|
||||
rows={rows}
|
||||
stats={stats}
|
||||
canEdit={canEdit}
|
||||
settings={{
|
||||
needed: settings.referrals_needed ?? "5",
|
||||
rewardAmount: settings.referral_reward_amount ?? "30",
|
||||
rewardCurrency: settings.referral_reward_currency_type ?? "diamonds",
|
||||
blockSameIp:
|
||||
(settings.referrals_block_same_ip ?? "1").toLowerCase() === "1" ||
|
||||
(settings.referrals_block_same_ip ?? "1").toLowerCase() === "true",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { updateReferralSettings } from "@/actions/admin-referrals";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
export type ReferralRow = {
|
||||
id: string;
|
||||
inviter: string;
|
||||
invitee: string;
|
||||
ip: string;
|
||||
createdAt: string | null;
|
||||
};
|
||||
|
||||
export function ReferralsClient({
|
||||
rows,
|
||||
stats,
|
||||
canEdit,
|
||||
settings,
|
||||
}: {
|
||||
rows: ReferralRow[];
|
||||
stats: {
|
||||
total: number | null;
|
||||
today: number | null;
|
||||
claimed: number | null;
|
||||
invited: number | null;
|
||||
};
|
||||
canEdit: boolean;
|
||||
settings: {
|
||||
needed: string;
|
||||
rewardAmount: string;
|
||||
rewardCurrency: string;
|
||||
blockSameIp: boolean;
|
||||
};
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.referrals");
|
||||
const { run, isPending } = useServerAction();
|
||||
const [needed, setNeeded] = useState(settings.needed);
|
||||
const [rewardAmount, setRewardAmount] = useState(settings.rewardAmount);
|
||||
const [rewardCurrency, setRewardCurrency] = useState(settings.rewardCurrency);
|
||||
const [blockSameIp, setBlockSameIp] = useState(settings.blockSameIp);
|
||||
|
||||
function handleSettings(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!canEdit) return;
|
||||
run(
|
||||
() =>
|
||||
updateReferralSettings({
|
||||
referralsNeeded: needed,
|
||||
rewardAmount,
|
||||
rewardCurrency,
|
||||
blockSameIp: blockSameIp ? "1" : "0",
|
||||
}),
|
||||
{
|
||||
successMessage: t("saved"),
|
||||
errorMessage: t("saveError"),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard label={t("referralsToday")} value={stats.today} icon="🎁" />
|
||||
<StatusCard label={t("referralsTotal")} value={stats.total} icon="👥" />
|
||||
<StatusCard label={t("invitedTotal")} value={stats.invited} icon="🤝" />
|
||||
<StatusCard label={t("claims")} value={stats.claimed} icon="💰" />
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<section className="mb-6 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-5">
|
||||
<h2 className="mb-4 text-lg font-semibold">{t("settings")}</h2>
|
||||
<form onSubmit={handleSettings} className="grid max-w-2xl gap-4">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(170px,1fr))] gap-4">
|
||||
<label className="grid gap-1 text-sm">
|
||||
<span className="text-[var(--color-text-muted)]">
|
||||
{t("needed")}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={needed}
|
||||
onChange={(e) => setNeeded(e.target.value)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
<span className="text-[var(--color-text-muted)]">
|
||||
{t("rewardAmount")}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={rewardAmount}
|
||||
onChange={(e) => setRewardAmount(e.target.value)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
<span className="text-[var(--color-text-muted)]">
|
||||
{t("rewardCurrency")}
|
||||
</span>
|
||||
<select
|
||||
value={rewardCurrency}
|
||||
onChange={(e) => setRewardCurrency(e.target.value)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="credits">Credits</option>
|
||||
<option value="duckets">Duckets</option>
|
||||
<option value="diamonds">Diamonds</option>
|
||||
<option value="points">Points</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={blockSameIp}
|
||||
onChange={(e) => setBlockSameIp(e.target.checked)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
{t("blockSameIp")}
|
||||
</label>
|
||||
<div>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? t("saving") : t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2 className="mb-4 text-lg font-semibold">{t("recent")}</h2>
|
||||
{stats.total === null ? (
|
||||
<p role="status" className="text-[var(--color-text-muted)]">
|
||||
{t("unavailable")}
|
||||
</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p role="status" className="text-[var(--color-text-muted)]">
|
||||
{t("empty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<table className="w-full min-w-[520px] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--color-border)]">
|
||||
<th className="p-3 font-medium">{t("inviter")}</th>
|
||||
<th className="p-3 font-medium">{t("invitee")}</th>
|
||||
<th className="p-3 font-medium">{t("ip")}</th>
|
||||
<th className="p-3 font-medium">{t("date")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className="border-b border-[var(--color-border)] last:border-0"
|
||||
>
|
||||
<td className="p-3 font-medium">{row.inviter}</td>
|
||||
<td className="p-3">{row.invitee}</td>
|
||||
<td className="p-3 text-[var(--color-text-muted)]">
|
||||
{row.ip}
|
||||
</td>
|
||||
<td className="p-3 text-[var(--color-text-muted)]">
|
||||
{formatDate(
|
||||
row.createdAt ? new Date(row.createdAt) : null,
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user