mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
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:
@@ -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": [
|
||||
|
||||
+954
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user