diff --git a/src/db/schema.ts b/src/db/schema.ts
index eb61351b..01e53912 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -714,6 +714,39 @@ export const ClaimedReferralLogs = mysqlTable("claimed_referral_logs", {
updatedAt: timestamp("updated_at"),
});
+export const WebsiteDailyRewards = mysqlTable("website_daily_rewards", {
+ id: int("id", { unsigned: true }).autoincrement().primaryKey().notNull(),
+ day: int("day", { unsigned: true }).notNull().default(1),
+ currency: varchar("currency", { length: 20 }).notNull().default("credits"),
+ amount: int("amount").notNull().default(0),
+ createdAt: datetime("created_at", { fsp: 3 })
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP(3)`),
+ updatedAt: datetime("updated_at", { fsp: 3 })
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP(3)`),
+});
+
+export const WebsiteDailyRewardClaims = mysqlTable(
+ "website_daily_reward_claims",
+ {
+ id: bigint("id", { mode: "bigint", unsigned: true })
+ .autoincrement()
+ .primaryKey()
+ .notNull(),
+ userId: int("user_id").notNull(),
+ claimDate: dateAsDate("claim_date").notNull(),
+ streak: int("streak", { unsigned: true }).notNull().default(1),
+ rewardDay: int("reward_day", { unsigned: true }).notNull().default(1),
+ currency: varchar("currency", { length: 20 }).notNull().default("credits"),
+ amount: int("amount").notNull().default(0),
+ createdAt: datetime("created_at", { fsp: 3 })
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP(3)`),
+ },
+ (t) => [uniqueIndex("daily_claim_user_date_uk").on(t.userId, t.claimDate)],
+);
+
export const WebsiteHousekeepingPermissions = mysqlTable(
"website_housekeeping_permissions",
{
diff --git a/src/features/housekeeping/domains/economy/manifest.ts b/src/features/housekeeping/domains/economy/manifest.ts
index 8eddf3b5..9642c96e 100644
--- a/src/features/housekeeping/domains/economy/manifest.ts
+++ b/src/features/housekeeping/domains/economy/manifest.ts
@@ -15,6 +15,10 @@ export const economyManifest = {
PERMS.SHOP_VIEW,
PERMS.CATALOG_EDIT,
PERMS.SHOP_EDIT,
+ PERMS.REFERRALS_VIEW,
+ PERMS.REFERRALS_EDIT,
+ PERMS.DAILY_REWARDS_VIEW,
+ PERMS.DAILY_REWARDS_EDIT,
),
routes: [],
searchProviders: [],
diff --git a/src/features/housekeeping/foundation/foundation-source-contract.test.ts b/src/features/housekeeping/foundation/foundation-source-contract.test.ts
index 14856092..f0d1957e 100644
--- a/src/features/housekeeping/foundation/foundation-source-contract.test.ts
+++ b/src/features/housekeeping/foundation/foundation-source-contract.test.ts
@@ -621,15 +621,15 @@ describe("housekeeping foundation completion contracts", () => {
expect(html).toContain(`>${sentinel}`);
});
- it("validates the complete 144-row migration matrix without issues", () => {
+ it("validates the complete 146-row migration matrix without issues", () => {
const discovered = discoverLegacyPages();
const issues = validateMigrationEntries(
discovered,
HOUSEKEEPING_MIGRATION_MATRIX,
);
- expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(144);
- expect(discovered).toHaveLength(144);
+ expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(146);
+ expect(discovered).toHaveLength(146);
expect(issues).toEqual([]);
});
});
diff --git a/src/features/housekeeping/foundation/registry.test.ts b/src/features/housekeeping/foundation/registry.test.ts
index 2af60855..70450059 100644
--- a/src/features/housekeeping/foundation/registry.test.ts
+++ b/src/features/housekeeping/foundation/registry.test.ts
@@ -146,6 +146,10 @@ const expectedManifests = [
PERMS.SHOP_VIEW,
PERMS.CATALOG_EDIT,
PERMS.SHOP_EDIT,
+ PERMS.REFERRALS_VIEW,
+ PERMS.REFERRALS_EDIT,
+ PERMS.DAILY_REWARDS_VIEW,
+ PERMS.DAILY_REWARDS_EDIT,
],
routes: [],
},
diff --git a/src/features/housekeeping/migration/discover-legacy-pages.test.ts b/src/features/housekeeping/migration/discover-legacy-pages.test.ts
index 244eb52d..7b783ffb 100644
--- a/src/features/housekeeping/migration/discover-legacy-pages.test.ts
+++ b/src/features/housekeeping/migration/discover-legacy-pages.test.ts
@@ -29,12 +29,22 @@ describe("discoverLegacyPages", () => {
it("discovers the exact legacy administration inventory", () => {
const pages = discoverLegacyPages();
- expect(pages).toHaveLength(144);
+ expect(pages).toHaveLength(146);
expect(pages).toContainEqual({
surface: "admin",
legacyPath: "/admin/users/:id/edit",
sourceFile: "src/app/admin/users/[id]/edit/page.tsx",
});
+ expect(pages).toContainEqual({
+ surface: "admin",
+ legacyPath: "/admin/referrals",
+ sourceFile: "src/app/admin/referrals/page.tsx",
+ });
+ expect(pages).toContainEqual({
+ surface: "admin",
+ legacyPath: "/admin/daily-rewards",
+ sourceFile: "src/app/admin/daily-rewards/page.tsx",
+ });
expect(pages).toContainEqual({
surface: "mod",
legacyPath: "/mod/cfh/:id",
diff --git a/src/features/housekeeping/migration/economy.test.ts b/src/features/housekeeping/migration/economy.test.ts
index e93e9e76..ed6fcb50 100644
--- a/src/features/housekeeping/migration/economy.test.ts
+++ b/src/features/housekeeping/migration/economy.test.ts
@@ -16,6 +16,8 @@ const ECONOMY_PREFIXES = [
"/admin/achievements",
"/admin/sounds",
"/admin/calendar",
+ "/admin/referrals",
+ "/admin/daily-rewards",
] as const;
describe("economyMigrationEntries", () => {
diff --git a/src/features/housekeeping/migration/economy.ts b/src/features/housekeeping/migration/economy.ts
index d93005f1..5f0a6134 100644
--- a/src/features/housekeeping/migration/economy.ts
+++ b/src/features/housekeeping/migration/economy.ts
@@ -419,4 +419,46 @@ export const economyMigrationEntries: readonly MigrationEntry[] = [
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
+ plannedEconomyEntry({
+ surface: "admin",
+ legacyPath: "/admin/referrals",
+ sourceFile: "src/app/admin/referrals/page.tsx",
+ targetPath: "/admin/economy/growth/referrals",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.REFERRALS_VIEW],
+ mutate: [PERMS.REFERRALS_EDIT],
+ },
+ dependencies: {
+ queries: ["Referrals", "User", "SiteSettings"],
+ mutations: ["updateReferralSettings"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: [
+ "Attribution tracked at registration via referral code; payouts handled by existing reward cron.",
+ ],
+ }),
+ plannedEconomyEntry({
+ surface: "admin",
+ legacyPath: "/admin/daily-rewards",
+ sourceFile: "src/app/admin/daily-rewards/page.tsx",
+ targetPath: "/admin/economy/growth/daily-rewards",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.DAILY_REWARDS_VIEW],
+ mutate: [PERMS.DAILY_REWARDS_EDIT],
+ },
+ dependencies: {
+ queries: ["WebsiteDailyRewards", "WebsiteDailyRewardClaims"],
+ mutations: ["upsertDailyReward", "deleteDailyReward"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: [
+ "Daily login rewards replace the legacy daily duckets script; claims paid via sendCurrency.",
+ ],
+ }),
];
diff --git a/src/features/housekeeping/migration/matrix.test.ts b/src/features/housekeeping/migration/matrix.test.ts
index a6bdf4e8..1268fcaf 100644
--- a/src/features/housekeeping/migration/matrix.test.ts
+++ b/src/features/housekeeping/migration/matrix.test.ts
@@ -4,10 +4,10 @@ import { HOUSEKEEPING_MIGRATION_MATRIX } from "./matrix";
import { validateMigrationEntries } from "./validate-matrix";
describe("HOUSEKEEPING_MIGRATION_MATRIX", () => {
- it("covers all 144 legacy pages exactly once", () => {
+ it("covers all 146 legacy pages exactly once", () => {
const discovered = discoverLegacyPages();
- expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(144);
+ expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(146);
expect(
validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX),
).toEqual([]);
diff --git a/src/lib/admin-nav.ts b/src/lib/admin-nav.ts
index a5353486..f0668c25 100644
--- a/src/lib/admin-nav.ts
+++ b/src/lib/admin-nav.ts
@@ -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"],
+ },
],
},
{
diff --git a/src/lib/permission-slugs.ts b/src/lib/permission-slugs.ts
index 659a6cb4..9fa96be0 100644
--- a/src/lib/permission-slugs.ts
+++ b/src/lib/permission-slugs.ts
@@ -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",
diff --git a/src/lib/services/daily-rewards.test.ts b/src/lib/services/daily-rewards.test.ts
new file mode 100644
index 00000000..c9624f76
--- /dev/null
+++ b/src/lib/services/daily-rewards.test.ts
@@ -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();
+ });
+});
diff --git a/src/lib/services/daily-rewards.ts b/src/lib/services/daily-rewards.ts
new file mode 100644
index 00000000..ea461f70
--- /dev/null
+++ b/src/lib/services/daily-rewards.ts
@@ -0,0 +1,156 @@
+import "server-only";
+
+import { asc, desc, eq } from "drizzle-orm";
+import { db, WebsiteDailyRewardClaims, WebsiteDailyRewards } from "@/lib/db";
+import { siteSettings } from "./site-settings";
+
+export const VALID_CURRENCIES = [
+ "credits",
+ "duckets",
+ "diamonds",
+ "points",
+] as const;
+export type RewardCurrency = (typeof VALID_CURRENCIES)[number];
+
+export function isRewardCurrency(value: string): value is RewardCurrency {
+ return (VALID_CURRENCIES as readonly string[]).includes(value);
+}
+
+export interface DailyRewardRow {
+ id: string;
+ day: number;
+ currency: RewardCurrency;
+ amount: number;
+}
+
+/**
+ * A "day" is identified by its UTC date key (YYYY-MM-DD) — a single
+ * cross-timezone convention so past claims, today and "yesterday" are always
+ * computed the same way. The date is stored as a DATE column via
+ * `dateAsDate`, which round-trips ISO keys exactly.
+ */
+export function dateKey(value: Date = new Date()): string {
+ return value.toISOString().slice(0, 10);
+}
+
+/** Shift a YYYY-MM-DD key by a signed number of days. */
+export function shiftDayKey(key: string, days: number): string {
+ const [y, m, d] = key.split("-").map(Number);
+ const shifted = new Date(Date.UTC(y, m - 1, d + days));
+ return shifted.toISOString().slice(0, 10);
+}
+
+export function yesterdayKey(from: Date = new Date()): string {
+ return shiftDayKey(dateKey(from), -1);
+}
+
+/**
+ * Resolve which reward a user's streak entitlement maps to. The cycle repeats
+ * with the highest configured day number; any unconfigured day within the
+ * cycle falls back to the day-1 reward.
+ */
+export function rewardForStreak(
+ rewards: DailyRewardRow[],
+ streak: number,
+): DailyRewardRow | null {
+ if (rewards.length === 0 || !(streak >= 1)) return null;
+ const sorted = [...rewards].sort((a, b) => a.day - b.day);
+ const cycle = Math.max(...sorted.map((r) => r.day));
+ const dayOfCycle = ((streak - 1) % cycle) + 1;
+ return sorted.find((r) => r.day === dayOfCycle) ?? sorted[0];
+}
+
+export interface DailyRewardState {
+ enabled: boolean;
+ today: string;
+ rewards: DailyRewardRow[];
+ /** null when the user has never claimed; otherwise the most recent claim. */
+ lastClaim: { date: string; streak: number } | null;
+ alreadyClaimedToday: boolean;
+ /** Streak the next claim would land on. */
+ nextStreak: number;
+ /** Reward day number the next claim would grant. */
+ nextRewardDay: number;
+ /** Resolved reward for the next claim (null when rewards are unconfigured). */
+ nextReward: DailyRewardRow | null;
+}
+
+export async function loadDailyRewardState(
+ userId: number,
+ now: Date = new Date(),
+): Promise {
+ const today = dateKey(now);
+
+ let enabled = true;
+ let rewards: DailyRewardRow[] = [];
+ let lastClaim: { date: string; streak: number } | null = null;
+ try {
+ enabled = await siteSettings.getBool("daily_reward_enabled", true);
+ const rewardRows = await db
+ .select({
+ id: WebsiteDailyRewards.id,
+ day: WebsiteDailyRewards.day,
+ currency: WebsiteDailyRewards.currency,
+ amount: WebsiteDailyRewards.amount,
+ })
+ .from(WebsiteDailyRewards)
+ .orderBy(asc(WebsiteDailyRewards.day));
+ rewards = rewardRows
+ .filter((row) => isRewardCurrency(row.currency))
+ .map((row) => ({
+ id: String(row.id),
+ day: Number(row.day),
+ currency: row.currency as RewardCurrency,
+ amount: Number(row.amount),
+ }));
+
+ const claimRows = await db
+ .select({
+ id: WebsiteDailyRewardClaims.id,
+ claimDate: WebsiteDailyRewardClaims.claimDate,
+ streak: WebsiteDailyRewardClaims.streak,
+ })
+ .from(WebsiteDailyRewardClaims)
+ .where(eq(WebsiteDailyRewardClaims.userId, userId))
+ .orderBy(
+ desc(WebsiteDailyRewardClaims.claimDate),
+ desc(WebsiteDailyRewardClaims.id),
+ )
+ .limit(1);
+ if (claimRows[0]) {
+ lastClaim = {
+ date: dateKey(claimRows[0].claimDate),
+ streak: Number(claimRows[0].streak),
+ };
+ }
+ } catch {
+ enabled = false;
+ rewards = [];
+ lastClaim = null;
+ }
+
+ const alreadyClaimedToday = lastClaim?.date === today;
+ const nextStreak = !lastClaim
+ ? 1
+ : lastClaim.date === yesterdayKey(now)
+ ? lastClaim.streak + 1
+ : lastClaim.date === today
+ ? lastClaim.streak
+ : 1;
+ // Same day claims keep yesterday's streak (already claimed), otherwise the
+ // streak for the next claim is what matters.
+ const effectiveStreak =
+ lastClaim?.date === today ? lastClaim.streak : nextStreak;
+ const nextReward = rewardForStreak(rewards, effectiveStreak);
+
+ return {
+ enabled,
+ today,
+ rewards,
+ lastClaim,
+ alreadyClaimedToday,
+ nextStreak,
+ nextRewardDay: nextReward?.day ?? 1,
+ nextReward,
+ };
+}
diff --git a/src/lib/services/referrals.ts b/src/lib/services/referrals.ts
new file mode 100644
index 00000000..e19dc7b4
--- /dev/null
+++ b/src/lib/services/referrals.ts
@@ -0,0 +1,101 @@
+import "server-only";
+
+import { and, eq } from "drizzle-orm";
+import { db, Referrals, User, UserReferrals } from "@/lib/db";
+import { logger } from "@/lib/logger";
+import { siteSettings } from "./site-settings";
+
+/**
+ * Attribute a registration to an inviter. Called from the register server
+ * action after the account is created — it must never fail the sign-up, so
+ * every step is best-effort and logged on error.
+ *
+ * Rules:
+ * - the `ref` username must resolve to an existing user;
+ * - self-referral is rejected;
+ * - when `referrals_block_same_ip` is enabled (default), an invitee sharing
+ * the inviter's current IP is rejected (multi-account abuse);
+ * - a duplicate inviter → invitee pair is silently ignored;
+ * - on success the pair is recorded and the inviter's tally is bumped by one.
+ */
+export async function recordReferral(input: {
+ inviterUsername: string | null;
+ inviteeId: number;
+ inviteeIp: string;
+}): Promise {
+ const username = String(input.inviterUsername ?? "")
+ .normalize("NFC")
+ .trim();
+ if (!username) return;
+
+ if (!Number.isSafeInteger(input.inviteeId) || input.inviteeId <= 0) return;
+
+ try {
+ const [inviter] = await db
+ .select({
+ id: User.id,
+ username: User.username,
+ ipCurrent: User.ipCurrent,
+ })
+ .from(User)
+ .where(eq(User.username, username))
+ .limit(1);
+ if (!inviter) return;
+ if (Number(inviter.id) === input.inviteeId) return;
+
+ const blockSameIp = await siteSettings.getBool(
+ "referrals_block_same_ip",
+ true,
+ );
+ if (blockSameIp && inviter.ipCurrent === input.inviteeIp) return;
+
+ const [existing] = await db
+ .select({ id: Referrals.id })
+ .from(Referrals)
+ .where(
+ and(
+ eq(Referrals.userId, inviter.id),
+ eq(Referrals.referredUserId, BigInt(input.inviteeId)),
+ ),
+ )
+ .limit(1);
+ if (existing) return;
+
+ const now = new Date();
+ await db.insert(Referrals).values({
+ userId: inviter.id,
+ referredUserId: BigInt(input.inviteeId),
+ referredUserIp: input.inviteeIp,
+ createdAt: now,
+ updatedAt: now,
+ });
+
+ const [tally] = await db
+ .select({
+ id: UserReferrals.id,
+ referralsTotal: UserReferrals.referralsTotal,
+ })
+ .from(UserReferrals)
+ .where(eq(UserReferrals.userId, inviter.id))
+ .limit(1);
+ if (tally) {
+ await db
+ .update(UserReferrals)
+ .set({ referralsTotal: tally.referralsTotal + 1n, updatedAt: now })
+ .where(eq(UserReferrals.id, tally.id));
+ } else {
+ await db.insert(UserReferrals).values({
+ userId: inviter.id,
+ referralsTotal: 1n,
+ createdAt: now,
+ updatedAt: now,
+ });
+ }
+ } catch (error) {
+ logger.warn("Failed to attribute referral during registration", {
+ error,
+ inviter: username,
+ inviteeId: input.inviteeId,
+ });
+ }
+}
diff --git a/src/messages/en.json b/src/messages/en.json
index fea89de3..c6f90e55 100644
--- a/src/messages/en.json
+++ b/src/messages/en.json
@@ -1050,7 +1050,8 @@
"usernameRequired": "Username is required.",
"emailRequired": "Email is required.",
"passwordRequired": "Password is required.",
- "passwordsDontMatch": "Passwords do not match."
+ "passwordsDontMatch": "Passwords do not match.",
+ "invitedBy": "Invited by {username}"
},
"forgot": {
"title": "Reset password",
@@ -1143,6 +1144,8 @@
"transactions": "Transactions",
"vouchers": "Vouchers",
"subscriptions": "Subscriptions",
+ "referrals": "Referrals",
+ "dailyRewards": "Daily rewards",
"rareValues": "Rare values",
"badges": "Badges",
"achievements": "Achievements",
@@ -3197,6 +3200,64 @@
"cancel": "Cancel"
}
},
+ "referrals": {
+ "title": "Referrals",
+ "subtitle": "Invitation system",
+ "referralsToday": "Referrals today",
+ "referralsTotal": "Total referrals",
+ "invitedTotal": "Invitations counted",
+ "claims": "Rewards claimed",
+ "settings": "Reward settings",
+ "needed": "Referrals needed",
+ "rewardAmount": "Reward amount",
+ "rewardCurrency": "Reward currency",
+ "blockSameIp": "Reject referrals sharing the inviter's current IP",
+ "save": "Save settings",
+ "saving": "Saving…",
+ "saved": "Referral settings saved.",
+ "saveError": "Referral settings could not be saved.",
+ "recent": "Recent referrals",
+ "unavailable": "Referral data is temporarily unavailable.",
+ "empty": "No referrals yet.",
+ "inviter": "Inviter",
+ "invitee": "Invited user",
+ "ip": "IP",
+ "date": "Date"
+ },
+ "dailyRewards": {
+ "title": "Daily login rewards",
+ "subtitle": "Daily login reward schedule",
+ "enabled": "Feature status",
+ "yes": "Enabled",
+ "no": "Disabled",
+ "scheduleDays": "Schedule days",
+ "claimsToday": "Claims today",
+ "claimsTotal": "Total claims",
+ "status": "Feature status",
+ "statusHint": "When disabled, the reward card is hidden and new claims are rejected.",
+ "disable": "Disable",
+ "enable": "Enable",
+ "schedule": "Reward schedule",
+ "day": "Day {day}",
+ "reward": "Reward",
+ "amount": "Amount",
+ "actions": "Actions",
+ "addDay": "Day",
+ "add": "Add day",
+ "save": "Save",
+ "saving": "Saving…",
+ "saved": "Reward schedule saved.",
+ "cancel": "Cancel",
+ "edit": "Edit",
+ "delete": "Delete",
+ "created": "Reward day added.",
+ "updated": "Reward day updated.",
+ "deleted": "Reward day removed.",
+ "saveError": "Reward day could not be saved.",
+ "deleteError": "Reward day could not be removed.",
+ "confirmDeleteTitle": "Remove reward day",
+ "confirmDeleteBody": "This reward day will no longer be granted. Past claims stay recorded."
+ },
"subscriptions": {
"title": "Subscriptions",
"subtitle": "Active recurring subscriptions",
@@ -4056,6 +4117,8 @@
"vouchers": "Vouchers",
"subscriptions": "Subscriptions",
"calendar": "Calendar",
+ "referrals": "Referrals",
+ "dailyRewards": "Daily rewards",
"history": "History",
"moderationTab": "Moderation",
"monitoring": "Monitoring",
@@ -6277,6 +6340,7 @@
"login": "Sign in",
"never": "Never",
"claimed": "Reward claimed successfully!",
+ "dailyClaimed": "Daily reward claimed — see you tomorrow!",
"welcome": "Your space on {hotel}",
"noMotto": "No motto yet",
"registered": "Registered: {date}",
@@ -6329,6 +6393,23 @@
"ratelimit": "Too many attempts. Wait a moment and try again.",
"error": "The reward could not be claimed. Please try again."
},
+ "dailyErrors": {
+ "already_claimed": "You already claimed today's daily reward.",
+ "disabled": "Daily rewards are currently disabled.",
+ "bad_config": "The daily reward schedule is not configured correctly. Contact the staff.",
+ "ratelimit": "Too many attempts. Wait a moment and try again.",
+ "error": "The daily reward could not be claimed. Please try again."
+ },
+ "dailyRewardTitle": "Daily rewards",
+ "dailyRewardHint": "Log in every day and claim your reward — streaks grow until the big days.",
+ "dailyRewardDay": "Day {day}",
+ "dailyRewardStreak": "{count, plural, one {Streak: # day} other {Streak: # days}}",
+ "dailyRewardNextClaim": "Next claim: {amount} {currency}",
+ "dailyRewardTomorrow": "Tomorrow: {amount} {currency}",
+ "dailyRewardClaim": "Claim daily reward",
+ "dailyRewardClaimedToday": "Claimed today — come back tomorrow for the next reward.",
+ "dailyRewardDisabled": "Daily rewards are currently disabled. You can still explore the hotel.",
+ "dailyRewardUnavailable": "The daily reward schedule could not be loaded. Please try again.",
"messagesCaughtUp": "No unread messages",
"messagesUnavailable": "Open your inbox to check",
"eventTitle": "On the calendar",
@@ -6345,6 +6426,7 @@
"friends": "{count, plural, one {# friend request to review} other {# friend requests to review}}",
"event": "Your next registered event: {title} · {date}",
"reward": "Your referral reward is ready to claim",
+ "dailyReward": "Your daily reward is ready to claim",
"empty": "You're all caught up. No actions waiting.",
"unavailable": "Some actions could not be checked. Available actions are shown above.",
"retry": "Try again",
diff --git a/src/messages/it.json b/src/messages/it.json
index b221cafa..1fbdc823 100644
--- a/src/messages/it.json
+++ b/src/messages/it.json
@@ -1107,6 +1107,8 @@
"transactions": "Transazioni",
"vouchers": "Buoni",
"subscriptions": "Abbonamenti",
+ "referrals": "Inviti",
+ "dailyRewards": "Ricompense giornaliere",
"rareValues": "Valori rari",
"badges": "Distintivi",
"achievements": "Trofei",
@@ -4388,6 +4390,8 @@
"vouchers": "Voucher",
"subscriptions": "Abbonamenti",
"calendar": "Calendario",
+ "referrals": "Inviti",
+ "dailyRewards": "Ricompense giornaliere",
"history": "Cronologia",
"moderationTab": "Moderazione",
"monitoring": "Monitoraggio",
@@ -6282,6 +6286,7 @@
"login": "Accedi",
"never": "Mai",
"claimed": "Premio riscattato!",
+ "dailyClaimed": "Ricompensa giornaliera riscattata — torna domani!",
"welcome": "Il tuo spazio su {hotel}",
"noMotto": "Nessun motto",
"registered": "Iscritto dal {date}",
@@ -6334,6 +6339,13 @@
"ratelimit": "Troppi tentativi. Attendi un momento e riprova.",
"error": "Non è stato possibile riscattare il premio. Riprova."
},
+ "dailyErrors": {
+ "already_claimed": "Hai già riscattato la ricompensa giornaliera di oggi.",
+ "disabled": "Le ricompense giornaliere sono attualmente disattivate.",
+ "bad_config": "Il programma delle ricompense giornaliere non è configurato correttamente. Contatta lo staff.",
+ "ratelimit": "Troppi tentativi. Attendi un momento e riprova.",
+ "error": "Non è stato possibile riscattare la ricompensa giornaliera. Riprova."
+ },
"messagesCaughtUp": "Nessun messaggio da leggere",
"messagesUnavailable": "Apri la posta per controllare",
"eventTitle": "In programma",
@@ -6344,12 +6356,23 @@
"eventDetails": "Scopri l’evento",
"noUpcomingEvents": "Non sono ancora stati annunciati nuovi eventi.",
"roomsUnavailable": "Le stanze non sono al momento disponibili. Apri la community per riprovare.",
+ "dailyRewardTitle": "Ricompense giornaliere",
+ "dailyRewardHint": "Accedi ogni giorno e riscatta la ricompensa: la serie cresce fino ai giorni speciali.",
+ "dailyRewardDay": "Giorno {day}",
+ "dailyRewardStreak": "{count, plural, one {Serie: # giorno} other {Serie: # giorni}}",
+ "dailyRewardNextClaim": "Prossimo ritiro: {amount} {currency}",
+ "dailyRewardTomorrow": "Domani: {amount} {currency}",
+ "dailyRewardClaim": "Riscuoti la ricompensa giornaliera",
+ "dailyRewardClaimedToday": "Riscattata oggi — torna domani per la prossima ricompensa.",
+ "dailyRewardDisabled": "Le ricompense giornaliere sono attualmente disattivate. Puoi comunque esplorare l’hotel.",
+ "dailyRewardUnavailable": "Non è stato possibile caricare il programma delle ricompense giornaliere. Riprova.",
"tasks": {
"title": "Da fare",
"support": "{count, plural, one {# ticket con risposta del supporto} other {# ticket con risposte del supporto}}",
"friends": "{count, plural, one {# richiesta di amicizia da controllare} other {# richieste di amicizia da controllare}}",
"event": "Il prossimo evento a cui sei iscritto: {title} · {date}",
"reward": "Il premio per gli inviti è pronto da ritirare",
+ "dailyReward": "La tua ricompensa giornaliera è pronta da ritirare",
"empty": "Tutto a posto. Nessuna azione in attesa.",
"unavailable": "Non è stato possibile verificare alcune azioni. Quelle disponibili sono mostrate sopra.",
"retry": "Riprova",
diff --git a/src/messages/nl.json b/src/messages/nl.json
index 2f510f9d..9bdc3ca8 100644
--- a/src/messages/nl.json
+++ b/src/messages/nl.json
@@ -1050,6 +1050,7 @@
"emailRequired": "E-mail is verplicht.",
"passwordRequired": "Wachtwoord is verplicht.",
"passwordsDontMatch": "Wachtwoorden komen niet overeen.",
+ "invitedBy": "Uitgenodigd door {username}",
"accepted": "Accepted & agreed",
"showPassword": "Show",
"hidePassword": "Hide",
@@ -1140,6 +1141,8 @@
"transactions": "Transacties",
"vouchers": "Vouchers",
"subscriptions": "Abonnementen",
+ "referrals": "Uitnodigingen",
+ "dailyRewards": "Dagelijkse beloningen",
"rareValues": "Zeldzame waarden",
"badges": "Badges",
"achievements": "Prestaties",
@@ -3061,6 +3064,64 @@
"updated": "Voucher updated",
"updateError": "Could not update voucher"
},
+ "referrals": {
+ "title": "Uitnodigingen",
+ "subtitle": "Uitnodigingssysteem",
+ "referralsToday": "Uitnodigingen vandaag",
+ "referralsTotal": "Totaal uitnodigingen",
+ "invitedTotal": "Getelde uitnodigingen",
+ "claims": "Beloningen geclaimd",
+ "settings": "Beloningsinstellingen",
+ "needed": "Benodigde uitnodigingen",
+ "rewardAmount": "Beloningshoeveelheid",
+ "rewardCurrency": "Beloningsvaluta",
+ "blockSameIp": "Uitnodigingen weigeren die het huidige IP van de uitnodiger delen",
+ "save": "Instellingen opslaan",
+ "saving": "Opslaan…",
+ "saved": "Uitnodigingsinstellingen opgeslagen.",
+ "saveError": "Uitnodigingsinstellingen konden niet worden opgeslagen.",
+ "recent": "Recente uitnodigingen",
+ "unavailable": "Uitnodigingsgegevens zijn tijdelijk niet beschikbaar.",
+ "empty": "Nog geen uitnodigingen.",
+ "inviter": "Uitnodiger",
+ "invitee": "Uitgenodigde gebruiker",
+ "ip": "IP",
+ "date": "Datum"
+ },
+ "dailyRewards": {
+ "title": "Dagelijkse beloningen",
+ "subtitle": "Planning dagelijkse beloningen",
+ "enabled": "Functiestatus",
+ "yes": "Ingeschakeld",
+ "no": "Uitgeschakeld",
+ "scheduleDays": "Planningsdagen",
+ "claimsToday": "Claims vandaag",
+ "claimsTotal": "Totaal claims",
+ "status": "Functiestatus",
+ "statusHint": "Wanneer uitgeschakeld, wordt de beloningskaart verborgen en worden nieuwe claims geweigerd.",
+ "disable": "Uitschakelen",
+ "enable": "Inschakelen",
+ "schedule": "Beloningsplanning",
+ "day": "Dag {day}",
+ "reward": "Beloning",
+ "amount": "Hoeveelheid",
+ "actions": "Acties",
+ "addDay": "Dag",
+ "add": "Dag toevoegen",
+ "save": "Opslaan",
+ "saving": "Opslaan…",
+ "saved": "Beloningsplanning opgeslagen.",
+ "cancel": "Annuleren",
+ "edit": "Bewerken",
+ "delete": "Verwijderen",
+ "created": "Beloningsdag toegevoegd.",
+ "updated": "Beloningsdag bijgewerkt.",
+ "deleted": "Beloningsdag verwijderd.",
+ "saveError": "Beloningsdag kon niet worden opgeslagen.",
+ "deleteError": "Beloningsdag kon niet worden verwijderd.",
+ "confirmDeleteTitle": "Beloningsdag verwijderen",
+ "confirmDeleteBody": "Deze beloningsdag wordt niet langer toegekend. Eerdere claims blijven vastgelegd."
+ },
"subscriptions": {
"title": "Abonnementen",
"subtitle": "Actieve terugkerende abonnementen",
@@ -4688,6 +4749,8 @@
"vouchers": "Vouchers",
"subscriptions": "Abonnementen",
"calendar": "Kalender",
+ "referrals": "Uitnodigingen",
+ "dailyRewards": "Dagelijkse beloningen",
"history": "Geschiedenis",
"moderationTab": "Moderatie",
"monitoring": "Bewaking",
@@ -6172,6 +6235,7 @@
"friends": "{count, plural, one {# vriendschapsverzoek om te bekijken} other {# vriendschapsverzoeken om te bekijken}}",
"event": "Je volgende aangemelde evenement: {title} · {date}",
"reward": "Je beloning voor uitnodigingen staat klaar",
+ "dailyReward": "Je dagelijkse beloning staat klaar",
"empty": "Alles is bijgewerkt. Er zijn geen acties nodig.",
"unavailable": "Sommige acties konden niet worden gecontroleerd. Beschikbare acties staan hierboven.",
"retry": "Opnieuw proberen",
@@ -6183,6 +6247,7 @@
"login": "Sign in",
"never": "Never",
"claimed": "Reward claimed successfully!",
+ "dailyClaimed": "Dagelijkse beloning geclaimd — tot morgen!",
"welcome": "Your space on {hotel}",
"noMotto": "No motto yet",
"registered": "Registered: {date}",
@@ -6234,7 +6299,24 @@
"bad_config": "The reward is not configured correctly. Contact the staff.",
"ratelimit": "Too many attempts. Wait a moment and try again.",
"error": "The reward could not be claimed. Please try again."
- }
+ },
+ "dailyErrors": {
+ "already_claimed": "Je hebt de dagelijkse beloning van vandaag al geclaimd.",
+ "disabled": "Dagelijkse beloningen zijn momenteel uitgeschakeld.",
+ "bad_config": "De planning voor dagelijkse beloningen is niet correct ingesteld. Neem contact op met het personeel.",
+ "ratelimit": "Te veel pogingen. Wacht even en probeer het opnieuw.",
+ "error": "De dagelijkse beloning kon niet worden geclaimd. Probeer het opnieuw."
+ },
+ "dailyRewardTitle": "Dagelijkse beloningen",
+ "dailyRewardHint": "Log elke dag in en claim je beloning — je streak groeit tot de grote dagen.",
+ "dailyRewardDay": "Dag {day}",
+ "dailyRewardStreak": "{count, plural, one {Streak: # dag} other {Streak: # dagen}}",
+ "dailyRewardNextClaim": "Volgende claim: {amount} {currency}",
+ "dailyRewardTomorrow": "Morgen: {amount} {currency}",
+ "dailyRewardClaim": "Dagelijkse beloning claimen",
+ "dailyRewardClaimedToday": "Vandaag geclaimd — kom morgen terug voor de volgende beloning.",
+ "dailyRewardDisabled": "Dagelijkse beloningen zijn momenteel uitgeschakeld. Je kunt nog steeds het hotel verkennen.",
+ "dailyRewardUnavailable": "De planning voor dagelijkse beloningen kon niet worden geladen. Probeer het opnieuw."
},
"search": {
"title": "Zoeken",