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
@@ -4,6 +4,7 @@ import {
|
||||
BadgeCheck,
|
||||
Ban,
|
||||
Calendar,
|
||||
CalendarCheck,
|
||||
CalendarDays,
|
||||
ClipboardList,
|
||||
Cog,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
FileText,
|
||||
Filter,
|
||||
Gavel,
|
||||
Gift,
|
||||
HelpCircle,
|
||||
Image,
|
||||
KeyRound,
|
||||
@@ -526,6 +528,20 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
||||
permission: PERMS.SHOP_VIEW,
|
||||
matchPrefixes: ["/admin/calendar"],
|
||||
},
|
||||
{
|
||||
href: "/admin/referrals",
|
||||
labelKey: "referrals",
|
||||
icon: Gift,
|
||||
permission: PERMS.REFERRALS_VIEW,
|
||||
matchPrefixes: ["/admin/referrals"],
|
||||
},
|
||||
{
|
||||
href: "/admin/daily-rewards",
|
||||
labelKey: "dailyRewards",
|
||||
icon: CalendarCheck,
|
||||
permission: PERMS.DAILY_REWARDS_VIEW,
|
||||
matchPrefixes: ["/admin/daily-rewards"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -48,6 +48,12 @@ export const PERMS = {
|
||||
// ── Polls Module ──
|
||||
POLLS_VIEW: "admin.polls.view",
|
||||
POLLS_EDIT: "admin.polls.edit",
|
||||
// ── Referrals Module ──
|
||||
REFERRALS_VIEW: "admin.referrals.view",
|
||||
REFERRALS_EDIT: "admin.referrals.edit",
|
||||
// ── Daily Login Rewards Module ──
|
||||
DAILY_REWARDS_VIEW: "admin.dailyrewards.view",
|
||||
DAILY_REWARDS_EDIT: "admin.dailyrewards.edit",
|
||||
// ── Notifications Module ──
|
||||
NOTIFICATIONS_VIEW: "admin.notifications.view",
|
||||
NOTIFICATIONS_EDIT: "admin.notifications.edit",
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/lib/db", () => ({ db: {} }));
|
||||
vi.mock("./site-settings", () => ({
|
||||
siteSettings: { getBool: async () => true },
|
||||
}));
|
||||
|
||||
import {
|
||||
type DailyRewardRow,
|
||||
rewardForStreak,
|
||||
shiftDayKey,
|
||||
} from "./daily-rewards";
|
||||
|
||||
describe("daily reward date math", () => {
|
||||
it("shifts day keys across month and year boundaries", () => {
|
||||
expect(shiftDayKey("2026-03-01", -1)).toBe("2026-02-28");
|
||||
expect(shiftDayKey("2026-01-01", 31)).toBe("2026-02-01");
|
||||
expect(shiftDayKey("2026-12-31", 1)).toBe("2027-01-01");
|
||||
expect(shiftDayKey("2026-02-28", 1)).toBe("2026-03-01");
|
||||
});
|
||||
});
|
||||
|
||||
describe("daily reward streak resolution", () => {
|
||||
const rewards: DailyRewardRow[] = [
|
||||
{ id: "1", day: 1, currency: "duckets", amount: 50 },
|
||||
{ id: "2", day: 2, currency: "duckets", amount: 60 },
|
||||
{ id: "5", day: 5, currency: "diamonds", amount: 2 },
|
||||
];
|
||||
|
||||
it("resolves configured days and repeats the cycle", () => {
|
||||
expect(rewardForStreak(rewards, 1)?.day).toBe(1);
|
||||
expect(rewardForStreak(rewards, 2)?.day).toBe(2);
|
||||
expect(rewardForStreak(rewards, 5)?.day).toBe(5);
|
||||
// The cycle is the highest configured day: 5 → back to day 1.
|
||||
expect(rewardForStreak(rewards, 6)?.day).toBe(1);
|
||||
expect(rewardForStreak(rewards, 7)?.day).toBe(2);
|
||||
expect(rewardForStreak(rewards, 10)?.day).toBe(5);
|
||||
});
|
||||
|
||||
it("falls back to the day-1 reward for unconfigured days", () => {
|
||||
// Days 3 and 4 are not configured in the schedule → day-1 reward.
|
||||
expect(rewardForStreak(rewards, 3)?.day).toBe(1);
|
||||
expect(rewardForStreak(rewards, 4)?.day).toBe(1);
|
||||
});
|
||||
|
||||
it("returns null for empty schedules or non-positive streaks", () => {
|
||||
expect(rewardForStreak([], 3)).toBeNull();
|
||||
expect(
|
||||
rewardForStreak(
|
||||
[{ id: "1", day: 3, currency: "credits", amount: 10 }],
|
||||
0,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user