From 3f99cbc506a57c3dcfd9de8cceb10011ff4ddebb Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 14 Aug 2026 09:00:34 +0700 Subject: [PATCH 01/16] Add Bonus Milestone completion storage --- .../arcade/facilitator-bonus-milestone.ts | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 components/arcade/facilitator-bonus-milestone.ts diff --git a/components/arcade/facilitator-bonus-milestone.ts b/components/arcade/facilitator-bonus-milestone.ts new file mode 100644 index 000000000..b8873b0e5 --- /dev/null +++ b/components/arcade/facilitator-bonus-milestone.ts @@ -0,0 +1,64 @@ +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) + + try { + window.localStorage.setItem(key, completed ? "true" : "false") + } 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. + window.dispatchEvent(new Event("storage")) +} From 63aaf9a7fd9828698e40e37a207ba8255713103a Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 14 Aug 2026 09:01:25 +0700 Subject: [PATCH 02/16] Add simplified Bonus Milestone control --- .../facilitator-bonus-milestone-control.tsx | 366 ++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 components/arcade/facilitator-bonus-milestone-control.tsx diff --git a/components/arcade/facilitator-bonus-milestone-control.tsx b/components/arcade/facilitator-bonus-milestone-control.tsx new file mode 100644 index 000000000..83daab5de --- /dev/null +++ b/components/arcade/facilitator-bonus-milestone-control.tsx @@ -0,0 +1,366 @@ +"use client" + +import { CheckCircle2, CircleHelp, Trophy } 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 { + FACILITATOR_BONUS_MILESTONE_POINTS, + 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 currentLegacySection: HTMLElement | null = null + let currentTarget: HTMLElement | null = null + + const installSimpleSection = () => { + const legacySection = findLegacyBonusSection() + if (!legacySection) return + + legacySection.hidden = true + currentLegacySection = legacySection + + let target = legacySection.parentElement?.querySelector( + "[data-simple-bonus-milestone]", + ) + if (!target) { + target = document.createElement("div") + target.dataset.simpleBonusMilestone = "true" + legacySection.before(target) + } + + currentTarget = target + setPortalTarget((previous) => (previous === target ? previous : target)) + } + + installSimpleSection() + const observer = new MutationObserver(installSimpleSection) + observer.observe(document.body, { childList: true, subtree: true }) + + return () => { + observer.disconnect() + currentTarget?.remove() + if (currentLegacySection?.isConnected) currentLegacySection.hidden = false + setPortalTarget(null) + } + }, []) + + useEffect(() => { + 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 = Array.from(scoreCards).find( + (card) => card.querySelector("span")?.textContent?.trim() === "Facilitator bonus", + ) + const totalCard = Array.from(scoreCards).find( + (card) => card.querySelector("span")?.textContent?.trim() === "Estimated total after bonus", + ) + + if (bonusCard) { + setText( + bonusCard.querySelector("strong"), + participating ? `+${formatNumber(score.bonus)}` : "Off", + ) + setText( + bonusCard.querySelector("small"), + participating + ? completed + ? `Includes +${FACILITATOR_BONUS_MILESTONE_POINTS} Bonus Milestone` + : "Highest completed milestone only" + : "Participation is not enabled", + ) + } + + if (totalCard) { + setText(totalCard.querySelector("strong"), formatNumber(score.totalPoints)) + setText( + totalCard.querySelector("small"), + participating + ? completed + ? `Includes +${FACILITATOR_BONUS_MILESTONE_POINTS} Bonus Milestone` + : `Optional +${FACILITATOR_BONUS_MILESTONE_POINTS} Bonus Milestone not included` + : "No Facilitator bonus included", + ) + } + + const launcherSmall = document.querySelector( + ".facilitator-launcher small", + ) + if (participating && launcherSmall) { + const current = launcherSmall.textContent ?? "" + const separatorIndex = current.indexOf("·") + const suffix = + separatorIndex >= 0 ? current.slice(separatorIndex).trim() : "" + setText( + launcherSmall, + `+${formatNumber(score.bonus)} bonus${suffix ? ` ${suffix}` : ""}`, + ) + } + + setText( + content.querySelector(".facilitator-disclaimer"), + participating + ? `Facilitator bonuses are included after participation is confirmed. The Bonus Milestone adds +${FACILITATOR_BONUS_MILESTONE_POINTS} when you mark the official completion check as completed.` + : "Facilitator bonuses are not included while participation is disabled.", + ) + } + + syncScoreSummary() + const observer = new MutationObserver(syncScoreSummary) + observer.observe(document.body, { + childList: true, + subtree: true, + characterData: true, + }) + + return () => observer.disconnect() + }, [completed, participating]) + + const toggleCompleted = () => { + if (!participating) return + writeFacilitatorBonusMilestoneCompletion(profileUrl, !completed) + } + + if (!portalTarget) return null + + return createPortal( +
+ + +
+
+

Bonus Milestone

+

+ One completion check only. When it is confirmed, add + + {FACILITATOR_BONUS_MILESTONE_POINTS} bonus points. +

+
+ {completed && participating ? "+10 added" : "+10 bonus"} +
+ +
+ +
+ Bonus Milestone completion + + Use the official completion check as the source of truth. The + detailed GEAR requirement list does not need to be tracked here. + +
+ +
+ +

+

+
, + portalTarget, + ) +} From e8cac42c0aca8490476c9682cb4d5746e8cc886c Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 14 Aug 2026 09:01:43 +0700 Subject: [PATCH 03/16] Include checked Bonus Milestone in Facilitator score --- components/arcade/facilitator-points.ts | 40 ++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/components/arcade/facilitator-points.ts b/components/arcade/facilitator-points.ts index 2278a9df3..99140c9fa 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,46 @@ export function getFacilitatorMilestoneBonus(counts: FacilitatorCounts): number return getHighestFacilitatorMilestone(counts)?.bonus ?? 0 } +function readRuntimeBonusMilestoneCompletion(): boolean { + if (typeof window === "undefined") return false + + try { + if (new URLSearchParams(window.location.search).get("bonus") === "1") { + return true + } + + 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, From f937a0b5eac685f9dab8a1f598d9089269864d8f Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 14 Aug 2026 09:02:01 +0700 Subject: [PATCH 04/16] Use simplified Bonus Milestone control --- components/arcade/facilitator-panel-gate.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) 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 ( - + <> + + + ) } From 43aae35971a5383a44bdd619dc2b8d8c9449ac9d Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 14 Aug 2026 09:02:30 +0700 Subject: [PATCH 05/16] Preserve Bonus Milestone in shared profile links --- components/arcade/share-profile-enhancer.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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() From b9865ad7de906586b09bd287898febb46e8d351e Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 14 Aug 2026 09:02:49 +0700 Subject: [PATCH 06/16] Test checked Bonus Milestone scoring --- tests/facilitator-profile-score.test.mjs | 33 ++++++++++++++++++++++++ 1 file changed, 33 insertions(+) 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 }, + ) +}) From 6152484aa5992e6fb27860fa4001ed87f9e386fc Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 14 Aug 2026 09:05:40 +0700 Subject: [PATCH 07/16] Keep shared Bonus Milestone state profile-safe --- components/arcade/facilitator-points.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/components/arcade/facilitator-points.ts b/components/arcade/facilitator-points.ts index 99140c9fa..7d2d4ee8d 100644 --- a/components/arcade/facilitator-points.ts +++ b/components/arcade/facilitator-points.ts @@ -60,9 +60,15 @@ function readRuntimeBonusMilestoneCompletion(): boolean { if (typeof window === "undefined") return false try { - if (new URLSearchParams(window.location.search).get("bonus") === "1") { - return true - } + 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 From a4f14e6fd941972aad9176393b9a7fd5205bdb8a Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 14 Aug 2026 09:11:04 +0700 Subject: [PATCH 08/16] Restore Bonus Milestone guidance with manual completion --- .../facilitator-bonus-milestone-control.tsx | 183 ++++++++++++++++-- 1 file changed, 167 insertions(+), 16 deletions(-) diff --git a/components/arcade/facilitator-bonus-milestone-control.tsx b/components/arcade/facilitator-bonus-milestone-control.tsx index 83daab5de..23c81b908 100644 --- a/components/arcade/facilitator-bonus-milestone-control.tsx +++ b/components/arcade/facilitator-bonus-milestone-control.tsx @@ -1,6 +1,11 @@ "use client" -import { CheckCircle2, CircleHelp, Trophy } from "lucide-react" +import { + CheckCircle2, + CircleHelp, + ExternalLink, + Trophy, +} from "lucide-react" import { useEffect, useState } from "react" import { createPortal } from "react-dom" import { @@ -13,9 +18,7 @@ import { FACILITATOR_BONUS_MILESTONE_POINTS, getFacilitatorAdjustedPoints, } from "./facilitator-points" -import { - normalizeFacilitatorProfileUrl, -} from "./facilitator-participation" +import { normalizeFacilitatorProfileUrl } from "./facilitator-participation" import { DASHBOARD_STORAGE_KEY, formatNumber, @@ -23,6 +26,35 @@ import { type ArcadeApiResponse, } from "./model" +const BONUS_GUIDE_URL = + "https://rsvp.withgoogle.com/events/arcade-facilitator/bonus-milestone" +const BONUS_FORM_URL = "https://forms.gle/MMfH5RKp83TfRtXj9" + +const BONUS_STEPS = [ + { + title: "Earn the GEAR Sign-up badge", + detail: "Complete the GEAR program enrolment requirement.", + }, + { + title: "Earn the Arcade - GEAR badge", + detail: "Make sure the Arcade - GEAR badge appears on your developer profile.", + }, + { + title: "Complete Facilitator Milestone 1", + detail: "Reach at least 6 Arcade Games and 18 Skill Badges.", + }, + { + title: "Complete all 4 GEAR skill badges", + detail: + "Create Your First Gemini Enterprise Application; Engineer AI Agents with ADK; Deploy Multi-Agent Architectures; and Orchestrate Multi-Agent Workflows with Gemini Enterprise.", + }, + { + title: "Build and submit your AI agent", + detail: + "Follow the official Bonus Milestone guide, complete the required agent work, then submit the verification form.", + }, +] as const + type Props = { profileUrl: string participating: boolean @@ -46,7 +78,8 @@ function findLegacyBonusSection(): HTMLElement | null { return ( sections.find( - (section) => section.querySelector("h3")?.textContent?.trim() === "Bonus Milestone", + (section) => + section.querySelector("h3")?.textContent?.trim() === "Bonus Milestone", ) ?? null ) } @@ -77,7 +110,8 @@ export default function FacilitatorBonusMilestoneControl({ } const onCompletionChange = (event: Event) => { - const detail = (event as CustomEvent).detail + const detail = (event as CustomEvent) + .detail if (!detail) return if ( @@ -163,10 +197,14 @@ export default function FacilitatorBonusMilestoneControl({ ".facilitator-score-grid > article", ) const bonusCard = Array.from(scoreCards).find( - (card) => card.querySelector("span")?.textContent?.trim() === "Facilitator bonus", + (card) => + card.querySelector("span")?.textContent?.trim() === + "Facilitator bonus", ) const totalCard = Array.from(scoreCards).find( - (card) => card.querySelector("span")?.textContent?.trim() === "Estimated total after bonus", + (card) => + card.querySelector("span")?.textContent?.trim() === + "Estimated total after bonus", ) if (bonusCard) { @@ -185,7 +223,10 @@ export default function FacilitatorBonusMilestoneControl({ } if (totalCard) { - setText(totalCard.querySelector("strong"), formatNumber(score.totalPoints)) + setText( + totalCard.querySelector("strong"), + formatNumber(score.totalPoints), + ) setText( totalCard.querySelector("small"), participating @@ -213,7 +254,7 @@ export default function FacilitatorBonusMilestoneControl({ setText( content.querySelector(".facilitator-disclaimer"), participating - ? `Facilitator bonuses are included after participation is confirmed. The Bonus Milestone adds +${FACILITATOR_BONUS_MILESTONE_POINTS} when you mark the official completion check as completed.` + ? `Facilitator bonuses are included after participation is confirmed. Follow the Bonus Milestone guide and add +${FACILITATOR_BONUS_MILESTONE_POINTS} only after you confirm the official Bonus Milestone is completed.` : "Facilitator bonuses are not included while participation is disabled.", ) } @@ -239,11 +280,86 @@ export default function FacilitatorBonusMilestoneControl({ return createPortal(
-
-
-

Bonus Milestone

-

- Follow the steps below. After the official Bonus Milestone check is - complete, confirm it here to add +{FACILITATOR_BONUS_MILESTONE_POINTS} - {" "}bonus points. -

-
- {completed && participating ? "+10 added" : "+10 bonus"} -
- -
- {BONUS_STEPS.map((step, index) => ( -
- -
- {step.title} - {step.detail} -
-
- ))} -
- - -
-
, + , portalTarget, ) } From a9f493b968c7594a82d486d95947433973d6a265 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 14 Aug 2026 09:27:41 +0700 Subject: [PATCH 10/16] Polish Bonus Milestone layout and collapse skill details --- .../facilitator-bonus-milestone-control.tsx | 189 +++++++++++++++--- 1 file changed, 163 insertions(+), 26 deletions(-) diff --git a/components/arcade/facilitator-bonus-milestone-control.tsx b/components/arcade/facilitator-bonus-milestone-control.tsx index 7aa799bf0..f3d2247fe 100644 --- a/components/arcade/facilitator-bonus-milestone-control.tsx +++ b/components/arcade/facilitator-bonus-milestone-control.tsx @@ -106,11 +106,77 @@ export default function FacilitatorBonusMilestoneControl({ 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 installConfirmation = () => { + const installOptimizedLayout = () => { 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 + + toggle.textContent = expanded + ? `Hide GEAR skill badges · ${completedSkills}/4` + : `View 4 GEAR skill badges · ${completedSkills}/4` + 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]", ) @@ -118,20 +184,32 @@ export default function FacilitatorBonusMilestoneControl({ if (!target) { target = document.createElement("div") target.dataset.bonusMilestoneConfirmation = "true" - bonusSection.append(target) + 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)) } - installConfirmation() - const observer = new MutationObserver(installConfirmation) + 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) } }, []) @@ -213,10 +291,21 @@ export default function FacilitatorBonusMilestoneControl({ ) } + const bonusSection = findLegacyBonusSection() + const sectionNote = bonusSection?.querySelector( + ":scope > .facilitator-syllabus-note", + ) + setText( + sectionNote ?? null, + participating + ? "Complete the requirements above, then submit the verification form." + : "Enable Facilitator participation to include bonus points.", + ) + setText( content.querySelector(".facilitator-disclaimer"), participating - ? `Facilitator bonuses are included after participation is confirmed. The optional Bonus Milestone adds +${FACILITATOR_BONUS_MILESTONE_POINTS} only after you confirm completion below.` + ? `+${FACILITATOR_BONUS_MILESTONE_POINTS} Bonus Milestone is included only after you confirm completion.` : "Facilitator bonuses are not included while participation is disabled.", ) } @@ -242,17 +331,64 @@ export default function FacilitatorBonusMilestoneControl({ return createPortal(