diff --git a/book.html b/book.html index e6a1042..074f300 100644 --- a/book.html +++ b/book.html @@ -5,334 +5,203 @@ - The Midnight Library - BookStore + Milinda - Home + + +
- + - +
- - + +
-
- - - - - - Back to Books - +
+ - -
-
- + + Voeg toe aan verlanglijstje + + + Schrijf een recensie + + + + + +

+ Zen is opendoen bevat een selectie van zeventig columns die Dick + Verstegen schreef tussen 2002 en 2017. Hij schreef ze als + columnist voor o.a. het boeddhistisch kwartaalblad Vorm & + Leegte, het Boeddhistisch Dagblad, Centrum Waerbeke, het Han + Fortmann Centrum en de Wijkkrant van Nijmegen-Oost. Zijn columns + wijzen zonder uitzondering naar het mysterie van het leven en + geven blijk van bewogenheid en overgave, maar de lichte toets + ontbreekt nooit. Ze gaan over zeer uiteenlopende onderwerpen, + zoals: lente, de ware stem van je hart, compassie, ontroering, + woorden, de dood van zijn vrouw, de kathedraal van Royan, het + windorgel in Vlissingen, liefde, boeddhaschap, bedelen, emoties, + relaties, management, overgave, gedachten, Nepal, leerling zijn, + theekommen, zijn vader, stilte, licht en opendoen. +

+

+ Uit zijn columns blijkt hoezeer hij zich ervan bewust is dat + zenboeddhisme geen mening is, maar een non-duale zienswijze. + Non-dualiteit is zien dat de dualiteiten geen hindernissen zijn + voor een bevrijd bestaan. (...) zo te spreken of te schrijven + dat het mysteriekarakter onaangetast blijft. Wat mij betreft is + Dick hierin volkomen geslaagd. Hoe hem dit gelukt is, is mij een + raadsel. Misschien wel dankzij zijn grote liefde voor de taal + die spreekt uit elke bladzijde van deze verzameling literaire + miniaturen. +

+

—Nico Tydeman

+ + + + + + - - + Een prachtige verzameling columns die je aan het denken zet. + Dick Verstegen schrijft met zoveel warmte en wijsheid. Elk + stukje is een kleine meditatie op zich. Aanrader voor iedereen + die geïnteresseerd is in zen en het dagelijks leven. + + + Dit boek heeft mijn kijk op zen volledig veranderd. De columns + zijn toegankelijk geschreven en toch diepgaand. Ik lees elke + avond een column voor het slapen gaan. Een boek om te koesteren. + + + Mooie, poëtische teksten die je uitnodigen om stil te staan bij + het leven. Soms wat abstract, maar overall een waardevolle + toevoeging aan mijn boekenplank. De quote van Nico Tydeman op de + achterkant vat het perfect samen. + + + +
+ +
+ +

+ Gespecialiseerd op het vlak van boeddhisme en aanverwante + Oost-West thema's +

+
+ Klantenservice + Neem contact op
-
+ +
- -
-

The Midnight Library

-

by Matt Haig

- -
-
- - - - - - - - - - - - - - - -
- 4.5 (2,847 reviews) -
- -
-
- Format - Hardcover -
-
- Pages - 304 -
-
- Language - English -
-
-
- - -
-
- $14.99 - $24.99 - 40% OFF -
- -
- - -
-
- - -
-

About this book

-

- 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? -

-

- 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. -

-
- - -
-

Customer Reviews

- -
-
-
- Sarah M. - December 2025 -
-
- - - - - - - - - - - - - - - -
-
-

- This book changed my perspective on life. Beautifully written - and deeply thought-provoking. A must-read for anyone going - through a difficult time. -

-
- -
-
-
- James K. - November 2025 -
-
- - - - - - - - - - - - - - - -
-
-

- Great concept and well-executed. The writing flows effortlessly - and keeps you engaged throughout. Highly recommend! -

-
- - -
+
+
- + + MILINDA uitgevers + + + + + + + + + + + + + + + + + + + + +
diff --git a/images/book-insight.jpg b/images/book-insight.jpg new file mode 100644 index 0000000..cb6781e Binary files /dev/null and b/images/book-insight.jpg differ diff --git a/index.html b/index.html index df7364e..c23ee1d 100644 --- a/index.html +++ b/index.html @@ -22,59 +22,15 @@
diff --git a/js/app.js b/js/app.js index c7de25e..5d025f4 100644 --- a/js/app.js +++ b/js/app.js @@ -3,6 +3,9 @@ * 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 "./components/site-header.js"; import "./components/top-bar.js"; @@ -19,8 +22,75 @@ import "./components/add-to-cart-button.js"; import "./components/cta-button.js"; import "./components/category-card.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"; +import "./components/content-tabs.js"; +import "./components/image-gallery.js"; +import "./components/book-description.js"; +import "./components/book-reviews.js"; +import "./components/book-review-item.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", () => { 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`); + } + }); }); diff --git a/js/components/action-links-list.js b/js/components/action-links-list.js new file mode 100644 index 0000000..db18d98 --- /dev/null +++ b/js/components/action-links-list.js @@ -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 = ` + + + `; + } +} + +customElements.define("action-links-list", ActionLinksList); diff --git a/js/components/arrow-button.js b/js/components/arrow-button.js index f045445..42bfbca 100644 --- a/js/components/arrow-button.js +++ b/js/components/arrow-button.js @@ -54,13 +54,6 @@ class ArrowButton extends HTMLElement { flex-shrink: 0; } - .arrow-icon svg { - width: 24px; - height: 24px; - stroke: currentColor; - fill: none; - } - .button-text { text-decoration: underline; text-underline-offset: 3px; @@ -68,11 +61,7 @@ class ArrowButton extends HTMLElement { - - - - - + diff --git a/js/components/book-card.js b/js/components/book-card.js index e451d47..2e2ceb4 100644 --- a/js/components/book-card.js +++ b/js/components/book-card.js @@ -93,9 +93,7 @@ class BookCard extends HTMLElement { const imageHtml = this.image ? `${this.title}` : `
- - - +
`; this.shadowRoot.innerHTML = ` @@ -164,11 +162,6 @@ class BookCard extends HTMLElement { align-items: center; justify-content: center; color: var(--color-text-light, #64748b); - } - - .book-image.placeholder svg { - width: 48px; - height: 48px; opacity: 0.5; } diff --git a/js/components/book-description.js b/js/components/book-description.js new file mode 100644 index 0000000..1cbb6f4 --- /dev/null +++ b/js/components/book-description.js @@ -0,0 +1,53 @@ +/** + * Book Description Component + * Displays formatted book description text + */ +class BookDescription extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + } + + connectedCallback() { + this.render(); + } + + render() { + this.shadowRoot.innerHTML = ` + +
+ +
+ `; + } +} + +customElements.define("book-description", BookDescription); diff --git a/js/components/book-details.js b/js/components/book-details.js new file mode 100644 index 0000000..f9620dd --- /dev/null +++ b/js/components/book-details.js @@ -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 `
${name}`; + } + return `${name}`; + }); + + return cats.join(' / '); + } + + render() { + const imageHtml = this.image + ? `${this.bookTitle}` + : `
+ +
`; + + this.shadowRoot.innerHTML = ` + + +
+ +
+
+

${this.bookTitle}

+ +
+ ${this.author} +

${this.price}

+
+ + +
+
+ ${imageHtml} +
+
+ ${ + this.categories + ? ` +
+ Categorieën + ${this.renderCategories()} +
+ ` + : "" + } +
+ ${ + this.isbn + ? ` +
+ ISBN + ${this.isbn} +
+ ` + : "" + } +
+ Uitvoering + ${this.format} +
+
+
+
+ Uitvoering + ${this.format} +
+
+ Levertijd + ${this.delivery} +
+
+
+
+ + +
+ + In winkelwagen + + ${ + this.ebookAvailable + ? ` + + Koop eBook + + ` + : "" + } +
+
+ `; + } +} + +customElements.define("book-details", BookDetails); diff --git a/js/components/book-review-item.js b/js/components/book-review-item.js new file mode 100644 index 0000000..e1c1a1b --- /dev/null +++ b/js/components/book-review-item.js @@ -0,0 +1,128 @@ +/** + * Book Review Item Component + * Individual review with rating, author, date, and text + */ +class BookReviewItem extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + } + + connectedCallback() { + this.render(); + } + + get rating() { + return parseInt(this.getAttribute("rating") || "5", 10); + } + + get author() { + return this.getAttribute("author") || "Anoniem"; + } + + get date() { + return this.getAttribute("date") || ""; + } + + renderStars() { + const rating = Math.min(5, Math.max(0, this.rating)); + const fullStars = rating; + const emptyStars = 5 - rating; + + let stars = ""; + for (let i = 0; i < fullStars; i++) { + stars += ``; + } + for (let i = 0; i < emptyStars; i++) { + stars += ``; + } + return stars; + } + + render() { + this.shadowRoot.innerHTML = ` + +
+
+
${this.renderStars()}
+
+ ${this.author} + ${this.date ? `• ${this.date}` : ""} +
+
+

+ +

+
+ `; + } +} + +customElements.define("book-review-item", BookReviewItem); diff --git a/js/components/book-reviews.js b/js/components/book-reviews.js new file mode 100644 index 0000000..52c3869 --- /dev/null +++ b/js/components/book-reviews.js @@ -0,0 +1,47 @@ +/** + * Book Reviews Component + * Displays customer reviews for a book + */ +class BookReviews extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + } + + connectedCallback() { + this.render(); + } + + render() { + this.shadowRoot.innerHTML = ` + +
+ +

Nog geen recensies. Wees de eerste om een recensie te schrijven!

+
+
+ `; + } +} + +customElements.define("book-reviews", BookReviews); diff --git a/js/components/category-card.js b/js/components/category-card.js index 2f8c196..2384c82 100644 --- a/js/components/category-card.js +++ b/js/components/category-card.js @@ -39,9 +39,7 @@ class CategoryCard extends HTMLElement { const iconHtml = this.icon ? `${this.title}` : `
- - - +
`; this.shadowRoot.innerHTML = ` @@ -84,11 +82,6 @@ class CategoryCard extends HTMLElement { background-color: var(--color-background-tertiary, #f1f5f9); border-radius: var(--radius-md, 0.5rem); color: var(--color-text-light, #64748b); - } - - .category-icon.placeholder svg { - width: 32px; - height: 32px; opacity: 0.5; } diff --git a/js/components/content-tabs.js b/js/components/content-tabs.js new file mode 100644 index 0000000..85080a7 --- /dev/null +++ b/js/components/content-tabs.js @@ -0,0 +1,144 @@ +/** + * Content Tabs Component + * Tabbed interface for displaying different content sections + */ +class ContentTabs extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + this.activeTab = 0; + } + + connectedCallback() { + this.render(); + this.setupEventListeners(); + } + + get tabs() { + const tabsAttr = this.getAttribute("tabs"); + return tabsAttr ? tabsAttr.split(",").map((t) => t.trim()) : []; + } + + setupEventListeners() { + const tabButtons = this.shadowRoot.querySelectorAll(".tab-button"); + tabButtons.forEach((button, index) => { + button.addEventListener("click", () => { + this.setActiveTab(index); + }); + }); + } + + setActiveTab(index) { + this.activeTab = index; + this.updateTabs(); + this.dispatchEvent( + new CustomEvent("tab-change", { + bubbles: true, + composed: true, + detail: { index, tab: this.tabs[index] }, + }) + ); + } + + updateTabs() { + // Update tab buttons + const tabButtons = this.shadowRoot.querySelectorAll(".tab-button"); + tabButtons.forEach((button, index) => { + button.classList.toggle("active", index === this.activeTab); + }); + + // Update panels + const panels = this.querySelectorAll("[slot^='panel-']"); + panels.forEach((panel, index) => { + panel.style.display = index === this.activeTab ? "block" : "none"; + }); + } + + render() { + const tabsHtml = this.tabs + .map( + (tab, index) => ` + + ` + ) + .join(""); + + this.shadowRoot.innerHTML = ` + +
+
+ ${tabsHtml} +
+
+ + + +
+
+ `; + + // Initialize panel visibility + setTimeout(() => this.updateTabs(), 0); + } +} + +customElements.define("content-tabs", ContentTabs); diff --git a/js/components/footer-accordion-item.js b/js/components/footer-accordion-item.js index dbf4da3..69fe417 100644 --- a/js/components/footer-accordion-item.js +++ b/js/components/footer-accordion-item.js @@ -34,7 +34,7 @@ class FooterAccordionItem extends HTMLElement { this._expanded = !this._expanded; const content = this.shadowRoot.querySelector(".accordion-content"); const icon = this.shadowRoot.querySelector(".accordion-icon"); - + if (content) { content.classList.toggle("expanded", this._expanded); } @@ -74,8 +74,7 @@ class FooterAccordionItem extends HTMLElement { } .accordion-icon { - width: 24px; - height: 24px; + display: inline-flex; color: var(--color-text-inverse, #ffffff); transition: transform var(--transition-fast, 150ms ease); } @@ -101,9 +100,7 @@ class FooterAccordionItem extends HTMLElement {

${title}

- - - +
diff --git a/js/components/icon-cta-button.js b/js/components/icon-cta-button.js new file mode 100644 index 0000000..c6074c2 --- /dev/null +++ b/js/components/icon-cta-button.js @@ -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 = ` + + <${tag} + class="icon-cta-button ${isPrimary ? "primary" : "secondary"}" + ${hrefAttr} + ${typeAttr} + > + ${iconHtml ? `${iconHtml}` : ""} + + + + + `; + } +} + +customElements.define("icon-cta-button", IconCtaButton); diff --git a/js/components/icon-link-button.js b/js/components/icon-link-button.js new file mode 100644 index 0000000..8721841 --- /dev/null +++ b/js/components/icon-link-button.js @@ -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 = ` + + <${tag} + class="icon-link-button" + ${hrefAttr} + ${typeAttr} + > + ${iconHtml ? `${iconHtml}` : ""} + + + + + `; + } +} + +customElements.define("icon-link-button", IconLinkButton); diff --git a/js/components/image-gallery.js b/js/components/image-gallery.js new file mode 100644 index 0000000..4b7fbd5 --- /dev/null +++ b/js/components/image-gallery.js @@ -0,0 +1,368 @@ +/** + * Image Gallery Component + * Grid of images that open in a fullscreen modal on click + */ +class ImageGallery extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + + // Zoom and pan state + this.currentZoom = 1; + this.translateX = 0; + this.translateY = 0; + this.isDragging = false; + this.startX = 0; + this.startY = 0; + this.lastTranslateX = 0; + this.lastTranslateY = 0; + } + + connectedCallback() { + this.render(); + this.setupEventListeners(); + } + + get images() { + const imagesAttr = this.getAttribute("images"); + return imagesAttr ? imagesAttr.split(",").map((img) => img.trim()) : []; + } + + setupEventListeners() { + const imageItems = this.shadowRoot.querySelectorAll(".gallery-image"); + imageItems.forEach((img, index) => { + img.addEventListener("click", () => { + this.openModal(index); + }); + }); + + // Modal controls + const closeBtn = this.shadowRoot.querySelector(".modal-close"); + const modal = this.shadowRoot.querySelector(".modal-overlay"); + + if (closeBtn) { + closeBtn.addEventListener("click", () => this.closeModal()); + } + + if (modal) { + modal.addEventListener("click", (e) => { + if (e.target === modal) { + this.closeModal(); + } + }); + } + + // Keyboard support + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + this.closeModal(); + } + }); + + // Zoom controls + const zoomIn = this.shadowRoot.querySelector(".zoom-in"); + const zoomOut = this.shadowRoot.querySelector(".zoom-out"); + const resetZoom = this.shadowRoot.querySelector(".zoom-reset"); + + if (zoomIn) { + zoomIn.addEventListener("click", () => this.zoom(0.25)); + } + if (zoomOut) { + zoomOut.addEventListener("click", () => this.zoom(-0.25)); + } + if (resetZoom) { + resetZoom.addEventListener("click", () => this.resetZoom()); + } + + // Drag/pan functionality + const modalImage = this.shadowRoot.querySelector(".modal-image"); + if (modalImage) { + // Mouse events + modalImage.addEventListener("mousedown", (e) => this.startDrag(e)); + modalImage.addEventListener("mousemove", (e) => this.drag(e)); + modalImage.addEventListener("mouseup", () => this.endDrag()); + modalImage.addEventListener("mouseleave", () => this.endDrag()); + + // Touch events for mobile + modalImage.addEventListener("touchstart", (e) => this.startDrag(e), { passive: false }); + modalImage.addEventListener("touchmove", (e) => this.drag(e), { passive: false }); + modalImage.addEventListener("touchend", () => this.endDrag()); + + // Prevent default drag behavior + modalImage.addEventListener("dragstart", (e) => e.preventDefault()); + } + } + + startDrag(e) { + // Only enable drag when zoomed in + if (this.currentZoom <= 1) return; + + this.isDragging = true; + + if (e.type === "touchstart") { + this.startX = e.touches[0].clientX - this.translateX; + this.startY = e.touches[0].clientY - this.translateY; + } else { + this.startX = e.clientX - this.translateX; + this.startY = e.clientY - this.translateY; + } + + const modalImage = this.shadowRoot.querySelector(".modal-image"); + if (modalImage) { + modalImage.style.cursor = "grabbing"; + modalImage.style.transition = "none"; + } + } + + drag(e) { + if (!this.isDragging) return; + + e.preventDefault(); + + let clientX, clientY; + if (e.type === "touchmove") { + clientX = e.touches[0].clientX; + clientY = e.touches[0].clientY; + } else { + clientX = e.clientX; + clientY = e.clientY; + } + + this.translateX = clientX - this.startX; + this.translateY = clientY - this.startY; + + this.updateImageTransform(); + } + + endDrag() { + if (!this.isDragging) return; + + this.isDragging = false; + + const modalImage = this.shadowRoot.querySelector(".modal-image"); + if (modalImage) { + modalImage.style.cursor = this.currentZoom > 1 ? "grab" : "default"; + modalImage.style.transition = "transform 0.2s ease"; + } + } + + updateImageTransform() { + const modalImage = this.shadowRoot.querySelector(".modal-image"); + if (modalImage) { + modalImage.style.transform = `scale(${this.currentZoom}) translate(${this.translateX / this.currentZoom}px, ${this.translateY / this.currentZoom}px)`; + } + } + + openModal(index) { + const modal = this.shadowRoot.querySelector(".modal-overlay"); + const modalImage = this.shadowRoot.querySelector(".modal-image"); + + if (modal && modalImage && this.images[index]) { + modalImage.src = this.images[index]; + + // Reset zoom and pan + this.currentZoom = 1; + this.translateX = 0; + this.translateY = 0; + this.updateImageTransform(); + + modalImage.style.cursor = "default"; + modal.classList.add("open"); + document.body.style.overflow = "hidden"; + } + } + + closeModal() { + const modal = this.shadowRoot.querySelector(".modal-overlay"); + if (modal) { + modal.classList.remove("open"); + document.body.style.overflow = ""; + } + } + + zoom(delta) { + const modalImage = this.shadowRoot.querySelector(".modal-image"); + if (modalImage) { + const prevZoom = this.currentZoom; + this.currentZoom = Math.max(1, Math.min(4, this.currentZoom + delta)); + + // If zooming out to 1x, reset position + if (this.currentZoom === 1) { + this.translateX = 0; + this.translateY = 0; + } + + this.updateImageTransform(); + modalImage.style.cursor = this.currentZoom > 1 ? "grab" : "default"; + } + } + + resetZoom() { + const modalImage = this.shadowRoot.querySelector(".modal-image"); + if (modalImage) { + this.currentZoom = 1; + this.translateX = 0; + this.translateY = 0; + this.updateImageTransform(); + modalImage.style.cursor = "default"; + } + } + + render() { + const imagesHtml = this.images + .map( + (img, index) => ` + + ` + ) + .join(""); + + this.shadowRoot.innerHTML = ` + + + + + + `; + } +} + +customElements.define("image-gallery", ImageGallery); diff --git a/js/icons/arrow-circle-right-icon.js b/js/icons/arrow-circle-right-icon.js new file mode 100644 index 0000000..0f039f8 --- /dev/null +++ b/js/icons/arrow-circle-right-icon.js @@ -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 = ` + + + + + + + `; + } +} + +customElements.define("arrow-circle-right-icon", ArrowCircleRightIcon); diff --git a/js/icons/book-open-icon.js b/js/icons/book-open-icon.js new file mode 100644 index 0000000..d309c66 --- /dev/null +++ b/js/icons/book-open-icon.js @@ -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 = ` + + + + + `; + } +} + +customElements.define("book-open-icon", BookOpenIcon); diff --git a/js/icons/cart-icon.js b/js/icons/cart-icon.js new file mode 100644 index 0000000..16f236d --- /dev/null +++ b/js/icons/cart-icon.js @@ -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 ` + + + + + + `; +} diff --git a/js/icons/chevron-down-icon.js b/js/icons/chevron-down-icon.js new file mode 100644 index 0000000..efc0ed2 --- /dev/null +++ b/js/icons/chevron-down-icon.js @@ -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 = ` + + + + + `; + } +} + +customElements.define("chevron-down-icon", ChevronDownIcon); diff --git a/js/icons/clipboard-icon.js b/js/icons/clipboard-icon.js new file mode 100644 index 0000000..a62bab9 --- /dev/null +++ b/js/icons/clipboard-icon.js @@ -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 = ` + + + + + `; + } +} + +customElements.define("clipboard-icon", ClipboardIcon); diff --git a/js/icons/ebook-icon.js b/js/icons/ebook-icon.js new file mode 100644 index 0000000..69eb78f --- /dev/null +++ b/js/icons/ebook-icon.js @@ -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 ` + + + + `; +} diff --git a/js/icons/index.js b/js/icons/index.js index 6813ca2..24f96fa 100644 --- a/js/icons/index.js +++ b/js/icons/index.js @@ -2,9 +2,16 @@ * Lucide Icons Index * Re-exports all icons for easy importing */ + +// Icon functions (return SVG strings) export { micIcon } from "./mic.js"; export { searchIcon } from "./search.js"; export { menuIcon } from "./menu.js"; export { userIcon } from "./user.js"; export { shoppingBagIcon } from "./shopping-bag.js"; export { sendIcon } from "./send-icon.js"; + +// Icon web components +import "./menu-icon.js"; +import "./user-icon.js"; +import "./shopping-bag-icon.js"; diff --git a/js/icons/menu-icon.js b/js/icons/menu-icon.js new file mode 100644 index 0000000..f550045 --- /dev/null +++ b/js/icons/menu-icon.js @@ -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 = ` + + + + + + + `; + } +} + +customElements.define("menu-icon", MenuIcon); diff --git a/js/icons/review-icon.js b/js/icons/review-icon.js new file mode 100644 index 0000000..69df302 --- /dev/null +++ b/js/icons/review-icon.js @@ -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 ` + + + + + + `; +} diff --git a/js/icons/shopping-bag-icon.js b/js/icons/shopping-bag-icon.js new file mode 100644 index 0000000..1976e86 --- /dev/null +++ b/js/icons/shopping-bag-icon.js @@ -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 = ` + +
+ + + + + + ${displayCount} +
+ `; + } +} + +customElements.define("shopping-bag-icon", ShoppingBagIcon); diff --git a/js/icons/user-icon.js b/js/icons/user-icon.js new file mode 100644 index 0000000..96165ef --- /dev/null +++ b/js/icons/user-icon.js @@ -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 = ` + + + + + + + `; + } +} + +customElements.define("user-icon", UserIcon); diff --git a/js/icons/wishlist-icon.js b/js/icons/wishlist-icon.js new file mode 100644 index 0000000..ed4b0e9 --- /dev/null +++ b/js/icons/wishlist-icon.js @@ -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 ` + + + + + + + `; +} diff --git a/js/store/cart.js b/js/store/cart.js new file mode 100644 index 0000000..5c5a85d --- /dev/null +++ b/js/store/cart.js @@ -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;