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/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 = `
+
+
+
+ ${imagesHtml}
+
+
+
+
+
+
+
+
+ `;
+ }
+}
+
+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;