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

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