diff --git a/app/layout.tsx b/app/layout.tsx index 5e831cc04..a9ba17709 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs" import path from "node:path" import localFont from "next/font/local" import type { Metadata, Viewport } from "next" +import ArcadeFaqChatbot from "@/components/arcade/arcade-faq-chatbot" import WebsiteLanguage from "@/components/i18n/website-language" import CookieConsent from "@/components/privacy/cookie-consent" import BackToTop from "@/components/back-to-top" @@ -35,6 +36,7 @@ import "./styles/profile-share.css" import "./styles/bonus-milestone.css" import "./styles/site-ui.css" import "./styles/back-to-top.css" +import "./styles/arcade-chatbot.css" const arcadeRequestDedupeBootstrap = readFileSync( path.join(process.cwd(), "scripts", "arcade-request-dedupe.js"), @@ -243,6 +245,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac {children} + div { + min-width: 0; +} + +.arcade-chatbot-header strong, +.arcade-chatbot-header span { + display: block; +} + +.arcade-chatbot-header strong { + color: var(--ui-text); + font-size: .86rem; + line-height: 1.25; +} + +.arcade-chatbot-header span { + margin-top: 3px; + color: var(--ui-muted); + font-size: .66rem; +} + +.arcade-chatbot-header-actions { + position: relative; + display: flex; + gap: 2px; +} + +.arcade-chatbot-header-actions > button { + display: grid; + place-items: center; + width: 34px; + height: 34px; + padding: 0; + border: 1px solid transparent; + border-radius: var(--ui-radius-sm); + background: transparent; + color: var(--ui-muted); + cursor: pointer; +} + +.arcade-chatbot-header-actions > button:hover { + border-color: var(--ui-border); + background: var(--ui-soft); + color: var(--ui-text); +} + +.arcade-chatbot-header-actions > button svg { + width: 16px; + height: 16px; +} + +.arcade-chatbot-log { + min-height: 0; + overflow-y: auto; + padding: 14px; + scroll-behavior: smooth; +} + +.arcade-chatbot-message { + display: flex; + align-items: flex-start; + gap: 8px; + margin-bottom: 12px; +} + +.arcade-chatbot-message > div { + max-width: 88%; + padding: 10px 11px; + border: 1px solid var(--ui-border); + border-radius: 5px 12px 12px; + background: var(--ui-soft); +} + +.arcade-chatbot-message.is-user { + justify-content: flex-end; +} + +.arcade-chatbot-message.is-user > div { + border-color: var(--ui-accent-border); + border-radius: 12px 5px 12px 12px; + background: var(--ui-accent-soft); +} + +.arcade-chatbot-message p { + margin: 0; + color: var(--ui-text); + font-size: .73rem; + line-height: 1.58; +} + +.arcade-chatbot-message small { + display: block; + margin-top: 7px; + color: var(--ui-muted); + font-size: .61rem; + line-height: 1.45; +} + +.arcade-chatbot-mini-avatar { + width: 25px; + height: 25px; + margin-top: 2px; + border-radius: 8px; +} + +.arcade-chatbot-mini-avatar svg { + width: 12px; + height: 12px; +} + +.arcade-chatbot-sources { + display: grid; + gap: 5px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--ui-border); +} + +.arcade-chatbot-sources a, +.arcade-chatbot-shortcuts a { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--ui-accent); + font-size: .61rem; + font-weight: 700; + text-decoration: none; +} + +.arcade-chatbot-sources a:hover, +.arcade-chatbot-shortcuts a:hover { + text-decoration: underline; + text-underline-offset: 3px; +} + +.arcade-chatbot-sources svg, +.arcade-chatbot-shortcuts svg { + width: 11px; + height: 11px; +} + +.arcade-chatbot-prompts { + display: grid; + gap: 7px; + margin: 4px 0 2px 33px; +} + +.arcade-chatbot-prompts button { + min-height: 38px; + padding: 8px 10px; + border: 1px solid var(--ui-border); + border-radius: var(--ui-radius-sm); + background: var(--ui-surface); + color: var(--ui-muted); + font-size: .68rem; + font-weight: 650; + text-align: left; + cursor: pointer; +} + +.arcade-chatbot-prompts button:hover { + border-color: var(--ui-accent-border); + background: var(--ui-accent-soft); + color: var(--ui-text); +} + +.arcade-chatbot-shortcuts { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + padding: 9px 14px; + border-top: 1px solid var(--ui-border); + background: var(--ui-soft); +} + +.arcade-chatbot-form { + display: grid; + grid-template-columns: minmax(0, 1fr) 40px; + gap: 8px; + padding: 10px; + border-top: 1px solid var(--ui-border); + background: var(--ui-surface); +} + +.arcade-chatbot-form input { + width: 100%; + min-width: 0; + min-height: 40px; + padding: 0 11px; + border: 1px solid var(--ui-border); + border-radius: var(--ui-radius-sm); + outline: 0; + background: var(--ui-soft); + color: var(--ui-text); + font-size: .72rem; +} + +.arcade-chatbot-form input::placeholder { + color: var(--ui-muted); +} + +.arcade-chatbot-form input:focus { + border-color: var(--ui-accent-border); + box-shadow: 0 0 0 3px hsl(var(--primary) / .12); +} + +.arcade-chatbot-form button { + display: grid; + place-items: center; + width: 40px; + min-height: 40px; + padding: 0; + border: 0; + border-radius: var(--ui-radius-sm); + background: var(--ui-primary); + color: var(--ui-on-accent); + cursor: pointer; +} + +.arcade-chatbot-form button:disabled { + opacity: .45; +} + +.arcade-chatbot-form button svg { + width: 15px; + height: 15px; +} + +@media (max-width: 640px) { + .arcade-chatbot { + right: 12px; + bottom: 84px; + } + + .arcade-chatbot-panel { + width: calc(100vw - 24px); + height: min(540px, calc(100dvh - 164px)); + border-radius: 14px; + } + + .arcade-chatbot-launcher { + min-height: 46px; + padding: 0 14px; + } +} + +@media (max-width: 420px) { + .arcade-chatbot-panel { + height: min(520px, calc(100dvh - 156px)); + } + + .arcade-chatbot-log { + padding: 12px; + } + + .arcade-chatbot-message > div { + max-width: 90%; + } + + .arcade-chatbot-prompts { + margin-left: 31px; + } +} + +@media (prefers-reduced-motion: reduce) { + .arcade-chatbot-launcher, + .arcade-chatbot-log { + transition: none; + scroll-behavior: auto; + } +} + + +.arcade-chatbot-menu { + position: absolute; + top: 40px; + right: 0; + z-index: 3; + width: 238px; + overflow: hidden; + padding: 5px; + border: 1px solid var(--ui-border); + border-radius: 12px; + background: var(--ui-surface); + box-shadow: 0 16px 40px hsl(224 50% 3% / .28); +} + +.arcade-chatbot-menu > button { + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + gap: 8px; + width: 100%; + padding: 9px 8px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--ui-text); + text-align: left; + cursor: pointer; +} + +.arcade-chatbot-menu > button:hover { + background: var(--ui-soft); +} + +.arcade-chatbot-menu svg { + width: 15px; + height: 15px; + margin: 2px auto 0; + color: var(--ui-muted); +} + +.arcade-chatbot-menu strong, +.arcade-chatbot-menu small { + display: block; +} + +.arcade-chatbot-menu strong { + font-size: .68rem; +} + +.arcade-chatbot-menu small { + margin-top: 2px; + color: var(--ui-muted); + font-size: .58rem; + line-height: 1.35; +} + +.arcade-chatbot-about { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid var(--ui-border); + border-radius: 12px; + background: var(--ui-soft); +} + +.arcade-chatbot-about > div { + display: flex; + align-items: center; + gap: 7px; +} + +.arcade-chatbot-about > div svg { + width: 15px; + height: 15px; + color: var(--ui-accent); +} + +.arcade-chatbot-about strong { + font-size: .76rem; +} + +.arcade-chatbot-about p { + margin: 0; + color: var(--ui-muted); + font-size: .68rem; + line-height: 1.55; +} + +.arcade-chatbot-about a { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--ui-accent); + font-size: .63rem; + font-weight: 700; + text-decoration: none; +} + +.arcade-chatbot-about a svg { + width: 10px; + height: 10px; +} + +.arcade-chatbot-about > button { + justify-self: start; + padding: 7px 10px; + border: 1px solid var(--ui-border); + border-radius: 8px; + background: var(--ui-surface); + color: var(--ui-text); + font-size: .64rem; + font-weight: 700; + cursor: pointer; +} diff --git a/app/styles/back-to-top.css b/app/styles/back-to-top.css index bbb87c6b2..da52e0ae0 100644 --- a/app/styles/back-to-top.css +++ b/app/styles/back-to-top.css @@ -46,6 +46,10 @@ html.light .back-to-top { width: 40px; height: 40px; } + + html[data-facilitator-launcher-visible="true"] .back-to-top { + bottom: calc(148px + env(safe-area-inset-bottom)); + } } @media (prefers-reduced-motion: reduce) { diff --git a/components/arcade/arcade-faq-chatbot.tsx b/components/arcade/arcade-faq-chatbot.tsx new file mode 100644 index 000000000..cd56e1976 --- /dev/null +++ b/components/arcade/arcade-faq-chatbot.tsx @@ -0,0 +1,587 @@ +"use client" + +import { + ExternalLink, + CircleHelp, + Search, + Sparkles, + MoreVertical, + RotateCcw, + Info, + X, +} from "lucide-react" +import type { FormEvent } from "react" +import { useEffect, useMemo, useRef, useState } from "react" + +type ChatSource = { + label: string + href: string +} + +type FaqItem = { + id: string + keywords: string[] + answer: { + en: string + vi: string + } + sources: ChatSource[] +} + +type ChatMessage = { + id: number + role: "assistant" | "user" + text: string + sources?: ChatSource[] +} + +const TIER_SOURCE = + "https://discuss.google.dev/t/google-skills-arcade-2026-tiers/371066" +const PRIZE_SOURCE = + "https://discuss.google.dev/t/clarification-your-2026-arcade-points-the-prize-counter/349716" +const ARCADE_OVERVIEW_SOURCE = + "https://cloud.google.com/blog/topics/training-certifications/the-arcade-with-google-cloud-game-helps-boost-cloud-skills" +const SPECIAL_GAME_SOURCE = + "https://discuss.google.dev/t/chasing-the-win-with-pup-kit/331876" + +const FAQ_ITEMS: FaqItem[] = [ + { + id: "tiers", + keywords: [ + "tier", + "trooper", + "ranger", + "champion", + "legend", + "moc diem", + "hang", + "cap bac", + "bao nhieu diem de", + ], + answer: { + en: "For the 2026 season, the official prize tiers are: Trooper 50–74 points, Ranger 75–94, Champion 95–119, and Legend 120+. Prize capacity is limited to 6,000 / 4,000 / 3,000 / 2,500 slots respectively, and Google uses a first-come-first-served waterfall system when a higher tier fills.", + vi: "Mùa 2026 có 4 tier thưởng chính thức: Trooper 50–74 điểm, Ranger 75–94, Champion 95–119 và Legend từ 120 điểm. Số suất lần lượt là 6.000 / 4.000 / 3.000 / 2.500; Google áp dụng cơ chế first-come-first-served và waterfall khi tier cao đã đầy.", + }, + sources: [{ label: "Official 2026 tier announcement", href: TIER_SOURCE }], + }, + { + id: "prize-counter", + keywords: [ + "prize counter", + "redeem", + "reward", + "swag", + "qua", + "doi qua", + "nhan qua", + "trao thuong", + "khi nao co qua", + "khi nao doi", + "prize", + ], + answer: { + en: "For 2026, Google moved to one unified Prize Counter window after the season wraps up at the end of the year. Google has confirmed the window is coming, but an exact opening date should only be trusted once it is officially announced. Your 2026 points remain counted toward that redemption window.", + vi: "Năm 2026 Google chuyển sang một đợt Prize Counter duy nhất sau khi mùa Arcade kết thúc vào cuối năm. Google đã xác nhận Prize Counter chắc chắn sẽ mở, nhưng ngày mở chính xác chỉ nên tin khi có thông báo chính thức. Điểm bạn kiếm trong năm 2026 vẫn được tính cho đợt đổi quà này.", + }, + sources: [ + { label: "2026 Prize Counter clarification", href: PRIZE_SOURCE }, + { label: "2026 tiers & season timing", href: TIER_SOURCE }, + ], + }, + { + id: "lab-release", + keywords: [ + "lab", + "game moi", + "new game", + "release", + "released", + "ra lab", + "mo lab", + "lich lab", + "khi nao co lab", + "deadline", + "monthly lab", + ], + answer: { + en: "Arcade activities are time-limited, but Google does not guarantee one fixed global release hour for every monthly or special game. Monthly games and special events can use different start/end dates. For the most practical view, check this site's Monthly Labs page for known access codes/deadlines, then verify the final timing on the official Arcade page before starting.", + vi: "Các hoạt động Arcade có thời hạn nhưng Google không cam kết một giờ mở cố định cho mọi monthly game hoặc special game. Mỗi game có thể có ngày bắt đầu/kết thúc khác nhau. Bạn nên xem trang Monthly Labs trên site này để biết access code/deadline đã thu thập được, sau đó kiểm tra lại trang Arcade chính thức trước khi làm.", + }, + sources: [ + { label: "Google Cloud Arcade overview", href: ARCADE_OVERVIEW_SOURCE }, + ], + }, + { + id: "points", + keywords: [ + "point", + "points", + "score", + "diem", + "tinh diem", + "cach tinh", + "skill badge", + "game badge", + "trivia", + "bao nhieu diem", + ], + answer: { + en: "Arcade points come from eligible completed badges/activities, not from each individual lab task. This calculator follows the current scoring model used by the project: normal Game/Trivia badges are typically 1 point, and 2 Skill Badges equal 1 Arcade point. Special games can override the normal value — some 2026 special games awarded 3 points — so the official game page and Arcade Insider total are the final authority.", + vi: "Arcade Point được tính từ badge/hoạt động đủ điều kiện đã hoàn thành, không phải mỗi task nhỏ trong lab. Calculator hiện dùng rule: Game/Trivia thông thường thường là 1 điểm và 2 Skill Badge = 1 Arcade Point. Special Game có thể có điểm riêng — đã có special game năm 2026 cho 3 điểm — nên trang game chính thức và tổng điểm trong Arcade Insider vẫn là nguồn quyết định cuối cùng.", + }, + sources: [ + { label: "Google Cloud Arcade overview", href: ARCADE_OVERVIEW_SOURCE }, + { label: "2026 special-game example", href: SPECIAL_GAME_SOURCE }, + ], + }, + { + id: "waterfall", + keywords: [ + "waterfall", + "het slot", + "het suat", + "full tier", + "slot", + "spots left", + "con suat", + "first come", + ], + answer: { + en: "In 2026, prize eligibility and point tier are not exactly the same thing because each tier has limited slots. If a higher-tier pool fills, eligible players can roll down to the next tier under Google's waterfall system. Reaching the point threshold therefore does not guarantee an item from that exact tier.", + vi: "Năm 2026, đạt mốc điểm chưa đồng nghĩa chắc chắn nhận quà đúng tier vì mỗi tier có số suất giới hạn. Nếu tier cao đã đầy, người đủ điều kiện có thể được chuyển xuống tier kế tiếp theo cơ chế waterfall. Vì vậy đạt đủ điểm không đảm bảo 100% còn suất quà ở tier đó.", + }, + sources: [{ label: "Official waterfall rules", href: TIER_SOURCE }], + }, + { + id: "shipping", + keywords: [ + "ship", + "shipping", + "delivery", + "giao hang", + "giao qua", + "bao lau nhan", + "nhan hang", + "van chuyen", + ], + answer: { + en: "Google has said the 2026 fulfillment process is being changed to shorten the wait after redemption, but there is no universal delivery-time guarantee for every country. After you redeem, follow the confirmation email and carrier tracking; treat any community ETA as an estimate unless Google gives a specific date.", + vi: "Google cho biết quy trình fulfillment 2026 đang được thay đổi để rút ngắn thời gian chờ sau khi đổi quà, nhưng không có một SLA giao hàng cố định cho mọi quốc gia. Sau khi redeem, hãy theo dõi email xác nhận và tracking của đơn vị vận chuyển; các mốc thời gian từ cộng đồng chỉ nên xem là ước tính.", + }, + sources: [{ label: "2026 fulfillment update", href: PRIZE_SOURCE }], + }, + { + id: "subscribe", + keywords: [ + "subscribe", + "subscription", + "register", + "dang ky", + "tham gia", + "bat dau", + "start arcade", + "arcade la gi", + "what is arcade", + ], + answer: { + en: "Google Skills Arcade is a hands-on learning program built around limited-time games and challenges on Google Skills. You complete cloud labs, earn badges, build Arcade points, and may qualify for rewards. Use the official Arcade page for enrollment and current event access; this site is an unofficial tracker/calculator.", + vi: "Google Skills Arcade là chương trình học thực hành trên Google Skills với các game/challenge có thời hạn. Bạn làm lab Cloud, nhận badge, tích Arcade Point và có thể đủ điều kiện nhận reward. Việc đăng ký và truy cập sự kiện nên thực hiện trên trang Arcade chính thức; site này là công cụ theo dõi/calculator không chính thức.", + }, + sources: [{ label: "Google Cloud Arcade overview", href: ARCADE_OVERVIEW_SOURCE }], + }, + { + id: "missing-points", + keywords: [ + "missing point", + "missing points", + "wrong point", + "sai diem", + "thieu diem", + "chua cong diem", + "khong thay diem", + "insider", + ], + answer: { + en: "If your estimate and Google's total differ, treat Google's Arcade Insider / official Arcade total as authoritative. First verify that the expected badge is actually earned on your public Google Skills profile and that the activity belongs to the current scoring window. This site's result is an estimate built from public badge data.", + vi: "Nếu điểm ước tính khác số Google gửi, hãy ưu tiên tổng điểm trong Arcade Insider / nguồn chính thức của Google. Trước tiên kiểm tra badge đã thực sự xuất hiện trên public Google Skills profile và hoạt động đó nằm trong thời gian được tính điểm. Kết quả trên site này chỉ là ước tính từ dữ liệu badge công khai.", + }, + sources: [{ label: "Official 2026 tracking guidance", href: TIER_SOURCE }], + }, +] + +const QUICK_PROMPTS = { + en: [ + "How are Arcade points calculated?", + "What are the 2026 prize tiers?", + "When does the Prize Counter open?", + "When are new labs released?", + ], + vi: [ + "Arcade tính điểm như thế nào?", + "Tier thưởng 2026 gồm những mốc nào?", + "Khi nào Prize Counter mở?", + "Khi nào có lab mới?", + ], +} + +function normalize(value: string): string { + return value + .toLocaleLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/đ/g, "d") + .replace(/[^a-z0-9+\s-]/g, " ") + .replace(/\s+/g, " ") + .trim() +} + +function detectVietnamese(value: string, locale: string): boolean { + if (locale.startsWith("vi")) return true + if (/[ăâđêôơưà-ỹ]/i.test(value)) return true + const normalized = normalize(value) + return /\b(khi nao|diem|qua|lab moi|tinh diem|dang ky|nhan hang|bao lau|cach)\b/.test( + normalized, + ) +} + +function findAnswer(question: string): FaqItem | null { + const normalized = normalize(question) + if (!normalized) return null + + let best: { item: FaqItem; score: number } | null = null + for (const item of FAQ_ITEMS) { + const score = item.keywords.reduce( + (total, keyword) => total + (normalized.includes(normalize(keyword)) ? 1 : 0), + 0, + ) + if (score > 0 && (!best || score > best.score)) { + best = { item, score } + } + } + return best?.item ?? null +} + +function getLocale(): string { + if (typeof document === "undefined") return "en" + return ( + document.documentElement.dataset.locale || + document.documentElement.lang || + "en" + ).toLowerCase() +} + +export default function ArcadeFaqChatbot() { + const [open, setOpen] = useState(false) + const [input, setInput] = useState("") + const [menuOpen, setMenuOpen] = useState(false) + const [showAbout, setShowAbout] = useState(false) + const [locale, setLocale] = useState("en") + const [hiddenForWidget, setHiddenForWidget] = useState(false) + const [messages, setMessages] = useState([]) + const messageId = useRef(0) + const logRef = useRef(null) + const inputRef = useRef(null) + + const vietnameseUi = locale.startsWith("vi") + const prompts = vietnameseUi ? QUICK_PROMPTS.vi : QUICK_PROMPTS.en + + const welcome = useMemo( + () => + vietnameseUi + ? "Chào bạn! Mình có thể trả lời nhanh về Google Skills Arcade 2026: cách tính điểm, tier, lab/game, Prize Counter và reward." + : "Hi! I can answer quick questions about Google Skills Arcade 2026: points, tiers, labs/games, the Prize Counter and rewards.", + [vietnameseUi], + ) + + useEffect(() => { + setLocale(getLocale()) + setHiddenForWidget( + /^\/(?:[a-z]{2}(?:-[a-z]{2})?\/)?widget\/?$/i.test( + window.location.pathname, + ), + ) + + const observer = new MutationObserver(() => setLocale(getLocale())) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["lang", "data-locale"], + }) + return () => observer.disconnect() + }, []) + + useEffect(() => { + if (!open) return + + const onEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false) + } + document.addEventListener("keydown", onEscape) + window.setTimeout(() => inputRef.current?.focus(), 0) + return () => document.removeEventListener("keydown", onEscape) + }, [open]) + + useEffect(() => { + if (!open) return + logRef.current?.scrollTo({ + top: logRef.current.scrollHeight, + behavior: "smooth", + }) + }, [messages, open]) + + function ask(rawQuestion: string) { + const question = rawQuestion.trim() + if (!question) return + + const useVietnamese = detectVietnamese(question, locale) + const matched = findAnswer(question) + const fallback = useVietnamese + ? "Mình chưa có câu trả lời chắc chắn cho câu này. Bạn có thể hỏi về cách tính điểm, tier 2026, lịch lab/game, Prize Counter, shipping hoặc điểm bị thiếu. Với thông tin có thể thay đổi, hãy ưu tiên thông báo chính thức của Google." + : "I do not have a reliable answer for that yet. Try asking about point calculation, 2026 tiers, lab/game timing, the Prize Counter, shipping, or missing points. For anything time-sensitive, prefer Google's official announcements." + + const nextUserId = ++messageId.current + const nextAssistantId = ++messageId.current + setMessages((current) => [ + ...current, + { id: nextUserId, role: "user", text: question }, + { + id: nextAssistantId, + role: "assistant", + text: matched + ? matched.answer[useVietnamese ? "vi" : "en"] + : fallback, + sources: matched?.sources, + }, + ]) + setInput("") + } + + function clearChat() { + setMessages([]) + setInput("") + setMenuOpen(false) + setShowAbout(false) + messageId.current = 0 + window.setTimeout(() => inputRef.current?.focus(), 0) + } + + function showSuggestions() { + setMessages([]) + setMenuOpen(false) + setShowAbout(false) + window.setTimeout(() => inputRef.current?.focus(), 0) + } + + function submit(event: FormEvent) { + event.preventDefault() + ask(input) + } + + if (hiddenForWidget) return null + + return ( +
+ {open ? ( +
+
+ +
+ Arcade Guide + + {vietnameseUi + ? "FAQ nhanh · cập nhật 2026" + : "Quick FAQ · 2026 guidance"} + +
+
+ + + {menuOpen ? ( +
+ + + +
+ ) : null} +
+
+ +
+ {showAbout ? ( +
+
+ + {vietnameseUi ? "Về Arcade Guide" : "About Arcade Guide"} +
+

+ {vietnameseUi + ? "Đây là FAQ assistant chạy trực tiếp trên website, không gửi câu hỏi tới AI API. Hội thoại chỉ tồn tại trong phiên trang hiện tại và có thể xóa bằng New chat." + : "This FAQ assistant runs directly in the website and does not send questions to an AI API. Conversation history only lives in the current page session and can be cleared with New chat."} +

+ + {vietnameseUi ? "Rule & tier Arcade 2026" : "2026 Arcade rules & tiers"} + + + {vietnameseUi ? "Thông tin Prize Counter 2026" : "2026 Prize Counter guidance"} + + +
+ ) : null} + {!showAbout ? ( + <> +
+ +
+

{welcome}

+ + {vietnameseUi + ? "Thông tin có thể thay đổi; Google là nguồn quyết định cuối cùng." + : "Details can change; Google remains the final authority."} + +
+
+ + {messages.map((message) => ( +
+ {message.role === "assistant" ? ( + + ) : null} +
+

{message.text}

+ {message.sources && message.sources.length > 0 ? ( +
+ {message.sources.map((source) => ( + + {source.label} + + ))} +
+ ) : null} +
+
+ ))} + + {messages.length === 0 ? ( +
+ {prompts.map((prompt) => ( + + ))} +
+ ) : null} + + ) : null} +
+ + + +
+ setInput(event.target.value)} + maxLength={240} + placeholder={ + vietnameseUi + ? "Hỏi về điểm, lab, reward..." + : "Ask about points, labs, rewards..." + } + aria-label={vietnameseUi ? "Nhập câu hỏi" : "Ask a question"} + /> + +
+
+ ) : null} + + +
+ ) +} diff --git a/components/arcade/facilitator-panel-gate.tsx b/components/arcade/facilitator-panel-gate.tsx index 4fccdf0f8..a47df5a56 100644 --- a/components/arcade/facilitator-panel-gate.tsx +++ b/components/arcade/facilitator-panel-gate.tsx @@ -6,9 +6,13 @@ import FacilitatorPanel from "./facilitator-panel" import { FACILITATOR_PANEL_OPEN_EVENT, FACILITATOR_PARTICIPATION_EVENT, + FACILITATOR_PROGRAM_STATE_EVENT, normalizeFacilitatorProfileUrl, readFacilitatorParticipation, + readFacilitatorProgramState, type FacilitatorParticipationDetail, + type FacilitatorProgramState, + type FacilitatorProgramStateDetail, } from "./facilitator-participation" import { readActiveDashboard, observeDashboardMode } from "./dashboard-state" @@ -21,6 +25,9 @@ function readStoredProfileUrl(): string { export default function FacilitatorPanelGate() { const [profileUrl, setProfileUrl] = useState("") const [participating, setParticipating] = useState(false) + const [programState, setProgramState] = useState( + () => readFacilitatorProgramState(), + ) const returnFocusRef = useRef(null) const drawerWasOpenRef = useRef(false) @@ -79,14 +86,37 @@ export default function FacilitatorPanelGate() { } }, [profileUrl]) + useEffect(() => { + const syncProgramState = () => setProgramState(readFacilitatorProgramState()) + const onProgramStateChange = (event: Event) => { + const detail = (event as CustomEvent).detail + setProgramState(detail?.state ?? readFacilitatorProgramState()) + } + + syncProgramState() + window.addEventListener(FACILITATOR_PROGRAM_STATE_EVENT, onProgramStateChange) + return () => + window.removeEventListener( + FACILITATOR_PROGRAM_STATE_EVENT, + onProgramStateChange, + ) + }, []) + useEffect(() => { const updateLauncherVisibility = () => { const launcher = document.querySelector(".facilitator-launcher") if (!launcher) return + const programAllowsLauncher = + programState === "active" || programState === "unconfigured" launcher.hidden = - !participating || !launcher.classList.contains("is-participating") + !participating || + !programAllowsLauncher || + !launcher.classList.contains("is-participating") + document.documentElement.dataset.facilitatorLauncherVisible = launcher.hidden + ? "false" + : "true" } updateLauncherVisibility() @@ -98,8 +128,11 @@ export default function FacilitatorPanelGate() { attributeFilter: ["class"], }) - return () => observer.disconnect() - }, [participating]) + return () => { + observer.disconnect() + delete document.documentElement.dataset.facilitatorLauncherVisible + } + }, [participating, programState]) useEffect(() => { const restoreFocusWhenDrawerCloses = () => { diff --git a/components/arcade/facilitator-participation.ts b/components/arcade/facilitator-participation.ts index ee284354d..2be2feed9 100644 --- a/components/arcade/facilitator-participation.ts +++ b/components/arcade/facilitator-participation.ts @@ -1,6 +1,19 @@ export const FACILITATOR_PARTICIPATION_EVENT = "arcade-facilitator-participation-change" export const FACILITATOR_PANEL_OPEN_EVENT = "arcade-facilitator-panel-open" +export const FACILITATOR_PROGRAM_STATE_EVENT = + "arcade-facilitator-program-state-change" + +export type FacilitatorProgramState = + | "unknown" + | "active" + | "ended" + | "unconfigured" + | "disabled" + +export type FacilitatorProgramStateDetail = { + state: FacilitatorProgramState +} const PARTICIPATION_STORAGE_PREFIX = "arcade-facilitator-participation-v1" @@ -61,3 +74,35 @@ export function writeFacilitatorParticipation( ), ) } + +const FACILITATOR_PROGRAM_STATES = new Set([ + "unknown", + "active", + "ended", + "unconfigured", + "disabled", +]) + +export function readFacilitatorProgramState(): FacilitatorProgramState { + if (typeof document === "undefined") return "unknown" + + const state = document.documentElement.dataset.facilitatorProgramState + return FACILITATOR_PROGRAM_STATES.has(state as FacilitatorProgramState) + ? (state as FacilitatorProgramState) + : "unknown" +} + +export function writeFacilitatorProgramState( + state: FacilitatorProgramState, +): void { + if (typeof document === "undefined" || typeof window === "undefined") return + if (document.documentElement.dataset.facilitatorProgramState === state) return + + document.documentElement.dataset.facilitatorProgramState = state + window.dispatchEvent( + new CustomEvent( + FACILITATOR_PROGRAM_STATE_EVENT, + { detail: { state } }, + ), + ) +} diff --git a/components/arcade/program-countdown.tsx b/components/arcade/program-countdown.tsx index 738f2ff88..6f1964026 100644 --- a/components/arcade/program-countdown.tsx +++ b/components/arcade/program-countdown.tsx @@ -9,10 +9,14 @@ import { type DeadlineSource, type ResolvedDeadline, } from "./countdown-deadline" +import { + FACILITATOR_PANEL_OPEN_EVENT, + writeFacilitatorProgramState, + type FacilitatorProgramState, +} from "./facilitator-participation" const HOST_SELECTOR = ".program-countdown-host" const HERO_SELECTOR = ".arcade-hero" -const FACILITATOR_LAUNCHER_SELECTOR = ".facilitator-launcher" const DEFAULT_TIME_ZONE_OFFSET = "+05:30" const FIREBASE_CDN_VERSION = "12.18.0" const DEFAULT_FETCH_INTERVAL_MS = 900_000 @@ -339,10 +343,18 @@ function formatPart(value: number): string { return String(value).padStart(2, "0") } +function facilitatorProgramState( + config: ProgramCountdownConfig | undefined, + nowMs: number, +): FacilitatorProgramState { + if (!config) return "unknown" + if (!config.enabled) return "disabled" + if (!config.deadline) return "unconfigured" + return countdownParts(config.deadline, nowMs).ended ? "ended" : "active" +} + function openFacilitatorDetails() { - document - .querySelector(FACILITATOR_LAUNCHER_SELECTOR) - ?.click() + window.dispatchEvent(new Event(FACILITATOR_PANEL_OPEN_EVENT)) } function ProgramCard({ @@ -389,8 +401,12 @@ function ProgramCard({ ) : remaining.ended ? (
- Unavailable - Program tracker + {config.id === "facilitator" ? "Season ended" : "Unavailable"} + + {config.id === "facilitator" + ? "Facilitator Program 2026 has ended" + : "Program tracker"} +
{config.id === "facilitator" ? (