From a60ccdb29360ddc94643e8c1d23f4cdc9c45fd69 Mon Sep 17 00:00:00 2001 From: Dario <105294544+Dario-DC@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:01:08 +0200 Subject: [PATCH] feat(curriculum): add product showcase lab (#65456) Co-authored-by: Kolade Chris <65571316+Ksound22@users.noreply.github.com> Co-authored-by: Kolade --- client/i18n/locales/english/intro.json | 6 + .../696920c0c98a1ed58eb86293.md | 954 ++++++++++++++++++ .../blocks/lab-product-showcase.json | 11 + .../front-end-development-libraries-v9.json | 1 + 4 files changed, 972 insertions(+) create mode 100644 curriculum/challenges/english/blocks/lab-product-showcase/696920c0c98a1ed58eb86293.md create mode 100644 curriculum/structure/blocks/lab-product-showcase.json diff --git a/client/i18n/locales/english/intro.json b/client/i18n/locales/english/intro.json index 79c92c62373..c9e726eb83e 100644 --- a/client/i18n/locales/english/intro.json +++ b/client/i18n/locales/english/intro.json @@ -6211,6 +6211,12 @@ "In this workshop, you will learn about TypeScript abstract classes and generics by building a bug species selector that displays different bug emojis." ] }, + "lab-product-showcase": { + "title": "Build a Product Showcase", + "intro": [ + "In this lab, you will practice generics and type narrowing in TypeScript." + ] + }, "lecture-working-with-typescript-configuration-files": { "title": "Working with TypeScript Configuration Files", "intro": [ diff --git a/curriculum/challenges/english/blocks/lab-product-showcase/696920c0c98a1ed58eb86293.md b/curriculum/challenges/english/blocks/lab-product-showcase/696920c0c98a1ed58eb86293.md new file mode 100644 index 00000000000..754ed06f1de --- /dev/null +++ b/curriculum/challenges/english/blocks/lab-product-showcase/696920c0c98a1ed58eb86293.md @@ -0,0 +1,954 @@ +--- +id: 696920c0c98a1ed58eb86293 +title: Build a Product Showcase +challengeType: 25 +dashedName: lab-product-showcase +demoType: onClick +--- + +# --description-- + +For this lab, you have been provided with all the HTML and CSS. You will complete a product showcase using TypeScript. + +**Objective:** Fulfill the user stories below and get all the tests to pass to complete the lab. + +**User Stories:** + +1. You should create an interface named `Item` with: + - A `type` property that can only be `"book"`, `"electronics"`, or `"clothing"`. + - An `id` property of type `string`. + - A `price` property of type `number`. + +1. You should create an interface named `Book` that extends `Item`. Its `type` property should be set to the literal `"book"`. It should also have `title` and `author` properties of type `string`. + +1. You should create an interface named `Electronics` that extends `Item`. Its `type` property should be set to the literal `"electronics"`. It should also have `item` and `model` properties of type `string`, and an optional `warranty` property of type `number`. + +1. You should create an interface named `Clothing` that extends `Item`. Its `type` property should be set to the literal `"clothing"`. It should also have `item` and `brand` properties of type `string`, and an optional `size` property that can only be `"S"`, `"M"`, or `"L"`. + +1. You should create a union type named `Product` that combines the `Book`, `Electronics`, and `Clothing` interfaces. + +1. You should create a generic class named `Collection` with a type parameter `T`. The constructor should accept an array of items of type `T` and store them in a property named `items`. + +1. The `Collection` class should have a `getAll()` method that returns all items in the collection. + +1. The `Collection` class should have a `filter()` method that accepts a callback and returns only the elements from the `items` array that satisfy the given condition. The callback should receive an element of type `T` and return a boolean. + +1. You should create a function named `renderProduct` with a parameter of type `Product`. It should return an HTML string representing a product with: + - An element with the class `item` and an ID with the value of the product `id`. + - An element with the class `price` and the text of the product `price`. + - An element containing additional product information. + +1. The `renderProduct` function should use type narrowing to render different HTML depending on the product type: + + - For books, the rendered HTML should have the format `Book: [title] by [author]`, where `[title]` and `[author]` should be replaced by the title and the author, respectively. + - For electronics, the rendered HTML should have the format `Electronics: [item] - [model]`. If the product has a warranty, append ` - Warranty: [warranty] year(s)` to the format. Replace `[item]`, `[model]`, and `[warranty]` with the item, the model, and the warranty, respectively. + - For clothing, the rendered HTML should have the format `Clothing: [item] by [brand]`. If the product has a size, append ` - Size [size]` to the format. Replace `[item]`, `[brand]`, and `[size]` with the item, the brand, and the size, respectively. + - If an invalid product type is encountered, throw an error with the format: `Unknown product type: [product]`, where `[product]` should be replaced by the invalid product object. Use `JSON.stringify` to convert the invalid product into a string. + +1. You should create a `Collection` instance that includes at least one item for each product type and assign the instance to a variable named `products`. + +1. You should create a `showProducts` function that displays products in the `#output` element. It should accept an optional filter representing a product type; when omitted, show all products; when provided, show only products of that type. + +1. You should render products by converting each product to an HTML string using the `renderProduct` function and injecting the combined result into the `#output` element. + +1. Clicking the `#all`, `#books`, `#electronics`, and `#clothing` buttons should display the corresponding set of products in the `#output` element. + +1. On page load, all products should be shown by default. + +# --hints-- + +You should have an interface named `Item`. + +```js +const explorer = await __helpers.Explorer(code); +assert.exists(explorer.interfaces.Item); +``` + +Your `Item` interface should have a `type` property that can only be `"book"`, `"electronics"`, or `"clothing"`. + +```js +const explorer = await __helpers.Explorer(code); +const { Item } = explorer.interfaces; +const typeProp = Item.typeProps.type; +assert.isTrue(typeProp.isUnionOf(["'book'", "'electronics'", "'clothing'"])); +``` + +Your `Item` interface should have an `id` property of type `string`. + +```js +const explorer = await __helpers.Explorer(code); +assert.isTrue(explorer.interfaces.Item.hasTypeProps({name: "id", type: "string"})); +``` + +Your `Item` interface should have a `price` property of type `number`. + +```js +const explorer = await __helpers.Explorer(code); +assert.isTrue(explorer.interfaces.Item.hasTypeProps({name: "price", type: "number"})); +``` + +You should have an interface named `Book`. + +```js +const explorer = await __helpers.Explorer(code); +assert.exists(explorer.interfaces.Book); +``` + +Your `Book` interface should extend `Item`. + +```js +const explorer = await __helpers.Explorer(code); +assert.isTrue(explorer.interfaces.Book.doesExtend("Item")); +``` + +Your `Book` interface should have a `type` property set to `"book"`. + +```js +const explorer = await __helpers.Explorer(code); +assert.isTrue(explorer.interfaces.Book.hasTypeProps({name: "type", type: "'book'"})); +``` + +Your `Book` interface should have a `title` property of type `string`. + +```js +const explorer = await __helpers.Explorer(code); +assert.isTrue(explorer.interfaces.Book.hasTypeProps({name: "title", type: "string"})); +``` + +Your `Book` interface should have an `author` property of type `string`. + +```js +const explorer = await __helpers.Explorer(code); +assert.isTrue(explorer.interfaces.Book.hasTypeProps({name: "author", type: "string"})); +``` + +You should have an interface named `Electronics`. + +```js +const explorer = await __helpers.Explorer(code); +assert.exists(explorer.interfaces.Electronics); +``` + +Your `Electronics` interface should extend `Item`. + +```js +const explorer = await __helpers.Explorer(code); +assert.isTrue(explorer.interfaces.Electronics.doesExtend("Item")); +``` + +Your `Electronics` interface should have a `type` property set to `"electronics"`. + +```js +const explorer = await __helpers.Explorer(code); +assert.isTrue(explorer.interfaces.Electronics.hasTypeProps({name: "type", type: "'electronics'"})); +``` + +Your `Electronics` interface should have an `item` property of type `string`. + +```js +const explorer = await __helpers.Explorer(code); +assert.isTrue(explorer.interfaces.Electronics.hasTypeProps({name: "item", type: "string"})); +``` + +Your `Electronics` interface should have a `model` property of type `string`. + +```js +const explorer = await __helpers.Explorer(code); +assert.isTrue(explorer.interfaces.Electronics.hasTypeProps({name: "model", type: "string"})); +``` + +Your `Electronics` interface should have an optional `warranty` property of type `number`. + +```js +const explorer = await __helpers.Explorer(code); +assert.isTrue(explorer.interfaces.Electronics.hasTypeProps({name: "warranty", type: "number", isOptional: true})); +``` + +You should have an interface named `Clothing`. + +```js +const explorer = await __helpers.Explorer(code); +assert.exists(explorer.interfaces.Clothing); +``` + +Your `Clothing` interface should extend `Item`. + +```js +const explorer = await __helpers.Explorer(code); +assert.isTrue(explorer.interfaces.Clothing.doesExtend("Item")); +``` + +Your `Clothing` interface should have a `type` property set to `"clothing"`. + +```js +const explorer = await __helpers.Explorer(code); +assert.isTrue(explorer.interfaces.Clothing.hasTypeProps({name: "type", type: "'clothing'"})); +``` + +Your `Clothing` interface should have an `item` property of type `string`. + +```js +const explorer = await __helpers.Explorer(code); +assert.isTrue(explorer.interfaces.Clothing.hasTypeProps({name: "item", type: "string"})); +``` + +Your `Clothing` interface should have a `brand` property of type `string`. + +```js +const explorer = await __helpers.Explorer(code); +assert.isTrue(explorer.interfaces.Clothing.hasTypeProps({name: "brand", type: "string"})); +``` + +Your `Clothing` interface should have an optional `size` property that can only be `"S"`, `"M"`, or `"L"`. + +```js +const explorer = await __helpers.Explorer(code); +const { Clothing } = explorer.interfaces; +assert.isTrue(Clothing.hasTypeProps({name: "size", isOptional: true})); +assert.isTrue(Clothing.typeProps.size.isUnionOf(["'S'", "'M'", "'L'"])); +``` + +You should create a union type named `Product` that combines the `Book`, `Electronics`, and `Clothing` interfaces. + +```js +const explorer = await __helpers.Explorer(code); +const { Product } = explorer.types; +assert.isTrue(Product.isUnionOf(["Book", "Electronics", "Clothing"])); +``` + +You should have a `Collection` class with a type parameter `T`. + +```js +const explorer = await __helpers.Explorer(code); +const { Collection } = explorer.classes; +assert.isTrue(Collection.typeParameters[0].matches("T")); +``` + +The constructor of your `Collection` class should accept an array of items of type `T` and store them in a property named `items`. + +```js +let arr = [1, 2, 3]; +const numCollection = new Collection(arr); +assert.deepEqual(numCollection.items, arr); +arr = ["a", "b", "c", "d"]; +const stringCollection = new Collection(arr); +assert.deepEqual(stringCollection.items, arr); +``` + +Your `Collection` class should have a `getAll` method. + +```js +const explorer = await __helpers.Explorer(code); +const { Collection } = explorer.classes; +console.log(Collection.methods) +console.log(Collection.methods.getAll) +assert.exists(Collection.methods.getAll); +``` + +Your `getAll` method should return all items in the collection. + +```js +let arr = [1, 2, 3]; +const numCollection = new Collection(arr); +assert.deepEqual(numCollection.getAll(), arr); +arr = ["a", "b", "c", "d"]; +const stringCollection = new Collection(arr); +assert.deepEqual(stringCollection.getAll(), arr); +``` + +Your `Collection` class should have a `filter` method. + +```js +const explorer = await __helpers.Explorer(code); +const { Collection } = explorer.classes; +assert.exists(Collection.methods.filter); +``` + +Your `filter` method should accept a callback function and return an array containting the elements of the `items` property that satisfy the given condition. + +```js +let arr = [1, 2, 3, 4, 5, 6]; +const numCollection = new Collection(arr); +assert.deepEqual(numCollection.filter(n => n % 2 === 0), [2, 4, 6]); +arr = ["a", "b", "c", "d"]; +const stringCollection = new Collection(arr); +assert.deepEqual(stringCollection.filter(s => s === "c"), ["c"]); +``` + +You should have a function named `renderProduct`. + +```js +const explorer = await __helpers.Explorer(code); +assert.exists(explorer.allFunctions.renderProduct); +``` + +`renderProduct(product)` should return a string containing an element with the class of `item` and the ID equal to `product.id`. + +```js +const testEl = document.createElement("div"); +const productObjects = [ + { id: "c1", type: "clothing", item: "Jacket", brand: "Northloom", size: "M", price: 89.99 }, + { id: "e1", type: "electronics", item: "Tablet", model: "Pixelon Slate-A9", warranty: 2, price: 349.99 }, + { id: "b1", type: "book", title: "Dune", author: "Frank Herbert", price: 14.99 }, +] +productObjects.forEach(p => { + const renderedProduct = renderProduct(p); + testEl.innerHTML = renderedProduct; + assert.isNotNull(testEl.querySelector(`[class="item"][id="${p.id}"]`)); +}) +``` + +`renderProduct(product)` should return a string containing an element with the class of `price` and the text equal to `product.price`. + +```js +const testEl = document.createElement("div"); +const productObjects = [ + { id: "c1", type: "clothing", item: "Jacket", brand: "Northloom", size: "M", price: 89.99 }, + { id: "e1", type: "electronics", item: "Tablet", model: "Pixelon Slate-A9", warranty: 2, price: 349.99 }, + { id: "b1", type: "book", title: "Dune", author: "Frank Herbert", price: 14.99 }, +] +productObjects.forEach(p => { + const renderedProduct = renderProduct(p); + testEl.innerHTML = renderedProduct; + const price = testEl.querySelector(`[class="price"]`); + assert.include(price.innerText, p.price.toString()); +}) +``` + +When `renderProduct` is called with a `Book` product `p`, it should return a string containing an element with the text following the format `Book: [title] by [author]`. Replace `[title]` and `[author]` with the corresponding properties of `p`. + +```js +const testEl = document.createElement("div"); +const productObjects = [ + { id: "b1", type: "book", title: "Dune", author: "Frank Herbert", price: 14.99 }, + { id: "b4", type: "book", title: "The Lord of the Rings", author: "J.R.R. Tolkien", price: 24.99 } + +] +productObjects.forEach(p => { + const renderedProduct = renderProduct(p); + testEl.innerHTML = renderedProduct; + const expectedText = `Book: ${p.title} by ${p.author}`; + assert.include(testEl.innerText, expectedText); +}) +``` + +When `renderProduct` is called with an `Electronics` product `p`, it should return a string containing an element with the text following either the format `Electronics: [item] - [model]` or `Electronics: [item] - [model] - Warranty: [warranty] year(s)` if `p.warranty` is not undefined. Replace `[item]`, `[model]`, and `[warranty]` with the corresponding properties of `p`. + +```js +const testEl = document.createElement("div"); +const productObjects = [ + { id: "e1", type: "electronics", item: "Tablet", model: "Pixelon Slate-A9", warranty: 2, price: 349.99 }, + { id: "e2", type: "electronics", item: "Smartphone", model: "NovaCore X1-Alpha", warranty: 2, price: 699.99 }, + { id: "e3", type: "electronics", item: "Headphones", model: "EchoSphere Silent-7", price: 129.99 }, + { id: "e4", type: "electronics", item: "Laptop", model: "HexaBook Orion-15", warranty: 3, price: 1199.99 } + +] +productObjects.forEach(p => { + const renderedProduct = renderProduct(p); + testEl.innerHTML = renderedProduct; + const baseFormat = `Electronics: ${p.item} - ${p.model}`; + const expectedText = !p.warranty ? baseFormat : baseFormat + ` - Warranty: ${p.warranty} year(s)`; + assert.include(testEl.innerText, expectedText); + if (!p.warranty) { + assert.notInclude(testEl.innerText, `undefined`); + } +}) +``` + +When `renderProduct` is called with a `Clothing` product `p`, it should return a string containing an element with the text following either the format `Clothing: [item] by [brand]` or `Clothing: [item] by [brand] - Size [size]` if `p.size` is not undefined. Replace `[item]`, `[brand]`, and `[size]` with the corresponding properties of `p`. + +```js +const testEl = document.createElement("div"); +const productObjects = [ + { id: "c4", type: "clothing", item: "T-Shirt", brand: "Fabricon", price: 19.99 }, + { id: "c3", type: "clothing", item: "Jeans", brand: "BlueWeave", size: "L", price: 59.99 }, + { id: "c2", type: "clothing", item: "Hoodie", brand: "CozyForge", size: "S", price: 49.99 }, + { id: "c1", type: "clothing", item: "Jacket", brand: "Northloom", size: "M", price: 89.99 } + +] +productObjects.forEach(p => { + const renderedProduct = renderProduct(p); + testEl.innerHTML = renderedProduct; + const baseFormat = `Clothing: ${p.item} by ${p.brand}`; + const expectedText = !p.size ? baseFormat : baseFormat + ` - Size ${p.size}`; + assert.include(testEl.innerText, expectedText); + if (!p.size) { + assert.notInclude(testEl.innerText, `undefined`); + } +}) +``` + +When `renderProduct` is called with an invalid product, it should throw an error with the format `Unknown product type: [p]`. Replace `[p]` with the invalid product stringified with `JSON.stringify`. + +```js +const invalidProduct = { item: "T-Shirt", price: 19.99 }; +const expected = `Unknown product type: ${JSON.stringify(invalidProduct)}`; +try { + renderProduct(invalidProduct); + assert.fail(); +} catch({name, message}) { + if (name == "AssertionError") { + assert.fail("renderProduct should throw with invalid product"); + } else { + assert.equal(message, expected); + } +} +``` + +You should create a `Collection` instance and assign it to a variable named `products`. + +```js +assert.instanceOf(products, Collection); +const explorer = await __helpers.Explorer(code); +assert.isTrue(explorer.variables.products.value.typeArguments[0].matches("Product")); +``` + +Your `products` variable should contain at least one item for each product type. + +```js +assert.isTrue(products.items.some(p => p.type === "book")); +assert.isTrue(products.items.some(p => p.type === "electronics")); +assert.isTrue(products.items.some(p => p.type === "clothing")); +``` + +You should have a function named `showProducts`. + +```js +assert.isFunction(showProducts); +``` + +Clicking the `#all` button should display all the products in the `#output` element. + +```js +const len = products.items.length; +assert.isAtLeast(len, 3); +books.dispatchEvent(new Event("click")); +all.dispatchEvent(new Event("click")); +assert.lengthOf(output.querySelectorAll(".item"), len); +``` + +Clicking the `#books` button should display the corresponding set of products in the `#output` element. + +```js +const len = products.items.length; +assert.isAtLeast(len, 3); +let count = 0; +products.items.forEach(i => { + if (i.type === "book") { + count++; + } +}); +clothing.dispatchEvent(new Event("click")); +books.dispatchEvent(new Event("click")); +assert.lengthOf(output.querySelectorAll(".item"), count); +``` + +Clicking the `#electronics` button should display the corresponding set of products in the `#output` element. + +```js +const len = products.items.length; +assert.isAtLeast(len, 3); +let count = 0; +products.items.forEach(i => { + if (i.type === "electronics") { + count++; + } +}); +clothing.dispatchEvent(new Event("click")); +electronics.dispatchEvent(new Event("click")); +assert.lengthOf(output.querySelectorAll(".item"), count); +``` + +Clicking the `#clothing` button should display the corresponding set of products in the `#output` element. + +```js +const len = products.items.length; +assert.isAtLeast(len, 3); +let count = 0; +products.items.forEach(i => { + if (i.type === "clothing") { + count++; + } +}); +books.dispatchEvent(new Event("click")); +clothing.dispatchEvent(new Event("click")); +assert.lengthOf(output.querySelectorAll(".item"), count); +``` + +All products should be visible by default when the page loads. Use the `DOMContentLoaded` event to ensure it. + +```js +const len = products.items.length; +assert.isAtLeast(len, 3); +document.dispatchEvent(new Event("DOMContentLoaded")); +assert.lengthOf(output.querySelectorAll(".item"), len); +``` + +# --seed-- + +## --seed-contents-- + +```html + + + + + + Product Showcase + + + +

Product Showcase

+
+ + + + +
+
+ + + +``` + +```css +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background: linear-gradient(135deg, #0f4c75 0%, #3282b8 100%); + padding: 40px 20px; + margin: 0; + color: #1a1a1a; + min-height: 100vh; +} + +h1 { + text-align: center; + margin-bottom: 40px; + color: #ffffff; + font-size: 2.5rem; + font-weight: 600; + letter-spacing: -0.5px; +} + +.buttons { + display: flex; + justify-content: center; + gap: 12px; + margin-bottom: 40px; + flex-wrap: wrap; +} + +button { + padding: 12px 24px; + font-size: 15px; + font-weight: 500; + cursor: pointer; + border: none; + border-radius: 8px; + background: rgba(255, 255, 255, 0.9); + color: #333; + transition: all 0.3s ease-in-out; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +button:hover { + background: white; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +button.active { + background: #ff9800; + color: #1a1a1a; + box-shadow: 0 4px 15px rgba(255, 152, 0, 0.4); + font-weight: 600; +} + +#output { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 24px; + max-width: 1200px; + margin: 0 auto; +} + +.item { + background: white; + border-radius: 12px; + padding: 24px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease-in-out; + display: flex; + flex-direction: column; + gap: 12px; +} + +.item:hover { + transform: translateY(-8px); + box-shadow: 0 12px 25px rgba(0, 0, 0, 0.15); +} + +.item strong { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 1px; + color: #0f4c75; + font-weight: 700; +} + +.item > div:not(.price) { + font-size: 16px; + color: #555; + line-height: 1.5; +} + +.price { + margin-top: auto; + font-size: 24px; + font-weight: 700; + color: #0f4c75; + padding-top: 12px; + border-top: 2px solid #f0f0f0; +} + +@media (max-width: 768px) { + #output { + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 16px; + } + + h1 { + font-size: 2rem; + } + + .item { + padding: 16px; + } +} + +@media (max-width: 480px) { + #output { + grid-template-columns: 1fr; + } + + button { + padding: 10px 16px; + font-size: 14px; + } + + .price { + font-size: 20px; + } +} +``` + +```ts + +``` + +# --solutions-- + +```html + + + + + + Product Showcase + + + +

Product Showcase

+
+ + + + +
+
+ + + +``` + +```css +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background: linear-gradient(135deg, #0f4c75 0%, #3282b8 100%); + padding: 40px 20px; + margin: 0; + color: #1a1a1a; + min-height: 100vh; +} + +h1 { + text-align: center; + margin-bottom: 40px; + color: #ffffff; + font-size: 2.5rem; + font-weight: 600; + letter-spacing: -0.5px; +} + +.buttons { + display: flex; + justify-content: center; + gap: 12px; + margin-bottom: 40px; + flex-wrap: wrap; +} + +button { + padding: 12px 24px; + font-size: 15px; + font-weight: 500; + cursor: pointer; + border: none; + border-radius: 8px; + background: rgba(255, 255, 255, 0.9); + color: #333; + transition: all 0.3s ease-in-out; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +button:hover { + background: white; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +button.active { + background: #ff9800; + color: #1a1a1a; + box-shadow: 0 4px 15px rgba(255, 152, 0, 0.4); + font-weight: 600; +} + +#output { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 24px; + max-width: 1200px; + margin: 0 auto; +} + +.item { + background: white; + border-radius: 12px; + padding: 24px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease-in-out; + display: flex; + flex-direction: column; + gap: 12px; +} + +.item:hover { + transform: translateY(-8px); + box-shadow: 0 12px 25px rgba(0, 0, 0, 0.15); +} + +.item strong { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 1px; + color: #0f4c75; + font-weight: 700; +} + +.item > div:not(.price) { + font-size: 16px; + color: #555; + line-height: 1.5; +} + +.price { + margin-top: auto; + font-size: 24px; + font-weight: 700; + color: #0f4c75; + padding-top: 12px; + border-top: 2px solid #f0f0f0; +} + +@media (max-width: 768px) { + #output { + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 16px; + } + + h1 { + font-size: 2rem; + } + + .item { + padding: 16px; + } +} + +@media (max-width: 480px) { + #output { + grid-template-columns: 1fr; + } + + button { + padding: 10px 16px; + font-size: 14px; + } + + .price { + font-size: 20px; + } +} +``` + +```ts +interface Item { + type: "book" | "electronics" | "clothing"; + id: string; + price: number; +} + +interface Book extends Item { + type: "book"; + title: string; + author: string; +} + +interface Electronics extends Item { + type: "electronics"; + item: string; + model: string; + warranty?: number; +} + +interface Clothing extends Item { + type: "clothing"; + item: string; + brand: string; + size?: "S" | "M" | "L"; +} + +type Product = Book | Electronics | Clothing; + +class Collection { + items: T[]; + constructor(items: T[]) { + this.items = items; + } + + getAll(): T[] { + return this.items; + } + + filter(callback: (item: T) => boolean): T[] { + return this.items.filter(callback); + } +} + +function renderProduct(p: Product): string { + const createProductCard = (id: string, content: string, price: number): string => { + return ` +
+ ${content} +
$${price}
+
+ `; + }; + + if (p.type === "book") { + const content = `Book: ${p.title} by ${p.author}`; + return createProductCard(p.id, content, p.price); + } + + if (p.type === "electronics") { + const warranty = p.warranty ? ` - Warranty: ${p.warranty} year(s)` : ""; + const content = `Electronics: ${p.item} - ${p.model}${warranty}`; + return createProductCard(p.id, content, p.price); + } + + if (p.type === "clothing") { + const size = p.size ? ` - Size ${p.size}` : ""; + const content = `Clothing: ${p.item} by ${p.brand}${size}`; + return createProductCard(p.id, content, p.price); + } + + const _never: never = p; + throw new Error(`Unknown product type: ${JSON.stringify(p)}`); +} + + +const products = new Collection([ + { id: "c1", type: "clothing", item: "Jacket", brand: "Northloom", size: "M", price: 89.99 }, + { id: "e1", type: "electronics", item: "Tablet", model: "Pixelon Slate-A9", warranty: 2, price: 349.99 }, + { id: "b1", type: "book", title: "Dune", author: "Frank Herbert", price: 14.99 }, + + { id: "e2", type: "electronics", item: "Smartphone", model: "NovaCore X1-Alpha", warranty: 2, price: 699.99 }, + { id: "c2", type: "clothing", item: "Hoodie", brand: "CozyForge", size: "S", price: 49.99 }, + { id: "b2", type: "book", title: "1984", author: "George Orwell", price: 9.99 }, + + { id: "e3", type: "electronics", item: "Headphones", model: "EchoSphere Silent-7", price: 129.99 }, + { id: "c3", type: "clothing", item: "Jeans", brand: "BlueWeave", size: "L", price: 59.99 }, + { id: "e4", type: "electronics", item: "Laptop", model: "HexaBook Orion-15", warranty: 3, price: 1199.99 }, + + { id: "b3", type: "book", title: "Brave New World", author: "Aldous Huxley", price: 11.99 }, + { id: "c4", type: "clothing", item: "T-Shirt", brand: "Fabricon", price: 19.99 }, + { id: "e5", type: "electronics", item: "Smartwatch", model: "Chronex Pulse-Q", warranty: 1, price: 199.99 }, + + { id: "b4", type: "book", title: "The Lord of the Rings", author: "J.R.R. Tolkien", price: 24.99 }, +]); + +const output = document.getElementById("output"); + +// Track currently active filter category (or undefined for all) +let currentFilter: Product["type"] | undefined = undefined; + +function showProducts(filter?: Product["type"]): void { + if (!output) return; + + let itemsToShow: Product[]; + + // Toggle logic + if (currentFilter === filter || filter === undefined) { + // Reset to all if same button clicked again or filter is undefined + itemsToShow = products.getAll(); + currentFilter = undefined; + setActiveButton(undefined); + } else { + itemsToShow = products.filter((p: Product): boolean => p.type === filter); + currentFilter = filter; + setActiveButton(filter); + } + + output.innerHTML = itemsToShow.map(renderProduct).join(""); +} + +// Active Button Highlighting +function setActiveButton(filter: Product["type"] | undefined): void { + const buttons: { [key: string]: HTMLElement | null } = { + all: document.getElementById("all"), + book: document.getElementById("books"), + electronics: document.getElementById("electronics"), + clothing: document.getElementById("clothing") + }; + + for (const key in buttons) { + buttons[key]?.classList.remove("active"); + } + + if (filter === undefined) { + buttons["all"]?.classList.add("active"); + } else { + buttons[filter]?.classList.add("active"); + } +} + +// Button Event Handlers +document.getElementById("all")?.addEventListener("click", (): void => showProducts()); +document.getElementById("books")?.addEventListener("click", (): void => showProducts("book")); +document.getElementById("electronics")?.addEventListener("click", (): void => showProducts("electronics")); +document.getElementById("clothing")?.addEventListener("click", (): void => showProducts("clothing")); + +// Initial Render +document.addEventListener("DOMContentLoaded", (): void => showProducts()); +``` diff --git a/curriculum/structure/blocks/lab-product-showcase.json b/curriculum/structure/blocks/lab-product-showcase.json new file mode 100644 index 00000000000..a269cde668b --- /dev/null +++ b/curriculum/structure/blocks/lab-product-showcase.json @@ -0,0 +1,11 @@ +{ + "isUpcomingChange": true, + "dashedName": "lab-product-showcase", + "helpCategory": "JavaScript", + "blockLayout": "link", + "challengeOrder": [ + { "id": "696920c0c98a1ed58eb86293", "title": "Build a Product Showcase" } + ], + "blockLabel": "lab", + "usesMultifileEditor": true +} diff --git a/curriculum/structure/superblocks/front-end-development-libraries-v9.json b/curriculum/structure/superblocks/front-end-development-libraries-v9.json index 0a4a352b334..a0e3be4c5ce 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", "lecture-working-with-generics-and-type-narrowing", + "lab-product-showcase", "workshop-bug-emoji-picker", "lecture-working-with-typescript-configuration-files", "review-typescript",