Promotoz/textbook-sherpa
0
1import { schoolsData } from './components/schools-data.js';2 3const API_BASE = 'https://api.textbooksherpa.com/v1';4 5const rawData = {6 schools: schoolsData,7 courses: [],8 sections: [],9 textbooks: [],10 prices: [],11 reviews: [],12 discussions: [],13 avatars: [14 'https://static.photos/people/100x100/201',15 'https://static.photos/people/100x100/202',16 'https://static.photos/people/100x100/203',17 'https://static.photos/people/100x100/204',18 'https://static.photos/people/100x100/205',19 'https://static.photos/people/100x100/206',20 'https://static.photos/people/100x100/207',21 'https://static.photos/people/100x100/208'22 ]23};24 25function safeLocalStorageGet(key, fallback = null) {26 try {27 const value = window.localStorage.getItem(key);28 return value === null ? fallback : value;29 } catch {30 return fallback;31 }32}33 34function safeLocalStorageSet(key, value) {35 try {36 window.localStorage.setItem(key, value);37 return true;38 } catch {39 return false;40 }41}42 43function safeLocalStorageRemove(key) {44 try {45 window.localStorage.removeItem(key);46 return true;47 } catch {48 return false;49 }50}51 52function delay(ms) {53 return new Promise((resolve) => window.setTimeout(resolve, ms));54}55 56function safeFeatherReplace() {57 if (window.feather && typeof window.feather.replace === 'function') {58 window.feather.replace();59 }60}61 62function escapeHtml(value) {63 return String(value)64 .replace(/&/g, '&')65 .replace(/</g, '<')66 .replace(/>/g, '>')67 .replace(/"/g, '"')68 .replace(/'/g, ''');69}70 71function formatDate(value) {72 const date = new Date(value);73 if (Number.isNaN(date.getTime())) {74 return 'Unknown date';75 }76 return date.toLocaleDateString(undefined, {77 month: 'short',78 day: 'numeric',79 year: 'numeric'80 });81}82 83function slugifyPathValue(value) {84 return encodeURIComponent(String(value || '').trim());85}86 87function parseRouteParam(path, prefix) {88 if (!String(path || '').startsWith(prefix)) {89 return null;90 }91 const remainder = String(path).slice(prefix.length);92 if (!remainder) {93 return null;94 }95 const [firstPart] = remainder.split('/');96 return firstPart ? decodeURIComponent(firstPart) : null;97}98 99function normalizeEntityId(value, fallback) {100 const normalized = String(value ?? '').trim();101 return normalized || fallback;102}103 104function normalizeData(input) {105 return {106 schools: Array.isArray(input.schools)107 ? input.schools108 .filter((school) => school && typeof school === 'object')109 .map((school, index) => ({110 ...school,111 id: normalizeEntityId(school.id, `school-${index + 1}`),112 name: String(school.name ?? '').trim(),113 abbr: String(school.abbr ?? '').trim(),114 location: String(school.location ?? '').trim(),115 state: String(school.state ?? '').trim(),116 image: String(school.image ?? '').trim(),117 courses: Number.isFinite(Number(school.courses)) ? Number(school.courses) : 0118 }))119 : [],120 courses: Array.isArray(input.courses)121 ? input.courses122 .filter((course) => course && typeof course === 'object')123 .map((course, index) => ({124 ...course,125 id: normalizeEntityId(course.id, `course-${index + 1}`),126 schoolId: normalizeEntityId(course.schoolId, ''),127 code: String(course.code ?? '').trim(),128 name: String(course.name ?? '').trim(),129 department: String(course.department ?? '').trim(),130 credits: Number.isFinite(Number(course.credits)) ? Number(course.credits) : 0131 }))132 : [],133 sections: Array.isArray(input.sections)134 ? input.sections135 .filter((section) => section && typeof section === 'object')136 .map((section, index) => ({137 ...section,138 id: normalizeEntityId(section.id, `section-${index + 1}`),139 courseId: normalizeEntityId(section.courseId, ''),140 number: String(section.number ?? '').trim(),141 professor: String(section.professor ?? '').trim(),142 semester: String(section.semester ?? '').trim(),143 seats: Number.isFinite(Number(section.seats)) ? Number(section.seats) : 0144 }))145 : [],146 textbooks: Array.isArray(input.textbooks)147 ? input.textbooks148 .filter((textbook) => textbook && typeof textbook === 'object')149 .map((textbook, index) => ({150 ...textbook,151 id: normalizeEntityId(textbook.id, `tb-${index + 1}`),152 courseId: normalizeEntityId(textbook.courseId, ''),153 title: String(textbook.title ?? '').trim(),154 author: String(textbook.author ?? '').trim(),155 edition: String(textbook.edition ?? '').trim(),156 isbn: String(textbook.isbn ?? '').trim(),157 required: Boolean(textbook.required),158 usedOften: Boolean(textbook.usedOften),159 olderEditionOk: Boolean(textbook.olderEditionOk),160 image: String(textbook.image ?? '').trim(),161 freeUrl: textbook.freeUrl ? String(textbook.freeUrl).trim() : ''162 }))163 : [],164 prices: Array.isArray(input.prices)165 ? input.prices166 .filter((price) => price && typeof price === 'object')167 .map((price, index) => ({168 ...price,169 id: normalizeEntityId(price.id, `price-${index + 1}`),170 textbookId: normalizeEntityId(price.textbookId, ''),171 seller: String(price.seller ?? '').trim(),172 newPrice: Number.isFinite(Number(price.newPrice)) ? Number(price.newPrice) : null,173 usedPrice: Number.isFinite(Number(price.usedPrice)) ? Number(price.usedPrice) : null,174 rentalPrice: Number.isFinite(Number(price.rentalPrice)) ? Number(price.rentalPrice) : null,175 digitalPrice: Number.isFinite(Number(price.digitalPrice)) ? Number(price.digitalPrice) : null,176 buyUrl: String(price.buyUrl ?? '#').trim() || '#',177 inStock: price.inStock !== false178 }))179 : [],180 reviews: Array.isArray(input.reviews)181 ? input.reviews182 .filter((review) => review && typeof review === 'object')183 .map((review, index) => ({184 ...review,185 id: normalizeEntityId(review.id, `review-${index + 1}`),186 textbookId: normalizeEntityId(review.textbookId, ''),187 user: String(review.user ?? '').trim(),188 rating: Number.isFinite(Number(review.rating)) ? Number(review.rating) : 0,189 actuallyRequired: Boolean(review.actuallyRequired),190 usedOften: String(review.usedOften ?? '').trim(),191 olderEditionOk: Boolean(review.olderEditionOk),192 semester: String(review.semester ?? '').trim(),193 comment: String(review.comment ?? '').trim(),194 helpful: Number.isFinite(Number(review.helpful)) ? Number(review.helpful) : 0,195 notHelpful: Number.isFinite(Number(review.notHelpful)) ? Number(review.notHelpful) : 0,196 date: String(review.date ?? '').trim()197 }))198 : [],199 discussions: Array.isArray(input.discussions)200 ? input.discussions201 .filter((discussion) => discussion && typeof discussion === 'object')202 .map((discussion, index) => ({203 ...discussion,204 id: normalizeEntityId(discussion.id, `discussion-${index + 1}`),205 courseId: normalizeEntityId(discussion.courseId, ''),206 user: String(discussion.user ?? '').trim(),207 avatar: String(discussion.avatar ?? '').trim(),208 title: String(discussion.title ?? '').trim(),209 content: String(discussion.content ?? '').trim(),210 replies: Number.isFinite(Number(discussion.replies)) ? Number(discussion.replies) : 0,211 views: Number.isFinite(Number(discussion.views)) ? Number(discussion.views) : 0,212 tags: Array.isArray(discussion.tags) ? discussion.tags.map((tag) => String(tag).trim()).filter(Boolean) : [],213 createdAt: String(discussion.createdAt ?? '').trim()214 }))215 : [],216 avatars: Array.isArray(input.avatars)217 ? input.avatars.map((avatar) => String(avatar ?? '').trim()).filter(Boolean)218 : []219 };220}221 222const appData = window.appData = normalizeData(rawData);223 224function getCourseById(courseId) {225 return appData.courses.find((course) => course.id === courseId) || null;226}227 228function getSchoolById(schoolId) {229 return appData.schools.find((school) => school.id === schoolId) || null;230}231 232function getSectionById(sectionId) {233 return appData.sections.find((section) => section.id === sectionId) || null;234}235 236function getTextbookById(textbookId) {237 return appData.textbooks.find((textbook) => textbook.id === textbookId) || null;238}239 240function getReviewStats(textbookId) {241 const reviews = appData.reviews.filter((review) => review.textbookId === textbookId);242 const count = reviews.length;243 const avgRating = count ? (reviews.reduce((sum, review) => sum + (Number(review.rating) || 0), 0) / count).toFixed(1) : '0.0';244 const requiredPercent = count ? Math.round((reviews.filter((review) => review.actuallyRequired).length / count) * 100) : 0;245 const usedOftenPercent = count ? Math.round((reviews.filter((review) => review.usedOften && review.usedOften !== 'rarely').length / count) * 100) : 0;246 const olderEditionPercent = count ? Math.round((reviews.filter((review) => review.olderEditionOk).length / count) * 100) : 0;247 return {248 count,249 avgRating,250 requiredPercent,251 usedOftenPercent,252 olderEditionPercent,253 reviews254 };255}256 257function getSafeLowestDisplayedPrice(textbookId) {258 const values = appData.prices259 .filter((price) => price.textbookId === textbookId)260 .flatMap((price) => [price.usedPrice, price.rentalPrice, price.newPrice, price.digitalPrice])261 .filter((value) => Number.isFinite(value));262 return values.length ? Math.min(...values) : null;263}264 265function ensureSelectedSchoolFromCourse(course) {266 if (!course) {267 return null;268 }269 const school = getSchoolById(course.schoolId);270 if (school) {271 store.setSchool(school, { preserveCourse: true, preserveSection: true });272 }273 return school;274}275 276const store = window.store = {277 currentUser: null,278 selectedSchool: null,279 selectedCourse: null,280 selectedSection: null,281 theme: safeLocalStorageGet('theme', 'light') || 'light',282 setSchool(school, options = {}) {283 const preserveCourse = Boolean(options.preserveCourse);284 const preserveSection = Boolean(options.preserveSection);285 this.selectedSchool = school || null;286 if (!preserveCourse) {287 this.selectedCourse = null;288 }289 if (!preserveSection) {290 this.selectedSection = null;291 }292 if (school) {293 safeLocalStorageSet('selectedSchool', JSON.stringify(school));294 } else {295 safeLocalStorageRemove('selectedSchool');296 }297 },298 setCourse(course) {299 this.selectedCourse = course || null;300 this.selectedSection = null;301 if (course) {302 ensureSelectedSchoolFromCourse(course);303 }304 },305 setSection(section) {306 this.selectedSection = section || null;307 },308 hydrateSelectedSchool() {309 const raw = safeLocalStorageGet('selectedSchool');310 if (!raw) {311 this.selectedSchool = null;312 return null;313 }314 try {315 const parsed = JSON.parse(raw);316 const matchedSchool = getSchoolById(parsed.id);317 this.selectedSchool = matchedSchool;318 return matchedSchool;319 } catch {320 this.selectedSchool = null;321 return null;322 }323 },324 applyTheme() {325 document.documentElement.classList.toggle('dark', this.theme === 'dark');326 },327 toggleTheme() {328 this.theme = this.theme === 'light' ? 'dark' : 'light';329 safeLocalStorageSet('theme', this.theme);330 this.applyTheme();331 }332};333 334store.applyTheme();335store.hydrateSelectedSchool();336 337const api = window.api = {338 async getSchools() {339 await delay(100);340 return appData.schools;341 },342 async getCourses(schoolId) {343 await delay(100);344 return appData.courses.filter((course) => course.schoolId === schoolId);345 },346 async getSections(courseId) {347 await delay(100);348 return appData.sections.filter((section) => section.courseId === courseId);349 },350 async getTextbooks(courseId) {351 await delay(100);352 return appData.textbooks.filter((textbook) => textbook.courseId === courseId);353 },354 async getPrices(textbookId) {355 await delay(100);356 return appData.prices.filter((price) => price.textbookId === textbookId);357 },358 async getReviews(textbookId) {359 await delay(100);360 return appData.reviews.filter((review) => review.textbookId === textbookId);361 },362 async getDiscussions(courseId) {363 await delay(100);364 return appData.discussions.filter((discussion) => discussion.courseId === courseId);365 },366 async submitReview(review) {367 await delay(100);368 const record = {369 ...review,370 id: `review-${Date.now()}`,371 date: new Date().toISOString()372 };373 appData.reviews.unshift(record);374 return { success: true, review: record };375 },376 async postDiscussion(post) {377 await delay(100);378 const record = {379 ...post,380 id: `discussion-${Date.now()}`,381 createdAt: new Date().toISOString()382 };383 appData.discussions.unshift(record);384 return { success: true, discussion: record };385 }386};387 388function selectSchoolAndGo(schoolId) {389 const school = getSchoolById(schoolId);390 if (!school) {391 return;392 }393 store.setSchool(school);394 router.navigate('/courses');395}396 397window.selectSchoolAndGo = selectSchoolAndGo;398 399function showNewPostModal() {400 const modal = document.getElementById('newPostModal');401 if (modal && typeof modal.open === 'function') {402 modal.open();403 }404}405 406function showReviewModal() {407 const modal = document.getElementById('reviewModal');408 if (modal && typeof modal.open === 'function') {409 modal.open();410 }411}412 413window.showNewPostModal = showNewPostModal;414window.showReviewModal = showReviewModal;415 416const router = window.router = {417 routes: {418 '/': 'home',419 '/schools': 'schools',420 '/courses': 'courses',421 '/community': 'community'422 },423 currentRoute: window.location.pathname || '/',424 navigate(path) {425 const nextPath = String(path || '/').trim() || '/';426 this.currentRoute = nextPath;427 if (window.location.pathname !== nextPath) {428 window.history.pushState({}, '', nextPath);429 }430 return this.render();431 },432 getRouteComponent(path) {433 if (String(path || '').startsWith('/course/')) return 'courseDetail';434 if (String(path || '').startsWith('/textbook/')) return 'textbookDetail';435 return this.routes[path] || 'home';436 },437 extractParam(path, pattern) {438 if (pattern === '/course/:id') {439 return parseRouteParam(path, '/course/');440 }441 if (pattern === '/textbook/:id') {442 return parseRouteParam(path, '/textbook/');443 }444 return null;445 },446 async render() {447 const app = document.getElementById('app');448 if (!app) {449 return;450 }451 452 const component = this.getRouteComponent(this.currentRoute);453 app.innerHTML = '<div class="min-h-screen flex items-center justify-center"><div class="skeleton w-16 h-16 rounded-full"></div></div>';454 await delay(50);455 456 if (component === 'home') {457 app.innerHTML = renderHome();458 setupHomeListeners();459 } else if (component === 'schools') {460 app.innerHTML = await renderSchools();461 setupSchoolEventListeners();462 } else if (component === 'courses') {463 app.innerHTML = await renderCourses();464 setupCourseEventListeners();465 } else if (component === 'courseDetail') {466 const courseId = this.extractParam(this.currentRoute, '/course/:id');467 app.innerHTML = await renderCourseDetail(courseId);468 setupCourseDetailListeners();469 } else if (component === 'textbookDetail') {470 const textbookId = this.extractParam(this.currentRoute, '/textbook/:id');471 app.innerHTML = await renderTextbookDetail(textbookId);472 setupTextbookDetailListeners();473 } else {474 this.currentRoute = '/';475 app.innerHTML = renderHome();476 setupHomeListeners();477 }478 479 safeFeatherReplace();480 }481};482 483window.addEventListener('popstate', () => {484 router.currentRoute = window.location.pathname || '/';485 router.render();486});487 488function renderHome() {489 const featuredSchools = appData.schools.slice(0, 4);490 return `491 <div class="mesh-gradient min-h-screen">492 <section class="relative overflow-hidden">493 <div class="absolute inset-0">494 <div class="absolute top-20 left-10 w-72 h-72 bg-primary-500/20 rounded-full blur-3xl animate-pulse-slow"></div>495 <div class="absolute bottom-20 right-10 w-96 h-96 bg-secondary-500/20 rounded-full blur-3xl animate-pulse-slow" style="animation-delay: 1.5s;"></div>496 </div>497 <div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-20 pb-16">498 <div class="text-center">499 <div class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 text-sm font-medium mb-8 animate-float">500 <i data-feather="zap" class="w-4 h-4"></i>501 <span>Save up to 80% on textbooks</span>502 </div>503 <h1 class="text-5xl sm:text-6xl lg:text-7xl font-bold mb-6 leading-tight">504 Find the <span class="gradient-text">cheapest</span> textbooks<br>505 <span class="text-slate-600 dark:text-slate-400">for your courses</span>506 </h1>507 <p class="text-xl text-slate-600 dark:text-slate-400 max-w-2xl mx-auto mb-10">508 Stop overpaying. Compare prices from 20+ sellers, read real student reviews, and connect with classmates to split costs.509 </p>510 <div class="max-w-2xl mx-auto">511 <div class="glass rounded-2xl p-2 flex flex-col sm:flex-row gap-2">512 <div class="flex-1 flex items-center px-4 py-3 bg-white dark:bg-slate-800 rounded-xl">513 <i data-feather="search" class="w-5 h-5 text-slate-400 mr-3"></i>514 <input type="text" placeholder="Search by school, course, or professor..." class="w-full bg-transparent border-none outline-none text-slate-800 dark:text-slate-100 placeholder-slate-400" id="hero-search">515 </div>516 <button type="button" id="hero-find-books" class="px-8 py-4 bg-gradient-to-r from-primary-500 to-primary-600 hover:from-primary-600 hover:to-primary-700 text-white font-semibold rounded-xl transition-all glow-primary flex items-center justify-center gap-2">517 <span>Find Books</span>518 <i data-feather="arrow-right" class="w-5 h-5"></i>519 </button>520 </div>521 </div>522 <div class="flex flex-wrap justify-center gap-8 sm:gap-16 mt-16">523 <div class="text-center">524 <div class="text-3xl sm:text-4xl font-bold gradient-text">$2.4M</div>525 <div class="text-sm text-slate-600 dark:text-slate-400 mt-1">Saved by students</div>526 </div>527 <div class="text-center">528 <div class="text-3xl sm:text-4xl font-bold gradient-text">50K+</div>529 <div class="text-sm text-slate-600 dark:text-slate-400 mt-1">Course listings</div>530 </div>531 <div class="text-center">532 <div class="text-3xl sm:text-4xl font-bold gradient-text">20+</div>533 <div class="text-sm text-slate-600 dark:text-slate-400 mt-1">Price sources</div>534 </div>535 </div>536 </div>537 </div>538 </section>539 <section class="py-20 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">540 <h2 class="text-3xl font-bold text-center mb-16">How Textbook Sherpa Works</h2>541 <div class="grid md:grid-cols-3 gap-8">542 <div class="relative p-8 rounded-2xl bg-white dark:bg-slate-800 shadow-lg course-hover">543 <div class="w-12 h-12 rounded-xl bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center text-primary-600 dark:text-primary-400 font-bold text-xl mb-6">1</div>544 <h3 class="text-xl font-semibold mb-3">Find Your Course</h3>545 <p class="text-slate-600 dark:text-slate-400">Search by school, course code, section, or professor. No more hunting for ISBNs.</p>546 </div>547 <div class="relative p-8 rounded-2xl bg-white dark:bg-slate-800 shadow-lg course-hover">548 <div class="w-12 h-12 rounded-xl bg-secondary-100 dark:bg-secondary-900/30 flex items-center justify-center text-secondary-600 dark:text-secondary-400 font-bold text-xl mb-6">2</div>549 <h3 class="text-xl font-semibold mb-3">Compare Prices</h3>550 <p class="text-slate-600 dark:text-slate-400">See prices from campus bookstore, Amazon, Chegg, AbeBooks, and more in one place.</p>551 </div>552 <div class="relative p-8 rounded-2xl bg-white dark:bg-slate-800 shadow-lg course-hover">553 <div class="w-12 h-12 rounded-xl bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center text-primary-600 dark:text-primary-400 font-bold text-xl mb-6">3</div>554 <h3 class="text-xl font-semibold mb-3">Save & Connect</h3>555 <p class="text-slate-600 dark:text-slate-400">Buy the cheapest option, or team up with classmates to split costs and share books.</p>556 </div>557 </div>558 </section>559 <section class="py-20 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">560 <div class="flex items-center justify-between mb-12">561 <h2 class="text-3xl font-bold">Popular Schools</h2>562 <a href="/schools" data-router-link="/schools" class="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-medium flex items-center gap-1">563 View all <i data-feather="arrow-right" class="w-4 h-4"></i>564 </a>565 </div>566 <div class="bento-grid">567 ${featuredSchools.map((school) => `568 <div class="group relative overflow-hidden rounded-2xl bg-white dark:bg-slate-800 shadow-lg course-hover cursor-pointer" data-school-id="${escapeHtml(school.id)}">569 <div class="aspect-video relative">570 <img src="${escapeHtml(school.image)}" alt="${escapeHtml(school.name)}" class="w-full h-full object-cover">571 <div class="absolute inset-0 bg-gradient-to-t from-slate-900/80 to-transparent"></div>572 <div class="absolute bottom-0 left-0 right-0 p-6">573 <div class="flex items-center gap-2 mb-2">574 <span class="px-2 py-1 rounded bg-primary-500/20 text-primary-300 text-xs font-medium">${escapeHtml(school.abbr)}</span>575 </div>576 <h3 class="text-xl font-bold text-white">${escapeHtml(school.name)}</h3>577 <p class="text-slate-300 text-sm mt-1">${escapeHtml(school.courses)} courses listed</p>578 </div>579 </div>580 </div>581 `).join('')}582 </div>583 </section>584 </div>585 `;586}587 588async function renderSchools() {589 const schools = await api.getSchools();590 return `591 <div class="min-h-screen bg-slate-50 dark:bg-slate-900 py-12 px-4 sm:px-6 lg:px-8">592 <div class="max-w-7xl mx-auto">593 <div class="mb-12">594 <h1 class="text-4xl font-bold mb-4">Select Your School</h1>595 <p class="text-slate-600 dark:text-slate-400">Choose your university to browse course listings and textbook prices.</p>596 </div>597 <div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">598 ${schools.map((school) => `599 <div class="school-card group relative overflow-hidden rounded-2xl bg-white dark:bg-slate-800 shadow-lg cursor-pointer transition-all hover:shadow-xl hover:-translate-y-1" data-school-id="${escapeHtml(school.id)}">600 <div class="h-48 relative overflow-hidden">601 <img src="${escapeHtml(school.image)}" alt="${escapeHtml(school.name)}" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110">602 <div class="absolute inset-0 bg-gradient-to-t from-slate-900/90 via-slate-900/40 to-transparent"></div>603 <div class="absolute top-4 left-4">604 <span class="px-3 py-1 rounded-full bg-white/20 backdrop-blur text-white text-sm font-semibold">${escapeHtml(school.abbr)}</span>605 </div>606 </div>607 <div class="p-6">608 <h3 class="text-xl font-bold mb-2 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">${escapeHtml(school.name)}</h3>609 <p class="text-slate-600 dark:text-slate-400 text-sm mb-4 flex items-center gap-1">610 <i data-feather="map-pin" class="w-4 h-4"></i>611 ${escapeHtml(school.location)}612 </p>613 <div class="flex items-center justify-between">614 <span class="text-sm text-slate-500">${escapeHtml(school.courses)} courses</span>615 <span class="text-primary-600 dark:text-primary-400 group-hover:translate-x-1 transition-transform">616 <i data-feather="arrow-right" class="w-5 h-5"></i>617 </span>618 </div>619 </div>620 </div>621 `).join('')}622 </div>623 </div>624 </div>625 `;626}627 628async function renderCourses() {629 const selectedSchool = store.selectedSchool || store.hydrateSelectedSchool();630 if (!selectedSchool) {631 await router.navigate('/schools');632 return '';633 }634 635 const school = getSchoolById(selectedSchool.id);636 if (!school) {637 await router.navigate('/schools');638 return '';639 }640 641 store.setSchool(school, { preserveCourse: true, preserveSection: true });642 const courses = await api.getCourses(school.id);643 644 return `645 <div class="min-h-screen bg-slate-50 dark:bg-slate-900 py-12 px-4 sm:px-6 lg:px-8">646 <div class="max-w-7xl mx-auto">647 <nav class="flex items-center gap-2 text-sm text-slate-500 mb-8">648 <a href="/" data-router-link="/" class="hover:text-primary-600 dark:hover:text-primary-400">Home</a>649 <i data-feather="chevron-right" class="w-4 h-4"></i>650 <a href="/schools" data-router-link="/schools" class="hover:text-primary-600 dark:hover:text-primary-400">Schools</a>651 <i data-feather="chevron-right" class="w-4 h-4"></i>652 <span class="text-slate-800 dark:text-slate-200 font-medium">${escapeHtml(school.name)}</span>653 </nav>654 <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">655 <div>656 <h1 class="text-3xl font-bold mb-2">${escapeHtml(school.name)}</h1>657 <p class="text-slate-600 dark:text-slate-400">${courses.length} courses available</p>658 </div>659 </div>660 ${courses.length === 0 ? `661 <div class="rounded-2xl bg-white dark:bg-slate-800 shadow-lg p-10 text-center text-slate-500 dark:text-slate-400">662 <div class="text-lg font-medium mb-2">No course data yet for this school</div>663 <p>School selection works, but course records have not been loaded into this file yet.</p>664 </div>665 ` : `666 <div class="grid lg:grid-cols-2 gap-6">667 ${courses.map((course) => `668 <div class="course-card group p-6 rounded-2xl bg-white dark:bg-slate-800 shadow-lg cursor-pointer transition-all hover:shadow-xl hover:-translate-y-1 border-l-4 border-primary-500" data-course-id="${escapeHtml(course.id)}">669 <div class="flex items-start justify-between mb-4">670 <div>671 <div class="flex items-center gap-2 mb-2">672 <span class="px-2 py-1 rounded-lg bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 text-sm font-semibold">${escapeHtml(course.code)}</span>673 <span class="px-2 py-1 rounded-lg bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400 text-sm">${escapeHtml(course.credits)} credits</span>674 </div>675 <h3 class="text-xl font-bold group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">${escapeHtml(course.name)}</h3>676 </div>677 <i data-feather="arrow-right" class="w-5 h-5 text-slate-400 group-hover:text-primary-500 transition-colors"></i>678 </div>679 <p class="text-slate-600 dark:text-slate-400 text-sm mb-4">${escapeHtml(course.department)}</p>680 <div class="flex items-center gap-6 text-sm text-slate-500">681 <span class="flex items-center gap-1">682 <i data-feather="book" class="w-4 h-4"></i>683 ${appData.textbooks.filter((textbook) => textbook.courseId === course.id).length} textbooks684 </span>685 <span class="flex items-center gap-1">686 <i data-feather="users" class="w-4 h-4"></i>687 ${appData.sections.filter((section) => section.courseId === course.id).reduce((sum, section) => sum + (Number(section.seats) || 0), 0)} students688 </span>689 </div>690 </div>691 `).join('')}692 </div>693 `}694 </div>695 </div>696 `;697}698 699async function renderCourseDetail(courseId) {700 const course = getCourseById(courseId) || store.selectedCourse;701 if (!course) {702 await router.navigate('/courses');703 return '';704 }705 706 store.setCourse(course);707 const school = getSchoolById(course.schoolId);708 const sections = await api.getSections(course.id);709 const textbooks = await api.getTextbooks(course.id);710 const discussions = await api.getDiscussions(course.id);711 712 return `713 <div class="min-h-screen bg-slate-50 dark:bg-slate-900 pb-12">714 <div class="bg-gradient-to-r from-primary-600 to-secondary-600 text-white">715 <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">716 <nav class="flex items-center gap-2 text-sm text-white/80 mb-6">717 <a href="/" data-router-link="/" class="hover:text-white">Home</a>718 <i data-feather="chevron-right" class="w-4 h-4"></i>719 <a href="/schools" data-router-link="/schools" class="hover:text-white">Schools</a>720 <i data-feather="chevron-right" class="w-4 h-4"></i>721 <span>${escapeHtml(school ? school.abbr : '')}</span>722 <i data-feather="chevron-right" class="w-4 h-4"></i>723 <span class="font-medium">${escapeHtml(course.code)}</span>724 </nav>725 <div class="flex flex-col lg:flex-row lg:items-end justify-between gap-6">726 <div>727 <div class="flex items-center gap-3 mb-4">728 <span class="px-3 py-1 rounded-full bg-white/20 text-sm font-semibold">${escapeHtml(course.code)}</span>729 <span class="px-3 py-1 rounded-full bg-white/20 text-sm">${escapeHtml(course.credits)} credits</span>730 </div>731 <h1 class="text-4xl font-bold mb-3">${escapeHtml(course.name)}</h1>732 <p class="text-white/80 text-lg">${escapeHtml(course.department)} • ${escapeHtml(school ? school.name : '')}</p>733 </div>734 </div>735 </div>736 </div>737 <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 -mt-8">738 <div class="grid lg:grid-cols-3 gap-8">739 <div class="lg:col-span-2 space-y-8">740 <div class="glass rounded-2xl p-6">741 <h2 class="text-xl font-bold mb-4 flex items-center gap-2">742 <i data-feather="grid" class="w-5 h-5 text-primary-500"></i>743 Select Your Section744 </h2>745 ${sections.length === 0 ? `746 <div class="text-slate-500 dark:text-slate-400">No section data yet for this course.</div>747 ` : `748 <div class="grid sm:grid-cols-2 gap-4">749 ${sections.map((section) => `750 <div class="section-card p-4 rounded-xl bg-white dark:bg-slate-800 border-2 border-transparent hover:border-primary-500 cursor-pointer transition-all" data-section-id="${escapeHtml(section.id)}">751 <div class="flex items-center justify-between mb-2">752 <span class="font-semibold">Section ${escapeHtml(section.number)}</span>753 <span class="text-sm text-slate-500">${escapeHtml(section.semester)}</span>754 </div>755 <p class="text-slate-600 dark:text-slate-400 text-sm mb-2">756 <i data-feather="user" class="w-4 h-4 inline mr-1"></i>757 Prof. ${escapeHtml(section.professor)}758 </p>759 <div class="flex items-center justify-between">760 <span class="text-sm text-slate-500">${escapeHtml(section.seats)} seats</span>761 <span class="text-primary-600 dark:text-primary-400 text-sm font-medium">View books →</span>762 </div>763 </div>764 `).join('')}765 </div>766 `}767 </div>768 <div>769 <h2 class="text-xl font-bold mb-4 flex items-center gap-2">770 <i data-feather="book" class="w-5 h-5 text-primary-500"></i>771 Required & Optional Textbooks772 </h2>773 ${textbooks.length === 0 ? `774 <div class="text-center py-12 text-slate-500">775 <i data-feather="book-open" class="w-16 h-16 mx-auto mb-4 opacity-50"></i>776 <p>No textbooks listed yet for this course.</p>777 </div>778 ` : textbooks.map((book) => {779 const lowestPrice = getSafeLowestDisplayedPrice(book.id);780 return `781 <div class="textbook-card group mb-6 p-6 rounded-2xl bg-white dark:bg-slate-800 shadow-lg hover:shadow-xl transition-all cursor-pointer border-l-4 ${book.required ? 'border-red-500' : 'border-slate-300 dark:border-slate-600'}" data-textbook-id="${escapeHtml(book.id)}">782 <div class="flex flex-col sm:flex-row gap-6">783 <div class="flex-shrink-0">784 <div class="w-32 h-40 rounded-lg shadow-lg overflow-hidden relative">785 <img src="${escapeHtml(book.image || '')}" alt="${escapeHtml(book.title)}" class="w-full h-full object-cover">786 ${book.required ? '<div class="absolute top-0 left-0 bg-red-500 text-white text-xs font-bold px-2 py-1 rounded-br-lg">REQUIRED</div>' : ''}787 ${book.freeUrl ? '<div class="absolute bottom-0 left-0 right-0 bg-green-500 text-white text-xs font-bold px-2 py-1 text-center">FREE</div>' : ''}788 </div>789 </div>790 <div class="flex-1">791 <h3 class="text-xl font-bold mb-2 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">${escapeHtml(book.title)}</h3>792 <p class="text-slate-600 dark:text-slate-400 mb-3">${escapeHtml(book.author)} • ${escapeHtml(book.edition)} Edition</p>793 <p class="text-sm text-slate-500 mb-4">ISBN: ${escapeHtml(book.isbn)}</p>794 <div class="flex flex-wrap gap-2 mb-4">795 ${book.required ? '<span class="px-2 py-1 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-xs font-medium">Required</span>' : ''}796 ${book.usedOften ? '<span class="px-2 py-1 rounded-lg bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 text-xs font-medium">Heavily used</span>' : '<span class="px-2 py-1 rounded-lg bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 text-xs font-medium">Rarely used</span>'}797 ${book.olderEditionOk ? '<span class="px-2 py-1 rounded-lg bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs font-medium">Older edition OK</span>' : ''}798 ${book.freeUrl ? '<span class="px-2 py-1 rounded-lg bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 text-xs font-medium">Free online</span>' : ''}799 </div>800 <div class="flex items-center justify-between p-3 rounded-lg bg-slate-50 dark:bg-slate-700/50">801 <div>802 ${lowestPrice !== null ? `<span class="text-2xl font-bold text-primary-600 dark:text-primary-400">$${lowestPrice}</span><span class="text-slate-500 text-sm"> cheapest</span>` : '<span class="text-slate-500 text-sm">No prices yet</span>'}803 </div>804 <span class="text-primary-600 dark:text-primary-400 font-medium">Compare all prices →</span>805 </div>806 </div>807 </div>808 </div>809 `;810 }).join('')}811 </div>812 <div>813 <div class="flex items-center justify-between mb-4">814 <h2 class="text-xl font-bold flex items-center gap-2">815 <i data-feather="message-circle" class="w-5 h-5 text-secondary-500"></i>816 Class Discussion817 </h2>818 </div>819 ${discussions.length === 0 ? `820 <div class="rounded-2xl bg-white dark:bg-slate-800 shadow-lg p-6 text-slate-500 dark:text-slate-400">No discussion posts yet for this course.</div>821 ` : `822 <div class="space-y-4">823 ${discussions.map((post, index) => `824 <div class="discussion-card p-5 rounded-2xl bg-white dark:bg-slate-800 shadow-lg hover:shadow-xl transition-all">825 <div class="flex items-start gap-4">826 <img src="${escapeHtml(appData.avatars.length ? appData.avatars[index % appData.avatars.length] : '')}" alt="${escapeHtml(post.user)}" class="w-10 h-10 rounded-full flex-shrink-0">827 <div class="flex-1 min-w-0">828 <div class="flex items-center gap-2 mb-1">829 <span class="font-semibold">${escapeHtml(post.user)}</span>830 <span class="text-slate-400 text-sm">• ${formatDate(post.createdAt)}</span>831 </div>832 <h4 class="font-medium mb-2">${escapeHtml(post.title)}</h4>833 <p class="text-slate-600 dark:text-slate-400 text-sm mb-3">${escapeHtml(post.content)}</p>834 </div>835 </div>836 </div>837 `).join('')}838 </div>839 `}840 </div>841 </div>842 <div class="space-y-6">843 <div class="glass rounded-2xl p-6">844 <h3 class="font-bold mb-4">Teaching Team</h3>845 ${sections.length === 0 ? `846 <div class="text-slate-500 dark:text-slate-400">No instructors listed yet.</div>847 ` : `848 <div class="space-y-3">849 ${[...new Set(sections.map((section) => section.professor))].map((professor) => `850 <div class="flex items-center gap-3 p-3 rounded-lg bg-white dark:bg-slate-800">851 <div class="w-10 h-10 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center">852 <i data-feather="user" class="w-5 h-5 text-slate-500"></i>853 </div>854 <div>855 <p class="font-medium text-sm">Prof. ${escapeHtml(professor)}</p>856 <p class="text-xs text-slate-500">${sections.filter((section) => section.professor === professor).length} section${sections.filter((section) => section.professor === professor).length > 1 ? 's' : ''}</p>857 </div>858 </div>859 `).join('')}860 </div>861 `}862 </div>863 </div>864 </div>865 </div>866 </div>867 `;868}869 870async function renderTextbookDetail(textbookId) {871 const book = getTextbookById(textbookId);872 if (!book) {873 await router.navigate('/courses');874 return '';875 }876 877 const course = getCourseById(book.courseId);878 if (course) {879 store.setCourse(course);880 }881 const school = course ? getSchoolById(course.schoolId) : store.selectedSchool;882 const prices = await api.getPrices(book.id);883 const stats = getReviewStats(book.id);884 885 const byNumericKey = (key) => prices.filter((price) => Number.isFinite(price[key])).sort((a, b) => a[key] - b[key])[0] || null;886 const bestNew = byNumericKey('newPrice');887 const bestUsed = byNumericKey('usedPrice');888 const bestRental = byNumericKey('rentalPrice');889 const bestDigital = byNumericKey('digitalPrice');890 891 return `892 <div class="min-h-screen bg-slate-50 dark:bg-slate-900 pb-12">893 <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">894 <nav class="flex items-center gap-2 text-sm text-slate-500 mb-6">895 <a href="/" data-router-link="/" class="hover:text-primary-600 dark:hover:text-primary-400">Home</a>896 <i data-feather="chevron-right" class="w-4 h-4"></i>897 <a href="/schools" data-router-link="/schools" class="hover:text-primary-600 dark:hover:text-primary-400">Schools</a>898 <i data-feather="chevron-right" class="w-4 h-4"></i>899 <span>${escapeHtml(school ? school.abbr : '')}</span>900 <i data-feather="chevron-right" class="w-4 h-4"></i>901 <span class="text-slate-800 dark:text-slate-200 font-medium">${escapeHtml(book.title)}</span>902 </nav>903 <div class="grid lg:grid-cols-3 gap-8">904 <div class="lg:col-span-1">905 <div class="sticky top-8">906 <div class="relative rounded-2xl overflow-hidden shadow-2xl mb-6">907 <img src="${escapeHtml(book.image || '')}" alt="${escapeHtml(book.title)}" class="w-full aspect-[3/4] object-cover">908 ${book.required ? '<div class="absolute top-4 left-4 px-3 py-1.5 bg-red-500 text-white text-sm font-bold rounded-lg shadow-lg">REQUIRED for course</div>' : ''}909 </div>910 </div>911 </div>912 <div class="lg:col-span-2 space-y-8">913 <div>914 <h1 class="text-3xl font-bold mb-3">${escapeHtml(book.title)}</h1>915 <div class="flex flex-wrap items-center gap-4 text-slate-600 dark:text-slate-400">916 <span>${escapeHtml(book.author)}</span>917 <span>${escapeHtml(book.edition)} Edition</span>918 <span>${escapeHtml(book.isbn)}</span>919 </div>920 </div>921 <div class="flex flex-wrap items-center gap-6 p-6 rounded-2xl bg-white dark:bg-slate-800 shadow-lg">922 <div class="text-center">923 <div class="text-5xl font-bold gradient-text">${stats.avgRating}</div>924 <div class="text-sm text-slate-500 mt-1">${stats.count} reviews</div>925 </div>926 <div class="flex-1 space-y-2">927 <div class="flex items-center gap-2">928 <span class="text-sm w-24">Actually required</span>929 <div class="flex-1 h-2 rounded-full bg-slate-200 dark:bg-slate-700 overflow-hidden"><div class="h-full rounded-full bg-green-500" style="width: ${stats.requiredPercent}%"></div></div>930 <span class="text-sm w-12 text-right">${stats.requiredPercent}%</span>931 </div>932 <div class="flex items-center gap-2">933 <span class="text-sm w-24">Used often</span>934 <div class="flex-1 h-2 rounded-full bg-slate-200 dark:bg-slate-700 overflow-hidden"><div class="h-full rounded-full bg-amber-500" style="width: ${stats.usedOftenPercent}%"></div></div>935 <span class="text-sm w-12 text-right">${stats.usedOftenPercent}%</span>936 </div>937 <div class="flex items-center gap-2">938 <span class="text-sm w-24">Older edition OK</span>939 <div class="flex-1 h-2 rounded-full bg-slate-200 dark:bg-slate-700 overflow-hidden"><div class="h-full rounded-full bg-blue-500" style="width: ${stats.olderEditionPercent}%"></div></div>940 <span class="text-sm w-12 text-right">${stats.olderEditionPercent}%</span>941 </div>942 </div>943 </div>944 <div>945 <h2 class="text-xl font-bold mb-4">Price Comparison</h2>946 ${(bestRental || bestUsed || bestNew || bestDigital) ? `947 <div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">948 ${bestRental ? `<div class="p-4 rounded-xl bg-green-50 dark:bg-green-900/20 border-2 border-green-500"><div class="text-sm font-medium mb-2">Best Rental</div><div class="text-2xl font-bold">$${bestRental.rentalPrice}</div><div class="text-sm">${escapeHtml(bestRental.seller)}</div></div>` : ''}949 ${bestUsed ? `<div class="p-4 rounded-xl bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-500"><div class="text-sm font-medium mb-2">Best Used</div><div class="text-2xl font-bold">$${bestUsed.usedPrice}</div><div class="text-sm">${escapeHtml(bestUsed.seller)}</div></div>` : ''}950 ${bestNew ? `<div class="p-4 rounded-xl bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-500"><div class="text-sm font-medium mb-2">Best New</div><div class="text-2xl font-bold">$${bestNew.newPrice}</div><div class="text-sm">${escapeHtml(bestNew.seller)}</div></div>` : ''}951 ${bestDigital ? `<div class="p-4 rounded-xl bg-purple-50 dark:bg-purple-900/20 border-2 border-purple-500"><div class="text-sm font-medium mb-2">Best Digital</div><div class="text-2xl font-bold">$${bestDigital.digitalPrice}</div><div class="text-sm">${escapeHtml(bestDigital.seller)}</div></div>` : ''}952 </div>953 ` : `954 <div class="rounded-2xl bg-white dark:bg-slate-800 shadow-lg p-6 text-slate-500 dark:text-slate-400">No price data yet for this textbook.</div>955 `}956 </div>957 </div>958 </div>959 </div>960 </div>961 `;962}963 964function bindRouterLinks(root = document) {965 root.querySelectorAll('[data-router-link]').forEach((link) => {966 link.addEventListener('click', (event) => {967 event.preventDefault();968 const nextPath = link.getAttribute('data-router-link');969 if (nextPath) {970 router.navigate(nextPath);971 }972 });973 });974}975 976function setupHomeListeners() {977 bindRouterLinks();978 const button = document.getElementById('hero-find-books');979 if (button) {980 button.addEventListener('click', () => router.navigate('/schools'));981 }982 document.querySelectorAll('[data-school-id]').forEach((card) => {983 card.addEventListener('click', () => selectSchoolAndGo(card.getAttribute('data-school-id')));984 });985}986 987function setupSchoolEventListeners() {988 document.querySelectorAll('.school-card').forEach((card) => {989 card.addEventListener('click', () => selectSchoolAndGo(card.dataset.schoolId));990 });991}992 993function setupCourseEventListeners() {994 bindRouterLinks();995 document.querySelectorAll('.course-card').forEach((card) => {996 card.addEventListener('click', () => {997 const course = getCourseById(card.dataset.courseId);998 if (!course) {999 return;1000 }1001 store.setCourse(course);1002 router.navigate(`/course/${slugifyPathValue(course.id)}`);1003 });1004 });1005}1006 1007function setupCourseDetailListeners() {1008 bindRouterLinks();1009 document.querySelectorAll('.section-card').forEach((card) => {1010 card.addEventListener('click', () => {1011 const section = getSectionById(card.dataset.sectionId);1012 store.setSection(section);1013 document.querySelectorAll('.section-card').forEach((item) => item.classList.remove('border-primary-500'));1014 card.classList.add('border-primary-500');1015 });1016 });1017 document.querySelectorAll('.textbook-card').forEach((card) => {1018 card.addEventListener('click', () => {1019 const textbookId = card.dataset.textbookId;1020 if (textbookId) {1021 router.navigate(`/textbook/${slugifyPathValue(textbookId)}`);1022 }1023 });1024 });1025}1026 1027function setupTextbookDetailListeners() {1028 bindRouterLinks();1029}1030 1031document.addEventListener('DOMContentLoaded', () => {1032 router.render();1033});1034 