diff --git a/drizzle/migrations/0033_referrals_daily_rewards.sql b/drizzle/migrations/0033_referrals_daily_rewards.sql new file mode 100644 index 00000000..4354f142 --- /dev/null +++ b/drizzle/migrations/0033_referrals_daily_rewards.sql @@ -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 +); \ No newline at end of file diff --git a/src/actions/admin-daily-rewards.ts b/src/actions/admin-daily-rewards.ts new file mode 100644 index 00000000..715d8d8b --- /dev/null +++ b/src/actions/admin-daily-rewards.ts @@ -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 { + 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 { + 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 { + 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"); + } +} diff --git a/src/actions/admin-referrals.ts b/src/actions/admin-referrals.ts new file mode 100644 index 00000000..7d1bfe6c --- /dev/null +++ b/src/actions/admin-referrals.ts @@ -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 { + 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"); + } +} diff --git a/src/actions/daily-reward.ts b/src/actions/daily-reward.ts new file mode 100644 index 00000000..b1037128 --- /dev/null +++ b/src/actions/daily-reward.ts @@ -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= 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 { + 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}`); +} diff --git a/src/actions/register.ts b/src/actions/register.ts index ddcc73f0..5394c135 100644 --- a/src/actions/register.ts +++ b/src/actions/register.ts @@ -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=`). 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) { diff --git a/src/app/(site)/me/DailyRewardCard.tsx b/src/app/(site)/me/DailyRewardCard.tsx new file mode 100644 index 00000000..33ede61f --- /dev/null +++ b/src/app/(site)/me/DailyRewardCard.tsx @@ -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 ( + +

+ {t("dailyRewardDisabled")} +

+
+ ); + } + + if (state.rewards.length === 0) { + return ( + +

+ {t("dailyRewardUnavailable")} +

+
+ ); + } + + const upcoming = state.alreadyClaimedToday + ? rewardForStreak(state.rewards, state.nextStreak + 1) + : state.nextReward; + + return ( + +
+

{t("dailyRewardHint")}

+
    + {state.rewards.map((reward) => ( +
  • + {t("dailyRewardDay", { day: reward.day })} + + {reward.amount} +
  • + ))} +
+

+ {t("dailyRewardStreak", { count: state.nextStreak })} +

+ {upcoming && ( +

+ + {t( + state.alreadyClaimedToday + ? "dailyRewardTomorrow" + : "dailyRewardNextClaim", + { + amount: upcoming.amount, + currency: t.has(`currencies.${upcoming.currency}`) + ? t(`currencies.${upcoming.currency}`) + : upcoming.currency, + }, + )} +

+ )} + {state.alreadyClaimedToday ? ( +

+ {t("dailyRewardClaimedToday")} +

+ ) : ( +
+ +
+ )} + + ); +} diff --git a/src/app/(site)/me/DashboardTasks.tsx b/src/app/(site)/me/DashboardTasks.tsx index 2ce79a82..76a18c38 100644 --- a/src/app/(site)/me/DashboardTasks.tsx +++ b/src/app/(site)/me/DashboardTasks.tsx @@ -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 || diff --git a/src/app/(site)/me/dashboard.module.css b/src/app/(site)/me/dashboard.module.css index 5dd2f434..1130932d 100644 --- a/src/app/(site)/me/dashboard.module.css +++ b/src/app/(site)/me/dashboard.module.css @@ -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; +} diff --git a/src/app/(site)/me/page.tsx b/src/app/(site)/me/page.tsx index b18d004e..9c35d478 100644 --- a/src/app/(site)/me/page.tsx +++ b/src/app/(site)/me/page.tsx @@ -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({ ); } + // 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}`)}

)} + {daily === "claimed" && ( +

+ {t("dailyClaimed")} +

+ )} + {daily && + daily !== "claimed" && + [ + "already_claimed", + "disabled", + "bad_config", + "ratelimit", + "error", + ].includes(daily) && ( +

+ {t(`dailyErrors.${daily}`)} +

+ )}
{wallet.map((currency) => ( @@ -356,6 +386,7 @@ async function MePage({