From 459af9d46ab4f1685e6cf524490f302ba1638c48 Mon Sep 17 00:00:00 2001 From: rgrgogu Date: Mon, 24 Aug 2026 12:11:15 +0800 Subject: [PATCH] wip: QAS environment banner + upload-limit messaging (pre-monorepo checkpoint) Co-Authored-By: Claude Sonnet 5 --- .env.example | 5 + src/components/generic/EnvironmentBanner.jsx | 46 +++++ src/modules/admin/layouts/AdminLayout.jsx | 3 + src/modules/admin/pages/assets/AddAsset.jsx | 6 + .../admin/pages/assets/AddAssetsBulk.jsx | 6 + .../client/components/blocks/FileUpload.jsx | 11 ++ src/modules/client/layout/ClientLayout.jsx | 3 + src/modules/client/pages/UnitList.jsx | 167 ++++++++++-------- src/utils/environment.util.js | 3 + 9 files changed, 177 insertions(+), 73 deletions(-) create mode 100644 src/components/generic/EnvironmentBanner.jsx create mode 100644 src/utils/environment.util.js diff --git a/.env.example b/.env.example index 8ffa7e4..8a9f8af 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,11 @@ VITE_APP_NAME=starr VITE_API_URL=https://api.yourdomain.com/api VITE_APP_URL=https://api.yourdomain.com +# ── Environment ─────────────────────────────────────────────────────────────── +# "production" (droplet) or "qas" (home-hosted, behind Cloudflare Tunnel). +# Set to "qas" only on the QAS Preview build in Vercel's project settings. +VITE_APP_ENV=production + # ── PayPal ──────────────────────────────────────────────────────────────────── # Client ID is the public key — safe to expose in the browser bundle. # developer.paypal.com → My Apps & Credentials → Live tab → Client ID diff --git a/src/components/generic/EnvironmentBanner.jsx b/src/components/generic/EnvironmentBanner.jsx new file mode 100644 index 0000000..91b843c --- /dev/null +++ b/src/components/generic/EnvironmentBanner.jsx @@ -0,0 +1,46 @@ +import { useState } from "react"; +import { X } from "lucide-react"; + +// Hardcoded (not DB-driven) — flip to false if it should reappear on every +// reload instead of staying dismissed once closed. +const PERSIST_DISMISSAL = true; +const DISMISS_KEY = "qas_env_banner_dismissed"; + +export default function EnvironmentBanner() { + const [dismissed, setDismissed] = useState( + () => PERSIST_DISMISSAL && localStorage.getItem(DISMISS_KEY) === "true" + ); + + if (dismissed) return null; + + const handleDismiss = () => { + if (PERSIST_DISMISSAL) localStorage.setItem(DISMISS_KEY, "true"); + setDismissed(true); + }; + + return ( +
+
+

+ Development / Staging (QAS) environment — not production +

+ +
{ + if (e.key !== "Enter" && e.key !== " ") return; + e.preventDefault(); + handleDismiss(); + }} + aria-label="Dismiss environment banner" + title="Dismiss" + className="absolute right-2 inline-flex items-center justify-center size-8 rounded-lg cursor-pointer hover:opacity-70 transition-opacity text-amber-950" + > + +
+
+
+ ); +} diff --git a/src/modules/admin/layouts/AdminLayout.jsx b/src/modules/admin/layouts/AdminLayout.jsx index 98af361..c9c64ad 100644 --- a/src/modules/admin/layouts/AdminLayout.jsx +++ b/src/modules/admin/layouts/AdminLayout.jsx @@ -23,6 +23,8 @@ import { cn } from "@/lib/utils" import UserMenu from "@/components/generic/UserMenu" import NotificationBell from "@/components/generic/NotificationBell" import AdminStickyAnnouncementBar from "@/components/generic/AdminStickyAnnouncementBar" +import EnvironmentBanner from "@/components/generic/EnvironmentBanner" +import { isQasEnv } from "@/utils/environment.util" import UploadProgressToast from "@/components/generic/UploadProgressToast" import { ROLE_CONFIG } from "@/data/profile.data" @@ -50,6 +52,7 @@ const AdminLayout = () => { {/* AdminProvider wraps header + body so UserMenu can access ProfileProvider */}
+ {isQasEnv && }
diff --git a/src/modules/admin/pages/assets/AddAsset.jsx b/src/modules/admin/pages/assets/AddAsset.jsx index e7cf541..29350e8 100644 --- a/src/modules/admin/pages/assets/AddAsset.jsx +++ b/src/modules/admin/pages/assets/AddAsset.jsx @@ -13,6 +13,7 @@ import { useUploadQueue } from "@/contexts/UploadQueueContext"; import { useAuth } from "@/contexts/AuthContext"; import { MAX_ASSET_FILE_SIZE_SINGLE, MAX_ASSET_FILE_SIZE_SINGLE_LABEL } from "@/utils/assetUpload.util"; import { formatFileSize } from "@/utils/format.util"; +import { isQasEnv, QAS_UPLOAD_LIMIT_MB } from "@/utils/environment.util"; import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; @@ -239,6 +240,11 @@ export default function AddAsset() { error={errors._file?.message} /> + {isQasEnv && ( +

+ Self-hosted staging environment — uploads over {QAS_UPLOAD_LIMIT_MB}MB may fail (Cloudflare limit). +

+ )} + {isQasEnv && ( +

+ Self-hosted staging environment — uploads over {QAS_UPLOAD_LIMIT_MB}MB may fail (Cloudflare limit). +

+ )} {/* ── File list ── */} {total > 0 && ( diff --git a/src/modules/client/components/blocks/FileUpload.jsx b/src/modules/client/components/blocks/FileUpload.jsx index 3d785af..c32ea6e 100644 --- a/src/modules/client/components/blocks/FileUpload.jsx +++ b/src/modules/client/components/blocks/FileUpload.jsx @@ -13,6 +13,7 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; +import { isQasEnv, QAS_UPLOAD_LIMIT_MB } from "@/utils/environment.util"; // ── Helpers ─────────────────────────────────────────────────────────────────── function formatBytes(b) { @@ -271,6 +272,11 @@ const FileUpload = ({

Drop files here or click to browse

Upload your work to submit with this task

{derivedHint}

+ {isQasEnv && ( +

+ Self-hosted staging environment — uploads over {QAS_UPLOAD_LIMIT_MB}MB may fail (Cloudflare limit). +

+ )}
)} @@ -303,6 +309,11 @@ const FileUpload = ({ )}

{derivedHint}

+ {isQasEnv && ( +

+ Self-hosted staging environment — uploads over {QAS_UPLOAD_LIMIT_MB}MB may fail (Cloudflare limit). +

+ )}
)} diff --git a/src/modules/client/layout/ClientLayout.jsx b/src/modules/client/layout/ClientLayout.jsx index d62f44f..b921ca6 100644 --- a/src/modules/client/layout/ClientLayout.jsx +++ b/src/modules/client/layout/ClientLayout.jsx @@ -38,6 +38,8 @@ import { AVATAR_COLORS } from "@/data/profile.data" import { Badge } from "@/components/ui/badge" import ClientNotificationBell from "@/components/generic/ClientNotificationBell" import StickyAnnouncementBar from "@/components/generic/StickyAnnouncementBar" +import EnvironmentBanner from "@/components/generic/EnvironmentBanner" +import { isQasEnv } from "@/utils/environment.util" import { QRCodeCanvas } from "qrcode.react" // ─── Refer / Invite dialog ──────────────────────────────────────────────────── @@ -443,6 +445,7 @@ const ClientLayout = () => {
+ {isQasEnv && }
diff --git a/src/modules/client/pages/UnitList.jsx b/src/modules/client/pages/UnitList.jsx index 044afe4..bb29416 100644 --- a/src/modules/client/pages/UnitList.jsx +++ b/src/modules/client/pages/UnitList.jsx @@ -30,7 +30,7 @@ import { TYPE_DEFS } from "@/modules/admin/components/courses/completionRequirem const QuizPrerequisiteGate = ({ previousQuizzes, units, onQuizClick }) => { const [open, setOpen] = useState(false); - const passed = previousQuizzes.filter((q) => q.has_passed).length; + const passed = previousQuizzes.filter((q) => q.has_passed).length; const useModal = previousQuizzes.length > QUIZ_MODAL_THRESHOLD; return ( @@ -95,11 +95,10 @@ const QuizGateList = ({ requiredQuizzes, units, onQuizClick, onItemClick }) => ( onQuizClick({ unit: units.find((u) => u.unit_id === q.unitId), quiz: { quiz_id: q.quizId } }); onItemClick?.(); }} - className={`flex items-center gap-3 p-3 rounded-lg border transition-colors ${ - q.has_passed + className={`flex items-center gap-3 p-3 rounded-lg border transition-colors ${q.has_passed ? "bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800" : "border-border hover:bg-muted cursor-pointer" - }`} + }`} > {q.has_passed ? @@ -207,20 +206,20 @@ const SidebarContent = ({ {(unit.lessons ?? []).map((lesson) => { const lessonDone = getLessonCompleted?.(lesson); return ( -
  • onLessonClick({ unit, lesson })} - className={`flex items-center gap-2 pl-4 pr-3 py-1.5 text-sm rounded-md hover:bg-muted-foreground/10 hover:text-foreground cursor-pointer transition-colors ${selectedLessonId === lesson.lesson_id - ? "bg-muted-foreground/10 text-foreground font-medium" - : "text-muted-foreground" - }`} - > - {lessonDone - ? - : - } - {lesson.title} -
  • +
  • onLessonClick({ unit, lesson })} + className={`flex items-center gap-2 pl-4 pr-3 py-1.5 text-sm rounded-md hover:bg-muted-foreground/10 hover:text-foreground cursor-pointer transition-colors ${selectedLessonId === lesson.lesson_id + ? "bg-muted-foreground/10 text-foreground font-medium" + : "text-muted-foreground" + }`} + > + {lessonDone + ? + : + } + {lesson.title} +
  • ); })} {unit.quiz && (() => { @@ -228,21 +227,20 @@ const SidebarContent = ({ return (
  • onQuizClick({ unit, quiz: unit.quiz })} - className={`flex items-center gap-2 pl-6 pr-3 py-1.5 text-sm rounded-md cursor-pointer transition-colors ${ - selectedQuizId === unit.quiz.quiz_id + className={`flex items-center gap-2 pl-6 pr-3 py-1.5 text-sm rounded-md cursor-pointer transition-colors ${selectedQuizId === unit.quiz.quiz_id ? "bg-muted-foreground/10 text-foreground font-medium" : unit.quiz.has_passed - ? "text-emerald-600 dark:text-emerald-400 hover:bg-muted-foreground/10" - : quizLocked - ? "text-muted-foreground/50 hover:bg-muted-foreground/5" - : "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground" - }`} + ? "text-emerald-600 dark:text-emerald-400 hover:bg-muted-foreground/10" + : quizLocked + ? "text-muted-foreground/50 hover:bg-muted-foreground/5" + : "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground" + }`} > {unit.quiz.has_passed ? : quizLocked - ? - : + ? + : } {"Quiz"}
  • @@ -335,6 +333,7 @@ const UnitList = () => { const [sidebarOpen, setSidebarOpen] = useState(false); const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(true); const [helpOpen, setHelpOpen] = useState(false); + const [taskModeOpen, setTaskModeOpen] = useState(false); const resetCompletion = () => setSelectedCompletion(false); @@ -373,19 +372,19 @@ const UnitList = () => { const courseNoticeTitle = courseNoticeMissingQuiz && courseNoticeNoAssessment ? "Quizzes & Assessment Coming Soon" : courseNoticeMissingQuiz - ? "Quizzes Coming Soon" - : "Assessment Coming Soon"; + ? "Quizzes Coming Soon" + : "Assessment Coming Soon"; const courseNoticeDescription = courseNoticeMissingQuiz && courseNoticeNoAssessment ? "Some units in this course don't have a quiz yet, and this course does not have an assessment built yet — we are currently working on both. You may proceed with your studies. Enjoy!" : courseNoticeMissingQuiz - ? "Some units in this course don't have a quiz yet — we are currently working on it. You may proceed with your studies. Enjoy!" - : "This course does not have an assessment built yet — we are currently working on it. You may proceed with your studies. Enjoy!"; + ? "Some units in this course don't have a quiz yet — we are currently working on it. You may proceed with your studies. Enjoy!" + : "This course does not have an assessment built yet — we are currently working on it. You may proceed with your studies. Enjoy!"; // ── Session guard — block navigation while a quiz/assessment is in progress ─ - const quizActiveRef = useRef(false); // sync check inside handlers + const quizActiveRef = useRef(false); // sync check inside handlers const [quizSessionActive, setQuizSessionActive] = useState(false); - const [pendingNav, setPendingNav] = useState(null); // deferred nav fn + const [pendingNav, setPendingNav] = useState(null); // deferred nav fn const setQuizActive = useCallback((active) => { quizActiveRef.current = active; @@ -596,7 +595,7 @@ const UnitList = () => { .then(({ data }) => { if (data?.data?.has_task) setTaskCtx(data.data); }) - .catch(() => {}); // non-critical — silently swallow + .catch(() => { }); // non-critical — silently swallow }, [courseId]); // ── Toast when a task's read requirements are all done ──────────────── @@ -693,8 +692,8 @@ const UnitList = () => { label: selectedCompletion ? "Course Complete" : selectedAssessment - ? "Course Assessment" - : currentUnit ? `Unit ${(course?.units?.indexOf(currentUnit) ?? 0) + 1}: ${currentUnit.title}` : "Select a lesson" + ? "Course Assessment" + : currentUnit ? `Unit ${(course?.units?.indexOf(currentUnit) ?? 0) + 1}: ${currentUnit.title}` : "Select a lesson" }, ]; const breadcrumbItemsMobile = [ @@ -823,9 +822,9 @@ const UnitList = () => { const units = course?.units ?? []; const nextContent = getNextContent(); const nextLabel = nextContent - ? nextContent.type === "lesson" ? nextContent.lesson.title - : nextContent.type === "quiz" ? (nextContent.quiz.title || "Quiz") - : (nextContent.assessment?.title || "Course Assessment") + ? nextContent.type === "lesson" ? nextContent.lesson.title + : nextContent.type === "quiz" ? (nextContent.quiz.title || "Quiz") + : (nextContent.assessment?.title || "Course Assessment") : null; const showSidebarContent = desktopSidebarOpen && !quizSessionActive; @@ -973,20 +972,21 @@ const UnitList = () => {
    - {/* ── Task-mode banner ─────────────────────────────────────────── */} - {taskCtx?.has_task && ( -
    - - - {course?.is_completed - ? 'Course complete — tracking finished' - : 'Task mode — reading progress is being tracked automatically' - } - + {/* ── "Task mode" info dialog ── */} + setTaskModeOpen(false)}>Got it} + > +
    +

    + This course is part of one of your assigned tasks. + Your reading and completion progress here is being tracked automatically — no need to do anything extra. +

    - )} +
    {/* ── Up next after passing a quiz ── */} {selectedQuizId && quiz?.has_passed && !quizSessionActive && nextContent && ( @@ -999,7 +999,7 @@ const UnitList = () => { Up next {nextContent.type === "lesson" ? nextContent.lesson.title : nextContent.type === "quiz" ? (nextContent.quiz.title || "Quiz") - : (nextContent.assessment?.title || "Course Assessment")} + : (nextContent.assessment?.title || "Course Assessment")}
    @@ -1019,24 +1019,45 @@ const UnitList = () => { - {!selectedQuizId && !selectedAssessment && !selectedCompletion && lesson && ( - isProgressCompleted(lesson.uuid) ? ( -
    - - Done -
    - ) : ( - - ) - )} +
    + {taskCtx?.has_task && ( + course?.is_completed ? ( +
    + + Task complete +
    + ) : ( + + ) + )} + + {!selectedQuizId && !selectedAssessment && !selectedCompletion && lesson && ( + isProgressCompleted(lesson.uuid) ? ( +
    + + Done +
    + ) : ( + + ) + )} +
    {/* Mobile hamburger */} @@ -1086,7 +1107,7 @@ const UnitList = () => { {/* ── Desktop sidebar: rail + collapsible content panel (desktop only) ── */} -
    +
    {/* Rail — always visible */}
    {/* ── Main content ── */} -
    +
    {selectedCompletion ? ( diff --git a/src/utils/environment.util.js b/src/utils/environment.util.js new file mode 100644 index 0000000..90f9bc3 --- /dev/null +++ b/src/utils/environment.util.js @@ -0,0 +1,3 @@ +export const isQasEnv = import.meta.env.VITE_APP_ENV === "qas"; + +export const QAS_UPLOAD_LIMIT_MB = 100;