feature/book-page (#4)

Co-authored-by: Tim Rijkse <trijkse@gmail.com>
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-01-16 08:46:03 +00:00
parent 45d0872495
commit 3dbe404443
30 changed files with 2534 additions and 384 deletions

View File

@@ -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`);
}
});
});

View File

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

View File

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

View File

@@ -93,9 +93,7 @@ class BookCard extends HTMLElement {
const imageHtml = this.image
? `<img src="${this.image}" alt="${this.title}" class="book-image">`
: `<div class="book-image placeholder">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
</svg>
<book-open-icon size="48"></book-open-icon>
</div>`;
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;
}

View File

@@ -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 = `
<style>
:host {
display: block;
}
.description {
font-family: var(--font-family-outfit, "Outfit", sans-serif);
font-size: 16px;
font-weight: 400;
line-height: 28px;
color: var(--color-text, #1e293b);
}
::slotted(p) {
margin: 0 0 var(--spacing-md, 1rem) 0;
}
::slotted(p:last-child) {
margin-bottom: 0;
}
::slotted(.quote) {
font-style: italic;
}
::slotted(.author) {
font-weight: 400;
}
</style>
<div class="description">
<slot></slot>
</div>
`;
}
}
customElements.define("book-description", BookDescription);

View File

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

View File

@@ -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 += `<span class="star filled">★</span>`;
}
for (let i = 0; i < emptyStars; i++) {
stars += `<span class="star empty">☆</span>`;
}
return stars;
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
}
.review {
padding-bottom: var(--spacing-lg, 1.5rem);
border-bottom: 1px solid var(--color-border, #e2e8f0);
}
:host(:last-child) .review {
border-bottom: none;
padding-bottom: 0;
}
.review-header {
display: flex;
flex-direction: column;
gap: var(--spacing-xs, 0.25rem);
margin-bottom: var(--spacing-sm, 0.5rem);
}
.stars {
display: flex;
gap: 2px;
}
.star {
font-size: 16px;
line-height: 1;
}
.star.filled {
color: var(--color-purple, #951d51);
}
.star.empty {
color: var(--color-border, #e2e8f0);
}
.review-meta {
display: flex;
align-items: center;
gap: var(--spacing-sm, 0.5rem);
font-family: var(--font-family-outfit, "Outfit", sans-serif);
font-size: 14px;
line-height: 20px;
}
.author {
font-weight: 500;
color: var(--color-text, #1e293b);
}
.date {
color: var(--color-text-light, #64748b);
font-weight: 300;
}
.review-text {
font-family: var(--font-family-outfit, "Outfit", sans-serif);
font-size: 16px;
font-weight: 400;
line-height: 26px;
color: var(--color-text, #1e293b);
margin: 0;
}
</style>
<article class="review">
<div class="review-header">
<div class="stars">${this.renderStars()}</div>
<div class="review-meta">
<span class="author">${this.author}</span>
${this.date ? `<span class="date">• ${this.date}</span>` : ""}
</div>
</div>
<p class="review-text">
<slot></slot>
</p>
</article>
`;
}
}
customElements.define("book-review-item", BookReviewItem);

View File

@@ -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 = `
<style>
:host {
display: block;
}
.reviews-container {
display: flex;
flex-direction: column;
gap: var(--spacing-lg, 1.5rem);
}
.no-reviews {
font-family: var(--font-family-outfit, "Outfit", sans-serif);
font-size: 16px;
font-weight: 400;
line-height: 24px;
color: var(--color-text-light, #64748b);
text-align: center;
padding: var(--spacing-xl, 2rem);
}
</style>
<div class="reviews-container">
<slot>
<p class="no-reviews">Nog geen recensies. Wees de eerste om een recensie te schrijven!</p>
</slot>
</div>
`;
}
}
customElements.define("book-reviews", BookReviews);

View File

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

View File

@@ -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) => `
<button
class="tab-button ${index === this.activeTab ? "active" : ""}"
type="button"
role="tab"
aria-selected="${index === this.activeTab}"
>
${tab}
</button>
`
)
.join("");
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
}
.tabs-container {
display: flex;
flex-direction: column;
}
.tab-list {
display: flex;
gap: var(--spacing-lg, 1.5rem);
border-bottom: 1px solid var(--color-border, #e2e8f0);
margin-bottom: var(--spacing-md, 1rem);
}
.tab-button {
padding: var(--spacing-sm, 0.5rem) 0;
background: none;
border: none;
font-family: var(--font-family-outfit, "Outfit", sans-serif);
font-size: 16px;
font-weight: 400;
line-height: 24px;
color: var(--color-purple, #951d51);
text-decoration: underline;
text-underline-offset: 3px;
cursor: pointer;
transition: opacity var(--transition-fast, 150ms ease);
position: relative;
}
.tab-button:first-child {
color: var(--color-text, #1e293b);
text-decoration: none;
font-weight: 400;
}
.tab-button.active {
color: var(--color-text, #1e293b);
text-decoration: none;
font-weight: 400;
}
.tab-button:hover {
opacity: 0.7;
}
.tab-panels {
min-height: 100px;
}
</style>
<div class="tabs-container">
<div class="tab-list" role="tablist">
${tabsHtml}
</div>
<div class="tab-panels">
<slot name="panel-0"></slot>
<slot name="panel-1"></slot>
<slot name="panel-2"></slot>
</div>
</div>
`;
// Initialize panel visibility
setTimeout(() => this.updateTabs(), 0);
}
}
customElements.define("content-tabs", ContentTabs);

View File

@@ -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 {
</style>
<div class="accordion-header">
<h3 class="accordion-title">${title}</h3>
<svg class="accordion-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
<chevron-down-icon class="accordion-icon" size="24"></chevron-down-icon>
</div>
<div class="accordion-content">
<slot></slot>

View File

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

View File

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

View File

@@ -0,0 +1,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) => `
<button class="gallery-item" type="button" aria-label="View image ${index + 1}">
<img src="${img}" alt="Book preview ${index + 1}" class="gallery-image" />
</button>
`
)
.join("");
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-sm, 0.5rem);
}
.gallery-item {
aspect-ratio: 3/4;
overflow: hidden;
border-radius: var(--radius-sm, 0.25rem);
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: opacity var(--transition-fast, 150ms ease);
}
.gallery-item:hover {
opacity: 0.8;
}
.gallery-image {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Modal Styles */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.95);
z-index: 1000;
flex-direction: column;
}
.modal-overlay.open {
display: flex;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-md, 1rem);
background-color: rgba(0, 0, 0, 0.5);
}
.modal-close {
width: 40px;
height: 40px;
background: none;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
}
.modal-close svg {
width: 32px;
height: 32px;
color: #ffffff;
}
.zoom-controls {
display: flex;
gap: var(--spacing-sm, 0.5rem);
}
.zoom-btn {
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: var(--radius-sm, 0.25rem);
color: #ffffff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
transition: background var(--transition-fast, 150ms ease);
}
.zoom-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.modal-content {
flex: 1;
overflow: auto;
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-md, 1rem);
}
.modal-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
transition: transform 0.2s ease;
cursor: default;
user-select: none;
-webkit-user-drag: none;
}
</style>
<div class="gallery-grid">
${imagesHtml}
</div>
<div class="modal-overlay">
<div class="modal-header">
<div class="zoom-controls">
<button class="zoom-btn zoom-out" type="button" aria-label="Zoom out"></button>
<button class="zoom-btn zoom-reset" type="button" aria-label="Reset zoom">⟲</button>
<button class="zoom-btn zoom-in" type="button" aria-label="Zoom in">+</button>
</div>
<button class="modal-close" type="button" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="modal-content">
<img class="modal-image" src="" alt="Full size preview" />
</div>
</div>
`;
}
}
customElements.define("image-gallery", ImageGallery);

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

@@ -2,9 +2,16 @@
* Lucide Icons Index
* 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";

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

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

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

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

View File

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

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

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

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

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

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

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