Last active
August 14, 2026 04:51
-
-
Save rajtilakjee/e7b6985b4dee5bdbb3a277f3e55a697a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import * as React from "react" | |
| import { addPropertyControls, ControlType } from "framer" | |
| const COURSES_URL = | |
| "https://syncsphere-hiv6.onrender.com/assignment/course-data" | |
| const COUNTRY_URL = | |
| "https://syncsphere-hiv6.onrender.com/assignment/country-code" | |
| type Course = { | |
| courseName: string | |
| courseCode: string | |
| description: string | |
| mainCategory: string | |
| shortCourse: string | |
| courseType: string | |
| pricePaise: number | |
| priceUsdCents: number | |
| mangoId: string | |
| refundable: boolean | |
| } | |
| type SupportedCountry = "IN" | "US" | |
| type CountryCode = SupportedCountry | null | |
| type SortOrder = "default" | "low-high" | "high-low" | |
| type Props = { | |
| sectionTitle: string | |
| accentColor: string | |
| style?: React.CSSProperties | |
| } | |
| async function fetchJSON<T>(url: string): Promise<T> { | |
| const response = await fetch(url, { | |
| method: "GET", | |
| cache: "no-store", | |
| }) | |
| if (!response.ok) { | |
| throw new Error(`Request failed with status ${response.status}`) | |
| } | |
| return response.json() | |
| } | |
| async function fetchCountryCode(): Promise<SupportedCountry> { | |
| const data = await fetchJSON<{ country_code: string }>(COUNTRY_URL) | |
| if (data.country_code !== "IN" && data.country_code !== "US") { | |
| throw new Error(`Unsupported country code: ${data.country_code}`) | |
| } | |
| return data.country_code | |
| } | |
| function formatPrice(course: Course, country: CountryCode) { | |
| if (country === "IN") { | |
| return new Intl.NumberFormat("en-IN", { | |
| style: "currency", | |
| currency: "INR", | |
| minimumFractionDigits: 0, | |
| maximumFractionDigits: 2, | |
| }).format(course.pricePaise / 100) | |
| } | |
| if (country === "US") { | |
| return new Intl.NumberFormat("en-US", { | |
| style: "currency", | |
| currency: "USD", | |
| }).format(course.priceUsdCents / 100) | |
| } | |
| return null | |
| } | |
| function SkeletonCards() { | |
| return ( | |
| <div | |
| className="skillpath-grid" | |
| aria-label="Loading courses" | |
| aria-live="polite" | |
| > | |
| {Array.from({ length: 6 }).map((_, index) => ( | |
| <div | |
| className="skillpath-card skillpath-skeleton-card" | |
| key={index} | |
| > | |
| <div className="skillpath-skeleton skillpath-skeleton-small" /> | |
| <div className="skillpath-skeleton skillpath-skeleton-title" /> | |
| <div className="skillpath-skeleton skillpath-skeleton-line" /> | |
| <div className="skillpath-skeleton skillpath-skeleton-line short" /> | |
| <div className="skillpath-skeleton skillpath-skeleton-price" /> | |
| </div> | |
| ))} | |
| </div> | |
| ) | |
| } | |
| /** | |
| * @framerSupportedLayoutWidth any | |
| * @framerSupportedLayoutHeight auto | |
| */ | |
| export default function CourseGrid(props: Props) { | |
| const { | |
| sectionTitle = "Explore our courses", | |
| accentColor = "#6657E8", | |
| style, | |
| } = props | |
| const [courses, setCourses] = React.useState<Course[]>([]) | |
| const [country, setCountry] = React.useState<CountryCode>(null) | |
| const [loading, setLoading] = React.useState(true) | |
| const [courseError, setCourseError] = React.useState(false) | |
| const [countryError, setCountryError] = React.useState(false) | |
| const [countryRetrying, setCountryRetrying] = React.useState(false) | |
| const [retryCount, setRetryCount] = React.useState(0) | |
| // Search and sorting | |
| const [searchQuery, setSearchQuery] = React.useState("") | |
| const [sortOrder, setSortOrder] = | |
| React.useState<SortOrder>("default") | |
| React.useEffect(() => { | |
| let ignore = false | |
| async function loadData() { | |
| setLoading(true) | |
| setCourseError(false) | |
| setCountryError(false) | |
| setCourses([]) | |
| setCountry(null) | |
| const [coursesResult, countryResult] = | |
| await Promise.allSettled([ | |
| fetchJSON<Course[]>(COURSES_URL), | |
| fetchCountryCode(), | |
| ]) | |
| if (ignore) return | |
| // Handle courses request | |
| if ( | |
| coursesResult.status === "fulfilled" && | |
| Array.isArray(coursesResult.value) | |
| ) { | |
| setCourses(coursesResult.value) | |
| } else { | |
| setCourseError(true) | |
| } | |
| // Handle country request separately | |
| if (countryResult.status === "fulfilled") { | |
| setCountry(countryResult.value) | |
| } else { | |
| setCountryError(true) | |
| setSortOrder("default") | |
| } | |
| setLoading(false) | |
| } | |
| loadData() | |
| return () => { | |
| ignore = true | |
| } | |
| }, [retryCount]) | |
| async function retryCountry() { | |
| setCountryRetrying(true) | |
| try { | |
| const code = await fetchCountryCode() | |
| setCountry(code) | |
| setCountryError(false) | |
| } catch (error) { | |
| console.error("Country request failed:", error) | |
| setCountry(null) | |
| setCountryError(true) | |
| setSortOrder("default") | |
| } finally { | |
| setCountryRetrying(false) | |
| } | |
| } | |
| /* | |
| * Search | |
| * | |
| * We filter by: | |
| * - course name | |
| * - description | |
| * - category | |
| * | |
| * Everything is changed to lowercase so searches | |
| * are case-insensitive. | |
| */ | |
| const visibleCourses = courses | |
| .filter((course) => { | |
| const query = searchQuery.trim().toLowerCase() | |
| if (!query) { | |
| return true | |
| } | |
| return ( | |
| course.courseName | |
| .toLowerCase() | |
| .includes(query) || | |
| course.description | |
| .toLowerCase() | |
| .includes(query) || | |
| course.mainCategory | |
| .toLowerCase() | |
| .includes(query) | |
| ) | |
| }) | |
| /* | |
| * Sort | |
| * | |
| * We sort using the currency that belongs to | |
| * the detected country. | |
| * | |
| * IN -> pricePaise | |
| * US -> priceUsdCents | |
| * | |
| * If country detection failed, sorting by price | |
| * is disabled and we keep the API's original order. | |
| */ | |
| .sort((a, b) => { | |
| if (sortOrder === "default" || !country) { | |
| return 0 | |
| } | |
| const priceA = | |
| country === "IN" | |
| ? a.pricePaise | |
| : a.priceUsdCents | |
| const priceB = | |
| country === "IN" | |
| ? b.pricePaise | |
| : b.priceUsdCents | |
| if (sortOrder === "low-high") { | |
| return priceA - priceB | |
| } | |
| return priceB - priceA | |
| }) | |
| return ( | |
| <section | |
| className="skillpath-root" | |
| style={style} | |
| aria-busy={loading} | |
| > | |
| <style>{` | |
| .skillpath-root { | |
| width: 100%; | |
| box-sizing: border-box; | |
| padding: 80px 32px; | |
| background: #f7f7f4; | |
| color: #171717; | |
| font-family: Inter, system-ui, sans-serif; | |
| } | |
| .skillpath-root * { | |
| box-sizing: border-box; | |
| } | |
| .skillpath-inner { | |
| width: 100%; | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| } | |
| .skillpath-heading { | |
| margin: 0 0 32px; | |
| font-size: 40px; | |
| line-height: 1.1; | |
| letter-spacing: -0.04em; | |
| font-weight: 700; | |
| } | |
| /* --------------------------------- | |
| SEARCH + SORT TOOLBAR | |
| --------------------------------- */ | |
| .skillpath-toolbar { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| width: 100%; | |
| margin-bottom: 28px; | |
| } | |
| .skillpath-search, | |
| .skillpath-sort { | |
| height: 48px; | |
| padding: 0 14px; | |
| border: 1px solid rgba(0, 0, 0, 0.12); | |
| border-radius: 10px; | |
| background: #ffffff; | |
| color: #171717; | |
| font-family: inherit; | |
| font-size: 14px; | |
| line-height: 1; | |
| outline: none; | |
| transition: | |
| border-color 0.2s ease, | |
| box-shadow 0.2s ease; | |
| } | |
| .skillpath-search { | |
| flex: 1; | |
| min-width: 0; | |
| } | |
| .skillpath-search::placeholder { | |
| color: #888888; | |
| } | |
| .skillpath-sort { | |
| width: 200px; | |
| cursor: pointer; | |
| } | |
| .skillpath-search:focus, | |
| .skillpath-sort:focus { | |
| border-color: ${accentColor}; | |
| box-shadow: 0 0 0 3px ${accentColor}20; | |
| } | |
| .skillpath-sort:disabled { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| } | |
| /* --------------------------------- | |
| COURSE GRID | |
| --------------------------------- */ | |
| .skillpath-grid { | |
| display: grid; | |
| grid-template-columns: repeat( | |
| 3, | |
| minmax(0, 1fr) | |
| ); | |
| gap: 24px; | |
| width: 100%; | |
| } | |
| .skillpath-card { | |
| min-width: 0; | |
| padding: 24px; | |
| border: 1px solid rgba(0, 0, 0, 0.09); | |
| border-radius: 18px; | |
| background: #ffffff; | |
| display: flex; | |
| flex-direction: column; | |
| min-height: 280px; | |
| } | |
| .skillpath-card-top { | |
| display: flex; | |
| align-items: center; | |
| flex-wrap: wrap; | |
| gap: 8px; | |
| margin-bottom: 20px; | |
| } | |
| /* --------------------------------- | |
| BADGES | |
| --------------------------------- */ | |
| .skillpath-category, | |
| .skillpath-refundable { | |
| display: inline-flex; | |
| align-items: center; | |
| width: fit-content; | |
| padding: 6px 10px; | |
| border-radius: 999px; | |
| font-size: 12px; | |
| line-height: 1; | |
| font-weight: 600; | |
| } | |
| .skillpath-category { | |
| background: #f1f1ed; | |
| color: #555555; | |
| } | |
| .skillpath-refundable { | |
| background: #e9f7ef; | |
| color: #207044; | |
| } | |
| /* --------------------------------- | |
| CARD TEXT | |
| --------------------------------- */ | |
| .skillpath-course-name { | |
| margin: 0 0 12px; | |
| font-size: 22px; | |
| line-height: 1.25; | |
| letter-spacing: -0.025em; | |
| } | |
| .skillpath-description { | |
| margin: 0; | |
| color: #666666; | |
| font-size: 15px; | |
| line-height: 1.55; | |
| display: -webkit-box; | |
| -webkit-box-orient: vertical; | |
| -webkit-line-clamp: 2; | |
| overflow: hidden; | |
| } | |
| .skillpath-price { | |
| margin-top: auto; | |
| padding-top: 28px; | |
| font-size: 22px; | |
| line-height: 1; | |
| font-weight: 700; | |
| } | |
| /* --------------------------------- | |
| ERROR / EMPTY / NOTICE STATES | |
| --------------------------------- */ | |
| .skillpath-notice, | |
| .skillpath-state { | |
| padding: 20px; | |
| border-radius: 14px; | |
| background: #ffffff; | |
| border: 1px solid rgba(0, 0, 0, 0.08); | |
| } | |
| .skillpath-notice { | |
| margin-bottom: 24px; | |
| color: #5f5f5f; | |
| font-size: 14px; | |
| line-height: 1.5; | |
| } | |
| .skillpath-state h3 { | |
| margin: 0 0 8px; | |
| font-size: 20px; | |
| } | |
| .skillpath-state p { | |
| margin: 0; | |
| color: #666666; | |
| line-height: 1.5; | |
| } | |
| .skillpath-retry { | |
| margin-top: 18px; | |
| border: 0; | |
| border-radius: 10px; | |
| padding: 11px 16px; | |
| color: white; | |
| font: inherit; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: | |
| opacity 0.2s ease, | |
| transform 0.2s ease; | |
| } | |
| .skillpath-retry:hover { | |
| opacity: 0.9; | |
| } | |
| .skillpath-retry:active { | |
| transform: translateY(1px); | |
| } | |
| .skillpath-retry:disabled { | |
| opacity: 0.6; | |
| cursor: wait; | |
| transform: none; | |
| } | |
| /* --------------------------------- | |
| SKELETON LOADERS | |
| --------------------------------- */ | |
| .skillpath-skeleton { | |
| border-radius: 7px; | |
| background: #ededeb; | |
| animation: | |
| skillpath-pulse | |
| 1.4s | |
| ease-in-out | |
| infinite; | |
| } | |
| .skillpath-skeleton-card { | |
| gap: 14px; | |
| } | |
| .skillpath-skeleton-small { | |
| width: 92px; | |
| height: 24px; | |
| } | |
| .skillpath-skeleton-title { | |
| width: 70%; | |
| height: 28px; | |
| margin-top: 6px; | |
| } | |
| .skillpath-skeleton-line { | |
| width: 100%; | |
| height: 16px; | |
| } | |
| .skillpath-skeleton-line.short { | |
| width: 75%; | |
| } | |
| .skillpath-skeleton-price { | |
| width: 100px; | |
| height: 26px; | |
| margin-top: auto; | |
| } | |
| @keyframes skillpath-pulse { | |
| 0%, | |
| 100% { | |
| opacity: 1; | |
| } | |
| 50% { | |
| opacity: 0.45; | |
| } | |
| } | |
| /* --------------------------------- | |
| TABLET | |
| --------------------------------- */ | |
| @media (max-width: 899px) { | |
| .skillpath-grid { | |
| grid-template-columns: repeat( | |
| 2, | |
| minmax(0, 1fr) | |
| ); | |
| } | |
| .skillpath-heading { | |
| font-size: 34px; | |
| } | |
| } | |
| /* --------------------------------- | |
| MOBILE | |
| --------------------------------- */ | |
| @media (max-width: 599px) { | |
| .skillpath-root { | |
| padding: 56px 20px; | |
| } | |
| .skillpath-heading { | |
| font-size: 30px; | |
| margin-bottom: 24px; | |
| } | |
| .skillpath-toolbar { | |
| flex-direction: column; | |
| align-items: stretch; | |
| gap: 10px; | |
| margin-bottom: 24px; | |
| } | |
| .skillpath-search, | |
| .skillpath-sort { | |
| width: 100%; | |
| } | |
| .skillpath-grid { | |
| grid-template-columns: 1fr; | |
| gap: 16px; | |
| } | |
| .skillpath-card { | |
| min-height: 260px; | |
| } | |
| } | |
| `}</style> | |
| <div className="skillpath-inner"> | |
| <h2 className="skillpath-heading"> | |
| {sectionTitle} | |
| </h2> | |
| {/* ----------------------------- | |
| LOADING STATE | |
| ----------------------------- */} | |
| {loading ? ( | |
| <SkeletonCards /> | |
| ) : courseError ? ( | |
| /* ----------------------------- | |
| COURSE API ERROR | |
| ----------------------------- */ | |
| <div | |
| className="skillpath-state" | |
| role="alert" | |
| > | |
| <h3>Courses couldn't be loaded</h3> | |
| <p> | |
| Something went wrong while fetching | |
| the courses. Please try again. | |
| </p> | |
| <button | |
| type="button" | |
| className="skillpath-retry" | |
| style={{ | |
| backgroundColor: accentColor, | |
| }} | |
| onClick={() => | |
| setRetryCount( | |
| (current) => current + 1 | |
| ) | |
| } | |
| > | |
| Try again | |
| </button> | |
| </div> | |
| ) : courses.length === 0 ? ( | |
| /* ----------------------------- | |
| API RETURNED ZERO COURSES | |
| ----------------------------- */ | |
| <div className="skillpath-state"> | |
| <h3>No courses yet</h3> | |
| <p> | |
| There aren't any courses | |
| available right now. | |
| </p> | |
| </div> | |
| ) : ( | |
| <> | |
| {/* ----------------------------- | |
| SEARCH + SORT | |
| ----------------------------- */} | |
| <div className="skillpath-toolbar"> | |
| <input | |
| className="skillpath-search" | |
| type="search" | |
| aria-label="Search courses" | |
| placeholder="Search courses..." | |
| value={searchQuery} | |
| onChange={(event) => | |
| setSearchQuery( | |
| event.target.value | |
| ) | |
| } | |
| /> | |
| <select | |
| className="skillpath-sort" | |
| aria-label="Sort courses" | |
| value={sortOrder} | |
| disabled={!country} | |
| onChange={(event) => | |
| setSortOrder( | |
| event.target | |
| .value as SortOrder | |
| ) | |
| } | |
| > | |
| <option value="default"> | |
| Default order | |
| </option> | |
| <option value="low-high"> | |
| Price: Low to High | |
| </option> | |
| <option value="high-low"> | |
| Price: High to Low | |
| </option> | |
| </select> | |
| </div> | |
| {/* ----------------------------- | |
| COUNTRY API FAILED | |
| ----------------------------- */} | |
| {countryError && ( | |
| <div | |
| className="skillpath-notice" | |
| role="status" | |
| > | |
| <div> | |
| We couldn't determine your | |
| region, so course prices are | |
| temporarily unavailable. Price | |
| sorting has also been disabled. | |
| </div> | |
| <button | |
| type="button" | |
| className="skillpath-retry" | |
| style={{ | |
| backgroundColor: accentColor, | |
| }} | |
| onClick={retryCountry} | |
| disabled={countryRetrying} | |
| > | |
| {countryRetrying | |
| ? "Retrying pricing..." | |
| : "Retry pricing"} | |
| </button> | |
| </div> | |
| )} | |
| {/* ----------------------------- | |
| SEARCH HAS NO MATCHES | |
| ----------------------------- */} | |
| {visibleCourses.length === 0 ? ( | |
| <div className="skillpath-state"> | |
| <h3>No matching courses</h3> | |
| <p> | |
| Try searching for a | |
| different course or category. | |
| </p> | |
| </div> | |
| ) : ( | |
| /* ----------------------------- | |
| COURSE CARDS | |
| ----------------------------- */ | |
| <div className="skillpath-grid"> | |
| {visibleCourses.map( | |
| (course) => { | |
| const price = | |
| formatPrice( | |
| course, | |
| country | |
| ) | |
| return ( | |
| <article | |
| className="skillpath-card" | |
| key={ | |
| course.mangoId || | |
| course.courseCode | |
| } | |
| > | |
| <div className="skillpath-card-top"> | |
| {/* Category */} | |
| <span className="skillpath-category"> | |
| { | |
| course.mainCategory | |
| } | |
| </span> | |
| {/* Only render when true */} | |
| {course.refundable && ( | |
| <span className="skillpath-refundable"> | |
| Refundable | |
| </span> | |
| )} | |
| </div> | |
| <h3 className="skillpath-course-name"> | |
| { | |
| course.courseName | |
| } | |
| </h3> | |
| <p className="skillpath-description"> | |
| { | |
| course.description | |
| } | |
| </p> | |
| <div | |
| className="skillpath-price" | |
| style={{ | |
| color: accentColor, | |
| }} | |
| > | |
| {price ?? | |
| "Price unavailable"} | |
| </div> | |
| </article> | |
| ) | |
| } | |
| )} | |
| </div> | |
| )} | |
| </> | |
| )} | |
| </div> | |
| </section> | |
| ) | |
| } | |
| CourseGrid.defaultProps = { | |
| sectionTitle: "Explore our courses", | |
| accentColor: "#6657E8", | |
| } | |
| addPropertyControls(CourseGrid, { | |
| sectionTitle: { | |
| type: ControlType.String, | |
| title: "Heading", | |
| defaultValue: "Explore our courses", | |
| }, | |
| accentColor: { | |
| type: ControlType.Color, | |
| title: "Accent", | |
| defaultValue: "#6657E8", | |
| }, | |
| }) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment