Skip to content

Instantly share code, notes, and snippets.

@rajtilakjee
Last active August 14, 2026 04:51
Show Gist options
  • Select an option

  • Save rajtilakjee/e7b6985b4dee5bdbb3a277f3e55a697a to your computer and use it in GitHub Desktop.

Select an option

Save rajtilakjee/e7b6985b4dee5bdbb3a277f3e55a697a to your computer and use it in GitHub Desktop.
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