@@ -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}` : ""}
+
+
+
+ ${tag}>
+ `;
+ }
+}
+
+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}` : ""}
+
+
+
+ ${tag}>
+ `;
+ }
+}
+
+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;