Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 107 additions & 15 deletions apps/web/src/app/dashboard/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { useConvexAuth, usePaginatedQuery, useQuery } from "convex/react";
import { useEffect, useRef, useState } from "react";
import Selector from "@/app/dashboard/register/components/Selector";
import { useNextTerm, useNextYear } from "@/components/AppConfigProvider";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useSearchParam } from "@/hooks/use-search-param";
import { CourseSelector } from "@/modules/course-selection";
import CourseSelectorSkeleton from "@/modules/course-selection/components/CourseSelectorSkeleton";
Expand All @@ -13,6 +15,7 @@ import type {
CourseOfferingWithCourse,
} from "@/modules/course-selection/types";
import {
type Class,
getUserClassesByTerm,
ScheduleCalendar,
} from "@/modules/schedule-calendar/schedule-calendar";
Expand All @@ -25,9 +28,50 @@ const RegisterPage = () => {
const [hoveredCourse, setHoveredCourse] = useState<CourseOffering | null>(
null,
);
const [selectedCourse, setSelectedCourse] = useState<Class | null>(null);
const [mobileView, setMobileView] = useState<"selector" | "calendar">(
"selector",
);
const [previousMobileView, setPreviousMobileView] = useState<
"selector" | "calendar"
>("selector");
const [isMobile, setIsMobile] = useState(false);

// TODO: save the state to cookie
const [showAlternatives, setShowAlternatives] = useState(true);

useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};

checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);

useEffect(() => {
if (selectedCourse && isMobile && mobileView === "calendar") {
setPreviousMobileView("calendar");
setMobileView("selector");
}
}, [selectedCourse, isMobile, mobileView]);

const handleCourseSelect = (course: Class | null) => {
if (!course && isMobile && previousMobileView === "calendar") {
// When closing detail panel on mobile, return to calendar view
setMobileView("calendar");
}
setSelectedCourse(course);
};

// clear selected course when switching tabs
const handleMobileViewChange = (view: "selector" | "calendar") => {
setMobileView(view);
if (view === "calendar" && selectedCourse) {
setSelectedCourse(null);
}
};

// Search param state with debouncing and URL sync
const { searchValue, setSearchValue, debouncedSearchValue } = useSearchParam({
Expand Down Expand Up @@ -67,7 +111,16 @@ const RegisterPage = () => {
}
}, [results, debouncedSearchValue, status]);

const classes = getUserClassesByTerm(allClasses, currentYear, currentTerm);
const allClassesForTerm = getUserClassesByTerm(
allClasses,
currentYear,
currentTerm,
);

// Filter out alternatives if toggle is off
const classes = showAlternatives
? allClassesForTerm
: allClassesForTerm?.filter((c) => !c.alternativeOf);

const isSearching =
status === "LoadingFirstPage" &&
Expand All @@ -83,11 +136,28 @@ const RegisterPage = () => {
return <CourseSelectorSkeleton />;
}

const AltToggle = () => (
<>
<Switch
id="alt-switcher"
className="order-1 h-4 w-6 after:absolute after:inset-0 [&_span]:size-3 data-[state=checked]:[&_span]:translate-x-2 data-[state=checked]:[&_span]:rtl:-translate-x-2"
checked={showAlternatives}
onCheckedChange={setShowAlternatives}
/>
<div className="grid grow gap-2">
<Label htmlFor="alt-switcher">Show alternative courses</Label>
<p className="text-xs text-muted-foreground">
You can set one course as alternative for another.
</p>
</div>
</>
);

return (
<div className="flex flex-col gap-4 w-full">
{/* Mobile toggle buttons */}
<div className="md:hidden shrink-0 p-2">
<Selector value={mobileView} onValueChange={setMobileView} />
<Selector value={mobileView} onValueChange={handleMobileViewChange} />
</div>

{/* Mobile view */}
Expand All @@ -101,31 +171,53 @@ const RegisterPage = () => {
loadMore={loadMore}
status={status}
isSearching={isSearching}
selectedCourse={selectedCourse}
onCourseSelect={handleCourseSelect}
selectedClassNumbers={selectedClassNumbers}
/>
) : (
<div className="h-full">
<ScheduleCalendar classes={classes} hoveredCourse={hoveredCourse} />
<div className="h-full flex flex-col space-y-2">
<div className="md:hidden relative flex w-full items-start gap-2 rounded-md border border-input p-4 shadow-xs outline-none has-data-[state=checked]:border-primary/50">
<AltToggle />
</div>
<ScheduleCalendar
classes={classes}
hoveredCourse={hoveredCourse}
selectedCourse={selectedCourse}
onCourseSelect={handleCourseSelect}
/>
</div>
)}
</div>

{/* Desktop view */}
<div className="hidden md:flex gap-4 flex-1 min-h-0">
<CourseSelector
courseOfferingsWithCourses={displayedResults}
onHover={setHoveredCourse}
onSearchChange={setSearchValue}
searchQuery={searchValue}
loadMore={loadMore}
status={status}
isSearching={isSearching}
selectedClassNumbers={selectedClassNumbers}
/>
<div className="flex flex-col space-y-4">
<div className="relative flex w-full items-start gap-2 rounded-md border border-input p-4 shadow-xs outline-none has-data-[state=checked]:border-primary/50">
<AltToggle />
</div>
<CourseSelector
courseOfferingsWithCourses={displayedResults}
onHover={setHoveredCourse}
onSearchChange={setSearchValue}
searchQuery={searchValue}
loadMore={loadMore}
status={status}
isSearching={isSearching}
selectedCourse={selectedCourse}
onCourseSelect={handleCourseSelect}
selectedClassNumbers={selectedClassNumbers}
/>
</div>

<div className="flex-1 min-w-0">
<div className="sticky top-0">
<ScheduleCalendar classes={classes} hoveredCourse={hoveredCourse} />
<ScheduleCalendar
classes={classes}
hoveredCourse={hoveredCourse}
selectedCourse={selectedCourse}
onCourseSelect={handleCourseSelect}
/>
</div>
</div>
</div>
Expand Down
23 changes: 15 additions & 8 deletions apps/web/src/hooks/use-search-param.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useDebounce } from "./use-debounce";

interface UseSearchParamOptions {
Expand All @@ -19,16 +19,23 @@ export function useSearchParam(options: UseSearchParamOptions) {
);
const debouncedSearchValue = useDebounce(searchValue, debounceDelay);

// Track the last URL-synced value to prevent infinite loops
const lastSyncedValue = useRef<string | null>(searchParams.get(paramKey));

// Update URL with debounced search value
useEffect(() => {
const params = new URLSearchParams(searchParams);
if (debouncedSearchValue) {
params.set(paramKey, debouncedSearchValue);
} else {
params.delete(paramKey);
// Only update if the debounced value differs from what's already in the URL
if (debouncedSearchValue !== lastSyncedValue.current) {
const params = new URLSearchParams(window.location.search);
if (debouncedSearchValue) {
params.set(paramKey, debouncedSearchValue);
} else {
params.delete(paramKey);
}
lastSyncedValue.current = debouncedSearchValue || null;
router.replace(`?${params.toString()}`, { scroll: false });
}
router.replace(`?${params.toString()}`, { scroll: false });
}, [debouncedSearchValue, router, searchParams, paramKey]);
}, [debouncedSearchValue, paramKey, router]);

return {
searchValue,
Expand Down
Loading