feat(curriculum): add product showcase lab (#65456)

Co-authored-by: Kolade Chris <65571316+Ksound22@users.noreply.github.com>
Co-authored-by: Kolade <chrisjay967@gmail.com>
This commit is contained in:
Dario
2026-04-14 11:01:08 +02:00
committed by GitHub
parent cf920c156c
commit a60ccdb293
4 changed files with 972 additions and 0 deletions
+6
View File
@@ -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": [
@@ -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<Product>` 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<Product>` 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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Product Showcase</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<h1>Product Showcase</h1>
<div class="buttons">
<button id="all">All</button>
<button id="books">Books</button>
<button id="electronics">Electronics</button>
<button id="clothing">Clothing</button>
</div>
<div id="output" class="product-list"></div>
<script src="index.ts"></script>
</body>
</html>
```
```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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Product Showcase</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<h1>Product Showcase</h1>
<div class="buttons">
<button id="all">All</button>
<button id="books">Books</button>
<button id="electronics">Electronics</button>
<button id="clothing">Clothing</button>
</div>
<div id="output" class="product-list"></div>
<script src="index.ts"></script>
</body>
</html>
```
```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<T> {
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 `
<div class="item" id="${id}">
${content}
<div class="price">$${price}</div>
</div>
`;
};
if (p.type === "book") {
const content = `<strong>Book:</strong> ${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 = `<strong>Electronics:</strong> ${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 = `<strong>Clothing:</strong> ${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<Product>([
{ 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());
```
@@ -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
}
@@ -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",