diff --git a/app/styles/facilitator-syllabus.css b/app/styles/facilitator-syllabus.css index 15bf0adbb..7a3e3e9c5 100644 --- a/app/styles/facilitator-syllabus.css +++ b/app/styles/facilitator-syllabus.css @@ -218,6 +218,15 @@ scrollbar-color: #33415b transparent; } +/* The Bonus Milestone toggle owns the visual collapse state. Using the + adjacent sibling selector keeps the list collapsed even if React/i18n + replaces or mutates the list after the toggle has been installed. */ +.bonus-milestone-optimized + > .bonus-gear-toggle[aria-expanded="false"] + + .facilitator-syllabus-list { + display: none !important; +} + .facilitator-syllabus-list > article { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; diff --git a/components/arcade/facilitator-bonus-milestone-control.tsx b/components/arcade/facilitator-bonus-milestone-control.tsx new file mode 100644 index 000000000..8521cbffa --- /dev/null +++ b/components/arcade/facilitator-bonus-milestone-control.tsx @@ -0,0 +1,459 @@ +"use client" + +import { CheckCircle2, CircleHelp } from "lucide-react" +import { useEffect, useState } from "react" +import { createPortal } from "react-dom" +import { + FACILITATOR_BONUS_MILESTONE_EVENT, + readFacilitatorBonusMilestoneCompletion, + writeFacilitatorBonusMilestoneCompletion, + type FacilitatorBonusMilestoneDetail, +} from "./facilitator-bonus-milestone" +import { getFacilitatorAdjustedPoints } from "./facilitator-points" +import { normalizeFacilitatorProfileUrl } from "./facilitator-participation" +import { + DASHBOARD_STORAGE_KEY, + formatNumber, + numeric, + type ArcadeApiResponse, +} from "./model" + +type Props = { + profileUrl: string + participating: boolean +} + +type StoredDashboard = { + profileUrl?: string + result?: ArcadeApiResponse +} + +function setText(element: Element | null, value: string): void { + if (element && element.textContent !== value) element.textContent = value +} + +function findLegacyBonusSection(): HTMLElement | null { + const sections = Array.from( + document.querySelectorAll( + ".facilitator-content > .facilitator-section", + ), + ) + + return ( + sections.find( + (section) => + section.querySelector("h3")?.textContent?.trim() === "Bonus Milestone", + ) ?? null + ) +} + +function readStoredDashboard(): StoredDashboard | null { + try { + const raw = window.localStorage.getItem(DASHBOARD_STORAGE_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) as unknown + return typeof parsed === "object" && parsed !== null + ? (parsed as StoredDashboard) + : null + } catch { + return null + } +} + +export default function FacilitatorBonusMilestoneControl({ + profileUrl, + participating, +}: Props) { + const [completed, setCompleted] = useState(false) + const [portalTarget, setPortalTarget] = useState(null) + + useEffect(() => { + const syncCompletion = () => { + setCompleted(readFacilitatorBonusMilestoneCompletion(profileUrl)) + } + + const onCompletionChange = (event: Event) => { + const detail = (event as CustomEvent) + .detail + if (!detail) return + + if ( + normalizeFacilitatorProfileUrl(detail.profileUrl) === + normalizeFacilitatorProfileUrl(profileUrl) + ) { + setCompleted(detail.completed) + } + } + + syncCompletion() + window.addEventListener("storage", syncCompletion) + window.addEventListener( + FACILITATOR_BONUS_MILESTONE_EVENT, + onCompletionChange, + ) + + return () => { + window.removeEventListener("storage", syncCompletion) + window.removeEventListener( + FACILITATOR_BONUS_MILESTONE_EVENT, + onCompletionChange, + ) + } + }, [profileUrl]) + + useEffect(() => { + let currentTarget: HTMLElement | null = null + let currentBonusSection: HTMLElement | null = null + let currentDetailsList: HTMLElement | null = null + let currentToggle: HTMLButtonElement | null = null + let currentActionRow: HTMLElement | null = null + let assignedDetailsId = false + + const installOptimizedLayout = () => { + // Ignore mutations produced by the layout we already installed. This + // prevents a MutationObserver feedback loop while the drawer is open. + if (currentTarget?.isConnected && currentBonusSection?.isConnected) return + + const bonusSection = findLegacyBonusSection() + if (!bonusSection) return + + bonusSection.classList.add("bonus-milestone-optimized") + currentBonusSection = bonusSection + + const detailsList = bonusSection.querySelector( + ":scope > .facilitator-syllabus-list", + ) + + if (detailsList) { + detailsList.classList.add("bonus-gear-details-list") + currentDetailsList = detailsList + + if (!detailsList.id) { + detailsList.id = "bonus-gear-skill-details" + assignedDetailsId = true + } + + let toggle = bonusSection.querySelector( + "[data-bonus-gear-toggle]", + ) + + const updateToggleLabel = () => { + if (!toggle) return + const completedSkills = detailsList.querySelectorAll( + ":scope > article.is-completed", + ).length + const expanded = !detailsList.hidden + const label = expanded + ? `Hide GEAR skill badges · ${completedSkills}/4` + : `View 4 GEAR skill badges · ${completedSkills}/4` + + setText(toggle, label) + toggle.setAttribute("aria-expanded", String(expanded)) + toggle.classList.toggle("is-complete", completedSkills === 4) + } + + if (!toggle) { + detailsList.hidden = true + toggle = document.createElement("button") + toggle.type = "button" + toggle.dataset.bonusGearToggle = "true" + toggle.className = "bonus-gear-toggle" + toggle.setAttribute("aria-controls", detailsList.id) + toggle.addEventListener("click", () => { + detailsList.hidden = !detailsList.hidden + updateToggleLabel() + }) + detailsList.before(toggle) + } + + currentToggle = toggle + updateToggleLabel() + } + + const actionLink = Array.from( + bonusSection.querySelectorAll("a"), + ).find((link) => link.textContent?.includes("Read official guide")) + const actionRow = actionLink?.parentElement + if (actionRow) { + actionRow.classList.add("bonus-milestone-actions-compact") + currentActionRow = actionRow + } + + let target = bonusSection.querySelector( + "[data-bonus-milestone-confirmation]", + ) + + if (!target) { + target = document.createElement("div") + target.dataset.bonusMilestoneConfirmation = "true" + const note = bonusSection.querySelector( + ":scope > .facilitator-syllabus-note", + ) + if (note) note.before(target) + else bonusSection.append(target) + } + + currentTarget = target + setPortalTarget((previous) => (previous === target ? previous : target)) + } + + installOptimizedLayout() + const observer = new MutationObserver(installOptimizedLayout) + observer.observe(document.body, { childList: true, subtree: true }) + + return () => { + observer.disconnect() + currentTarget?.remove() + currentToggle?.remove() + if (currentDetailsList) { + currentDetailsList.hidden = false + currentDetailsList.classList.remove("bonus-gear-details-list") + if (assignedDetailsId) currentDetailsList.removeAttribute("id") + } + currentActionRow?.classList.remove("bonus-milestone-actions-compact") + currentBonusSection?.classList.remove("bonus-milestone-optimized") + setPortalTarget(null) + } + }, []) + + useEffect(() => { + if (!portalTarget) return + + const syncScoreSummary = () => { + const dashboard = readStoredDashboard() + const result = dashboard?.result + if (!result) return + + const score = getFacilitatorAdjustedPoints( + numeric(result.arcadePoints?.totalPoints), + { + games: numeric(result.faciCounts?.faciGame), + skills: numeric(result.faciCounts?.faciSkill), + }, + participating, + completed, + ) + + const content = document.querySelector(".facilitator-content") + if (!content) return + + const scoreCards = content.querySelectorAll( + ".facilitator-score-grid > article", + ) + const bonusCard = scoreCards.item(1) + const totalCard = scoreCards.item(2) + + if (bonusCard) { + setText( + bonusCard.querySelector("strong"), + participating ? `+${formatNumber(score.bonus)}` : "Off", + ) + const detail = bonusCard.querySelector("small") + if (detail) detail.hidden = Boolean(participating && completed) + } + + if (totalCard) { + setText( + totalCard.querySelector("strong"), + formatNumber(score.totalPoints), + ) + const detail = totalCard.querySelector("small") + if (detail) detail.hidden = Boolean(participating && completed) + } + + const launcherSmall = document.querySelector( + ".facilitator-launcher small", + ) + if (participating && launcherSmall?.textContent) { + setText( + launcherSmall, + launcherSmall.textContent.replace( + /\+\s*\d+(?:[.,]\d+)?/, + `+${formatNumber(score.bonus)}`, + ), + ) + } + } + + const frame = window.requestAnimationFrame(syncScoreSummary) + return () => window.cancelAnimationFrame(frame) + }, [completed, participating, portalTarget]) + + const toggleCompleted = () => { + if (!participating) return + writeFacilitatorBonusMilestoneCompletion(profileUrl, !completed) + } + + if (!portalTarget) return null + + return createPortal( +
+ + +
+ +
+ Bonus Milestone completed + Confirm after you finish all required steps above. +
+ +
+
, + portalTarget, + ) +} diff --git a/components/arcade/facilitator-bonus-milestone.ts b/components/arcade/facilitator-bonus-milestone.ts new file mode 100644 index 000000000..ede0a64eb --- /dev/null +++ b/components/arcade/facilitator-bonus-milestone.ts @@ -0,0 +1,66 @@ +import { normalizeFacilitatorProfileUrl } from "./facilitator-participation" + +export const FACILITATOR_BONUS_MILESTONE_EVENT = + "arcade-facilitator-bonus-milestone-change" + +const BONUS_MILESTONE_STORAGE_PREFIX = + "arcade-facilitator-bonus-milestone-v1" + +export type FacilitatorBonusMilestoneDetail = { + profileUrl: string + completed: boolean +} + +export function getFacilitatorBonusMilestoneStorageKey( + profileUrl?: string, +): string { + return `${BONUS_MILESTONE_STORAGE_PREFIX}:${normalizeFacilitatorProfileUrl( + profileUrl, + )}` +} + +export function readFacilitatorBonusMilestoneCompletion( + profileUrl?: string, +): boolean { + try { + return ( + window.localStorage.getItem( + getFacilitatorBonusMilestoneStorageKey(profileUrl), + ) === "true" + ) + } catch { + return false + } +} + +export function writeFacilitatorBonusMilestoneCompletion( + profileUrl: string | undefined, + completed: boolean, +): void { + const normalizedProfileUrl = normalizeFacilitatorProfileUrl(profileUrl) + const key = getFacilitatorBonusMilestoneStorageKey(normalizedProfileUrl) + let stored = false + + try { + window.localStorage.setItem(key, completed ? "true" : "false") + stored = true + } catch { + // Keep the caller's in-memory state when storage is unavailable. + } + + window.dispatchEvent( + new CustomEvent( + FACILITATOR_BONUS_MILESTONE_EVENT, + { + detail: { + profileUrl: normalizedProfileUrl, + completed, + }, + }, + ), + ) + + // Existing score surfaces already listen for the storage event. Dispatching + // one here makes the +10 update immediately in the current tab as well. + if (stored) window.dispatchEvent(new Event("storage")) +} diff --git a/components/arcade/facilitator-panel-gate.tsx b/components/arcade/facilitator-panel-gate.tsx index 0bc033ff4..1ae7f4085 100644 --- a/components/arcade/facilitator-panel-gate.tsx +++ b/components/arcade/facilitator-panel-gate.tsx @@ -1,6 +1,7 @@ "use client" import { useEffect, useRef, useState } from "react" +import FacilitatorBonusMilestoneControl from "./facilitator-bonus-milestone-control" import FacilitatorPanel from "./facilitator-panel" import { FACILITATOR_PANEL_OPEN_EVENT, @@ -153,8 +154,14 @@ export default function FacilitatorPanelGate() { }, []) return ( - + <> + + + ) } diff --git a/components/arcade/facilitator-points.ts b/components/arcade/facilitator-points.ts index 2278a9df3..7d2d4ee8d 100644 --- a/components/arcade/facilitator-points.ts +++ b/components/arcade/facilitator-points.ts @@ -5,6 +5,10 @@ export type FacilitatorCounts = { export const FACILITATOR_BONUS_MILESTONE_POINTS = 10 +const BONUS_MILESTONE_STORAGE_PREFIX = + "arcade-facilitator-bonus-milestone-v1" +const DASHBOARD_STORAGE_KEY = "eplus-arcade-dashboard-v1" + export const FACILITATOR_MILESTONES = [ { id: "1", @@ -52,12 +56,52 @@ export function getFacilitatorMilestoneBonus(counts: FacilitatorCounts): number return getHighestFacilitatorMilestone(counts)?.bonus ?? 0 } +function readRuntimeBonusMilestoneCompletion(): boolean { + if (typeof window === "undefined") return false + + try { + const searchParams = new URLSearchParams(window.location.search) + if (searchParams.get("bonus") === "1") return true + + // Shared profile pages must only trust the explicit share parameter so a + // locally checked profile cannot leak +10 into somebody else's shared URL. + const isSharedProfilePage = /\/(?:profiles\/[^/]+|profile)\/?$/i.test( + window.location.pathname, + ) + if (isSharedProfilePage) return false + + const raw = window.localStorage.getItem(DASHBOARD_STORAGE_KEY) + if (!raw) return false + + const parsed = JSON.parse(raw) as { profileUrl?: unknown } + const profileUrl = + typeof parsed.profileUrl === "string" + ? parsed.profileUrl.trim().replace(/\/$/, "") + : "" + if (!profileUrl) return false + + return ( + window.localStorage.getItem( + `${BONUS_MILESTONE_STORAGE_PREFIX}:${profileUrl}`, + ) === "true" + ) + } catch { + return false + } +} + export function getFacilitatorAdjustedPoints( basePoints: number, counts: FacilitatorCounts, participating: boolean, + bonusMilestoneCompleted = readRuntimeBonusMilestoneCompletion(), ) { - const bonus = participating ? getFacilitatorMilestoneBonus(counts) : 0 + const milestoneBonus = participating ? getFacilitatorMilestoneBonus(counts) : 0 + const bonusMilestone = + participating && bonusMilestoneCompleted + ? FACILITATOR_BONUS_MILESTONE_POINTS + : 0 + const bonus = milestoneBonus + bonusMilestone return { basePoints, diff --git a/components/arcade/share-profile-enhancer.tsx b/components/arcade/share-profile-enhancer.tsx index 7a07485a3..7ed619f6b 100644 --- a/components/arcade/share-profile-enhancer.tsx +++ b/components/arcade/share-profile-enhancer.tsx @@ -1,6 +1,7 @@ "use client" import { useEffect } from "react" +import { readFacilitatorBonusMilestoneCompletion } from "@/components/arcade/facilitator-bonus-milestone" import { readFacilitatorParticipation } from "@/components/arcade/facilitator-participation" import { DASHBOARD_STORAGE_KEY } from "@/components/arcade/model" @@ -25,8 +26,13 @@ function getShareUrl(): string { shareUrl.searchParams.set("id", match[1]) } - if (readFacilitatorParticipation(parsed?.profileUrl)) { + const facilitatorParticipating = readFacilitatorParticipation(parsed?.profileUrl) + if (facilitatorParticipating) { shareUrl.searchParams.set("facilitator", "1") + + if (readFacilitatorBonusMilestoneCompletion(parsed?.profileUrl)) { + shareUrl.searchParams.set("bonus", "1") + } } return shareUrl.toString() diff --git a/tests/facilitator-profile-score.test.mjs b/tests/facilitator-profile-score.test.mjs index d1cc716a1..0f301dd79 100644 --- a/tests/facilitator-profile-score.test.mjs +++ b/tests/facilitator-profile-score.test.mjs @@ -61,3 +61,36 @@ test("Bonus Milestone remains a separate +10 and is not part of standard milesto assert.equal(facilitator.FACILITATOR_BONUS_MILESTONE_POINTS, 10) assert.equal(facilitator.getFacilitatorMilestoneBonus({ games: 6, skills: 18 }), 5) }) + +test("checked Bonus Milestone adds +10 on top of the standard Facilitator bonus", () => { + assert.deepEqual( + facilitator.getFacilitatorAdjustedPoints( + 75, + { games: 6, skills: 18 }, + true, + true, + ), + { basePoints: 75, bonus: 15, totalPoints: 90 }, + ) + assert.deepEqual( + facilitator.getFacilitatorAdjustedPoints( + 75, + { games: 12, skills: 66 }, + true, + true, + ), + { basePoints: 75, bonus: 45, totalPoints: 120 }, + ) +}) + +test("checked Bonus Milestone is ignored when Facilitator participation is off", () => { + assert.deepEqual( + facilitator.getFacilitatorAdjustedPoints( + 75, + { games: 12, skills: 66 }, + false, + true, + ), + { basePoints: 75, bonus: 0, totalPoints: 75 }, + ) +})