merge(modules): gate achievements behind the achievements module flag

# Conflicts:
#	src/hooks/use-auth.ts
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 21:57:43 +02:00
commit 6bde45ce0a
10 files changed
+349 -7

No files matched your search

@@ -0,0 +1,70 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.18.0 — `<AchievementUnlockNotifier>` honours the achievements module
* gate. When the module is disabled the polling query is never enabled,
* so no unlock toast can surface — the engagement surface disappears in
* lock-step with the page, the dashboard tile, and the 403 route.
*/
let mockUser: { modules?: Record<string, boolean> } | null = {};
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true, user: mockUser }),
}));
const useAchievementsQuery = vi.fn(
(_opts?: { enabled?: boolean }) => ({ data: undefined }),
);
vi.mock("@/lib/queries/use-achievements-query", () => ({
useAchievementsQuery: (opts?: { enabled?: boolean }) =>
useAchievementsQuery(opts),
}));
const toast = vi.fn();
vi.mock("sonner", () => ({ toast: (...args: unknown[]) => toast(...args) }));
import { AchievementUnlockNotifier } from "../achievement-unlock-notifier";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<AchievementUnlockNotifier userId="user-1" />
</I18nProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
mockUser = {};
});
describe("<AchievementUnlockNotifier> — achievements module gate", () => {
it("disables the polling query when the module is off", () => {
mockUser = { modules: { achievements: false } };
expect(render()).toBe("");
expect(useAchievementsQuery).toHaveBeenCalledWith(
expect.objectContaining({ enabled: false }),
);
});
it("enables the polling query when the module is on (explicit true)", () => {
mockUser = { modules: { achievements: true } };
render();
expect(useAchievementsQuery).toHaveBeenCalledWith(
expect.objectContaining({ enabled: true }),
);
});
it("enables the query by default when the module key is absent (default-on)", () => {
mockUser = { modules: {} };
render();
expect(useAchievementsQuery).toHaveBeenCalledWith(
expect.objectContaining({ enabled: true }),
);
});
});
@@ -14,8 +14,10 @@ import type { AchievementProgress } from "@/lib/gamification/achievements";
* with their localized title + completedAt date.
*/
let mockUser: { modules?: Record<string, boolean> } | null = {};
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true }),
useAuth: () => ({ isAuthenticated: true, user: mockUser }),
}));
let mockData:
@@ -54,6 +56,7 @@ function render() {
beforeEach(() => {
vi.clearAllMocks();
mockIsPending = false;
mockUser = {};
});
const baseAchievement: AchievementProgress = {
@@ -130,6 +133,41 @@ describe("pickRecentUnlocks", () => {
});
});
describe("<RecentAchievementsCard> — achievements module gate", () => {
it("renders nothing when the achievements module is disabled", () => {
// v1.18.0 — an explicit `false` for the module hides the dashboard
// tile entirely so the surface disappears in lock-step with the page
// and the 403 route.
mockUser = { modules: { achievements: false } };
mockData = {
summary: {},
achievements: [
{ ...baseAchievement, id: "a", completedAt: "2026-04-15T12:00:00.000Z" },
],
metrics: {},
};
expect(render()).toBe("");
});
it("renders the tile when the module is enabled (explicit true)", () => {
mockUser = { modules: { achievements: true } };
mockData = {
summary: {},
achievements: [
{ ...baseAchievement, id: "a", completedAt: "2026-04-15T12:00:00.000Z" },
],
metrics: {},
};
expect(render()).toContain('data-slot="recent-achievements-card"');
});
it("renders the tile by default when the module key is absent (default-on)", () => {
mockUser = { modules: {} };
mockData = { summary: {}, achievements: [], metrics: {} };
expect(render()).toContain('data-slot="recent-achievements-card"');
});
});
describe("<RecentAchievementsCard>", () => {
it("renders a skeleton (no content, no empty state) while the query is pending", () => {
// The flash this guards against: before the query settles the card
@@ -3,6 +3,7 @@
import { Sparkles, Trophy } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";
import { toast } from "sonner";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { useAchievementsQuery } from "@/lib/queries/use-achievements-query";
@@ -56,6 +57,14 @@ export function AchievementUnlockNotifier({
userId,
}: AchievementUnlockNotifierProps) {
const { t } = useTranslations();
const { user } = useAuth();
// v1.18.0 — the achievements module gate. When the account has the
// module turned off the notifier goes inert: the polling query never
// fires and no unlock toast can surface. Default-on: an absent key reads
// as enabled, so the notifier only stands down on an explicit `false`.
const achievementsEnabled = user?.modules?.achievements !== false;
const storageKey = useMemo(() => `${STORAGE_KEY_PREFIX}:${userId}`, [userId]);
const seenIdsRef = useRef<Set<string>>(new Set());
const localStateReadyRef = useRef(false);
@@ -70,6 +79,7 @@ export function AchievementUnlockNotifier({
// long-open tabs still surface new unlocks without forcing the card
// to re-render on the same cadence.
const { data } = useAchievementsQuery({
enabled: !!user && achievementsEnabled,
refetchInterval: 2 * 60 * 1000,
});
@@ -19,6 +19,7 @@ import {
type LucideIcon,
} from "lucide-react";
import Link from "next/link";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { formatDate } from "@/lib/format";
import { useAchievementsQuery } from "@/lib/queries/use-achievements-query";
@@ -81,6 +82,13 @@ const RECENT_LIMIT = 3;
*/
export function RecentAchievementsCard() {
const { t } = useTranslations();
const { user, isAuthenticated } = useAuth();
// v1.18.0 — the achievements module gate. When the account has the
// module turned off the dashboard tile disappears entirely (the data
// query never fires; the API would 403 anyway). Default-on: an absent
// key reads as enabled, so the tile only hides on an explicit `false`.
const achievementsEnabled = user?.modules?.achievements !== false;
// `isPending` (no data yet, fetch in flight or gated) drives the
// loading branch. Rendering the empty / content branch before the
@@ -89,10 +97,17 @@ export function RecentAchievementsCard() {
// content) for the fetch window, then swapped once the real payload
// landed. A skeleton holds the card's footprint without committing to
// a content shape it may have to retract.
const { data, isPending } = useAchievementsQuery();
const { data, isPending } = useAchievementsQuery({
enabled: isAuthenticated && achievementsEnabled,
});
const recent = pickRecentUnlocks(data?.achievements ?? [], RECENT_LIMIT);
// Module off ⇒ render nothing so the tile vanishes from the dashboard.
if (!achievementsEnabled) {
return null;
}
return (
<div
data-slot="recent-achievements-card"