Compare commits

..

3 Commits

Author SHA1 Message Date
Tim Rijkse
5755d43cfc fix: add action list 2026-01-16 09:23:37 +01:00
Tim Rijkse
337f5dbf5b fix: add book details 2026-01-16 08:51:28 +01:00
Tim Rijkse
7925172039 fix: externalise icons 2026-01-16 08:20:19 +01:00
24 changed files with 1721 additions and 385 deletions

406
book.html
View File

@@ -5,334 +5,134 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta <meta
name="description" name="description"
content="The Midnight Library - Between life and death there is a library" content="Milinda - Discover and buy your next favorite book"
/> />
<title>The Midnight Library - BookStore</title> <title>Milinda - Home</title>
<!-- Fonts are loaded via @font-face in styles.css --> <!-- Fonts are loaded via @font-face in styles.css -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="css/styles.css" /> <link rel="stylesheet" href="css/styles.css" />
</head> </head>
<body> <body>
<div class="mobile-container"> <div class="mobile-container">
<site-header> <site-header>
<top-bar> <top-bar slot="top-bar">
<button slot="menu-button" class="icon-button" aria-label="Menu"> <button slot="menu-button" class="icon-button" aria-label="Menu">
<svg <menu-icon></menu-icon>
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="#ffffff"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="4" x2="20" y1="12" y2="12"></line>
<line x1="4" x2="20" y1="6" y2="6"></line>
<line x1="4" x2="20" y1="18" y2="18"></line>
</svg>
</button> </button>
<a slot="logo" href="index.html" class="logo">BookStore</a> <a slot="logo" href="index.html" class="logo">Milinda</a>
<div slot="actions" class="actions"> <div slot="actions" class="actions">
<button class="icon-button" aria-label="Profile"> <button class="icon-button" aria-label="Profile">
<svg <user-icon></user-icon>
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="#ffffff"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="8" r="5"></circle>
<path d="M20 21a8 8 0 0 0-16 0"></path>
</svg>
</button> </button>
<button class="icon-button" aria-label="Shopping basket"> <button class="icon-button" aria-label="Shopping basket">
<svg <shopping-bag-icon></shopping-bag-icon>
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="#ffffff"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"
></path>
<path d="M3 6h18"></path>
<path d="M16 10a4 4 0 0 1-8 0"></path>
</svg>
</button> </button>
</div> </div>
</top-bar> </top-bar>
<horizontal-scroll-nav></horizontal-scroll-nav> <horizontal-scroll-nav slot="nav"></horizontal-scroll-nav>
<search-bar></search-bar> <search-bar slot="search"></search-bar>
</site-header> </site-header>
<site-content> <site-content>
<div class="book-detail"> <section class="section">
<!-- Back navigation --> <book-details
<a href="index.html" class="back-link"> title="Zen is opendoen"
<svg author="Ayya Khema"
xmlns="http://www.w3.org/2000/svg" author-href="#"
fill="none" price="€ 24,95"
viewBox="0 0 24 24" isbn="9789056703691"
stroke-width="2" format="Paperback"
stroke="currentColor" delivery="Direct leverbaar"
width="20" categories="Zen|#,Integrale spiritualiteit|#"
height="20" ebook-available
> ></book-details>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 19.5 8.25 12l7.5-7.5"
/>
</svg>
Back to Books
</a>
<!-- Book Cover --> <action-links-list>
<div class="book-cover"> <icon-link-button icon="wishlist" href="#">
<div class="book-cover-placeholder"> Voeg toe aan verlanglijstje
<svg </icon-link-button>
xmlns="http://www.w3.org/2000/svg" <icon-link-button icon="review" href="#">
fill="none" Schrijf een recensie
viewBox="0 0 24 24" </icon-link-button>
stroke-width="1" </action-links-list>
stroke="currentColor" </section>
>
<path <div class="content-padding">
stroke-linecap="round" <push-box>
stroke-linejoin="round" <h2 slot="title">
d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" Gespecialiseerd op het vlak van boeddhisme en aanverwante
/> Oost-West thema's
</svg> </h2>
<div slot="cta" class="cta-buttons">
<arrow-button href="#">Klantenservice</arrow-button>
<arrow-button href="#">Neem contact op</arrow-button>
</div> </div>
</div> </push-box>
</div>
<!-- Book Info --> <div class="content-padding">
<div class="book-info"> <newsletter-signup
<h1 class="book-title">The Midnight Library</h1> title="Blijf op de hoogte"
<p class="book-author">by Matt Haig</p> description="Schrijf je in voor onze nieuwsbrief en ontvang het laatste nieuws over nieuwe boeken en aanbiedingen."
button-text="Inschrijven"
<div class="book-rating"> placeholder="Je e-mailadres"
<div class="stars"> ></newsletter-signup>
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1"
>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
</div>
<span class="rating-text">4.5 (2,847 reviews)</span>
</div>
<div class="book-meta">
<div class="meta-item">
<span class="meta-label">Format</span>
<span class="meta-value">Hardcover</span>
</div>
<div class="meta-item">
<span class="meta-label">Pages</span>
<span class="meta-value">304</span>
</div>
<div class="meta-item">
<span class="meta-label">Language</span>
<span class="meta-value">English</span>
</div>
</div>
</div>
<!-- Price & Purchase -->
<div class="purchase-section">
<div class="price-container">
<span class="current-price">$14.99</span>
<span class="original-price">$24.99</span>
<span class="discount-badge">40% OFF</span>
</div>
<div class="purchase-actions">
<button class="btn btn-primary btn-large">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
width="20"
height="20"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"
/>
</svg>
Add to Cart
</button>
<button class="btn btn-secondary">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
width="20"
height="20"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z"
/>
</svg>
</button>
</div>
</div>
<!-- Description -->
<div class="book-description">
<h2 class="section-heading">About this book</h2>
<p>
Between life and death there is a library, and within that
library, the shelves go on forever. Every book provides a chance
to try another life you could have lived. To see how things would
be if you had made other choices... Would you have done anything
different, if you had the chance to undo your regrets?
</p>
<p>
A dazzling novel about all the choices that go into a life well
lived, from the internationally bestselling author of Reasons to
Stay Alive and How to Stop Time.
</p>
</div>
<!-- Reviews -->
<div class="reviews-section">
<h2 class="section-heading">Customer Reviews</h2>
<div class="review-card">
<div class="review-header">
<div class="reviewer-info">
<span class="reviewer-name">Sarah M.</span>
<span class="review-date">December 2025</span>
</div>
<div class="review-stars">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
</div>
</div>
<p class="review-text">
This book changed my perspective on life. Beautifully written
and deeply thought-provoking. A must-read for anyone going
through a difficult time.
</p>
</div>
<div class="review-card">
<div class="review-header">
<div class="reviewer-info">
<span class="reviewer-name">James K.</span>
<span class="review-date">November 2025</span>
</div>
<div class="review-stars">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1"
>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
</div>
</div>
<p class="review-text">
Great concept and well-executed. The writing flows effortlessly
and keeps you engaged throughout. Highly recommend!
</p>
</div>
<button class="btn btn-outline btn-full">View All Reviews</button>
</div>
</div> </div>
</site-content> </site-content>
<site-footer></site-footer> <site-footer>
<span slot="logo">MILINDA uitgevers</span>
<footer-accordion-item slot="accordion" title="Service & bestellen">
<div class="accordion-links">
<a href="#">Bestellen</a>
<a href="#">Verzending</a>
<a href="#">Retourneren</a>
<a href="#">Betaalmethoden</a>
</div>
</footer-accordion-item>
<footer-accordion-item slot="accordion" title="Over MILINDA uitgevers">
<div class="accordion-links">
<a href="#">Onze geschiedenis</a>
<a href="#">Ons team</a>
<a href="#">Vacatures</a>
</div>
</footer-accordion-item>
<footer-accordion-item slot="accordion" title="Populaire categorieën">
<div class="accordion-links">
<a href="#">Boeddhisme</a>
<a href="#">Meditatie</a>
<a href="#">Mindfulness</a>
<a href="#">Filosofie</a>
</div>
</footer-accordion-item>
<footer-accordion-item slot="accordion" title="Accessibility">
<div class="accordion-links">
<a href="#">Toegankelijkheidsverklaring</a>
<a href="#">Hulpmiddelen</a>
</div>
</footer-accordion-item>
<div slot="links-left" class="footer-bottom-links">
<a href="#">Klantenservice</a>
<a href="#">Uitgeverij</a>
<a href="#">Neem contact op</a>
</div>
<div slot="links-right" class="footer-bottom-links">
<a href="#">Privacyverklaring</a>
<a href="#">Algemene voorwaarden</a>
<a href="#">Toegankelijkheidsverklaring</a>
</div>
</site-footer>
</div> </div>
<script type="module" src="js/app.js"></script> <script type="module" src="js/app.js"></script>

View File

@@ -22,59 +22,15 @@
<site-header> <site-header>
<top-bar slot="top-bar"> <top-bar slot="top-bar">
<button slot="menu-button" class="icon-button" aria-label="Menu"> <button slot="menu-button" class="icon-button" aria-label="Menu">
<svg <menu-icon></menu-icon>
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="#ffffff"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="4" x2="20" y1="12" y2="12"></line>
<line x1="4" x2="20" y1="6" y2="6"></line>
<line x1="4" x2="20" y1="18" y2="18"></line>
</svg>
</button> </button>
<a slot="logo" href="index.html" class="logo">Milinda</a> <a slot="logo" href="index.html" class="logo">Milinda</a>
<div slot="actions" class="actions"> <div slot="actions" class="actions">
<button class="icon-button" aria-label="Profile"> <button class="icon-button" aria-label="Profile">
<svg <user-icon></user-icon>
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="#ffffff"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 20a6 6 0 0 0-12 0" />
<circle cx="12" cy="10" r="4" />
<circle cx="12" cy="12" r="10" />
</svg>
</button> </button>
<button class="icon-button" aria-label="Shopping basket"> <button class="icon-button" aria-label="Shopping basket">
<svg <shopping-bag-icon></shopping-bag-icon>
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="#ffffff"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="8" cy="21" r="1" />
<circle cx="19" cy="21" r="1" />
<path
d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"
/>
</svg>
</button> </button>
</div> </div>
</top-bar> </top-bar>

View File

@@ -3,6 +3,9 @@
* Imports and registers all web components * Imports and registers all web components
*/ */
// Import cart store (must be first to set up window.cartStore)
import cart from "./store/cart.js";
// Import all components // Import all components
import "./components/site-header.js"; import "./components/site-header.js";
import "./components/top-bar.js"; import "./components/top-bar.js";
@@ -19,8 +22,70 @@ import "./components/add-to-cart-button.js";
import "./components/cta-button.js"; import "./components/cta-button.js";
import "./components/category-card.js"; import "./components/category-card.js";
import "./components/newsletter-signup.js"; import "./components/newsletter-signup.js";
import "./components/book-details.js";
import "./components/icon-cta-button.js";
import "./components/icon-link-button.js";
import "./components/action-links-list.js";
// App initialization (if needed) // Import icon components
import "./icons/menu-icon.js";
import "./icons/user-icon.js";
import "./icons/shopping-bag-icon.js";
import "./icons/arrow-circle-right-icon.js";
import "./icons/book-open-icon.js";
import "./icons/clipboard-icon.js";
import "./icons/chevron-down-icon.js";
// App initialization
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
console.log("BookStore app initialized"); console.log("BookStore app initialized");
// Initialize cart badge on page load
const count = cart.getItemCount();
if (count > 0) {
window.dispatchEvent(
new CustomEvent("cart-updated", {
detail: {
items: cart.getItems(),
count: count,
total: cart.getTotal(),
},
})
);
}
// Listen for add-to-cart events from book-card and book-details components
document.addEventListener("add-to-cart", (event) => {
const { title, author, price, type, image } = event.detail || {};
if (title) {
cart.addItem({
title,
author: author || "",
price: price || "€ 0,00",
type: type || "physical",
image: image || "",
});
// Optional: Show feedback to user
console.log(`Added "${title}" to cart`);
}
});
// Listen for buy-ebook events from book-details component
document.addEventListener("buy-ebook", (event) => {
const { title } = event.detail || {};
if (title) {
cart.addItem({
title,
author: "",
price: "€ 0,00", // eBook price would come from component
type: "ebook",
image: "",
});
console.log(`Added eBook "${title}" to cart`);
}
});
}); });

View File

@@ -0,0 +1,36 @@
/**
* Action Links List Component
* A container for icon link buttons with dividers
*/
class ActionLinksList extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
}
.action-links-list {
display: flex;
flex-direction: column;
margin-top: var(--spacing-md, 1rem);
gap: var(--spacing-md, 1rem);
}
</style>
<div class="action-links-list">
<slot></slot>
</div>
`;
}
}
customElements.define("action-links-list", ActionLinksList);

View File

@@ -54,13 +54,6 @@ class ArrowButton extends HTMLElement {
flex-shrink: 0; flex-shrink: 0;
} }
.arrow-icon svg {
width: 24px;
height: 24px;
stroke: currentColor;
fill: none;
}
.button-text { .button-text {
text-decoration: underline; text-decoration: underline;
text-underline-offset: 3px; text-underline-offset: 3px;
@@ -68,11 +61,7 @@ class ArrowButton extends HTMLElement {
</style> </style>
<a class="arrow-button" href="${this.href}"> <a class="arrow-button" href="${this.href}">
<span class="arrow-icon"> <span class="arrow-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <arrow-circle-right-icon size="24"></arrow-circle-right-icon>
<circle cx="12" cy="12" r="10"/>
<path d="m12 16 4-4-4-4"/>
<path d="M8 12h8"/>
</svg>
</span> </span>
<span class="button-text"> <span class="button-text">
<slot></slot> <slot></slot>

View File

@@ -93,9 +93,7 @@ class BookCard extends HTMLElement {
const imageHtml = this.image const imageHtml = this.image
? `<img src="${this.image}" alt="${this.title}" class="book-image">` ? `<img src="${this.image}" alt="${this.title}" class="book-image">`
: `<div class="book-image placeholder"> : `<div class="book-image placeholder">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"> <book-open-icon size="48"></book-open-icon>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
</svg>
</div>`; </div>`;
this.shadowRoot.innerHTML = ` this.shadowRoot.innerHTML = `
@@ -164,11 +162,6 @@ class BookCard extends HTMLElement {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: var(--color-text-light, #64748b); color: var(--color-text-light, #64748b);
}
.book-image.placeholder svg {
width: 48px;
height: 48px;
opacity: 0.5; opacity: 0.5;
} }

View File

@@ -0,0 +1,409 @@
/**
* Book Details Component
* Displays book information with cover, metadata, and purchase buttons
*/
class BookDetails extends HTMLElement {
static get observedAttributes() {
return [
"title",
"author",
"author-href",
"price",
"image",
"isbn",
"format",
"delivery",
"categories",
"ebook-available",
];
}
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
this.setupEventListeners();
}
attributeChangedCallback() {
if (this.shadowRoot) {
this.render();
this.setupEventListeners();
}
}
setupEventListeners() {
const addToCartBtn = this.shadowRoot?.querySelector(".btn-cart");
const ebookBtn = this.shadowRoot?.querySelector(".btn-ebook");
const favoriteBtn = this.shadowRoot?.querySelector(".favorite-btn");
if (addToCartBtn) {
addToCartBtn.addEventListener("button-click", () => {
this.dispatchEvent(
new CustomEvent("add-to-cart", {
bubbles: true,
composed: true,
detail: {
title: this.bookTitle,
price: this.price,
type: "physical",
},
})
);
});
}
if (ebookBtn) {
ebookBtn.addEventListener("button-click", () => {
this.dispatchEvent(
new CustomEvent("buy-ebook", {
bubbles: true,
composed: true,
detail: {
title: this.bookTitle,
type: "ebook",
},
})
);
});
}
if (favoriteBtn) {
favoriteBtn.addEventListener("click", () => {
favoriteBtn.classList.toggle("active");
this.dispatchEvent(
new CustomEvent("toggle-favorite", {
bubbles: true,
composed: true,
detail: {
title: this.bookTitle,
},
})
);
});
}
}
get bookTitle() {
return this.getAttribute("title") || "Book Title";
}
get author() {
return this.getAttribute("author") || "Author Name";
}
get authorHref() {
return this.getAttribute("author-href") || "#";
}
get price() {
return this.getAttribute("price") || "€ 0,00";
}
get image() {
return this.getAttribute("image") || "";
}
get isbn() {
return this.getAttribute("isbn") || "";
}
get format() {
return this.getAttribute("format") || "Paperback";
}
get delivery() {
return this.getAttribute("delivery") || "Direct leverbaar";
}
get categories() {
return this.getAttribute("categories") || "";
}
get ebookAvailable() {
return this.hasAttribute("ebook-available");
}
renderCategories() {
if (!this.categories) return "";
// Categories are comma-separated, with optional href in format: "Name|href,Name2|href2"
const cats = this.categories.split(",").map((cat) => {
const [name, href] = cat.trim().split("|");
if (href) {
return `<a href="${href}" class="category-link">${name}</a>`;
}
return `<span class="category-text">${name}</span>`;
});
return cats.join('<span class="category-separator"> / </span>');
}
render() {
const imageHtml = this.image
? `<img src="${this.image}" alt="${this.bookTitle}" class="book-cover">`
: `<div class="book-cover placeholder">
<book-open-icon size="48" color="var(--color-text-light)"></book-open-icon>
</div>`;
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
font-family: var(--font-family-outfit, "Outfit", sans-serif);
}
.book-details {
display: flex;
flex-direction: column;
gap: var(--spacing-md, 1rem);
}
/* Header Section */
.header {
display: flex;
flex-direction: column;
gap: var(--spacing-sm, 0.5rem);
}
.title-row {
display: flex;
align-items: center;
gap: var(--spacing-sm, 0.5rem);
}
.title {
font-family: var(--font-family-outfit, "Outfit", sans-serif);
font-size: 24px;
font-weight: 700;
line-height: 34px;
color: var(--color-text, #1e293b);
margin: 0;
}
.favorite-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: none;
border: none;
cursor: pointer;
padding: 0;
flex-shrink: 0;
}
.favorite-btn svg {
width: 28px;
height: 28px;
transition: transform var(--transition-fast, 150ms ease);
}
.favorite-btn:hover svg {
transform: scale(1.1);
}
.favorite-btn.active svg {
fill: #f59e0b;
}
.author-link {
font-family: var(--font-family-outfit, "Outfit", sans-serif);
font-size: 16px;
font-weight: 300;
line-height: 24px;
color: var(--color-purple, #951d51);
text-decoration: underline;
}
.author-link:hover {
opacity: 0.8;
}
.price {
font-family: var(--font-family-outfit, "Outfit", sans-serif);
font-size: 16px;
font-weight: 700;
line-height: 24px;
color: var(--color-text, #1e293b);
margin: 0;
}
/* Content Section */
.content {
display: flex;
gap: var(--spacing-md, 1rem);
}
.cover-container {
flex-shrink: 0;
width: 110px;
height: 177px;
}
.book-cover {
width: 110px;
height: 177px;
object-fit: cover;
border-radius: var(--radius-sm, 0.25rem);
}
.book-cover.placeholder {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-background-tertiary, #f1f5f9);
border-radius: var(--radius-sm, 0.25rem);
}
/* Details Grid */
.details {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--spacing-sm, 0.5rem);
}
.detail-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-sm, 0.5rem);
}
.detail-item {
display: flex;
flex-direction: column;
gap: 0;
}
.detail-item.full-width {
grid-column: 1 / -1;
}
.detail-label {
font-family: var(--font-family-outfit, "Outfit", sans-serif);
font-size: 12px;
font-weight: 200;
line-height: 24px;
color: var(--color-purple, #951d51);
}
.detail-value {
font-family: var(--font-family-outfit, "Outfit", sans-serif);
font-size: 16px;
font-weight: 300;
line-height: 24px;
color: var(--color-text, #1e293b);
}
.category-link {
color: var(--color-text, #1e293b);
text-decoration: underline;
}
.category-link:hover {
opacity: 0.8;
}
.category-text {
color: var(--color-text, #1e293b);
}
.category-separator {
color: var(--color-text, #1e293b);
}
/* Buttons Section */
.buttons {
display: flex;
flex-direction: column;
gap: var(--spacing-sm, 0.5rem);
}
</style>
<div class="book-details">
<!-- Header -->
<div class="header">
<div class="title-row">
<h1 class="title">${this.bookTitle}</h1>
<button class="favorite-btn" aria-label="Add to favorites">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
</button>
</div>
<a href="${this.authorHref}" class="author-link">${this.author}</a>
<p class="price">${this.price}</p>
</div>
<!-- Content: Cover + Details -->
<div class="content">
<div class="cover-container">
${imageHtml}
</div>
<div class="details">
${
this.categories
? `
<div class="detail-item full-width">
<span class="detail-label">Categorieën</span>
<span class="detail-value">${this.renderCategories()}</span>
</div>
`
: ""
}
<div class="detail-row">
${
this.isbn
? `
<div class="detail-item">
<span class="detail-label">ISBN</span>
<span class="detail-value">${this.isbn}</span>
</div>
`
: ""
}
<div class="detail-item">
<span class="detail-label">Uitvoering</span>
<span class="detail-value">${this.format}</span>
</div>
</div>
<div class="detail-row">
<div class="detail-item">
<span class="detail-label">Uitvoering</span>
<span class="detail-value">${this.format}</span>
</div>
<div class="detail-item">
<span class="detail-label">Levertijd</span>
<span class="detail-value">${this.delivery}</span>
</div>
</div>
</div>
</div>
<!-- Buttons -->
<div class="buttons">
<icon-cta-button class="btn-cart" icon="cart" variant="primary">
In winkelwagen
</icon-cta-button>
${
this.ebookAvailable
? `
<icon-cta-button class="btn-ebook" icon="ebook" variant="secondary">
Koop eBook
</icon-cta-button>
`
: ""
}
</div>
</div>
`;
}
}
customElements.define("book-details", BookDetails);

View File

@@ -39,9 +39,7 @@ class CategoryCard extends HTMLElement {
const iconHtml = this.icon const iconHtml = this.icon
? `<img src="${this.icon}" alt="${this.title}" class="category-icon">` ? `<img src="${this.icon}" alt="${this.title}" class="category-icon">`
: `<div class="category-icon placeholder"> : `<div class="category-icon placeholder">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"> <clipboard-icon size="32"></clipboard-icon>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" />
</svg>
</div>`; </div>`;
this.shadowRoot.innerHTML = ` this.shadowRoot.innerHTML = `
@@ -84,11 +82,6 @@ class CategoryCard extends HTMLElement {
background-color: var(--color-background-tertiary, #f1f5f9); background-color: var(--color-background-tertiary, #f1f5f9);
border-radius: var(--radius-md, 0.5rem); border-radius: var(--radius-md, 0.5rem);
color: var(--color-text-light, #64748b); color: var(--color-text-light, #64748b);
}
.category-icon.placeholder svg {
width: 32px;
height: 32px;
opacity: 0.5; opacity: 0.5;
} }

View File

@@ -74,8 +74,7 @@ class FooterAccordionItem extends HTMLElement {
} }
.accordion-icon { .accordion-icon {
width: 24px; display: inline-flex;
height: 24px;
color: var(--color-text-inverse, #ffffff); color: var(--color-text-inverse, #ffffff);
transition: transform var(--transition-fast, 150ms ease); transition: transform var(--transition-fast, 150ms ease);
} }
@@ -101,9 +100,7 @@ class FooterAccordionItem extends HTMLElement {
</style> </style>
<div class="accordion-header"> <div class="accordion-header">
<h3 class="accordion-title">${title}</h3> <h3 class="accordion-title">${title}</h3>
<svg class="accordion-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <chevron-down-icon class="accordion-icon" size="24"></chevron-down-icon>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</div> </div>
<div class="accordion-content"> <div class="accordion-content">
<slot></slot> <slot></slot>

View File

@@ -0,0 +1,152 @@
/**
* Icon CTA Button Component
* Full-width button with icon and text, supports primary (purple) and secondary (gray) variants
* Extends the CTA button pattern with icon support
*/
import { cartIcon } from "../icons/cart-icon.js";
import { ebookIcon } from "../icons/ebook-icon.js";
class IconCtaButton extends HTMLElement {
static get observedAttributes() {
return ["href", "variant", "icon"];
}
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
this.setupEventListeners();
}
attributeChangedCallback() {
if (this.shadowRoot) {
this.render();
this.setupEventListeners();
}
}
setupEventListeners() {
const button = this.shadowRoot?.querySelector(".icon-cta-button");
if (button && !this.hasAttribute("href")) {
button.addEventListener("click", () => {
this.dispatchEvent(
new CustomEvent("button-click", {
bubbles: true,
composed: true,
})
);
});
}
}
get href() {
return this.getAttribute("href") || "";
}
get variant() {
return this.getAttribute("variant") || "primary"; // 'primary' or 'secondary'
}
get icon() {
return this.getAttribute("icon") || ""; // 'cart', 'ebook', or empty
}
getIconHtml() {
const isPrimary = this.variant === "primary";
const iconColor = isPrimary ? "#ffffff" : "currentColor";
switch (this.icon) {
case "cart":
return cartIcon({ size: 20, color: iconColor });
case "ebook":
return ebookIcon({ size: 20, color: iconColor });
default:
return "";
}
}
render() {
const isLink = this.hasAttribute("href") && this.href;
const tag = isLink ? "a" : "button";
const hrefAttr = isLink ? `href="${this.href}"` : "";
const typeAttr = isLink ? "" : 'type="button"';
const isPrimary = this.variant === "primary";
const iconHtml = this.getIconHtml();
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
width: 100%;
}
.icon-cta-button {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm, 0.5rem);
width: 100%;
padding: 14px var(--spacing-md, 1rem);
border: none;
border-radius: var(--radius-sm, 0.25rem);
font-family: var(--font-family-outfit, "Outfit", sans-serif);
font-size: 16px;
font-weight: 400;
line-height: 24px;
text-decoration: none;
cursor: pointer;
transition: opacity var(--transition-fast, 150ms ease);
}
.icon-cta-button:hover {
opacity: 0.9;
}
.icon-cta-button:active {
opacity: 0.8;
}
.icon-cta-button.primary {
background-color: var(--color-purple, #951d51);
color: var(--color-text-inverse, #ffffff);
}
.icon-cta-button.secondary {
background-color: var(--color-push-box-bg, #EBEEF4);
color: var(--color-text, #1e293b);
}
.icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.icon svg {
width: 20px;
height: 20px;
}
.button-text {
text-decoration: none;
}
</style>
<${tag}
class="icon-cta-button ${isPrimary ? "primary" : "secondary"}"
${hrefAttr}
${typeAttr}
>
${iconHtml ? `<span class="icon">${iconHtml}</span>` : ""}
<span class="button-text">
<slot></slot>
</span>
</${tag}>
`;
}
}
customElements.define("icon-cta-button", IconCtaButton);

View File

@@ -0,0 +1,130 @@
/**
* Icon Link Button Component
* A text link with an icon on the left
* Used for actions like "Add to wishlist", "Write a review"
*/
import { wishlistIcon } from "../icons/wishlist-icon.js";
import { reviewIcon } from "../icons/review-icon.js";
class IconLinkButton extends HTMLElement {
static get observedAttributes() {
return ["href", "icon"];
}
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
this.setupEventListeners();
}
attributeChangedCallback() {
if (this.shadowRoot) {
this.render();
this.setupEventListeners();
}
}
setupEventListeners() {
const button = this.shadowRoot?.querySelector(".icon-link-button");
if (button && !this.hasAttribute("href")) {
button.addEventListener("click", () => {
this.dispatchEvent(
new CustomEvent("link-click", {
bubbles: true,
composed: true,
})
);
});
}
}
get href() {
return this.getAttribute("href") || "";
}
get icon() {
return this.getAttribute("icon") || "";
}
getIconHtml() {
switch (this.icon) {
case "wishlist":
return wishlistIcon({ size: 24, color: "currentColor" });
case "review":
return reviewIcon({ size: 24, color: "currentColor" });
default:
return "";
}
}
render() {
const isLink = this.hasAttribute("href") && this.href;
const tag = isLink ? "a" : "button";
const hrefAttr = isLink ? `href="${this.href}"` : "";
const typeAttr = isLink ? "" : 'type="button"';
const iconHtml = this.getIconHtml();
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
}
.icon-link-button {
display: flex;
align-items: center;
gap: var(--spacing-md, 1rem);
background: none;
border: none;
font-family: var(--font-family-outfit, "Outfit", sans-serif);
font-size: 16px;
font-weight: 400;
line-height: 24px;
color: var(--color-text, #1e293b);
text-decoration: none;
cursor: pointer;
transition: opacity var(--transition-fast, 150ms ease);
}
.icon-link-button:hover {
opacity: 0.7;
}
.icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 24px;
height: 24px;
}
.icon svg {
width: 24px;
height: 24px;
}
.button-text {
text-decoration: underline;
text-underline-offset: 3px;
}
</style>
<${tag}
class="icon-link-button"
${hrefAttr}
${typeAttr}
>
${iconHtml ? `<span class="icon">${iconHtml}</span>` : ""}
<span class="button-text">
<slot></slot>
</span>
</${tag}>
`;
}
}
customElements.define("icon-link-button", IconLinkButton);

View File

@@ -0,0 +1,66 @@
/**
* Arrow Circle Right Icon Web Component
* A circular arrow pointing right
*/
class ArrowCircleRightIcon extends HTMLElement {
static get observedAttributes() {
return ["size", "color", "stroke-width"];
}
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
this.render();
}
get size() {
return this.getAttribute("size") || "24";
}
get color() {
return this.getAttribute("color") || "currentColor";
}
get strokeWidth() {
return this.getAttribute("stroke-width") || "2";
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: inline-flex;
align-items: center;
justify-content: center;
}
svg {
display: block;
}
</style>
<svg
xmlns="http://www.w3.org/2000/svg"
width="${this.size}"
height="${this.size}"
viewBox="0 0 24 24"
fill="none"
stroke="${this.color}"
stroke-width="${this.strokeWidth}"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10"></circle>
<path d="m12 16 4-4-4-4"></path>
<path d="M8 12h8"></path>
</svg>
`;
}
}
customElements.define("arrow-circle-right-icon", ArrowCircleRightIcon);

View File

@@ -0,0 +1,64 @@
/**
* Book Open Icon Web Component
* An open book icon
*/
class BookOpenIcon extends HTMLElement {
static get observedAttributes() {
return ["size", "color", "stroke-width"];
}
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
this.render();
}
get size() {
return this.getAttribute("size") || "48";
}
get color() {
return this.getAttribute("color") || "currentColor";
}
get strokeWidth() {
return this.getAttribute("stroke-width") || "1";
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: inline-flex;
align-items: center;
justify-content: center;
}
svg {
display: block;
}
</style>
<svg
xmlns="http://www.w3.org/2000/svg"
width="${this.size}"
height="${this.size}"
viewBox="0 0 24 24"
fill="none"
stroke="${this.color}"
stroke-width="${this.strokeWidth}"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25"></path>
</svg>
`;
}
}
customElements.define("book-open-icon", BookOpenIcon);

31
js/icons/cart-icon.js Normal file
View File

@@ -0,0 +1,31 @@
/**
* Cart Icon (Lucide style shopping cart)
* @param {Object} props - Icon properties
* @param {number} props.size - Icon size (default: 20)
* @param {string} props.color - Icon color (default: currentColor)
* @param {number} props.strokeWidth - Stroke width (default: 2)
* @returns {string} SVG string
*/
export function cartIcon({
size = 20,
color = "currentColor",
strokeWidth = 2,
} = {}) {
return `
<svg
xmlns="http://www.w3.org/2000/svg"
width="${size}"
height="${size}"
viewBox="0 0 24 24"
fill="none"
stroke="${color}"
stroke-width="${strokeWidth}"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="8" cy="21" r="1"/>
<circle cx="19" cy="21" r="1"/>
<path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"/>
</svg>
`;
}

View File

@@ -0,0 +1,64 @@
/**
* Chevron Down Icon Web Component
* A downward pointing chevron
*/
class ChevronDownIcon extends HTMLElement {
static get observedAttributes() {
return ["size", "color", "stroke-width"];
}
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
this.render();
}
get size() {
return this.getAttribute("size") || "24";
}
get color() {
return this.getAttribute("color") || "currentColor";
}
get strokeWidth() {
return this.getAttribute("stroke-width") || "2";
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: inline-flex;
align-items: center;
justify-content: center;
}
svg {
display: block;
}
</style>
<svg
xmlns="http://www.w3.org/2000/svg"
width="${this.size}"
height="${this.size}"
viewBox="0 0 24 24"
fill="none"
stroke="${this.color}"
stroke-width="${this.strokeWidth}"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
`;
}
}
customElements.define("chevron-down-icon", ChevronDownIcon);

View File

@@ -0,0 +1,64 @@
/**
* Clipboard Icon Web Component
* A clipboard/document icon
*/
class ClipboardIcon extends HTMLElement {
static get observedAttributes() {
return ["size", "color", "stroke-width"];
}
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
this.render();
}
get size() {
return this.getAttribute("size") || "32";
}
get color() {
return this.getAttribute("color") || "currentColor";
}
get strokeWidth() {
return this.getAttribute("stroke-width") || "1";
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: inline-flex;
align-items: center;
justify-content: center;
}
svg {
display: block;
}
</style>
<svg
xmlns="http://www.w3.org/2000/svg"
width="${this.size}"
height="${this.size}"
viewBox="0 0 24 24"
fill="none"
stroke="${this.color}"
stroke-width="${this.strokeWidth}"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z"></path>
</svg>
`;
}
}
customElements.define("clipboard-icon", ClipboardIcon);

29
js/icons/ebook-icon.js Normal file
View File

@@ -0,0 +1,29 @@
/**
* eBook Icon (Open book style)
* @param {Object} props - Icon properties
* @param {number} props.size - Icon size (default: 20)
* @param {string} props.color - Icon color (default: currentColor)
* @param {number} props.strokeWidth - Stroke width (default: 2)
* @returns {string} SVG string
*/
export function ebookIcon({
size = 20,
color = "currentColor",
strokeWidth = 2,
} = {}) {
return `
<svg
xmlns="http://www.w3.org/2000/svg"
width="${size}"
height="${size}"
viewBox="0 0 24 24"
fill="none"
stroke="${color}"
stroke-width="${strokeWidth}"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25"/>
</svg>
`;
}

View File

@@ -2,9 +2,16 @@
* Lucide Icons Index * Lucide Icons Index
* Re-exports all icons for easy importing * Re-exports all icons for easy importing
*/ */
// Icon functions (return SVG strings)
export { micIcon } from "./mic.js"; export { micIcon } from "./mic.js";
export { searchIcon } from "./search.js"; export { searchIcon } from "./search.js";
export { menuIcon } from "./menu.js"; export { menuIcon } from "./menu.js";
export { userIcon } from "./user.js"; export { userIcon } from "./user.js";
export { shoppingBagIcon } from "./shopping-bag.js"; export { shoppingBagIcon } from "./shopping-bag.js";
export { sendIcon } from "./send-icon.js"; export { sendIcon } from "./send-icon.js";
// Icon web components
import "./menu-icon.js";
import "./user-icon.js";
import "./shopping-bag-icon.js";

66
js/icons/menu-icon.js Normal file
View File

@@ -0,0 +1,66 @@
/**
* Menu Icon Web Component
* A reusable menu/hamburger icon element
*/
class MenuIcon extends HTMLElement {
static get observedAttributes() {
return ["size", "color", "stroke-width"];
}
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
this.render();
}
get size() {
return this.getAttribute("size") || "32";
}
get color() {
return this.getAttribute("color") || "#ffffff";
}
get strokeWidth() {
return this.getAttribute("stroke-width") || "2";
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: inline-flex;
align-items: center;
justify-content: center;
}
svg {
display: block;
}
</style>
<svg
xmlns="http://www.w3.org/2000/svg"
width="${this.size}"
height="${this.size}"
viewBox="0 0 24 24"
fill="none"
stroke="${this.color}"
stroke-width="${this.strokeWidth}"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="4" x2="20" y1="12" y2="12"></line>
<line x1="4" x2="20" y1="6" y2="6"></line>
<line x1="4" x2="20" y1="18" y2="18"></line>
</svg>
`;
}
}
customElements.define("menu-icon", MenuIcon);

31
js/icons/review-icon.js Normal file
View File

@@ -0,0 +1,31 @@
/**
* Review Icon (person with star)
* @param {Object} props - Icon properties
* @param {number} props.size - Icon size (default: 24)
* @param {string} props.color - Icon color (default: currentColor)
* @param {number} props.strokeWidth - Stroke width (default: 2)
* @returns {string} SVG string
*/
export function reviewIcon({
size = 24,
color = "currentColor",
strokeWidth = 2,
} = {}) {
return `
<svg
xmlns="http://www.w3.org/2000/svg"
width="${size}"
height="${size}"
viewBox="0 0 24 24"
fill="none"
stroke="${color}"
stroke-width="${strokeWidth}"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="9" cy="7" r="4"/>
<path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/>
<polygon points="19 8 20.5 11 24 11.5 21.5 14 22 17.5 19 16 16 17.5 16.5 14 14 11.5 17.5 11 19 8"/>
</svg>
`;
}

View File

@@ -0,0 +1,138 @@
/**
* Shopping Bag Icon Web Component
* A reusable shopping bag/cart icon element with optional badge count
*/
class ShoppingBagIcon extends HTMLElement {
static get observedAttributes() {
return ["size", "color", "stroke-width", "count"];
}
constructor() {
super();
this.attachShadow({ mode: "open" });
this.handleCartUpdate = this.handleCartUpdate.bind(this);
}
connectedCallback() {
this.render();
// Listen for cart updates
window.addEventListener("cart-updated", this.handleCartUpdate);
// Initialize count from cart store if available
this.initializeCount();
}
disconnectedCallback() {
window.removeEventListener("cart-updated", this.handleCartUpdate);
}
initializeCount() {
// Wait for cart store to be available
setTimeout(() => {
if (window.cartStore) {
const count = window.cartStore.getItemCount();
if (count > 0) {
this.setAttribute("count", count.toString());
}
}
}, 0);
}
handleCartUpdate(event) {
const { count } = event.detail;
if (count > 0) {
this.setAttribute("count", count.toString());
} else {
this.removeAttribute("count");
}
}
attributeChangedCallback() {
this.render();
}
get size() {
return this.getAttribute("size") || "32";
}
get color() {
return this.getAttribute("color") || "#ffffff";
}
get strokeWidth() {
return this.getAttribute("stroke-width") || "2";
}
get count() {
const countAttr = this.getAttribute("count");
return countAttr ? parseInt(countAttr, 10) : 0;
}
render() {
const showBadge = this.count > 0;
const displayCount = this.count > 99 ? "99+" : this.count.toString();
this.shadowRoot.innerHTML = `
<style>
:host {
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
}
.icon-wrapper {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
svg {
display: block;
}
.badge {
position: absolute;
top: -6px;
right: -8px;
min-width: 18px;
height: 18px;
padding: 0 5px;
color: var(--color-purple, #951d51);
background-color: #ffffff;
font-family: var(--font-family-outfit, "Outfit", sans-serif);
font-size: 11px;
font-weight: 700;
line-height: 18px;
text-align: center;
border-radius: 9px;
box-sizing: border-box;
}
.badge.hidden {
display: none;
}
</style>
<div class="icon-wrapper">
<svg
xmlns="http://www.w3.org/2000/svg"
width="${this.size}"
height="${this.size}"
viewBox="0 0 24 24"
fill="none"
stroke="${this.color}"
stroke-width="${this.strokeWidth}"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="8" cy="21" r="1"></circle>
<circle cx="19" cy="21" r="1"></circle>
<path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"></path>
</svg>
<span class="badge ${showBadge ? "" : "hidden"}">${displayCount}</span>
</div>
`;
}
}
customElements.define("shopping-bag-icon", ShoppingBagIcon);

66
js/icons/user-icon.js Normal file
View File

@@ -0,0 +1,66 @@
/**
* User Icon Web Component
* A reusable user/profile icon element
*/
class UserIcon extends HTMLElement {
static get observedAttributes() {
return ["size", "color", "stroke-width"];
}
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
this.render();
}
get size() {
return this.getAttribute("size") || "32";
}
get color() {
return this.getAttribute("color") || "#ffffff";
}
get strokeWidth() {
return this.getAttribute("stroke-width") || "2";
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: inline-flex;
align-items: center;
justify-content: center;
}
svg {
display: block;
}
</style>
<svg
xmlns="http://www.w3.org/2000/svg"
width="${this.size}"
height="${this.size}"
viewBox="0 0 24 24"
fill="none"
stroke="${this.color}"
stroke-width="${this.strokeWidth}"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 20a6 6 0 0 0-12 0"></path>
<circle cx="12" cy="10" r="4"></circle>
<circle cx="12" cy="12" r="10"></circle>
</svg>
`;
}
}
customElements.define("user-icon", UserIcon);

32
js/icons/wishlist-icon.js Normal file
View File

@@ -0,0 +1,32 @@
/**
* Wishlist Icon (tablet with heart)
* @param {Object} props - Icon properties
* @param {number} props.size - Icon size (default: 24)
* @param {string} props.color - Icon color (default: currentColor)
* @param {number} props.strokeWidth - Stroke width (default: 2)
* @returns {string} SVG string
*/
export function wishlistIcon({
size = 24,
color = "currentColor",
strokeWidth = 2,
} = {}) {
return `
<svg
xmlns="http://www.w3.org/2000/svg"
width="${size}"
height="${size}"
viewBox="0 0 24 24"
fill="none"
stroke="${color}"
stroke-width="${strokeWidth}"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="5" y="2" width="14" height="20" rx="2" ry="2"/>
<path d="M12 18h.01"/>
<path d="M12 8l1.5 1.5L12 11l-1.5-1.5L12 8z" fill="${color}" stroke="none"/>
<path d="M12 6.5c-.5-.5-1.5-.5-2 0s-.5 1.5 0 2l2 2 2-2c.5-.5.5-1.5 0-2s-1.5-.5-2 0"/>
</svg>
`;
}

158
js/store/cart.js Normal file
View File

@@ -0,0 +1,158 @@
/**
* Cart Store
* Manages shopping cart state with localStorage persistence
* Dispatches 'cart-updated' events when cart changes
*/
const CART_STORAGE_KEY = "milinda-cart";
class CartStore {
constructor() {
this.items = this.loadFromStorage();
}
/**
* Load cart from localStorage
*/
loadFromStorage() {
try {
const stored = localStorage.getItem(CART_STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch (e) {
console.error("Failed to load cart from storage:", e);
return [];
}
}
/**
* Save cart to localStorage
*/
saveToStorage() {
try {
localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(this.items));
} catch (e) {
console.error("Failed to save cart to storage:", e);
}
}
/**
* Dispatch cart-updated event
*/
notifyUpdate() {
window.dispatchEvent(
new CustomEvent("cart-updated", {
detail: {
items: this.items,
count: this.getItemCount(),
total: this.getTotal(),
},
})
);
}
/**
* Add item to cart
* @param {Object} item - Item to add { title, author, price, type, image }
*/
addItem(item) {
// Check if item already exists (by title and type)
const existingIndex = this.items.findIndex(
(i) => i.title === item.title && i.type === item.type
);
if (existingIndex >= 0) {
// Increment quantity
this.items[existingIndex].quantity += 1;
} else {
// Add new item
this.items.push({
...item,
quantity: 1,
addedAt: Date.now(),
});
}
this.saveToStorage();
this.notifyUpdate();
return this.items;
}
/**
* Remove item from cart
* @param {number} index - Index of item to remove
*/
removeItem(index) {
if (index >= 0 && index < this.items.length) {
this.items.splice(index, 1);
this.saveToStorage();
this.notifyUpdate();
}
return this.items;
}
/**
* Update item quantity
* @param {number} index - Index of item
* @param {number} quantity - New quantity
*/
updateQuantity(index, quantity) {
if (index >= 0 && index < this.items.length) {
if (quantity <= 0) {
this.removeItem(index);
} else {
this.items[index].quantity = quantity;
this.saveToStorage();
this.notifyUpdate();
}
}
return this.items;
}
/**
* Clear all items from cart
*/
clear() {
this.items = [];
this.saveToStorage();
this.notifyUpdate();
return this.items;
}
/**
* Get all items
*/
getItems() {
return this.items;
}
/**
* Get total item count (sum of quantities)
*/
getItemCount() {
return this.items.reduce((sum, item) => sum + item.quantity, 0);
}
/**
* Get cart total price
*/
getTotal() {
return this.items.reduce((sum, item) => {
// Parse price like "€ 24,95" to number
const priceStr = item.price || "0";
const price = parseFloat(
priceStr.replace(/[€$£\s]/g, "").replace(",", ".")
);
return sum + price * item.quantity;
}, 0);
}
}
// Create singleton instance
const cart = new CartStore();
// Export for module use
export default cart;
// Also attach to window for global access
window.cartStore = cart;