CoolFace
Apppublic

Promotoz/textbook-sherpa

sourceHugging Faceupdated 6mo agoView on Hugging Face
0likes
script.js1034 linesDownload Raw Back to root
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, '&amp;')65    .replace(/</g, '&lt;')66    .replace(/>/g, '&gt;')67    .replace(/"/g, '&quot;')68    .replace(/'/g, '&#39;');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