diff --git a/src/__tests__/app/sitemap.test.ts b/src/__tests__/app/sitemap.test.ts new file mode 100644 index 00000000..58dd34c4 --- /dev/null +++ b/src/__tests__/app/sitemap.test.ts @@ -0,0 +1,47 @@ +import sitemap from "@/app/sitemap"; + +const siteUrlEnvironmentKeys = [ + "NEXT_PUBLIC_SITE_URL", + "NEXT_PUBLIC_APP_URL", + "VERCEL_PROJECT_PRODUCTION_URL", + "VERCEL_URL", +] as const; + +const originalEnvironment = Object.fromEntries( + siteUrlEnvironmentKeys.map((key) => [key, process.env[key]]), +); + +describe("sitemap", () => { + afterEach(() => { + for (const key of siteUrlEnvironmentKeys) { + const originalValue = originalEnvironment[key]; + if (originalValue === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalValue; + } + } + }); + + it("preserves static route metadata without generated modification dates", () => { + for (const key of siteUrlEnvironmentKeys) { + delete process.env[key]; + } + + const entries = sitemap(); + expect(entries.every((entry) => !("lastModified" in entry))).toBe(true); + expect(entries[0].url).toBe("https://doubt-desk-seven.vercel.app"); + expect(entries[0].changeFrequency).toBe("weekly"); + expect(entries[0].priority).toBe(1); + }); + + it("uses the configured site URL without introducing duplicate slashes", () => { + process.env.NEXT_PUBLIC_SITE_URL = "https://docs.example.com/"; + + const entries = sitemap(); + + expect(entries[0].url).toBe("https://docs.example.com"); + expect(entries[1].url).toBe("https://docs.example.com/about"); + expect(entries.every((entry) => !("lastModified" in entry))).toBe(true); + }); +}); diff --git a/src/app/(routes)/bookmarks/page.tsx b/src/app/(routes)/bookmarks/page.tsx index a0c48d4a..846d040e 100644 --- a/src/app/(routes)/bookmarks/page.tsx +++ b/src/app/(routes)/bookmarks/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { Bookmark, Loader2, ArrowLeft, RefreshCw } from "lucide-react"; +import { Bookmark, Loader2, ArrowLeft, RefreshCw, Search, X } from "lucide-react"; import DoubtCard from "@/components/classroom/DoubtCard"; import { useRouter } from "next/navigation"; import { useAppUser } from "@/app/provider"; @@ -21,6 +21,7 @@ export default function BookmarksPage() { const [bookmarks, setBookmarks] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); const router = useRouter(); const { appUser } = useAppUser(); const { isLoaded, isSignedIn } = useAuth(); @@ -54,6 +55,15 @@ export default function BookmarksPage() { fetchBookmarks(); }, [isLoaded, isSignedIn]); + const filteredBookmarks = bookmarks.filter((doubt) => { + if (!searchQuery.trim()) return true; + const q = searchQuery.toLowerCase(); + return ( + doubt.subject?.toLowerCase().includes(q) || + doubt.content?.toLowerCase().includes(q) + ); + }); + return ( <> @@ -86,6 +96,28 @@ export default function BookmarksPage() { + {!loading && !error && bookmarks.length > 0 && ( +
+ + setSearchQuery(e.target.value)} + placeholder="Search bookmarks by subject or content..." + className="w-full pl-11 pr-10 py-3 rounded-xl border border-slate-200 dark:border-zinc-800 bg-slate-50 dark:bg-zinc-900 text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-zinc-500 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-purple-500/40 transition-all" + /> + {searchQuery && ( + + )} +
+ )} + {loading ? (
@@ -112,9 +144,9 @@ export default function BookmarksPage() { {UI_TEXT.RETRY_BUTTON}
- ) : bookmarks.length > 0 ? ( + ) : filteredBookmarks.length > 0 ? (
- {bookmarks.map((doubt) => ( + {filteredBookmarks.map((doubt) => ( ({ - ...initialDoubt, - tags: [], - replyCount: initialDoubt.replyCount || 0, - hasLiked: false, - hasBookmarked: false, - }); - - const fetchDoubt = async () => { - try { - const res = await fetch(`/api/doubts/${initialDoubt.id}`); - if (res.ok) { - const data = await res.json(); - setDoubt(data); - } - } catch (error) { - console.error("Failed to fetch doubt details:", error); - } - }; - - useEffect(() => { - fetchDoubt(); - }, [initialDoubt.id]); - - const backUrl = doubt.classroomId ? `/rooms/${doubt.classroomId}` : "/public-rooms"; - - const handleScrollToComments = () => { - const commentsSection = document.getElementById("permalink-comments"); - if (commentsSection) { - commentsSection.scrollIntoView({ behavior: "smooth" }); - } - }; - - return ( -
- {/* Glow effect */} -
- - {/* Header/Back button */} -
- - - Back to Feed - -

- Doubt Permalink -

-
- - {/* Content */} -
- - -
-
- ); -} diff --git a/src/app/doubts/[id]/page.tsx b/src/app/doubts/[id]/page.tsx deleted file mode 100644 index b575f84c..00000000 --- a/src/app/doubts/[id]/page.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { notFound, redirect } from "next/navigation"; -import { db } from "@/configs/db"; -import { doubtsTable, classroomsTable, membershipsTable } from "@/configs/schema"; -import { eq, and } from "drizzle-orm"; -import { currentUser } from "@clerk/nextjs/server"; -import DoubtPermalinkClient from "./DoubtPermalinkClient"; -import type { Metadata } from "next"; -import { toPublicDoubt } from "@/lib/anonymity/anonymity"; - -export async function generateMetadata( - { params }: { params: Promise<{ id: string }> } -): Promise { - const { id } = await params; - const doubtId = parseInt(id, 10); - if (isNaN(doubtId)) { - return { - title: "Doubt Not Found", - }; - } - - try { - const [doubt] = await db - .select({ - subject: doubtsTable.subject, - content: doubtsTable.content, - }) - .from(doubtsTable) - .where(eq(doubtsTable.id, doubtId)) - .limit(1); - - if (!doubt) { - return { - title: "Doubt Not Found", - }; - } - - const title = doubt.subject || "Doubt Thread"; - const contentSnippet = doubt.content - ? doubt.content.slice(0, 150) + (doubt.content.length > 150 ? "..." : "") - : "View this doubt on DoubtDesk."; - - return { - title, - description: contentSnippet, - }; - } catch { - return { - title: "Doubt Thread", - }; - } -} - -export default async function DoubtPermalinkPage( - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params; - const doubtId = parseInt(id, 10); - - if (isNaN(doubtId)) { - notFound(); - } - - const user = await currentUser(); - const email = user?.primaryEmailAddress?.emailAddress ?? null; - - // Fetch the single doubt - const [doubt] = await db - .select() - .from(doubtsTable) - .where(eq(doubtsTable.id, doubtId)) - .limit(1); - - if (!doubt) { - notFound(); - } - - // Classroom membership check - if (doubt.classroomId) { - if (!email) { - redirect("/sign-in"); - } - - // Check membership - const [membership] = await db - .select({ role: membershipsTable.role }) - .from(membershipsTable) - .where( - and( - eq(membershipsTable.userEmail, email), - eq(membershipsTable.classroomId, doubt.classroomId) - ) - ); - - let role = membership?.role ?? null; - if (!role) { - // Check if teacher owns the classroom - const [ownedClassroom] = await db - .select() - .from(classroomsTable) - .where( - and( - eq(classroomsTable.id, doubt.classroomId), - eq(classroomsTable.teacherEmail, email) - ) - ); - if (ownedClassroom) { - role = "owner"; - } - } - - if (!role) { - redirect("/403"); - } - - // Teacher doubts visibility rules - if (doubt.type === "teacher") { - const isTeacher = ["teacher", "owner", "admin"].includes(role.toLowerCase()); - const isAuthor = doubt.userEmail === email; - if (!isTeacher && !isAuthor) { - redirect("/403"); - } - } - } - - // Sanitize before handing the row to a client component: props serialized into - // the RSC payload are visible in the browser, so the raw userEmail/embedding must - // be stripped here just as the API does. All server-side checks above used the - // raw row; only the client-facing shape is anonymized. - return ( - - ); -} diff --git a/src/app/page.tsx b/src/app/page.tsx index 1b5b96d3..29a45ac5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,53 +1,35 @@ "use client"; -import { useState, useEffect } from "react"; -import { - SignInButton, - SignUpButton, - SignedIn, - SignedOut, - useClerk, - UserButton, -} from "@clerk/nextjs"; -import { - Map, - MessageCircle, - ArrowRight, - LayoutGrid, - Clipboard, - Activity, - Users, - Globe, -} from "lucide-react"; -import Link from "next/link"; + +import { useEffect, useState } from "react"; +import { useClerk } from "@clerk/nextjs"; import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { ThemeToggle } from "@/components/layout/ThemeToggle"; -import ShapeGrid from "@/components/marketing/ShapeGrid"; -import { Inter, Staatliches } from "next/font/google"; -import LiveCampusThreadPanel from "@/components/classroom/LiveCampusThreadPanel"; -import { scrollToSection } from "@/lib/utils/scroll-to-section"; + Inter, + // Keeping only Inter here since other sections use their own fonts. + // (Next/font modules are safe to instantiate per component.) +} from "next/font/google"; + +import ScrollToTopButton from "@/components/ScrollToTopButton"; +import { scrollToSection } from "@/lib/scroll-to-section"; -import TestimonialsMarquee from "@/components/marketing/TestimonialsMarquee"; //Testimonials marquee +import SignOutDialog from "@/components/landing/SignOutDialog"; +import HeroSection from "@/components/landing/HeroSection"; +import FeaturesSection from "@/components/landing/FeaturesSection"; +import HowItWorksSection from "@/components/landing/HowItWorksSection"; +import TestimonialsSection from "@/components/landing/TestimonialsSection"; const inter = Inter({ subsets: ["latin"] }); -const staatliches = Staatliches({ weight: "400", subsets: ["latin"] }); export default function Home() { const [showSignOutDialog, setShowSignOutDialog] = useState(false); + const { signOut } = useClerk(); useEffect(() => { const scrollFromHash = () => { const hash = window.location.hash.slice(1); if (!hash) return; - requestAnimationFrame(() => scrollToSection(hash, { updateHash: false })); + requestAnimationFrame(() => + scrollToSection(hash, { updateHash: false }) + ); }; scrollFromHash(); @@ -55,131 +37,6 @@ export default function Home() { return () => window.removeEventListener("hashchange", scrollFromHash); }, []); - const { signOut } = useClerk(); - - const features = [ - { - slug: "collaborative-discussions", - title: "Real-time collaborative discussions", - description: - "Share questions, answers, and classroom updates instantly across study groups.", - icon: MessageCircle, - }, - { - slug: "classroom-management", - title: "Smart classroom management", - description: - "Organize learning spaces, schedules, and teacher workflows with ease.", - icon: LayoutGrid, - }, - { - slug: "notes-resource-sharing", - title: "Notes and resource sharing", - description: - "Keep study materials, highlights, and shared guides organized in one hub.", - icon: Clipboard, - }, - { - slug: "learning-roadmaps", - title: "Learning roadmaps and guidance", - description: - "Follow curated study paths that keep learners focused on milestones.", - icon: Map, - }, - { - slug: "ai-powered-doubt-solving", - title: "AI-powered doubt solving", - description: - "Get instant, context-aware answers to questions with smart AI support.", - icon: Activity, - }, - { - slug: "study-collaboration", - title: "Organized study collaboration", - description: - "Coordinate projects, peer review, and group work with clear tools and structure.", - icon: Users, - }, - ]; - - const howItWorks = [ - { - title: "Join or create a classroom", - description: "Teachers set up rooms, students join using invite codes.", - }, - { - title: "Ask doubts instantly", - description: "Post questions using text or image and get AI + peer help.", - }, - { - title: "Get clear answers & insights", - description: - "AI explanations, teacher guidance, and analytics all in one place.", - }, - ]; - - const testimonials = [ - { - id: "1", - name: "Aarav Sharma", - role: "B.Tech Student", - institution: "DTU", - avatarUrl: "/avatars/arav.png", - quote: - "DoubtDesk helped me resolve doubts 3x faster during exams. The AI explanations are incredibly clear.", - rating: 5, - }, - { - id: "2", - name: "Neha Verma", - role: "CS Student", - institution: "Punjab University", - avatarUrl: "/avatars/neha.png", - quote: - "Everything is organized in one place. No more scrolling through endless chat groups.", - rating: 5, - }, - { - id: "3", - name: "Rohit Mehta", - role: "Teaching Assistant", - institution: "NIT Jalandhar", - avatarUrl: "/avatars/rohit.png", - quote: - "Analytics help me quickly identify where students struggle the most.", - rating: 5, - }, - { - id: "4", - name: "Priya Singh", - role: "MBA Student", - institution: "IIM Indore", - avatarUrl: "/avatars/priya.png", - quote: - "My exam preparation became much more efficient after switching to DoubtDesk.", - rating: 5, - }, - { - id: "5", - name: "Karan Gupta", - role: "Engineering Student", - institution: "PEC Chandigarh", - avatarUrl: "/avatars/karan.png", - quote: - "Instant answers saved hours of searching through notes and forums.", - rating: 5, - }, - { - id: "6", - name: "Ananya Kapoor", - role: "Professor", - institution: "Chitkara University", - avatarUrl: "/avatars/ananya.png", - quote: "Managing classroom discussions has become significantly easier.", - rating: 5, - }, - ]; - const handleSignOut = async () => { await signOut({ redirectUrl: "/" }); }; @@ -188,260 +45,19 @@ export default function Home() {
- {/* Logout Confirmation Dialog */} - - - - - Are you sure you want to sign out? - - - You will need to log in again to access your classroom insights - and doubt-solving history. - - - - - Cancel - - - Sign Out - - - - - - {/* Hero Section */} -
-
- -
-
-
-
-
-
- {/* Primary Header Typography */} -

- Empower
- Your Learning
- with{" "} - - Collaborative AI. - -

- {/* Description Subtext Stack */} -
-
- Collaborative classrooms -
-

- Built for collaborative classrooms, instant doubt solving, and - smarter learning. -

-
- {/* Call to Action Button Core Wrappers */} -
- - - - - - - - - - - - - -
-
- {/* Right Live Panel Graphic Column */} -
-
- -
-
-
-
- - {/* Features Section */} -
-
-
-
- Features -
-

- Everything your classroom needs to solve doubts, stay aligned, - and move faster. -

-

- Built for modern study teams, DoubtDesk blends AI-powered doubt - solving, shared resources, and smart classroom flows into a - single polished platform. -

-
- -
- {features.map((feature, i) => { - const Icon = feature.icon; - return ( -
-
- -
-

- {feature.title} -

-

- {feature.description} -

-
- ); - })} -
-
-
- - {/* How It Works, Testimonials, etc. remain unchanged */} - {/* How It Works Section */} -
-
-
-
- Process -
-

- How it works -

-

- Simple flow from doubt → solution → understanding -

-
-
-
- {howItWorks.map((step, index) => ( -
-
- {index + 1} -
-

- {step.title} -

-

- {step.description} -

-
- ))} -
-
-
- - {/* Testimonials Section */} -
-
-
-
- Testimonials -
-

- What students say -

-

- Real feedback from learners and educators -

-
-
- {testimonials.map((t, index) => ( -
-

- “{t.quote}” -

-
-
- {t.name} -
-
- {t.role} -
-
-
- ))} -
-
-
-
+ + + + + + + +
); } + diff --git a/src/app/provider.tsx b/src/app/provider.tsx index fe31af63..f9197dfc 100644 --- a/src/app/provider.tsx +++ b/src/app/provider.tsx @@ -80,15 +80,24 @@ export function Provider({ children }: { children: React.ReactNode }) { headers: { "content-type": "application/json" }, }); - const data = await res.json().catch(() => null); - if (!res.ok) { + console.error(`User API failed with status ${res.status}`); + setAppUser(null); + return; + } + + let data: AppUser | null = null; + try { + data = await res.json(); + } catch (error) { + console.error("Failed to parse user response:", error); setAppUser(null); return; } - setAppUser(data as AppUser); - } catch { + setAppUser(data); + } catch (error) { + console.error("Failed to fetch user:", error); setAppUser(null); } finally { setLoading(false); diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 62716e02..e2d8ad3d 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -16,14 +16,15 @@ export default function sitemap(): MetadataRoute.Sitemap { const baseUrl = getSiteUrl(); return [ - { url: baseUrl, lastModified: new Date(), changeFrequency: "weekly", priority: 1 }, - { url: `${baseUrl}/about`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.8 }, - { url: `${baseUrl}/faq`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.7 }, - { url: `${baseUrl}/contributors`, lastModified: new Date(), changeFrequency: "weekly", priority: 0.6 }, - { url: `${baseUrl}/contact`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.5 }, - { url: `${baseUrl}/discussions`, lastModified: new Date(), changeFrequency: "daily", priority: 0.8 }, - { url: `${baseUrl}/public-rooms`, lastModified: new Date(), changeFrequency: "daily", priority: 0.9 }, - { url: `${baseUrl}/terms-of-service`, lastModified: new Date(), changeFrequency: "yearly", priority: 0.3 }, - { url: `${baseUrl}/privacy-policy`, lastModified: new Date(), changeFrequency: "yearly", priority: 0.3 }, + { url: baseUrl, changeFrequency: "weekly", priority: 1 }, + { url: `${baseUrl}/about`, changeFrequency: "monthly", priority: 0.8 }, + { url: `${baseUrl}/faq`, changeFrequency: "monthly", priority: 0.7 }, + { url: `${baseUrl}/contributors`, changeFrequency: "weekly", priority: 0.6 }, + { url: `${baseUrl}/contact`, changeFrequency: "monthly", priority: 0.5 }, + { url: `${baseUrl}/discussions`, changeFrequency: "daily", priority: 0.8 }, + { url: `${baseUrl}/roadmaps`, changeFrequency: "weekly", priority: 0.7 }, + { url: `${baseUrl}/public-rooms`, changeFrequency: "daily", priority: 0.9 }, + { url: `${baseUrl}/terms-of-service`, changeFrequency: "yearly", priority: 0.3 }, + { url: `${baseUrl}/privacy-policy`, changeFrequency: "yearly", priority: 0.3 }, ]; } diff --git a/src/components/ScrollToTopButton.tsx b/src/components/ScrollToTopButton.tsx new file mode 100644 index 00000000..1d1acbec --- /dev/null +++ b/src/components/ScrollToTopButton.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { usePathname } from "next/navigation"; +import { ArrowUp } from "lucide-react"; +import { cn } from "@/lib/utils/utils"; +import { getScrollBehavior } from "@/lib/utils/scroll-to-section"; + +type ScrollToTopButtonProps = { + showProgress?: boolean; + /** Scroll progress % (0–100) to show button when showProgress is true */ + progressThreshold?: number; + /** Pixel scroll offset to show button when showProgress is false */ + scrollThreshold?: number; +}; + +export default function ScrollToTopButton({ + showProgress = false, + progressThreshold = 5, + scrollThreshold = 300, +}: ScrollToTopButtonProps) { + const pathname = usePathname(); + const showProgressOnHome = showProgress || pathname === "/"; + const [isVisible, setIsVisible] = useState(false); + const [scrollProgress, setScrollProgress] = useState(0); + + useEffect(() => { + const handleScroll = () => { + const scrollTop = window.scrollY; + const docHeight = + document.documentElement.scrollHeight - window.innerHeight; + const progress = + docHeight > 0 ? (scrollTop / docHeight) * 100 : 0; + setScrollProgress(progress); + + if (showProgressOnHome) { + setIsVisible(progress > progressThreshold); + } else { + setIsVisible(scrollTop > scrollThreshold); + } + }; + + handleScroll(); + window.addEventListener("scroll", handleScroll, { passive: true }); + + return () => window.removeEventListener("scroll", handleScroll); + }, [showProgressOnHome, progressThreshold, scrollThreshold]); + + if (!isVisible) return null; + + const circumference = 2 * Math.PI * 24; + const scrollToTop = () => + window.scrollTo({ top: 0, behavior: getScrollBehavior() }); + + const focusRingClass = + "focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"; + + if (showProgressOnHome) { + return ( + + ); + } + + return ( + + ); +} diff --git a/src/components/classroom/AskAIView.tsx b/src/components/classroom/AskAIView.tsx index 6ce6540a..7cf81cd4 100644 --- a/src/components/classroom/AskAIView.tsx +++ b/src/components/classroom/AskAIView.tsx @@ -92,22 +92,32 @@ export default function AskAIView({ classroomId = null, onSuccess, initialDoubt setIsLoading(true); try { const res = await fetch(`/api/replies?doubtId=${initialDoubt.id}`); - if (res.ok) { - const data = await res.json(); - if (Array.isArray(data) && data.length > 0) { - const solution = data.find( - (r: any) => - r.type === "solution" || r.userName === "DoubtDesk AI" - ); - if (solution) { - const assistantMsg: DisplayMessage = { - id: "initial-assistant-" + initialDoubt.id, - role: "assistant", - content: solution.content, - isCelebration: mode === "mentor" && isCelebrationMessage(solution.content), - }; - setMessages([initialUserMsg, assistantMsg]); - } + if (!res.ok) { + console.error("Could not retrieve the solution."); + return; + } + + let data; + try { + data = await res.json(); + } catch (error) { + console.error("Failed to parse replies response:", error); + return; + } + + if (Array.isArray(data) && data.length > 0) { + const solution = data.find( + (r: any) => + r.type === "solution" || r.userName === "DoubtDesk AI" + ); + if (solution) { + const assistantMsg: DisplayMessage = { + id: "initial-assistant-" + initialDoubt.id, + role: "assistant", + content: solution.content, + isCelebration: mode === "mentor" && isCelebrationMessage(solution.content), + }; + setMessages([initialUserMsg, assistantMsg]); } } } catch (err) { @@ -164,10 +174,14 @@ export default function AskAIView({ classroomId = null, onSuccess, initialDoubt }); if (!res.ok) { - throw new Error(`API error ${res.status}`); + throw new Error(res.statusText || "Something went wrong"); + } + let data; + try { + data = (await res.json()) as { reply: string }; + } catch (error) { + throw new Error("Invalid server response format"); } - - const data = (await res.json()) as { reply: string }; const replyText = data.reply ?? "Sorry, something went wrong."; const assistantMsg: DisplayMessage = { diff --git a/src/components/classroom/DoubtRepliesModal.tsx b/src/components/classroom/DoubtRepliesModal.tsx index e428bad6..927650c3 100644 --- a/src/components/classroom/DoubtRepliesModal.tsx +++ b/src/components/classroom/DoubtRepliesModal.tsx @@ -121,10 +121,18 @@ export default function DoubtRepliesModal({ doubt, isOpen, onClose, onReplyChang try { const url = `/api/replies?doubtId=${doubt.id}`; const res = await fetch(url); - if (res.ok) { - const data = await res.json(); - setReplies(data); + if (!res.ok) { + console.error(`Replies API failed with status ${res.status}`); + return; + } + let data; + try { + data = await res.json(); + } catch (err) { + console.error("Failed to parse replies response:", err); + return; } + setReplies(data); } catch (error) { console.error("Failed to fetch replies:", error); } finally { diff --git a/src/components/landing/FeaturesSection.tsx b/src/components/landing/FeaturesSection.tsx new file mode 100644 index 00000000..82b21aba --- /dev/null +++ b/src/components/landing/FeaturesSection.tsx @@ -0,0 +1,60 @@ +import { features } from "./landingContent"; +import { Staatliches } from "next/font/google"; + +const staatliches = Staatliches({ weight: "400", subsets: ["latin"] }); + +export default function FeaturesSection() { + return ( +
+
+
+
+ Features +
+

+ Everything your classroom needs to solve doubts, stay aligned, + and move faster. +

+

+ Built for modern study teams, DoubtDesk blends AI-powered doubt + solving, shared resources, and smart classroom flows into a + single polished platform. +

+
+ +
+ {features.map((feature, i) => { + const Icon = feature.icon; + return ( +
+
+ +
+

+ {feature.title} +

+

+ {feature.description} +

+
+ ); + })} +
+
+
+ ); +} + diff --git a/src/components/landing/HeroSection.tsx b/src/components/landing/HeroSection.tsx new file mode 100644 index 00000000..78e834bd --- /dev/null +++ b/src/components/landing/HeroSection.tsx @@ -0,0 +1,105 @@ +import { ArrowRight, Globe } from "lucide-react"; +import Link from "next/link"; +import { SignedIn, SignedOut } from "@clerk/nextjs"; +import { Staatliches } from "next/font/google"; +import ShapeGrid from "@/components/marketing/ShapeGrid"; +import LiveCampusThreadPanel from "@/components/classroom/LiveCampusThreadPanel"; + +const staatliches = Staatliches({ weight: "400", subsets: ["latin"] }); + + +export default function HeroSection() { + return ( +
+
+ +
+
+
+
+
+
+

+ Empower
+ Your Learning
+ with{" "} + + Collaborative AI. + +

+ +
+
+ Collaborative classrooms +
+

+ Built for collaborative classrooms, instant doubt solving, and + smarter learning. +

+
+ +
+ + + + + + + + + + + + + + + +
+
+ +
+
+ +
+
+
+
+
+ ); +} + diff --git a/src/components/landing/HowItWorksSection.tsx b/src/components/landing/HowItWorksSection.tsx new file mode 100644 index 00000000..c86a8cd7 --- /dev/null +++ b/src/components/landing/HowItWorksSection.tsx @@ -0,0 +1,47 @@ +import { howItWorks } from "./landingContent"; + +export default function HowItWorksSection() { + return ( +
+
+
+
+ Process +
+

+ How it works +

+

+ Simple flow from doubt → solution → understanding +

+
+ +
+
+ + {howItWorks.map((step, index) => ( +
+
+ {index + 1} +
+

+ {step.title} +

+

+ {step.description} +

+
+ ))} +
+
+
+ ); +} + diff --git a/src/components/landing/SignOutDialog.tsx b/src/components/landing/SignOutDialog.tsx new file mode 100644 index 00000000..4783896e --- /dev/null +++ b/src/components/landing/SignOutDialog.tsx @@ -0,0 +1,48 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +type SignOutDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onSignOut: () => void | Promise; +}; + +export default function SignOutDialog({ + open, + onOpenChange, + onSignOut, +}: SignOutDialogProps) { + return ( + + + + Are you sure you want to sign out? + + You will need to log in again to access your classroom insights and + doubt-solving history. + + + + + Cancel + + + Sign Out + + + + + ); +} + diff --git a/src/components/landing/TestimonialsSection.tsx b/src/components/landing/TestimonialsSection.tsx new file mode 100644 index 00000000..a0de75ce --- /dev/null +++ b/src/components/landing/TestimonialsSection.tsx @@ -0,0 +1,47 @@ +import { testimonials } from "./landingContent"; + +export default function TestimonialsSection() { + return ( +
+
+
+
+ Testimonials +
+

+ What students say +

+

+ Real feedback from learners and educators +

+
+ +
+ {testimonials.map((t, index) => ( +
+

+ “{t.quote}” +

+
+
+ {t.name} +
+
+ {t.role} +
+
+
+ ))} +
+
+
+ ); +} + diff --git a/src/components/landing/landingContent.ts b/src/components/landing/landingContent.ts new file mode 100644 index 00000000..e593fac1 --- /dev/null +++ b/src/components/landing/landingContent.ts @@ -0,0 +1,157 @@ +import { + Activity, + Clipboard, + LayoutGrid, + Map, + MessageCircle, + Users, +} from "lucide-react"; + +export type LandingFeature = { + slug: string; + title: string; + description: string; + // Stored as a React component type (lucide icons) + icon: React.ComponentType<{ className?: string }>; +}; + + +export type LandingHowItWorksStep = { + title: string; + description: string; +}; + +export type LandingTestimonial = { + id: string; + name: string; + role: string; + institution: string; + avatarUrl: string; + quote: string; + rating: number; +}; + +export const features: LandingFeature[] = [ + { + slug: "collaborative-discussions", + title: "Real-time collaborative discussions", + description: + "Share questions, answers, and classroom updates instantly across study groups.", + icon: MessageCircle, + }, + { + slug: "classroom-management", + title: "Smart classroom management", + description: + "Organize learning spaces, schedules, and teacher workflows with ease.", + icon: LayoutGrid, + }, + { + slug: "notes-resource-sharing", + title: "Notes and resource sharing", + description: + "Keep study materials, highlights, and shared guides organized in one hub.", + icon: Clipboard, + }, + { + slug: "learning-roadmaps", + title: "Learning roadmaps and guidance", + description: + "Follow curated study paths that keep learners focused on milestones.", + icon: Map, + }, + { + slug: "ai-powered-doubt-solving", + title: "AI-powered doubt solving", + description: + "Get instant, context-aware answers to questions with smart AI support.", + icon: Activity, + }, + { + slug: "study-collaboration", + title: "Organized study collaboration", + description: + "Coordinate projects, peer review, and group work with clear tools and structure.", + icon: Users, + }, +]; + +export const howItWorks: LandingHowItWorksStep[] = [ + { + title: "Join or create a classroom", + description: "Teachers set up rooms, students join using invite codes.", + }, + { + title: "Ask doubts instantly", + description: "Post questions using text or image and get AI + peer help.", + }, + { + title: "Get clear answers & insights", + description: + "AI explanations, teacher guidance, and analytics all in one place.", + }, +]; + +export const testimonials: LandingTestimonial[] = [ + { + id: "1", + name: "Aarav Sharma", + role: "B.Tech Student", + institution: "DTU", + avatarUrl: "/avatars/arav.png", + quote: + "DoubtDesk helped me resolve doubts 3x faster during exams. The AI explanations are incredibly clear.", + rating: 5, + }, + { + id: "2", + name: "Neha Verma", + role: "CS Student", + institution: "Punjab University", + avatarUrl: "/avatars/neha.png", + quote: + "Everything is organized in one place. No more scrolling through endless chat groups.", + rating: 5, + }, + { + id: "3", + name: "Rohit Mehta", + role: "Teaching Assistant", + institution: "NIT Jalandhar", + avatarUrl: "/avatars/rohit.png", + quote: + "Analytics help me quickly identify where students struggle the most.", + rating: 5, + }, + { + id: "4", + name: "Priya Singh", + role: "MBA Student", + institution: "IIM Indore", + avatarUrl: "/avatars/priya.png", + quote: + "My exam preparation became much more efficient after switching to DoubtDesk.", + rating: 5, + }, + { + id: "5", + name: "Karan Gupta", + role: "Engineering Student", + institution: "PEC Chandigarh", + avatarUrl: "/avatars/karan.png", + quote: + "Instant answers saved hours of searching through notes and forums.", + rating: 5, + }, + { + id: "6", + name: "Ananya Kapoor", + role: "Professor", + institution: "Chitkara University", + avatarUrl: "/avatars/ananya.png", + quote: + "Managing classroom discussions has become significantly easier.", + rating: 5, + }, +]; + diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 25676cff..e4299f23 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import Link from "next/link"; import { useRouter, usePathname } from "next/navigation"; import { SignedIn, SignedOut, UserButton } from "@clerk/nextjs"; @@ -31,6 +31,31 @@ export default function Header() { return () => window.removeEventListener("popstate", handlePopState); }, [pathname]); + // Lock body scroll while the mobile menu is open + useEffect(() => { + if (isOpen) { + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = previousOverflow; + }; + } + }, [isOpen]); + + // Close mobile menu on Escape + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") setIsOpen(false); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + + // Close mobile menu automatically on route change + useEffect(() => { + setIsOpen(false); + }, [pathname]); + // Scroll Progress Bar useEffect(() => { const updateScrollProgress = () => { @@ -55,8 +80,14 @@ export default function Header() { { href: "/public-rooms", label: "Public Rooms" }, { href: "/faq", label: "FAQ" }, { href: "/contact", label: "Contact" }, + { href: "/about", label: "About" }, ]; + const isActive = useCallback( + (href: string) => !href.startsWith("#") && pathname === href, + [pathname] + ); + const handleScrollNavigation = (targetId: string) => { setIsOpen(false); @@ -77,7 +108,7 @@ export default function Header() { {showBackButton && (