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>
@@ -0,0 +1,326 @@
"use client";
import { useTranslations } from "next-intl";
import { useState } from "react";
import {
deleteDailyReward,
setDailyRewardEnabled,
upsertDailyReward,
} from "@/actions/admin-daily-rewards";
import { ConfirmDialog } from "@/components/admin/confirm-dialog";
import { StatusCard } from "@/components/admin/dashboard";
import {
CurrencyIcon,
type CurrencyKind,
} from "@/components/shared/currency-icon";
import { Button } from "@/components/ui/button";
import { useServerAction } from "@/hooks/use-server-action";
const CURRENCIES = ["credits", "duckets", "diamonds", "points"] as const;
function asCurrencyKind(value: string): CurrencyKind {
return (CURRENCIES as readonly string[]).includes(value)
? (value as CurrencyKind)
: "duckets";
}
export type DailyRewardEditRow = {
id: string;
day: number;
currency: string;
amount: number;
};
export function DailyRewardsClient({
rows,
enabled,
canEdit,
claimsToday,
dayTotal,
}: {
rows: DailyRewardEditRow[];
enabled: boolean;
canEdit: boolean;
claimsToday: number | null;
dayTotal: number | null;
}) {
const t = useTranslations("pages.admin.dailyRewards");
const { run, isPending } = useServerAction();
const [editingRow, setEditingRow] = useState<DailyRewardEditRow | null>(null);
const [pendingDelete, setPendingDelete] = useState<DailyRewardEditRow | null>(
null,
);
// "Add day" draft row (id-less until saved).
const [draft, setDraft] = useState({
day: String(rows.reduce((max, r) => Math.max(max, r.day), 0) + 1),
currency: "duckets",
amount: "50",
});
const [editDraft, setEditDraft] = useState({ currency: "", amount: "" });
function handleSave() {
if (!canEdit) return;
run(
() =>
upsertDailyReward({
day: draft.day,
currency: draft.currency,
amount: draft.amount,
}),
{
successMessage: t("created"),
errorMessage: t("saveError"),
},
);
}
function handleToggle() {
if (!canEdit) return;
run(() => setDailyRewardEnabled({ enabled: !enabled }), {
successMessage: t("saved"),
errorMessage: t("saveError"),
});
}
function handleStartEdit(row: DailyRewardEditRow) {
setEditingRow(row);
setEditDraft({ currency: row.currency, amount: String(row.amount) });
}
function handleConfirmUpdate() {
if (!editingRow || !canEdit) return;
run(
() =>
upsertDailyReward({
id: editingRow.id,
day: String(editingRow.day),
currency: editDraft.currency,
amount: editDraft.amount,
}),
{
successMessage: t("updated"),
errorMessage: t("saveError"),
onSuccess: () => setEditingRow(null),
},
);
}
function handleConfirmDelete() {
if (!pendingDelete) return;
const id = pendingDelete.id;
run(() => deleteDailyReward({ id }), {
successMessage: t("deleted"),
errorMessage: t("deleteError"),
onSuccess: () => setPendingDelete(null),
});
}
return (
<main>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("enabled")}
value={enabled ? t("yes") : t("no")}
icon="📅"
/>
<StatusCard label={t("scheduleDays")} value={rows.length} icon="🗓️" />
<StatusCard label={t("claimsToday")} value={claimsToday} icon="🎁" />
<StatusCard label={t("claimsTotal")} value={dayTotal} icon="💰" />
</div>
{canEdit && (
<section className="mb-6 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-5">
<div className="flex flex-wrap items-center justify-between gap-4">
<h2 className="text-lg font-semibold">{t("status")}</h2>
<Button
type="button"
variant={enabled ? "destructive" : "default"}
onClick={handleToggle}
disabled={isPending}
>
{t(enabled ? "disable" : "enable")}
</Button>
</div>
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
{t("statusHint")}
</p>
</section>
)}
<section className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-5">
<h2 className="mb-4 text-lg font-semibold">{t("schedule")}</h2>
<div className="overflow-x-auto">
<table className="w-full min-w-[480px] text-left text-sm">
<thead>
<tr className="border-b border-[var(--color-border)]">
<th className="p-3 font-medium">{t("day")}</th>
<th className="p-3 font-medium">{t("reward")}</th>
<th className="p-3 font-medium">{t("actions")}</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">
{t("day", { day: row.day })}
</td>
<td className="p-3">
{editingRow?.id === row.id ? (
<div className="flex flex-wrap gap-2">
<select
value={editDraft.currency}
onChange={(e) =>
setEditDraft((d) => ({
...d,
currency: e.target.value,
}))
}
>
{CURRENCIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<input
type="number"
min="1"
value={editDraft.amount}
onChange={(e) =>
setEditDraft((d) => ({
...d,
amount: e.target.value,
}))
}
/>
</div>
) : (
<span className="inline-flex items-center gap-2">
<CurrencyIcon
kind={asCurrencyKind(row.currency)}
size={18}
alt=""
/>
{row.amount} {row.currency}
</span>
)}
</td>
<td className="p-3">
{editingRow?.id === row.id ? (
<div className="flex gap-2">
<Button
type="button"
onClick={handleConfirmUpdate}
disabled={isPending}
>
{t("save")}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => setEditingRow(null)}
>
{t("cancel")}
</Button>
</div>
) : (
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => handleStartEdit(row)}
>
{t("edit")}
</Button>
<Button
type="button"
variant="destructive"
onClick={() => setPendingDelete(row)}
>
{t("delete")}
</Button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{canEdit && (
<div className="mt-5 grid max-w-2xl grid-cols-[repeat(auto-fit,minmax(140px,1fr))] gap-3 rounded-lg border border-[var(--color-border)] p-4">
<label className="grid gap-1 text-sm">
<span className="text-[var(--color-text-muted)]">
{t("addDay")}
</span>
<input
type="number"
min="1"
max="365"
value={draft.day}
onChange={(e) =>
setDraft((d) => ({ ...d, day: e.target.value }))
}
/>
</label>
<label className="grid gap-1 text-sm">
<span className="text-[var(--color-text-muted)]">
{t("reward")}
</span>
<select
value={draft.currency}
onChange={(e) =>
setDraft((d) => ({ ...d, currency: e.target.value }))
}
>
{CURRENCIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</label>
<label className="grid gap-1 text-sm">
<span className="text-[var(--color-text-muted)]">
{t("amount")}
</span>
<input
type="number"
min="1"
value={draft.amount}
onChange={(e) =>
setDraft((d) => ({ ...d, amount: e.target.value }))
}
/>
</label>
<div className="flex items-end">
<Button type="button" onClick={handleSave} disabled={isPending}>
{t("add")}
</Button>
</div>
</div>
)}
</section>
{canEdit && (
<ConfirmDialog
open={pendingDelete !== null}
onOpenChange={(open) => {
if (!open) setPendingDelete(null);
}}
title={t("confirmDeleteTitle")}
description={t("confirmDeleteBody")}
confirmLabel={t("delete")}
cancelLabel={t("cancel")}
loading={isPending}
onConfirm={handleConfirmDelete}
/>
)}
</main>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { asc, sql } from "drizzle-orm";
import { redirect } from "next/navigation";
import { db, WebsiteDailyRewardClaims, WebsiteDailyRewards } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
import {
type DailyRewardEditRow,
DailyRewardsClient,
} from "./daily-rewards-client";
export default async function AdminDailyRewards() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.DAILY_REWARDS_VIEW, session.user.rank)) {
redirect("/admin");
}
const canEdit = canAccess(
permissions,
PERMS.DAILY_REWARDS_EDIT,
session.user.rank,
);
const enabled = await siteSettings.getBool("daily_reward_enabled", true);
let rows: DailyRewardEditRow[] = [];
let claimsToday: number | null = null;
let dayTotal: number | null = null;
try {
const schedule = await db
.select({
id: WebsiteDailyRewards.id,
day: WebsiteDailyRewards.day,
currency: WebsiteDailyRewards.currency,
amount: WebsiteDailyRewards.amount,
})
.from(WebsiteDailyRewards)
.orderBy(asc(WebsiteDailyRewards.day));
rows = schedule.map((row) => ({
id: String(row.id),
day: Number(row.day),
currency: row.currency,
amount: Number(row.amount),
}));
const [[claimed], [total]] = await Promise.all([
db
.select({
value: sql<number>`COUNT(*)`,
})
.from(WebsiteDailyRewardClaims)
.where(sql`${WebsiteDailyRewardClaims.claimDate} = CURRENT_DATE`),
db
.select({ value: sql<number>`COUNT(*)` })
.from(WebsiteDailyRewardClaims),
]);
claimsToday = Number(claimed?.value ?? 0);
dayTotal = Number(total?.value ?? 0);
} catch {
rows = [];
claimsToday = null;
dayTotal = null;
}
return (
<DailyRewardsClient
rows={rows}
enabled={enabled}
canEdit={canEdit}
claimsToday={claimsToday}
dayTotal={dayTotal}
/>
);
}
+113
View File
@@ -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>
);
}