From dbea7d3e89b81713592208926e0f22459d455557 Mon Sep 17 00:00:00 2001 From: Anna Date: Fri, 17 Apr 2026 11:07:43 -0400 Subject: [PATCH] feat: create interfaces and types lab (#64659) Co-authored-by: Dario <105294544+Dario-DC@users.noreply.github.com> Co-authored-by: Kolade Chris <65571316+Ksound22@users.noreply.github.com> Co-authored-by: Kolade --- client/i18n/locales/english/intro.json | 6 + .../694175528a794a090ea0ba74.md | 1670 +++++++++++++++++ .../structure/blocks/lab-motorcycle-shop.json | 10 + .../front-end-development-libraries-v9.json | 1 + 4 files changed, 1687 insertions(+) create mode 100644 curriculum/challenges/english/blocks/lab-motorcycle-shop/694175528a794a090ea0ba74.md create mode 100644 curriculum/structure/blocks/lab-motorcycle-shop.json diff --git a/client/i18n/locales/english/intro.json b/client/i18n/locales/english/intro.json index 7f489c193c1..4eae230e659 100644 --- a/client/i18n/locales/english/intro.json +++ b/client/i18n/locales/english/intro.json @@ -6212,6 +6212,12 @@ "In this workshop, you will practice basic TypeScript features like types and interfaces by building a shape manager program." ] }, + "lab-motorcycle-shop": { + "title": "Build a Motorcycle Shop", + "intro": [ + "For this lab, you will use TypeScript to build a Motorcycle Shop." + ] + }, "lecture-working-with-generics-and-type-narrowing": { "title": "Working with Generics and Type Narrowing", "intro": [ diff --git a/curriculum/challenges/english/blocks/lab-motorcycle-shop/694175528a794a090ea0ba74.md b/curriculum/challenges/english/blocks/lab-motorcycle-shop/694175528a794a090ea0ba74.md new file mode 100644 index 00000000000..c1a1273d65e --- /dev/null +++ b/curriculum/challenges/english/blocks/lab-motorcycle-shop/694175528a794a090ea0ba74.md @@ -0,0 +1,1670 @@ +--- +id: 694175528a794a090ea0ba74 +title: Lab Motorcycle Shop +challengeType: 25 +dashedName: lab-motorcycle-shop +demoType: onClick +--- + +# --description-- + +For this lab, you have been provided with all the HTML and CSS. You are going to use TypeScript to complete the functionalities of a motorcycle shop page. + +**Objective:** Fulfill the user stories below and get all the tests to pass to complete the lab. + +**User Stories:** + +1. You should create a `type` called `Category` that consists of the following possible values: `'Sport'`, `'Cruiser'`, `'Touring'`, `'Dirt'`, `'Adventure'`, `'Naked'`, `'Electric'`. +2. You should create an `interface` called `Motorcycle` that has the following properties: an `id` of type `string`, an `name` of type `string`, an `manufacturer` of type `string`, a `category` of type `Category`, a `price` of type `number`, an `image_url` of type `string`, an `created_at` property of type `Date`, an `description` of type `string`, and a year of type `number` +3. You should create a function named `fetchMotorcycles` that returns a `Promise` resolving to an array of `Motorcycle` objects. +4. You should load `Motorcycle` data from the `https://cdn.freecodecamp.org/curriculum/labs/data/motorcycles.json` endpoint. +5. You should create a function named `renderMotorcycleCard` that takes a single `Motorcycle` object as a parameter and returns a string containing the HTML that contains a formatted HTML string for the motorcycle card. + 1. The `renderMotorCycleCard` function should take a parameter called `motorcycle` of type `Motorcycle`. + 2. The `renderMotorCycleCard` function should return a type of `string`. + 3. Each motorcycle card component should have an `img` element that has a valid image and a class of `motorcycle-card-image-container`. + 4. Each motorcycle card component should have an element with an class of `motorcycle-card-year-badge` that lists the year in plain text. + 5. Each motorcycle card component should have an element with a class of `motorcycle-card-title` that lists the name of the motorcycle in plain text. + 6. Each motorcycle card component should have an element with a class of `motorcycle-card-manufacturer` that lists the name of the motorcycle manufacturer in plain text. + 7. Each motorcycle card component should have an element with a class of `motorcycle-card-category` that lists the category of the motorcycle in plain text. + 8. Each motorcycle card component should have an element with a class of `motorcycle-card-description` that lists the description of the motorcycle in plain text. + 9. Each motorcycle card component should have an element with a class of `motorcycle-card-price` that lists the price of the motorcycle in plain text. + 10. Each motorcycle card component should have an element with a class of `motorcycle-card-engine` that lists the horsepower of each engine. +6. You should have a class called `MotorcycleGalleryApp`. + 1. The class should have a `private` array called `allMotorcycles` that is statically typed to a type of an array of `Motorcycle` objects. + 2. That class should have a function called `renderMotorcycles` that renders the equivalent motorcycle card components of the `allMotorcycles` variable inside the element with id of `motorcycle-grid`. +7. The number of `Motorcycle` elements that are displayed should be listed inside of an element with an id of `results-number`. +8. The filter should be updated on an `input` event. + +# --hints-- + +You should have a `type` called `Category`. + +```js +const explorer = await __helpers.Explorer(code); +assert.exists(explorer.types.Category); +``` + +Your `Category` type should be set to a union of the correct values. + +```js +const explorer = await __helpers.Explorer(code); +const { Category } = explorer.types; +assert.isTrue(Category.isUnionOf(["'Sport'", "'Cruiser'", "'Touring'", "'Dirt'", "'Adventure'", "'Naked'", "'Electric'"])); +``` + +You should have an `interface` called `Motorcycle`. + +```js +const explorer = await __helpers.Explorer(code); +assert.exists(explorer.interfaces.Motorcycle); +``` + +You should have all specified properties on the `Motorcycle` interface. + +```js +const explorer = await __helpers.Explorer(code); +assert.isTrue(explorer.interfaces.Motorcycle.hasTypeProps([ + { name: 'id', type: 'string' }, + { name: 'name', type: 'string' }, + { name: 'manufacturer', type: 'string' }, + { name: 'category', type: 'Category' }, + { name: 'price', type: 'number' }, + { name: 'image_url', type: 'string' }, + { name: 'description', type: 'string' } +])); +``` + +You should have a function named `fetchMotorcycles`. + +```js +assert.isFunction(fetchMotorcycles); +``` + +You should use the `https://cdn.freecodecamp.org/curriculum/labs/data/motorcycles.json` endpoint. + +```js +const spy = __helpers.spyOn(window, 'fetch'); +const motorcycles = await fetchMotorcycles(); +assert.strictEqual(spy.calls[0][0].trim(),"https://cdn.freecodecamp.org/curriculum/labs/data/motorcycles.json"); +``` + +You should return a promise of 25 `Motorcycle` objects from `fetchMotorcycles`. + +```js +const spy = __helpers.spyOn(window, 'fetch'); +const motorcycles = await fetchMotorcycles(); +assert.lengthOf(motorcycles,25); +``` + +You should have a function named `renderMotorcycleCard`. + +```js +assert.isFunction(renderMotorcycleCard); +``` + +The `renderMotorcycle` function should accept a statically typed parameter of `Motorcycle`. + +```js +const explorer = await __helpers.Explorer(code); +const [param] = explorer.allFunctions.renderMotorcycleCard.parameters; +assert.equal(param.annotation, 'Motorcycle'); +``` + +The `renderMotorcycleCard` function should explicitly return a type of `string`. + +```js +const explorer = await __helpers.Explorer(code); +assert.isTrue(explorer.allFunctions.renderMotorcycleCard.hasReturnAnnotation("string")); +``` + +The `renderMotorcycleCard` function should return a `string`. + +```js +const sampleMotorcycle = { + "id": "1", + "name": "Ninja H2", + "manufacturer": "Kawasaki", + "category": "Sport", + "price": 29000, + "image_url": "https://cdn.freecodecamp.org/curriculum/labs/motorcycle-vmoto-stash.jpg", + "description": "Supercharged hypersport with unmatched power", + "year": 2024, + "created_at": "2024-01-01T00:00:00Z" +}; + +assert.isString(renderMotorcycleCard(sampleMotorcycle)); +``` + +You should have a class named `MotorcycleGalleryApp`. + +```js +const explorer = await __helpers.Explorer(code); +assert.exists(explorer.classes.MotorcycleGalleryApp); +``` + +The `MotorcycleGalleryApp` should have an array named `allMotorcycles`. + +```js +const gallery = new MotorcycleGalleryApp(); +assert.isArray(gallery.allMotorcycles); +``` + +The `allMotorcycles` property should be `private`. + +```js +const explorer = await __helpers.Explorer(code); +const prop = explorer.classes.MotorcycleGalleryApp.classProps.allMotorcycles; +assert.isFalse(prop.isEmpty()); +assert.isTrue(prop.isPrivate()); +``` + +The `allMotorcycles` property should be statically typed to `Motorcycle[]`. + +```js +const explorer = await __helpers.Explorer(code); +const prop = explorer.classes.MotorcycleGalleryApp.classProps.allMotorcycles; +assert.match(prop.annotation, /Motorcycle\[\]/); +``` + +The `MotorcycleGalleryApp` should have a function named `renderMotorcycles` + +```js +const gallery = new MotorcycleGalleryApp(); +assert.isFunction(gallery.renderMotorcycles); +``` + +The `renderMotorcycles` function should render 25 elements with a class of `motorcycle-card-image-container`. + +```js +const gallery = new MotorcycleGalleryApp(); +gallery.renderMotorcycles(); +const images = document.getElementsByClassName("motorcycle-card-image-container"); +assert.lengthOf(images,25); +``` + +The `renderMotorcycles` function should render 25 elements with a class of `motorcycle-card-year-badge`. + +```js +const gallery = new MotorcycleGalleryApp(); +gallery.renderMotorcycles(); +const badges = document.getElementsByClassName("motorcycle-card-year-badge"); +assert.lengthOf(badges,25); +``` + +The `renderMotorcycles` function should render 25 elements with a class of `motorcycle-card-title`. + +```js +const gallery = new MotorcycleGalleryApp(); +gallery.renderMotorcycles(); +const titles = document.getElementsByClassName("motorcycle-card-title"); +assert.lengthOf(titles,25); +``` + +The `renderMotorcycles` function should render 25 elements with a class of `motorcycle-card-manufacturer`. + +```js +const gallery = new MotorcycleGalleryApp(); +gallery.renderMotorcycles(); +const manufacturers = document.getElementsByClassName("motorcycle-card-manufacturer"); +assert.lengthOf(manufacturers,25); +``` + +The `renderMotorcycles` function should render 25 elements with a class of `motorcycle-card-category`. + +```js +const gallery = new MotorcycleGalleryApp(); +gallery.renderMotorcycles(); +const categories = document.getElementsByClassName("motorcycle-card-category"); +assert.lengthOf(categories,25); +``` + +The `renderMotorcycles` function should render 25 elements with a class of `motorcycle-card-description`. + +```js +const gallery = new MotorcycleGalleryApp(); +gallery.renderMotorcycles(); +const descriptions = document.getElementsByClassName("motorcycle-card-description"); +assert.lengthOf(descriptions,25); +``` + +The `renderMotorcycles` function should render 25 elements with a class of `motorcycle-card-price`. + +```js +const gallery = new MotorcycleGalleryApp(); +gallery.renderMotorcycles(); +const prices = document.getElementsByClassName("motorcycle-card-price"); +assert.lengthOf(prices,25); +``` + +The `renderMotorcycles` function should render 25 elements with a class of `motorcycle-card-engine`. + +```js +const gallery = new MotorcycleGalleryApp(); +gallery.renderMotorcycles(); +const engines = document.getElementsByClassName("motorcycle-card-engine"); +assert.lengthOf(engines,25); +``` + +# --seed-- + +## --seed-contents-- + +```html + + + + + + MotoShop - Find Your Perfect Ride + + + +
+
+
+
+
+ +

MotoShop

+
+
+
+ + + + + +
+
+
+
+
+ +
+
+

Find Your Perfect Ride

+

+ Explore our curated collection of premium motorcycles from the world's leading manufacturers +

+
+
+ +
+ +
+

+ Showing 0 motorcycles +

+
+ + + + + +
+
+
+ +
+
+

+ © 2024 MotoGallery. Ride with passion, choose with confidence. +

+
+
+
+ + + + + +``` + +```css +/* Reset and base styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Layout utilities */ +.max-w-7xl { + max-width: 80rem; +} + +.mx-auto { + margin-left: auto; + margin-right: auto; +} + +.px-4 { + padding-left: 1rem; + padding-right: 1rem; +} + +.py-6 { + padding-top: 1.5rem; + padding-bottom: 1.5rem; +} + +.py-8 { + padding-top: 2rem; + padding-bottom: 2rem; +} + +.py-20 { + padding-top: 5rem; + padding-bottom: 5rem; +} + +.mt-20 { + margin-top: 5rem; +} + +.mb-4 { + margin-bottom: 1rem; +} + +/* Responsive padding */ +@media (min-width: 640px) { + .sm\:px-6 { + padding-left: 1.5rem; + padding-right: 1.5rem; + } +} + +@media (min-width: 1024px) { + .lg\:px-8 { + padding-left: 2rem; + padding-right: 2rem; + } +} + +/* Background gradients */ +.bg-gradient-to-r { + background: linear-gradient(to right, var(--tw-gradient-stops)); +} + +.from-gray-900 { + --tw-gradient-from: #111827; + --tw-gradient-to: rgb(17 24 39 / 0); + --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); +} + +.via-gray-800 { + --tw-gradient-to: rgb(31 41 55 / 0); + --tw-gradient-stops: var(--tw-gradient-from), #1f2937, var(--tw-gradient-to); +} + +.to-gray-900 { + --tw-gradient-to: #111827; +} + +.from-orange-600 { + --tw-gradient-from: #ea580c; + --tw-gradient-to: rgb(234 88 12 / 0); + --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); +} + +.via-orange-500 { + --tw-gradient-to: rgb(249 115 22 / 0); + --tw-gradient-stops: var(--tw-gradient-from), #f97316, var(--tw-gradient-to); +} + +.to-red-500 { + --tw-gradient-to: #ef4444; +} + +/* Colors */ +.bg-gray-900 { + background-color: #111827; +} + +.text-white { + color: #ffffff; +} + +.text-gray-400 { + color: #9ca3af; +} + +.text-orange-500 { + color: #f97316; + stroke: #f97316; +} + +/* Flexbox utilities */ +.flex { + display: flex; +} + +.items-center { + align-items: center; +} + +.gap-3 { + gap: 0.75rem; +} + +/* Grid utilities */ +.grid { + display: grid; +} + +.gap-6 { + gap: 1.5rem; +} + +/* Typography */ +.text-xl { + font-size: 1.25rem; + line-height: 1.75rem; +} + +.text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; +} + +.text-5xl { + font-size: 3rem; + line-height: 1; +} + +.font-bold { + font-weight: 700; +} + +.text-center { + text-align: center; +} + +.opacity-90 { + opacity: 0.9; +} + +.max-w-2xl { + max-width: 42rem; +} + +/* Spacing utilities */ +.w-8 { + width: 2rem; +} + +.h-8 { + height: 2rem; +} + +.h-2 { + height: 0.5rem; +} + +.w-full { + width: 100%; +} + +/* Border utilities */ +.border { + border-width: 1px; +} + +.border-t { + border-top-width: 1px; +} + +.rounded-lg { + border-radius: 0.5rem; +} + +/* Position utilities */ +.relative { + position: relative; +} + +.absolute { + position: absolute; +} + +/* Padding utilities */ +.px-4 { + padding-left: 1rem; + padding-right: 1rem; +} + +.px-6 { + padding-left: 1.5rem; + padding-right: 1.5rem; +} + +.py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + +.py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +/* Margin utilities */ +.mb-4 { + margin-bottom: 1rem; +} + +/* Focus utilities */ +.focus\:ring-2:focus { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + +.focus\:ring-orange-500:focus { + --tw-ring-color: #f97316; +} + +.focus\:border-transparent:focus { + border-color: transparent; +} + +/* Hover utilities */ +.hover\:-translate-y-1:hover { + transform: translateY(-0.25rem); +} + +/* Animation utilities */ +.animate-spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Custom component styles */ +.loading-container { + display: flex; + align-items: center; + justify-content: center; + min-height: 400px; +} + +.loading-spinner { + display: flex; + align-items: center; + justify-content: center; +} + +.motorcycle-grid { + display: grid; + grid-template-columns: repeat(1, minmax(0, 1fr)); + gap: 2rem; +} + +@media (min-width: 768px) { + .motorcycle-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (min-width: 1024px) { + .motorcycle-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.motorcycle-card { + background-color: #ffffff; + border-radius: 0.75rem; + overflow: hidden; + box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1); + transform: translateY(0); +} + +.motorcycle-card:hover { + box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25); + transform: translateY(-0.25rem); +} + +.motorcycle-card-image-container { + position: relative; + height: 16rem; + overflow: hidden; + background-color: #111827; +} + +.motorcycle-card-image { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 500ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.motorcycle-card:hover .motorcycle-card-image { + transform: scale(1.1); +} + +.motorcycle-card-year-badge { + position: absolute; + top: 1rem; + right: 1rem; + background-color: #f97316; + color: #ffffff; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.875rem; + font-weight: 700; +} + +.motorcycle-card-content { + padding: 1.5rem; +} + +.motorcycle-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 0.75rem; +} + +.motorcycle-card-title { + font-size: 1.25rem; + font-weight: 700; + color: #111827; + margin-bottom: 0.25rem; +} + +.motorcycle-card-manufacturer { + color: #4b5563; + font-weight: 500; +} + +.motorcycle-card-category { + padding: 0.25rem 0.75rem; + background-color: #f3f4f6; + color: #374151; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.025em; +} + +.motorcycle-card-description { + color: #4b5563; + font-size: 0.875rem; + margin-bottom: 1rem; + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.motorcycle-card-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 1rem; + border-top: 1px solid #e5e7eb; +} + +.motorcycle-card-price { + font-size: 1.5rem; + font-weight: 700; + color: #ea580c; +} + +.motorcycle-card-engine { + font-size: 0.75rem; + color: #6b7280; +} + +.motorcycle-card-button { + background-color: #f97316; + color: #ffffff; + padding: 0.5rem 1.5rem; + border-radius: 0.5rem; + font-weight: 600; + transition: background-color 200ms cubic-bezier(0.4, 0, 0.2, 1); + border: none; + cursor: pointer; +} + +.motorcycle-card-button:hover { + background-color: #ea580c; +} + +.no-results { + text-align: center; + padding: 3rem 0; +} + +.no-results-text { + font-size: 1.25rem; + color: #4b5563; +} + +.results-count { + margin-bottom: 1.5rem; +} + +.results-count-text { + color: #4b5563; +} + +.results-count-number { + font-weight: 700; + color: #111827; +} + +/* Additional utility classes for search input */ +.bg-gray-700 { + background-color: #374151; +} + +.border-gray-600 { + border-color: #4b5563; +} + +.placeholder-gray-400::placeholder { + color: #9ca3af; +} + +.focus\:outline-none:focus { + outline: none; +} + +.focus\:ring-2:focus { + box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.5); +} + +.focus\:ring-orange-500:focus { + box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.5); +} + +.focus\:border-transparent:focus { + border-color: transparent; +} + +.pl-10 { + padding-left: 2.5rem; +} + +.w-96 { + width: 24rem; +} + +.left-3 { + left: 0.75rem; +} + +.top-1\/2 { + top: 50%; +} + +.transform { + transform: translateY(-50%); +} + +.flex-1 { + flex: 1 1 0%; +} + +.gap-4 { + gap: 1rem; +} + +.gap-6 { + gap: 1.5rem; +} + +.flex-col { + flex-direction: column; +} + +.items-start { + align-items: flex-start; +} + +.items-center { + align-items: center; +} + +@media (min-width: 768px) { + .md\:flex-row { + flex-direction: row; + } + + .md\:items-center { + align-items: center; + } + + .md\:w-auto { + width: auto; + } +} +``` + +```ts +``` + +# --solutions-- + +```html + + + + + + MotoShop - Find Your Perfect Ride + + + + +
+
+
+
+
+ +

MotoShop

+
+
+
+ + + + + +
+
+
+
+
+ +
+
+

Find Your Perfect Ride

+

+ Explore our curated collection of premium motorcycles from the world's leading manufacturers +

+
+
+ +
+ +
+

+ Showing 0 motorcycles +

+
+ + + + + +
+
+
+ +
+
+

+ © 2024 MotoGallery. Ride with passion, choose with confidence. +

+
+
+
+ + + + + +``` + +```css +/* Reset and base styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Layout utilities */ +.max-w-7xl { + max-width: 80rem; +} + +.mx-auto { + margin-left: auto; + margin-right: auto; +} + +.px-4 { + padding-left: 1rem; + padding-right: 1rem; +} + +.py-6 { + padding-top: 1.5rem; + padding-bottom: 1.5rem; +} + +.py-8 { + padding-top: 2rem; + padding-bottom: 2rem; +} + +.py-20 { + padding-top: 5rem; + padding-bottom: 5rem; +} + +.mt-20 { + margin-top: 5rem; +} + +.mb-4 { + margin-bottom: 1rem; +} + +/* Responsive padding */ +@media (min-width: 640px) { + .sm\:px-6 { + padding-left: 1.5rem; + padding-right: 1.5rem; + } +} + +@media (min-width: 1024px) { + .lg\:px-8 { + padding-left: 2rem; + padding-right: 2rem; + } +} + +/* Background gradients */ +.bg-gradient-to-r { + background: linear-gradient(to right, var(--tw-gradient-stops)); +} + +.from-gray-900 { + --tw-gradient-from: #111827; + --tw-gradient-to: rgb(17 24 39 / 0); + --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); +} + +.via-gray-800 { + --tw-gradient-to: rgb(31 41 55 / 0); + --tw-gradient-stops: var(--tw-gradient-from), #1f2937, var(--tw-gradient-to); +} + +.to-gray-900 { + --tw-gradient-to: #111827; +} + +.from-orange-600 { + --tw-gradient-from: #ea580c; + --tw-gradient-to: rgb(234 88 12 / 0); + --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); +} + +.via-orange-500 { + --tw-gradient-to: rgb(249 115 22 / 0); + --tw-gradient-stops: var(--tw-gradient-from), #f97316, var(--tw-gradient-to); +} + +.to-red-500 { + --tw-gradient-to: #ef4444; +} + +/* Colors */ +.bg-gray-900 { + background-color: #111827; +} + +.text-white { + color: #ffffff; +} + +.text-gray-400 { + color: #9ca3af; +} + +.text-orange-500 { + color: #f97316; + stroke: #f97316; +} + +/* Flexbox utilities */ +.flex { + display: flex; +} + +.items-center { + align-items: center; +} + +.gap-3 { + gap: 0.75rem; +} + +/* Grid utilities */ +.grid { + display: grid; +} + +.gap-6 { + gap: 1.5rem; +} + +/* Typography */ +.text-xl { + font-size: 1.25rem; + line-height: 1.75rem; +} + +.text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; +} + +.text-5xl { + font-size: 3rem; + line-height: 1; +} + +.font-bold { + font-weight: 700; +} + +.text-center { + text-align: center; +} + +.opacity-90 { + opacity: 0.9; +} + +.max-w-2xl { + max-width: 42rem; +} + +/* Spacing utilities */ +.w-8 { + width: 2rem; +} + +.h-8 { + height: 2rem; +} + +.h-2 { + height: 0.5rem; +} + +.w-full { + width: 100%; +} + +/* Border utilities */ +.border { + border-width: 1px; +} + +.border-t { + border-top-width: 1px; +} + +.rounded-lg { + border-radius: 0.5rem; +} + +/* Position utilities */ +.relative { + position: relative; +} + +.absolute { + position: absolute; +} + +/* Padding utilities */ +.px-4 { + padding-left: 1rem; + padding-right: 1rem; +} + +.px-6 { + padding-left: 1.5rem; + padding-right: 1.5rem; +} + +.py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + +.py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +/* Margin utilities */ +.mb-4 { + margin-bottom: 1rem; +} + +/* Focus utilities */ +.focus\:ring-2:focus { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + +.focus\:ring-orange-500:focus { + --tw-ring-color: #f97316; +} + +.focus\:border-transparent:focus { + border-color: transparent; +} + +/* Hover utilities */ +.hover\:-translate-y-1:hover { + transform: translateY(-0.25rem); +} + +/* Animation utilities */ +.animate-spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Custom component styles */ +.loading-container { + display: flex; + align-items: center; + justify-content: center; + min-height: 400px; +} + +.loading-spinner { + display: flex; + align-items: center; + justify-content: center; +} + +.motorcycle-grid { + display: grid; + grid-template-columns: repeat(1, minmax(0, 1fr)); + gap: 2rem; +} + +@media (min-width: 768px) { + .motorcycle-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (min-width: 1024px) { + .motorcycle-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.motorcycle-card { + background-color: #ffffff; + border-radius: 0.75rem; + overflow: hidden; + box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1); + transform: translateY(0); +} + +.motorcycle-card:hover { + box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25); + transform: translateY(-0.25rem); +} + +.motorcycle-card-image-container { + position: relative; + height: 16rem; + overflow: hidden; + background-color: #111827; +} + +.motorcycle-card-image { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 500ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.motorcycle-card:hover .motorcycle-card-image { + transform: scale(1.1); +} + +.motorcycle-card-year-badge { + position: absolute; + top: 1rem; + right: 1rem; + background-color: #f97316; + color: #ffffff; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.875rem; + font-weight: 700; +} + +.motorcycle-card-content { + padding: 1.5rem; +} + +.motorcycle-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 0.75rem; +} + +.motorcycle-card-title { + font-size: 1.25rem; + font-weight: 700; + color: #111827; + margin-bottom: 0.25rem; +} + +.motorcycle-card-manufacturer { + color: #4b5563; + font-weight: 500; +} + +.motorcycle-card-category { + padding: 0.25rem 0.75rem; + background-color: #f3f4f6; + color: #374151; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.025em; +} + +.motorcycle-card-description { + color: #4b5563; + font-size: 0.875rem; + margin-bottom: 1rem; + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.motorcycle-card-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 1rem; + border-top: 1px solid #e5e7eb; +} + +.motorcycle-card-price { + font-size: 1.5rem; + font-weight: 700; + color: #ea580c; +} + +.motorcycle-card-engine { + font-size: 0.75rem; + color: #6b7280; +} + +.motorcycle-card-button { + background-color: #f97316; + color: #ffffff; + padding: 0.5rem 1.5rem; + border-radius: 0.5rem; + font-weight: 600; + transition: background-color 200ms cubic-bezier(0.4, 0, 0.2, 1); + border: none; + cursor: pointer; +} + +.motorcycle-card-button:hover { + background-color: #ea580c; +} + +.no-results { + text-align: center; + padding: 3rem 0; +} + +.no-results-text { + font-size: 1.25rem; + color: #4b5563; +} + +.results-count { + margin-bottom: 1.5rem; +} + +.results-count-text { + color: #4b5563; +} + +.results-count-number { + font-weight: 700; + color: #111827; +} + +/* Additional utility classes for search input */ +.bg-gray-700 { + background-color: #374151; +} + +.border-gray-600 { + border-color: #4b5563; +} + +.placeholder-gray-400::placeholder { + color: #9ca3af; +} + +.focus\:outline-none:focus { + outline: none; +} + +.focus\:ring-2:focus { + box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.5); +} + +.focus\:ring-orange-500:focus { + box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.5); +} + +.focus\:border-transparent:focus { + border-color: transparent; +} + +.pl-10 { + padding-left: 2.5rem; +} + +.w-96 { + width: 24rem; +} + +.left-3 { + left: 0.75rem; +} + +.top-1\/2 { + top: 50%; +} + +.transform { + transform: translateY(-50%); +} + +.flex-1 { + flex: 1 1 0%; +} + +.gap-4 { + gap: 1rem; +} + +.gap-6 { + gap: 1.5rem; +} + +.flex-col { + flex-direction: column; +} + +.items-start { + align-items: flex-start; +} + +.items-center { + align-items: center; +} + +@media (min-width: 768px) { + .md\:flex-row { + flex-direction: row; + } + + .md\:items-center { + align-items: center; + } + + .md\:w-auto { + width: auto; + } +} +``` + +```ts +type Category = + | 'Sport' + | 'Cruiser' + | 'Touring' + | 'Dirt' + | 'Adventure' + | 'Naked' + | 'Electric'; + +interface Motorcycle { + id: string; + name: string; + manufacturer: string; + category: Category; + price: number; + image_url: string; + description: string; + year: number; + engine_cc: number; + created_at: string; +} + + +async function fetchMotorcycles() : Promise +{ + const result = await fetch(`https://cdn.freecodecamp.org/curriculum/labs/data/motorcycles.json`); + let motorcycles = await result.json(); + return [...motorcycles].sort((a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); +} + + +function renderMotorcycleCard(motorcycle: Motorcycle): string { + return ` +
+
+ ${motorcycle.name} +
+ ${motorcycle.year} +
+
+
+
+
+

${motorcycle.name}

+

${motorcycle.manufacturer}

+
+ + ${motorcycle.category} + +
+

${motorcycle.description}

+ +
+
+ `; + } + +class MotorcycleGalleryApp { + private allMotorcycles: Motorcycle[] = []; + private filteredMotorcycles: Motorcycle[] = []; + private nameFilter: string = ''; + + constructor() { + this.init(); + } + + private async init(): Promise { + await this.loadMotorcycles(); + this.setupEventListeners(); + this.render(); + } + + private async loadMotorcycles(): Promise { + this.showLoading(true); + try { + const data = await fetchMotorcycles(); + this.allMotorcycles = [...data]; + this.applyFilters(); + } catch (error) { + console.error('Error loading motorcycles:', error); + } finally { + this.showLoading(false); + } + } + + private applyFilters(): void { + this.filteredMotorcycles = this.allMotorcycles.filter((motorcycle) => { + const matchesName = this.nameFilter === '' || + motorcycle.name.toLowerCase().includes(this.nameFilter.toLowerCase()); + return matchesName; + }); + this.render(); + } + + private setupEventListeners(): void { + const nameFilterInput = document.getElementById('name-filter-input') as HTMLInputElement; + if (nameFilterInput) { + nameFilterInput.addEventListener('input', (event: Event) => { + const target = event.target as HTMLInputElement; + this.nameFilter = target.value; + this.applyFilters(); + }); + } + } + + private render(): void { + this.renderResultsCount(); + this.renderMotorcycles(); + } + + private renderResultsCount(): void { + const resultsNumber = document.getElementById('results-number'); + if (resultsNumber) { + resultsNumber.textContent = this.filteredMotorcycles.length.toString(); + } + } + + private renderMotorcycles(): void { + const container = document.getElementById('motorcycle-grid'); + const noResults = document.getElementById('no-results'); + if (!container) return; + if (this.filteredMotorcycles.length === 0) { + container.style.display = 'none'; + if (noResults) { + noResults.style.display = 'block'; + } + return; + } + if (noResults) { + noResults.style.display = 'none'; + } + container.style.display = 'grid'; + container.innerHTML = this.filteredMotorcycles + .map((motorcycle) => renderMotorcycleCard(motorcycle)) + .join(''); + } + + private showLoading(show: boolean): void { + const loadingContainer = document.getElementById('loading-container'); + const motorcycleGrid = document.getElementById('motorcycle-grid'); + if (loadingContainer) { + loadingContainer.style.display = show ? 'flex' : 'none'; + } + if (motorcycleGrid) { + motorcycleGrid.style.display = show ? 'none' : 'grid'; + } + } +} + +document.addEventListener('DOMContentLoaded', () => { + new MotorcycleGalleryApp(); +}); + +``` diff --git a/curriculum/structure/blocks/lab-motorcycle-shop.json b/curriculum/structure/blocks/lab-motorcycle-shop.json new file mode 100644 index 00000000000..6761e0b1128 --- /dev/null +++ b/curriculum/structure/blocks/lab-motorcycle-shop.json @@ -0,0 +1,10 @@ +{ + "isUpcomingChange": true, + "dashedName": "lab-motorcycle-shop", + "helpCategory": "JavaScript", + "blockLayout": "link", + "challengeOrder": [ + { "id": "694175528a794a090ea0ba74", "title": "Build a Motorcycle Shop" } + ], + "blockLabel": "lab" +} diff --git a/curriculum/structure/superblocks/front-end-development-libraries-v9.json b/curriculum/structure/superblocks/front-end-development-libraries-v9.json index c2a3d21daaa..d8d45a2b594 100644 --- a/curriculum/structure/superblocks/front-end-development-libraries-v9.json +++ b/curriculum/structure/superblocks/front-end-development-libraries-v9.json @@ -95,6 +95,7 @@ "workshop-type-safe-math-toolkit", "lecture-understanding-type-composition", "workshop-shape-manager", + "lab-motorcycle-shop", "lecture-working-with-generics-and-type-narrowing", "workshop-bug-emoji-picker", "lab-product-showcase",