Compare commits
19 Commits
3aff4d1630
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69c281b8bd | ||
|
|
7367127b50 | ||
|
|
4e650899c8 | ||
|
|
5b068ac51a | ||
|
|
95ffd84aeb | ||
|
|
69e4f160fe | ||
|
|
939a4e9c57 | ||
| 3dbe404443 | |||
|
|
45d0872495 | ||
|
|
ac10ab8f4b | ||
|
|
1a6b47c8d3 | ||
|
|
968d2036b4 | ||
|
|
846cfeef55 | ||
| 7beab685e2 | |||
|
|
0344f06d71 | ||
|
|
f5d2b242c0 | ||
|
|
d8185591f1 | ||
| 80673fd810 | |||
| 49d22484c0 |
492
README.md
Normal file
@@ -0,0 +1,492 @@
|
||||
# Milinda Uitgevers — Herontwerp Mobiele Webshop
|
||||
|
||||
> **Pitch voor het herontwerp van de webshop van Milinda Uitgevers**
|
||||
|
||||
---
|
||||
|
||||
## 📖 Over dit project
|
||||
|
||||
Dit project is een pitch voor het herontwerp van de webshop van Milinda Uitgevers. De huidige website is ruim vijftien jaar oud en voldoet niet meer aan de eisen van vandaag op het gebied van responsive design en toegankelijkheid.
|
||||
|
||||
### Doelstelling
|
||||
|
||||
Het doel van dit herontwerp is om:
|
||||
|
||||
1. **Gebruiksgemak op alle platforms** — Een volledig responsieve mobiele ervaring die naadloos werkt op smartphones, tablets en desktops
|
||||
2. **Toegankelijkheid** — Volledige ondersteuning voor gebruikers met een beperking (WCAG-richtlijnen)
|
||||
3. **Behoud van de "feel"** — De oorspronkelijke sfeer en identiteit van de website behouden terwijl de visuele presentatie wordt gemoderniseerd
|
||||
|
||||
### Waarom deze aanpak?
|
||||
|
||||
We hebben gekozen voor een **Web Components-architectuur** om de volgende redenen:
|
||||
|
||||
- **Geen externe frameworks** — Werkt met vanilla JavaScript, geen React/Vue/Angular nodig
|
||||
- **Eenvoudige integratie** — De componenten kunnen gemakkelijk worden geïntegreerd met de bestaande back-end
|
||||
- **Toekomstbestendig** — Web Components zijn een webstandaard en werken in alle moderne browsers
|
||||
- **Modulair** — Elk component is zelfstandig en herbruikbaar
|
||||
- **Geen server-side libraries** — Volledig client-side, zoals gevraagd in de opdracht
|
||||
|
||||
---
|
||||
|
||||
## 🎯 UX Onderbouwing
|
||||
|
||||
### Paginastructuur
|
||||
|
||||
De pagina's zijn opgebouwd volgens een bewuste hiërarchie die de gebruiker begeleidt van oriëntatie naar actie:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ Header (sticky) │ ← Altijd toegankelijk
|
||||
│ ├── Logo + Menu + Acties │
|
||||
│ ├── Categorie navigatie │
|
||||
│ └── Zoekbalk │
|
||||
├─────────────────────────────────┤
|
||||
│ Hoofdinhoud │ ← Scrollbare content
|
||||
│ ├── Promotieblok (push-box) │
|
||||
│ ├── Boekensecties │
|
||||
│ ├── Categorieën │
|
||||
│ └── Nieuwsbrief │
|
||||
├─────────────────────────────────┤
|
||||
│ Footer │ ← Service informatie
|
||||
│ └── Accordion navigatie │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Ontwerpkeuzes per element
|
||||
|
||||
#### 🔍 Zoekbalk (prominent in header)
|
||||
|
||||
**Waarom:** De zoekfunctie staat bovenaan omdat:
|
||||
- **Directe toegang** — Terugkerende klanten weten vaak al wat ze zoeken
|
||||
- **Tijdsbesparing** — Sneller dan door categorieën navigeren
|
||||
- **Spraakherkenning** — Extra toegankelijkheid voor gebruikers met beperkingen of onderweg
|
||||
- **Verwachting** — Gebruikers verwachten een zoekfunctie bij een webshop
|
||||
|
||||
#### 📱 Mobile Drawer (slide-in navigatie)
|
||||
|
||||
**Waarom een drawer in plaats van dropdown:**
|
||||
- **Meer ruimte** — Volledige schermhoogte voor uitgebreide navigatie
|
||||
- **Touch-vriendelijk** — Grote klikgebieden, swipe om te sluiten
|
||||
- **Focus** — Backdrop voorkomt interactie met achterliggende content
|
||||
- **Overzicht** — Alle categorieën en imprints in één overzicht
|
||||
- **Authenticatie** — Plek voor inlog/registratie knoppen
|
||||
|
||||
#### 📚 Boekkaarten (horizontale layout)
|
||||
|
||||
**Waarom horizontale kaarten:**
|
||||
- **Scanbaarheid** — Cover links trekt de aandacht, tekst rechts geeft context
|
||||
- **Efficiënt** — Meer informatie zichtbaar per kaart dan bij verticale layout
|
||||
- **Touch targets** — Grote klikbare gebieden voor mobiel gebruik
|
||||
- **Consistentie** — Herkenbare structuur door hele site
|
||||
|
||||
**Informatiehiërarchie:**
|
||||
1. **Titel** (vetgedrukt, onderstreept) — Primair identificatie
|
||||
2. **Beschrijving** — Context over de inhoud
|
||||
3. **Auteur** (paars, onderstreept) — Secundair, maar klikbaar
|
||||
4. **Prijs** (vetgedrukt) — Koopbeslissing informatie
|
||||
5. **Winkelwagen knop** — Directe actie
|
||||
|
||||
#### 📦 Push Boxes (promotieblokken)
|
||||
|
||||
**Waarom push boxes:**
|
||||
- **Visuele onderbreking** — Doorbreekt de lijst met boeken
|
||||
- **Merkidentiteit** — Ruimte voor imprint logos (Asoka)
|
||||
- **Call-to-action** — Stuurt naar belangrijke pagina's
|
||||
- **Flexibiliteit** — Twee varianten (default/paars) voor verschillende contexten
|
||||
|
||||
**Plaatsing:**
|
||||
- **Bovenaan homepage** — Introductie van het merk/imprint
|
||||
- **Onder boekdetails** — Klantenservice toegang na productinfo
|
||||
- **Strategisch** — Voorkomt "scroll fatigue" bij lange pagina's
|
||||
|
||||
#### 🏷️ Categoriekaarten (grid layout)
|
||||
|
||||
**Waarom visuele categorieën:**
|
||||
- **Herkenning** — Iconen zijn sneller te verwerken dan tekst
|
||||
- **Uitnodigend** — Visuele elementen nodigen uit tot verkennen
|
||||
- **Oriëntatie** — Helpt nieuwe bezoekers het aanbod te begrijpen
|
||||
- **Twee kolommen** — Optimaal voor mobiele weergave
|
||||
|
||||
#### 📑 Tabbladen (boekdetailpagina)
|
||||
|
||||
**Waarom tabs voor Beschrijving/Inzage/Recensies:**
|
||||
- **Ruimtebesparing** — Voorkomt zeer lange pagina's
|
||||
- **Gebruikerskeuze** — Laat gebruiker kiezen wat relevant is
|
||||
- **Focus** — Toont één type informatie tegelijk
|
||||
- **Verwachting** — Bekend patroon van andere webshops
|
||||
|
||||
#### 📰 Nieuwsbrief sectie
|
||||
|
||||
**Waarom onderaan (maar boven footer):**
|
||||
- **Niet opdringerig** — Staat niet in de weg van productinhoud
|
||||
- **Logische plek** — Na het bekijken van content, uitnodiging om verbonden te blijven
|
||||
- **Visueel onderscheid** — Border maakt het herkenbaar als apart element
|
||||
|
||||
#### 🦶 Footer met accordions
|
||||
|
||||
**Waarom accordions:**
|
||||
- **Ruimtebesparing** — Veel links zonder lange scrolllijsten
|
||||
- **Georganiseerd** — Duidelijke categorisering van footer content
|
||||
- **Touch-vriendelijk** — Grote tap targets voor mobiel
|
||||
- **Verwachting** — Standaard patroon voor mobiele footers
|
||||
|
||||
### Visuele hiërarchie
|
||||
|
||||
De kleurkeuzes ondersteunen de gebruikerservaring:
|
||||
|
||||
| Element | Kleur | Reden |
|
||||
|---------|-------|-------|
|
||||
| Primaire acties | Paars (#951D51) | Opvallend, merkidentiteit |
|
||||
| Links | Paars + onderstreept | Herkenbaar als klikbaar |
|
||||
| Titels | Zwart + vetgedrukt | Maximaal contrast, hiërarchie |
|
||||
| Secundaire tekst | Grijs | Ondersteunend, niet afleidend |
|
||||
| Achtergronden | Lichtgrijs varianten | Subtiel onderscheid tussen secties |
|
||||
|
||||
### Scroll-gedrag header
|
||||
|
||||
**Smart header die inklapt bij scrollen:**
|
||||
- **Naar beneden** — Header klapt deels in, meer ruimte voor content
|
||||
- **Naar boven** — Header verschijnt weer, navigatie altijd bereikbaar
|
||||
- **Drempel van 100px** — Voorkomt "flikkeren" bij kleine scrollbewegingen
|
||||
|
||||
Dit patroon maximaliseert schermruimte terwijl navigatie toegankelijk blijft.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Project starten
|
||||
|
||||
### Vereisten
|
||||
|
||||
- Node.js 18.0.0 of hoger
|
||||
|
||||
### Installatie
|
||||
|
||||
```bash
|
||||
# Installeer dependencies
|
||||
npm install
|
||||
|
||||
# Start de development server
|
||||
npm start
|
||||
```
|
||||
|
||||
De applicatie is vervolgens beschikbaar op `http://localhost:3000`.
|
||||
|
||||
### Beschikbare pagina's
|
||||
|
||||
| Pagina | URL | Beschrijving |
|
||||
|--------|-----|--------------|
|
||||
| Homepage | `/index.html` | Overzicht met uitgelichte boeken, categorieën en nieuwsbrief |
|
||||
| Boekdetail | `/book.html` | Detailpagina voor "Zen is opendoen" |
|
||||
|
||||
---
|
||||
|
||||
## 📁 Projectstructuur
|
||||
|
||||
```
|
||||
milinda-pitch/
|
||||
├── css/
|
||||
│ └── styles.css # Globale stijlen en design tokens
|
||||
├── images/ # Afbeeldingen en iconen
|
||||
├── js/
|
||||
│ ├── app.js # Hoofdapplicatie entry point
|
||||
│ ├── components/ # Web Components
|
||||
│ ├── icons/ # SVG icon components
|
||||
│ └── store/ # State management (winkelwagen)
|
||||
├── index.html # Homepage
|
||||
├── book.html # Boekdetailpagina
|
||||
└── package.json # Project configuratie
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Componenten
|
||||
|
||||
### Layout Componenten
|
||||
|
||||
| Component | Bestand | Beschrijving |
|
||||
|-----------|---------|--------------|
|
||||
| `<site-header>` | `site-header.js` | Sticky header met scroll-gedrag: klapt in bij scrollen naar beneden, verschijnt bij omhoog scrollen |
|
||||
| `<top-bar>` | `top-bar.js` | Bovenste balk met logo, menu-knop en acties (profiel, winkelwagen) |
|
||||
| `<horizontal-scroll-nav>` | `horizontal-scroll-nav.js` | Horizontaal scrollbare categorie-navigatie met pill-vormige knoppen |
|
||||
| `<search-bar>` | `search-bar.js` | Zoekbalk met spraakherkenning (Nederlands) en zoek-icoon |
|
||||
| `<site-content>` | `site-content.js` | Wrapper voor de hoofdinhoud van de pagina |
|
||||
| `<site-footer>` | `site-footer.js` | Footer met accordion-secties en linkkolommen |
|
||||
| `<mobile-drawer>` | `mobile-drawer.js` | Slide-in navigatiemenu vanaf de linkerzijde met backdrop |
|
||||
|
||||
### Content Componenten
|
||||
|
||||
| Component | Bestand | Beschrijving |
|
||||
|-----------|---------|--------------|
|
||||
| `<book-card>` | `book-card.js` | Boekkaart met cover, titel, beschrijving, auteur en prijs. Ondersteunt licht/donker thema |
|
||||
| `<book-details>` | `book-details.js` | Uitgebreide boekdetails met metadata, categorieën en koopknoppen |
|
||||
| `<book-description>` | `book-description.js` | Gestileerde boekbeschrijving met citaten |
|
||||
| `<book-reviews>` | `book-reviews.js` | Container voor boekrecensies |
|
||||
| `<book-review-item>` | `book-review-item.js` | Individuele recensie met sterren, auteur en datum |
|
||||
| `<category-card>` | `category-card.js` | Categoriekaart met icoon en titel |
|
||||
| `<image-gallery>` | `image-gallery.js` | Afbeeldingsgalerij met thumbnails |
|
||||
| `<content-tabs>` | `content-tabs.js` | Tabblad-interface voor verschillende secties (Beschrijving, Inzage, Recensies) met volledige keyboard navigatie |
|
||||
|
||||
### Interactieve Componenten
|
||||
|
||||
| Component | Bestand | Beschrijving |
|
||||
|-----------|---------|--------------|
|
||||
| `<push-box>` | `push-box.js` | Promotionele container met logo, titel en CTA. Ondersteunt `variant="purple"` |
|
||||
| `<newsletter-signup>` | `newsletter-signup.js` | Nieuwsbrief aanmeldformulier met e-mail input |
|
||||
| `<footer-accordion-item>` | `footer-accordion-item.js` | Inklapbare sectie voor de footer met ARIA-ondersteuning |
|
||||
| `<add-to-cart-button>` | `add-to-cart-button.js` | Plus-knop voor toevoegen aan winkelwagen |
|
||||
|
||||
### Button Componenten
|
||||
|
||||
| Component | Bestand | Beschrijving |
|
||||
|-----------|---------|--------------|
|
||||
| `<arrow-button>` | `arrow-button.js` | Link met ronde pijl-icoon |
|
||||
| `<cta-button>` | `cta-button.js` | Full-width call-to-action knop |
|
||||
| `<icon-cta-button>` | `icon-cta-button.js` | CTA knop met icoon (winkelwagen, e-book) |
|
||||
| `<icon-link-button>` | `icon-link-button.js` | Link met icoon (verlanglijstje, recensie) |
|
||||
| `<action-links-list>` | `action-links-list.js` | Gegroepeerde actie-links |
|
||||
|
||||
### Utility Componenten
|
||||
|
||||
| Component | Bestand | Beschrijving |
|
||||
|-----------|---------|--------------|
|
||||
| `<section-title>` | `section-title.js` | Consistente sectietitel |
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Iconen
|
||||
|
||||
Alle iconen zijn geïmplementeerd als Web Components en/of exporteerbare functies. Ze ondersteunen `size`, `color` en `stroke-width` attributen.
|
||||
|
||||
| Icon Component | Bestand | Beschrijving |
|
||||
|----------------|---------|--------------|
|
||||
| `<menu-icon>` | `menu-icon.js` | Hamburger menu icoon (3 lijnen) |
|
||||
| `<user-icon>` | `user-icon.js` | Gebruikersprofiel icoon |
|
||||
| `<shopping-bag-icon>` | `shopping-bag-icon.js` | Boodschappentas icoon |
|
||||
| `<arrow-circle-right-icon>` | `arrow-circle-right-icon.js` | Pijl in cirkel (rechts) |
|
||||
| `<book-open-icon>` | `book-open-icon.js` | Open boek icoon (placeholder) |
|
||||
| `<clipboard-icon>` | `clipboard-icon.js` | Klembord icoon |
|
||||
| `<chevron-down-icon>` | `chevron-down-icon.js` | Pijl naar beneden (accordion) |
|
||||
| `<close-icon>` | `close-icon.js` | Sluiten/kruis icoon |
|
||||
|
||||
### Icon Functies
|
||||
|
||||
| Functie | Bestand | Beschrijving |
|
||||
|---------|---------|--------------|
|
||||
| `micIcon()` | `mic.js` | Microfoon icoon (spraakherkenning) |
|
||||
| `searchIcon()` | `search.js` | Vergrootglas icoon |
|
||||
| `sendIcon()` | `send-icon.js` | Verstuur/pijl icoon (nieuwsbrief) |
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Design System
|
||||
|
||||
### Lettertypen
|
||||
|
||||
| Font | Gewichten | Gebruik |
|
||||
|------|-----------|---------|
|
||||
| **Outfit** | 400 (Regular), 700 (Bold) | Primair lettertype voor alle tekst, titels en UI elementen |
|
||||
| System fonts | - | Fallback: system-ui, -apple-system, etc. |
|
||||
|
||||
Het Outfit lettertype wordt geladen via Google Fonts CDN.
|
||||
|
||||
### Kleuren
|
||||
|
||||
#### Primaire Kleuren
|
||||
|
||||
| Naam | Waarde | CSS Variable | Gebruik |
|
||||
|------|--------|--------------|---------|
|
||||
| **Purple (primair)** | `#951D51` | `--color-button-primary`, `--color-purple` | Knoppen, links, accenten |
|
||||
| **Purple Dark** | `#7A1842` | `--color-purple-dark` | Hover states |
|
||||
|
||||
#### Tekst Kleuren
|
||||
|
||||
| Naam | Waarde | CSS Variable | Gebruik |
|
||||
|------|--------|--------------|---------|
|
||||
| **Text** | `#1E293B` | `--color-text` | Primaire tekst |
|
||||
| **Text Light** | `#64748B` | `--color-text-light` | Secundaire tekst |
|
||||
| **Text Inverse** | `#FFFFFF` | `--color-text-inverse` | Tekst op donkere achtergrond |
|
||||
| **Black** | `#000000` | `--color-black` | Titels en koppen |
|
||||
|
||||
#### Achtergrond Kleuren
|
||||
|
||||
| Naam | Waarde | CSS Variable | Gebruik |
|
||||
|------|--------|--------------|---------|
|
||||
| **Background** | `#FFFFFF` | `--color-background` | Primaire achtergrond |
|
||||
| **Background Secondary** | `#F8FAFC` | `--color-background-secondary` | Pagina achtergrond |
|
||||
| **Background Tertiary** | `#F1F5F9` | `--color-background-tertiary` | Cards, secties |
|
||||
| **Push Box BG** | `#EBEEF4` | `--color-push-box-bg` | Promotie blokken |
|
||||
| **Card Dark BG** | `#EBEEF4` | `--color-card-dark-bg` | Donkere kaarten |
|
||||
| **Category Card** | `#F5F4FC` | - | Categorie kaarten |
|
||||
|
||||
#### Overige Kleuren
|
||||
|
||||
| Naam | Waarde | CSS Variable | Gebruik |
|
||||
|------|--------|--------------|---------|
|
||||
| **Border** | `#E2E8F0` | `--color-border` | Borders en scheidingslijnen |
|
||||
| **Accent** | `#F59E0B` | `--color-accent` | Sterren, favoriet icoon |
|
||||
| **Success** | `#22C55E` | `--color-success` | Bevestigingen |
|
||||
| **Error** | `#EF4444` | `--color-error` | Foutmeldingen |
|
||||
|
||||
### Spacing
|
||||
|
||||
| Naam | Waarde | CSS Variable |
|
||||
|------|--------|--------------|
|
||||
| **XS** | 4px (0.25rem) | `--spacing-xs` |
|
||||
| **SM** | 8px (0.5rem) | `--spacing-sm` |
|
||||
| **MD** | 16px (1rem) | `--spacing-md` |
|
||||
| **LG** | 24px (1.5rem) | `--spacing-lg` |
|
||||
| **XL** | 32px (2rem) | `--spacing-xl` |
|
||||
| **2XL** | 48px (3rem) | `--spacing-2xl` |
|
||||
|
||||
### Typografie Schaal
|
||||
|
||||
| Naam | Waarde | CSS Variable |
|
||||
|------|--------|--------------|
|
||||
| **XS** | 12px (0.75rem) | `--font-size-xs` |
|
||||
| **SM** | 14px (0.875rem) | `--font-size-sm` |
|
||||
| **Base** | 16px (1rem) | `--font-size-base` |
|
||||
| **LG** | 18px (1.125rem) | `--font-size-lg` |
|
||||
| **XL** | 20px (1.25rem) | `--font-size-xl` |
|
||||
| **2XL** | 24px (1.5rem) | `--font-size-2xl` |
|
||||
| **3XL** | 30px (1.875rem) | `--font-size-3xl` |
|
||||
|
||||
### Border Radius
|
||||
|
||||
| Naam | Waarde | CSS Variable |
|
||||
|------|--------|--------------|
|
||||
| **SM** | 4px (0.25rem) | `--radius-sm` |
|
||||
| **MD** | 8px (0.5rem) | `--radius-md` |
|
||||
| **LG** | 12px (0.75rem) | `--radius-lg` |
|
||||
| **XL** | 16px (1rem) | `--radius-xl` |
|
||||
| **Full** | 9999px | `--radius-full` |
|
||||
|
||||
### Transities
|
||||
|
||||
| Naam | Waarde | CSS Variable |
|
||||
|------|--------|--------------|
|
||||
| **Fast** | 150ms ease | `--transition-fast` |
|
||||
| **Normal** | 250ms ease | `--transition-normal` |
|
||||
| **Slow** | 350ms ease | `--transition-slow` |
|
||||
|
||||
---
|
||||
|
||||
## ♿ Toegankelijkheid (WCAG)
|
||||
|
||||
Dit project is gebouwd met toegankelijkheid als prioriteit en volgt de WCAG 2.1 richtlijnen.
|
||||
|
||||
### Geïmplementeerde functies
|
||||
|
||||
#### Skip-to-content link
|
||||
- Verborgen link die verschijnt bij keyboard focus
|
||||
- Stelt gebruikers in staat direct naar de hoofdinhoud te navigeren
|
||||
- Slaat herhalende navigatie-elementen over
|
||||
- Geplaatst binnen de header landmark voor correcte WCAG compliance
|
||||
|
||||
#### Semantische HTML
|
||||
- Correcte gebruik van `<header>`, `<nav>`, `<main>`, `<footer>`, `<section>`, `<article>`
|
||||
- Logische heading hiërarchie (h1, h2, h3)
|
||||
- `<main>` element met `id="main-content"` voor skip-link target
|
||||
|
||||
#### ARIA Labels en Rollen
|
||||
- `aria-label` op alle icon buttons (menu, profiel, winkelwagen, zoeken, etc.)
|
||||
- `aria-expanded` en `aria-controls` op accordion componenten
|
||||
- `role="tablist"`, `role="tab"`, en `role="tabpanel"` voor tabbed interfaces
|
||||
- `aria-selected` voor actieve tabs
|
||||
- `aria-current` voor actieve navigatie-items
|
||||
- `aria-pressed` voor toggle buttons
|
||||
|
||||
#### Keyboard Navigatie
|
||||
- Alle interactieve elementen zijn bereikbaar met Tab
|
||||
- Tab componenten ondersteunen pijltjestoetsen (←/→), Home, en End
|
||||
- Escape sluit de mobile drawer
|
||||
- Zichtbare focus indicators op alle focusable elementen
|
||||
- Logische focus volgorde
|
||||
|
||||
#### Formulieren
|
||||
- `aria-label` op input velden
|
||||
- Associatie tussen labels en inputs
|
||||
- Duidelijke foutmeldingen
|
||||
|
||||
#### Spraakherkenning
|
||||
- Zoekfunctie met Nederlandse spraakherkenning (`lang="nl-NL"`)
|
||||
- Visuele feedback tijdens luisteren (pulse animatie)
|
||||
- Graceful degradation in browsers zonder ondersteuning
|
||||
|
||||
#### Taal
|
||||
- `lang="nl"` attribuut op HTML element
|
||||
- Correcte taalinstelling voor screenreaders
|
||||
- Alle ARIA labels zijn in het Nederlands
|
||||
|
||||
### Focus Stijlen
|
||||
|
||||
Alle interactieve elementen hebben duidelijke focus indicators:
|
||||
|
||||
```css
|
||||
/* Voorbeeld focus stijl */
|
||||
element:focus {
|
||||
outline: 2px solid var(--color-purple);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 Responsive Design
|
||||
|
||||
> **Let op:** De huidige implementatie toont een gefixeerde mobiele container (430px) puur ter demonstratie voor deze pitch. Bij de daadwerkelijke ontwikkeling wordt de website volledig responsive gebouwd voor alle schermformaten.
|
||||
|
||||
### Pitch weergave
|
||||
|
||||
| Breakpoint | Gedrag |
|
||||
|------------|--------|
|
||||
| < 431px | Volledige breedte, mobiele ervaring |
|
||||
| ≥ 431px | Gecentreerde container met afgeronde hoeken en schaduw (pitch preview) |
|
||||
|
||||
### Geplande responsive implementatie
|
||||
|
||||
Bij de definitieve ontwikkeling worden de volgende breakpoints toegevoegd:
|
||||
|
||||
| Breakpoint | Apparaat | Aanpassingen |
|
||||
|------------|----------|--------------|
|
||||
| < 640px | Mobiel | Huidige ontwerp, enkele kolom |
|
||||
| 640px - 1024px | Tablet | Twee-koloms grid voor boeken, aangepaste navigatie |
|
||||
| > 1024px | Desktop | Volledige sidebar navigatie, drie-koloms grid, uitgebreide header |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Technische details
|
||||
|
||||
### Browser ondersteuning
|
||||
|
||||
- Chrome 90+
|
||||
- Firefox 90+
|
||||
- Safari 14+
|
||||
- Edge 90+
|
||||
|
||||
### Geen build stap nodig
|
||||
|
||||
Het project maakt gebruik van native ES modules en heeft geen build tooling nodig. Alle JavaScript wordt direct door de browser geladen.
|
||||
|
||||
### State management
|
||||
|
||||
Een eenvoudige winkelwagen store (`js/store/cart.js`) beheert de winkelwagen state met:
|
||||
- `addItem()` — Voeg item toe
|
||||
- `removeItem()` — Verwijder item
|
||||
- `getItems()` — Haal alle items op
|
||||
- `getTotal()` — Bereken totaal
|
||||
- `getItemCount()` — Tel items
|
||||
|
||||
Events worden gedispatched voor reactieve updates.
|
||||
|
||||
---
|
||||
|
||||
## 📞 Contact
|
||||
|
||||
**Tim Rijkse**
|
||||
Front-end Developer
|
||||
|
||||
---
|
||||
|
||||
## 📄 Licentie
|
||||
|
||||
Dit project is gemaakt als onderdeel van een pitch voor Milinda Uitgevers en mag uitsluitend worden gebruikt indien de opdracht wordt gegund.
|
||||
478
book.html
@@ -1,338 +1,220 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="The Midnight Library - Between life and death there is a library"
|
||||
content="Zen is opendoen - Dick Verstegen | Milinda Uitgevers"
|
||||
/>
|
||||
<title>The Midnight Library - BookStore</title>
|
||||
<title>Zen is opendoen - Milinda Uitgevers</title>
|
||||
<!-- Fonts are loaded via @font-face in styles.css -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="css/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<mobile-drawer></mobile-drawer>
|
||||
<div class="mobile-container">
|
||||
<site-header>
|
||||
<top-bar>
|
||||
<button slot="menu-button" class="icon-button" aria-label="Menu">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
<a href="#main-content" class="skip-to-content" slot="skip-link"
|
||||
>Ga naar inhoud</a
|
||||
>
|
||||
<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>
|
||||
<top-bar slot="top-bar">
|
||||
<button
|
||||
slot="menu-button"
|
||||
class="icon-button"
|
||||
aria-label="Menu"
|
||||
onclick="window.dispatchEvent(new CustomEvent('toggle-mobile-drawer'))"
|
||||
>
|
||||
<menu-icon></menu-icon>
|
||||
</button>
|
||||
<a slot="logo" href="index.html" class="logo">BookStore</a>
|
||||
<a slot="logo" href="index.html" class="logo">Milinda</a>
|
||||
<div slot="actions" class="actions">
|
||||
<button class="icon-button" aria-label="Profile">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="8" r="5"></circle>
|
||||
<path d="M20 21a8 8 0 0 0-16 0"></path>
|
||||
</svg>
|
||||
<button class="icon-button" aria-label="Profiel">
|
||||
<user-icon></user-icon>
|
||||
</button>
|
||||
<button class="icon-button" aria-label="Shopping basket">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"
|
||||
></path>
|
||||
<path d="M3 6h18"></path>
|
||||
<path d="M16 10a4 4 0 0 1-8 0"></path>
|
||||
</svg>
|
||||
<button class="icon-button" aria-label="Winkelwagen">
|
||||
<shopping-bag-icon></shopping-bag-icon>
|
||||
</button>
|
||||
</div>
|
||||
</top-bar>
|
||||
<horizontal-scroll-nav></horizontal-scroll-nav>
|
||||
<search-bar></search-bar>
|
||||
<horizontal-scroll-nav slot="nav"></horizontal-scroll-nav>
|
||||
<search-bar slot="search"></search-bar>
|
||||
</site-header>
|
||||
|
||||
<main id="main-content">
|
||||
<site-content>
|
||||
<div class="book-detail">
|
||||
<!-- Back navigation -->
|
||||
<a href="index.html" class="back-link">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.75 19.5 8.25 12l7.5-7.5"
|
||||
/>
|
||||
</svg>
|
||||
Back to Books
|
||||
</a>
|
||||
<section class="section">
|
||||
<book-details
|
||||
title="Zen is opendoen"
|
||||
author="Ayya Khema"
|
||||
author-href="#"
|
||||
price="€ 24,95"
|
||||
isbn="9789056703691"
|
||||
format="Paperback"
|
||||
delivery="Direct leverbaar"
|
||||
categories="Zen|#,Integrale spiritualiteit|#"
|
||||
ebook-available
|
||||
></book-details>
|
||||
|
||||
<!-- Book Cover -->
|
||||
<div class="book-cover">
|
||||
<div class="book-cover-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>
|
||||
</div>
|
||||
</div>
|
||||
<action-links-list>
|
||||
<icon-link-button icon="wishlist" href="#">
|
||||
Voeg toe aan verlanglijstje
|
||||
</icon-link-button>
|
||||
<icon-link-button icon="review" href="#">
|
||||
Schrijf een recensie
|
||||
</icon-link-button>
|
||||
</action-links-list>
|
||||
|
||||
<!-- Book Info -->
|
||||
<div class="book-info">
|
||||
<h1 class="book-title">The Midnight Library</h1>
|
||||
<p class="book-author">by Matt Haig</p>
|
||||
|
||||
<div class="book-rating">
|
||||
<div class="stars">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
|
||||
/>
|
||||
</svg>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
|
||||
/>
|
||||
</svg>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
|
||||
/>
|
||||
</svg>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
>
|
||||
<path
|
||||
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="rating-text">4.5 (2,847 reviews)</span>
|
||||
</div>
|
||||
|
||||
<div class="book-meta">
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Format</span>
|
||||
<span class="meta-value">Hardcover</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Pages</span>
|
||||
<span class="meta-value">304</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Language</span>
|
||||
<span class="meta-value">English</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Price & Purchase -->
|
||||
<div class="purchase-section">
|
||||
<div class="price-container">
|
||||
<span class="current-price">$14.99</span>
|
||||
<span class="original-price">$24.99</span>
|
||||
<span class="discount-badge">40% OFF</span>
|
||||
</div>
|
||||
|
||||
<div class="purchase-actions">
|
||||
<button class="btn btn-primary btn-large">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"
|
||||
/>
|
||||
</svg>
|
||||
Add to Cart
|
||||
</button>
|
||||
<button class="btn btn-secondary">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="book-description">
|
||||
<h2 class="section-heading">About this book</h2>
|
||||
<content-tabs tabs="Beschrijving,Inzage,Recensies">
|
||||
<book-description slot="panel-0">
|
||||
<p>
|
||||
Between life and death there is a library, and within that
|
||||
library, the shelves go on forever. Every book provides a chance
|
||||
to try another life you could have lived. To see how things would
|
||||
be if you had made other choices... Would you have done anything
|
||||
different, if you had the chance to undo your regrets?
|
||||
Zen is opendoen bevat een selectie van zeventig columns die
|
||||
Dick Verstegen schreef tussen 2002 en 2017. Hij schreef ze als
|
||||
columnist voor o.a. het boeddhistisch kwartaalblad Vorm &
|
||||
Leegte, het Boeddhistisch Dagblad, Centrum Waerbeke, het Han
|
||||
Fortmann Centrum en de Wijkkrant van Nijmegen-Oost. Zijn
|
||||
columns wijzen zonder uitzondering naar het mysterie van het
|
||||
leven en geven blijk van bewogenheid en overgave, maar de
|
||||
lichte toets ontbreekt nooit. Ze gaan over zeer uiteenlopende
|
||||
onderwerpen, zoals: lente, de ware stem van je hart,
|
||||
compassie, ontroering, woorden, de dood van zijn vrouw, de
|
||||
kathedraal van Royan, het windorgel in Vlissingen, liefde,
|
||||
boeddhaschap, bedelen, emoties, relaties, management,
|
||||
overgave, gedachten, Nepal, leerling zijn, theekommen, zijn
|
||||
vader, stilte, licht en opendoen.
|
||||
</p>
|
||||
<p>
|
||||
A dazzling novel about all the choices that go into a life well
|
||||
lived, from the internationally bestselling author of Reasons to
|
||||
Stay Alive and How to Stop Time.
|
||||
<p class="quote">
|
||||
Uit zijn columns blijkt hoezeer hij zich ervan bewust is dat
|
||||
zenboeddhisme geen mening is, maar een non-duale zienswijze.
|
||||
Non-dualiteit is zien dat de dualiteiten geen hindernissen
|
||||
zijn voor een bevrijd bestaan. (...) zo te spreken of te
|
||||
schrijven dat het mysteriekarakter onaangetast blijft. Wat mij
|
||||
betreft is Dick hierin volkomen geslaagd. Hoe hem dit gelukt
|
||||
is, is mij een raadsel. Misschien wel dankzij zijn grote
|
||||
liefde voor de taal die spreekt uit elke bladzijde van deze
|
||||
verzameling literaire miniaturen.
|
||||
</p>
|
||||
</div>
|
||||
<p class="author">—Nico Tydeman</p>
|
||||
</book-description>
|
||||
|
||||
<!-- Reviews -->
|
||||
<div class="reviews-section">
|
||||
<h2 class="section-heading">Customer Reviews</h2>
|
||||
<image-gallery
|
||||
slot="panel-1"
|
||||
images="images/book-insight.jpg,images/book-insight.jpg,images/book-insight.jpg"
|
||||
></image-gallery>
|
||||
|
||||
<div class="review-card">
|
||||
<div class="review-header">
|
||||
<div class="reviewer-info">
|
||||
<span class="reviewer-name">Sarah M.</span>
|
||||
<span class="review-date">December 2025</span>
|
||||
</div>
|
||||
<div class="review-stars">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
|
||||
/>
|
||||
</svg>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
|
||||
/>
|
||||
</svg>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
|
||||
/>
|
||||
</svg>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
|
||||
/>
|
||||
</svg>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="review-text">
|
||||
This book changed my perspective on life. Beautifully written
|
||||
and deeply thought-provoking. A must-read for anyone going
|
||||
through a difficult time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="review-card">
|
||||
<div class="review-header">
|
||||
<div class="reviewer-info">
|
||||
<span class="reviewer-name">James K.</span>
|
||||
<span class="review-date">November 2025</span>
|
||||
</div>
|
||||
<div class="review-stars">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
|
||||
/>
|
||||
</svg>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
|
||||
/>
|
||||
</svg>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
|
||||
/>
|
||||
</svg>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
<book-reviews slot="panel-2">
|
||||
<book-review-item
|
||||
rating="5"
|
||||
author="Maria van der Berg"
|
||||
date="12 januari 2026"
|
||||
>
|
||||
<path
|
||||
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
|
||||
/>
|
||||
</svg>
|
||||
Een prachtige verzameling columns die je aan het denken zet.
|
||||
Dick Verstegen schrijft met zoveel warmte en wijsheid. Elk
|
||||
stukje is een kleine meditatie op zich. Aanrader voor iedereen
|
||||
die geïnteresseerd is in zen en het dagelijks leven.
|
||||
</book-review-item>
|
||||
<book-review-item
|
||||
rating="5"
|
||||
author="Jan Pietersen"
|
||||
date="8 december 2025"
|
||||
>
|
||||
Dit boek heeft mijn kijk op zen volledig veranderd. De columns
|
||||
zijn toegankelijk geschreven en toch diepgaand. Ik lees elke
|
||||
avond een column voor het slapen gaan. Een boek om te
|
||||
koesteren.
|
||||
</book-review-item>
|
||||
<book-review-item
|
||||
rating="4"
|
||||
author="Sophie de Vries"
|
||||
date="23 november 2025"
|
||||
>
|
||||
Mooie, poëtische teksten die je uitnodigen om stil te staan
|
||||
bij het leven. Soms wat abstract, maar overall een waardevolle
|
||||
toevoeging aan mijn boekenplank. De quote van Nico Tydeman op
|
||||
de achterkant vat het perfect samen.
|
||||
</book-review-item>
|
||||
</book-reviews>
|
||||
</content-tabs>
|
||||
</section>
|
||||
|
||||
<div class="content-padding">
|
||||
<push-box variant="purple">
|
||||
<h2 slot="title">
|
||||
Kom je er niet uit of heb je een vraag? Neem contact op met de
|
||||
klantenservice.
|
||||
</h2>
|
||||
<div slot="cta" class="cta-buttons">
|
||||
<arrow-button href="#">Klantenservice</arrow-button>
|
||||
<arrow-button href="#">Neem contact op</arrow-button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="review-text">
|
||||
Great concept and well-executed. The writing flows effortlessly
|
||||
and keeps you engaged throughout. Highly recommend!
|
||||
</p>
|
||||
</push-box>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-outline btn-full">View All Reviews</button>
|
||||
</div>
|
||||
<div class="content-padding">
|
||||
<newsletter-signup
|
||||
title="Blijf op de hoogte"
|
||||
description="Schrijf je in voor onze nieuwsbrief en ontvang het laatste nieuws over nieuwe boeken en aanbiedingen."
|
||||
button-text="Inschrijven"
|
||||
placeholder="Je e-mailadres"
|
||||
></newsletter-signup>
|
||||
</div>
|
||||
</site-content>
|
||||
</main>
|
||||
|
||||
<site-footer></site-footer>
|
||||
<site-footer>
|
||||
<span slot="logo">MILINDA uitgevers</span>
|
||||
|
||||
<footer-accordion-item slot="accordion" title="Service & bestellen">
|
||||
<div class="accordion-links">
|
||||
<a href="#">Bestellen</a>
|
||||
<a href="#">Verzending</a>
|
||||
<a href="#">Retourneren</a>
|
||||
<a href="#">Betaalmethoden</a>
|
||||
</div>
|
||||
</footer-accordion-item>
|
||||
|
||||
<footer-accordion-item slot="accordion" title="Over MILINDA uitgevers">
|
||||
<div class="accordion-links">
|
||||
<a href="#">Onze geschiedenis</a>
|
||||
<a href="#">Ons team</a>
|
||||
<a href="#">Vacatures</a>
|
||||
</div>
|
||||
</footer-accordion-item>
|
||||
|
||||
<footer-accordion-item slot="accordion" title="Populaire categorieën">
|
||||
<div class="accordion-links">
|
||||
<a href="#">Boeddhisme</a>
|
||||
<a href="#">Meditatie</a>
|
||||
<a href="#">Mindfulness</a>
|
||||
<a href="#">Filosofie</a>
|
||||
</div>
|
||||
</footer-accordion-item>
|
||||
|
||||
<footer-accordion-item slot="accordion" title="Accessibility">
|
||||
<div class="accordion-links">
|
||||
<a href="#">Toegankelijkheidsverklaring</a>
|
||||
<a href="#">Hulpmiddelen</a>
|
||||
</div>
|
||||
</footer-accordion-item>
|
||||
|
||||
<div slot="links-left" class="footer-bottom-links">
|
||||
<a href="#">Klantenservice</a>
|
||||
<a href="#">Uitgeverij</a>
|
||||
<a href="#">Neem contact op</a>
|
||||
</div>
|
||||
|
||||
<div slot="links-right" class="footer-bottom-links">
|
||||
<a href="#">Privacyverklaring</a>
|
||||
<a href="#">Algemene voorwaarden</a>
|
||||
<a href="#">Toegankelijkheidsverklaring</a>
|
||||
</div>
|
||||
</site-footer>
|
||||
</div>
|
||||
|
||||
<script type="module" src="js/app.js"></script>
|
||||
|
||||
142
css/styles.css
@@ -1,16 +1,3 @@
|
||||
/* ==========================================================================
|
||||
Font Definitions
|
||||
========================================================================== */
|
||||
|
||||
@font-face {
|
||||
font-family: "Outline";
|
||||
src: url("../fonts/Outline-Regular.woff2") format("woff2"),
|
||||
url("../fonts/Outline-Regular.woff") format("woff");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
CSS Reset & Normalize (Modern Best Practices)
|
||||
Based on normalize.css v8.0.1 + modern resets
|
||||
@@ -101,22 +88,6 @@ textarea:not([rows]) {
|
||||
scroll-margin-block: 5ex;
|
||||
}
|
||||
|
||||
/* Remove all animations, transitions and smooth scroll for people that prefer not to see them */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html:focus-within {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Remove the inner border and padding in Firefox */
|
||||
::-moz-focus-inner {
|
||||
border-style: none;
|
||||
@@ -180,6 +151,7 @@ table {
|
||||
--color-text: #1e293b;
|
||||
--color-text-light: #64748b;
|
||||
--color-text-inverse: #ffffff;
|
||||
--color-black: #000000;
|
||||
|
||||
--color-background: #ffffff;
|
||||
--color-background-secondary: #f8fafc;
|
||||
@@ -188,12 +160,21 @@ table {
|
||||
--color-border: #e2e8f0;
|
||||
--color-border-light: #f1f5f9;
|
||||
|
||||
--color-push-box-bg: #ebeef4;
|
||||
--color-card-dark-bg: #ebeef4;
|
||||
--color-button-primary: #951d51;
|
||||
--color-purple: #951d51;
|
||||
--color-purple-dark: #7a1842;
|
||||
|
||||
/* Layout */
|
||||
--site-header-height: 210px;
|
||||
|
||||
--color-success: #22c55e;
|
||||
--color-error: #ef4444;
|
||||
--color-warning: #f59e0b;
|
||||
|
||||
/* Typography */
|
||||
--font-family-base: "Outline", system-ui, -apple-system, BlinkMacSystemFont,
|
||||
--font-family-base: "Outfit", system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue",
|
||||
sans-serif;
|
||||
--font-family-heading: var(--font-family-base);
|
||||
@@ -206,6 +187,7 @@ table {
|
||||
--font-size-2xl: 1.5rem; /* 24px */
|
||||
--font-size-3xl: 1.875rem; /* 30px */
|
||||
|
||||
--font-weight-light: 300;
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
@@ -303,10 +285,15 @@ top-bar .actions {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
padding-top: 0;
|
||||
background-color: var(--color-background);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
site-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 431px) {
|
||||
body {
|
||||
display: flex;
|
||||
@@ -340,6 +327,30 @@ top-bar .actions {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Skip to Content Link */
|
||||
.skip-to-content {
|
||||
position: absolute;
|
||||
top: -100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--color-button-primary);
|
||||
color: var(--color-text-inverse);
|
||||
font-family: var(--font-family-base);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: top var(--transition-fast);
|
||||
}
|
||||
|
||||
.skip-to-content:focus {
|
||||
top: var(--spacing-sm);
|
||||
outline: 2px solid var(--color-text-inverse);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -353,13 +364,26 @@ top-bar .actions {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-padding {
|
||||
padding-left: var(--spacing-md, 1rem);
|
||||
padding-right: var(--spacing-md, 1rem);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Page Components
|
||||
========================================================================== */
|
||||
|
||||
/* Section */
|
||||
.section {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
/* Full-width within container */
|
||||
width: 100%;
|
||||
/* Padding inside section */
|
||||
padding-left: var(--spacing-md, 1rem);
|
||||
padding-right: var(--spacing-md, 1rem);
|
||||
}
|
||||
|
||||
.section-dark {
|
||||
background-color: var(--color-card-dark-bg);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@@ -379,8 +403,22 @@ top-bar .actions {
|
||||
/* Book Grid */
|
||||
.book-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--spacing-md);
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing-md, 1rem); /* 16px */
|
||||
padding-bottom: var(--spacing-md, 1rem);
|
||||
}
|
||||
|
||||
/* Category Grid */
|
||||
.category-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
gap: var(--spacing-md, 1rem); /* 16px */
|
||||
padding-bottom: var(--spacing-md, 1rem);
|
||||
}
|
||||
|
||||
.category-grid > * {
|
||||
flex: 0 0 calc(50% - var(--spacing-md, 1rem) / 2);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
@@ -671,3 +709,41 @@ top-bar .actions {
|
||||
color: var(--color-text);
|
||||
line-height: var(--line-height-normal);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Footer Styles (Light DOM content for slotted elements)
|
||||
========================================================================== */
|
||||
|
||||
.accordion-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm, 0.5rem);
|
||||
}
|
||||
|
||||
.accordion-links a {
|
||||
color: var(--color-text-inverse, #ffffff);
|
||||
text-decoration: none;
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
transition: opacity var(--transition-fast, 150ms ease);
|
||||
}
|
||||
|
||||
.accordion-links a:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.footer-bottom-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md, 1rem);
|
||||
}
|
||||
|
||||
.footer-bottom-links a {
|
||||
color: var(--color-text-inverse, #ffffff);
|
||||
text-decoration: underline;
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
transition: opacity var(--transition-fast, 150ms ease);
|
||||
}
|
||||
|
||||
.footer-bottom-links a:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
BIN
images/biografieautobiografie.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
images/boeddhisme-en-het-westen.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
images/boeddhismealgemeen.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
images/book-insight.jpg
Normal file
|
After Width: | Height: | Size: 550 KiB |
BIN
images/kinderboeken.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
images/logo-asoka.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
images/meditatie.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
images/mindfulness.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
images/palicanon.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
images/theravada.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
images/tibetaans-boeddhisme.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
images/vipassana.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
329
index.html
@@ -1,86 +1,116 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Milinda - Discover and buy your next favorite book"
|
||||
content="Milinda Uitgevers - Boeken over boeddhisme, meditatie en mindfulness"
|
||||
/>
|
||||
<title>Milinda - Home</title>
|
||||
<!-- Fonts are loaded via @font-face in styles.css -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="css/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<mobile-drawer></mobile-drawer>
|
||||
<div class="mobile-container">
|
||||
<site-header>
|
||||
<top-bar>
|
||||
<button slot="menu-button" class="icon-button" aria-label="Menu">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
<a href="#main-content" class="skip-to-content" slot="skip-link"
|
||||
>Ga naar inhoud</a
|
||||
>
|
||||
<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>
|
||||
<top-bar slot="top-bar">
|
||||
<button
|
||||
slot="menu-button"
|
||||
class="icon-button"
|
||||
aria-label="Menu"
|
||||
onclick="window.dispatchEvent(new CustomEvent('toggle-mobile-drawer'))"
|
||||
>
|
||||
<menu-icon></menu-icon>
|
||||
</button>
|
||||
<a slot="logo" href="index.html" class="logo">Milinda</a>
|
||||
<div slot="actions" class="actions">
|
||||
<button class="icon-button" aria-label="Profile">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="8" r="5"></circle>
|
||||
<path d="M20 21a8 8 0 0 0-16 0"></path>
|
||||
</svg>
|
||||
<button class="icon-button" aria-label="Profiel">
|
||||
<user-icon></user-icon>
|
||||
</button>
|
||||
<button class="icon-button" aria-label="Shopping basket">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"
|
||||
></path>
|
||||
<path d="M3 6h18"></path>
|
||||
<path d="M16 10a4 4 0 0 1-8 0"></path>
|
||||
</svg>
|
||||
<button class="icon-button" aria-label="Winkelwagen">
|
||||
<shopping-bag-icon></shopping-bag-icon>
|
||||
</button>
|
||||
</div>
|
||||
</top-bar>
|
||||
<horizontal-scroll-nav></horizontal-scroll-nav>
|
||||
<search-bar></search-bar>
|
||||
<horizontal-scroll-nav slot="nav"></horizontal-scroll-nav>
|
||||
<search-bar slot="search"></search-bar>
|
||||
</site-header>
|
||||
|
||||
<main id="main-content">
|
||||
<site-content>
|
||||
<section class="section">
|
||||
<h2 class="section-title">Featured Books</h2>
|
||||
<div class="content-padding">
|
||||
<push-box>
|
||||
<img slot="logo" src="images/logo-asoka.png" alt="Asoka Logo" />
|
||||
<h2 slot="title">
|
||||
Gespecialiseerd op het vlak van boeddhisme en aanverwante
|
||||
Oost-West thema's
|
||||
</h2>
|
||||
<arrow-button slot="cta" href="#">Meer over Asoka</arrow-button>
|
||||
</push-box>
|
||||
</div>
|
||||
|
||||
<section class="section section">
|
||||
<section-title text="Recent verschenen boeken"></section-title>
|
||||
<div class="book-grid">
|
||||
<book-card
|
||||
title="The Midnight Library"
|
||||
description="A library of infinite possibilities"
|
||||
author="Matt Haig"
|
||||
price="$14.99"
|
||||
rating="4.5"
|
||||
href="book.html"
|
||||
theme="dark"
|
||||
></book-card>
|
||||
<book-card
|
||||
title="Atomic Habits"
|
||||
description="A simple, proven system for breaking bad habits and forming good ones"
|
||||
author="James Clear"
|
||||
price="$16.99"
|
||||
rating="5"
|
||||
href="book.html"
|
||||
theme="dark"
|
||||
></book-card>
|
||||
<book-card
|
||||
title="The Psychology of Money"
|
||||
description="A book about the psychology of money and how it affects our lives"
|
||||
author="Morgan Housel"
|
||||
price="$12.99"
|
||||
rating="4"
|
||||
href="book.html"
|
||||
theme="dark"
|
||||
></book-card>
|
||||
<book-card
|
||||
title="Project Hail Mary"
|
||||
description="A book about the science of space travel and the future of humanity"
|
||||
author="Andy Weir"
|
||||
price="$18.99"
|
||||
rating="4.5"
|
||||
href="book.html"
|
||||
theme="dark"
|
||||
></book-card>
|
||||
<cta-button href="#"
|
||||
>Toon meer recent verschenen boeken</cta-button
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section section-dark">
|
||||
<section-title text="Meest verkochte boeken"></section-title>
|
||||
<div class="book-grid">
|
||||
<book-card
|
||||
title="The Midnight Library"
|
||||
description="A library of infinite possibilities"
|
||||
author="Matt Haig"
|
||||
price="$14.99"
|
||||
rating="4.5"
|
||||
@@ -88,6 +118,7 @@
|
||||
></book-card>
|
||||
<book-card
|
||||
title="Atomic Habits"
|
||||
description="A simple, proven system for breaking bad habits and forming good ones"
|
||||
author="James Clear"
|
||||
price="$16.99"
|
||||
rating="5"
|
||||
@@ -95,6 +126,7 @@
|
||||
></book-card>
|
||||
<book-card
|
||||
title="The Psychology of Money"
|
||||
description="A book about the psychology of money and how it affects our lives"
|
||||
author="Morgan Housel"
|
||||
price="$12.99"
|
||||
rating="4"
|
||||
@@ -102,84 +134,145 @@
|
||||
></book-card>
|
||||
<book-card
|
||||
title="Project Hail Mary"
|
||||
description="A book about the science of space travel and the future of humanity"
|
||||
author="Andy Weir"
|
||||
price="$18.99"
|
||||
rating="4.5"
|
||||
href="book.html"
|
||||
></book-card>
|
||||
<cta-button href="#">Toon meer meest verkochte boeken</cta-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="section-title">New Releases</h2>
|
||||
<div class="book-grid">
|
||||
<book-card
|
||||
title="Tomorrow, and Tomorrow"
|
||||
author="Gabrielle Zevin"
|
||||
price="$15.99"
|
||||
rating="4"
|
||||
href="book.html"
|
||||
></book-card>
|
||||
<book-card
|
||||
title="The House in the Pines"
|
||||
author="Ana Reyes"
|
||||
price="$13.99"
|
||||
rating="3.5"
|
||||
href="book.html"
|
||||
></book-card>
|
||||
<book-card
|
||||
title="Demon Copperhead"
|
||||
author="Barbara Kingsolver"
|
||||
price="$19.99"
|
||||
rating="5"
|
||||
href="book.html"
|
||||
></book-card>
|
||||
<book-card
|
||||
title="The Light We Carry"
|
||||
author="Michelle Obama"
|
||||
price="$17.99"
|
||||
rating="4.5"
|
||||
href="book.html"
|
||||
></book-card>
|
||||
<section-title text="Onderwerpen"></section-title>
|
||||
<div class="category-grid">
|
||||
<category-card
|
||||
title="Biografie / autobiografie"
|
||||
icon="images/biografieautobiografie.png"
|
||||
href="category.html"
|
||||
></category-card>
|
||||
<category-card
|
||||
title="Boeddhisme (algemeen)"
|
||||
icon="images/boeddhismealgemeen.png"
|
||||
href="category.html"
|
||||
></category-card>
|
||||
<category-card
|
||||
title="Boeddhisme en het Westen"
|
||||
icon="images/boeddhisme-en-het-westen.png"
|
||||
href="category.html"
|
||||
></category-card>
|
||||
<category-card
|
||||
title="Kinderboeken"
|
||||
icon="images/kinderboeken.png"
|
||||
href="category.html"
|
||||
></category-card>
|
||||
<category-card
|
||||
title="Meditatie"
|
||||
icon="images/meditatie.png"
|
||||
href="category.html"
|
||||
></category-card>
|
||||
<category-card
|
||||
title="Mindfulness"
|
||||
icon="images/mindfulness.png"
|
||||
href="category.html"
|
||||
></category-card>
|
||||
<category-card
|
||||
title="Pali-canon"
|
||||
icon="images/palicanon.png"
|
||||
href="category.html"
|
||||
></category-card>
|
||||
<category-card
|
||||
title="Theravada"
|
||||
icon="images/theravada.png"
|
||||
href="category.html"
|
||||
></category-card>
|
||||
<category-card
|
||||
title="Tibetaans boeddhisme"
|
||||
icon="images/tibetaans-boeddhisme.png"
|
||||
href="category.html"
|
||||
></category-card>
|
||||
<category-card
|
||||
title="Vipassana"
|
||||
icon="images/vipassana.png"
|
||||
href="category.html"
|
||||
></category-card>
|
||||
</div>
|
||||
<cta-button href="#">Toon alle onderwerpen</cta-button>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="section-title">Best Sellers</h2>
|
||||
<div class="book-grid">
|
||||
<book-card
|
||||
title="Where the Crawdads Sing"
|
||||
author="Delia Owens"
|
||||
price="$11.99"
|
||||
rating="4.5"
|
||||
href="book.html"
|
||||
></book-card>
|
||||
<book-card
|
||||
title="The Silent Patient"
|
||||
author="Alex Michaelides"
|
||||
price="$14.99"
|
||||
rating="4"
|
||||
href="book.html"
|
||||
></book-card>
|
||||
<book-card
|
||||
title="Educated"
|
||||
author="Tara Westover"
|
||||
price="$13.99"
|
||||
rating="4.5"
|
||||
href="book.html"
|
||||
></book-card>
|
||||
<book-card
|
||||
title="Becoming"
|
||||
author="Michelle Obama"
|
||||
price="$16.99"
|
||||
rating="5"
|
||||
href="book.html"
|
||||
></book-card>
|
||||
<div class="content-padding">
|
||||
<push-box>
|
||||
<h2 slot="title">
|
||||
Gespecialiseerd op het vlak van boeddhisme en aanverwante
|
||||
Oost-West thema's
|
||||
</h2>
|
||||
<div slot="cta" class="cta-buttons">
|
||||
<arrow-button href="#">Klantenservice</arrow-button>
|
||||
<arrow-button href="#">Neem contact op</arrow-button>
|
||||
</div>
|
||||
</push-box>
|
||||
</div>
|
||||
|
||||
<div class="content-padding">
|
||||
<newsletter-signup
|
||||
title="Blijf op de hoogte"
|
||||
description="Schrijf je in voor onze nieuwsbrief en ontvang het laatste nieuws over nieuwe boeken en aanbiedingen."
|
||||
button-text="Inschrijven"
|
||||
placeholder="Je e-mailadres"
|
||||
></newsletter-signup>
|
||||
</div>
|
||||
</section>
|
||||
</site-content>
|
||||
</main>
|
||||
|
||||
<site-footer></site-footer>
|
||||
<site-footer>
|
||||
<span slot="logo">MILINDA uitgevers</span>
|
||||
|
||||
<footer-accordion-item slot="accordion" title="Service & bestellen">
|
||||
<div class="accordion-links">
|
||||
<a href="#">Bestellen</a>
|
||||
<a href="#">Verzending</a>
|
||||
<a href="#">Retourneren</a>
|
||||
<a href="#">Betaalmethoden</a>
|
||||
</div>
|
||||
</footer-accordion-item>
|
||||
|
||||
<footer-accordion-item slot="accordion" title="Over MILINDA uitgevers">
|
||||
<div class="accordion-links">
|
||||
<a href="#">Onze geschiedenis</a>
|
||||
<a href="#">Ons team</a>
|
||||
<a href="#">Vacatures</a>
|
||||
</div>
|
||||
</footer-accordion-item>
|
||||
|
||||
<footer-accordion-item slot="accordion" title="Populaire categorieën">
|
||||
<div class="accordion-links">
|
||||
<a href="#">Boeddhisme</a>
|
||||
<a href="#">Meditatie</a>
|
||||
<a href="#">Mindfulness</a>
|
||||
<a href="#">Filosofie</a>
|
||||
</div>
|
||||
</footer-accordion-item>
|
||||
|
||||
<footer-accordion-item slot="accordion" title="Accessibility">
|
||||
<div class="accordion-links">
|
||||
<a href="#">Toegankelijkheidsverklaring</a>
|
||||
<a href="#">Hulpmiddelen</a>
|
||||
</div>
|
||||
</footer-accordion-item>
|
||||
|
||||
<div slot="links-left" class="footer-bottom-links">
|
||||
<a href="#">Klantenservice</a>
|
||||
<a href="#">Uitgeverij</a>
|
||||
<a href="#">Neem contact op</a>
|
||||
</div>
|
||||
|
||||
<div slot="links-right" class="footer-bottom-links">
|
||||
<a href="#">Privacyverklaring</a>
|
||||
<a href="#">Algemene voorwaarden</a>
|
||||
<a href="#">Toegankelijkheidsverklaring</a>
|
||||
</div>
|
||||
</site-footer>
|
||||
</div>
|
||||
|
||||
<script type="module" src="js/app.js"></script>
|
||||
|
||||
102
js/app.js
@@ -3,16 +3,96 @@
|
||||
* Imports and registers all web components
|
||||
*/
|
||||
|
||||
// Import all components
|
||||
import './components/site-header.js';
|
||||
import './components/top-bar.js';
|
||||
import './components/horizontal-scroll-nav.js';
|
||||
import './components/search-bar.js';
|
||||
import './components/site-content.js';
|
||||
import './components/site-footer.js';
|
||||
import './components/book-card.js';
|
||||
// Import cart store (must be first to set up window.cartStore)
|
||||
import cart from "./store/cart.js";
|
||||
|
||||
// App initialization (if needed)
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('BookStore app initialized');
|
||||
// Import all components
|
||||
import "./components/site-header.js";
|
||||
import "./components/top-bar.js";
|
||||
import "./components/horizontal-scroll-nav.js";
|
||||
import "./components/search-bar.js";
|
||||
import "./components/site-content.js";
|
||||
import "./components/site-footer.js";
|
||||
import "./components/footer-accordion-item.js";
|
||||
import "./components/book-card.js";
|
||||
import "./components/push-box.js";
|
||||
import "./components/arrow-button.js";
|
||||
import "./components/section-title.js";
|
||||
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";
|
||||
import "./components/mobile-drawer.js";
|
||||
|
||||
// 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";
|
||||
import "./icons/close-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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
36
js/components/action-links-list.js
Normal 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);
|
||||
77
js/components/add-to-cart-button.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Add to Cart Button Component
|
||||
* Button with plus icon and shopping bag icon
|
||||
*/
|
||||
import { plusIcon } from "../icons/plus.js";
|
||||
import { shoppingBagIcon } from "../icons/shopping-bag.js";
|
||||
|
||||
class AddToCartButton extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const button = this.shadowRoot.querySelector(".add-to-cart-button");
|
||||
if (button) {
|
||||
button.addEventListener("click", () => {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("add-to-cart", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.add-to-cart-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-xs, 0.25rem);
|
||||
width: 70px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
background-color: var(--color-button-primary, #951d51);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm, 0.25rem);
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-fast, 150ms ease);
|
||||
}
|
||||
|
||||
.add-to-cart-button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.add-to-cart-button:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.add-to-cart-button svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--color-text-inverse, #ffffff);
|
||||
}
|
||||
</style>
|
||||
<button class="add-to-cart-button" type="button" aria-label="Voeg toe aan winkelwagen">
|
||||
${plusIcon({ size: 16, color: "#ffffff" })}
|
||||
${shoppingBagIcon({ size: 16, color: "#ffffff" })}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("add-to-cart-button", AddToCartButton);
|
||||
74
js/components/arrow-button.js
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Arrow Button Component
|
||||
* A CTA link with a circular arrow icon
|
||||
* Uses a slot for the button text content
|
||||
*/
|
||||
class ArrowButton extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ["href"];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
get href() {
|
||||
return this.getAttribute("href") || "#";
|
||||
}
|
||||
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.arrow-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
transition: opacity var(--transition-fast, 150ms ease);
|
||||
}
|
||||
|
||||
.arrow-button:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.button-text {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
</style>
|
||||
<a class="arrow-button" href="${this.href}">
|
||||
<span class="arrow-icon">
|
||||
<arrow-circle-right-icon size="24"></arrow-circle-right-icon>
|
||||
</span>
|
||||
<span class="button-text">
|
||||
<slot></slot>
|
||||
</span>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("arrow-button", ArrowButton);
|
||||
@@ -1,96 +1,99 @@
|
||||
/**
|
||||
* Book Card Component
|
||||
* Reusable card displaying book thumbnail, title, author, and price
|
||||
* Reusable card displaying book thumbnail, title, description, author, and price
|
||||
* Horizontal layout with image on left and content on right
|
||||
*/
|
||||
class BookCard extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ['title', 'author', 'price', 'image', 'href', 'rating'];
|
||||
return [
|
||||
"title",
|
||||
"author",
|
||||
"description",
|
||||
"price",
|
||||
"image",
|
||||
"href",
|
||||
"theme",
|
||||
];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const addToCartButton =
|
||||
this.shadowRoot?.querySelector("add-to-cart-button");
|
||||
if (addToCartButton) {
|
||||
addToCartButton.addEventListener("add-to-cart", (e) => {
|
||||
e.stopPropagation();
|
||||
// Re-dispatch with book details
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("add-to-cart", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: {
|
||||
title: this.title,
|
||||
author: this.author,
|
||||
price: this.price,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
if (this.shadowRoot) {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
}
|
||||
|
||||
get title() {
|
||||
return this.getAttribute('title') || 'Book Title';
|
||||
return this.getAttribute("title") || "Book Title";
|
||||
}
|
||||
|
||||
get author() {
|
||||
return this.getAttribute('author') || 'Author Name';
|
||||
return this.getAttribute("author") || "Author Name";
|
||||
}
|
||||
|
||||
get description() {
|
||||
return this.getAttribute("description") || "";
|
||||
}
|
||||
|
||||
get price() {
|
||||
return this.getAttribute('price') || '$0.00';
|
||||
return this.getAttribute("price") || "$0.00";
|
||||
}
|
||||
|
||||
get image() {
|
||||
return this.getAttribute('image') || '';
|
||||
return this.getAttribute("image") || "";
|
||||
}
|
||||
|
||||
get href() {
|
||||
return this.getAttribute('href') || 'book.html';
|
||||
return this.getAttribute("href") || "book.html";
|
||||
}
|
||||
|
||||
get rating() {
|
||||
return parseFloat(this.getAttribute('rating')) || 0;
|
||||
}
|
||||
|
||||
renderStars(rating) {
|
||||
const fullStars = Math.floor(rating);
|
||||
const hasHalf = rating % 1 >= 0.5;
|
||||
const emptyStars = 5 - fullStars - (hasHalf ? 1 : 0);
|
||||
|
||||
let stars = '';
|
||||
|
||||
// Full stars
|
||||
for (let i = 0; i < fullStars; i++) {
|
||||
stars += `<svg class="star star-full" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// Half star
|
||||
if (hasHalf) {
|
||||
stars += `<svg class="star star-half" viewBox="0 0 24 24">
|
||||
<defs>
|
||||
<linearGradient id="halfGrad">
|
||||
<stop offset="50%" stop-color="currentColor"/>
|
||||
<stop offset="50%" stop-color="transparent"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#halfGrad)" stroke="currentColor" stroke-width="1" d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// Empty stars
|
||||
for (let i = 0; i < emptyStars; i++) {
|
||||
stars += `<svg class="star star-empty" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
return stars;
|
||||
get theme() {
|
||||
return this.getAttribute("theme") || "light"; // 'light' or 'dark'
|
||||
}
|
||||
|
||||
render() {
|
||||
const backgroundColor =
|
||||
this.theme === "dark"
|
||||
? "var(--color-card-dark-bg, #ebeef4)"
|
||||
: "var(--color-background, #ffffff)";
|
||||
|
||||
// Generate placeholder image if none provided
|
||||
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 = `
|
||||
@@ -100,14 +103,18 @@ class BookCard extends HTMLElement {
|
||||
}
|
||||
|
||||
.card {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
gap: var(--spacing-md, 1rem);
|
||||
color: inherit;
|
||||
background-color: var(--color-background, #ffffff);
|
||||
border-radius: var(--radius-lg, 0.75rem);
|
||||
overflow: hidden;
|
||||
background-color: ${backgroundColor};
|
||||
border-radius: var(--radius-sm, 0.25rem);
|
||||
padding: var(--spacing-md, 1rem);
|
||||
transition: transform var(--transition-fast, 150ms ease),
|
||||
box-shadow var(--transition-fast, 150ms ease);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
@@ -120,16 +127,34 @@ class BookCard extends HTMLElement {
|
||||
}
|
||||
|
||||
.image-container {
|
||||
position: relative;
|
||||
aspect-ratio: 3 / 4;
|
||||
width: 102px;
|
||||
min-width: 102px;
|
||||
max-width: 102px;
|
||||
height: 165px;
|
||||
background-color: var(--color-background-tertiary, #f1f5f9);
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-sm, 0.25rem);
|
||||
}
|
||||
|
||||
.image-link {
|
||||
display: block;
|
||||
height: 100%;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.image-link:visited,
|
||||
.image-link:hover,
|
||||
.image-link:active {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.book-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.book-image.placeholder {
|
||||
@@ -137,75 +162,110 @@ 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;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: var(--spacing-sm, 0.5rem);
|
||||
.content-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
font-weight: var(--font-weight-semibold, 600);
|
||||
color: var(--color-text, #1e293b);
|
||||
margin-bottom: var(--spacing-xs, 0.25rem);
|
||||
.content {
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm, 0.5rem); /* 8px gap between elements */
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.title-link {
|
||||
font-family: var(--font-family-outfit, "Outfit", sans-serif);
|
||||
font-size: var(--font-size-xl, 1.25rem); /* 20px */
|
||||
font-weight: var(--font-weight-bold, 700);
|
||||
line-height: var(--line-height-24, 24px);
|
||||
color: var(--color-black, #000000);
|
||||
text-decoration: underline;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: var(--line-height-tight, 1.25);
|
||||
}
|
||||
|
||||
.author {
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
color: var(--color-text-light, #64748b);
|
||||
margin-bottom: var(--spacing-xs, 0.25rem);
|
||||
.title-link:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
font-family: var(--font-family-outfit, "Outfit", sans-serif);
|
||||
font-size: var(--font-size-base, 1rem); /* 16px */
|
||||
font-weight: var(--font-weight-light, 300);
|
||||
line-height: var(--line-height-24, 24px);
|
||||
color: var(--color-black, #000000);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.author-link {
|
||||
font-family: var(--font-family-outfit, "Outfit", sans-serif);
|
||||
font-size: var(--font-size-base, 1rem); /* 16px */
|
||||
font-weight: var(--font-weight-light, 300);
|
||||
line-height: var(--line-height-24, 24px);
|
||||
color: var(--color-button-primary, #951d51);
|
||||
text-decoration: underline;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-bottom: var(--spacing-xs, 0.25rem);
|
||||
}
|
||||
|
||||
.star {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: var(--color-accent, #f59e0b);
|
||||
}
|
||||
|
||||
.star-empty {
|
||||
color: var(--color-border, #e2e8f0);
|
||||
.author-link:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: var(--font-size-base, 1rem);
|
||||
margin: 0;
|
||||
font-family: var(--font-family-outfit, "Outfit", sans-serif);
|
||||
font-size: var(--font-size-md, 1rem); /* 16px */
|
||||
font-weight: var(--font-weight-bold, 700);
|
||||
color: var(--color-primary, #2563eb);
|
||||
line-height: var(--line-height-24, 24px);
|
||||
color: var(--color-text, #1e293b);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: auto;
|
||||
}
|
||||
</style>
|
||||
<a href="${this.href}" class="card">
|
||||
<div class="card">
|
||||
<a href="${this.href}" class="image-link">
|
||||
<div class="image-container">
|
||||
${imageHtml}
|
||||
</div>
|
||||
</a>
|
||||
<div class="content-wrapper">
|
||||
<div class="content">
|
||||
<h3 class="title">${this.title}</h3>
|
||||
<p class="author">${this.author}</p>
|
||||
${this.rating > 0 ? `<div class="rating">${this.renderStars(this.rating)}</div>` : ''}
|
||||
<a href="${this.href}" class="title-link">${this.title}</a>
|
||||
${
|
||||
this.description
|
||||
? `<p class="description">${this.description}</p>`
|
||||
: ""
|
||||
}
|
||||
<a href="${this.href}" class="author-link">${this.author}</a>
|
||||
<p class="price">${this.price}</p>
|
||||
</div>
|
||||
</a>
|
||||
<div class="actions">
|
||||
<add-to-cart-button></add-to-cart-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('book-card', BookCard);
|
||||
customElements.define("book-card", BookCard);
|
||||
|
||||
53
js/components/book-description.js
Normal 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);
|
||||
409
js/components/book-details.js
Normal 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="Toevoegen aan favorieten">
|
||||
<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);
|
||||
128
js/components/book-review-item.js
Normal 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);
|
||||
47
js/components/book-reviews.js
Normal 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);
|
||||
106
js/components/category-card.js
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Category Card Component
|
||||
* Displays a category with an icon and title
|
||||
* Icon is centered above the category text
|
||||
*/
|
||||
class CategoryCard extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ["title", "href", "icon"];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
if (this.shadowRoot) {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
get title() {
|
||||
return this.getAttribute("title") || "Category";
|
||||
}
|
||||
|
||||
get href() {
|
||||
return this.getAttribute("href") || "#";
|
||||
}
|
||||
|
||||
get icon() {
|
||||
return this.getAttribute("icon") || "";
|
||||
}
|
||||
|
||||
render() {
|
||||
const iconHtml = this.icon
|
||||
? `<img src="${this.icon}" alt="${this.title}" class="category-icon">`
|
||||
: `<div class="category-icon placeholder">
|
||||
<clipboard-icon size="32"></clipboard-icon>
|
||||
</div>`;
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
color: inherit;
|
||||
padding: var(--spacing-md, 0.875rem) var(--spacing-xs, 0.25rem);
|
||||
text-decoration: none;
|
||||
transition: transform var(--transition-fast, 150ms ease);
|
||||
background-color: #F5F4FC;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.category-icon.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-background-tertiary, #f1f5f9);
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
color: var(--color-text-light, #64748b);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.category-title {
|
||||
font-family: var(--font-family-outfit, "Outfit", sans-serif);
|
||||
font-size: var(--font-size-sm, 0.875rem); /* 14px */
|
||||
font-weight: var(--font-weight-light, 300);
|
||||
line-height: var(--line-height-24, 24px);
|
||||
color: var(--color-text, #1e293b);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
<a href="${this.href}" class="card">
|
||||
${iconHtml}
|
||||
<p class="category-title">${this.title}</p>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("category-card", CategoryCard);
|
||||
194
js/components/content-tabs.js
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
|
||||
// Keyboard navigation
|
||||
button.addEventListener("keydown", (e) => {
|
||||
const tabCount = this.tabs.length;
|
||||
let newIndex = this.activeTab;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowLeft":
|
||||
newIndex = (this.activeTab - 1 + tabCount) % tabCount;
|
||||
break;
|
||||
case "ArrowRight":
|
||||
newIndex = (this.activeTab + 1) % tabCount;
|
||||
break;
|
||||
case "Home":
|
||||
newIndex = 0;
|
||||
break;
|
||||
case "End":
|
||||
newIndex = tabCount - 1;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
this.setActiveTab(newIndex);
|
||||
this.shadowRoot.querySelectorAll(".tab-button")[newIndex]?.focus();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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) => {
|
||||
const isActive = index === this.activeTab;
|
||||
button.classList.toggle("active", isActive);
|
||||
button.setAttribute("aria-selected", isActive.toString());
|
||||
button.setAttribute("tabindex", isActive ? "0" : "-1");
|
||||
});
|
||||
|
||||
// Update panels in shadow DOM
|
||||
const shadowPanels = this.shadowRoot.querySelectorAll("[role='tabpanel']");
|
||||
shadowPanels.forEach((panel, index) => {
|
||||
panel.style.display = index === this.activeTab ? "block" : "none";
|
||||
});
|
||||
|
||||
// Update slotted 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"
|
||||
id="tab-${index}"
|
||||
aria-selected="${index === this.activeTab}"
|
||||
aria-controls="panel-${index}"
|
||||
tabindex="${index === this.activeTab ? "0" : "-1"}"
|
||||
>
|
||||
${tab}
|
||||
</button>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
display: block;
|
||||
margin: var(--spacing-xl, 2rem) 0;
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tab-list {
|
||||
display: flex;
|
||||
gap: var(--spacing-md, 1rem);
|
||||
margin-top: var(--spacing-lg, 1.5rem);
|
||||
border-bottom: 1px solid var(--color-border, #e2e8f0);
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: var(--spacing-sm, 0.5rem) var(--spacing-md, 1rem);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
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: all var(--transition-fast, 150ms ease);
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
background: var(--color-border, #e2e8f0);
|
||||
color: var(--color-text, #1e293b);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tab-button:not(.active):hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tab-button:focus {
|
||||
outline: 2px solid var(--color-purple, #951d51);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.tab-panels {
|
||||
min-height: 100px;
|
||||
background: var(--color-border, #e2e8f0);
|
||||
padding: var(--spacing-lg, 1.5rem) var(--spacing-md, 1rem);
|
||||
border-radius: var(--radius-sm, 0.25rem);
|
||||
}
|
||||
</style>
|
||||
<div class="tabs-container">
|
||||
<div class="tab-list" role="tablist">
|
||||
${tabsHtml}
|
||||
</div>
|
||||
<div class="tab-panels">
|
||||
<div id="panel-0" role="tabpanel" aria-labelledby="tab-0">
|
||||
<slot name="panel-0"></slot>
|
||||
</div>
|
||||
<div id="panel-1" role="tabpanel" aria-labelledby="tab-1">
|
||||
<slot name="panel-1"></slot>
|
||||
</div>
|
||||
<div id="panel-2" role="tabpanel" aria-labelledby="tab-2">
|
||||
<slot name="panel-2"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Initialize panel visibility
|
||||
setTimeout(() => this.updateTabs(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("content-tabs", ContentTabs);
|
||||
85
js/components/cta-button.js
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* CTA Button Component
|
||||
* Full-width purple button with white underlined text
|
||||
*/
|
||||
class CtaButton extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ["href"];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
get href() {
|
||||
return this.getAttribute("href") || "#";
|
||||
}
|
||||
|
||||
render() {
|
||||
const isLink = this.hasAttribute("href");
|
||||
const tag = isLink ? "a" : "button";
|
||||
const hrefAttr = isLink ? `href="${this.href}"` : "";
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
padding: 0;
|
||||
background-color: var(--color-button-primary, #951d51);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm, 0.25rem);
|
||||
color: var(--color-text-inverse, #ffffff);
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-fast, 150ms ease);
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.cta-button:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
a.cta-button {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.cta-button .button-text {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
</style>
|
||||
<${tag} class="cta-button" ${hrefAttr} type="${isLink ? "" : "button"}">
|
||||
<span class="button-text">
|
||||
<slot></slot>
|
||||
</span>
|
||||
</${tag}>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("cta-button", CtaButton);
|
||||
137
js/components/footer-accordion-item.js
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Footer Accordion Item Component
|
||||
* Expandable accordion item for footer navigation
|
||||
*/
|
||||
class FooterAccordionItem extends HTMLElement {
|
||||
static _idCounter = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this._expanded = false;
|
||||
this._uniqueId = `accordion-${FooterAccordionItem._idCounter++}`;
|
||||
}
|
||||
|
||||
static get observedAttributes() {
|
||||
return ["title"];
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const header = this.shadowRoot.querySelector(".accordion-header");
|
||||
if (header) {
|
||||
header.addEventListener("click", () => this.toggle());
|
||||
}
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this._expanded = !this._expanded;
|
||||
const content = this.shadowRoot.querySelector(".accordion-content");
|
||||
const icon = this.shadowRoot.querySelector(".accordion-icon");
|
||||
const header = this.shadowRoot.querySelector(".accordion-header");
|
||||
|
||||
if (content) {
|
||||
content.classList.toggle("expanded", this._expanded);
|
||||
}
|
||||
if (icon) {
|
||||
icon.classList.toggle("rotated", this._expanded);
|
||||
}
|
||||
if (header) {
|
||||
header.setAttribute("aria-expanded", this._expanded.toString());
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const title = this.getAttribute("title") || "";
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
display: block;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.accordion-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: var(--spacing-lg, 1.5rem) 0;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.accordion-header:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.accordion-header:focus {
|
||||
outline: 2px solid var(--color-text-inverse, #ffffff);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.accordion-title {
|
||||
font-size: var(--font-size-base, 1rem);
|
||||
font-weight: 400;
|
||||
color: var(--color-text-inverse, #ffffff);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.accordion-icon {
|
||||
display: inline-flex;
|
||||
color: var(--color-text-inverse, #ffffff);
|
||||
transition: transform var(--transition-fast, 150ms ease);
|
||||
}
|
||||
|
||||
.accordion-icon.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.accordion-content {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height var(--transition-normal, 300ms ease), padding var(--transition-normal, 300ms ease);
|
||||
}
|
||||
|
||||
.accordion-content.expanded {
|
||||
max-height: 500px;
|
||||
padding-bottom: var(--spacing-lg, 1.5rem);
|
||||
}
|
||||
|
||||
::slotted(*) {
|
||||
color: var(--color-text-inverse, #ffffff);
|
||||
}
|
||||
</style>
|
||||
<button
|
||||
class="accordion-header"
|
||||
type="button"
|
||||
aria-expanded="${this._expanded}"
|
||||
aria-controls="accordion-content-${this._uniqueId}"
|
||||
>
|
||||
<h3 class="accordion-title">${title}</h3>
|
||||
<chevron-down-icon class="accordion-icon" size="24"></chevron-down-icon>
|
||||
</button>
|
||||
<div
|
||||
id="accordion-content-${this._uniqueId}"
|
||||
class="accordion-content"
|
||||
role="region"
|
||||
aria-labelledby="accordion-header-${this._uniqueId}"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("footer-accordion-item", FooterAccordionItem);
|
||||
@@ -7,14 +7,11 @@ class HorizontalScrollNav extends HTMLElement {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.categories = [
|
||||
{ id: "all", label: "All", active: true },
|
||||
{ id: "fiction", label: "Fiction", active: false },
|
||||
{ id: "non-fiction", label: "Non-Fiction", active: false },
|
||||
{ id: "mystery", label: "Mystery", active: false },
|
||||
{ id: "romance", label: "Romance", active: false },
|
||||
{ id: "sci-fi", label: "Sci-Fi", active: false },
|
||||
{ id: "biography", label: "Biography", active: false },
|
||||
{ id: "history", label: "History", active: false },
|
||||
{ id: "asoka", label: "Asoka", active: true },
|
||||
{ id: "synthese", label: "Synthese", active: false },
|
||||
{ id: "de-driehoek", label: "De Driehoek", active: false },
|
||||
{ id: "waerbeke", label: "Waerbeke", active: false },
|
||||
{ id: "stuivenberg", label: "Stuivenberg", active: false },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -66,6 +63,7 @@ class HorizontalScrollNav extends HTMLElement {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
padding: 0 16px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.nav-container::-webkit-scrollbar {
|
||||
@@ -109,7 +107,7 @@ class HorizontalScrollNav extends HTMLElement {
|
||||
background-color: #7a1843;
|
||||
}
|
||||
</style>
|
||||
<nav class="nav-container" role="navigation" aria-label="Book categories">
|
||||
<nav class="nav-container" role="navigation" aria-label="Imprints">
|
||||
${this.categories
|
||||
.map(
|
||||
(cat) => `
|
||||
|
||||
152
js/components/icon-cta-button.js
Normal 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);
|
||||
130
js/components/icon-link-button.js
Normal 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);
|
||||
534
js/components/image-gallery.js
Normal file
@@ -0,0 +1,534 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// Pinch zoom state
|
||||
this.initialPinchDistance = 0;
|
||||
this.initialZoom = 1;
|
||||
this.pinchCenterX = 0;
|
||||
this.pinchCenterY = 0;
|
||||
this.initialTranslateX = 0;
|
||||
this.initialTranslateY = 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());
|
||||
|
||||
// Mouse wheel zoom
|
||||
modalImage.addEventListener("wheel", (e) => this.handleWheel(e), { passive: false });
|
||||
}
|
||||
|
||||
// Pinch zoom for touch devices on modal content
|
||||
const modalContent = this.shadowRoot.querySelector(".modal-content");
|
||||
if (modalContent) {
|
||||
modalContent.addEventListener("touchstart", (e) => this.handleTouchStart(e), { passive: false });
|
||||
modalContent.addEventListener("touchmove", (e) => this.handleTouchMove(e), { passive: false });
|
||||
modalContent.addEventListener("touchend", (e) => this.handleTouchEnd(e));
|
||||
|
||||
// Prevent default browser gestures on the modal
|
||||
modalContent.addEventListener("gesturestart", (e) => e.preventDefault());
|
||||
modalContent.addEventListener("gesturechange", (e) => e.preventDefault());
|
||||
modalContent.addEventListener("gestureend", (e) => e.preventDefault());
|
||||
}
|
||||
}
|
||||
|
||||
handleWheel(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const delta = e.deltaY > 0 ? -0.15 : 0.15;
|
||||
|
||||
// Get mouse position relative to modal content center
|
||||
const modalContent = this.shadowRoot.querySelector(".modal-content");
|
||||
const rect = modalContent.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left - rect.width / 2;
|
||||
const mouseY = e.clientY - rect.top - rect.height / 2;
|
||||
|
||||
this.zoomToPoint(delta, mouseX, mouseY);
|
||||
}
|
||||
|
||||
zoomToPoint(delta, pointX, pointY) {
|
||||
const modalImage = this.shadowRoot.querySelector(".modal-image");
|
||||
if (!modalImage) return;
|
||||
|
||||
const prevZoom = this.currentZoom;
|
||||
const newZoom = Math.max(1, Math.min(4, this.currentZoom + delta));
|
||||
|
||||
if (newZoom === prevZoom) return;
|
||||
|
||||
// Calculate new translate to zoom towards point
|
||||
if (newZoom === 1) {
|
||||
this.translateX = 0;
|
||||
this.translateY = 0;
|
||||
} else {
|
||||
const zoomRatio = newZoom / prevZoom;
|
||||
this.translateX = pointX - (pointX - this.translateX) * zoomRatio;
|
||||
this.translateY = pointY - (pointY - this.translateY) * zoomRatio;
|
||||
}
|
||||
|
||||
this.currentZoom = newZoom;
|
||||
this.updateImageTransform(false);
|
||||
modalImage.style.cursor = this.currentZoom > 1 ? "grab" : "default";
|
||||
}
|
||||
|
||||
handleTouchStart(e) {
|
||||
if (e.touches.length === 2) {
|
||||
// Pinch zoom start
|
||||
e.preventDefault();
|
||||
this.isDragging = false; // Stop any ongoing drag
|
||||
this.initialPinchDistance = this.getPinchDistance(e.touches);
|
||||
this.initialZoom = this.currentZoom;
|
||||
this.initialTranslateX = this.translateX;
|
||||
this.initialTranslateY = this.translateY;
|
||||
|
||||
// Get pinch center relative to viewport
|
||||
const modalContent = this.shadowRoot.querySelector(".modal-content");
|
||||
const rect = modalContent.getBoundingClientRect();
|
||||
this.pinchCenterX = ((e.touches[0].clientX + e.touches[1].clientX) / 2) - rect.left - rect.width / 2;
|
||||
this.pinchCenterY = ((e.touches[0].clientY + e.touches[1].clientY) / 2) - rect.top - rect.height / 2;
|
||||
} else if (e.touches.length === 1 && this.currentZoom > 1) {
|
||||
// Single touch drag when zoomed
|
||||
this.startDrag(e);
|
||||
}
|
||||
}
|
||||
|
||||
handleTouchMove(e) {
|
||||
if (e.touches.length === 2 && this.initialPinchDistance > 0) {
|
||||
// Pinch zoom with zoom-to-point
|
||||
e.preventDefault();
|
||||
const currentDistance = this.getPinchDistance(e.touches);
|
||||
const scale = currentDistance / this.initialPinchDistance;
|
||||
const newZoom = Math.max(1, Math.min(4, this.initialZoom * scale));
|
||||
const prevZoom = this.currentZoom;
|
||||
|
||||
this.currentZoom = newZoom;
|
||||
|
||||
if (this.currentZoom === 1) {
|
||||
this.translateX = 0;
|
||||
this.translateY = 0;
|
||||
} else {
|
||||
// Adjust translate to zoom towards pinch center
|
||||
const zoomRatio = this.currentZoom / this.initialZoom;
|
||||
this.translateX = this.pinchCenterX - (this.pinchCenterX - this.initialTranslateX) * zoomRatio;
|
||||
this.translateY = this.pinchCenterY - (this.pinchCenterY - this.initialTranslateY) * zoomRatio;
|
||||
}
|
||||
|
||||
this.updateImageTransform(false); // No transition during pinch
|
||||
|
||||
const modalImage = this.shadowRoot.querySelector(".modal-image");
|
||||
if (modalImage) {
|
||||
modalImage.style.cursor = this.currentZoom > 1 ? "grab" : "default";
|
||||
}
|
||||
} else if (e.touches.length === 1 && this.isDragging) {
|
||||
// Single touch drag
|
||||
this.drag(e);
|
||||
}
|
||||
}
|
||||
|
||||
handleTouchEnd(e) {
|
||||
this.initialPinchDistance = 0;
|
||||
this.endDrag();
|
||||
}
|
||||
|
||||
getPinchDistance(touches) {
|
||||
const dx = touches[0].clientX - touches[1].clientX;
|
||||
const dy = touches[0].clientY - touches[1].clientY;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Use no transition during drag for smooth movement
|
||||
const modalImage = this.shadowRoot.querySelector(".modal-image");
|
||||
if (modalImage) {
|
||||
modalImage.style.transition = "none";
|
||||
modalImage.style.transform = `scale(${this.currentZoom}) translate(${this.translateX / this.currentZoom}px, ${this.translateY / this.currentZoom}px)`;
|
||||
}
|
||||
}
|
||||
|
||||
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(useTransition = true) {
|
||||
const modalImage = this.shadowRoot.querySelector(".modal-image");
|
||||
if (modalImage) {
|
||||
if (!useTransition) {
|
||||
modalImage.style.transition = "none";
|
||||
}
|
||||
modalImage.style.transform = `scale(${this.currentZoom}) translate(${this.translateX / this.currentZoom}px, ${this.translateY / this.currentZoom}px)`;
|
||||
|
||||
// Force reflow and restore transition
|
||||
if (!useTransition) {
|
||||
modalImage.offsetHeight; // Force reflow
|
||||
modalImage.style.transition = "transform 0.2s ease";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 all state
|
||||
this.currentZoom = 1;
|
||||
this.translateX = 0;
|
||||
this.translateY = 0;
|
||||
this.isDragging = false;
|
||||
this.initialPinchDistance = 0;
|
||||
this.initialZoom = 1;
|
||||
this.initialTranslateX = 0;
|
||||
this.initialTranslateY = 0;
|
||||
|
||||
// Reset transform without transition
|
||||
modalImage.style.transition = "none";
|
||||
modalImage.style.transform = "scale(1) translate(0px, 0px)";
|
||||
modalImage.style.cursor = "default";
|
||||
|
||||
// Force reflow then restore transition
|
||||
modalImage.offsetHeight;
|
||||
modalImage.style.transition = "transform 0.2s ease";
|
||||
|
||||
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) {
|
||||
// Reset all state
|
||||
this.currentZoom = 1;
|
||||
this.translateX = 0;
|
||||
this.translateY = 0;
|
||||
this.isDragging = false;
|
||||
this.initialPinchDistance = 0;
|
||||
this.initialZoom = 1;
|
||||
this.initialTranslateX = 0;
|
||||
this.initialTranslateY = 0;
|
||||
|
||||
// Ensure transition is enabled for smooth reset animation
|
||||
modalImage.style.transition = "transform 0.3s ease";
|
||||
modalImage.style.transform = "scale(1) translate(0px, 0px)";
|
||||
modalImage.style.cursor = "default";
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const imagesHtml = this.images
|
||||
.map(
|
||||
(img, index) => `
|
||||
<button class="gallery-item" type="button" aria-label="Bekijk afbeelding ${index + 1}">
|
||||
<img src="${img}" alt="Boek voorvertoning ${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;
|
||||
touch-action: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.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: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-md, 1rem);
|
||||
touch-action: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
touch-action: 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="Uitzoomen">−</button>
|
||||
<button class="zoom-btn zoom-reset" type="button" aria-label="Zoom resetten">⟲</button>
|
||||
<button class="zoom-btn zoom-in" type="button" aria-label="Inzoomen">+</button>
|
||||
</div>
|
||||
<button class="modal-close" type="button" aria-label="Sluiten">
|
||||
<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="Volledige afbeelding" />
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("image-gallery", ImageGallery);
|
||||
384
js/components/mobile-drawer.js
Normal file
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* Mobile Drawer Component
|
||||
* Slide-in navigation drawer from the left with backdrop
|
||||
*/
|
||||
class MobileDrawer extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.isOpen = false;
|
||||
this.handleBackdropClick = this.handleBackdropClick.bind(this);
|
||||
this.handleKeyDown = this.handleKeyDown.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
// Set initial aria-hidden state
|
||||
this.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
document.removeEventListener("keydown", this.handleKeyDown);
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Close button
|
||||
const closeBtn = this.shadowRoot.querySelector(".close-button");
|
||||
closeBtn?.addEventListener("click", () => this.close());
|
||||
|
||||
// Backdrop click
|
||||
const backdrop = this.shadowRoot.querySelector(".backdrop");
|
||||
backdrop?.addEventListener("click", this.handleBackdropClick);
|
||||
|
||||
// Escape key
|
||||
document.addEventListener("keydown", this.handleKeyDown);
|
||||
|
||||
// Listen for global open event
|
||||
window.addEventListener("open-mobile-drawer", () => this.open());
|
||||
window.addEventListener("close-mobile-drawer", () => this.close());
|
||||
window.addEventListener("toggle-mobile-drawer", () => this.toggle());
|
||||
}
|
||||
|
||||
handleBackdropClick() {
|
||||
this.close();
|
||||
}
|
||||
|
||||
handleKeyDown(e) {
|
||||
if (e.key === "Escape" && this.isOpen) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
this.isOpen = true;
|
||||
this.shadowRoot.querySelector(".drawer-container").classList.add("open");
|
||||
this.setAttribute("aria-hidden", "false");
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
// Focus trap - focus first focusable element
|
||||
setTimeout(() => {
|
||||
const closeBtn = this.shadowRoot.querySelector(".close-button");
|
||||
closeBtn?.focus();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.shadowRoot.querySelector(".drawer-container").classList.remove("open");
|
||||
this.setAttribute("aria-hidden", "true");
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.isOpen) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
--drawer-width: 320px;
|
||||
--drawer-bg: #ffffff;
|
||||
--drawer-header-bg: #951D51;
|
||||
--drawer-header-color: #ffffff;
|
||||
--backdrop-color: rgba(0, 0, 0, 0.5);
|
||||
--transition-duration: 300ms;
|
||||
--section-title-color: #64748b;
|
||||
--link-color: #1e293b;
|
||||
--link-hover-color: #951D51;
|
||||
--border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.drawer-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.drawer-container.open {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--backdrop-color);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-duration) ease;
|
||||
}
|
||||
|
||||
.drawer-container.open .backdrop {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: min(var(--drawer-width), 85vw);
|
||||
height: 100%;
|
||||
background-color: var(--drawer-bg);
|
||||
transform: translateX(-100%);
|
||||
transition: transform var(--transition-duration) cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.drawer-container.open .drawer {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background-color: var(--drawer-header-bg);
|
||||
color: var(--drawer-header-color);
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.drawer-logo {
|
||||
font-family: var(--font-family-base, system-ui);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #ffffff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
color: #951D51;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: #f1f5f9;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.close-button:focus {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.5);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
padding: 20px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.nav-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.nav-section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--section-title-color);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-list a {
|
||||
display: block;
|
||||
padding: 10px 12px;
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
font-size: 0.9375rem;
|
||||
border-radius: 8px;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.nav-list a:hover {
|
||||
background-color: #f1f5f9;
|
||||
color: var(--link-hover-color);
|
||||
}
|
||||
|
||||
.nav-list a:focus {
|
||||
outline: 2px solid var(--link-hover-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Milinda section - smaller styling */
|
||||
.nav-section.milinda .nav-section-title {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.nav-section.milinda .nav-list a {
|
||||
font-size: 0.875rem;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
padding: 20px 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.auth-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 20px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #951D51;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #7a1842;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: transparent;
|
||||
color: #951D51;
|
||||
border: 2px solid #951D51;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #951D51;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn:focus {
|
||||
outline: 2px solid #951D51;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.drawer-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.drawer-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.drawer-content::-webkit-scrollbar-thumb {
|
||||
background-color: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.drawer-content::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="drawer-container">
|
||||
<div class="backdrop"></div>
|
||||
<nav class="drawer" aria-label="Hoofdnavigatie">
|
||||
<div class="drawer-header">
|
||||
<a href="index.html" class="drawer-logo">Milinda</a>
|
||||
<button class="close-button" aria-label="Sluit menu">
|
||||
<close-icon size="18" color="#951D51"></close-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="drawer-content">
|
||||
<div class="nav-section">
|
||||
<h2 class="nav-section-title">Onderwerpen</h2>
|
||||
<ul class="nav-list">
|
||||
<li><a href="#">Boeddhisme (algemeen)</a></li>
|
||||
<li><a href="#">Theravada</a></li>
|
||||
<li><a href="#">Zen</a></li>
|
||||
<li><a href="#">Tibetaans boeddhisme</a></li>
|
||||
<li><a href="#">Meditatie</a></li>
|
||||
<li><a href="#">Mindfulness</a></li>
|
||||
<li><a href="#">Vipassana</a></li>
|
||||
<li><a href="#">Pali-canon</a></li>
|
||||
<li><a href="#">Biografie / autobiografie</a></li>
|
||||
<li><a href="#">Kinderboeken</a></li>
|
||||
<li><a href="#">Alle onderwerpen bekijken</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<h2 class="nav-section-title">Imprints</h2>
|
||||
<ul class="nav-list">
|
||||
<li><a href="#">Asoka</a></li>
|
||||
<li><a href="#">Synthese</a></li>
|
||||
<li><a href="#">De Driehoek</a></li>
|
||||
<li><a href="#">Waerbeke</a></li>
|
||||
<li><a href="#">Stuivenberg</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="nav-section milinda">
|
||||
<h2 class="nav-section-title">Milinda</h2>
|
||||
<ul class="nav-list">
|
||||
<li><a href="#">Over ons</a></li>
|
||||
<li><a href="#">Klantenservice</a></li>
|
||||
<li><a href="#">Verzending & retourneren</a></li>
|
||||
<li><a href="#">Nieuwsbrief</a></li>
|
||||
<li><a href="#">Contact</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-footer">
|
||||
<div class="auth-buttons">
|
||||
<a href="#" class="btn btn-primary">Inloggen</a>
|
||||
<a href="#" class="btn btn-secondary">Registreren</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("mobile-drawer", MobileDrawer);
|
||||
195
js/components/newsletter-signup.js
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Newsletter Signup Component
|
||||
* A newsletter subscription form with title, description, and email input
|
||||
*/
|
||||
import { sendIcon } from "../icons/send-icon.js";
|
||||
|
||||
class NewsletterSignup extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ["title", "description", "button-text", "placeholder"];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
get title() {
|
||||
return this.getAttribute("title") || "Blijf op de hoogte";
|
||||
}
|
||||
|
||||
get description() {
|
||||
return (
|
||||
this.getAttribute("description") ||
|
||||
"Schrijf je in voor onze nieuwsbrief en ontvang het laatste nieuws over nieuwe boeken en aanbiedingen."
|
||||
);
|
||||
}
|
||||
|
||||
get buttonText() {
|
||||
return this.getAttribute("button-text") || "Inschrijven";
|
||||
}
|
||||
|
||||
get placeholder() {
|
||||
return this.getAttribute("placeholder") || "Je e-mailadres";
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const form = this.shadowRoot?.querySelector("form");
|
||||
if (form) {
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const emailInput = this.shadowRoot.querySelector('input[type="email"]');
|
||||
const email = emailInput?.value;
|
||||
|
||||
if (email) {
|
||||
// Dispatch custom event for parent components to handle
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("newsletter-submit", {
|
||||
detail: { email },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Reset form
|
||||
form.reset();
|
||||
|
||||
// Show feedback (you could enhance this)
|
||||
console.log("Newsletter signup:", email);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.newsletter-box {
|
||||
border: 1px solid var(--color-newsletter-border, #951D51);
|
||||
border-radius: 4px;
|
||||
padding: 24px 16px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.newsletter-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.newsletter-title {
|
||||
font-family: var(--font-family-outfit, "Outfit", sans-serif);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 34px;
|
||||
color: var(--color-newsletter-title, #951D51);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.newsletter-description {
|
||||
font-family: var(--font-family-outfit, "Outfit", sans-serif);
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 32px;
|
||||
color: #000000;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.newsletter-form {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.newsletter-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 12px 16px;
|
||||
font-family: var(--font-family-outfit, "Outfit", sans-serif);
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
border: 1px solid var(--color-newsletter-border, #951D51);
|
||||
border-radius: 4px;
|
||||
background-color: #ffffff;
|
||||
outline: none;
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
|
||||
.newsletter-input::placeholder {
|
||||
color: #9CA3AF;
|
||||
}
|
||||
|
||||
.newsletter-input:focus {
|
||||
border-color: var(--color-newsletter-border, #951D51);
|
||||
box-shadow: 0 0 0 1px var(--color-newsletter-border, #951D51);
|
||||
}
|
||||
|
||||
.newsletter-button {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 80px;
|
||||
height: 48px;
|
||||
padding: 0;
|
||||
color: #ffffff;
|
||||
background-color: var(--color-newsletter-button, #951D51);
|
||||
border: 1px solid var(--color-newsletter-button, #951D51);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease, border-color 150ms ease;
|
||||
}
|
||||
|
||||
.newsletter-button svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.newsletter-button:hover {
|
||||
background-color: var(--color-newsletter-button-hover, #7a1842);
|
||||
border-color: var(--color-newsletter-button-hover, #7a1842);
|
||||
}
|
||||
|
||||
.newsletter-button:focus {
|
||||
outline: 2px solid var(--color-newsletter-button, #951D51);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
<div class="newsletter-box">
|
||||
<div class="newsletter-content">
|
||||
<h2 class="newsletter-title">${this.title}</h2>
|
||||
<p class="newsletter-description">${this.description}</p>
|
||||
<form class="newsletter-form">
|
||||
<input
|
||||
type="email"
|
||||
class="newsletter-input"
|
||||
placeholder="${this.placeholder}"
|
||||
required
|
||||
aria-label="E-mailadres"
|
||||
/>
|
||||
<button type="submit" class="newsletter-button" aria-label="${
|
||||
this.buttonText || "Inschrijven"
|
||||
}">
|
||||
${sendIcon({ size: 24, color: "#ffffff" })}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("newsletter-signup", NewsletterSignup);
|
||||
151
js/components/push-box.js
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Push Box Component
|
||||
* A promotional container with logo, title, and CTA
|
||||
* Uses slots for all content to allow easy customization in HTML
|
||||
* Supports variant="purple" for purple background with white content
|
||||
*/
|
||||
class PushBox extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ["background-color", "text-color", "variant"];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
// Use requestAnimationFrame to ensure slots are assigned
|
||||
requestAnimationFrame(() => {
|
||||
this.updateLogoVisibility();
|
||||
});
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
this.render();
|
||||
requestAnimationFrame(() => {
|
||||
this.updateLogoVisibility();
|
||||
});
|
||||
}
|
||||
|
||||
updateLogoVisibility() {
|
||||
const logoSlot = this.shadowRoot?.querySelector('slot[name="logo"]');
|
||||
const logoWrapper = this.shadowRoot?.querySelector(".logo-wrapper");
|
||||
|
||||
if (logoSlot && logoWrapper) {
|
||||
const assignedNodes = logoSlot.assignedNodes();
|
||||
const hasContent =
|
||||
assignedNodes.length > 0 &&
|
||||
assignedNodes.some((node) => {
|
||||
return node.nodeType === Node.ELEMENT_NODE && node.tagName === "IMG";
|
||||
});
|
||||
|
||||
if (!hasContent) {
|
||||
logoWrapper.classList.add("hidden");
|
||||
} else {
|
||||
logoWrapper.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get variant() {
|
||||
return this.getAttribute("variant") || "default";
|
||||
}
|
||||
|
||||
get isPurple() {
|
||||
return this.variant === "purple";
|
||||
}
|
||||
|
||||
get backgroundColor() {
|
||||
if (this.isPurple) {
|
||||
return "#951D51";
|
||||
}
|
||||
return (
|
||||
this.getAttribute("background-color") ||
|
||||
"var(--color-push-box-bg, #EBEEF4)"
|
||||
);
|
||||
}
|
||||
|
||||
get textColor() {
|
||||
if (this.isPurple) {
|
||||
return "#FFFFFF";
|
||||
}
|
||||
return this.getAttribute("text-color") || "#951D51";
|
||||
}
|
||||
|
||||
get titleColor() {
|
||||
if (this.isPurple) {
|
||||
return "#FFFFFF";
|
||||
}
|
||||
return "#000000";
|
||||
}
|
||||
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.push-box {
|
||||
background-color: ${this.backgroundColor};
|
||||
padding: 48px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.logo-wrapper {
|
||||
display: block;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo-wrapper.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.logo-wrapper ::slotted(img) {
|
||||
width: 104px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.title-wrapper ::slotted(*) {
|
||||
font-family: var(--font-family-outfit, "Outfit", sans-serif);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 34px;
|
||||
color: ${this.titleColor};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cta-wrapper {
|
||||
color: ${this.textColor};
|
||||
font-family: var(--font-family-outfit, "Outfit", sans-serif);
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.cta-wrapper ::slotted(.cta-buttons) {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
<div class="push-box">
|
||||
<div class="logo-wrapper">
|
||||
<slot name="logo"></slot>
|
||||
</div>
|
||||
<div class="title-wrapper">
|
||||
<slot name="title"></slot>
|
||||
</div>
|
||||
<div class="cta-wrapper">
|
||||
<slot name="cta"></slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("push-box", PushBox);
|
||||
@@ -173,7 +173,7 @@ class SearchBar extends HTMLElement {
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
background-color: #FADCE7;
|
||||
background-color: #FFF;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -184,7 +184,7 @@ class SearchBar extends HTMLElement {
|
||||
padding: 0 16px;
|
||||
font-family: var(--font-family-base);
|
||||
font-size: var(--font-size-base, 16px);
|
||||
font-weight: var(--font-weight-normal, 400);
|
||||
font-weight: var(--font-weight-light, 300);
|
||||
color: #383838;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
@@ -194,7 +194,7 @@ class SearchBar extends HTMLElement {
|
||||
.search-input::placeholder {
|
||||
font-family: var(--font-family-base);
|
||||
font-size: var(--font-size-base, 16px);
|
||||
font-weight: var(--font-weight-normal, 400);
|
||||
font-weight: var(--font-weight-light, 300);
|
||||
color: rgba(56, 56, 56, 0.5);
|
||||
}
|
||||
|
||||
|
||||
75
js/components/section-title.js
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Section Title Component
|
||||
* Displays a title with an arrow right icon on the right side
|
||||
*/
|
||||
import { arrowRightIcon } from "../icons/arrow-right.js";
|
||||
|
||||
class SectionTitle extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ["text"];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
if (this.shadowRoot) {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
get text() {
|
||||
return this.getAttribute("text") || this.textContent || "Section Title";
|
||||
}
|
||||
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-lg, 1.5rem) 0;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
font-family: var(--font-family-outfit, "Outfit", sans-serif);
|
||||
font-size: var(--font-size-2xl, 1.5rem);
|
||||
font-weight: var(--font-weight-normal, 400);
|
||||
line-height: var(--line-height-24, 24px);
|
||||
color: var(--color-text, #1e293b);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-text, #1e293b);
|
||||
}
|
||||
|
||||
.icon-wrapper svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
</style>
|
||||
<div class="section-title">
|
||||
<h2 class="title-text">${this.text}</h2>
|
||||
<div class="icon-wrapper">
|
||||
${arrowRightIcon({ size: 24, color: "currentColor" })}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("section-title", SectionTitle);
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Site Content Component
|
||||
* Main content area wrapper with slot for page content
|
||||
* Uses flexbox with gap for consistent vertical spacing
|
||||
*/
|
||||
class SiteContent extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
@@ -22,14 +23,29 @@ class SiteContent extends HTMLElement {
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: var(--spacing-md, 1rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md, 16px);
|
||||
padding-top: var(--spacing-md, 16px);
|
||||
padding-bottom: var(--spacing-md, 16px);
|
||||
}
|
||||
|
||||
/* Remove vertical padding from direct children to let gap handle spacing */
|
||||
::slotted(.content-padding) {
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
::slotted(.section) {
|
||||
padding-top: var(--spacing-md, 16px);
|
||||
padding-bottom: var(--spacing-md, 16px);
|
||||
}
|
||||
</style>
|
||||
<main class="content">
|
||||
<div class="content">
|
||||
<slot></slot>
|
||||
</main>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('site-content', SiteContent);
|
||||
customElements.define("site-content", SiteContent);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Site Footer Component
|
||||
* Footer with navigation links, social icons, and copyright
|
||||
* Footer with configurable accordion sections and link columns
|
||||
*/
|
||||
class SiteFooter extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
@@ -17,95 +17,83 @@ class SiteFooter extends HTMLElement {
|
||||
<style>
|
||||
:host {
|
||||
display: block;
|
||||
background-color: var(--color-background-secondary, #f8fafc);
|
||||
border-top: 1px solid var(--color-border, #e2e8f0);
|
||||
background-color: var(--color-button-primary, #951D51);
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: var(--spacing-lg, 1.5rem) var(--spacing-md, 1rem);
|
||||
padding: var(--spacing-xl, 2rem) var(--spacing-md, 1rem);
|
||||
}
|
||||
|
||||
.footer-nav {
|
||||
.footer-logo {
|
||||
font-size: var(--font-size-2xl, 1.5rem);
|
||||
font-weight: 400;
|
||||
color: var(--color-text-inverse, #ffffff);
|
||||
margin: 0;
|
||||
padding-bottom: var(--spacing-lg, 1.5rem);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.accordion-section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-md, 1rem);
|
||||
margin-bottom: var(--spacing-lg, 1.5rem);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
color: var(--color-text-light, #64748b);
|
||||
.footer-links {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-md, 1rem) var(--spacing-xl, 2rem);
|
||||
padding-top: var(--spacing-xl, 2rem);
|
||||
}
|
||||
|
||||
.footer-link-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md, 1rem);
|
||||
}
|
||||
|
||||
::slotted([slot="logo"]) {
|
||||
font-size: var(--font-size-2xl, 1.5rem);
|
||||
font-weight: 400;
|
||||
color: var(--color-text-inverse, #ffffff) !important;
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast, 150ms ease);
|
||||
}
|
||||
|
||||
.footer-link:hover {
|
||||
color: var(--color-primary, #2563eb);
|
||||
::slotted(footer-accordion-item) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.social-icons {
|
||||
::slotted([slot="links-left"]),
|
||||
::slotted([slot="links-right"]) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md, 1rem);
|
||||
margin-bottom: var(--spacing-lg, 1.5rem);
|
||||
}
|
||||
|
||||
.social-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: var(--color-text-light, #64748b);
|
||||
background-color: var(--color-background, #ffffff);
|
||||
border-radius: var(--radius-full, 9999px);
|
||||
transition: all var(--transition-fast, 150ms ease);
|
||||
}
|
||||
|
||||
.social-icon:hover {
|
||||
color: var(--color-primary, #2563eb);
|
||||
background-color: var(--color-background-tertiary, #f1f5f9);
|
||||
}
|
||||
|
||||
.social-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.copyright {
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
color: var(--color-text-light, #64748b);
|
||||
text-align: center;
|
||||
/* Style for links inside the footer */
|
||||
.links-wrapper ::slotted(a) {
|
||||
color: var(--color-text-inverse, #ffffff) !important;
|
||||
text-decoration: underline;
|
||||
font-size: var(--font-size-base, 1rem);
|
||||
}
|
||||
</style>
|
||||
<footer class="footer">
|
||||
<nav class="footer-nav">
|
||||
<a href="#" class="footer-link">About Us</a>
|
||||
<a href="#" class="footer-link">Contact</a>
|
||||
<a href="#" class="footer-link">FAQ</a>
|
||||
<a href="#" class="footer-link">Privacy Policy</a>
|
||||
<a href="#" class="footer-link">Terms of Service</a>
|
||||
</nav>
|
||||
<div class="social-icons">
|
||||
<a href="#" class="social-icon" aria-label="Facebook">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="#" class="social-icon" aria-label="Twitter">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="#" class="social-icon" aria-label="Instagram">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<h2 class="footer-logo">
|
||||
<slot name="logo">MILINDA uitgevers</slot>
|
||||
</h2>
|
||||
<div class="accordion-section">
|
||||
<slot name="accordion"></slot>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<div class="footer-link-column links-wrapper">
|
||||
<slot name="links-left"></slot>
|
||||
</div>
|
||||
<div class="footer-link-column links-wrapper">
|
||||
<slot name="links-right"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<p class="copyright">© 2026 BookStore. All rights reserved.</p>
|
||||
</footer>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('site-footer', SiteFooter);
|
||||
customElements.define("site-footer", SiteFooter);
|
||||
|
||||
@@ -1,15 +1,62 @@
|
||||
/**
|
||||
* Site Header Component
|
||||
* Sticky header container that holds top-bar, navigation, and search
|
||||
* Collapses on scroll down, expands on scroll up
|
||||
* Based on: https://stackoverflow.com/questions/63902512/
|
||||
*/
|
||||
class SiteHeader extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.prevScrollPos = 0;
|
||||
this.headerOffset = 0;
|
||||
this.collapsibleHeight = 0;
|
||||
this.upwardScroll = 0;
|
||||
this.handleScroll = this.handleScroll.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.collapsible = this.shadowRoot.querySelector(".collapsible");
|
||||
window.addEventListener("scroll", this.handleScroll, { passive: true });
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener("scroll", this.handleScroll);
|
||||
}
|
||||
|
||||
handleScroll() {
|
||||
const currentScrollPos = Math.max(0, window.scrollY);
|
||||
const isScrollingDown = currentScrollPos > this.prevScrollPos;
|
||||
const delta = currentScrollPos - this.prevScrollPos;
|
||||
|
||||
if (!this.collapsibleHeight && this.collapsible) {
|
||||
this.collapsibleHeight = this.collapsible.scrollHeight;
|
||||
}
|
||||
|
||||
// Always show header when at top of page
|
||||
if (currentScrollPos === 0) {
|
||||
this.upwardScroll = 0;
|
||||
this.headerOffset = 0;
|
||||
this.classList.add("reveal");
|
||||
} else if (isScrollingDown) {
|
||||
this.upwardScroll = 0;
|
||||
this.headerOffset = Math.min(
|
||||
this.collapsibleHeight,
|
||||
Math.max(0, this.headerOffset + delta)
|
||||
);
|
||||
this.classList.remove("reveal");
|
||||
} else if (delta < 0) {
|
||||
this.upwardScroll += Math.abs(delta);
|
||||
if (this.upwardScroll >= 100) {
|
||||
this.headerOffset = 0;
|
||||
this.classList.add("reveal");
|
||||
}
|
||||
}
|
||||
|
||||
this.collapsible.style.transform = `translateY(-${this.headerOffset}px)`;
|
||||
|
||||
this.prevScrollPos = currentScrollPos;
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -20,17 +67,41 @@ class SiteHeader extends HTMLElement {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background-color: var(--color-background, #ffffff);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.collapsible {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
transform: translateY(0);
|
||||
transition: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
:host(.reveal) .collapsible {
|
||||
transition: transform 400ms ease;
|
||||
}
|
||||
|
||||
::slotted(top-bar) {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
border-bottom: 1px solid var(--color-border, #e2e8f0);
|
||||
}
|
||||
</style>
|
||||
<header class="header">
|
||||
<slot></slot>
|
||||
<slot name="skip-link"></slot>
|
||||
<slot name="top-bar"></slot>
|
||||
<div class="collapsible">
|
||||
<slot name="nav"></slot>
|
||||
<slot name="search"></slot>
|
||||
</div>
|
||||
</header>
|
||||
`;
|
||||
}
|
||||
|
||||
66
js/icons/arrow-circle-right-icon.js
Normal 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);
|
||||
30
js/icons/arrow-right.js
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Arrow Right Icon (Lucide)
|
||||
* @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 arrowRightIcon({
|
||||
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"
|
||||
>
|
||||
<path d="M5 12h14"/>
|
||||
<path d="m12 5 7 7-7 7"/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
64
js/icons/book-open-icon.js
Normal 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
@@ -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>
|
||||
`;
|
||||
}
|
||||
64
js/icons/chevron-down-icon.js
Normal 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);
|
||||
64
js/icons/clipboard-icon.js
Normal 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);
|
||||
65
js/icons/close-icon.js
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Close Icon Web Component
|
||||
* A reusable close/X icon element
|
||||
*/
|
||||
class CloseIcon 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"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("close-icon", CloseIcon);
|
||||
29
js/icons/ebook-icon.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
@@ -2,8 +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
@@ -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);
|
||||
@@ -23,9 +23,9 @@ export function micIcon({
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/>
|
||||
<path d="M12 19v3"/>
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
|
||||
<line x1="12" x2="12" y1="19" y2="22"/>
|
||||
<rect x="9" y="2" width="6" height="13" rx="3"/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
30
js/icons/plus.js
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Plus Icon (Lucide)
|
||||
* @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 plusIcon({
|
||||
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"
|
||||
>
|
||||
<path d="M5 12h14"/>
|
||||
<path d="M12 5v14"/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
31
js/icons/review-icon.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
@@ -23,8 +23,8 @@ export function searchIcon({
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m21 21-4.34-4.34"/>
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.3-4.3"/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
31
js/icons/send-icon.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Send Icon (Lucide)
|
||||
* @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 sendIcon({
|
||||
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"
|
||||
class="lucide lucide-send-icon lucide-send"
|
||||
>
|
||||
<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/>
|
||||
<path d="m21.854 2.147-10.94 10.939"/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
138
js/icons/shopping-bag-icon.js
Normal 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);
|
||||
@@ -23,9 +23,9 @@ export function shoppingBagIcon({
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/>
|
||||
<path d="M3 6h18"/>
|
||||
<path d="M16 10a4 4 0 0 1-8 0"/>
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
||||
66
js/icons/user-icon.js
Normal 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);
|
||||
@@ -23,8 +23,9 @@ export function userIcon({
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="8" r="5"/>
|
||||
<path d="M20 21a8 8 0 0 0-16 0"/>
|
||||
<path d="M18 20a6 6 0 0 0-12 0"/>
|
||||
<circle cx="12" cy="10" r="4"/>
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
32
js/icons/wishlist-icon.js
Normal 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
@@ -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;
|
||||
@@ -3,8 +3,8 @@
|
||||
"version": "1.0.0",
|
||||
"description": "BookStore - Discover and buy your next favorite book",
|
||||
"scripts": {
|
||||
"start": "serve -s . -l ${PORT:-3000}",
|
||||
"serve": "serve -s . -l 3000"
|
||||
"start": "serve . -l ${PORT:-3000}",
|
||||
"serve": "serve . -l 3000"
|
||||
},
|
||||
"dependencies": {
|
||||
"serve": "^14.2.4"
|
||||
|
||||
6
serve.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"cleanUrls": false,
|
||||
"rewrites": [
|
||||
{ "source": "/", "destination": "/index.html" }
|
||||
]
|
||||
}
|
||||