feat(referrals): add referral attribution and daily login rewards
- Track referral attribution at registration via ?ref code with same-IP and duplicate-pair guards - Add daily login rewards with streak tracking, claim flow and sendCurrency payout backed by RCON with DB fallback - Add admin pages for referral settings and the daily reward schedule - Add migration 0033 with tables, seed schedule, settings and ACL grants - Add admin.referrals.* and admin.dailyrewards.* permission slugs - Localize new copy in en, nl and it
This commit is contained in:
1 parent
463bc2cb47
commit
c3ff497050
30 files changed
+2028
-11
No files matched your search
@@ -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
|
||||||
|
);
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -11,6 +11,7 @@ import { logger } from "@/lib/logger";
|
|||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
||||||
import { checkVpn } from "@/lib/services/ip-lookup";
|
import { checkVpn } from "@/lib/services/ip-lookup";
|
||||||
|
import { recordReferral } from "@/lib/services/referrals";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
const registerSchema = z.object({
|
const registerSchema = z.object({
|
||||||
@@ -134,8 +135,9 @@ export async function register(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
let inviteeId = 0;
|
||||||
try {
|
try {
|
||||||
await db.insert(User).values({
|
const [result] = await db.insert(User).values({
|
||||||
username,
|
username,
|
||||||
password: await hashPassword(password),
|
password: await hashPassword(password),
|
||||||
mail: hasEmail ? mail : null,
|
mail: hasEmail ? mail : null,
|
||||||
@@ -145,6 +147,7 @@ export async function register(
|
|||||||
look,
|
look,
|
||||||
termsAccepted: raw.termsAccepted,
|
termsAccepted: raw.termsAccepted,
|
||||||
});
|
});
|
||||||
|
inviteeId = Number(result.insertId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = (err as { cause?: { code?: string } }).cause?.code;
|
const code = (err as { cause?: { code?: string } }).cause?.code;
|
||||||
if (code === "ER_DUP_ENTRY") {
|
if (code === "ER_DUP_ENTRY") {
|
||||||
@@ -163,6 +166,21 @@ export async function register(
|
|||||||
// immediate auto sign-in sees the fresh row.
|
// immediate auto sign-in sees the fresh row.
|
||||||
await invalidateKey(`login:user:${username}`);
|
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
|
// Verification email must never block the sign-up response — it is sent
|
||||||
// after the response is flushed (no-op when mail is unconfigured).
|
// after the response is flushed (no-op when mail is unconfigured).
|
||||||
if (hasEmail) {
|
if (hasEmail) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,9 +8,12 @@ import styles from "./dashboard.module.css";
|
|||||||
export default async function DashboardTasks({
|
export default async function DashboardTasks({
|
||||||
userId,
|
userId,
|
||||||
rewardAvailable,
|
rewardAvailable,
|
||||||
|
dailyRewardAvailable,
|
||||||
}: {
|
}: {
|
||||||
userId: number;
|
userId: number;
|
||||||
rewardAvailable: boolean | null;
|
rewardAvailable: boolean | null;
|
||||||
|
/** Claimable daily login reward (false when claimed/disabled or loader down). */
|
||||||
|
dailyRewardAvailable?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [data, t, format] = await Promise.all([
|
const [data, t, format] = await Promise.all([
|
||||||
loadDashboardTasks(userId),
|
loadDashboardTasks(userId),
|
||||||
@@ -41,6 +44,8 @@ export default async function DashboardTasks({
|
|||||||
});
|
});
|
||||||
if (rewardAvailable)
|
if (rewardAvailable)
|
||||||
items.push({ href: "#referral-reward", label: t("reward") });
|
items.push({ href: "#referral-reward", label: t("reward") });
|
||||||
|
if (dailyRewardAvailable)
|
||||||
|
items.push({ href: "#daily-reward", label: t("dailyReward") });
|
||||||
const unavailable =
|
const unavailable =
|
||||||
data.support === null ||
|
data.support === null ||
|
||||||
data.friendRequests === null ||
|
data.friendRequests === null ||
|
||||||
|
|||||||
@@ -342,3 +342,47 @@
|
|||||||
.tasks a:hover {
|
.tasks a:hover {
|
||||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
|
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;
|
||||||
|
}
|
||||||
@@ -12,8 +12,10 @@ import { SurfaceCard } from "@/components/surface-card";
|
|||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { avatarImageUrl, slugify } from "@/lib/format";
|
import { avatarImageUrl, slugify } from "@/lib/format";
|
||||||
import { withPublicPagePerformance } from "@/lib/public-page-performance";
|
import { withPublicPagePerformance } from "@/lib/public-page-performance";
|
||||||
|
import { loadDailyRewardState } from "@/lib/services/daily-rewards";
|
||||||
import { loadUserDashboard } from "@/lib/services/user-dashboard";
|
import { loadUserDashboard } from "@/lib/services/user-dashboard";
|
||||||
import CopyReferralButton from "./CopyReferralButton";
|
import CopyReferralButton from "./CopyReferralButton";
|
||||||
|
import DailyRewardCard from "./DailyRewardCard";
|
||||||
import DashboardTasks from "./DashboardTasks";
|
import DashboardTasks from "./DashboardTasks";
|
||||||
import styles from "./dashboard.module.css";
|
import styles from "./dashboard.module.css";
|
||||||
|
|
||||||
@@ -25,13 +27,17 @@ export async function generateMetadata() {
|
|||||||
async function MePage({
|
async function MePage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ claimed?: string; error?: string }>;
|
searchParams: Promise<{
|
||||||
|
claimed?: string;
|
||||||
|
error?: string;
|
||||||
|
daily?: string;
|
||||||
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) redirect("/login");
|
if (!session?.user?.id) redirect("/login");
|
||||||
const userId = Number(session.user.id);
|
const userId = Number(session.user.id);
|
||||||
if (!Number.isFinite(userId) || userId <= 0) redirect("/login");
|
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"),
|
getTranslations("pages.myDashboard"),
|
||||||
getFormatter(),
|
getFormatter(),
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -51,6 +57,9 @@ async function MePage({
|
|||||||
</main>
|
</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];
|
const user = data.userRows[0];
|
||||||
if (!user)
|
if (!user)
|
||||||
return (
|
return (
|
||||||
@@ -147,6 +156,24 @@ async function MePage({
|
|||||||
{t(`errors.${errorKey}`)}
|
{t(`errors.${errorKey}`)}
|
||||||
</p>
|
</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}>
|
<SurfaceCard className={styles.hero}>
|
||||||
<ProfileImage
|
<ProfileImage
|
||||||
src={avatarFull}
|
src={avatarFull}
|
||||||
@@ -204,6 +231,9 @@ async function MePage({
|
|||||||
<DashboardTasks
|
<DashboardTasks
|
||||||
userId={userId}
|
userId={userId}
|
||||||
rewardAvailable={referralsRows === null ? null : canClaim}
|
rewardAvailable={referralsRows === null ? null : canClaim}
|
||||||
|
dailyRewardAvailable={
|
||||||
|
dailyState.enabled && !dailyState.alreadyClaimedToday
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<section aria-label={t("wallet")} className={styles.wallet}>
|
<section aria-label={t("wallet")} className={styles.wallet}>
|
||||||
{wallet.map((currency) => (
|
{wallet.map((currency) => (
|
||||||
@@ -356,6 +386,7 @@ async function MePage({
|
|||||||
</SurfaceCard>
|
</SurfaceCard>
|
||||||
</div>
|
</div>
|
||||||
<aside className={styles.stack}>
|
<aside className={styles.stack}>
|
||||||
|
<DailyRewardCard state={dailyState} />
|
||||||
<SurfaceCard title={t("referrals")} bodyClassName="p-5">
|
<SurfaceCard title={t("referrals")} bodyClassName="p-5">
|
||||||
<div id="referral-reward" />
|
<div id="referral-reward" />
|
||||||
<p>{t("referralHint")}</p>
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -57,8 +57,16 @@ export function RegisterForm({
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const autoLoginStartedRef = useRef(false);
|
const autoLoginStartedRef = useRef(false);
|
||||||
|
const [referralCode, setReferralCode] = useState("");
|
||||||
const strength = passwordStrength(password);
|
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
|
// Successful sign-up: sign in with the submitted credentials and go
|
||||||
// straight to /me. If the automatic sign-in is refused (e.g. email
|
// straight to /me. If the automatic sign-in is refused (e.g. email
|
||||||
// verification required), fall back to the regular login page.
|
// verification required), fall back to the regular login page.
|
||||||
@@ -187,6 +195,23 @@ export function RegisterForm({
|
|||||||
</div>
|
</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 */}
|
{/* Username & Email */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -714,6 +714,39 @@ export const ClaimedReferralLogs = mysqlTable("claimed_referral_logs", {
|
|||||||
updatedAt: timestamp("updated_at"),
|
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(
|
export const WebsiteHousekeepingPermissions = mysqlTable(
|
||||||
"website_housekeeping_permissions",
|
"website_housekeeping_permissions",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export const economyManifest = {
|
|||||||
PERMS.SHOP_VIEW,
|
PERMS.SHOP_VIEW,
|
||||||
PERMS.CATALOG_EDIT,
|
PERMS.CATALOG_EDIT,
|
||||||
PERMS.SHOP_EDIT,
|
PERMS.SHOP_EDIT,
|
||||||
|
PERMS.REFERRALS_VIEW,
|
||||||
|
PERMS.REFERRALS_EDIT,
|
||||||
|
PERMS.DAILY_REWARDS_VIEW,
|
||||||
|
PERMS.DAILY_REWARDS_EDIT,
|
||||||
),
|
),
|
||||||
routes: [],
|
routes: [],
|
||||||
searchProviders: [],
|
searchProviders: [],
|
||||||
|
|||||||
@@ -621,15 +621,15 @@ describe("housekeeping foundation completion contracts", () => {
|
|||||||
expect(html).toContain(`>${sentinel}</button>`);
|
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 discovered = discoverLegacyPages();
|
||||||
const issues = validateMigrationEntries(
|
const issues = validateMigrationEntries(
|
||||||
discovered,
|
discovered,
|
||||||
HOUSEKEEPING_MIGRATION_MATRIX,
|
HOUSEKEEPING_MIGRATION_MATRIX,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(144);
|
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(146);
|
||||||
expect(discovered).toHaveLength(144);
|
expect(discovered).toHaveLength(146);
|
||||||
expect(issues).toEqual([]);
|
expect(issues).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -146,6 +146,10 @@ const expectedManifests = [
|
|||||||
PERMS.SHOP_VIEW,
|
PERMS.SHOP_VIEW,
|
||||||
PERMS.CATALOG_EDIT,
|
PERMS.CATALOG_EDIT,
|
||||||
PERMS.SHOP_EDIT,
|
PERMS.SHOP_EDIT,
|
||||||
|
PERMS.REFERRALS_VIEW,
|
||||||
|
PERMS.REFERRALS_EDIT,
|
||||||
|
PERMS.DAILY_REWARDS_VIEW,
|
||||||
|
PERMS.DAILY_REWARDS_EDIT,
|
||||||
],
|
],
|
||||||
routes: [],
|
routes: [],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,12 +29,22 @@ describe("discoverLegacyPages", () => {
|
|||||||
it("discovers the exact legacy administration inventory", () => {
|
it("discovers the exact legacy administration inventory", () => {
|
||||||
const pages = discoverLegacyPages();
|
const pages = discoverLegacyPages();
|
||||||
|
|
||||||
expect(pages).toHaveLength(144);
|
expect(pages).toHaveLength(146);
|
||||||
expect(pages).toContainEqual({
|
expect(pages).toContainEqual({
|
||||||
surface: "admin",
|
surface: "admin",
|
||||||
legacyPath: "/admin/users/:id/edit",
|
legacyPath: "/admin/users/:id/edit",
|
||||||
sourceFile: "src/app/admin/users/[id]/edit/page.tsx",
|
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({
|
expect(pages).toContainEqual({
|
||||||
surface: "mod",
|
surface: "mod",
|
||||||
legacyPath: "/mod/cfh/:id",
|
legacyPath: "/mod/cfh/:id",
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const ECONOMY_PREFIXES = [
|
|||||||
"/admin/achievements",
|
"/admin/achievements",
|
||||||
"/admin/sounds",
|
"/admin/sounds",
|
||||||
"/admin/calendar",
|
"/admin/calendar",
|
||||||
|
"/admin/referrals",
|
||||||
|
"/admin/daily-rewards",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
describe("economyMigrationEntries", () => {
|
describe("economyMigrationEntries", () => {
|
||||||
|
|||||||
@@ -419,4 +419,46 @@ export const economyMigrationEntries: readonly MigrationEntry[] = [
|
|||||||
localization: "COMPLETE",
|
localization: "COMPLETE",
|
||||||
accessibility: "PARTIAL",
|
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";
|
import { validateMigrationEntries } from "./validate-matrix";
|
||||||
|
|
||||||
describe("HOUSEKEEPING_MIGRATION_MATRIX", () => {
|
describe("HOUSEKEEPING_MIGRATION_MATRIX", () => {
|
||||||
it("covers all 144 legacy pages exactly once", () => {
|
it("covers all 146 legacy pages exactly once", () => {
|
||||||
const discovered = discoverLegacyPages();
|
const discovered = discoverLegacyPages();
|
||||||
|
|
||||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(144);
|
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(146);
|
||||||
expect(
|
expect(
|
||||||
validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX),
|
validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX),
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
Ban,
|
Ban,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
CalendarCheck,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Cog,
|
Cog,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Filter,
|
Filter,
|
||||||
Gavel,
|
Gavel,
|
||||||
|
Gift,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
Image,
|
Image,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
@@ -526,6 +528,20 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
|||||||
permission: PERMS.SHOP_VIEW,
|
permission: PERMS.SHOP_VIEW,
|
||||||
matchPrefixes: ["/admin/calendar"],
|
matchPrefixes: ["/admin/calendar"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/referrals",
|
||||||
|
labelKey: "referrals",
|
||||||
|
icon: Gift,
|
||||||
|
permission: PERMS.REFERRALS_VIEW,
|
||||||
|
matchPrefixes: ["/admin/referrals"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/daily-rewards",
|
||||||
|
labelKey: "dailyRewards",
|
||||||
|
icon: CalendarCheck,
|
||||||
|
permission: PERMS.DAILY_REWARDS_VIEW,
|
||||||
|
matchPrefixes: ["/admin/daily-rewards"],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ export const PERMS = {
|
|||||||
// ── Polls Module ──
|
// ── Polls Module ──
|
||||||
POLLS_VIEW: "admin.polls.view",
|
POLLS_VIEW: "admin.polls.view",
|
||||||
POLLS_EDIT: "admin.polls.edit",
|
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 Module ──
|
||||||
NOTIFICATIONS_VIEW: "admin.notifications.view",
|
NOTIFICATIONS_VIEW: "admin.notifications.view",
|
||||||
NOTIFICATIONS_EDIT: "admin.notifications.edit",
|
NOTIFICATIONS_EDIT: "admin.notifications.edit",
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("server-only", () => ({}));
|
||||||
|
vi.mock("@/lib/db", () => ({ db: {} }));
|
||||||
|
vi.mock("./site-settings", () => ({
|
||||||
|
siteSettings: { getBool: async () => true },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
type DailyRewardRow,
|
||||||
|
rewardForStreak,
|
||||||
|
shiftDayKey,
|
||||||
|
} from "./daily-rewards";
|
||||||
|
|
||||||
|
describe("daily reward date math", () => {
|
||||||
|
it("shifts day keys across month and year boundaries", () => {
|
||||||
|
expect(shiftDayKey("2026-03-01", -1)).toBe("2026-02-28");
|
||||||
|
expect(shiftDayKey("2026-01-01", 31)).toBe("2026-02-01");
|
||||||
|
expect(shiftDayKey("2026-12-31", 1)).toBe("2027-01-01");
|
||||||
|
expect(shiftDayKey("2026-02-28", 1)).toBe("2026-03-01");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("daily reward streak resolution", () => {
|
||||||
|
const rewards: DailyRewardRow[] = [
|
||||||
|
{ id: "1", day: 1, currency: "duckets", amount: 50 },
|
||||||
|
{ id: "2", day: 2, currency: "duckets", amount: 60 },
|
||||||
|
{ id: "5", day: 5, currency: "diamonds", amount: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("resolves configured days and repeats the cycle", () => {
|
||||||
|
expect(rewardForStreak(rewards, 1)?.day).toBe(1);
|
||||||
|
expect(rewardForStreak(rewards, 2)?.day).toBe(2);
|
||||||
|
expect(rewardForStreak(rewards, 5)?.day).toBe(5);
|
||||||
|
// The cycle is the highest configured day: 5 → back to day 1.
|
||||||
|
expect(rewardForStreak(rewards, 6)?.day).toBe(1);
|
||||||
|
expect(rewardForStreak(rewards, 7)?.day).toBe(2);
|
||||||
|
expect(rewardForStreak(rewards, 10)?.day).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the day-1 reward for unconfigured days", () => {
|
||||||
|
// Days 3 and 4 are not configured in the schedule → day-1 reward.
|
||||||
|
expect(rewardForStreak(rewards, 3)?.day).toBe(1);
|
||||||
|
expect(rewardForStreak(rewards, 4)?.day).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for empty schedules or non-positive streaks", () => {
|
||||||
|
expect(rewardForStreak([], 3)).toBeNull();
|
||||||
|
expect(
|
||||||
|
rewardForStreak(
|
||||||
|
[{ id: "1", day: 3, currency: "credits", amount: 10 }],
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -1050,7 +1050,8 @@
|
|||||||
"usernameRequired": "Username is required.",
|
"usernameRequired": "Username is required.",
|
||||||
"emailRequired": "Email is required.",
|
"emailRequired": "Email is required.",
|
||||||
"passwordRequired": "Password is required.",
|
"passwordRequired": "Password is required.",
|
||||||
"passwordsDontMatch": "Passwords do not match."
|
"passwordsDontMatch": "Passwords do not match.",
|
||||||
|
"invitedBy": "Invited by {username}"
|
||||||
},
|
},
|
||||||
"forgot": {
|
"forgot": {
|
||||||
"title": "Reset password",
|
"title": "Reset password",
|
||||||
@@ -1143,6 +1144,8 @@
|
|||||||
"transactions": "Transactions",
|
"transactions": "Transactions",
|
||||||
"vouchers": "Vouchers",
|
"vouchers": "Vouchers",
|
||||||
"subscriptions": "Subscriptions",
|
"subscriptions": "Subscriptions",
|
||||||
|
"referrals": "Referrals",
|
||||||
|
"dailyRewards": "Daily rewards",
|
||||||
"rareValues": "Rare values",
|
"rareValues": "Rare values",
|
||||||
"badges": "Badges",
|
"badges": "Badges",
|
||||||
"achievements": "Achievements",
|
"achievements": "Achievements",
|
||||||
@@ -3197,6 +3200,64 @@
|
|||||||
"cancel": "Cancel"
|
"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": {
|
"subscriptions": {
|
||||||
"title": "Subscriptions",
|
"title": "Subscriptions",
|
||||||
"subtitle": "Active recurring subscriptions",
|
"subtitle": "Active recurring subscriptions",
|
||||||
@@ -4056,6 +4117,8 @@
|
|||||||
"vouchers": "Vouchers",
|
"vouchers": "Vouchers",
|
||||||
"subscriptions": "Subscriptions",
|
"subscriptions": "Subscriptions",
|
||||||
"calendar": "Calendar",
|
"calendar": "Calendar",
|
||||||
|
"referrals": "Referrals",
|
||||||
|
"dailyRewards": "Daily rewards",
|
||||||
"history": "History",
|
"history": "History",
|
||||||
"moderationTab": "Moderation",
|
"moderationTab": "Moderation",
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
@@ -6277,6 +6340,7 @@
|
|||||||
"login": "Sign in",
|
"login": "Sign in",
|
||||||
"never": "Never",
|
"never": "Never",
|
||||||
"claimed": "Reward claimed successfully!",
|
"claimed": "Reward claimed successfully!",
|
||||||
|
"dailyClaimed": "Daily reward claimed — see you tomorrow!",
|
||||||
"welcome": "Your space on {hotel}",
|
"welcome": "Your space on {hotel}",
|
||||||
"noMotto": "No motto yet",
|
"noMotto": "No motto yet",
|
||||||
"registered": "Registered: {date}",
|
"registered": "Registered: {date}",
|
||||||
@@ -6329,6 +6393,23 @@
|
|||||||
"ratelimit": "Too many attempts. Wait a moment and try again.",
|
"ratelimit": "Too many attempts. Wait a moment and try again.",
|
||||||
"error": "The reward could not be claimed. Please 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",
|
"messagesCaughtUp": "No unread messages",
|
||||||
"messagesUnavailable": "Open your inbox to check",
|
"messagesUnavailable": "Open your inbox to check",
|
||||||
"eventTitle": "On the calendar",
|
"eventTitle": "On the calendar",
|
||||||
@@ -6345,6 +6426,7 @@
|
|||||||
"friends": "{count, plural, one {# friend request to review} other {# friend requests to review}}",
|
"friends": "{count, plural, one {# friend request to review} other {# friend requests to review}}",
|
||||||
"event": "Your next registered event: {title} · {date}",
|
"event": "Your next registered event: {title} · {date}",
|
||||||
"reward": "Your referral reward is ready to claim",
|
"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.",
|
"empty": "You're all caught up. No actions waiting.",
|
||||||
"unavailable": "Some actions could not be checked. Available actions are shown above.",
|
"unavailable": "Some actions could not be checked. Available actions are shown above.",
|
||||||
"retry": "Try again",
|
"retry": "Try again",
|
||||||
|
|||||||
@@ -1107,6 +1107,8 @@
|
|||||||
"transactions": "Transazioni",
|
"transactions": "Transazioni",
|
||||||
"vouchers": "Buoni",
|
"vouchers": "Buoni",
|
||||||
"subscriptions": "Abbonamenti",
|
"subscriptions": "Abbonamenti",
|
||||||
|
"referrals": "Inviti",
|
||||||
|
"dailyRewards": "Ricompense giornaliere",
|
||||||
"rareValues": "Valori rari",
|
"rareValues": "Valori rari",
|
||||||
"badges": "Distintivi",
|
"badges": "Distintivi",
|
||||||
"achievements": "Trofei",
|
"achievements": "Trofei",
|
||||||
@@ -4388,6 +4390,8 @@
|
|||||||
"vouchers": "Voucher",
|
"vouchers": "Voucher",
|
||||||
"subscriptions": "Abbonamenti",
|
"subscriptions": "Abbonamenti",
|
||||||
"calendar": "Calendario",
|
"calendar": "Calendario",
|
||||||
|
"referrals": "Inviti",
|
||||||
|
"dailyRewards": "Ricompense giornaliere",
|
||||||
"history": "Cronologia",
|
"history": "Cronologia",
|
||||||
"moderationTab": "Moderazione",
|
"moderationTab": "Moderazione",
|
||||||
"monitoring": "Monitoraggio",
|
"monitoring": "Monitoraggio",
|
||||||
@@ -6282,6 +6286,7 @@
|
|||||||
"login": "Accedi",
|
"login": "Accedi",
|
||||||
"never": "Mai",
|
"never": "Mai",
|
||||||
"claimed": "Premio riscattato!",
|
"claimed": "Premio riscattato!",
|
||||||
|
"dailyClaimed": "Ricompensa giornaliera riscattata — torna domani!",
|
||||||
"welcome": "Il tuo spazio su {hotel}",
|
"welcome": "Il tuo spazio su {hotel}",
|
||||||
"noMotto": "Nessun motto",
|
"noMotto": "Nessun motto",
|
||||||
"registered": "Iscritto dal {date}",
|
"registered": "Iscritto dal {date}",
|
||||||
@@ -6334,6 +6339,13 @@
|
|||||||
"ratelimit": "Troppi tentativi. Attendi un momento e riprova.",
|
"ratelimit": "Troppi tentativi. Attendi un momento e riprova.",
|
||||||
"error": "Non è stato possibile riscattare il premio. 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",
|
"messagesCaughtUp": "Nessun messaggio da leggere",
|
||||||
"messagesUnavailable": "Apri la posta per controllare",
|
"messagesUnavailable": "Apri la posta per controllare",
|
||||||
"eventTitle": "In programma",
|
"eventTitle": "In programma",
|
||||||
@@ -6344,12 +6356,23 @@
|
|||||||
"eventDetails": "Scopri l’evento",
|
"eventDetails": "Scopri l’evento",
|
||||||
"noUpcomingEvents": "Non sono ancora stati annunciati nuovi eventi.",
|
"noUpcomingEvents": "Non sono ancora stati annunciati nuovi eventi.",
|
||||||
"roomsUnavailable": "Le stanze non sono al momento disponibili. Apri la community per riprovare.",
|
"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": {
|
"tasks": {
|
||||||
"title": "Da fare",
|
"title": "Da fare",
|
||||||
"support": "{count, plural, one {# ticket con risposta del supporto} other {# ticket con risposte del supporto}}",
|
"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}}",
|
"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}",
|
"event": "Il prossimo evento a cui sei iscritto: {title} · {date}",
|
||||||
"reward": "Il premio per gli inviti è pronto da ritirare",
|
"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.",
|
"empty": "Tutto a posto. Nessuna azione in attesa.",
|
||||||
"unavailable": "Non è stato possibile verificare alcune azioni. Quelle disponibili sono mostrate sopra.",
|
"unavailable": "Non è stato possibile verificare alcune azioni. Quelle disponibili sono mostrate sopra.",
|
||||||
"retry": "Riprova",
|
"retry": "Riprova",
|
||||||
|
|||||||
+83
-1
@@ -1050,6 +1050,7 @@
|
|||||||
"emailRequired": "E-mail is verplicht.",
|
"emailRequired": "E-mail is verplicht.",
|
||||||
"passwordRequired": "Wachtwoord is verplicht.",
|
"passwordRequired": "Wachtwoord is verplicht.",
|
||||||
"passwordsDontMatch": "Wachtwoorden komen niet overeen.",
|
"passwordsDontMatch": "Wachtwoorden komen niet overeen.",
|
||||||
|
"invitedBy": "Uitgenodigd door {username}",
|
||||||
"accepted": "Accepted & agreed",
|
"accepted": "Accepted & agreed",
|
||||||
"showPassword": "Show",
|
"showPassword": "Show",
|
||||||
"hidePassword": "Hide",
|
"hidePassword": "Hide",
|
||||||
@@ -1140,6 +1141,8 @@
|
|||||||
"transactions": "Transacties",
|
"transactions": "Transacties",
|
||||||
"vouchers": "Vouchers",
|
"vouchers": "Vouchers",
|
||||||
"subscriptions": "Abonnementen",
|
"subscriptions": "Abonnementen",
|
||||||
|
"referrals": "Uitnodigingen",
|
||||||
|
"dailyRewards": "Dagelijkse beloningen",
|
||||||
"rareValues": "Zeldzame waarden",
|
"rareValues": "Zeldzame waarden",
|
||||||
"badges": "Badges",
|
"badges": "Badges",
|
||||||
"achievements": "Prestaties",
|
"achievements": "Prestaties",
|
||||||
@@ -3061,6 +3064,64 @@
|
|||||||
"updated": "Voucher updated",
|
"updated": "Voucher updated",
|
||||||
"updateError": "Could not update voucher"
|
"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": {
|
"subscriptions": {
|
||||||
"title": "Abonnementen",
|
"title": "Abonnementen",
|
||||||
"subtitle": "Actieve terugkerende abonnementen",
|
"subtitle": "Actieve terugkerende abonnementen",
|
||||||
@@ -4688,6 +4749,8 @@
|
|||||||
"vouchers": "Vouchers",
|
"vouchers": "Vouchers",
|
||||||
"subscriptions": "Abonnementen",
|
"subscriptions": "Abonnementen",
|
||||||
"calendar": "Kalender",
|
"calendar": "Kalender",
|
||||||
|
"referrals": "Uitnodigingen",
|
||||||
|
"dailyRewards": "Dagelijkse beloningen",
|
||||||
"history": "Geschiedenis",
|
"history": "Geschiedenis",
|
||||||
"moderationTab": "Moderatie",
|
"moderationTab": "Moderatie",
|
||||||
"monitoring": "Bewaking",
|
"monitoring": "Bewaking",
|
||||||
@@ -6172,6 +6235,7 @@
|
|||||||
"friends": "{count, plural, one {# vriendschapsverzoek om te bekijken} other {# vriendschapsverzoeken om te bekijken}}",
|
"friends": "{count, plural, one {# vriendschapsverzoek om te bekijken} other {# vriendschapsverzoeken om te bekijken}}",
|
||||||
"event": "Je volgende aangemelde evenement: {title} · {date}",
|
"event": "Je volgende aangemelde evenement: {title} · {date}",
|
||||||
"reward": "Je beloning voor uitnodigingen staat klaar",
|
"reward": "Je beloning voor uitnodigingen staat klaar",
|
||||||
|
"dailyReward": "Je dagelijkse beloning staat klaar",
|
||||||
"empty": "Alles is bijgewerkt. Er zijn geen acties nodig.",
|
"empty": "Alles is bijgewerkt. Er zijn geen acties nodig.",
|
||||||
"unavailable": "Sommige acties konden niet worden gecontroleerd. Beschikbare acties staan hierboven.",
|
"unavailable": "Sommige acties konden niet worden gecontroleerd. Beschikbare acties staan hierboven.",
|
||||||
"retry": "Opnieuw proberen",
|
"retry": "Opnieuw proberen",
|
||||||
@@ -6183,6 +6247,7 @@
|
|||||||
"login": "Sign in",
|
"login": "Sign in",
|
||||||
"never": "Never",
|
"never": "Never",
|
||||||
"claimed": "Reward claimed successfully!",
|
"claimed": "Reward claimed successfully!",
|
||||||
|
"dailyClaimed": "Dagelijkse beloning geclaimd — tot morgen!",
|
||||||
"welcome": "Your space on {hotel}",
|
"welcome": "Your space on {hotel}",
|
||||||
"noMotto": "No motto yet",
|
"noMotto": "No motto yet",
|
||||||
"registered": "Registered: {date}",
|
"registered": "Registered: {date}",
|
||||||
@@ -6234,7 +6299,24 @@
|
|||||||
"bad_config": "The reward is not configured correctly. Contact the staff.",
|
"bad_config": "The reward is not configured correctly. Contact the staff.",
|
||||||
"ratelimit": "Too many attempts. Wait a moment and try again.",
|
"ratelimit": "Too many attempts. Wait a moment and try again.",
|
||||||
"error": "The reward could not be claimed. Please 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": {
|
"search": {
|
||||||
"title": "Zoeken",
|
"title": "Zoeken",
|
||||||
|
|||||||
Reference in new issue
Block a user