From 5755d43cfc40313bacf147cd9de5a9269d39b381 Mon Sep 17 00:00:00 2001 From: Tim Rijkse Date: Fri, 16 Jan 2026 09:23:37 +0100 Subject: [PATCH] fix: add action list --- book.html | 9 ++ js/app.js | 57 ++++++++++- js/components/action-links-list.js | 36 +++++++ js/components/book-details.js | 65 ++---------- js/components/icon-cta-button.js | 152 +++++++++++++++++++++++++++ js/components/icon-link-button.js | 130 ++++++++++++++++++++++++ js/icons/cart-icon.js | 31 ++++++ js/icons/ebook-icon.js | 29 ++++++ js/icons/review-icon.js | 31 ++++++ js/icons/shopping-bag-icon.js | 106 +++++++++++++++---- js/icons/wishlist-icon.js | 32 ++++++ js/store/cart.js | 158 +++++++++++++++++++++++++++++ 12 files changed, 761 insertions(+), 75 deletions(-) create mode 100644 js/components/action-links-list.js create mode 100644 js/components/icon-cta-button.js create mode 100644 js/components/icon-link-button.js create mode 100644 js/icons/cart-icon.js create mode 100644 js/icons/ebook-icon.js create mode 100644 js/icons/review-icon.js create mode 100644 js/icons/wishlist-icon.js create mode 100644 js/store/cart.js diff --git a/book.html b/book.html index 818fd51..9217831 100644 --- a/book.html +++ b/book.html @@ -51,6 +51,15 @@ categories="Zen|#,Integrale spiritualiteit|#" ebook-available > + + + + Voeg toe aan verlanglijstje + + + Schrijf een recensie + +
diff --git a/js/app.js b/js/app.js index e57a1b1..67dec5e 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"; @@ -20,6 +23,9 @@ 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 icon components import "./icons/menu-icon.js"; @@ -30,7 +36,56 @@ import "./icons/book-open-icon.js"; import "./icons/clipboard-icon.js"; import "./icons/chevron-down-icon.js"; -// App initialization (if needed) +// 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/book-details.js b/js/components/book-details.js index cac2158..f9620dd 100644 --- a/js/components/book-details.js +++ b/js/components/book-details.js @@ -2,7 +2,6 @@ * Book Details Component * Displays book information with cover, metadata, and purchase buttons */ -import { shoppingBagIcon } from "../icons/shopping-bag.js"; class BookDetails extends HTMLElement { static get observedAttributes() { @@ -43,7 +42,7 @@ class BookDetails extends HTMLElement { const favoriteBtn = this.shadowRoot?.querySelector(".favorite-btn"); if (addToCartBtn) { - addToCartBtn.addEventListener("click", () => { + addToCartBtn.addEventListener("button-click", () => { this.dispatchEvent( new CustomEvent("add-to-cart", { bubbles: true, @@ -59,7 +58,7 @@ class BookDetails extends HTMLElement { } if (ebookBtn) { - ebookBtn.addEventListener("click", () => { + ebookBtn.addEventListener("button-click", () => { this.dispatchEvent( new CustomEvent("buy-ebook", { bubbles: true, @@ -325,50 +324,6 @@ class BookDetails extends HTMLElement { flex-direction: column; gap: var(--spacing-sm, 0.5rem); } - - .btn { - display: flex; - align-items: center; - justify-content: center; - gap: var(--spacing-sm, 0.5rem); - padding: 14px var(--spacing-md, 1rem); - border: none; - border-radius: var(--radius-lg, 0.75rem); - font-family: var(--font-family-outfit, "Outfit", sans-serif); - font-size: 16px; - font-weight: 400; - line-height: 24px; - cursor: pointer; - transition: opacity var(--transition-fast, 150ms ease); - } - - .btn:hover { - opacity: 0.9; - } - - .btn:active { - opacity: 0.8; - } - - .btn-cart { - background-color: var(--color-purple, #951d51); - color: var(--color-text-inverse, #ffffff); - } - - .btn-cart svg { - width: 20px; - height: 20px; - } - - .btn-ebook { - background-color: var(--color-push-box-bg, #EBEEF4); - color: var(--color-text, #1e293b); - } - - .btn-ebook svg { - width: 20px; - height: 20px; - }
@@ -433,19 +388,15 @@ class BookDetails extends HTMLElement {
- + + In winkelwagen + ${ this.ebookAvailable ? ` - + + Koop eBook + ` : "" } 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/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/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/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 index d9babb3..1976e86 100644 --- a/js/icons/shopping-bag-icon.js +++ b/js/icons/shopping-bag-icon.js @@ -1,19 +1,49 @@ /** * Shopping Bag Icon Web Component - * A reusable shopping bag/cart icon element + * A reusable shopping bag/cart icon element with optional badge count */ class ShoppingBagIcon extends HTMLElement { static get observedAttributes() { - return ["size", "color", "stroke-width"]; + 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() { @@ -32,33 +62,75 @@ class ShoppingBagIcon extends HTMLElement { 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} +
`; } } 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;