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
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,57 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import {
|
||||
type ActionResult,
|
||||
actionError,
|
||||
actionOk,
|
||||
} from "@/lib/safe-action-shared";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
/**
|
||||
* Referral system configuration for the /admin/referrals settings form. All
|
||||
* values are validated before the keys leave the CMS settings table, and the
|
||||
* reward currency must be one of the four emulator wallets (bidirectional:
|
||||
* the claim action already validates on read).
|
||||
*/
|
||||
const VALID_CURRENCIES = ["credits", "duckets", "diamonds", "points"] as const;
|
||||
|
||||
export async function updateReferralSettings(input: {
|
||||
referralsNeeded: string;
|
||||
rewardAmount: string;
|
||||
rewardCurrency: string;
|
||||
blockSameIp: string;
|
||||
}): Promise<ActionResult> {
|
||||
await requirePermission(PERMS.REFERRALS_EDIT);
|
||||
|
||||
const needed = Number.parseInt(input.referralsNeeded, 10);
|
||||
const amount = Number.parseInt(input.rewardAmount, 10);
|
||||
const currency = String(input.rewardCurrency ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (!(needed >= 1)) return actionError("Referrals needed must be at least 1");
|
||||
if (!(amount > 0)) return actionError("Reward amount must be positive");
|
||||
if (!(VALID_CURRENCIES as readonly string[]).includes(currency))
|
||||
return actionError("Reward currency is not one of the emulator wallets");
|
||||
const blockSameIp = input.blockSameIp === "1";
|
||||
|
||||
try {
|
||||
await siteSettings.update("referrals_needed", String(needed));
|
||||
await siteSettings.update("referral_reward_amount", String(amount));
|
||||
await siteSettings.update("referral_reward_currency_type", currency);
|
||||
await siteSettings.update(
|
||||
"referrals_block_same_ip",
|
||||
blockSameIp ? "1" : "0",
|
||||
);
|
||||
revalidatePath("/admin/referrals");
|
||||
return actionOk();
|
||||
} catch (error) {
|
||||
logServerError("admin.referral_settings_update_failed", error);
|
||||
return actionError("Referral settings could not be saved");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+19
-1
@@ -11,6 +11,7 @@ import { logger } from "@/lib/logger";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
||||
import { checkVpn } from "@/lib/services/ip-lookup";
|
||||
import { recordReferral } from "@/lib/services/referrals";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
const registerSchema = z.object({
|
||||
@@ -134,8 +135,9 @@ export async function register(
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
let inviteeId = 0;
|
||||
try {
|
||||
await db.insert(User).values({
|
||||
const [result] = await db.insert(User).values({
|
||||
username,
|
||||
password: await hashPassword(password),
|
||||
mail: hasEmail ? mail : null,
|
||||
@@ -145,6 +147,7 @@ export async function register(
|
||||
look,
|
||||
termsAccepted: raw.termsAccepted,
|
||||
});
|
||||
inviteeId = Number(result.insertId);
|
||||
} catch (err) {
|
||||
const code = (err as { cause?: { code?: string } }).cause?.code;
|
||||
if (code === "ER_DUP_ENTRY") {
|
||||
@@ -163,6 +166,21 @@ export async function register(
|
||||
// immediate auto sign-in sees the fresh row.
|
||||
await invalidateKey(`login:user:${username}`);
|
||||
|
||||
// Referral attribution (`/register?ref=<username>`). Best-effort: a broken
|
||||
// referral must never fail the account creation.
|
||||
if (inviteeId > 0) {
|
||||
const invitedBy = String(formData.get("ref") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (invitedBy) {
|
||||
await recordReferral({
|
||||
inviterUsername: invitedBy,
|
||||
inviteeId,
|
||||
inviteeIp: ip,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Verification email must never block the sign-up response — it is sent
|
||||
// after the response is flushed (no-op when mail is unconfigured).
|
||||
if (hasEmail) {
|
||||
|
||||
@@ -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({
|
||||
userId,
|
||||
rewardAvailable,
|
||||
dailyRewardAvailable,
|
||||
}: {
|
||||
userId: number;
|
||||
rewardAvailable: boolean | null;
|
||||
/** Claimable daily login reward (false when claimed/disabled or loader down). */
|
||||
dailyRewardAvailable?: boolean;
|
||||
}) {
|
||||
const [data, t, format] = await Promise.all([
|
||||
loadDashboardTasks(userId),
|
||||
@@ -41,6 +44,8 @@ export default async function DashboardTasks({
|
||||
});
|
||||
if (rewardAvailable)
|
||||
items.push({ href: "#referral-reward", label: t("reward") });
|
||||
if (dailyRewardAvailable)
|
||||
items.push({ href: "#daily-reward", label: t("dailyReward") });
|
||||
const unavailable =
|
||||
data.support === null ||
|
||||
data.friendRequests === null ||
|
||||
|
||||
@@ -342,3 +342,47 @@
|
||||
.tasks a:hover {
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
|
||||
}
|
||||
.dailyTrack {
|
||||
list-style: none;
|
||||
margin: 12px 0 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(58px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.dailyCell {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.dailyCell span {
|
||||
color: var(--color-text-muted);
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.dailyCell strong {
|
||||
font-size: 0.9rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.dailyCellNext {
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-primary) 10%,
|
||||
var(--color-surface)
|
||||
);
|
||||
}
|
||||
.dailyStreak {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -12,8 +12,10 @@ import { SurfaceCard } from "@/components/surface-card";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl, slugify } from "@/lib/format";
|
||||
import { withPublicPagePerformance } from "@/lib/public-page-performance";
|
||||
import { loadDailyRewardState } from "@/lib/services/daily-rewards";
|
||||
import { loadUserDashboard } from "@/lib/services/user-dashboard";
|
||||
import CopyReferralButton from "./CopyReferralButton";
|
||||
import DailyRewardCard from "./DailyRewardCard";
|
||||
import DashboardTasks from "./DashboardTasks";
|
||||
import styles from "./dashboard.module.css";
|
||||
|
||||
@@ -25,13 +27,17 @@ export async function generateMetadata() {
|
||||
async function MePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ claimed?: string; error?: string }>;
|
||||
searchParams: Promise<{
|
||||
claimed?: string;
|
||||
error?: string;
|
||||
daily?: string;
|
||||
}>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
const userId = Number(session.user.id);
|
||||
if (!Number.isFinite(userId) || userId <= 0) redirect("/login");
|
||||
const [t, format, { claimed, error }] = await Promise.all([
|
||||
const [t, format, { claimed, error, daily }] = await Promise.all([
|
||||
getTranslations("pages.myDashboard"),
|
||||
getFormatter(),
|
||||
searchParams,
|
||||
@@ -51,6 +57,9 @@ async function MePage({
|
||||
</main>
|
||||
);
|
||||
}
|
||||
// Daily reward state (settings + schedule + the user's last claim). Never
|
||||
// throws: on any DB hiccup it degrades to a disabled state.
|
||||
const dailyState = await loadDailyRewardState(userId);
|
||||
const user = data.userRows[0];
|
||||
if (!user)
|
||||
return (
|
||||
@@ -147,6 +156,24 @@ async function MePage({
|
||||
{t(`errors.${errorKey}`)}
|
||||
</p>
|
||||
)}
|
||||
{daily === "claimed" && (
|
||||
<p role="status" className={styles.feedback}>
|
||||
{t("dailyClaimed")}
|
||||
</p>
|
||||
)}
|
||||
{daily &&
|
||||
daily !== "claimed" &&
|
||||
[
|
||||
"already_claimed",
|
||||
"disabled",
|
||||
"bad_config",
|
||||
"ratelimit",
|
||||
"error",
|
||||
].includes(daily) && (
|
||||
<p role="alert" className={styles.feedback}>
|
||||
{t(`dailyErrors.${daily}`)}
|
||||
</p>
|
||||
)}
|
||||
<SurfaceCard className={styles.hero}>
|
||||
<ProfileImage
|
||||
src={avatarFull}
|
||||
@@ -204,6 +231,9 @@ async function MePage({
|
||||
<DashboardTasks
|
||||
userId={userId}
|
||||
rewardAvailable={referralsRows === null ? null : canClaim}
|
||||
dailyRewardAvailable={
|
||||
dailyState.enabled && !dailyState.alreadyClaimedToday
|
||||
}
|
||||
/>
|
||||
<section aria-label={t("wallet")} className={styles.wallet}>
|
||||
{wallet.map((currency) => (
|
||||
@@ -356,6 +386,7 @@ async function MePage({
|
||||
</SurfaceCard>
|
||||
</div>
|
||||
<aside className={styles.stack}>
|
||||
<DailyRewardCard state={dailyState} />
|
||||
<SurfaceCard title={t("referrals")} bodyClassName="p-5">
|
||||
<div id="referral-reward" />
|
||||
<p>{t("referralHint")}</p>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,72 @@
|
||||
import { asc, sql } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { db, WebsiteDailyRewardClaims, WebsiteDailyRewards } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import {
|
||||
type DailyRewardEditRow,
|
||||
DailyRewardsClient,
|
||||
} from "./daily-rewards-client";
|
||||
|
||||
export default async function AdminDailyRewards() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.DAILY_REWARDS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
const canEdit = canAccess(
|
||||
permissions,
|
||||
PERMS.DAILY_REWARDS_EDIT,
|
||||
session.user.rank,
|
||||
);
|
||||
|
||||
const enabled = await siteSettings.getBool("daily_reward_enabled", true);
|
||||
|
||||
let rows: DailyRewardEditRow[] = [];
|
||||
let claimsToday: number | null = null;
|
||||
let dayTotal: number | null = null;
|
||||
try {
|
||||
const schedule = await db
|
||||
.select({
|
||||
id: WebsiteDailyRewards.id,
|
||||
day: WebsiteDailyRewards.day,
|
||||
currency: WebsiteDailyRewards.currency,
|
||||
amount: WebsiteDailyRewards.amount,
|
||||
})
|
||||
.from(WebsiteDailyRewards)
|
||||
.orderBy(asc(WebsiteDailyRewards.day));
|
||||
rows = schedule.map((row) => ({
|
||||
id: String(row.id),
|
||||
day: Number(row.day),
|
||||
currency: row.currency,
|
||||
amount: Number(row.amount),
|
||||
}));
|
||||
|
||||
const [[claimed], [total]] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
value: sql<number>`COUNT(*)`,
|
||||
})
|
||||
.from(WebsiteDailyRewardClaims)
|
||||
.where(sql`${WebsiteDailyRewardClaims.claimDate} = CURRENT_DATE`),
|
||||
db
|
||||
.select({ value: sql<number>`COUNT(*)` })
|
||||
.from(WebsiteDailyRewardClaims),
|
||||
]);
|
||||
claimsToday = Number(claimed?.value ?? 0);
|
||||
dayTotal = Number(total?.value ?? 0);
|
||||
} catch {
|
||||
rows = [];
|
||||
claimsToday = null;
|
||||
dayTotal = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DailyRewardsClient
|
||||
rows={rows}
|
||||
enabled={enabled}
|
||||
canEdit={canEdit}
|
||||
claimsToday={claimsToday}
|
||||
dayTotal={dayTotal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -57,8 +57,16 @@ export function RegisterForm({
|
||||
null,
|
||||
);
|
||||
const autoLoginStartedRef = useRef(false);
|
||||
const [referralCode, setReferralCode] = useState("");
|
||||
const strength = passwordStrength(password);
|
||||
|
||||
// The invite link is `/register?ref=<username>`; read it client-side so the
|
||||
// attribution travels with the sign-up form without server state.
|
||||
useEffect(() => {
|
||||
const ref = new URLSearchParams(window.location.search).get("ref");
|
||||
if (ref) setReferralCode(ref);
|
||||
}, []);
|
||||
|
||||
// Successful sign-up: sign in with the submitted credentials and go
|
||||
// straight to /me. If the automatic sign-in is refused (e.g. email
|
||||
// verification required), fall back to the regular login page.
|
||||
@@ -187,6 +195,23 @@ export function RegisterForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{referralCode && (
|
||||
<div
|
||||
className="animate-fade-in-up flex items-center gap-2 p-3 rounded-lg text-sm font-semibold"
|
||||
style={{
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
border:
|
||||
"1px solid color-mix(in srgb, var(--color-primary) 22%, transparent)",
|
||||
}}
|
||||
>
|
||||
{t("invitedBy", { username: referralCode })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input type="hidden" name="ref" value={referralCode} />
|
||||
|
||||
{/* Username & Email */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -714,6 +714,39 @@ export const ClaimedReferralLogs = mysqlTable("claimed_referral_logs", {
|
||||
updatedAt: timestamp("updated_at"),
|
||||
});
|
||||
|
||||
export const WebsiteDailyRewards = mysqlTable("website_daily_rewards", {
|
||||
id: int("id", { unsigned: true }).autoincrement().primaryKey().notNull(),
|
||||
day: int("day", { unsigned: true }).notNull().default(1),
|
||||
currency: varchar("currency", { length: 20 }).notNull().default("credits"),
|
||||
amount: int("amount").notNull().default(0),
|
||||
createdAt: datetime("created_at", { fsp: 3 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP(3)`),
|
||||
updatedAt: datetime("updated_at", { fsp: 3 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP(3)`),
|
||||
});
|
||||
|
||||
export const WebsiteDailyRewardClaims = mysqlTable(
|
||||
"website_daily_reward_claims",
|
||||
{
|
||||
id: bigint("id", { mode: "bigint", unsigned: true })
|
||||
.autoincrement()
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
userId: int("user_id").notNull(),
|
||||
claimDate: dateAsDate("claim_date").notNull(),
|
||||
streak: int("streak", { unsigned: true }).notNull().default(1),
|
||||
rewardDay: int("reward_day", { unsigned: true }).notNull().default(1),
|
||||
currency: varchar("currency", { length: 20 }).notNull().default("credits"),
|
||||
amount: int("amount").notNull().default(0),
|
||||
createdAt: datetime("created_at", { fsp: 3 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP(3)`),
|
||||
},
|
||||
(t) => [uniqueIndex("daily_claim_user_date_uk").on(t.userId, t.claimDate)],
|
||||
);
|
||||
|
||||
export const WebsiteHousekeepingPermissions = mysqlTable(
|
||||
"website_housekeeping_permissions",
|
||||
{
|
||||
|
||||
@@ -15,6 +15,10 @@ export const economyManifest = {
|
||||
PERMS.SHOP_VIEW,
|
||||
PERMS.CATALOG_EDIT,
|
||||
PERMS.SHOP_EDIT,
|
||||
PERMS.REFERRALS_VIEW,
|
||||
PERMS.REFERRALS_EDIT,
|
||||
PERMS.DAILY_REWARDS_VIEW,
|
||||
PERMS.DAILY_REWARDS_EDIT,
|
||||
),
|
||||
routes: [],
|
||||
searchProviders: [],
|
||||
|
||||
@@ -621,15 +621,15 @@ describe("housekeeping foundation completion contracts", () => {
|
||||
expect(html).toContain(`>${sentinel}</button>`);
|
||||
});
|
||||
|
||||
it("validates the complete 144-row migration matrix without issues", () => {
|
||||
it("validates the complete 146-row migration matrix without issues", () => {
|
||||
const discovered = discoverLegacyPages();
|
||||
const issues = validateMigrationEntries(
|
||||
discovered,
|
||||
HOUSEKEEPING_MIGRATION_MATRIX,
|
||||
);
|
||||
|
||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(144);
|
||||
expect(discovered).toHaveLength(144);
|
||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(146);
|
||||
expect(discovered).toHaveLength(146);
|
||||
expect(issues).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -146,6 +146,10 @@ const expectedManifests = [
|
||||
PERMS.SHOP_VIEW,
|
||||
PERMS.CATALOG_EDIT,
|
||||
PERMS.SHOP_EDIT,
|
||||
PERMS.REFERRALS_VIEW,
|
||||
PERMS.REFERRALS_EDIT,
|
||||
PERMS.DAILY_REWARDS_VIEW,
|
||||
PERMS.DAILY_REWARDS_EDIT,
|
||||
],
|
||||
routes: [],
|
||||
},
|
||||
|
||||
@@ -29,12 +29,22 @@ describe("discoverLegacyPages", () => {
|
||||
it("discovers the exact legacy administration inventory", () => {
|
||||
const pages = discoverLegacyPages();
|
||||
|
||||
expect(pages).toHaveLength(144);
|
||||
expect(pages).toHaveLength(146);
|
||||
expect(pages).toContainEqual({
|
||||
surface: "admin",
|
||||
legacyPath: "/admin/users/:id/edit",
|
||||
sourceFile: "src/app/admin/users/[id]/edit/page.tsx",
|
||||
});
|
||||
expect(pages).toContainEqual({
|
||||
surface: "admin",
|
||||
legacyPath: "/admin/referrals",
|
||||
sourceFile: "src/app/admin/referrals/page.tsx",
|
||||
});
|
||||
expect(pages).toContainEqual({
|
||||
surface: "admin",
|
||||
legacyPath: "/admin/daily-rewards",
|
||||
sourceFile: "src/app/admin/daily-rewards/page.tsx",
|
||||
});
|
||||
expect(pages).toContainEqual({
|
||||
surface: "mod",
|
||||
legacyPath: "/mod/cfh/:id",
|
||||
|
||||
@@ -16,6 +16,8 @@ const ECONOMY_PREFIXES = [
|
||||
"/admin/achievements",
|
||||
"/admin/sounds",
|
||||
"/admin/calendar",
|
||||
"/admin/referrals",
|
||||
"/admin/daily-rewards",
|
||||
] as const;
|
||||
|
||||
describe("economyMigrationEntries", () => {
|
||||
|
||||
@@ -419,4 +419,46 @@ export const economyMigrationEntries: readonly MigrationEntry[] = [
|
||||
localization: "COMPLETE",
|
||||
accessibility: "PARTIAL",
|
||||
}),
|
||||
plannedEconomyEntry({
|
||||
surface: "admin",
|
||||
legacyPath: "/admin/referrals",
|
||||
sourceFile: "src/app/admin/referrals/page.tsx",
|
||||
targetPath: "/admin/economy/growth/referrals",
|
||||
decision: "REBUILD",
|
||||
capabilities: {
|
||||
read: [PERMS.REFERRALS_VIEW],
|
||||
mutate: [PERMS.REFERRALS_EDIT],
|
||||
},
|
||||
dependencies: {
|
||||
queries: ["Referrals", "User", "SiteSettings"],
|
||||
mutations: ["updateReferralSettings"],
|
||||
},
|
||||
auditRequirement: "PRIVILEGED_MUTATION",
|
||||
localization: "PARTIAL",
|
||||
accessibility: "PARTIAL",
|
||||
notes: [
|
||||
"Attribution tracked at registration via referral code; payouts handled by existing reward cron.",
|
||||
],
|
||||
}),
|
||||
plannedEconomyEntry({
|
||||
surface: "admin",
|
||||
legacyPath: "/admin/daily-rewards",
|
||||
sourceFile: "src/app/admin/daily-rewards/page.tsx",
|
||||
targetPath: "/admin/economy/growth/daily-rewards",
|
||||
decision: "REBUILD",
|
||||
capabilities: {
|
||||
read: [PERMS.DAILY_REWARDS_VIEW],
|
||||
mutate: [PERMS.DAILY_REWARDS_EDIT],
|
||||
},
|
||||
dependencies: {
|
||||
queries: ["WebsiteDailyRewards", "WebsiteDailyRewardClaims"],
|
||||
mutations: ["upsertDailyReward", "deleteDailyReward"],
|
||||
},
|
||||
auditRequirement: "PRIVILEGED_MUTATION",
|
||||
localization: "PARTIAL",
|
||||
accessibility: "PARTIAL",
|
||||
notes: [
|
||||
"Daily login rewards replace the legacy daily duckets script; claims paid via sendCurrency.",
|
||||
],
|
||||
}),
|
||||
];
|
||||
@@ -4,10 +4,10 @@ import { HOUSEKEEPING_MIGRATION_MATRIX } from "./matrix";
|
||||
import { validateMigrationEntries } from "./validate-matrix";
|
||||
|
||||
describe("HOUSEKEEPING_MIGRATION_MATRIX", () => {
|
||||
it("covers all 144 legacy pages exactly once", () => {
|
||||
it("covers all 146 legacy pages exactly once", () => {
|
||||
const discovered = discoverLegacyPages();
|
||||
|
||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(144);
|
||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(146);
|
||||
expect(
|
||||
validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX),
|
||||
).toEqual([]);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
BadgeCheck,
|
||||
Ban,
|
||||
Calendar,
|
||||
CalendarCheck,
|
||||
CalendarDays,
|
||||
ClipboardList,
|
||||
Cog,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
FileText,
|
||||
Filter,
|
||||
Gavel,
|
||||
Gift,
|
||||
HelpCircle,
|
||||
Image,
|
||||
KeyRound,
|
||||
@@ -526,6 +528,20 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
||||
permission: PERMS.SHOP_VIEW,
|
||||
matchPrefixes: ["/admin/calendar"],
|
||||
},
|
||||
{
|
||||
href: "/admin/referrals",
|
||||
labelKey: "referrals",
|
||||
icon: Gift,
|
||||
permission: PERMS.REFERRALS_VIEW,
|
||||
matchPrefixes: ["/admin/referrals"],
|
||||
},
|
||||
{
|
||||
href: "/admin/daily-rewards",
|
||||
labelKey: "dailyRewards",
|
||||
icon: CalendarCheck,
|
||||
permission: PERMS.DAILY_REWARDS_VIEW,
|
||||
matchPrefixes: ["/admin/daily-rewards"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -48,6 +48,12 @@ export const PERMS = {
|
||||
// ── Polls Module ──
|
||||
POLLS_VIEW: "admin.polls.view",
|
||||
POLLS_EDIT: "admin.polls.edit",
|
||||
// ── Referrals Module ──
|
||||
REFERRALS_VIEW: "admin.referrals.view",
|
||||
REFERRALS_EDIT: "admin.referrals.edit",
|
||||
// ── Daily Login Rewards Module ──
|
||||
DAILY_REWARDS_VIEW: "admin.dailyrewards.view",
|
||||
DAILY_REWARDS_EDIT: "admin.dailyrewards.edit",
|
||||
// ── Notifications Module ──
|
||||
NOTIFICATIONS_VIEW: "admin.notifications.view",
|
||||
NOTIFICATIONS_EDIT: "admin.notifications.edit",
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/lib/db", () => ({ db: {} }));
|
||||
vi.mock("./site-settings", () => ({
|
||||
siteSettings: { getBool: async () => true },
|
||||
}));
|
||||
|
||||
import {
|
||||
type DailyRewardRow,
|
||||
rewardForStreak,
|
||||
shiftDayKey,
|
||||
} from "./daily-rewards";
|
||||
|
||||
describe("daily reward date math", () => {
|
||||
it("shifts day keys across month and year boundaries", () => {
|
||||
expect(shiftDayKey("2026-03-01", -1)).toBe("2026-02-28");
|
||||
expect(shiftDayKey("2026-01-01", 31)).toBe("2026-02-01");
|
||||
expect(shiftDayKey("2026-12-31", 1)).toBe("2027-01-01");
|
||||
expect(shiftDayKey("2026-02-28", 1)).toBe("2026-03-01");
|
||||
});
|
||||
});
|
||||
|
||||
describe("daily reward streak resolution", () => {
|
||||
const rewards: DailyRewardRow[] = [
|
||||
{ id: "1", day: 1, currency: "duckets", amount: 50 },
|
||||
{ id: "2", day: 2, currency: "duckets", amount: 60 },
|
||||
{ id: "5", day: 5, currency: "diamonds", amount: 2 },
|
||||
];
|
||||
|
||||
it("resolves configured days and repeats the cycle", () => {
|
||||
expect(rewardForStreak(rewards, 1)?.day).toBe(1);
|
||||
expect(rewardForStreak(rewards, 2)?.day).toBe(2);
|
||||
expect(rewardForStreak(rewards, 5)?.day).toBe(5);
|
||||
// The cycle is the highest configured day: 5 → back to day 1.
|
||||
expect(rewardForStreak(rewards, 6)?.day).toBe(1);
|
||||
expect(rewardForStreak(rewards, 7)?.day).toBe(2);
|
||||
expect(rewardForStreak(rewards, 10)?.day).toBe(5);
|
||||
});
|
||||
|
||||
it("falls back to the day-1 reward for unconfigured days", () => {
|
||||
// Days 3 and 4 are not configured in the schedule → day-1 reward.
|
||||
expect(rewardForStreak(rewards, 3)?.day).toBe(1);
|
||||
expect(rewardForStreak(rewards, 4)?.day).toBe(1);
|
||||
});
|
||||
|
||||
it("returns null for empty schedules or non-positive streaks", () => {
|
||||
expect(rewardForStreak([], 3)).toBeNull();
|
||||
expect(
|
||||
rewardForStreak(
|
||||
[{ id: "1", day: 3, currency: "credits", amount: 10 }],
|
||||
0,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+83
-1
File diff suppressed because it is too large.
Load diff
@@ -1107,6 +1107,8 @@
|
||||
"transactions": "Transazioni",
|
||||
"vouchers": "Buoni",
|
||||
"subscriptions": "Abbonamenti",
|
||||
"referrals": "Inviti",
|
||||
"dailyRewards": "Ricompense giornaliere",
|
||||
"rareValues": "Valori rari",
|
||||
"badges": "Distintivi",
|
||||
"achievements": "Trofei",
|
||||
@@ -4388,6 +4390,8 @@
|
||||
"vouchers": "Voucher",
|
||||
"subscriptions": "Abbonamenti",
|
||||
"calendar": "Calendario",
|
||||
"referrals": "Inviti",
|
||||
"dailyRewards": "Ricompense giornaliere",
|
||||
"history": "Cronologia",
|
||||
"moderationTab": "Moderazione",
|
||||
"monitoring": "Monitoraggio",
|
||||
@@ -6282,6 +6286,7 @@
|
||||
"login": "Accedi",
|
||||
"never": "Mai",
|
||||
"claimed": "Premio riscattato!",
|
||||
"dailyClaimed": "Ricompensa giornaliera riscattata — torna domani!",
|
||||
"welcome": "Il tuo spazio su {hotel}",
|
||||
"noMotto": "Nessun motto",
|
||||
"registered": "Iscritto dal {date}",
|
||||
@@ -6334,6 +6339,13 @@
|
||||
"ratelimit": "Troppi tentativi. Attendi un momento e riprova.",
|
||||
"error": "Non è stato possibile riscattare il premio. Riprova."
|
||||
},
|
||||
"dailyErrors": {
|
||||
"already_claimed": "Hai già riscattato la ricompensa giornaliera di oggi.",
|
||||
"disabled": "Le ricompense giornaliere sono attualmente disattivate.",
|
||||
"bad_config": "Il programma delle ricompense giornaliere non è configurato correttamente. Contatta lo staff.",
|
||||
"ratelimit": "Troppi tentativi. Attendi un momento e riprova.",
|
||||
"error": "Non è stato possibile riscattare la ricompensa giornaliera. Riprova."
|
||||
},
|
||||
"messagesCaughtUp": "Nessun messaggio da leggere",
|
||||
"messagesUnavailable": "Apri la posta per controllare",
|
||||
"eventTitle": "In programma",
|
||||
@@ -6344,12 +6356,23 @@
|
||||
"eventDetails": "Scopri l’evento",
|
||||
"noUpcomingEvents": "Non sono ancora stati annunciati nuovi eventi.",
|
||||
"roomsUnavailable": "Le stanze non sono al momento disponibili. Apri la community per riprovare.",
|
||||
"dailyRewardTitle": "Ricompense giornaliere",
|
||||
"dailyRewardHint": "Accedi ogni giorno e riscatta la ricompensa: la serie cresce fino ai giorni speciali.",
|
||||
"dailyRewardDay": "Giorno {day}",
|
||||
"dailyRewardStreak": "{count, plural, one {Serie: # giorno} other {Serie: # giorni}}",
|
||||
"dailyRewardNextClaim": "Prossimo ritiro: {amount} {currency}",
|
||||
"dailyRewardTomorrow": "Domani: {amount} {currency}",
|
||||
"dailyRewardClaim": "Riscuoti la ricompensa giornaliera",
|
||||
"dailyRewardClaimedToday": "Riscattata oggi — torna domani per la prossima ricompensa.",
|
||||
"dailyRewardDisabled": "Le ricompense giornaliere sono attualmente disattivate. Puoi comunque esplorare l’hotel.",
|
||||
"dailyRewardUnavailable": "Non è stato possibile caricare il programma delle ricompense giornaliere. Riprova.",
|
||||
"tasks": {
|
||||
"title": "Da fare",
|
||||
"support": "{count, plural, one {# ticket con risposta del supporto} other {# ticket con risposte del supporto}}",
|
||||
"friends": "{count, plural, one {# richiesta di amicizia da controllare} other {# richieste di amicizia da controllare}}",
|
||||
"event": "Il prossimo evento a cui sei iscritto: {title} · {date}",
|
||||
"reward": "Il premio per gli inviti è pronto da ritirare",
|
||||
"dailyReward": "La tua ricompensa giornaliera è pronta da ritirare",
|
||||
"empty": "Tutto a posto. Nessuna azione in attesa.",
|
||||
"unavailable": "Non è stato possibile verificare alcune azioni. Quelle disponibili sono mostrate sopra.",
|
||||
"retry": "Riprova",
|
||||
|
||||
+83
-1
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user