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

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+57
View File
@@ -0,0 +1,57 @@
"use server";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import {
type ActionResult,
actionError,
actionOk,
} from "@/lib/safe-action-shared";
import { logServerError } from "@/lib/server-log";
import { siteSettings } from "@/lib/services/site-settings";
/**
* Referral system configuration for the /admin/referrals settings form. All
* values are validated before the keys leave the CMS settings table, and the
* reward currency must be one of the four emulator wallets (bidirectional:
* the claim action already validates on read).
*/
const VALID_CURRENCIES = ["credits", "duckets", "diamonds", "points"] as const;
export async function updateReferralSettings(input: {
referralsNeeded: string;
rewardAmount: string;
rewardCurrency: string;
blockSameIp: string;
}): Promise<ActionResult> {
await requirePermission(PERMS.REFERRALS_EDIT);
const needed = Number.parseInt(input.referralsNeeded, 10);
const amount = Number.parseInt(input.rewardAmount, 10);
const currency = String(input.rewardCurrency ?? "")
.normalize("NFC")
.trim()
.toLowerCase();
if (!(needed >= 1)) return actionError("Referrals needed must be at least 1");
if (!(amount > 0)) return actionError("Reward amount must be positive");
if (!(VALID_CURRENCIES as readonly string[]).includes(currency))
return actionError("Reward currency is not one of the emulator wallets");
const blockSameIp = input.blockSameIp === "1";
try {
await siteSettings.update("referrals_needed", String(needed));
await siteSettings.update("referral_reward_amount", String(amount));
await siteSettings.update("referral_reward_currency_type", currency);
await siteSettings.update(
"referrals_block_same_ip",
blockSameIp ? "1" : "0",
);
revalidatePath("/admin/referrals");
return actionOk();
} catch (error) {
logServerError("admin.referral_settings_update_failed", error);
return actionError("Referral settings could not be saved");
}
}
File diff suppressed because it is too large. Load diff
+19 -1
View File
@@ -11,6 +11,7 @@ import { logger } from "@/lib/logger";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
import { checkVpn } from "@/lib/services/ip-lookup";
import { recordReferral } from "@/lib/services/referrals";
import { siteSettings } from "@/lib/services/site-settings";
const registerSchema = z.object({
@@ -134,8 +135,9 @@ export async function register(
}
const now = Math.floor(Date.now() / 1000);
let inviteeId = 0;
try {
await db.insert(User).values({
const [result] = await db.insert(User).values({
username,
password: await hashPassword(password),
mail: hasEmail ? mail : null,
@@ -145,6 +147,7 @@ export async function register(
look,
termsAccepted: raw.termsAccepted,
});
inviteeId = Number(result.insertId);
} catch (err) {
const code = (err as { cause?: { code?: string } }).cause?.code;
if (code === "ER_DUP_ENTRY") {
@@ -163,6 +166,21 @@ export async function register(
// immediate auto sign-in sees the fresh row.
await invalidateKey(`login:user:${username}`);
// Referral attribution (`/register?ref=<username>`). Best-effort: a broken
// referral must never fail the account creation.
if (inviteeId > 0) {
const invitedBy = String(formData.get("ref") ?? "")
.normalize("NFC")
.trim();
if (invitedBy) {
await recordReferral({
inviterUsername: invitedBy,
inviteeId,
inviteeIp: ip,
});
}
}
// Verification email must never block the sign-up response — it is sent
// after the response is flushed (no-op when mail is unconfigured).
if (hasEmail) {
+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>
File diff suppressed because it is too large. Load diff
+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}
/>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+25
View File
@@ -57,8 +57,16 @@ export function RegisterForm({
null,
);
const autoLoginStartedRef = useRef(false);
const [referralCode, setReferralCode] = useState("");
const strength = passwordStrength(password);
// The invite link is `/register?ref=<username>`; read it client-side so the
// attribution travels with the sign-up form without server state.
useEffect(() => {
const ref = new URLSearchParams(window.location.search).get("ref");
if (ref) setReferralCode(ref);
}, []);
// Successful sign-up: sign in with the submitted credentials and go
// straight to /me. If the automatic sign-in is refused (e.g. email
// verification required), fall back to the regular login page.
@@ -187,6 +195,23 @@ export function RegisterForm({
</div>
)}
{referralCode && (
<div
className="animate-fade-in-up flex items-center gap-2 p-3 rounded-lg text-sm font-semibold"
style={{
backgroundColor:
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
color: "var(--color-primary-readable, var(--color-primary))",
border:
"1px solid color-mix(in srgb, var(--color-primary) 22%, transparent)",
}}
>
{t("invitedBy", { username: referralCode })}
</div>
)}
<input type="hidden" name="ref" value={referralCode} />
{/* Username & Email */}
<div className="space-y-2">
<div className="flex items-center gap-2">
+33
View File
@@ -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",
{
@@ -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: [],
@@ -621,15 +621,15 @@ describe("housekeeping foundation completion contracts", () => {
expect(html).toContain(`>${sentinel}</button>`);
});
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([]);
});
});
@@ -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: [],
},
@@ -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",
@@ -16,6 +16,8 @@ const ECONOMY_PREFIXES = [
"/admin/achievements",
"/admin/sounds",
"/admin/calendar",
"/admin/referrals",
"/admin/daily-rewards",
] as const;
describe("economyMigrationEntries", () => {
@@ -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.",
],
}),
];
@@ -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([]);
+16
View File
@@ -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"],
},
],
},
{
+6
View File
@@ -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",
+56
View File
@@ -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
+83 -1
View File
File diff suppressed because it is too large. Load diff
+23
View File
@@ -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",
+83 -1
View File
File diff suppressed because it is too large. Load diff