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

@@ -0,0 +1,154 @@
-- 0033_referrals_daily_rewards.sql
-- Referral attribution tables (mirror the live-DB shape used by the existing
-- referral claim flow in src/actions/referral.ts) plus the CMS-owned daily
-- login reward schedule and claim ledger. All CREATE statements are idempotent
-- (IF NOT EXISTS) so fresh installs get the tables and existing hotels keep
-- whatever rows they already have.
CREATE TABLE IF NOT EXISTS `user_referrals` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL,
`referrals_total` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`created_at` TIMESTAMP NULL,
`updated_at` TIMESTAMP NULL,
PRIMARY KEY (`id`),
KEY `user_referrals_user_idx` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `referrals` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL,
`referred_user_id` BIGINT UNSIGNED NOT NULL,
`referred_user_ip` VARCHAR(255) NOT NULL,
`created_at` TIMESTAMP NULL,
`updated_at` TIMESTAMP NULL,
PRIMARY KEY (`id`),
KEY `referrals_user_idx` (`user_id`),
KEY `referrals_referred_user_idx` (`referred_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `claimed_referral_logs` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL,
`ip_address` VARCHAR(255) NOT NULL,
`created_at` TIMESTAMP NULL,
`updated_at` TIMESTAMP NULL,
PRIMARY KEY (`id`),
KEY `claimed_referral_logs_user_idx` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Daily login reward schedule: one row per streak day. The cycle repeats after
-- the highest day (day 1 of the cycle is used for any missing day).
CREATE TABLE IF NOT EXISTS `website_daily_rewards` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`day` INT UNSIGNED NOT NULL DEFAULT 1,
`currency` VARCHAR(20) NOT NULL DEFAULT 'credits',
`amount` INT NOT NULL DEFAULT 0,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `website_daily_rewards_day_uk` (`day`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Daily reward claims ledger; one row per user per claim date.
CREATE TABLE IF NOT EXISTS `website_daily_reward_claims` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL,
`claim_date` DATE NOT NULL,
`streak` INT UNSIGNED NOT NULL DEFAULT 1,
`reward_day` INT UNSIGNED NOT NULL DEFAULT 1,
`currency` VARCHAR(20) NOT NULL DEFAULT 'credits',
`amount` INT NOT NULL DEFAULT 0,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `daily_claim_user_date_uk` (`user_id`, `claim_date`),
KEY `daily_claim_created_idx` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Seed a sensible 7-day reward schedule only when the table is still empty.
INSERT INTO `website_daily_rewards` (`day`, `currency`, `amount`)
SELECT 1, 'duckets', 60
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `website_daily_rewards`);
INSERT INTO `website_daily_rewards` (`day`, `currency`, `amount`)
SELECT 3, 'credits', 50
FROM DUAL
WHERE (SELECT COUNT(*) FROM `website_daily_rewards`) = 1
AND NOT EXISTS (SELECT 1 FROM `website_daily_rewards` WHERE `day` = 3);
INSERT INTO `website_daily_rewards` (`day`, `currency`, `amount`)
SELECT 5, 'diamonds', 2
FROM DUAL
WHERE (SELECT COUNT(*) FROM `website_daily_rewards`) = 2
AND NOT EXISTS (SELECT 1 FROM `website_daily_rewards` WHERE `day` = 5);
-- Referral + daily reward configuration defaults (never overwrite an existing
-- value — operators tune these keys in the admin panel).
INSERT INTO `website_settings` (`key`, `value`, `comment`)
SELECT 'referrals_block_same_ip', '1', 'Block referral attribution when the new account shares the inviter IP'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `website_settings` WHERE `key` = 'referrals_block_same_ip');
INSERT INTO `website_settings` (`key`, `value`, `comment`)
SELECT 'daily_reward_enabled', '1', 'Enable the daily login reward claims on /me'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `website_settings` WHERE `key` = 'daily_reward_enabled');
-- New ACL slugs backing the /admin/referrals + /admin/daily-rewards modules.
INSERT INTO `acl_permissions` (`slug`, `title`) VALUES
('admin.referrals.view', 'View referrals'),
('admin.referrals.edit', 'Edit referrals'),
('admin.dailyrewards.view', 'View daily login rewards'),
('admin.dailyrewards.edit', 'Edit daily login rewards')
ON DUPLICATE KEY UPDATE `title` = VALUES(`title`);
-- Grant the new slugs to the same roles that already open the admin panel.
INSERT INTO `acl_model_permissions` (`model_type`, `model_id`, `permission_id`)
SELECT 'Role', ar.id, ap.id
FROM `acl_roles` ar
JOIN `acl_permissions` ap ON ap.slug IN (
'admin.referrals.view', 'admin.referrals.edit',
'admin.dailyrewards.view', 'admin.dailyrewards.edit'
)
WHERE EXISTS (
SELECT 1
FROM `acl_model_permissions` amp
JOIN `acl_permissions` apdash ON apdash.id = amp.permission_id
WHERE amp.model_type = 'Role'
AND amp.model_id = ar.id
AND apdash.slug = 'admin.dashboard'
)
AND NOT EXISTS (
SELECT 1
FROM `acl_model_permissions` amp2
WHERE amp2.model_type = 'Role'
AND amp2.model_id = ar.id
AND amp2.permission_id = ap.id
);
-- Safety net matching the 0018 seed: ranks >= 6 view, ranks >= 7 edit.
INSERT INTO `acl_model_permissions` (`model_type`, `model_id`, `permission_id`)
SELECT 'Role', ar.id, ap.id
FROM `permission_ranks` pr
JOIN `acl_roles` ar ON ar.slug = CONCAT('rank_', pr.id)
JOIN `acl_permissions` ap ON ap.slug IN ('admin.referrals.view', 'admin.dailyrewards.view')
WHERE pr.id >= 6
AND NOT EXISTS (
SELECT 1
FROM `acl_model_permissions` amp
WHERE amp.model_type = 'Role'
AND amp.model_id = ar.id
AND amp.permission_id = ap.id
);
INSERT INTO `acl_model_permissions` (`model_type`, `model_id`, `permission_id`)
SELECT 'Role', ar.id, ap.id
FROM `permission_ranks` pr
JOIN `acl_roles` ar ON ar.slug = CONCAT('rank_', pr.id)
JOIN `acl_permissions` ap ON ap.slug IN ('admin.referrals.edit', 'admin.dailyrewards.edit')
WHERE pr.id >= 7
AND NOT EXISTS (
SELECT 1
FROM `acl_model_permissions` amp
WHERE amp.model_type = 'Role'
AND amp.model_id = ar.id
AND amp.permission_id = ap.id
);
+133
View File
@@ -0,0 +1,133 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { positiveBigInt } from "@/lib/api";
import { db, WebsiteDailyRewards } from "@/lib/db";
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";
const VALID_CURRENCIES = ["credits", "duckets", "diamonds", "points"] as const;
async function normalizeReward(input: {
day: string;
currency: string;
amount: string;
}): Promise<
| { ok: true; day: number; currency: string; amount: number }
| { ok: false; message: string }
> {
const day = Number.parseInt(input.day, 10);
const amount = Number.parseInt(input.amount, 10);
const currency = String(input.currency ?? "")
.normalize("NFC")
.trim()
.toLowerCase();
if (!(day >= 1 && day <= 365))
return { ok: false, message: "Reward day must be between 1 and 365" };
if (!(amount > 0))
return { ok: false, message: "Reward amount must be positive" };
if (!(VALID_CURRENCIES as readonly string[]).includes(currency))
return {
ok: false,
message: "Reward currency is not one of the emulator wallets",
};
return { ok: true, day, currency, amount };
}
export async function upsertDailyReward(input: {
id?: string;
day: string;
currency: string;
amount: string;
}): Promise<ActionResult> {
await requirePermission(PERMS.DAILY_REWARDS_EDIT);
const normalized = await normalizeReward(input);
if (!normalized.ok) return actionError(normalized.message);
const id = input.id ? positiveBigInt(String(input.id).trim()) : null;
const now = new Date();
try {
if (id) {
await db
.update(WebsiteDailyRewards)
.set({
day: normalized.day,
currency: normalized.currency,
amount: normalized.amount,
updatedAt: now,
})
.where(eq(WebsiteDailyRewards.id, Number(id)));
} else {
await db
.insert(WebsiteDailyRewards)
.values({
day: normalized.day,
currency: normalized.currency,
amount: normalized.amount,
})
.onDuplicateKeyUpdate({
set: {
currency: normalized.currency,
amount: normalized.amount,
updatedAt: now,
},
});
}
revalidatePath("/admin/daily-rewards");
revalidatePath("/me");
return actionOk();
} catch (error) {
logServerError("admin.daily_reward_upsert_failed", error);
return actionError("Reward day could not be saved");
}
}
export async function deleteDailyReward(input: {
id: string;
}): Promise<ActionResult> {
await requirePermission(PERMS.DAILY_REWARDS_EDIT);
const id = positiveBigInt(String(input.id ?? "").trim());
if (!id) return actionError("Missing reward id");
try {
await db
.delete(WebsiteDailyRewards)
.where(eq(WebsiteDailyRewards.id, Number(id)));
revalidatePath("/admin/daily-rewards");
return actionOk();
} catch (error) {
logServerError("admin.daily_reward_delete_failed", error, {
rewardId: String(id),
});
return actionError("Reward day could not be deleted");
}
}
export async function setDailyRewardEnabled(input: {
enabled: boolean;
}): Promise<ActionResult> {
await requirePermission(PERMS.DAILY_REWARDS_EDIT);
try {
await siteSettings.update(
"daily_reward_enabled",
input.enabled ? "1" : "0",
);
revalidatePath("/admin/daily-rewards");
return actionOk();
} catch (error) {
logServerError("admin.daily_reward_toggle_failed", error);
return actionError("Reward setting could not be saved");
}
}
+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");
}
}
+145
View File
@@ -0,0 +1,145 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { db, WebsiteDailyRewardClaims } from "@/lib/db";
import { logger } from "@/lib/logger";
import { rateLimit } from "@/lib/rate-limit";
import {
isRewardCurrency,
loadDailyRewardState,
} from "@/lib/services/daily-rewards";
import { rcon } from "@/lib/services/rcon";
import {
type CurrencyName,
currencyDb,
sendCurrency,
} from "@/lib/services/send-currency";
/**
* Claim the daily login reward for the SIGNED-IN user. The user id is always
* re-read from the session — never from the form — so a crafted request cannot
* claim on another account.
*
* Streak rules (see src/lib/services/daily-rewards.ts):
* - a claim on the day after the previous claim extends the streak;
* - a claim on the same day is a no-op (already claimed);
* - a missed day resets the streak to 1.
* The reward schedule lives in website_daily_rewards (admin editable) and
* repeats after the highest configured day. The unique (user_id, claim_date)
* key makes the claim idempotent even under concurrent submits.
*
* Redirects back to /me with a machine-readable ?daily=<code> outcome; the
* redirect() control-flow throw is re-thrown so navigation actually happens.
*/
const OUTCOME = [
"claimed",
"already_claimed",
"disabled",
"bad_config",
"ratelimit",
"error",
] as const;
type Outcome = (typeof OUTCOME)[number];
export async function claimDailyReward(_formData: FormData): Promise<void> {
let outcome: Outcome = "error";
try {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const userId = Number(session.user.id);
if (!Number.isFinite(userId) || userId <= 0) redirect("/login");
if (!(await rateLimit(`daily-reward-claim:${userId}`, 5, 60_000)).ok) {
outcome = "ratelimit";
} else {
const state = await loadDailyRewardState(userId);
if (!state.enabled) {
outcome = "disabled";
} else if (state.alreadyClaimedToday) {
outcome = "already_claimed";
} else if (
!state.nextReward ||
!isRewardCurrency(state.nextReward.currency) ||
!(state.nextReward.amount > 0)
) {
outcome = "bad_config";
} else {
const { nextReward, nextStreak } = state;
const claimDate = new Date(`${state.today}T00:00:00Z`);
let claimId: number | null = null;
try {
// Insert first — the unique (user_id, claim_date) key is the
// single-claim-per-day guarantee. On success the reward is paid.
const [result] = await db.insert(WebsiteDailyRewardClaims).values({
userId,
claimDate,
streak: nextStreak,
rewardDay: nextReward.day,
currency: nextReward.currency,
amount: nextReward.amount,
});
claimId = Number(result.insertId);
} catch (claimError) {
const code = (claimError as { cause?: { code?: string } }).cause
?.code;
if (code === "ER_DUP_ENTRY") {
outcome = "already_claimed";
} else {
logger.error("Daily reward claim insert failed", {
userId,
error: claimError,
});
outcome = "error";
}
}
if (claimId !== null) {
try {
await sendCurrency(
{ rcon, db: currencyDb },
userId,
nextReward.currency as CurrencyName,
nextReward.amount,
);
outcome = "claimed";
} catch {
// Undeliverable — roll the claim back so the user can retry
// instead of losing the reward to a broken RCON/DB write.
try {
await db
.delete(WebsiteDailyRewardClaims)
.where(eq(WebsiteDailyRewardClaims.id, BigInt(claimId)));
} catch {
/* best-effort rollback */
}
outcome = "error";
}
}
}
}
} catch (err) {
if (
err &&
typeof err === "object" &&
"digest" in err &&
typeof (err as { digest?: unknown }).digest === "string" &&
(err as { digest: string }).digest.startsWith("NEXT_REDIRECT")
) {
throw err;
}
}
revalidatePath("/me");
if (outcome === "claimed") {
redirect("/me?daily=claimed");
}
redirect(`/me?daily=${outcome}`);
}
+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>
@@ -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>
);
}
+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();
});
});
+156
View File
@@ -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<DailyRewardState> {
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,
};
}
+101
View File
@@ -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<void> {
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,
});
}
}
+83 -1
View File
@@ -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",
+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
@@ -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",