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 { 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>
|
||||||
|
|||||||
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,
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
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",
|
"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
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user