TGA-Shop Demo: Storefront (kein Passwort, Umami, Stripe-Checkout mit Demo-Fallback) + Shopify-Style-Admin; Datenschicht sqlite|directus

This commit is contained in:
2026-06-16 08:41:43 +00:00
commit 4f9a2cf512
77 changed files with 11485 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.astro
.git
data
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.astro
data
+56
View File
@@ -0,0 +1,56 @@
# Repo anlegen, pushen, deployen — Runbook
> Diese Schritte laufen **auf deinem Mac** (dort hat der Ordner normale Rechte und dein
> gitea-Token sitzt). Aus der Cowork-Sandbox ging das nicht: der gemountete Ordner lässt
> Git keine Objekte schreiben, und ein Push bräuchte dein Token.
## 1. Git-Repo lokal sauber aufsetzen
```bash
cd ~/Coding/TGA-Shop-Projekt/tga-shop
# Kaputtes .git aus der Sandbox entfernen + frisch initialisieren
rm -rf .git
git init -b main
# Optionalen Duplikat-Bildordner löschen (entstand beim Mockup-Copy, ist gitignored)
rm -rf public/product-images/product-images
git add -A
git commit -m "Astro-Storefront: Mockup-Port (16 Seiten, datengetriebene Produkte)"
```
## 2. Repo in gitea anlegen
`git.heidrich-digital.de`**New Repository**
- Owner: `till`
- Name: `tga-shop`
- Sichtbarkeit: **Private** (wie deine anderen Repos)
- **Nicht** mit README/.gitignore initialisieren (haben wir schon)
## 3. Remote + Push
```bash
git remote add origin https://git.heidrich-digital.de/till/tga-shop.git
git push -u origin main
# Beim Push: Benutzer = till, Passwort = dein gitea-Access-Token
```
## 4. Lokal testen (optional, vor Deploy)
```bash
npm install
npm run dev # http://localhost:4321 (Passwort-Gate: Hoyaaa)
npm run build # muss grün durchlaufen (16 Seiten)
```
## 5. Coolify-Deployment
Sobald das Repo auf gitea liegt: **sag mir Bescheid** — dann lege ich die Coolify-App
über den Coolify-MCP an (Static-Site-Build aus diesem Repo, Branch `main`,
Build-Command `npm run build`, Output `dist/`) und hänge sie an `git.heidrich-digital.de`.
Domain `tgasolutions-shop.de` (DNS bei all-inkl) zeigen wir am Schluss per A-Record
auf den Server — und vor Go-live:
- **Passwort-Gate** in `public/shop.js` entfernen (Konstante `PASS_WORD`).
- Server auf 16 GB rescalen (wenn Medusa dazukommt).
+12
View File
@@ -0,0 +1,12 @@
FROM node:22-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ ca-certificates wget && rm -rf /var/lib/apt/lists/*
COPY package*.json ./
RUN npm install --no-audit --no-fund
COPY . .
RUN npm run build
ENV HOST=0.0.0.0 PORT=4321 NODE_ENV=production DB_PATH=/data/shop.db
RUN mkdir -p /data
EXPOSE 4321
HEALTHCHECK --interval=15s --timeout=5s --start-period=25s --retries=5 CMD wget -qO- http://127.0.0.1:4321/ >/dev/null 2>&1 || exit 1
CMD ["node","./dist/server/entry.mjs"]
+54
View File
@@ -0,0 +1,54 @@
# tga-shop — PMPNZNG Storefront (Astro)
Astro-Konvertierung des PMPNZNG-Merch-Mockups. **Phase „Mockup-Port"** — statische
Ausgabe aus lokalen Produktdaten. Später wird die Produkt-/Bestell-/Checkout-Schicht
durch ein **Medusa-v2-Backend + Stripe** ersetzt (siehe `../ARCHITEKTUR-UND-PLAN.md`).
## Stack
- **Astro 5** (output: `static`) — kein Framework-Overhead, Vanilla-JS-Inseln.
- **Vanilla CSS** mit Design-Tokens (`src/styles/global.css`, aus dem Mockup übernommen).
- **Client-JS** (`public/shop.js`): Warenkorb (localStorage), Passwort-Gate, Toasts,
Galerie, Größenwahl, Mobile-Menü, Instagram-Kacheln.
## Entwickeln
```bash
npm install
npm run dev # http://localhost:4321
npm run build # → dist/ (statisch)
npm run preview
```
> **Passwort-Vorschau:** Die Seite ist per Gate geschützt. Passwort: `Hoyaaa`
> (in `public/shop.js`, Konstante `PASS_WORD`). Vor Go-live entfernen oder serverseitig lösen.
## Struktur
```
src/
├── data/products.ts # EINZIGE Produkt-Quelle (Medusa-ersetzbar; Preis in Cent)
├── layouts/Base.astro # Head, Header/Nav, Marquee, Footer, JSON-LD, Script-Injektion
├── pages/
│ ├── index.astro # Startseite (aus Mockup portiert)
│ ├── shop.astro # Katalog (aus Mockup portiert)
│ ├── produkt/[slug].astro# Produktdetail (datengetrieben aus products.ts)
│ ├── produkt/online-workshop.astro # Digitalprodukt (bespoke Seite)
│ ├── warenkorb · checkout · bestellung-erfolgreich
│ └── ueber-uns · faq · impressum · datenschutz
└── styles/global.css # Design-System (Tokens → Reset → Components)
public/
├── shop.js # Client-Logik (Vanilla)
└── product-images/ # Produktfotos + freigestellte PNGs + Insta
```
## Bekannte To-dos (Mockup-Port → Produktiv)
- **Checkout** ist simuliert (leert nur den Warenkorb). → Stripe via Medusa anbinden.
- **Produktdaten** liegen lokal in `products.ts`. → durch Medusa-API ersetzen (Schema ist
bereits nah an Medusa: `priceCents`, `sizes`=Varianten, `productType`).
- **Recht**: AGB & Widerruf fehlen noch (nur Impressum/Datenschutz aus Mockup).
- **Design** wird separat überarbeitet (Tokens in `global.css` → OKLCH-Migration möglich).
- **Passwort-Gate** vor Launch entfernen.
- Beim Medusa-Anschluss: Node-Adapter + `output: 'server'` in `astro.config.mjs`.
```
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
site: 'https://tgasolutions-shop.de',
output: 'server',
adapter: node({ mode: 'standalone' }),
server: { host: '0.0.0.0', port: 4321 },
});
+26
View File
@@ -0,0 +1,26 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# gzip
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
# Astro „directory"-Format: /shop -> /shop/index.html
location / {
try_files $uri $uri/ $uri/index.html =404;
}
# Statische Assets lange cachen (gehashte Dateinamen)
location /_astro/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location /product-images/ {
expires 30d;
add_header Cache-Control "public";
}
}
+6370
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
{
"name": "demo-tga-shop",
"type": "module",
"version": "1.0.0",
"private": true,
"scripts": { "dev": "astro dev", "build": "astro build", "start": "node ./dist/server/entry.mjs" },
"dependencies": {
"astro": "^5.6.0",
"@astrojs/node": "^9.1.3",
"better-sqlite3": "^11.8.1",
"stripe": "^17.5.0"
}
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#0d0d0d"/>
<text x="16" y="23" font-family="Arial Black, Arial, sans-serif" font-size="20" font-weight="900" text-anchor="middle" fill="#e01c2e">Z</text>
</svg>

After

Width:  |  Height:  |  Size: 261 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 978 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 818 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

+312
View File
@@ -0,0 +1,312 @@
/**
* PMPNZNG Shop — Client-JS (Vanilla, kein Framework)
* Cart · Passwort-Gate · Toasts · Galerie · Größenwahl · Mobile-Menü · Insta · Exit-Intent
*
* Produktdaten kommen aus dem Astro-Layout (window.__PRODUCTS__),
* damit es nur EINE Quelle der Wahrheit gibt (src/data/products.ts).
* Später ersetzt Medusa sowohl die Render-Daten als auch den Checkout.
*/
'use strict';
const PRODUCTS = Array.isArray(window.__PRODUCTS__) ? window.__PRODUCTS__ : [];
/* ===================== PASSWORT-GATE ===================== */
const PASS_KEY = 'pmpnzng_auth';
const PASS_WORD = null; /* Passwortschutz entfernt */
function initPasswordGate() {
if (sessionStorage.getItem(PASS_KEY) === '1') return;
const overlay = document.createElement('div');
overlay.id = 'pw-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', 'Passwortschutz');
overlay.innerHTML = `
<div class="pw-box">
<div class="pw-logo">PMPN<strong>Z</strong>NG</div>
<div class="pw-sub">Vorschau · tgasolutions-shop.de</div>
<p class="pw-desc">Diese Seite ist passwortgeschützt.<br>Bitte gib das Passwort ein, um fortzufahren.</p>
<form class="pw-form" id="pw-form" novalidate>
<input type="password" id="pw-input" class="pw-input" placeholder="Passwort eingeben" autocomplete="current-password" aria-label="Passwort" required>
<button type="submit" class="pw-btn">Eintreten</button>
</form>
<p class="pw-error" id="pw-error" aria-live="polite"></p>
</div>`;
document.body.appendChild(overlay);
document.body.style.overflow = 'hidden';
setTimeout(() => document.getElementById('pw-input')?.focus(), 100);
document.getElementById('pw-form').addEventListener('submit', (e) => {
e.preventDefault();
const val = document.getElementById('pw-input').value;
if (val === PASS_WORD) {
sessionStorage.setItem(PASS_KEY, '1');
overlay.style.transition = 'opacity 0.4s ease';
overlay.style.opacity = '0';
setTimeout(() => { overlay.remove(); document.body.style.overflow = ''; }, 400);
} else {
document.getElementById('pw-error').textContent = 'Falsches Passwort. Bitte versuche es erneut.';
const input = document.getElementById('pw-input');
input.value = ''; input.focus();
const box = overlay.querySelector('.pw-box');
box.classList.add('pw-shake');
setTimeout(() => box.classList.remove('pw-shake'), 500);
}
});
}
/* ===================== CART ===================== */
const CART_KEY = 'pmpnzng_cart';
const getCart = () => { try { return JSON.parse(localStorage.getItem(CART_KEY)) || []; } catch { return []; } };
const saveCart = (cart) => { localStorage.setItem(CART_KEY, JSON.stringify(cart)); updateCartUI(); };
function addToCart(productId, size, qty = 1) {
const cart = getCart();
const product = PRODUCTS.find((p) => p.id === productId);
if (!product) return;
const key = `${productId}_${size}`;
const existing = cart.find((i) => i.key === key);
if (existing) existing.qty += qty;
else cart.push({ key, productId, size, qty, name: product.name, price: product.price, image: product.cardImage });
saveCart(cart);
showToast(`${product.shortName}" wurde in den Warenkorb gelegt.`);
}
const removeFromCart = (key) => saveCart(getCart().filter((i) => i.key !== key));
function updateQty(key, delta) {
const cart = getCart();
const item = cart.find((i) => i.key === key);
if (item) { item.qty = Math.max(1, item.qty + delta); saveCart(cart); }
}
const cartTotal = () => getCart().reduce((s, i) => s + i.price * i.qty, 0);
const cartCount = () => getCart().reduce((s, i) => s + i.qty, 0);
function updateCartUI() {
const count = cartCount();
document.querySelectorAll('.js-cart-count').forEach((el) => {
el.textContent = count;
el.style.display = count > 0 ? 'inline-flex' : 'none';
});
document.querySelectorAll('.js-cart-total').forEach((el) => { el.textContent = formatPrice(cartTotal()); });
}
/* ===================== TOASTS ===================== */
function showToast(message, type = 'success', duration = 3500) {
let c = document.getElementById('toast-container');
if (!c) { c = document.createElement('div'); c.id = 'toast-container'; c.setAttribute('aria-live', 'polite'); document.body.appendChild(c); }
const t = document.createElement('div');
t.className = `toast toast-${type}`;
t.setAttribute('role', 'status');
t.innerHTML = `<span class="toast-icon">${type === 'success' ? '✓' : '!'}</span><span class="toast-msg">${message}</span>`;
c.appendChild(t);
requestAnimationFrame(() => requestAnimationFrame(() => t.classList.add('toast-visible')));
setTimeout(() => { t.classList.remove('toast-visible'); setTimeout(() => t.remove(), 350); }, duration);
}
/* ===================== GALERIE ===================== */
function initGallery() {
const main = document.getElementById('gallery-main');
const thumbs = document.querySelectorAll('.gallery-thumb');
if (!main || !thumbs.length) return;
thumbs.forEach((thumb, i) => {
thumb.addEventListener('click', () => {
main.src = thumb.dataset.src || thumb.src;
main.alt = thumb.alt;
thumbs.forEach((t) => t.classList.remove('active'));
thumb.classList.add('active');
});
if (i === 0) thumb.classList.add('active');
});
}
/* ===================== GRÖSSENWAHL ===================== */
function initSizeSelector() {
const btns = document.querySelectorAll('.size-btn');
const input = document.getElementById('selected-size');
const addBtn = document.getElementById('add-to-cart-btn');
if (!btns.length) return;
btns.forEach((btn) => {
btn.addEventListener('click', () => {
btns.forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
if (input) input.value = btn.dataset.size;
if (addBtn) { addBtn.disabled = false; addBtn.textContent = 'In den Warenkorb'; }
});
});
}
/* ===================== ADD TO CART ===================== */
function initAddToCart() {
const form = document.getElementById('product-form');
if (form) {
form.addEventListener('submit', (e) => {
e.preventDefault();
const productId = form.dataset.productId;
const sizeInput = document.getElementById('selected-size');
const size = sizeInput ? sizeInput.value : 'One Size';
if (!size) { showToast('Bitte wähle zuerst eine Größe.', 'warning'); return; }
addToCart(productId, size);
});
}
document.querySelectorAll('.js-quick-add').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.preventDefault();
addToCart(btn.dataset.productId, btn.dataset.size || 'One Size');
});
});
}
/* ===================== WARENKORB-SEITE ===================== */
function renderCartPage() {
const container = document.getElementById('cart-items');
const emptyMsg = document.getElementById('cart-empty');
const summary = document.getElementById('cart-summary');
if (!container) return;
const cart = getCart();
if (cart.length === 0) {
container.innerHTML = '';
if (emptyMsg) emptyMsg.style.display = 'block';
if (summary) summary.style.display = 'none';
return;
}
if (emptyMsg) emptyMsg.style.display = 'none';
if (summary) summary.style.display = 'block';
container.innerHTML = cart.map((item) => `
<div class="cart-item" data-key="${item.key}">
<div class="cart-item-img"><img src="${item.image}" alt="${item.name}" width="80" height="100" loading="lazy"></div>
<div class="cart-item-info">
<h3 class="cart-item-name">${item.name}</h3>
<span class="cart-item-size">Größe: ${item.size}</span>
<div class="cart-item-qty">
<button class="qty-btn" data-key="${item.key}" data-delta="-1" aria-label="Weniger"></button>
<span class="qty-val">${item.qty}</span>
<button class="qty-btn" data-key="${item.key}" data-delta="1" aria-label="Mehr">+</button>
</div>
</div>
<div class="cart-item-price">
<span>${formatPrice(item.price * item.qty)}</span>
<button class="cart-remove" data-key="${item.key}" aria-label="${item.name} entfernen">Entfernen</button>
</div>
</div>`).join('');
container.querySelectorAll('.qty-btn').forEach((btn) => btn.addEventListener('click', () => { updateQty(btn.dataset.key, parseInt(btn.dataset.delta)); renderCartPage(); }));
container.querySelectorAll('.cart-remove').forEach((btn) => btn.addEventListener('click', () => { removeFromCart(btn.dataset.key); renderCartPage(); showToast('Artikel entfernt.', 'info'); }));
document.querySelectorAll('.js-cart-subtotal').forEach((el) => { el.textContent = formatPrice(cartTotal()); });
}
/* ===================== CHECKOUT (simuliert — später Stripe via Medusa) ===================== */
function initCheckout() {
const form = document.getElementById('checkout-form');
if (!form) return;
const summaryContainer = document.getElementById('checkout-order-items');
if (summaryContainer) {
const cart = getCart();
summaryContainer.innerHTML = cart.map((i) => `<div class="checkout-item"><span>${i.name} (${i.size}) × ${i.qty}</span><span>${formatPrice(i.price * i.qty)}</span></div>`).join('');
document.querySelectorAll('.js-checkout-total').forEach((el) => { el.textContent = formatPrice(cartTotal() + (cartTotal() >= 50 ? 0 : 4.9)); });
document.querySelectorAll('.js-checkout-shipping').forEach((el) => { el.textContent = cartTotal() >= 50 ? 'Kostenlos' : formatPrice(4.9); });
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const cart = getCart();
if (!cart.length) { showToast('Dein Warenkorb ist leer.', 'warning'); return; }
const fd = new FormData(form);
const btn = form.querySelector('.checkout-submit-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Wird verarbeitet …'; }
try {
const res = await fetch('/api/checkout', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: cart, contact: Object.fromEntries(fd.entries()) }),
});
const data = await res.json();
if (data.url) { localStorage.removeItem(CART_KEY); window.location.href = data.url; return; }
throw new Error(data.error || 'Fehler');
} catch (err) {
showToast('Checkout fehlgeschlagen. Bitte erneut versuchen.', 'warning');
if (btn) { btn.disabled = false; btn.textContent = 'Jetzt bestellen →'; }
}
});
}
/* ===================== INSTAGRAM-FEED ===================== */
const INSTA_POSTS = [
{ img: '/product-images/insta/insta-2.jpg', url: 'https://www.instagram.com/tgasolutions/reel/DVMOZMGiDto/' },
{ img: '/product-images/insta/insta-3.jpg', url: 'https://www.instagram.com/tgasolutions/reel/DVDX5KQiB8Z/' },
{ img: '/product-images/insta/insta-4.jpg', url: 'https://www.instagram.com/tgasolutions/reel/DU_br0OCE35/' },
{ img: '/product-images/insta/insta-1.jpg', url: 'https://www.instagram.com/tgasolutions/reel/DVgqeRQiNnD/' },
{ img: '/product-images/insta/insta-5.jpg', url: 'https://www.instagram.com/tgasolutions/reel/DVVazsxiIB8/' },
{ img: '/product-images/insta/insta-6.jpg', url: 'https://www.instagram.com/tgasolutions/reel/DVDX5KQiB8Z/' },
];
function initInstaFeed() {
const grid = document.getElementById('insta-grid');
if (!grid || !INSTA_POSTS.length) return;
grid.innerHTML = '';
grid.className = 'insta-grid';
grid.setAttribute('role', 'list');
INSTA_POSTS.forEach((post) => {
const tile = document.createElement('a');
tile.className = 'insta-post';
tile.href = post.url; tile.target = '_blank'; tile.rel = 'noopener noreferrer';
tile.setAttribute('role', 'listitem');
tile.setAttribute('aria-label', '@tgasolutions auf Instagram');
tile.innerHTML = `<img src="${post.img}" alt="@tgasolutions" loading="lazy">
<div class="insta-post-overlay">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><rect x="2" y="2" width="20" height="20" rx="5" ry="5"/><circle cx="12" cy="12" r="4.5"/><circle cx="17.5" cy="6.5" r="1.2" fill="white" stroke="none"/></svg>
<span class="insta-post-tag">@tgasolutions</span>
</div>`;
grid.appendChild(tile);
});
}
/* ===================== MOBILE-MENÜ ===================== */
function initMobileMenu() {
const toggle = document.getElementById('menu-toggle');
const nav = document.getElementById('main-nav');
if (!toggle || !nav) return;
toggle.addEventListener('click', () => {
const open = nav.classList.toggle('nav-open');
toggle.setAttribute('aria-expanded', open);
document.body.style.overflow = open ? 'hidden' : '';
});
nav.querySelectorAll('a').forEach((a) => a.addEventListener('click', () => {
nav.classList.remove('nav-open');
toggle.setAttribute('aria-expanded', 'false');
document.body.style.overflow = '';
}));
}
/* ===================== STICKY MOBILE ADD-TO-CART ===================== */
function initStickyMobileATC() {
const mainBtn = document.getElementById('add-to-cart-btn');
if (!mainBtn || window.innerWidth > 900) return;
const nameEl = document.querySelector('[class*="pdp-name"]') || document.querySelector('h1');
const productName = nameEl ? nameEl.textContent.trim().split('\n')[0].trim() : 'Produkt';
const bar = document.createElement('div');
bar.id = 'sticky-atc';
bar.setAttribute('aria-hidden', 'true');
bar.innerHTML = `<span class="sticky-atc-name">${productName.substring(0, 40)}</span><button class="sticky-atc-btn" id="sticky-atc-btn">In den Warenkorb</button>`;
document.body.appendChild(bar);
const observer = new IntersectionObserver(([entry]) => {
bar.classList.toggle('sticky-atc-visible', !entry.isIntersecting);
bar.setAttribute('aria-hidden', entry.isIntersecting ? 'true' : 'false');
}, { threshold: 0, rootMargin: '0px 0px -60px 0px' });
observer.observe(mainBtn);
document.getElementById('sticky-atc-btn').addEventListener('click', () => {
if (!mainBtn.disabled) mainBtn.click();
else { mainBtn.scrollIntoView({ behavior: 'smooth', block: 'center' }); mainBtn.focus(); }
});
}
/* ===================== UTIL ===================== */
function formatPrice(num) {
return num.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' });
}
/* ===================== INIT ===================== */
document.addEventListener('DOMContentLoaded', () => {
updateCartUI();
initGallery();
initSizeSelector();
initAddToCart();
initMobileMenu();
if (document.getElementById('cart-items')) renderCartPage();
if (document.getElementById('checkout-form')) initCheckout();
if (document.body.dataset.page === 'product') initStickyMobileATC();
if (document.getElementById('insta-grid')) initInstaFeed();
});
+2
View File
@@ -0,0 +1,2 @@
// Content-Collection wird in dieser Variante nicht genutzt — Daten kommen aus src/lib/store.js.
export const collections = {};
+23
View File
@@ -0,0 +1,23 @@
---
name: "Cap Elektro"
shortName: "Cap Elektro"
priceCents: 1999
category: "Cap"
productType: "physical"
sizes:
- "One Size"
images:
- "/product-images/p02-cap-elektro-1.jpg"
cardImage: "/product-images/p02-cap-elektro-1.jpg"
freigestellt: "/product-images/p02-cap-elektro-1-freigestellt.png"
badge: ""
stock: 41
material: "Baumwolle · SnapBack-Verschluss · One Size fits most"
features:
- "SnapBack-Verschluss"
- "Stickerei vorne & hinten"
- "One Size fits most"
featured: false
sort: 2
desc: "EST 2022. SnapBack. Für Elektriker, die wissen, was sie tun. PMPNZNG auf der Rückseite."
---
+24
View File
@@ -0,0 +1,24 @@
---
name: "Cap SHK"
shortName: "Cap SHK"
priceCents: 1999
category: "Cap"
productType: "physical"
sizes:
- "One Size"
images:
- "/product-images/p04-cap-shk-1.jpg"
- "/product-images/p04-cap-shk-2.jpg"
cardImage: "/product-images/p04-cap-shk-1.jpg"
freigestellt: "/product-images/p04-cap-shk-1-freigestellt.png"
badge: ""
stock: 38
material: "Baumwolle · SnapBack-Verschluss · One Size fits most"
features:
- "SnapBack-Verschluss"
- "SHK-Stickerei vorne"
- "PMPNZNG hinten"
featured: false
sort: 4
desc: "SHK vorne, PMPNZNG auf der Rückseite. Statement für die Branche. SnapBack, One Size."
---
@@ -0,0 +1,30 @@
---
name: "Hoodie Pumpenzange"
shortName: "Hoodie Pumpenzange"
priceCents: 4999
category: "Hoodie"
productType: "physical"
sizes:
- "XS"
- "S"
- "M"
- "L"
- "XL"
- "2XL"
- "3XL"
images:
- "/product-images/p06-hoodie-pumpenzange-1.jpg"
cardImage: "/product-images/p06-hoodie-pumpenzange-1-freigestellt.png"
freigestellt: "/product-images/p06-hoodie-pumpenzange-1-freigestellt.png"
badge: "Bio"
stock: 19
material: "Bio-Baumwolle (Fairtrade-zertifiziert) · 280 g/m²"
features:
- "Pumpenzangen-Grafik"
- "Kapuze mit Tunnelzug"
- "Känguru-Tasche"
- "Bio-Baumwolle"
featured: false
sort: 6
desc: "Bio-Baumwolle, Fairtrade. Für die, die es wissen. Das Werkzeug, das man trägt."
---
@@ -0,0 +1,34 @@
---
name: "PMPNZNG Hoodie Stick & Druck"
shortName: "Hoodie Stick & Druck"
priceCents: 4999
category: "Hoodie"
productType: "physical"
sizes:
- "XS"
- "S"
- "M"
- "L"
- "XL"
- "2XL"
- "3XL"
images:
- "/product-images/p01-pmpnzng-hoodie-1.jpg"
- "/product-images/p01-pmpnzng-hoodie-2.jpg"
- "/product-images/p01-pmpnzng-hoodie-3.jpg"
- "/product-images/p01-pmpnzng-hoodie-4.jpg"
- "/product-images/p01-pmpnzng-hoodie-5.jpg"
cardImage: "/product-images/p01-pmpnzng-hoodie-1.jpg"
freigestellt: ""
badge: "Bestseller"
stock: 23
material: "Bio-Baumwolle (Fairtrade-zertifiziert) · 280 g/m²"
features:
- "Besticktes TGA-Wappen"
- "PMPNZNG-Print vorne"
- "Kapuze mit Tunnelzug"
- "Känguru-Tasche"
featured: true
sort: 1
desc: "Besticktes TGA-Wappen auf der Brust, PMPNZNG-Print auf dem Rücken. Bio-Baumwolle, Fairtrade. Für die, die wissen, was eine Pumpenzange ist."
---
@@ -0,0 +1,31 @@
---
name: "T-Shirt Pumpenzange"
shortName: "T-Shirt Pumpenzange"
priceCents: 2799
category: "T-Shirt"
productType: "physical"
sizes:
- "XS"
- "S"
- "M"
- "L"
- "XL"
- "2XL"
- "3XL"
images:
- "/product-images/p05-tshirt-pumpenzange-1.jpg"
- "/product-images/p05-tshirt-pumpenzange-2.jpg"
cardImage: "/product-images/p05-tshirt-pumpenzange-1.jpg"
freigestellt: "/product-images/p05-tshirt-pumpenzange-1-freigestellt.png"
badge: ""
stock: 55
material: "Bio-Baumwolle (Fairtrade-zertifiziert) · 180 g/m²"
features:
- "Pumpenzangen-Print"
- "Bio-Baumwolle"
- "Unisex-Schnitt"
- "XS 3XL"
featured: false
sort: 5
desc: "Bio-Baumwolle, Fairtrade. Der tägliche Beweis, dass Handwerk Haltung hat. XS bis 3XL."
---
@@ -0,0 +1,25 @@
---
name: "Warmduscher Beutel"
shortName: "Warmduscher Beutel"
priceCents: 1099
category: "Tasche"
productType: "physical"
sizes:
- "One Size"
images:
- "/product-images/p03-warmduscher-beutel-1.jpg"
- "/product-images/p03-warmduscher-beutel-2.jpg"
- "/product-images/p03-warmduscher-beutel-3.jpg"
cardImage: "/product-images/p03-warmduscher-beutel-1.jpg"
freigestellt: "/product-images/p03-warmduscher-beutel-1-freigestellt.png"
badge: "Neu"
stock: 67
material: "Bio-Baumwolle · Beutel mit Kordelzug"
features:
- "Kordelzug-Verschluss"
- "PMPNZNG-Print"
- "Bio-Baumwolle"
featured: false
sort: 3
desc: "Du trägst was du bist. Und du weißt, dass du keiner bist. 😂 Das perfekte Gegengewicht zur Werkzeugkiste."
---
+62
View File
@@ -0,0 +1,62 @@
---
import '../styles/admin.css';
import { driverName } from '../lib/store.js';
export interface Props { title: string; active?: string; crumbs?: { label: string; href?: string }[]; }
const { title, active = '', crumbs = [] } = Astro.props;
const driver = driverName();
const nav = [
{ key:'dashboard', label:'Dashboard', href:'/admin', icon:'M3 13h8V3H3v10Zm0 8h8v-6H3v6Zm10 0h8V11h-8v10Zm0-18v6h8V3h-8Z' },
{ key:'bestellungen', label:'Bestellungen', href:'/admin/bestellungen', icon:'M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4H6Zm.5 2h11l1.5 2H5l1.5-2ZM5 8h14v12H5V8Zm4 2a3 3 0 0 0 6 0' },
{ key:'produkte', label:'Produkte', href:'/admin/produkte', icon:'M20.5 7.3 12 2 3.5 7.3 12 12.6l8.5-5.3ZM3 9v8l8 5v-8L3 9Zm10 13 8-5V9l-8 5v8Z' },
{ key:'kunden', label:'Kunden', href:'/admin/kunden', icon:'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm0 2c-4 0-8 2-8 5v1h16v-1c0-3-4-5-8-5Z' },
{ key:'analytics', label:'Analytics', href:'/admin/analytics', icon:'M4 20V10m6 10V4m6 16v-7m4 7H2' },
{ key:'einstellungen', label:'Einstellungen', href:'/admin/einstellungen', icon:'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm9 4-2 .5.4 2-1.7 1.3-1.7-1.2-1.8.8-.3 2H10l-.3-2-1.8-.8-1.7 1.2L4.5 14.5 5 12.5 3 12l.5-2 2-.5-.4-2L6.8 6.2l1.7 1.2 1.8-.8.3-2h2.8l.3 2 1.8.8 1.7-1.2 1.7 1.3-.4 2 2 .5-.5 2Z' },
];
---
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>{title} · PMPNZNG Admin</title>
</head>
<body class="admin-body">
<div class="admin-shell">
<aside class="s-side">
<div class="s-brand">
<div class="s-brand-logo">P</div>
<div><div class="s-brand-name">PMPNZNG</div><div class="s-brand-sub">Admin · {driver === 'directus' ? 'Directus' : 'SQLite'}</div></div>
</div>
<nav class="s-nav">
{nav.map((n) => (
<a href={n.href} class={active === n.key ? 'active' : ''}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={n.icon}/></svg>
{n.label}
</a>
))}
<div class="s-nav-sec">Vertriebskanal</div>
<a href="/" target="_blank">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>
Online-Shop ↗
</a>
</nav>
<div class="s-side-foot">Demo-Backend · <a href="/">Shop ansehen</a></div>
</aside>
<div class="s-main">
<header class="s-topbar">
<div>
<div class="s-crumbs">
<a href="/admin">Admin</a>
{crumbs.map((c) => (<><span>/</span>{c.href ? <a href={c.href}>{c.label}</a> : <span>{c.label}</span>}</>))}
</div>
<div class="s-title">{title}</div>
</div>
<div class="s-actions"><slot name="actions" /></div>
</header>
<div class="s-content"><slot /></div>
</div>
</div>
</body>
</html>
+89
View File
@@ -0,0 +1,89 @@
---
import '../styles/global.css';
import { getProducts } from '../lib/products';
export interface Props { title?: string; description?: string; page?: string; ogImage?: string; active?: string; }
const {
title = 'PMPNZNG — Heizungsbauerausleidenschaft',
description = 'PMPNZNG Merch für die SHK-Community. Bio-Baumwolle, Fairtrade. Aus Ahlerstedt, Niedersachsen.',
page = '', ogImage = '/product-images/p01-pmpnzng-hoodie-1.jpg', active = '',
} = Astro.props;
const allProducts = await getProducts();
const clientProducts = allProducts.map((p) => ({ id: p.slug, slug: p.slug, name: p.name, shortName: p.shortName, price: p.priceCents / 100, cardImage: p.cardImage }));
const UMAMI_SRC = process.env.UMAMI_SRC || 'https://analytics.heidrich-digital.de/script.js';
const UMAMI_WEBSITE_ID = process.env.UMAMI_WEBSITE_ID || '';
const marqueeItems = ['PMPNZNG','Heizungsbauer aus Leidenschaft','Bio-Baumwolle · Fairtrade','Aus Ahlerstedt, Niedersachsen',"Wenn's läuft, dann läuft's"];
const storeJsonLd = { '@context':'https://schema.org','@type':'Store',name:'PMPNZNG',url:'https://tgasolutions-shop.de',
address:{'@type':'PostalAddress',streetAddress:'Kleine Kamp 1',addressLocality:'Ahlerstedt',postalCode:'21702',addressCountry:'DE'},
parentOrganization:{'@type':'Organization',name:'TGA Solutions GmbH'} };
---
<!doctype html>
<html lang="de" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<meta name="description" content={description} />
<meta name="robots" content="noindex" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={ogImage} />
<meta property="og:type" content="website" />
<title>{title}</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<script type="application/ld+json" set:html={JSON.stringify(storeJsonLd)} is:inline />
{UMAMI_WEBSITE_ID && <script defer src={UMAMI_SRC} data-website-id={UMAMI_WEBSITE_ID} is:inline />}
</head>
<body data-page={page}>
<a class="skip-link" href="#main-content">Zum Inhalt springen</a>
<header class="site-header" role="banner">
<a href="/" class="header-logo" aria-label="PMPNZNG Startseite">
<div class="logo-mark">PMPN<strong>Z</strong>NG</div>
<div class="logo-sub">by TGA Solutions</div>
</a>
<nav id="main-nav" class="header-nav" aria-label="Hauptnavigation">
<a href="/shop" aria-current={active === 'shop' ? 'page' : undefined}>Shop</a>
<a href="/faq" aria-current={active === 'faq' ? 'page' : undefined}>FAQ &amp; Größen</a>
<a href="/ueber-uns" aria-current={active === 'ueber-uns' ? 'page' : undefined}>Über uns</a>
<a href="https://instagram.com/tgasolutions" target="_blank" rel="noopener">@tgasolutions</a>
</nav>
<div class="header-actions">
<a href="/warenkorb" class="cart-btn" aria-label="Warenkorb">Warenkorb
<span class="js-cart-count" style="display:none" aria-hidden="true">0</span></a>
<button id="menu-toggle" class="menu-toggle" aria-label="Menü öffnen" aria-expanded="false" aria-controls="main-nav">
<span></span><span></span><span></span></button>
</div>
</header>
<div class="marquee-band" aria-hidden="true">
<div class="marquee-inner">
{[0,1].map(() => (<div class="marquee-item">{marqueeItems.map((item) => (<><span>{item}</span><span class="marquee-sep"></span></>))}</div>))}
</div>
</div>
<main id="main-content"><slot /></main>
<footer class="site-footer" role="contentinfo">
<div class="footer-grid">
<div class="footer-brand">
<a href="/" class="footer-brand-logo">PMPN<strong>Z</strong>NG</a>
<p>Merch für Leute, die wissen, was eine Pumpenzange ist. Eine Marke der TGA Solutions GmbH — Meisterfachbetrieb für technische Gebäudeausrüstung, Ahlerstedt.</p>
<span class="footer-badge">EST. 2021 · Ahlerstedt, DE</span>
</div>
<nav class="footer-col" aria-label="Sortiment"><h4>Sortiment</h4>
<a href="/shop">Alle Produkte</a><a href="/produkt/hoodie-stick-druck">Hoodies</a>
<a href="/produkt/tshirt-pumpenzange">T-Shirts</a><a href="/produkt/cap-shk">Caps</a><a href="/produkt/warmduscher-beutel">Taschen</a></nav>
<nav class="footer-col" aria-label="Service"><h4>Service</h4>
<a href="/faq">Größentabelle</a><a href="/faq">Versand &amp; Rückgabe</a><a href="/faq">FAQ</a><a href="mailto:info@tgasolutions-shop.de">Kontakt</a></nav>
<nav class="footer-col" aria-label="Rechtliches"><h4>Rechtliches</h4>
<a href="/impressum">Impressum</a><a href="/datenschutz">Datenschutz</a><a href="/datenschutz#agb">AGB</a><a href="/admin">Admin-Backend</a></nav>
</div>
<div class="footer-bar">
<span>© 2026 PMPNZNG · TGA Solutions GmbH · Kleine Kamp 1 · 21702 Ahlerstedt · Demo</span>
<span><a href="mailto:info@tgasolutions-shop.de">info@tgasolutions-shop.de</a> · <a href="tel:+494166899502">04166 8991 502</a></span>
</div>
</footer>
<script is:inline define:vars={{ clientProducts }}>window.__PRODUCTS__ = clientProducts;</script>
<script is:inline src="/shop.js"></script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
import { listProducts, getProductBySlug as _bySlug } from './store.js';
export { formatPrice } from './seed.js';
export type Product = any;
export async function getProducts() { return await listProducts(); }
export async function getProductBySlug(slug: string) { return await _bySlug(slug); }
+43
View File
@@ -0,0 +1,43 @@
// Zentrale Seed-Daten — identisch für beide Treiber (SQLite & Directus).
export const SEED_PRODUCTS = [
{ slug:'hoodie-stick-druck', name:'PMPNZNG Hoodie Stick & Druck', shortName:'Hoodie Stick & Druck', priceCents:4999, category:'Hoodie', productType:'physical', sizes:['XS','S','M','L','XL','2XL','3XL'], images:['/product-images/p01-pmpnzng-hoodie-1.jpg','/product-images/p01-pmpnzng-hoodie-2.jpg','/product-images/p01-pmpnzng-hoodie-3.jpg','/product-images/p01-pmpnzng-hoodie-4.jpg','/product-images/p01-pmpnzng-hoodie-5.jpg'], cardImage:'/product-images/p01-pmpnzng-hoodie-1.jpg', freigestellt:'', badge:'Bestseller', stock:23, material:'Bio-Baumwolle (Fairtrade-zertifiziert) · 280 g/m²', features:['Besticktes TGA-Wappen','PMPNZNG-Print vorne','Kapuze mit Tunnelzug','Känguru-Tasche'], featured:true, sort:1, desc:'Besticktes TGA-Wappen auf der Brust, PMPNZNG-Print auf dem Rücken. Bio-Baumwolle, Fairtrade. Für die, die wissen, was eine Pumpenzange ist.' },
{ slug:'cap-elektro', name:'Cap Elektro', shortName:'Cap Elektro', priceCents:1999, category:'Cap', productType:'physical', sizes:['One Size'], images:['/product-images/p02-cap-elektro-1.jpg'], cardImage:'/product-images/p02-cap-elektro-1.jpg', freigestellt:'/product-images/p02-cap-elektro-1-freigestellt.png', badge:'', stock:41, material:'Baumwolle · SnapBack-Verschluss · One Size fits most', features:['SnapBack-Verschluss','Stickerei vorne & hinten','One Size fits most'], featured:false, sort:2, desc:'EST 2022. SnapBack. Für Elektriker, die wissen, was sie tun. PMPNZNG auf der Rückseite.' },
{ slug:'warmduscher-beutel', name:'Warmduscher Beutel', shortName:'Warmduscher Beutel', priceCents:1099, category:'Tasche', productType:'physical', sizes:['One Size'], images:['/product-images/p03-warmduscher-beutel-1.jpg','/product-images/p03-warmduscher-beutel-2.jpg','/product-images/p03-warmduscher-beutel-3.jpg'], cardImage:'/product-images/p03-warmduscher-beutel-1.jpg', freigestellt:'/product-images/p03-warmduscher-beutel-1-freigestellt.png', badge:'Neu', stock:67, material:'Bio-Baumwolle · Beutel mit Kordelzug', features:['Kordelzug-Verschluss','PMPNZNG-Print','Bio-Baumwolle'], featured:false, sort:3, desc:'Du trägst was du bist. Und du weißt, dass du keiner bist. Das perfekte Gegengewicht zur Werkzeugkiste.' },
{ slug:'cap-shk', name:'Cap SHK', shortName:'Cap SHK', priceCents:1999, category:'Cap', productType:'physical', sizes:['One Size'], images:['/product-images/p04-cap-shk-1.jpg','/product-images/p04-cap-shk-2.jpg'], cardImage:'/product-images/p04-cap-shk-1.jpg', freigestellt:'/product-images/p04-cap-shk-1-freigestellt.png', badge:'', stock:38, material:'Baumwolle · SnapBack-Verschluss · One Size fits most', features:['SnapBack-Verschluss','SHK-Stickerei vorne','PMPNZNG hinten'], featured:false, sort:4, desc:'SHK vorne, PMPNZNG auf der Rückseite. Statement für die Branche. SnapBack, One Size.' },
{ slug:'tshirt-pumpenzange', name:'T-Shirt Pumpenzange', shortName:'T-Shirt Pumpenzange', priceCents:2799, category:'T-Shirt', productType:'physical', sizes:['XS','S','M','L','XL','2XL','3XL'], images:['/product-images/p05-tshirt-pumpenzange-1.jpg','/product-images/p05-tshirt-pumpenzange-2.jpg'], cardImage:'/product-images/p05-tshirt-pumpenzange-1.jpg', freigestellt:'/product-images/p05-tshirt-pumpenzange-1-freigestellt.png', badge:'Bio', stock:55, material:'Bio-Baumwolle (Fairtrade-zertifiziert) · 180 g/m²', features:['Pumpenzangen-Print','Bio-Baumwolle','Unisex-Schnitt','XS 3XL'], featured:false, sort:5, desc:'Bio-Baumwolle, Fairtrade. Der tägliche Beweis, dass Handwerk Haltung hat. XS bis 3XL.' },
{ slug:'hoodie-pumpenzange', name:'Hoodie Pumpenzange', shortName:'Hoodie Pumpenzange', priceCents:4999, category:'Hoodie', productType:'physical', sizes:['XS','S','M','L','XL','2XL','3XL'], images:['/product-images/p06-hoodie-pumpenzange-1.jpg'], cardImage:'/product-images/p06-hoodie-pumpenzange-1-freigestellt.png', freigestellt:'/product-images/p06-hoodie-pumpenzange-1-freigestellt.png', badge:'Bio', stock:19, material:'Bio-Baumwolle (Fairtrade-zertifiziert) · 280 g/m²', features:['Pumpenzangen-Grafik','Kapuze mit Tunnelzug','Känguru-Tasche','Bio-Baumwolle'], featured:false, sort:6, desc:'Bio-Baumwolle, Fairtrade. Für die, die es wissen. Das Werkzeug, das man trägt.' },
];
export const SEED_CUSTOMERS = [
{ name:'Markus Brand', email:'m.brand@example.de', city:'Hamburg' },
{ name:'Sandra Kühn', email:'s.kuehn@example.de', city:'Bremen' },
{ name:'Tobias Reents', email:'t.reents@example.de', city:'Ahlerstedt' },
{ name:'Jens Holm', email:'jens.holm@example.de', city:'Stade' },
{ name:'Petra Vogt', email:'p.vogt@example.de', city:'Buxtehude' },
];
// Demo-Bestellungen (created_at relativ zu jetzt). status: pending|fulfilled|cancelled|refunded
export function seedOrders() {
const now = Date.now();
const d = (days) => new Date(now - days*86400000).toISOString();
return [
{ number:'PMPNZNG-1042', email:'m.brand@example.de', customer_name:'Markus Brand', status:'fulfilled', created_at:d(1),
items:[{name:'PMPNZNG Hoodie Stick & Druck', size:'L', qty:1, priceCents:4999},{name:'Cap SHK', size:'One Size', qty:1, priceCents:1999}] },
{ number:'PMPNZNG-1041', email:'s.kuehn@example.de', customer_name:'Sandra Kühn', status:'pending', created_at:d(1),
items:[{name:'T-Shirt Pumpenzange', size:'M', qty:2, priceCents:2799}] },
{ number:'PMPNZNG-1040', email:'t.reents@example.de', customer_name:'Tobias Reents', status:'fulfilled', created_at:d(3),
items:[{name:'Hoodie Pumpenzange', size:'XL', qty:1, priceCents:4999}] },
{ number:'PMPNZNG-1039', email:'jens.holm@example.de', customer_name:'Jens Holm', status:'fulfilled', created_at:d(4),
items:[{name:'Warmduscher Beutel', size:'One Size', qty:3, priceCents:1099},{name:'Cap Elektro', size:'One Size', qty:1, priceCents:1999}] },
{ number:'PMPNZNG-1038', email:'p.vogt@example.de', customer_name:'Petra Vogt', status:'cancelled', created_at:d(6),
items:[{name:'Cap Elektro', size:'One Size', qty:1, priceCents:1999}] },
{ number:'PMPNZNG-1037', email:'m.brand@example.de', customer_name:'Markus Brand', status:'fulfilled', created_at:d(9),
items:[{name:'T-Shirt Pumpenzange', size:'L', qty:1, priceCents:2799}] },
{ number:'PMPNZNG-1036', email:'s.kuehn@example.de', customer_name:'Sandra Kühn', status:'refunded', created_at:d(12),
items:[{name:'PMPNZNG Hoodie Stick & Druck', size:'M', qty:1, priceCents:4999}] },
{ number:'PMPNZNG-1035', email:'jens.holm@example.de', customer_name:'Jens Holm', status:'fulfilled', created_at:d(15),
items:[{name:'Cap SHK', size:'One Size', qty:2, priceCents:1999}] },
].map(o => ({ ...o, total_cents: o.items.reduce((s,i)=>s+i.priceCents*i.qty,0) }));
}
export const formatPrice = (cents) => (Number(cents)/100).toLocaleString('de-DE',{ style:'currency', currency:'EUR' });
+64
View File
@@ -0,0 +1,64 @@
// Directus-Treiber — CRUD über die Directus REST-API. Collections (products/orders/customers)
// werden vor dem Deploy per Skript provisioniert + geseedet; hier nur Lese-/Schreibzugriff.
const BASE = (process.env.DIRECTUS_URL || '').replace(/\/$/, '');
const TOKEN = process.env.DIRECTUS_TOKEN || '';
async function api(path, opts = {}) {
const res = await fetch(`${BASE}${path}`, {
...opts,
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${TOKEN}`, ...(opts.headers || {}) },
});
if (!res.ok) throw new Error(`Directus ${res.status} ${path}: ${await res.text().catch(()=> '')}`);
if (res.status === 204) return null;
return (await res.json()).data;
}
const arr = (v) => Array.isArray(v) ? v : (v ? (()=>{ try { return JSON.parse(v); } catch { return []; } })() : []);
const P = (r) => r && ({ ...r, sizes: arr(r.sizes), images: arr(r.images), features: arr(r.features), featured: !!r.featured });
const O = (r) => r && ({ ...r, items: arr(r.items) });
export const listProducts = async () => (await api('/items/products?limit=-1&sort=sort,id')).map(P);
export const getProductBySlug = async (slug) => { const d = await api(`/items/products?filter[slug][_eq]=${encodeURIComponent(slug)}&limit=1`); return P(d[0]); };
export const getProductById = async (id) => P(await api(`/items/products/${id}`));
export const createProduct = async (d) => (await api('/items/products', { method:'POST', body: JSON.stringify(payload(d)) })).id;
export const updateProduct = async (id, d) => { await api(`/items/products/${id}`, { method:'PATCH', body: JSON.stringify(payload(d)) }); return id; };
export const deleteProduct = async (id) => { await api(`/items/products/${id}`, { method:'DELETE' }); };
function payload(d){
const cardImage = d.cardImage || (Array.isArray(d.images)&&d.images[0]) || d.freigestellt || '';
return { slug:d.slug, name:d.name, shortName:d.shortName||d.name, priceCents:Math.round(Number(d.priceCents)||0), category:d.category||'', productType:d.productType||'physical',
sizes:d.sizes||['One Size'], images:d.images||[], cardImage, freigestellt:d.freigestellt||'', badge:d.badge||'',
stock:(d.stock===''||d.stock==null)?null:Math.round(Number(d.stock)), material:d.material||'', features:d.features||[],
featured:!!d.featured, sort:Number(d.sort)||99, desc:d.desc||'' };
}
export const listOrders = async () => (await api('/items/orders?limit=-1&sort=-created_at')).map(O);
export const getOrderById = async (id) => O(await api(`/items/orders/${id}`));
export async function createOrder({ email, customer_name, items, total_cents, status='pending', address='' }) {
const existing = await api('/items/orders?limit=1&sort=-id&fields=number');
let n = 1042; if (existing[0]?.number) { const m = parseInt(String(existing[0].number).split('-')[1]); if (!isNaN(m)) n = m; }
const number = 'PMPNZNG-' + (n + 1);
const o = await api('/items/orders', { method:'POST', body: JSON.stringify({ number, email:email||'', customer_name:customer_name||'', status, total_cents:total_cents||0, items:items||[], address:address||'', created_at: new Date().toISOString() }) });
if (email) { const c = await api(`/items/customers?filter[email][_eq]=${encodeURIComponent(email)}&limit=1`); if (!c.length) await api('/items/customers', { method:'POST', body: JSON.stringify({ name:customer_name||'', email, city:'', created_at:new Date().toISOString() }) }); }
return { id: o.id, number };
}
export const updateOrderStatus = async (id, status) => { await api(`/items/orders/${id}`, { method:'PATCH', body: JSON.stringify({ status }) }); };
export async function listCustomers() {
const [customers, orders] = await Promise.all([ api('/items/customers?limit=-1&sort=id'), api('/items/orders?limit=-1&fields=email,total_cents,status') ]);
return customers.map(c => {
const mine = orders.filter(o => o.email === c.email && !['cancelled','refunded'].includes(o.status));
return { ...c, orders_count: mine.length, total_spent_cents: mine.reduce((s,o)=>s+(o.total_cents||0),0) };
});
}
export const getCustomerById = async (id) => api(`/items/customers/${id}`);
export async function dashboard() {
const [orders, products, customers] = await Promise.all([ api('/items/orders?limit=-1&sort=-created_at'), api('/items/products?limit=-1'), api('/items/customers?limit=-1&fields=id') ]);
const paid = orders.filter(o => !['cancelled','refunded'].includes(o.status));
return {
revenueCents: paid.reduce((s,o)=>s+(o.total_cents||0),0),
orderCount: orders.length, productCount: products.length, customerCount: customers.length,
pending: orders.filter(o=>o.status==='pending').length,
recentOrders: orders.slice(0,6).map(O),
lowStock: products.filter(p=>p.stock!=null && p.stock<=25).sort((a,b)=>a.stock-b.stock).map(P),
};
}
+105
View File
@@ -0,0 +1,105 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { SEED_PRODUCTS, SEED_CUSTOMERS, seedOrders } from './seed.js';
const DB_PATH = process.env.DB_PATH || './data/shop.db';
try { mkdirSync(dirname(DB_PATH), { recursive: true }); } catch {}
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL, name TEXT NOT NULL, shortName TEXT,
priceCents INTEGER NOT NULL DEFAULT 0, category TEXT, productType TEXT DEFAULT 'physical',
sizes TEXT DEFAULT '["One Size"]', images TEXT DEFAULT '[]', cardImage TEXT, freigestellt TEXT DEFAULT '',
badge TEXT DEFAULT '', stock INTEGER, material TEXT DEFAULT '', features TEXT DEFAULT '[]',
featured INTEGER DEFAULT 0, sort INTEGER DEFAULT 99, desc TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS customers (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, email TEXT UNIQUE, city TEXT, created_at TEXT
);
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT, number TEXT UNIQUE, email TEXT, customer_name TEXT,
status TEXT DEFAULT 'pending', total_cents INTEGER DEFAULT 0, items TEXT DEFAULT '[]',
address TEXT DEFAULT '', created_at TEXT
);
`);
const P = (r) => r && ({ ...r, sizes: JSON.parse(r.sizes||'[]'), images: JSON.parse(r.images||'[]'), features: JSON.parse(r.features||'[]'), featured: !!r.featured });
const O = (r) => r && ({ ...r, items: JSON.parse(r.items||'[]') });
function seedIfEmpty() {
if (db.prepare('SELECT COUNT(*) c FROM products').get().c === 0) {
const ins = db.prepare(`INSERT INTO products (slug,name,shortName,priceCents,category,productType,sizes,images,cardImage,freigestellt,badge,stock,material,features,featured,sort,desc)
VALUES (@slug,@name,@shortName,@priceCents,@category,@productType,@sizes,@images,@cardImage,@freigestellt,@badge,@stock,@material,@features,@featured,@sort,@desc)`);
const tx = db.transaction(rows => rows.forEach(p => ins.run({ ...p, sizes:JSON.stringify(p.sizes), images:JSON.stringify(p.images), features:JSON.stringify(p.features), featured:p.featured?1:0 })));
tx(SEED_PRODUCTS);
}
if (db.prepare('SELECT COUNT(*) c FROM customers').get().c === 0) {
const ic = db.prepare('INSERT OR IGNORE INTO customers (name,email,city,created_at) VALUES (@name,@email,@city,@created_at)');
const now = new Date().toISOString();
SEED_CUSTOMERS.forEach(c => ic.run({ ...c, created_at: now }));
}
if (db.prepare('SELECT COUNT(*) c FROM orders').get().c === 0) {
const io = db.prepare('INSERT INTO orders (number,email,customer_name,status,total_cents,items,address,created_at) VALUES (@number,@email,@customer_name,@status,@total_cents,@items,@address,@created_at)');
seedOrders().forEach(o => io.run({ ...o, items: JSON.stringify(o.items), address: '' }));
}
}
seedIfEmpty();
export const listProducts = () => db.prepare('SELECT * FROM products ORDER BY sort, id').all().map(P);
export const getProductBySlug = (slug) => P(db.prepare('SELECT * FROM products WHERE slug=?').get(slug));
export const getProductById = (id) => P(db.prepare('SELECT * FROM products WHERE id=?').get(id));
export function createProduct(d) {
const r = db.prepare(`INSERT INTO products (slug,name,shortName,priceCents,category,productType,sizes,images,cardImage,freigestellt,badge,stock,material,features,featured,sort,desc)
VALUES (@slug,@name,@shortName,@priceCents,@category,@productType,@sizes,@images,@cardImage,@freigestellt,@badge,@stock,@material,@features,@featured,@sort,@desc)`)
.run(norm(d));
return r.lastInsertRowid;
}
export function updateProduct(id, d) {
db.prepare(`UPDATE products SET slug=@slug,name=@name,shortName=@shortName,priceCents=@priceCents,category=@category,productType=@productType,sizes=@sizes,images=@images,cardImage=@cardImage,freigestellt=@freigestellt,badge=@badge,stock=@stock,material=@material,features=@features,featured=@featured,sort=@sort,desc=@desc WHERE id=@id`)
.run({ ...norm(d), id: Number(id) });
return id;
}
export const deleteProduct = (id) => db.prepare('DELETE FROM products WHERE id=?').run(Number(id));
function norm(d){
const cardImage = d.cardImage || (Array.isArray(d.images)&&d.images[0]) || d.freigestellt || '';
return { slug:d.slug, name:d.name, shortName:d.shortName||d.name, priceCents:Math.round(Number(d.priceCents)||0), category:d.category||'', productType:d.productType||'physical',
sizes:JSON.stringify(d.sizes||['One Size']), images:JSON.stringify(d.images||[]), cardImage, freigestellt:d.freigestellt||'',
badge:d.badge||'', stock:(d.stock===''||d.stock==null)?null:Math.round(Number(d.stock)), material:d.material||'', features:JSON.stringify(d.features||[]),
featured:d.featured?1:0, sort:Number(d.sort)||99, desc:d.desc||'' };
}
export const listOrders = () => db.prepare('SELECT * FROM orders ORDER BY datetime(created_at) DESC, id DESC').all().map(O);
export const getOrderById = (id) => O(db.prepare('SELECT * FROM orders WHERE id=?').get(Number(id)));
export function createOrder({ email, customer_name, items, total_cents, status='pending', address='' }) {
const n = (db.prepare("SELECT MAX(CAST(substr(number,9) AS INTEGER)) m FROM orders").get().m) || 1042;
const number = 'PMPNZNG-' + (n + 1);
const r = db.prepare('INSERT INTO orders (number,email,customer_name,status,total_cents,items,address,created_at) VALUES (?,?,?,?,?,?,?,?)')
.run(number, email||'', customer_name||'', status, total_cents||0, JSON.stringify(items||[]), address||'', new Date().toISOString());
if (email) db.prepare('INSERT OR IGNORE INTO customers (name,email,city,created_at) VALUES (?,?,?,?)').run(customer_name||'', email, '', new Date().toISOString());
return { id: r.lastInsertRowid, number };
}
export const updateOrderStatus = (id, status) => db.prepare('UPDATE orders SET status=? WHERE id=?').run(status, Number(id));
export function listCustomers() {
const rows = db.prepare('SELECT * FROM customers ORDER BY id').all();
return rows.map(c => {
const agg = db.prepare("SELECT COUNT(*) cnt, COALESCE(SUM(total_cents),0) spent FROM orders WHERE email=? AND status NOT IN ('cancelled','refunded')").get(c.email);
return { ...c, orders_count: agg.cnt, total_spent_cents: agg.spent };
});
}
export const getCustomerById = (id) => db.prepare('SELECT * FROM customers WHERE id=?').get(Number(id));
export function dashboard() {
const revenue = db.prepare("SELECT COALESCE(SUM(total_cents),0) s FROM orders WHERE status NOT IN ('cancelled','refunded')").get().s;
const orderCount = db.prepare('SELECT COUNT(*) c FROM orders').get().c;
const productCount = db.prepare('SELECT COUNT(*) c FROM products').get().c;
const customerCount = db.prepare('SELECT COUNT(*) c FROM customers').get().c;
const pending = db.prepare("SELECT COUNT(*) c FROM orders WHERE status='pending'").get().c;
const recentOrders = db.prepare('SELECT * FROM orders ORDER BY datetime(created_at) DESC, id DESC LIMIT 6').all().map(O);
const lowStock = db.prepare('SELECT * FROM products WHERE stock IS NOT NULL AND stock <= 25 ORDER BY stock ASC').all().map(P);
return { revenueCents: revenue, orderCount, productCount, customerCount, pending, recentOrders, lowStock };
}
+20
View File
@@ -0,0 +1,20 @@
// Fassade: lädt nur den per STORE_DRIVER gewählten Treiber (sqlite|directus).
let _p;
function drv() {
if (!_p) _p = (process.env.STORE_DRIVER === 'directus') ? import('./store-directus.js') : import('./store-sqlite.js');
return _p;
}
export const driverName = () => (process.env.STORE_DRIVER === 'directus' ? 'directus' : 'sqlite');
export const listProducts = async () => (await drv()).listProducts();
export const getProductBySlug = async (s) => (await drv()).getProductBySlug(s);
export const getProductById = async (id) => (await drv()).getProductById(id);
export const createProduct = async (d) => (await drv()).createProduct(d);
export const updateProduct = async (id, d) => (await drv()).updateProduct(id, d);
export const deleteProduct = async (id) => (await drv()).deleteProduct(id);
export const listOrders = async () => (await drv()).listOrders();
export const getOrderById = async (id) => (await drv()).getOrderById(id);
export const createOrder = async (o) => (await drv()).createOrder(o);
export const updateOrderStatus= async (id, s) => (await drv()).updateOrderStatus(id, s);
export const listCustomers = async () => (await drv()).listCustomers();
export const getCustomerById = async (id) => (await drv()).getCustomerById(id);
export const dashboard = async () => (await drv()).dashboard();
+19
View File
@@ -0,0 +1,19 @@
const BASE = (process.env.UMAMI_API_URL || (process.env.UMAMI_SRC || 'https://analytics.heidrich-digital.de/script.js').replace(/\/script\.js$/, '')).replace(/\/$/, '');
export function umamiInfo() {
return { base: BASE, websiteId: process.env.UMAMI_WEBSITE_ID || '', hasCreds: !!(process.env.UMAMI_USER && process.env.UMAMI_PASS), shareUrl: process.env.UMAMI_SHARE_URL || '' };
}
export async function umamiStats() {
const id = process.env.UMAMI_WEBSITE_ID, user = process.env.UMAMI_USER, pass = process.env.UMAMI_PASS;
if (!id || !user || !pass) return { configured: false };
try {
const login = await fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: user, password: pass }), signal: AbortSignal.timeout(7000) });
if (!login.ok) throw new Error('login');
const { token } = await login.json();
const end = Date.now(), start = end - 7 * 86400000;
const r = await fetch(`${BASE}/api/websites/${id}/stats?startAt=${start}&endAt=${end}`, { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(7000) });
if (!r.ok) throw new Error('stats');
const s = await r.json();
const v = (x) => (x && typeof x === 'object') ? (x.value ?? 0) : (x ?? 0);
return { configured: true, pageviews: v(s.pageviews), visitors: v(s.visitors), visits: v(s.visits ?? s.sessions), bounces: v(s.bounces) };
} catch { return { configured: false, error: true }; }
}
+15
View File
@@ -0,0 +1,15 @@
const USER = process.env.ADMIN_USER || 'admin';
const PASS = process.env.ADMIN_PASS || 'demo';
export function onRequest({ request }, next) {
const url = new URL(request.url);
if (url.pathname.startsWith('/admin')) {
const hdr = request.headers.get('authorization') || '';
if (hdr.startsWith('Basic ')) {
let dec = ''; try { dec = atob(hdr.slice(6)); } catch {}
const i = dec.indexOf(':');
if (i > -1 && dec.slice(0, i) === USER && dec.slice(i + 1) === PASS) return next();
}
return new Response('Authentifizierung erforderlich', { status: 401, headers: { 'WWW-Authenticate': 'Basic realm="PMPNZNG Admin", charset="UTF-8"' } });
}
return next();
}
+39
View File
@@ -0,0 +1,39 @@
---
import Admin from '../../../layouts/Admin.astro';
import { umamiStats, umamiInfo } from '../../../lib/umami.js';
const info = umamiInfo();
const stats = await umamiStats();
---
<Admin title="Analytics" active="analytics" crumbs={[{label:'Analytics'}]}>
<a slot="actions" href={info.base} target="_blank" class="s-btn s-btn-primary">Umami öffnen ↗</a>
{stats.configured ? (
<div class="s-kpis" style="margin-bottom:16px">
<div class="s-kpi"><div class="s-kpi-label">Seitenaufrufe</div><div class="s-kpi-val">{stats.pageviews}</div><div class="s-kpi-sub">letzte 7 Tage</div></div>
<div class="s-kpi"><div class="s-kpi-label">Besucher</div><div class="s-kpi-val">{stats.visitors}</div><div class="s-kpi-sub">eindeutig</div></div>
<div class="s-kpi"><div class="s-kpi-label">Sitzungen</div><div class="s-kpi-val">{stats.visits}</div><div class="s-kpi-sub">letzte 7 Tage</div></div>
<div class="s-kpi"><div class="s-kpi-label">Absprünge</div><div class="s-kpi-val">{stats.bounces}</div><div class="s-kpi-sub">Bounces</div></div>
</div>
) : (
<div class="s-card s-card-pad" style="margin-bottom:16px">
<p class="s-section-title">Umami-Tracking {info.websiteId ? 'aktiv' : 'vorbereitet'}</p>
<p class="s-muted" style="margin-top:8px;max-width:62ch">
{info.websiteId
? 'Das Tracking-Script ist auf allen Shop-Seiten eingebunden — Besuche laufen live in dein Umami. Für die Live-Kacheln hier im Backend hinterlege zusätzlich UMAMI_USER und UMAMI_PASS (Umami-Login) als Environment-Variablen; dann werden Besucherzahlen direkt hier gezogen.'
: 'Setze die Environment-Variable UMAMI_WEBSITE_ID (aus deinem Umami-Projekt), damit das Tracking-Script eingebunden wird. Für die Live-Kacheln zusätzlich UMAMI_USER / UMAMI_PASS.'}
</p>
<div style="margin-top:14px"><a href={info.base} target="_blank" class="s-btn">Zum Umami-Dashboard ↗</a></div>
</div>
)}
{info.shareUrl && (
<div class="s-card">
<div class="s-card-head">Live-Dashboard</div>
<iframe src={info.shareUrl} style="width:100%;height:680px;border:0;border-radius:0 0 10px 10px" title="Umami"></iframe>
</div>
)}
<div class="s-card s-card-pad">
<p class="s-section-title">Datenschutz</p>
<p class="s-muted" style="margin-top:8px;max-width:62ch">Umami ist cookielos und DSGVO-freundlich, self-hosted in Deutschland (analytics.heidrich-digital.de). Es werden keine personenbezogenen Profile gebildet.</p>
</div>
</Admin>
+71
View File
@@ -0,0 +1,71 @@
---
import Admin from '../../../layouts/Admin.astro';
import { getOrderById, updateOrderStatus } from '../../../lib/store.js';
import { formatPrice } from '../../../lib/seed.js';
const { id } = Astro.params;
if (Astro.request.method === 'POST') {
const f = await Astro.request.formData();
await updateOrderStatus(id, String(f.get('status') || 'pending'));
return Astro.redirect(`/admin/bestellungen/${id}?ok=1`);
}
const o = await getOrderById(id);
if (!o) return Astro.redirect('/admin/bestellungen');
const ok = Astro.url.searchParams.get('ok');
const badge = (s) => ({ fulfilled:['green','Erfüllt'], pending:['amber','Offen'], cancelled:['gray','Storniert'], refunded:['blue','Erstattet'] }[s] || ['gray', s]);
const [bc, bl] = badge(o.status);
const subtotal = o.items.reduce((s,i)=>s+i.priceCents*i.qty,0);
const shipping = o.total_cents - subtotal;
const fmtDate = (s) => new Date(s).toLocaleDateString('de-DE', { day:'2-digit', month:'long', year:'numeric', hour:'2-digit', minute:'2-digit' });
---
<Admin title={`#${o.number}`} active="bestellungen" crumbs={[{label:'Bestellungen',href:'/admin/bestellungen'},{label:'#'+o.number}]}>
<a slot="actions" href="/admin/bestellungen" class="s-btn">← Zurück</a>
{ok && <div class="s-flash">✓ Status aktualisiert</div>}
<div class="s-two-col">
<div class="s-stack">
<div class="s-card">
<div class="s-card-head"><span>Artikel</span><span class={`s-badge ${bc}`}>{bl}</span></div>
<div class="s-table-wrap"><table class="s-table">
<thead><tr><th>Produkt</th><th>Größe</th><th>Menge</th><th class="num">Einzel</th><th class="num">Summe</th></tr></thead>
<tbody>
{o.items.map((i) => (
<tr style="cursor:default">
<td><span class="s-prodcell">{i.image && <img src={i.image} alt=""/>}<span class="nm">{i.name}</span></span></td>
<td>{i.size || '—'}</td><td>{i.qty}</td>
<td class="num">{formatPrice(i.priceCents)}</td>
<td class="num">{formatPrice(i.priceCents*i.qty)}</td>
</tr>
))}
</tbody>
</table></div>
<div class="s-card-pad" style="border-top:1px solid var(--s-border)">
<div style="display:flex;justify-content:space-between;padding:4px 0;color:var(--s-subtle)"><span>Zwischensumme</span><span>{formatPrice(subtotal)}</span></div>
<div style="display:flex;justify-content:space-between;padding:4px 0;color:var(--s-subtle)"><span>Versand</span><span>{shipping<=0?'Kostenlos':formatPrice(shipping)}</span></div>
<div style="display:flex;justify-content:space-between;padding:8px 0 0;font-weight:700;color:var(--s-ink);font-size:16px"><span>Gesamt</span><span>{formatPrice(o.total_cents)}</span></div>
</div>
</div>
</div>
<div class="s-stack">
<div class="s-card s-card-pad">
<p class="s-section-title">Status ändern</p>
<form method="POST" style="display:flex;gap:8px;margin-top:8px">
<select name="status" class="s-select">
<option value="pending" selected={o.status==='pending'}>Offen</option>
<option value="fulfilled" selected={o.status==='fulfilled'}>Erfüllt</option>
<option value="cancelled" selected={o.status==='cancelled'}>Storniert</option>
<option value="refunded" selected={o.status==='refunded'}>Erstattet</option>
</select>
<button class="s-btn s-btn-primary" type="submit">Speichern</button>
</form>
</div>
<div class="s-card s-card-pad">
<p class="s-section-title">Kunde</p>
<p style="margin:6px 0 2px;font-weight:600;color:var(--s-ink)">{o.customer_name || '—'}</p>
<p class="s-muted" style="font-size:13px">{o.email || '—'}</p>
<p class="s-section-title" style="margin-top:16px">Lieferadresse</p>
<p class="s-muted" style="font-size:13px">{o.address || 'keine Angabe'}</p>
<p class="s-section-title" style="margin-top:16px">Bestellt am</p>
<p class="s-muted" style="font-size:13px">{fmtDate(o.created_at)}</p>
</div>
</div>
</div>
</Admin>
+29
View File
@@ -0,0 +1,29 @@
---
import Admin from '../../../layouts/Admin.astro';
import { listOrders } from '../../../lib/store.js';
import { formatPrice } from '../../../lib/seed.js';
const orders = await listOrders();
const badge = (s) => ({ fulfilled:['green','Erfüllt'], pending:['amber','Offen'], cancelled:['gray','Storniert'], refunded:['blue','Erstattet'] }[s] || ['gray', s]);
const fmtDate = (s) => new Date(s).toLocaleDateString('de-DE', { day:'2-digit', month:'short', year:'numeric' });
const items = (o) => o.items.reduce((s,i)=>s+i.qty,0);
---
<Admin title="Bestellungen" active="bestellungen" crumbs={[{label:'Bestellungen'}]}>
<span slot="actions" class="s-muted" style="font-size:13px">{orders.length} Bestellungen</span>
<div class="s-card">
<div class="s-table-wrap"><table class="s-table">
<thead><tr><th>Bestellung</th><th>Datum</th><th>Kunde</th><th>Artikel</th><th>Status</th><th class="num">Betrag</th></tr></thead>
<tbody>
{orders.map((o) => { const [c,l] = badge(o.status); return (
<tr onclick={`location.href='/admin/bestellungen/${o.id}'`}>
<td><span class="s-link">#{o.number}</span></td>
<td class="s-muted">{fmtDate(o.created_at)}</td>
<td>{o.customer_name || '—'}<div class="s-muted" style="font-size:12px">{o.email}</div></td>
<td>{items(o)} Artikel</td>
<td><span class={`s-badge ${c}`}>{l}</span></td>
<td class="num">{formatPrice(o.total_cents)}</td>
</tr>); })}
{orders.length === 0 && <tr><td colspan="6"><div class="s-empty">Noch keine Bestellungen.</div></td></tr>}
</tbody>
</table></div>
</div>
</Admin>
+36
View File
@@ -0,0 +1,36 @@
---
import Admin from '../../../layouts/Admin.astro';
import { driverName } from '../../../lib/store.js';
const driver = driverName();
const stripeReal = /^sk_(test|live)_[A-Za-z0-9]{16,}/.test((process.env.STRIPE_SECRET_KEY||'').trim());
const umamiOn = !!process.env.UMAMI_WEBSITE_ID;
const row = (label, ok, okText, offText) => ({ label, ok, okText, offText });
const rows = [
row('Datenspeicher', true, driver==='directus'?'Directus (Headless-CMS)':'SQLite (eingebettet)', ''),
row('Stripe-Zahlungen', stripeReal, 'Test/Live-Key gesetzt', 'Demo-Modus (kein Key)'),
row('Umami-Analytics', umamiOn, 'Tracking aktiv', 'Website-ID fehlt'),
];
---
<Admin title="Einstellungen" active="einstellungen" crumbs={[{label:'Einstellungen'}]}>
<div class="s-stack">
<div class="s-card">
<div class="s-card-head">Shop-Konfiguration</div>
<div style="padding:6px 0">
{rows.map((r) => (
<div style="display:flex;justify-content:space-between;align-items:center;padding:14px 20px;border-bottom:1px solid var(--s-border)">
<div><div style="font-weight:600;color:var(--s-ink)">{r.label}</div><div class="s-muted" style="font-size:12px">{r.ok ? r.okText : r.offText}</div></div>
<span class={`s-badge ${r.ok?'green':'amber'}`}>{r.ok?'Verbunden':'Demo'}</span>
</div>
))}
</div>
</div>
<div class="s-card s-card-pad">
<p class="s-section-title">Stripe aktivieren</p>
<p class="s-muted" style="margin-top:8px;max-width:64ch">Hinterlege <code>STRIPE_SECRET_KEY</code> und <code>STRIPE_PUBLIC_KEY</code> (Test- oder Live-Keys) als Environment-Variablen in Coolify und deploye neu. Solange kein gültiger Key gesetzt ist, läuft der Checkout im Demo-Modus und zeigt eine „Bestellung erfolgreich"-Seite ohne echte Zahlung.</p>
</div>
<div class="s-card s-card-pad">
<p class="s-section-title">Hinweis</p>
<p class="s-muted" style="margin-top:8px;max-width:64ch">Dies ist eine Demo-Umgebung. Backend-Look angelehnt an Shopify zur schnellen Orientierung; Daten sind Beispieldaten.</p>
</div>
</div>
</Admin>
+60
View File
@@ -0,0 +1,60 @@
---
import Admin from '../../layouts/Admin.astro';
import { dashboard } from '../../lib/store.js';
import { formatPrice } from '../../lib/seed.js';
const d = await dashboard();
const badge = (s) => ({ fulfilled:['green','Erfüllt'], pending:['amber','Offen'], cancelled:['gray','Storniert'], refunded:['blue','Erstattet'] }[s] || ['gray', s]);
const fmtDate = (s) => new Date(s).toLocaleDateString('de-DE', { day:'2-digit', month:'short' });
---
<Admin title="Dashboard" active="dashboard">
<a slot="actions" href="/admin/produkte/neu" class="s-btn s-btn-primary">+ Produkt hinzufügen</a>
<div class="s-kpis" style="margin-bottom:16px">
<div class="s-kpi"><div class="s-kpi-label">Umsatz (gesamt)</div><div class="s-kpi-val">{formatPrice(d.revenueCents)}</div><div class="s-kpi-sub">bezahlte Bestellungen</div></div>
<div class="s-kpi"><div class="s-kpi-label">Bestellungen</div><div class="s-kpi-val">{d.orderCount}</div><div class="s-kpi-sub">{d.pending} offen</div></div>
<div class="s-kpi"><div class="s-kpi-label">Produkte</div><div class="s-kpi-val">{d.productCount}</div><div class="s-kpi-sub">{d.lowStock.length} mit niedrigem Bestand</div></div>
<div class="s-kpi"><div class="s-kpi-label">Kunden</div><div class="s-kpi-val">{d.customerCount}</div><div class="s-kpi-sub">gesamt</div></div>
</div>
<div class="s-two-col">
<div class="s-card">
<div class="s-card-head">Neueste Bestellungen <a href="/admin/bestellungen" class="s-link" style="font-weight:600;font-size:13px">Alle ansehen →</a></div>
<div class="s-table-wrap"><table class="s-table">
<thead><tr><th>Bestellung</th><th>Kunde</th><th>Datum</th><th>Status</th><th class="num">Betrag</th></tr></thead>
<tbody>
{d.recentOrders.map((o) => { const [c,l] = badge(o.status); return (
<tr onclick={`location.href='/admin/bestellungen/${o.id}'`}>
<td><span class="s-link">#{o.number}</span></td>
<td>{o.customer_name || o.email || '—'}</td>
<td class="s-muted">{fmtDate(o.created_at)}</td>
<td><span class={`s-badge ${c}`}>{l}</span></td>
<td class="num">{formatPrice(o.total_cents)}</td>
</tr>); })}
</tbody>
</table></div>
</div>
<div class="s-stack">
<div class="s-card">
<div class="s-card-head">Niedriger Bestand</div>
<div class="s-card-pad" style="padding:8px 0">
{d.lowStock.length === 0 && <div class="s-empty">Alles gut gefüllt.</div>}
{d.lowStock.map((p) => (
<a href={`/admin/produkte/${p.id}`} style="display:flex;align-items:center;justify-content:space-between;padding:10px 20px;border-bottom:1px solid var(--s-border)">
<span class="s-prodcell"><img src={p.cardImage} alt=""/><span class="nm">{p.shortName}</span></span>
<span class={`s-badge ${p.stock<=20?'red':'amber'}`}>{p.stock} Stk</span>
</a>
))}
</div>
</div>
<div class="s-card s-card-pad">
<p class="s-section-title">Schnellzugriff</p>
<div style="display:flex;flex-direction:column;gap:8px;margin-top:8px">
<a href="/admin/bestellungen" class="s-btn">Bestellungen verwalten</a>
<a href="/admin/produkte" class="s-btn">Produkte verwalten</a>
<a href="/admin/analytics" class="s-btn">Analytics ansehen</a>
</div>
</div>
</div>
</div>
</Admin>
+27
View File
@@ -0,0 +1,27 @@
---
import Admin from '../../../layouts/Admin.astro';
import { listCustomers } from '../../../lib/store.js';
import { formatPrice } from '../../../lib/seed.js';
const customers = await listCustomers();
const initials = (n) => (n||'?').split(' ').map(x=>x[0]).slice(0,2).join('').toUpperCase();
---
<Admin title="Kunden" active="kunden" crumbs={[{label:'Kunden'}]}>
<span slot="actions" class="s-muted" style="font-size:13px">{customers.length} Kunden</span>
<div class="s-card">
<div class="s-table-wrap"><table class="s-table">
<thead><tr><th>Name</th><th>E-Mail</th><th>Ort</th><th>Bestellungen</th><th class="num">Umsatz</th></tr></thead>
<tbody>
{customers.map((c) => (
<tr style="cursor:default">
<td><span class="s-prodcell"><span style="width:32px;height:32px;border-radius:50%;background:var(--s-green-l);color:var(--s-green-d);display:grid;place-items:center;font-weight:700;font-size:12px">{initials(c.name)}</span><span class="nm">{c.name||'—'}</span></span></td>
<td class="s-muted">{c.email}</td>
<td>{c.city||'—'}</td>
<td>{c.orders_count}</td>
<td class="num">{formatPrice(c.total_spent_cents)}</td>
</tr>
))}
{customers.length===0 && <tr><td colspan="5"><div class="s-empty">Noch keine Kunden.</div></td></tr>}
</tbody>
</table></div>
</div>
</Admin>
+88
View File
@@ -0,0 +1,88 @@
---
import Admin from '../../../layouts/Admin.astro';
import { getProductById, createProduct, updateProduct, deleteProduct } from '../../../lib/store.js';
const { id } = Astro.params;
const isNew = id === 'neu';
const slugify = (s)=>s.toLowerCase().replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss').replace(/&/g,'und').replace(/[^a-z0-9]+/g,'-').replace(/(^-|-$)/g,'');
const lines = (v)=>String(v||'').split(/[\n,]/).map(x=>x.trim()).filter(Boolean);
if (Astro.request.method === 'POST') {
const f = await Astro.request.formData();
if (f.get('_action') === 'delete') { await deleteProduct(id); return Astro.redirect('/admin/produkte?ok=del'); }
const name = String(f.get('name')||'').trim();
const priceCents = Math.round(parseFloat(String(f.get('price')||'0').replace(',','.')) * 100) || 0;
const data = {
name, shortName: String(f.get('shortName')||name).trim() || name,
slug: String(f.get('slug')||'').trim() || slugify(name),
desc: String(f.get('desc')||'').trim(), category: String(f.get('category')||'').trim(),
productType: 'physical', priceCents,
stock: String(f.get('stock')||'').trim()==='' ? null : parseInt(String(f.get('stock'))),
sizes: lines(f.get('sizes')).length ? lines(f.get('sizes')) : ['One Size'],
images: lines(f.get('images')), cardImage: String(f.get('cardImage')||'').trim(),
freigestellt: String(f.get('freigestellt')||'').trim(), badge: String(f.get('badge')||'').trim(),
material: String(f.get('material')||'').trim(), features: lines(f.get('features')),
featured: !!f.get('featured'), sort: parseInt(String(f.get('sort')||'99'))||99,
};
if (isNew) await createProduct(data); else await updateProduct(id, data);
return Astro.redirect('/admin/produkte?ok=save');
}
const p = isNew ? {} : (await getProductById(id)) || {};
if (!isNew && !p.id) return Astro.redirect('/admin/produkte');
const priceVal = p.priceCents != null ? (p.priceCents/100).toFixed(2).replace('.',',') : '';
---
<Admin title={isNew ? 'Neues Produkt' : p.name} active="produkte" crumbs={[{label:'Produkte',href:'/admin/produkte'},{label:isNew?'Neu':p.shortName}]}>
<div slot="actions" style="display:flex;gap:8px">
{!isNew && (
<form method="POST" onsubmit="return confirm('Produkt wirklich löschen?')"><input type="hidden" name="_action" value="delete"/>
<button class="s-btn s-btn-danger" type="submit">Löschen</button></form>
)}
<button form="prodform" class="s-btn s-btn-primary" type="submit">Speichern</button>
</div>
<form id="prodform" method="POST" class="s-two-col">
<div class="s-stack">
<div class="s-card s-card-pad">
<div class="s-field full"><label class="s-label">Titel</label><input class="s-input" name="name" value={p.name||''} required/></div>
<div class="s-field full"><label class="s-label">Kurzname (für Karten)</label><input class="s-input" name="shortName" value={p.shortName||''}/></div>
<div class="s-field full"><label class="s-label">Beschreibung</label><textarea class="s-textarea" name="desc" rows="4">{p.desc||''}</textarea></div>
</div>
<div class="s-card s-card-pad">
<p class="s-section-title">Medien</p>
<div class="s-field full" style="margin-top:10px"><label class="s-label">Karten-Bild (URL)</label><input class="s-input" name="cardImage" value={p.cardImage||''} placeholder="/product-images/…"/></div>
<div class="s-field full"><label class="s-label">Freigestelltes Bild (optional)</label><input class="s-input" name="freigestellt" value={p.freigestellt||''}/></div>
<div class="s-field full"><label class="s-label">Galerie-Bilder (eine URL pro Zeile)</label><textarea class="s-textarea" name="images" rows="3">{(p.images||[]).join('\n')}</textarea></div>
</div>
<div class="s-card s-card-pad">
<p class="s-section-title">Varianten & Details</p>
<div class="s-form-grid" style="margin-top:10px">
<div class="s-field"><label class="s-label">Größen (Komma)</label><input class="s-input" name="sizes" value={(p.sizes||['One Size']).join(', ')}/></div>
<div class="s-field"><label class="s-label">Material</label><input class="s-input" name="material" value={p.material||''}/></div>
</div>
<div class="s-field full"><label class="s-label">Features (eine pro Zeile)</label><textarea class="s-textarea" name="features" rows="3">{(p.features||[]).join('\n')}</textarea></div>
</div>
</div>
<div class="s-stack">
<div class="s-card s-card-pad">
<p class="s-section-title">Status</p>
<label class="s-check" style="margin-top:10px"><input type="checkbox" name="featured" checked={!!p.featured}/> Als „Featured" markieren</label>
</div>
<div class="s-card s-card-pad">
<p class="s-section-title">Preis & Bestand</p>
<div class="s-field" style="margin-top:10px"><label class="s-label">Preis (EUR, brutto)</label><input class="s-input" name="price" value={priceVal} inputmode="decimal" placeholder="49,99" required/></div>
<div class="s-field"><label class="s-label">Bestand (Stück)</label><input class="s-input" name="stock" value={p.stock??''} type="number" placeholder="leer = ohne Tracking"/></div>
</div>
<div class="s-card s-card-pad">
<p class="s-section-title">Organisation</p>
<div class="s-field" style="margin-top:10px"><label class="s-label">Kategorie</label>
<input class="s-input" name="category" value={p.category||''} list="cats"/>
<datalist id="cats"><option>Hoodie</option><option>T-Shirt</option><option>Cap</option><option>Tasche</option><option>Digital</option></datalist></div>
<div class="s-field"><label class="s-label">Badge</label><input class="s-input" name="badge" value={p.badge||''} placeholder="z. B. Neu, Bio, Bestseller"/></div>
<div class="s-form-grid">
<div class="s-field"><label class="s-label">Slug</label><input class="s-input" name="slug" value={p.slug||''} placeholder="automatisch"/></div>
<div class="s-field"><label class="s-label">Sortierung</label><input class="s-input" name="sort" value={p.sort??99} type="number"/></div>
</div>
</div>
</div>
</form>
</Admin>
+25
View File
@@ -0,0 +1,25 @@
---
import Admin from '../../../layouts/Admin.astro';
import { listProducts } from '../../../lib/store.js';
import { formatPrice } from '../../../lib/seed.js';
const products = await listProducts();
const stockBadge = (s) => s==null ? ['gray','—'] : s<=20 ? ['red', s+' Stk'] : s<=40 ? ['amber', s+' Stk'] : ['green', s+' Stk'];
---
<Admin title="Produkte" active="produkte" crumbs={[{label:'Produkte'}]}>
<a slot="actions" href="/admin/produkte/neu" class="s-btn s-btn-primary">+ Produkt hinzufügen</a>
<div class="s-card">
<div class="s-table-wrap"><table class="s-table">
<thead><tr><th>Produkt</th><th>Status</th><th>Kategorie</th><th>Bestand</th><th class="num">Preis</th></tr></thead>
<tbody>
{products.map((p) => { const [sc,sl] = stockBadge(p.stock); return (
<tr onclick={`location.href='/admin/produkte/${p.id}'`}>
<td><span class="s-prodcell"><img src={p.cardImage} alt=""/><span><span class="nm">{p.shortName}</span><div class="s-muted" style="font-size:12px">{p.slug}</div></span></span></td>
<td>{p.featured ? <span class="s-badge green">Aktiv · Featured</span> : <span class="s-badge green">Aktiv</span>}</td>
<td>{p.category}</td>
<td><span class={`s-badge ${sc}`}>{sl}</span></td>
<td class="num">{formatPrice(p.priceCents)}</td>
</tr>); })}
</tbody>
</table></div>
</div>
</Admin>
+48
View File
@@ -0,0 +1,48 @@
import { createOrder } from '../../lib/store.js';
export const prerender = false;
const keyLooksReal = (k) => typeof k === 'string' && /^sk_(test|live)_[A-Za-z0-9]{16,}/.test(k.trim());
export async function POST({ request }) {
let body;
try { body = await request.json(); } catch { return json({ error: 'Bad request' }, 400); }
const items = Array.isArray(body.items) ? body.items : [];
const contact = body.contact || {};
if (!items.length) return json({ error: 'Warenkorb leer' }, 400);
const lineItems = items.map((i) => ({
name: i.name, size: i.size || '', qty: Math.max(1, parseInt(i.qty) || 1),
priceCents: Math.round(Number(i.price) * 100) || Number(i.priceCents) || 0, image: i.image || '',
}));
const subtotal = lineItems.reduce((s, i) => s + i.priceCents * i.qty, 0);
const shipping = subtotal >= 5000 ? 0 : 490;
const total = subtotal + shipping;
const customer_name = [contact.vorname, contact.nachname].filter(Boolean).join(' ').trim();
const email = contact.email || '';
const order = await createOrder({ email, customer_name, items: lineItems, total_cents: total, status: 'pending',
address: [contact.strasse, contact.plz, contact.ort, contact.land].filter(Boolean).join(', ') });
const secret = process.env.STRIPE_SECRET_KEY || '';
const origin = new URL(request.url).origin;
if (keyLooksReal(secret)) {
try {
const Stripe = (await import('stripe')).default;
const stripe = new Stripe(secret);
const session = await stripe.checkout.sessions.create({
mode: 'payment', payment_method_types: ['card'], locale: 'de',
customer_email: email || undefined,
line_items: [
...lineItems.map((i) => ({ quantity: i.qty, price_data: { currency: 'eur', unit_amount: i.priceCents,
product_data: { name: `${i.name}${i.size ? ' · ' + i.size : ''}` } } })),
...(shipping > 0 ? [{ quantity: 1, price_data: { currency: 'eur', unit_amount: shipping, product_data: { name: 'Versand (DE)' } } }] : []),
],
success_url: `${origin}/bestellung-erfolgreich?order=${order.number}`,
cancel_url: `${origin}/warenkorb`, metadata: { order_number: order.number },
});
return json({ url: session.url });
} catch (e) { return json({ url: `/bestellung-erfolgreich?order=${order.number}&demo=1` }); }
}
return json({ url: `/bestellung-erfolgreich?order=${order.number}&demo=1` });
}
function json(obj, status = 200) { return new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } }); }
+138
View File
@@ -0,0 +1,138 @@
---
import Base from '../layouts/Base.astro';
const orderNr = Astro.url.searchParams.get('order') || '';
const isDemo = Astro.url.searchParams.get('demo') === '1';
const main = `<div class="success-page" role="main" aria-label="Bestellbestätigung">
<div class="success-icon" aria-hidden="true">✓</div>
<p class="success-eyebrow">Bestellung eingegangen</p>
${isDemo ? `<p style="font-size:11px;color:var(--c-text-muted);border:1px solid var(--c-border);padding:8px 14px;display:inline-block;margin-bottom:8px">Demo-Bestellung · keine echte Zahlung (Stripe-Testmodus nicht konfiguriert)</p>` : ``}
<h1 class="success-heading">
Danke.<br>
<span style="color:var(--c-red-bright)">Wird gemacht.</span>
</h1>
<p class="success-sub">
Deine Bestellung ist bei uns. Du bekommst in Kürze eine Bestätigung per E-Mail.
Lieferzeit: 24 Werktage innerhalb Deutschlands.
</p>
<div class="success-order-box" aria-label="Bestelldetails">
<h2>Bestelldetails</h2>
<div class="success-info-row">
<span>Bestellnummer</span>
<span id="order-number">${orderNr ? "#" + orderNr : "#PMPNZNG-—"}</span>
</div>
<div class="success-info-row">
<span>Zahlungsart</span>
<span>PayPal / Kreditkarte</span>
</div>
<div class="success-info-row">
<span>Versand</span>
<span>DHL · 24 Werktage</span>
</div>
<div class="success-info-row">
<span>Rückgaberecht</span>
<span>30 Tage</span>
</div>
</div>
<div class="success-actions">
<a href="/shop" class="btn btn-primary">Weiter shoppen</a>
<a href="/" class="btn btn-outline">Zur Startseite</a>
</div>
<p class="success-note">
Fragen zur Bestellung? Schreib uns: <a href="mailto:info@tgasolutions-shop.de" style="color:var(--c-text-muted);text-decoration:underline">info@tgasolutions-shop.de</a>
</p>
</div>`;
---
<Base title={"Bestellung erfolgreich — PMPNZNG"} page="success" active="">
<Fragment set:html={main} />
</Base>
<style is:global>
.success-page {
min-height: 80vh; display: flex; flex-direction: column;
align-items: center; justify-content: center;
padding: var(--sp-xl) var(--gutter);
text-align: center;
}
.success-icon {
width: 80px; height: 80px;
border: 3px solid var(--c-red);
display: flex; align-items: center; justify-content: center;
font-size: 32px; margin: 0 auto var(--sp-md);
position: relative;
}
.success-icon::before, .success-icon::after {
content: ''; position: absolute;
width: 10px; height: 10px;
background: var(--c-red);
}
.success-icon::before { top: -5px; left: -5px; }
.success-icon::after { bottom: -5px; right: -5px; }
.success-eyebrow {
font-size: 9px; letter-spacing: 0.32em; text-transform: uppercase;
color: var(--c-red-bright); font-weight: 700; margin-bottom: 12px;
display: flex; align-items: center; gap: 10px; justify-content: center;
}
.success-eyebrow::before, .success-eyebrow::after {
content: ''; display: inline-block; width: 20px; height: 2px; background: var(--c-red-bright);
}
.success-heading {
font-family: var(--font-display);
font-size: clamp(32px, 5vw, 64px);
font-weight: 900; text-transform: uppercase; line-height: 0.95;
margin-bottom: var(--sp-sm);
}
.success-sub {
font-size: 15px; color: var(--c-text-muted); line-height: 1.75;
max-width: 520px; margin: 0 auto var(--sp-md);
}
.success-order-box {
background: var(--c-surface-1); border: 1px solid var(--c-border);
padding: var(--sp-md); margin: var(--sp-md) 0;
max-width: 480px; width: 100%; text-align: left;
}
.success-order-box h2 {
font-size: 9px; letter-spacing: 0.22em; text-transform: uppercase;
font-weight: 700; color: var(--c-text-muted);
margin-bottom: 12px; padding-bottom: 10px;
border-bottom: 1px solid var(--c-border);
}
.success-info-row {
display: flex; justify-content: space-between; align-items: center;
font-size: 12px; padding: 6px 0;
border-bottom: 1px solid var(--c-border);
}
.success-info-row:last-child { border-bottom: none; }
.success-info-row span:first-child { color: var(--c-text-muted); }
.success-info-row span:last-child { font-weight: 600; }
.success-actions {
display: flex; gap: 12px; flex-wrap: wrap; justify-content: center;
margin-top: var(--sp-md);
}
.success-note {
font-size: 11px; color: var(--c-text-dim); margin-top: var(--sp-lg);
max-width: 400px;
}
/* confetti dots — pure CSS decorative */
.success-bg {
position: fixed; inset: 0; pointer-events: none; z-index: -1; overflow: hidden;
}
.confetti-dot {
position: absolute; border-radius: 50%;
animation: float-up 4s ease-in-out infinite;
opacity: 0.12;
}
@keyframes float-up {
0% { transform: translateY(100vh) rotate(0deg); opacity: 0.12; }
100% { transform: translateY(-10vh) rotate(360deg); opacity: 0; }
}
</style>
+290
View File
@@ -0,0 +1,290 @@
---
import Base from '../layouts/Base.astro';
const main = `<div class="checkout-layout">
<h1 class="checkout-heading">Kasse</h1>
<!-- Form -->
<div class="checkout-form-wrap">
<!-- Progress steps -->
<div class="checkout-steps" aria-label="Bestellschritte">
<div class="step active"><span class="step-num">1</span>Lieferung</div>
<div class="step"><span class="step-num">2</span>Zahlung</div>
<div class="step"><span class="step-num">3</span>Bestätigung</div>
</div>
<form id="checkout-form" novalidate>
<!-- Contact -->
<div class="form-section">
<h2 class="form-section-title">Kontakt</h2>
<div class="form-grid full">
<div class="form-field">
<label class="form-label" for="email">E-Mail-Adresse *</label>
<input class="form-input" type="email" id="email" name="email" placeholder="deine@email.de" required autocomplete="email">
</div>
</div>
</div>
<!-- Shipping address -->
<div class="form-section">
<h2 class="form-section-title">Lieferadresse</h2>
<div class="form-grid">
<div class="form-field">
<label class="form-label" for="vorname">Vorname *</label>
<input class="form-input" type="text" id="vorname" name="vorname" placeholder="Max" required autocomplete="given-name">
</div>
<div class="form-field">
<label class="form-label" for="nachname">Nachname *</label>
<input class="form-input" type="text" id="nachname" name="nachname" placeholder="Mustermann" required autocomplete="family-name">
</div>
<div class="form-field span-2">
<label class="form-label" for="firma">Firma (optional)</label>
<input class="form-input" type="text" id="firma" name="firma" placeholder="TGA Solutions GmbH" autocomplete="organization">
</div>
<div class="form-field span-2">
<label class="form-label" for="strasse">Straße und Hausnummer *</label>
<input class="form-input" type="text" id="strasse" name="strasse" placeholder="Kleine Kamp 1" required autocomplete="street-address">
</div>
<div class="form-field">
<label class="form-label" for="plz">PLZ *</label>
<input class="form-input" type="text" id="plz" name="plz" placeholder="21702" required autocomplete="postal-code" pattern="[0-9]{5}">
</div>
<div class="form-field">
<label class="form-label" for="ort">Ort *</label>
<input class="form-input" type="text" id="ort" name="ort" placeholder="Ahlerstedt" required autocomplete="address-level2">
</div>
<div class="form-field span-2">
<label class="form-label" for="land">Land *</label>
<select class="form-select" id="land" name="land" required autocomplete="country">
<option value="DE" selected>Deutschland</option>
<option value="AT">Österreich</option>
<option value="CH">Schweiz</option>
</select>
</div>
</div>
</div>
<!-- Payment -->
<div class="form-section">
<h2 class="form-section-title">Zahlungsmethode</h2>
<div class="payment-options" role="group" aria-label="Zahlungsoptionen">
<label class="payment-option">
<input class="payment-radio" type="radio" name="payment" value="paypal" checked>
<span class="payment-label">PayPal</span>
<span class="payment-logo">PayPal</span>
</label>
<label class="payment-option">
<input class="payment-radio" type="radio" name="payment" value="kreditkarte">
<span class="payment-label">Kreditkarte (Visa / Mastercard)</span>
<span class="payment-logo">VISA · MC</span>
</label>
<label class="payment-option">
<input class="payment-radio" type="radio" name="payment" value="ueberweisung">
<span class="payment-label">Banküberweisung (Vorkasse)</span>
<span class="payment-logo">IBAN</span>
</label>
</div>
</div>
<!-- Submit -->
<div class="checkout-submit-area">
<button type="submit" class="checkout-submit-btn">
Jetzt bestellen →
</button>
<p class="checkout-legal">
Mit deiner Bestellung stimmst du unseren <a href="/impressum">AGB</a> zu
und bestätigst, unsere <a href="/datenschutz">Datenschutzerklärung</a> gelesen zu haben.
Du hast ein 14-tägiges Widerrufsrecht.
</p>
</div>
</form>
</div>
<!-- Order Summary -->
<aside class="order-summary" aria-label="Bestellübersicht">
<div class="order-summary-title">Deine Bestellung</div>
<div id="checkout-order-items" aria-label="Bestellte Artikel"></div>
<div class="order-divider"></div>
<div class="order-line">
<span>Zwischensumme</span>
<strong class="js-cart-subtotal"></strong>
</div>
<div class="order-line">
<span>Versand (DE)</span>
<strong class="js-checkout-shipping"></strong>
</div>
<div class="order-divider"></div>
<div class="order-total">
<span class="order-total-label">Gesamt</span>
<span class="order-total-price js-checkout-total"></span>
</div>
<div class="security-badge">
SSL-verschlüsselt · sichere Datenübertragung · DSGVO-konform
</div>
</aside>
</div>`;
---
<Base title={"Kasse — PMPNZNG"} page="checkout" active="">
<Fragment set:html={main} />
</Base>
<style is:global>
.checkout-layout {
max-width: 1100px; margin: 0 auto;
padding: var(--sp-lg) var(--gutter) var(--sp-2xl);
display: grid; grid-template-columns: 1fr 380px;
gap: clamp(32px, 4vw, 64px); align-items: start;
}
.checkout-heading {
font-family: var(--font-display);
font-size: clamp(24px, 3.5vw, 40px);
font-weight: 900; text-transform: uppercase; line-height: 1;
grid-column: 1 / -1;
padding-bottom: var(--sp-sm);
border-bottom: 1px solid var(--c-border);
}
/* Steps */
.checkout-steps {
display: flex; gap: 0; margin-bottom: var(--sp-md);
border: 1px solid var(--c-border);
}
.step {
flex: 1; padding: 12px 16px; text-align: center;
font-size: 9px; font-weight: 900; letter-spacing: 0.2em;
text-transform: uppercase; color: var(--c-text-dim);
border-right: 1px solid var(--c-border);
position: relative;
}
.step:last-child { border-right: none; }
.step.active { color: var(--c-text); background: var(--c-surface-1); }
.step.active::after {
content: ''; position: absolute; bottom: -1px; left: 0; right: 0;
height: 2px; background: var(--c-red);
}
.step-num {
display: block; font-family: var(--font-display);
font-size: 16px; font-weight: 900; margin-bottom: 2px;
}
/* Form */
.checkout-form-wrap { display: flex; flex-direction: column; gap: var(--sp-md); }
.form-section {
background: var(--c-surface-1);
border: 1px solid var(--c-border);
padding: var(--sp-md);
}
.form-section-title {
font-family: var(--font-display); font-size: 11px; font-weight: 900;
letter-spacing: 0.22em; text-transform: uppercase;
margin-bottom: var(--sp-sm);
padding-bottom: 12px; border-bottom: 1px solid var(--c-border);
}
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.form-grid.full { grid-template-columns: 1fr; }
.form-field { display: flex; flex-direction: column; gap: 6px; }
.form-field.span-2 { grid-column: 1 / -1; }
.form-label {
font-size: 9px; letter-spacing: 0.18em; font-weight: 700;
text-transform: uppercase; color: var(--c-text-muted);
}
.form-input {
background: var(--c-surface-2); border: 1px solid var(--c-border-mid);
color: var(--c-text); padding: 12px 14px; font-size: 14px;
transition: border-color 0.15s;
width: 100%;
}
.form-input:focus { outline: none; border-color: var(--c-red); }
.form-input::placeholder { color: var(--c-text-dim); font-size: 13px; }
.form-select {
background: var(--c-surface-2); border: 1px solid var(--c-border-mid);
color: var(--c-text); padding: 12px 14px; font-size: 14px;
width: 100%; cursor: pointer;
transition: border-color 0.15s;
}
.form-select:focus { outline: none; border-color: var(--c-red); }
/* Payment */
.payment-options { display: flex; flex-direction: column; gap: 8px; }
.payment-option {
display: flex; align-items: center; gap: 14px;
border: 1px solid var(--c-border-mid);
padding: 14px 16px; cursor: pointer;
transition: border-color 0.15s;
background: var(--c-surface-2);
}
.payment-option:has(input:checked) { border-color: var(--c-red); }
.payment-radio { accent-color: var(--c-red); width: 16px; height: 16px; flex-shrink: 0; }
.payment-label { font-size: 13px; font-weight: 600; flex: 1; }
.payment-logo {
font-size: 9px; letter-spacing: 0.16em; font-weight: 900;
text-transform: uppercase; color: var(--c-text-muted);
border: 1px solid var(--c-border-mid); padding: 3px 8px;
}
/* Submit */
.checkout-submit-area { display: flex; flex-direction: column; gap: 12px; }
.checkout-submit-btn {
display: flex; align-items: center; justify-content: center; gap: 12px;
width: 100%; padding: 20px; font-size: 12px; font-weight: 900;
letter-spacing: 0.2em; text-transform: uppercase; font-family: var(--font-display);
background: var(--c-red); color: #fff; border: none; cursor: pointer;
transition: background var(--dur-fast);
}
.checkout-submit-btn:hover { background: var(--c-red-bright); }
.checkout-legal {
font-size: 10px; color: var(--c-text-dim); line-height: 1.65;
text-align: center;
}
.checkout-legal a { color: var(--c-text-muted); text-decoration: underline; }
/* Order Summary Sidebar */
.order-summary {
background: var(--c-surface-1);
border: 1px solid var(--c-border);
padding: var(--sp-md);
position: sticky; top: calc(var(--header-h) + 16px);
display: flex; flex-direction: column; gap: 16px;
}
.order-summary-title {
font-family: var(--font-display); font-size: 11px; font-weight: 900;
letter-spacing: 0.22em; text-transform: uppercase;
padding-bottom: 12px; border-bottom: 1px solid var(--c-border);
}
#checkout-order-items { display: flex; flex-direction: column; gap: 8px; }
.checkout-item {
display: flex; justify-content: space-between; align-items: center;
font-size: 12px; color: var(--c-text-muted);
}
.checkout-item span:last-child { color: var(--c-text); font-weight: 600; white-space: nowrap; }
.order-divider { height: 1px; background: var(--c-border); }
.order-line { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: var(--c-text-muted); }
.order-line strong { color: var(--c-text); }
.order-total { display: flex; justify-content: space-between; align-items: baseline; }
.order-total-label { font-family: var(--font-display); font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; font-weight: 900; }
.order-total-price { font-family: var(--font-display); font-size: 22px; font-weight: 900; }
.security-badge {
display: flex; align-items: center; gap: 10px;
border: 1px solid var(--c-border);
padding: 10px 14px; font-size: 10px; color: var(--c-text-muted);
}
.security-badge::before { content: '🔒'; font-size: 14px; }
@media (max-width: 900px) {
.checkout-layout { grid-template-columns: 1fr; }
.order-summary { position: static; order: -1; }
}
@media (max-width: 640px) {
.form-grid { grid-template-columns: 1fr; }
.form-field.span-2 { grid-column: 1; }
}
</style>
+117
View File
@@ -0,0 +1,117 @@
---
import Base from '../layouts/Base.astro';
const main = `<div class="legal-page">
<h1 class="legal-heading">Datenschutz&shy;erklärung</h1>
<div class="legal-section">
<h2 class="legal-h2">1. Datenschutz auf einen Blick</h2>
<div class="legal-text">
<h3 class="legal-h3">Allgemeine Hinweise</h3>
<p>Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren personenbezogenen Daten passiert, wenn Sie diese Website besuchen. Personenbezogene Daten sind alle Daten, mit denen Sie persönlich identifiziert werden können. Ausführliche Informationen zum Thema Datenschutz entnehmen Sie unserer unter diesem Text aufgeführten Datenschutzerklärung.</p>
<h3 class="legal-h3">Datenerfassung auf dieser Website</h3>
<p><strong>Wer ist verantwortlich für die Datenerfassung auf dieser Website?</strong><br>
Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber. Dessen Kontaktdaten können Sie dem Abschnitt „Hinweis zur Verantwortlichen Stelle" in dieser Datenschutzerklärung entnehmen.</p>
<p><strong>Wie erfassen wir Ihre Daten?</strong><br>
Ihre Daten werden zum einen dadurch erhoben, dass Sie uns diese mitteilen. Hierbei kann es sich z. B. um Daten handeln, die Sie in ein Kontaktformular eingeben. Andere Daten werden automatisch oder nach Ihrer Einwilligung beim Besuch der Website durch unsere IT-Systeme erfasst. Das sind vor allem technische Daten (z. B. Internetbrowser, Betriebssystem oder Uhrzeit des Seitenaufrufs).</p>
<p><strong>Wofür nutzen wir Ihre Daten?</strong><br>
Ein Teil der Daten wird erhoben, um eine fehlerfreie Bereitstellung der Website zu gewährleisten. Andere Daten können zur Analyse Ihres Nutzerverhaltens verwendet werden. Bestelldaten werden zur Abwicklung Ihrer Bestellung verwendet.</p>
</div>
</div>
<div class="legal-section">
<h2 class="legal-h2">2. Verantwortliche Stelle</h2>
<div class="legal-text">
<p>Die verantwortliche Stelle für die Datenverarbeitung auf dieser Website ist:</p>
<p><strong>TGA Solutions GmbH</strong><br>
Kleine Kamp 1<br>
21702 Ahlerstedt<br>
E-Mail: <a href="mailto:info@tgasolutions-shop.de">info@tgasolutions-shop.de</a></p>
<p>Verantwortliche Stelle ist die natürliche oder juristische Person, die allein oder gemeinsam mit anderen über die Zwecke und Mittel der Verarbeitung von personenbezogenen Daten (z. B. Namen, E-Mail-Adressen o. Ä.) entscheidet.</p>
</div>
</div>
<div class="legal-section">
<h2 class="legal-h2">3. Datenerfassung auf dieser Website</h2>
<div class="legal-text">
<h3 class="legal-h3">Cookies</h3>
<p>Unsere Internetseiten verwenden so genannte „Cookies". Cookies sind kleine Datenpakete und richten auf Ihrem Endgerät keinen Schaden an. Sie werden entweder vorübergehend für die Dauer einer Sitzung (Session-Cookies) oder dauerhaft (permanente Cookies) auf Ihrem Endgerät gespeichert. Session-Cookies werden nach Ende Ihres Besuchs automatisch gelöscht. Permanente Cookies bleiben auf Ihrem Endgerät gespeichert, bis Sie diese selbst löschen oder eine automatische Löschung durch Ihren Webbrowser erfolgt.</p>
<p>Wir nutzen ausschließlich technisch notwendige Cookies (Warenkorb, Sitzungsauthentifizierung). Es werden keine Tracking- oder Analyse-Cookies verwendet.</p>
<h3 class="legal-h3">LocalStorage</h3>
<p>Zur Speicherung Ihres Warenkorbs verwenden wir den localStorage Ihres Browsers. Diese Daten verlassen Ihr Gerät nicht und werden nicht an unsere Server übertragen, solange keine Bestellung abgeschlossen wird.</p>
<h3 class="legal-h3">Bestelldaten</h3>
<p>Wenn Sie eine Bestellung aufgeben, erheben wir folgende Daten zur Auftragsabwicklung:</p>
<ul>
<li>Name und Adresse (Lieferadresse)</li>
<li>E-Mail-Adresse</li>
<li>Bestellte Artikel und Mengen</li>
<li>Zahlungsart (nicht die Zahlungsdaten selbst — diese werden direkt beim Zahlungsdienstleister verarbeitet)</li>
</ul>
<p>Rechtsgrundlage: Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung). Die Daten werden für 10 Jahre aufbewahrt (steuerrechtliche Pflicht) und dann gelöscht.</p>
<h3 class="legal-h3">Newsletter</h3>
<p>Wenn Sie unseren Newsletter abonnieren, erheben wir Ihre E-Mail-Adresse. Rechtsgrundlage: Ihre Einwilligung (Art. 6 Abs. 1 lit. a DSGVO). Die Einwilligung kann jederzeit widerrufen werden. Jeder Newsletter enthält einen Abmeldelink.</p>
</div>
</div>
<div class="legal-section">
<h2 class="legal-h2">4. Ihre Rechte</h2>
<div class="legal-text">
<p>Sie haben jederzeit das Recht:</p>
<ul>
<li>unentgeltlich Auskunft über Herkunft, Empfänger und Zweck Ihrer gespeicherten personenbezogenen Daten zu erhalten (Art. 15 DSGVO)</li>
<li>die Berichtigung unrichtiger Daten zu verlangen (Art. 16 DSGVO)</li>
<li>die Löschung Ihrer Daten zu verlangen, sofern keine Aufbewahrungspflichten bestehen (Art. 17 DSGVO)</li>
<li>die Einschränkung der Verarbeitung zu verlangen (Art. 18 DSGVO)</li>
<li>Ihre Daten in einem maschinenlesbaren Format zu erhalten (Art. 20 DSGVO)</li>
<li>Widerspruch gegen die Verarbeitung einzulegen (Art. 21 DSGVO)</li>
</ul>
<p>Zur Ausübung Ihrer Rechte wenden Sie sich an: <a href="mailto:info@tgasolutions-shop.de">info@tgasolutions-shop.de</a></p>
<p>Sie haben zudem das Recht, bei der zuständigen Datenschutzaufsichtsbehörde Beschwerde einzulegen. Zuständige Behörde für Niedersachsen: Der Landesbeauftragte für den Datenschutz Niedersachsen.</p>
</div>
</div>
<div class="legal-section">
<h2 class="legal-h2">5. Zahlungsdienstleister</h2>
<div class="legal-text">
<p>Bei der Bezahlung per PayPal oder Kreditkarte werden die Zahlungsdaten direkt beim jeweiligen Zahlungsdienstleister verarbeitet. Wir erhalten keinen Zugriff auf Ihre Kreditkartendaten oder PayPal-Zugangsdaten.</p>
<p>Datenschutzinformationen der Zahlungsdienstleister:</p>
<ul>
<li><a href="https://www.paypal.com/de/webapps/mpp/ua/privacy-full" rel="noopener noreferrer" target="_blank">PayPal Datenschutzrichtlinie</a></li>
</ul>
</div>
</div>
<div class="legal-section">
<h2 class="legal-h2">6. Änderungen</h2>
<div class="legal-text">
<p>Wir behalten uns vor, diese Datenschutzerklärung anzupassen, damit sie stets den aktuellen rechtlichen Anforderungen entspricht oder um Änderungen unserer Leistungen in der Datenschutzerklärung umzusetzen, z. B. bei der Einführung neuer Services. Für Ihren erneuten Besuch gilt dann die neue Datenschutzerklärung.</p>
<p>Stand: 2025</p>
</div>
</div>
</div>`;
---
<Base title={"Datenschutz — PMPNZNG"} page="legal" active="">
<Fragment set:html={main} />
</Base>
<style is:global>
.legal-page { max-width: 720px; margin: 0 auto; padding: var(--sp-xl) var(--gutter) var(--sp-2xl); }
.legal-heading { font-family: var(--font-display); font-size: clamp(28px, 4vw, 48px); font-weight: 900; text-transform: uppercase; margin-bottom: var(--sp-lg); line-height: 1; }
.legal-section { margin-bottom: var(--sp-lg); }
.legal-h2 { font-family: var(--font-display); font-size: 14px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.1em; color: var(--c-text); margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--c-border); }
.legal-h3 { font-size: 13px; font-weight: 700; color: var(--c-text); margin-bottom: 8px; margin-top: 16px; }
.legal-text { font-size: 14px; color: var(--c-text-muted); line-height: 1.8; }
.legal-text p + p { margin-top: 10px; }
.legal-text ul { margin: 10px 0 10px 0; padding-left: 20px; }
.legal-text ul li { margin-bottom: 6px; list-style: disc; }
.legal-text a { color: var(--c-text-muted); text-decoration: underline; }
.legal-text a:hover { color: var(--c-red-bright); }
</style>
+259
View File
@@ -0,0 +1,259 @@
---
import Base from '../layouts/Base.astro';
const main = `<div class="faq-page">
<div class="faq-hero">
<p class="faq-eyebrow">Häufige Fragen</p>
<h1 class="faq-heading">FAQ &amp;<br>Größentabelle</h1>
</div>
<!-- Versand & Lieferung -->
<p class="faq-cat">Versand &amp; Lieferung</p>
<div class="faq-list" role="list">
<div class="faq-item" role="listitem">
<button class="faq-question" aria-expanded="false">
Wie lange dauert der Versand?
<span class="faq-icon" aria-hidden="true">+</span>
</button>
<div class="faq-answer">
Wir versenden in der Regel innerhalb von 12 Werktagen. Die Lieferzeit innerhalb Deutschlands beträgt dann 24 Werktage. In Österreich und der Schweiz 37 Werktage. Du bekommst eine Tracking-Nummer per E-Mail, sobald dein Paket unterwegs ist.
</div>
</div>
<div class="faq-item" role="listitem">
<button class="faq-question" aria-expanded="false">
Ab wann ist der Versand kostenlos?
<span class="faq-icon" aria-hidden="true">+</span>
</button>
<div class="faq-answer">
Ab einem Bestellwert von 50 € ist der Versand innerhalb Deutschlands kostenlos. Darunter berechnen wir 4,90 € Versandkosten. Für Österreich und die Schweiz berechnen wir 8,90 €, unabhängig vom Bestellwert.
</div>
</div>
<div class="faq-item" role="listitem">
<button class="faq-question" aria-expanded="false">
Versendest du auch ins Ausland?
<span class="faq-icon" aria-hidden="true">+</span>
</button>
<div class="faq-answer">
Aktuell versenden wir nach Deutschland, Österreich und in die Schweiz. Weitere Länder sind geplant. Meld dich gern bei uns, wenn du aus einem anderen Land bestellst — wir schauen, was wir machen können.
</div>
</div>
</div>
<!-- Rückgabe & Reklamation -->
<p class="faq-cat">Rückgabe &amp; Reklamation</p>
<div class="faq-list">
<div class="faq-item">
<button class="faq-question" aria-expanded="false">
Kann ich Artikel zurückschicken?
<span class="faq-icon" aria-hidden="true">+</span>
</button>
<div class="faq-answer">
Ja. Du hast 30 Tage Zeit, Artikel ungetragen und im Originalzustand zurückzuschicken. Das gesetzliche Widerrufsrecht beträgt 14 Tage — wir geben dir 30. Schreib uns eine Mail an <a href="mailto:info@tgasolutions-shop.de">info@tgasolutions-shop.de</a> und wir kümmern uns darum.
</div>
</div>
<div class="faq-item">
<button class="faq-question" aria-expanded="false">
Was mache ich, wenn ich einen Fehler oder Schaden reklamieren will?
<span class="faq-icon" aria-hidden="true">+</span>
</button>
<div class="faq-answer">
Schreib uns direkt an <a href="mailto:info@tgasolutions-shop.de">info@tgasolutions-shop.de</a> mit deiner Bestellnummer und einem Foto des Problems. Wir finden eine Lösung — unkompliziert und ohne Bürokratie.
</div>
</div>
</div>
<!-- Produkte -->
<p class="faq-cat">Produkte &amp; Material</p>
<div class="faq-list">
<div class="faq-item">
<button class="faq-question" aria-expanded="false">
Welches Material verwenden eure Produkte?
<span class="faq-icon" aria-hidden="true">+</span>
</button>
<div class="faq-answer">
Alle Textilien sind aus 100% Bio-Baumwolle (GOTS-zertifiziert, Fairtrade). Die Hoodies haben 280 g/m², die T-Shirts 180 g/m². Die Caps sind aus Baumwolle, ebenfalls ohne Plastik. Keine Synthetikfasern — weil's auf der Baustelle warm genug wird.
</div>
</div>
<div class="faq-item">
<button class="faq-question" aria-expanded="false">
Wie pflege ich die Teile richtig?
<span class="faq-icon" aria-hidden="true">+</span>
</button>
<div class="faq-answer">
Alles waschbar bei 40°C, am besten auf links gewaschen. Nicht heiß trocknen — einlaufen vermeiden. Nicht bleichen, nicht chemisch reinigen. Die Stickereien und Prints sind hitzeempfindlich — also nicht direkt drügebügeln.
</div>
</div>
<div class="faq-item">
<button class="faq-question" aria-expanded="false">
Wie fallen die Größen aus?
<span class="faq-icon" aria-hidden="true">+</span>
</button>
<div class="faq-answer">
Unsere Hoodies und T-Shirts fallen in Unisex-Schnitt aus — das bedeutet: etwas breiter geschnitten, eher gerader Fall. Wenn du es enger magst, eine Größe kleiner nehmen. Im Zweifel: die Größentabelle weiter unten auf dieser Seite checken.
</div>
</div>
</div>
<!-- Zahlung -->
<p class="faq-cat">Zahlung</p>
<div class="faq-list">
<div class="faq-item">
<button class="faq-question" aria-expanded="false">
Welche Zahlungsarten akzeptiert ihr?
<span class="faq-icon" aria-hidden="true">+</span>
</button>
<div class="faq-answer">
PayPal, Kreditkarte (Visa, Mastercard) und Banküberweisung (Vorkasse). Bei Banküberweisung: Wir versenden erst nach Zahlungseingang. In der Regel dauert das 12 Werktage zusätzlich.
</div>
</div>
<div class="faq-item">
<button class="faq-question" aria-expanded="false">
Habe ich einen Rabattcode?
<span class="faq-icon" aria-hidden="true">+</span>
</button>
<div class="faq-answer">
Wenn du dich für unseren Newsletter einträgst, bekommst du 10% auf deine erste Bestellung — Code: <strong>HANDWERK10</strong>. Den Code einfach im Warenkorb eingeben.
</div>
</div>
</div>
<!-- Größentabelle -->
<div class="size-section" id="groessen">
<p class="faq-cat" style="margin-top:0">Größentabelle</p>
<h2 style="font-family:var(--font-display);font-size:clamp(20px,2.5vw,28px);font-weight:900;text-transform:uppercase;margin-bottom:var(--sp-sm)">Hoodies &amp; T-Shirts (Unisex)</h2>
<div class="size-table-wrap">
<table class="size-table">
<thead>
<tr>
<th scope="col">Größe</th>
<th scope="col">Brustumfang (cm)</th>
<th scope="col">Körpergröße (cm)</th>
<th scope="col">Hüfte (cm)</th>
</tr>
</thead>
<tbody>
<tr><td>XS</td><td>8488</td><td>158164</td><td>8690</td></tr>
<tr><td>S</td><td>8892</td><td>164170</td><td>9094</td></tr>
<tr><td>M</td><td>9296</td><td>170176</td><td>9498</td></tr>
<tr><td>L</td><td>96100</td><td>176182</td><td>98102</td></tr>
<tr><td>XL</td><td>100108</td><td>182188</td><td>102108</td></tr>
<tr><td>2XL</td><td>108116</td><td>188194</td><td>108114</td></tr>
<tr><td>3XL</td><td>116124</td><td>194+</td><td>114120</td></tr>
</tbody>
</table>
</div>
<p class="size-note">Angaben in Zentimeter. Unisex-Schnitt = etwas breiter als Regular Fit. Bei Unsicherheit: eine Größe kleiner wählen.</p>
<p class="size-note" style="margin-top:6px">Die Caps sind One Size — SnapBack-Verschluss passt nahezu allen Kopfgrößen.</p>
</div>
<!-- Contact CTA -->
<div class="faq-contact">
<h2>Frage nicht beantwortet?</h2>
<p>Schreib uns — wir antworten auf der Baustelle, in der Werkstatt, irgendwo.</p>
<a href="mailto:info@tgasolutions-shop.de" class="btn btn-primary">info@tgasolutions-shop.de</a>
</div>
</div>`;
---
<Base title={"FAQ & Größen — PMPNZNG"} page="faq" active="faq">
<Fragment set:html={main} />
</Base>
<style is:global>
.faq-page { max-width: 860px; margin: 0 auto; padding: var(--sp-xl) var(--gutter) var(--sp-2xl); }
.faq-hero {
margin-bottom: var(--sp-xl);
}
.faq-eyebrow { font-size: 9px; letter-spacing: 0.32em; color: var(--c-red-bright); text-transform: uppercase; font-weight: 700; margin-bottom: 12px; display: flex; align-items: center; gap: 10px; }
.faq-eyebrow::before { content:''; display:block; width:24px; height:2px; background:var(--c-red-bright); }
.faq-heading { font-family: var(--font-display); font-size: clamp(32px, 5vw, 56px); font-weight: 900; text-transform: uppercase; line-height: 0.95; }
/* Category labels */
.faq-cat {
font-size: 9px; letter-spacing: 0.3em; text-transform: uppercase;
color: var(--c-red-bright); font-weight: 700;
margin: var(--sp-lg) 0 var(--sp-sm);
display: flex; align-items: center; gap: 10px;
}
.faq-cat::after { content:''; flex:1; height:1px; background:var(--c-border); }
/* Accordion */
.faq-list { display: flex; flex-direction: column; border-top: 1px solid var(--c-border); }
.faq-item { border-bottom: 1px solid var(--c-border); }
.faq-question {
display: flex; align-items: center; justify-content: space-between;
width: 100%; padding: 20px 0;
font-size: 15px; font-weight: 600; color: var(--c-text);
text-align: left; gap: 16px;
background: none; border: none; cursor: pointer;
transition: color var(--dur-fast);
}
.faq-question:hover { color: var(--c-red-bright); }
.faq-icon {
width: 24px; height: 24px; flex-shrink: 0;
border: 1px solid var(--c-border-mid);
display: flex; align-items: center; justify-content: center;
font-size: 14px; font-weight: 900; color: var(--c-text-muted);
transition: all var(--dur-fast);
}
.faq-item.open .faq-icon {
background: var(--c-red); border-color: var(--c-red); color: #fff;
}
.faq-answer {
display: none;
font-size: 14px; color: var(--c-text-muted); line-height: 1.8;
padding-bottom: 20px;
max-width: 680px;
}
.faq-item.open .faq-answer { display: block; }
.faq-answer a { color: var(--c-red-bright); text-decoration: underline; }
/* Size table */
.size-section { margin-top: var(--sp-xl); }
.size-table-wrap { overflow-x: auto; margin-top: var(--sp-sm); }
table.size-table {
width: 100%; border-collapse: collapse;
font-size: 13px;
}
.size-table th {
background: var(--c-surface-1); border: 1px solid var(--c-border);
padding: 12px 16px; text-align: center;
font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase;
font-weight: 700; color: var(--c-text-muted);
white-space: nowrap;
}
.size-table th:first-child { text-align: left; }
.size-table td {
border: 1px solid var(--c-border); padding: 11px 16px;
text-align: center; color: var(--c-text-muted);
}
.size-table td:first-child {
font-family: var(--font-display); font-weight: 900; font-size: 14px;
color: var(--c-text); text-align: left; background: var(--c-surface-1);
}
.size-table tr:hover td { background: var(--c-surface-1); }
.size-note { font-size: 11px; color: var(--c-text-dim); margin-top: 10px; }
/* Contact CTA */
.faq-contact {
background: var(--c-surface-1); border: 1px solid var(--c-border);
padding: var(--sp-lg); text-align: center; margin-top: var(--sp-xl);
}
.faq-contact h2 { font-family: var(--font-display); font-size: clamp(18px,2.5vw,24px); font-weight: 900; text-transform: uppercase; margin-bottom: 10px; }
.faq-contact p { font-size: 13px; color: var(--c-text-muted); margin-bottom: var(--sp-md); }
</style>
+113
View File
@@ -0,0 +1,113 @@
---
import Base from '../layouts/Base.astro';
const main = `<div class="legal-page">
<h1 class="legal-heading">Impressum</h1>
<div class="legal-section">
<h2 class="legal-h2">Angaben gemäß § 5 TMG</h2>
<div class="legal-text">
<p><strong>TGA Solutions GmbH</strong><br>
Kleine Kamp 1<br>
21702 Ahlerstedt<br>
Deutschland</p>
</div>
</div>
<div class="legal-section">
<h2 class="legal-h2">Kontakt</h2>
<div class="legal-text">
<p>Telefon: <a href="tel:+4941668991502">04166 8991 502</a><br>
E-Mail: <a href="mailto:info@tgasolutions-shop.de">info@tgasolutions-shop.de</a><br>
Shop: <a href="mailto:info@tgasolutions-shop.de">info@tgasolutions-shop.de</a><br>
Website: <a href="https://tgasolutions.de" rel="noopener noreferrer" target="_blank">tgasolutions.de</a></p>
</div>
</div>
<div class="legal-section">
<h2 class="legal-h2">Vertretungsberechtigte Geschäftsführer</h2>
<div class="legal-text">
<p>Sascha Hettich, Marcel Trautwein</p>
</div>
</div>
<div class="legal-section">
<h2 class="legal-h2">Inhaltlich verantwortlich gemäß § 6 MDStV</h2>
<div class="legal-text">
<p>Sascha Hettich, Marcel Trautwein</p>
</div>
</div>
<div class="legal-section">
<h2 class="legal-h2">Handelsregister</h2>
<div class="legal-text">
<p>Eingetragen im Handelsregister.<br>
Registergericht: Amtsgericht Tostedt<br>
Registernummer: HRB 208485</p>
</div>
</div>
<div class="legal-section">
<h2 class="legal-h2">Umsatzsteuer-ID</h2>
<div class="legal-text">
<p>Umsatzsteuer-Identifikationsnummer gemäß § 27 a Umsatzsteuergesetz:<br>
DE338904350</p>
</div>
</div>
<div class="legal-section">
<h2 class="legal-h2">Berufsbezeichnung und berufsrechtliche Regelungen</h2>
<div class="legal-text">
<p>Berufsbezeichnung: Installateur- und Heizungsbauermeister<br>
Verliehen in: Deutschland<br>
Zuständige Kammer: Handwerkskammer Hamburg</p>
</div>
</div>
<div class="legal-section">
<h2 class="legal-h2">EU-Streitschlichtung</h2>
<div class="legal-text">
<p>Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit: <a href="https://ec.europa.eu/consumers/odr/" rel="noopener noreferrer" target="_blank">https://ec.europa.eu/consumers/odr/</a></p>
<p>Unsere E-Mail-Adresse finden Sie oben im Impressum.</p>
</div>
</div>
<div class="legal-section">
<h2 class="legal-h2">Verbraucherstreitbeilegung/Universalschlichtungsstelle</h2>
<div class="legal-text">
<p>Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.</p>
</div>
</div>
<div class="legal-section">
<h2 class="legal-h2">Haftung für Inhalte</h2>
<div class="legal-text">
<p>Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.</p>
<p>Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den allgemeinen Gesetzen bleiben hiervon unberührt. Eine diesbezügliche Haftung ist jedoch erst ab dem Zeitpunkt der Kenntnis einer konkreten Rechtsverletzung möglich. Bei Bekanntwerden von entsprechenden Rechtsverletzungen werden wir diese Inhalte umgehend entfernen.</p>
</div>
</div>
<div class="legal-section">
<h2 class="legal-h2">Urheberrecht</h2>
<div class="legal-text">
<p>Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers. Downloads und Kopien dieser Seite sind nur für den privaten, nicht kommerziellen Gebrauch gestattet.</p>
</div>
</div>
</div>`;
---
<Base title={"Impressum — PMPNZNG"} page="legal" active="">
<Fragment set:html={main} />
</Base>
<style is:global>
.legal-page { max-width: 720px; margin: 0 auto; padding: var(--sp-xl) var(--gutter) var(--sp-2xl); }
.legal-heading { font-family: var(--font-display); font-size: clamp(28px, 4vw, 48px); font-weight: 900; text-transform: uppercase; margin-bottom: var(--sp-lg); line-height: 1; }
.legal-section { margin-bottom: var(--sp-lg); }
.legal-h2 { font-family: var(--font-display); font-size: 14px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.1em; color: var(--c-text); margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--c-border); }
.legal-text { font-size: 14px; color: var(--c-text-muted); line-height: 1.8; }
.legal-text p + p { margin-top: 10px; }
.legal-text a { color: var(--c-text-muted); text-decoration: underline; }
.legal-text a:hover { color: var(--c-red-bright); }
</style>
+486
View File
@@ -0,0 +1,486 @@
---
import Base from '../layouts/Base.astro';
const main = `<!-- HERO -->
<section class="hero" aria-labelledby="hero-heading">
<div class="hero-copy">
<span class="hero-eyebrow">Neue Kollektion · 2024</span>
<h1 class="hero-headline" id="hero-heading">
Trag,<br>wer
<span class="hl-accent">du bist.</span>
<span class="hl-dim">Aus Leidenschaft.</span>
</h1>
<p class="hero-desc">Für die, die früh aufstehen, die richtigen Werkzeuge kennen und am Ende des Tages wissen, was sie geleistet haben. Bio-Baumwolle. Fairtrade. Kein Kompromiss.</p>
<div class="hero-actions">
<a href="/shop" class="btn btn-primary btn-lg">Alle Produkte</a>
<a href="/ueber-uns" class="btn btn-outline">Über PMPNZNG</a>
</div>
<div class="hero-trust-mini">
<span class="trust-mini-item">Bio-Baumwolle</span>
<span class="trust-mini-item">Fairtrade</span>
<span class="trust-mini-item">Aus Ahlerstedt</span>
</div>
</div>
<div class="hero-visual" aria-hidden="true">
<a href="/produkt/hoodie-stick-druck" class="hero-img-main">
<img src="/product-images/p01-pmpnzng-hoodie-1.jpg" alt="PMPNZNG Hoodie Stick & Druck" width="600" height="750">
</a>
<div class="hero-img-sub-row">
<a href="/produkt/cap-shk" class="hero-img-sub">
<img src="/product-images/p04-cap-shk-1-freigestellt.png" alt="Cap SHK" width="300" height="140" loading="lazy">
</a>
<a href="/produkt/tshirt-pumpenzange" class="hero-img-sub">
<img src="/product-images/p05-tshirt-pumpenzange-2-freigestellt.png" alt="T-Shirt Pumpenzange" width="300" height="140" loading="lazy">
</a>
</div>
<div class="hero-price-badge">
<span class="hero-price-badge-val">ab 10,99&nbsp;€</span>
<span class="hero-price-badge-lbl">Bio · Fairtrade</span>
</div>
</div>
</section>
<!-- MARQUEE -->
<div class="marquee-band" aria-hidden="true">
<div class="marquee-inner">
<span class="marquee-item">PMPNZNG <span class="marquee-sep"></span> HEIZUNGSBAUERAUSLEIDENSCHAFT <span class="marquee-sep"></span> BIO-BAUMWOLLE <span class="marquee-sep"></span> FAIRTRADE <span class="marquee-sep"></span> AHLERSTEDT · NIEDERSACHSEN <span class="marquee-sep"></span> EST. 2021 <span class="marquee-sep"></span> SHK-COMMUNITY <span class="marquee-sep"></span> DU TRÄGST WAS DU BIST <span class="marquee-sep"></span> MEISTERFACHBETRIEB <span class="marquee-sep"></span></span>
<span class="marquee-item">PMPNZNG <span class="marquee-sep"></span> HEIZUNGSBAUERAUSLEIDENSCHAFT <span class="marquee-sep"></span> BIO-BAUMWOLLE <span class="marquee-sep"></span> FAIRTRADE <span class="marquee-sep"></span> AHLERSTEDT · NIEDERSACHSEN <span class="marquee-sep"></span> EST. 2021 <span class="marquee-sep"></span> SHK-COMMUNITY <span class="marquee-sep"></span> DU TRÄGST WAS DU BIST <span class="marquee-sep"></span> MEISTERFACHBETRIEB <span class="marquee-sep"></span></span>
</div>
</div>
<!-- TRUST BAR -->
<div class="trust-bar" role="list" aria-label="Unsere Versprechen">
<div class="trust-item-bar" role="listitem"><span class="trust-icon" aria-hidden="true">🌿</span> Bio-Baumwolle</div>
<div class="trust-item-bar" role="listitem"><span class="trust-icon" aria-hidden="true">⚖️</span> Fairtrade-zertifiziert</div>
<div class="trust-item-bar" role="listitem"><span class="trust-icon" aria-hidden="true">📦</span> Versand DE · AT · CH</div>
<div class="trust-item-bar" role="listitem"><span class="trust-icon" aria-hidden="true">↩</span> 30 Tage Rückgabe</div>
<div class="trust-item-bar" role="listitem"><span class="trust-icon" aria-hidden="true">🔒</span> Sicher zahlen via Stripe</div>
</div>
<!-- PRODUCTS -->
<section class="section-wrap" id="produkte" aria-labelledby="products-h">
<div class="section-container">
<header class="section-header">
<div class="section-label">
<div class="section-label-line" aria-hidden="true"></div>
<h2 id="products-h">Aktuelle Kollektion</h2>
</div>
<a href="/shop" class="section-meta" style="color:var(--c-red-bright);text-decoration:underline;text-underline-offset:3px;">Alle 6 Artikel →</a>
</header>
<div class="product-grid" role="list">
<article class="product-card" role="listitem">
<div class="card-badge">Bestseller</div>
<a href="/produkt/hoodie-stick-druck">
<div class="card-image-wrap">
<img src="/product-images/p01-pmpnzng-hoodie-1.jpg" alt="PMPNZNG Hoodie Stick & Druck" width="400" height="500" loading="lazy">
<div class="card-overlay" aria-hidden="true">Jetzt ansehen</div>
</div>
</a>
<div class="card-body">
<span class="card-cat">Hoodie · XS3XL</span>
<h3 class="card-name"><a href="/produkt/hoodie-stick-druck">PMPNZNG Hoodie<br>Stick & Druck</a></h3>
<p class="card-desc">Besticktes TGA-Wappen, PMPNZNG-Print. Bio-Baumwolle, Fairtrade.</p>
<div class="card-footer">
<span class="card-price">49,99&nbsp;€</span>
<span class="card-sizes">XS · S · M · L · XL</span>
</div>
</div>
</article>
<article class="product-card" role="listitem">
<a href="/produkt/cap-elektro">
<div class="card-image-wrap freigestellt" style="background:#f0ede8;">
<img src="/product-images/p02-cap-elektro-1-freigestellt.png" alt="Cap Elektro" width="400" height="500" loading="lazy">
<div class="card-overlay" aria-hidden="true">Jetzt ansehen</div>
</div>
</a>
<div class="card-body">
<span class="card-cat">Cap · One Size</span>
<h3 class="card-name"><a href="/produkt/cap-elektro">Cap Elektro</a></h3>
<p class="card-desc">EST 2022. SnapBack. Für Elektriker, die wissen, was sie tun.</p>
<div class="card-footer">
<span class="card-price">19,99&nbsp;€</span>
<span class="card-sizes">One Size</span>
</div>
</div>
</article>
<article class="product-card" role="listitem">
<div class="card-badge">Neu</div>
<a href="/produkt/warmduscher-beutel">
<div class="card-image-wrap freigestellt" style="background:#f0ede8;">
<img src="/product-images/p03-warmduscher-beutel-1-freigestellt.png" alt="Warmduscher Beutel" width="400" height="500" loading="lazy">
<div class="card-overlay" aria-hidden="true">Jetzt ansehen</div>
</div>
</a>
<div class="card-body">
<span class="card-cat">Tasche · One Size</span>
<h3 class="card-name"><a href="/produkt/warmduscher-beutel">Warmduscher Beutel</a></h3>
<p class="card-desc">Du trägst was du bist. Das perfekte Gegengewicht zur Werkzeugkiste.</p>
<div class="card-footer">
<span class="card-price">10,99&nbsp;€</span>
<span class="card-sizes">One Size</span>
</div>
</div>
</article>
<article class="product-card" role="listitem">
<a href="/produkt/cap-shk">
<div class="card-image-wrap freigestellt" style="background:#f0ede8;">
<img src="/product-images/p04-cap-shk-1-freigestellt.png" alt="Cap SHK" width="400" height="500" loading="lazy">
<div class="card-overlay" aria-hidden="true">Jetzt ansehen</div>
</div>
</a>
<div class="card-body">
<span class="card-cat">Cap · One Size</span>
<h3 class="card-name"><a href="/produkt/cap-shk">Cap SHK</a></h3>
<p class="card-desc">SHK vorne, PMPNZNG auf der Rückseite. Statement für die Branche.</p>
<div class="card-footer">
<span class="card-price">19,99&nbsp;€</span>
<span class="card-sizes">One Size</span>
</div>
</div>
</article>
<article class="product-card" role="listitem">
<div class="card-badge">Bio</div>
<a href="/produkt/tshirt-pumpenzange">
<div class="card-image-wrap freigestellt" style="background:#f0ede8;">
<img src="/product-images/p05-tshirt-pumpenzange-2-freigestellt.png" alt="T-Shirt Pumpenzange" width="400" height="500" loading="lazy">
<div class="card-overlay" aria-hidden="true">Jetzt ansehen</div>
</div>
</a>
<div class="card-body">
<span class="card-cat">T-Shirt · XS3XL</span>
<h3 class="card-name"><a href="/produkt/tshirt-pumpenzange">T-Shirt Pumpenzange</a></h3>
<p class="card-desc">Bio-Baumwolle, Fairtrade. XS bis 3XL. Handwerk hat Haltung.</p>
<div class="card-footer">
<span class="card-price">27,99&nbsp;€</span>
<span class="card-sizes">XS3XL</span>
</div>
</div>
</article>
<article class="product-card" role="listitem">
<div class="card-badge">Bio</div>
<a href="/produkt/hoodie-pumpenzange">
<!-- p06: freigestellt, no person -->
<div class="card-image-wrap freigestellt" style="background:#f0ede8;">
<img src="/product-images/p06-hoodie-pumpenzange-1-freigestellt.png" alt="Hoodie Pumpenzange" width="400" height="500" loading="lazy">
<div class="card-overlay" aria-hidden="true">Jetzt ansehen</div>
</div>
</a>
<div class="card-body">
<span class="card-cat">Hoodie · XS3XL</span>
<h3 class="card-name"><a href="/produkt/hoodie-pumpenzange">Hoodie Pumpenzange</a></h3>
<p class="card-desc">Bio-Baumwolle, Fairtrade. Das Werkzeug, das man trägt.</p>
<div class="card-footer">
<span class="card-price">49,99&nbsp;€</span>
<span class="card-sizes">XS3XL</span>
</div>
</div>
</article>
</div>
</div>
</section>
<!-- FEATURED STRIP -->
<section class="featured-strip" aria-labelledby="featured-h">
<div class="featured-inner">
<div class="featured-media">
<img src="/product-images/p01-pmpnzng-hoodie-2.jpg" alt="PMPNZNG Hoodie Detail der Stickerei" width="660" height="480" loading="lazy">
</div>
<div class="featured-content">
<p class="featured-label">Featured Produkt</p>
<h2 class="featured-headline" id="featured-h">TGA Solutions ///<br>Heizungsbauer<br>Aus Leidenschaft</h2>
<p class="featured-desc">Stick & Druck. Besticktes TGA-Wappen auf der Brust, PMPNZNG groß auf dem Rücken. Unisex, XS bis 3XL. Das Herzstück der Kollektion.</p>
<div class="featured-tags">
<span class="tag">Bio-Baumwolle</span>
<span class="tag">Fairtrade</span>
<span class="tag">Unisex</span>
<span class="tag">XS 3XL</span>
</div>
<div class="featured-price-row">
<span class="featured-price">49,99&nbsp;€</span>
<span class="featured-price-note">inkl. MwSt.</span>
</div>
<a href="/produkt/hoodie-stick-druck" class="btn btn-primary" style="align-self:flex-start;">Jetzt kaufen</a>
</div>
</div>
</section>
<!-- MANIFESTO -->
<section class="manifesto" aria-label="Manifest">
<div class="manifesto-inner">
<blockquote>„Du trägst<br>was <em>du bist.</em>"</blockquote>
<cite>— PMPNZNG · Ahlerstedt, Niedersachsen</cite>
</div>
</section>
<!-- TRUST CARDS -->
<section class="trust-grid-section" aria-labelledby="trust-h">
<div class="trust-grid">
<div class="trust-card">
<div class="trust-card-icon" aria-hidden="true">🌿</div>
<h3 class="trust-card-title">100% Bio-Baumwolle</h3>
<p class="trust-card-desc">Kein Kompromiss bei Material. Zertifizierte Bio-Baumwolle für alle Produkte.</p>
</div>
<div class="trust-card">
<div class="trust-card-icon" aria-hidden="true">⚖️</div>
<h3 class="trust-card-title">Fairtrade-zertifiziert</h3>
<p class="trust-card-desc">Faire Produktion, faire Preise, faire Bedingungen — überall in der Lieferkette.</p>
</div>
<div class="trust-card">
<div class="trust-card-icon" aria-hidden="true">🔧</div>
<h3 class="trust-card-title">Für die Branche</h3>
<p class="trust-card-desc">Von einem Meisterfachbetrieb für SHK-Profis, Elektriker und alle, die Handwerk leben.</p>
</div>
<div class="trust-card">
<div class="trust-card-icon" aria-hidden="true">📦</div>
<h3 class="trust-card-title">Versand aus DE</h3>
<p class="trust-card-desc">DE, AT & CH. Direkt aus Ahlerstedt, Niedersachsen. Kostenlos ab 50&nbsp;€.</p>
</div>
</div>
</section>
<!-- COMMUNITY -->
<section class="community-strip" id="community" aria-labelledby="community-h">
<div class="community-inner">
<p class="community-eyebrow">Community</p>
<h2 class="community-headline" id="community-h">Mehr als Merch.<br>Eine Haltung.</h2>
<p class="community-sub">PMPNZNG ist eine Marke der TGA Solutions GmbH — Meisterfachbetrieb für technische Gebäudeausrüstung aus Ahlerstedt. Wir machen Merch für Menschen, die Handwerk nicht als Beruf, sondern als Berufung verstehen.</p>
<div class="community-links">
<a href="https://instagram.com/tgasolutions" target="_blank" rel="noopener noreferrer" class="btn btn-outline">@tgasolutions auf Instagram</a>
<a href="/ueber-uns" class="btn btn-outline">Über PMPNZNG</a>
</div>
</div>
</section>
<!-- INSTAGRAM FEED -->
<section class="insta-section" aria-labelledby="insta-h">
<div class="insta-section-inner">
<div class="insta-header">
<div class="insta-title-wrap">
<span class="insta-eyebrow">Community · Instagram</span>
<h2 class="insta-title" id="insta-h">Folgst du<br>uns schon?</h2>
<p class="insta-handle">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14" aria-hidden="true"><rect x="2" y="2" width="20" height="20" rx="5" ry="5"/><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"/><line x1="17.5" y1="6.5" x2="17.51" y2="6.5"/></svg>
@tgasolutions
</p>
</div>
<a href="https://instagram.com/tgasolutions" target="_blank" rel="noopener noreferrer" class="insta-follow-btn" aria-label="@tgasolutions auf Instagram folgen">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="15" height="15" aria-hidden="true"><rect x="2" y="2" width="20" height="20" rx="5" ry="5"/><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"/><line x1="17.5" y1="6.5" x2="17.51" y2="6.5"/></svg>
Jetzt folgen
</a>
</div>
<!--
Echte Instagram-Posts einbinden:
1. Post auf instagram.com/tgasolutions öffnen
2. "..." > "Einbetten" > URL kopieren
3. URL in INSTA_POSTS[] in shop.js eintragen
→ Posts werden dann als offizielle Instagram-Embeds geladen
-->
<div class="insta-grid" id="insta-grid" role="list">
<a href="https://www.instagram.com/tgasolutions/reel/DVgqeRQiNnD/" target="_blank" rel="noopener noreferrer" class="insta-post" role="listitem" aria-label="FOR ALL HANDWERK TGA zieht um! 🚀">
<img src="/product-images/p01-pmpnzng-hoodie-1.jpg" alt="FOR ALL HANDWERK TGA zieht um! 🚀" width="400" height="400" loading="lazy">
<div class="insta-post-overlay" aria-hidden="true">
<span class="insta-post-icon">▶</span><span class="insta-post-tag">#forallhandwerk</span>
</div>
</a>
<a href="https://www.instagram.com/tgasolutions/p/DVbxKYwiB30/" target="_blank" rel="noopener noreferrer" class="insta-post" role="listitem" aria-label="Ordnung im Werkzeug PMPNZNG Hoodie" style="background:#f0ede8;">
<img src="/product-images/p06-hoodie-pumpenzange-1-freigestellt.png" alt="Ordnung im Werkzeug PMPNZNG Hoodie" width="400" height="400" loading="lazy" style="object-fit:contain;padding:16px;">
<div class="insta-post-overlay" aria-hidden="true">
<span class="insta-post-icon">📷</span><span class="insta-post-tag">#ordnungimwerkzeug</span>
</div>
</a>
<a href="https://www.instagram.com/tgasolutions/reel/DVVazsxiIB8/" target="_blank" rel="noopener noreferrer" class="insta-post" role="listitem" aria-label="PMPNZNG Reel besten Kompositionen zusammen">
<img src="/product-images/p01-pmpnzng-hoodie-3.jpg" alt="PMPNZNG Reel besten Kompositionen zusammen" width="400" height="400" loading="lazy">
<div class="insta-post-overlay" aria-hidden="true">
<span class="insta-post-icon">▶</span><span class="insta-post-tag">#handwerkerleben</span>
</div>
</a>
<a href="https://www.instagram.com/tgasolutions/reel/DVMOZMGiDto/" target="_blank" rel="noopener noreferrer" class="insta-post" role="listitem" aria-label="PMPNZNG Hoodie Lust auf Handwerk?" style="background:#f0ede8;">
<img src="/product-images/p05-tshirt-pumpenzange-1-freigestellt.png" alt="PMPNZNG Hoodie Lust auf Handwerk?" width="400" height="400" loading="lazy" style="object-fit:contain;padding:16px;">
<div class="insta-post-overlay" aria-hidden="true">
<span class="insta-post-icon">▶</span><span class="insta-post-tag">#pmpnzng</span>
</div>
</a>
<a href="https://www.instagram.com/tgasolutions/reel/DVDX5KQiB8Z/" target="_blank" rel="noopener noreferrer" class="insta-post" role="listitem" aria-label="PMPNZNG auf der Baustelle">
<img src="/product-images/p01-pmpnzng-hoodie-4.jpg" alt="PMPNZNG auf der Baustelle" width="400" height="400" loading="lazy">
<div class="insta-post-overlay" aria-hidden="true">
<span class="insta-post-icon">▶</span><span class="insta-post-tag">#pmpnzng</span>
</div>
</a>
<a href="https://www.instagram.com/tgasolutions/reel/DU_br0OCE35/" target="_blank" rel="noopener noreferrer" class="insta-post" role="listitem" aria-label="Ich bin wieder auf der Baustelle PMPNZNG" style="background:#f0ede8;">
<img src="/product-images/p02-cap-elektro-1-freigestellt.png" alt="Ich bin wieder auf der Baustelle PMPNZNG" width="400" height="400" loading="lazy" style="object-fit:contain;padding:16px;">
<div class="insta-post-overlay" aria-hidden="true">
<span class="insta-post-icon">▶</span><span class="insta-post-tag">#baustelle</span>
</div>
</a>
</div>
<p class="insta-footer">
<a href="https://instagram.com/tgasolutions" target="_blank" rel="noopener noreferrer">instagram.com/tgasolutions</a>
&nbsp;·&nbsp; Zeig uns deinen Look: <strong>#PMPNZNG</strong>
</p>
</div>
</section>
<!-- NEWSLETTER -->
<aside class="newsletter-strip" aria-labelledby="newsletter-h">
<div class="newsletter-badge-row" aria-hidden="true">
<span class="newsletter-badge-pill">Schon Abonnent? Nein?</span>
<span class="newsletter-badge-offer">— 5 % auf jede Bestellung sparen.</span>
</div>
<div class="newsletter-inner">
<div class="newsletter-copy">
<h3 id="newsletter-h">Neue Drops. Direkt in dein Postfach.</h3>
<p>Kein Spam. Nur neue Kollektion, Aktionen und Handwerk-Storys.<br>
<strong style="color:var(--c-text);">Bonus: 5 % Rabattcode direkt nach der Anmeldung.</strong></p>
</div>
<form class="newsletter-form" id="newsletter-form" action="#" method="post" aria-label="Newsletter anmelden">
<label for="nl-email" class="visually-hidden">E-Mail-Adresse</label>
<input class="newsletter-input" type="email" id="nl-email" name="email" placeholder="deine@email.de" required autocomplete="email">
<button type="submit" class="newsletter-submit">5 % sichern →</button>
</form>
</div>
</aside>`;
---
<Base title={"PMPNZNG — Heizungsbauerausleidenschaft"} page="home" active="">
<Fragment set:html={main} />
</Base>
<style is:global>
/* ── Homepage-specific ──────────────────────────────────── */
.hero {
display: grid; grid-template-columns: 1fr 1fr;
min-height: clamp(500px, 88vh, 820px);
max-width: var(--max-w); margin: 0 auto;
padding: var(--sp-xl) var(--gutter);
align-items: center; gap: clamp(32px, 5vw, 72px);
}
.hero-copy { display: flex; flex-direction: column; gap: var(--sp-md); }
.hero-eyebrow {
display: inline-flex; align-items: center; gap: 10px;
font-size: 10px; letter-spacing: 0.32em; color: var(--c-red-bright);
text-transform: uppercase; font-weight: 700;
}
.hero-eyebrow::before { content:''; display:block; width:28px; height:2px; background:var(--c-red-bright); }
.hero-headline {
font-family: var(--font-display); font-size: clamp(42px, 6vw, 80px);
font-weight: 900; line-height: 0.93; letter-spacing: -0.01em; text-transform: uppercase;
}
.hero-headline .hl-accent { display:block; color: var(--c-red-bright); font-style: italic; }
.hero-headline .hl-dim { display:block; color: var(--c-text-muted); font-weight:400; font-size:0.72em; }
.hero-desc { font-size: clamp(13px, 1.2vw, 15px); color: var(--c-text-muted); line-height: 1.75; max-width: 400px; }
.hero-actions { display: flex; align-items: center; gap: var(--sp-sm); flex-wrap: wrap; }
.hero-trust-mini { display: flex; gap: 24px; flex-wrap: wrap; }
.trust-mini-item { display:flex; align-items:center; gap:6px; font-size:11px; color: var(--c-text-dim); }
.trust-mini-item::before { content:'✓'; color: var(--c-red-bright); font-weight:900; }
.hero-visual { align-self: stretch; display: flex; flex-direction: column; gap: 4px; }
.hero-img-main { flex:1; background: var(--c-card); overflow: hidden; min-height: 380px; position: relative; }
.hero-img-main img { width:100%; height:100%; object-fit:cover; object-position: center top; transition: transform 0.6s var(--ease-out); }
.hero-img-main:hover img { transform: scale(1.03); }
.hero-img-sub-row { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; height: 140px; }
.hero-img-sub { background: var(--c-card-dark); overflow: hidden; }
.hero-img-sub img { width:100%; height:100%; object-fit:cover; object-position: center top; transition: transform 0.5s var(--ease-out); }
.hero-img-sub:hover img { transform: scale(1.06); }
.hero-price-badge {
position: absolute; bottom: 148px; right: -16px; z-index: 2;
background: var(--c-red); color: #fff; padding: 12px 16px; text-align: center;
}
.hero-price-badge-val { font-family:var(--font-display); font-size:22px; font-weight:900; line-height:1; display:block; }
.hero-price-badge-lbl { font-size:8px; letter-spacing:0.2em; text-transform:uppercase; opacity:0.8; margin-top:3px; }
.featured-strip { background: var(--c-surface-1); border-top: 1px solid var(--c-border); border-bottom: 1px solid var(--c-border); }
.featured-inner { max-width: var(--max-w); margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; min-height: 480px; }
.featured-media { background: var(--c-card); overflow: hidden; }
.featured-media img { width:100%; height:100%; object-fit:cover; object-position: center top; transition: transform 0.6s var(--ease-out); }
.featured-media:hover img { transform: scale(1.03); }
.featured-content { padding: clamp(40px, 6vw, 72px); display: flex; flex-direction: column; justify-content: center; gap: var(--sp-sm); }
.featured-label { font-size:9px; letter-spacing:0.3em; color:var(--c-red-bright); text-transform:uppercase; font-weight:700; }
.featured-headline { font-family:var(--font-display); font-size:clamp(22px, 3vw, 38px); font-weight:900; line-height:1.1; text-transform:uppercase; }
.featured-desc { font-size:13px; color:var(--c-text-muted); line-height:1.75; max-width:380px; }
.featured-tags { display:flex; gap:8px; flex-wrap:wrap; }
.tag { border:1px solid var(--c-border-mid); padding:5px 12px; font-size:9px; letter-spacing:0.18em; text-transform:uppercase; color:var(--c-text-muted); }
.featured-price-row { display:flex; align-items:baseline; gap:12px; }
.featured-price { font-family:var(--font-display); font-size:36px; font-weight:900; }
.featured-price-note { font-size:11px; color:var(--c-text-muted); }
.manifesto { padding: var(--sp-2xl) var(--gutter); text-align: center; position: relative; overflow: hidden; }
.manifesto::before {
content:'"'; position:absolute; top:-20px; left:50%; transform:translateX(-50%);
font-family:var(--font-display); font-size:clamp(140px,24vw,280px); font-weight:900;
color:var(--c-surface-2); line-height:1; pointer-events:none; z-index:0;
}
.manifesto-inner { position:relative; z-index:1; max-width:760px; margin:0 auto; }
.manifesto blockquote {
font-family:var(--font-display); font-size:clamp(26px,5vw,58px); font-weight:900;
line-height:1.1; text-transform:uppercase; margin-bottom:28px;
}
.manifesto blockquote em { font-style:italic; color:var(--c-red-bright); }
.manifesto cite { font-size:10px; letter-spacing:0.28em; color:var(--c-text-muted); font-style:normal; text-transform:uppercase; }
.trust-grid-section { background: var(--c-surface-1); border-top:1px solid var(--c-border); border-bottom:1px solid var(--c-border); padding: var(--sp-lg) var(--gutter); }
.trust-grid { max-width:var(--max-w); margin:0 auto; display:grid; grid-template-columns:repeat(4,1fr); gap:var(--sp-md); }
.trust-card { border:1px solid var(--c-border); padding:var(--sp-md); display:flex; flex-direction:column; gap:10px; transition:border-color 0.2s; }
.trust-card:hover { border-color:var(--c-red); }
.trust-card-icon { font-size:22px; line-height:1; }
.trust-card-title { font-size:12px; font-weight:700; letter-spacing:0.1em; text-transform:uppercase; }
.trust-card-desc { font-size:12px; color:var(--c-text-muted); line-height:1.6; }
.community-strip { padding:var(--sp-xl) var(--gutter); text-align:center; }
.community-inner { max-width:640px; margin:0 auto; display:flex; flex-direction:column; align-items:center; gap:var(--sp-md); }
.community-eyebrow { font-size:9px; letter-spacing:0.32em; color:var(--c-red-bright); text-transform:uppercase; font-weight:700; }
.community-headline { font-family:var(--font-display); font-size:clamp(20px,3vw,32px); font-weight:900; text-transform:uppercase; line-height:1.15; }
.community-sub { font-size:13px; color:var(--c-text-muted); line-height:1.7; }
.community-links { display:flex; gap:var(--sp-sm); flex-wrap:wrap; justify-content:center; }
.newsletter-strip { background:var(--c-surface-1); border-top:1px solid var(--c-border); padding:0 0 var(--sp-lg); overflow:hidden; }
.newsletter-badge-row {
display:flex; align-items:center; gap:10px; flex-wrap:wrap;
padding:10px var(--gutter); margin-bottom:var(--sp-md);
background:var(--c-red); border-bottom:none;
max-width:100%;
}
.newsletter-badge-pill {
font-size:10px; font-weight:900; letter-spacing:0.18em; text-transform:uppercase;
color:#fff; font-family:var(--font-display);
}
.newsletter-badge-offer {
font-size:12px; color:rgba(255,255,255,.85); letter-spacing:0.06em;
}
.newsletter-inner { max-width:var(--max-w); margin:0 auto; display:grid; grid-template-columns:1fr auto; align-items:center; gap:var(--sp-xl); padding:0 var(--gutter); }
.newsletter-copy h3 { font-family:var(--font-display); font-size:clamp(16px,2vw,22px); font-weight:900; text-transform:uppercase; margin-bottom:6px; }
.newsletter-copy p { font-size:12px; color:var(--c-text-muted); }
.newsletter-form { display:flex; }
.newsletter-input { background:var(--c-bg); border:1px solid var(--c-border-mid); border-right:none; color:var(--c-text); padding:12px 20px; font-size:12px; width:260px; outline:none; transition:border-color 0.2s; }
.newsletter-input:focus { border-color:var(--c-red); }
.newsletter-input::placeholder { color:var(--c-text-dim); }
.newsletter-submit { background:var(--c-red); color:#fff; padding:12px 24px; font-size:10px; font-weight:900; letter-spacing:0.2em; text-transform:uppercase; font-family:var(--font-display); transition:background 0.2s; border:none; cursor:pointer; }
.newsletter-submit:hover { background:var(--c-red-bright); }
@media (max-width:768px) {
.featured-inner { grid-template-columns:1fr; }
.featured-media { min-height:300px; }
.trust-grid { grid-template-columns:1fr 1fr; }
.newsletter-inner { grid-template-columns:1fr; }
.newsletter-form { flex-direction:column; }
.newsletter-input { width:100%; border-right:1px solid var(--c-border-mid); border-bottom:none; }
}
</style>
+217
View File
@@ -0,0 +1,217 @@
---
import Base from '../../layouts/Base.astro';
import { getProducts, getProductBySlug, formatPrice } from '../../lib/products';
const { slug } = Astro.params;
const product = await getProductBySlug(slug);
if (!product) return Astro.redirect('/shop');
const all = await getProducts();
const related = all.filter((r) => r.slug !== product.slug).slice(0, 3);
const gallery = product.freigestellt
? [product.freigestellt, ...product.images.filter((i) => i !== product.freigestellt)]
: product.images;
const mainImg = gallery[0];
const singleSize = product.sizes.length === 1;
const preSize = singleSize ? product.sizes[0] : '';
const lowStock = product.stock != null && product.stock <= 25;
---
<Base
title={`${product.name} — PMPNZNG`}
description={product.desc}
page="product"
ogImage={product.cardImage}
>
<div class="pdp-layout">
<div class="pdp-gallery" aria-label="Produktbilder">
<div class="gallery-main-wrap">
<img
id="gallery-main"
src={mainImg}
alt={product.name}
width="600"
height="750"
loading="eager"
/>
</div>
{
gallery.length > 1 && (
<div class="gallery-thumbs">
{gallery.map((img, i) => (
<button class:list={['gallery-thumb', { active: i === 0 }]} data-src={img} aria-label={`Bild ${i + 1}`}>
<img src={img} alt="" width="120" height="120" loading="lazy" />
</button>
))}
</div>
)
}
</div>
<div class="pdp-info">
<nav class="pdp-breadcrumb" aria-label="Brotkrümelpfad">
<a href="/">Start</a><span aria-hidden="true"></span>
<a href="/shop">Shop</a><span aria-hidden="true"></span>
<span aria-current="page">{product.shortName}</span>
</nav>
{product.badge && <span class="pdp-badge">{product.badge}</span>}
<h1 class="pdp-name">{product.name}</h1>
<div class="pdp-price-row">
<span class="pdp-price">{formatPrice(product.priceCents)}</span>
<span class="pdp-price-note">inkl. MwSt. · zzgl. Versand</span>
</div>
<div class="pdp-divider"></div>
<p class="pdp-desc">{product.desc}</p>
{
product.stock != null && (
<div class="stock-indicator">
<span class="stock-dot" aria-hidden="true"></span>
<span>
{lowStock ? <>Nur noch <strong>{product.stock} Stück</strong> verfügbar</> : <>Auf Lager</>}
</span>
</div>
)
}
<div class="pdp-divider"></div>
<form class="atc-form" id="product-form" data-product-id={product.slug} novalidate>
<input type="hidden" id="selected-size" name="size" value={preSize} />
<div>
<div class="size-label-row">
<span class="size-label">{singleSize ? 'Größe' : 'Größe wählen'}</span>
<a href="/faq#groessen" class="size-guide-link">Größentabelle</a>
</div>
<div class="size-btns" role="group" aria-label="Verfügbare Größen">
{
product.sizes.map((size, i) => (
<button type="button" class:list={['size-btn', { active: singleSize && i === 0 }]} data-size={size}>
{size}
</button>
))
}
</div>
</div>
{
lowStock && (
<div class="scarcity-note" aria-label="Hinweis zur Verfügbarkeit">
Achtung: Nur noch <strong>{product.stock} Stück</strong> auf Lager. Bestell jetzt, bevor er weg ist.
</div>
)
}
<button
type="submit"
class="btn btn-primary btn-full btn-lg"
id="add-to-cart-btn"
disabled={!singleSize}
>
<span aria-hidden="true">+</span> {singleSize ? 'In den Warenkorb' : 'Größe wählen'}
</button>
<div class="atc-secondary">
{product.features.map((f) => <span class="atc-trust">{f}</span>)}
<span class="atc-trust">30 Tage Rückgabe</span>
</div>
</form>
<div class="pdp-divider"></div>
<div class="pdp-specs">
<h3>Produktdetails</h3>
<ul class="spec-list">
<li class="spec-item">{product.material}</li>
{product.features.map((f) => <li class="spec-item">{f}</li>)}
</ul>
</div>
</div>
</div>
<section class="related-section">
<div class="related-inner">
<h2 class="related-heading">Passt auch dazu</h2>
<div class="related-grid">
{
related.map((r) => (
<a class="rel-card" href={`/produkt/${r.slug}`}>
<div class="rel-card-img">
<img src={r.freigestellt ?? r.cardImage} alt={r.name} width="400" height="500" loading="lazy" />
</div>
<div class="rel-card-info">
<div class="rel-card-name" set:html={r.name} />
<div class="rel-card-price">{formatPrice(r.priceCents)}</div>
</div>
</a>
))
}
</div>
</div>
</section>
</Base>
<style is:global>
.pdp-layout { max-width: var(--max-w); margin: 0 auto; padding: var(--sp-lg) var(--gutter) var(--sp-2xl); display: grid; grid-template-columns: 1fr 1fr; gap: clamp(32px, 5vw, 80px); align-items: start; }
.pdp-gallery { position: sticky; top: calc(var(--header-h) + 24px); }
.gallery-main-wrap { background: var(--c-card); aspect-ratio: 4/5; overflow: hidden; margin-bottom: 6px; display: flex; align-items: center; justify-content: center; }
#gallery-main { width: 100%; height: 100%; object-fit: contain; padding: 24px; transition: opacity 0.2s; }
.gallery-thumbs { display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; }
.gallery-thumb { aspect-ratio: 1; background: var(--c-card); overflow: hidden; cursor: pointer; border: 2px solid transparent; transition: border-color 0.15s; }
.gallery-thumb img { width: 100%; height: 100%; object-fit: contain; padding: 6px; }
.gallery-thumb.active { border-color: var(--c-red); }
.pdp-badge { display: inline-block; background: var(--c-red); color: #fff; font-size: 8px; font-weight: 900; letter-spacing: 0.2em; text-transform: uppercase; padding: 4px 12px; align-self: flex-start; }
.pdp-info { display: flex; flex-direction: column; gap: var(--sp-sm); }
.pdp-breadcrumb { display: flex; align-items: center; gap: 8px; font-size: 10px; letter-spacing: 0.14em; color: var(--c-text-muted); text-transform: uppercase; font-weight: 600; }
.pdp-breadcrumb a { color: var(--c-text-muted); }
.pdp-breadcrumb a:hover { color: var(--c-text); }
.pdp-breadcrumb span { color: var(--c-text-dim); }
.pdp-name { font-family: var(--font-display); font-size: clamp(26px, 3.5vw, 40px); font-weight: 900; line-height: 1.05; text-transform: uppercase; }
.pdp-price-row { display: flex; align-items: baseline; gap: 16px; }
.pdp-price { font-family: var(--font-display); font-size: 32px; font-weight: 900; }
.pdp-price-note { font-size: 11px; color: var(--c-text-muted); }
.pdp-divider { height: 1px; background: var(--c-border); margin: 4px 0; }
.pdp-desc { font-size: 14px; color: var(--c-text-muted); line-height: 1.75; }
.stock-indicator { display: flex; align-items: center; gap: 8px; font-size: 11px; font-weight: 600; }
.stock-dot { width: 8px; height: 8px; border-radius: 50%; background: #f59e0b; flex-shrink: 0; }
.stock-indicator span { color: var(--c-text-muted); }
.size-label-row { display: flex; align-items: center; justify-content: space-between; }
.size-label { font-size: 10px; letter-spacing: 0.18em; font-weight: 700; text-transform: uppercase; color: var(--c-text-muted); }
.size-guide-link { font-size: 10px; color: var(--c-red-bright); text-decoration: underline; letter-spacing: 0.1em; text-transform: uppercase; font-weight: 600; }
.size-btns { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.size-btn { min-width: 52px; padding: 10px 14px; border: 1px solid var(--c-border-mid); font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--c-text-muted); background: var(--c-surface-1); transition: all var(--dur-fast) var(--ease-out); text-align: center; }
.size-btn:hover { border-color: var(--c-text-muted); color: var(--c-text); }
.size-btn.active { background: var(--c-red); border-color: var(--c-red); color: #fff; }
.atc-form { display: flex; flex-direction: column; gap: var(--sp-sm); }
#add-to-cart-btn { display: flex; align-items: center; justify-content: center; gap: 12px; width: 100%; padding: 20px; font-size: 12px; font-weight: 900; letter-spacing: 0.2em; text-transform: uppercase; font-family: var(--font-display); background: var(--c-red); color: #fff; border: none; cursor: pointer; transition: background var(--dur-fast); }
#add-to-cart-btn:hover { background: var(--c-red-bright); }
#add-to-cart-btn:disabled { background: var(--c-surface-3); color: var(--c-text-dim); cursor: not-allowed; }
.atc-secondary { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.atc-trust { display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--c-text-muted); }
.atc-trust::before { content: '✓'; color: var(--c-red-bright); font-weight: 900; }
.scarcity-note { background: var(--c-surface-1); border-left: 3px solid var(--c-red); padding: 12px 16px; font-size: 12px; color: var(--c-text-muted); }
.scarcity-note strong { color: var(--c-red-bright); }
.pdp-specs { border-top: 1px solid var(--c-border); padding-top: var(--sp-sm); }
.pdp-specs h3 { font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase; font-weight: 700; color: var(--c-text-muted); margin-bottom: 12px; }
.spec-list { display: flex; flex-direction: column; gap: 8px; }
.spec-item { display: flex; align-items: center; gap: 10px; font-size: 12px; color: var(--c-text-muted); }
.spec-item::before { content: '—'; color: var(--c-red-bright); flex-shrink: 0; }
.related-section { border-top: 1px solid var(--c-border); padding: var(--sp-xl) var(--gutter); }
.related-inner { max-width: var(--max-w); margin: 0 auto; }
.related-heading { font-family: var(--font-display); font-size: clamp(20px, 2.5vw, 28px); font-weight: 900; text-transform: uppercase; margin-bottom: var(--sp-md); }
.related-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 3px; }
.rel-card { background: var(--c-card); color: var(--c-card-text); text-decoration: none; display: block; }
.rel-card-img { aspect-ratio: 4/5; overflow: hidden; background: var(--c-card); }
.rel-card-img img { width: 100%; height: 100%; object-fit: contain; padding: 12px 16px 0; transition: transform 0.4s var(--ease-out); }
.rel-card:hover .rel-card-img img { transform: scale(1.04); }
.rel-card-info { padding: 14px 16px; }
.rel-card-name { font-family: var(--font-display); font-size: 13px; font-weight: 900; text-transform: uppercase; margin-bottom: 4px; }
.rel-card-price { font-size: 13px; font-weight: 700; color: #333; }
@media (max-width: 1024px) { .pdp-layout { grid-template-columns: 1fr; } .pdp-gallery { position: static; } }
@media (max-width: 600px) { .related-grid { grid-template-columns: repeat(2, 1fr); } .atc-secondary { grid-template-columns: 1fr; } }
</style>
+360
View File
@@ -0,0 +1,360 @@
---
import Base from '../../layouts/Base.astro';
const main = `<div class="workshop-layout">
<!-- Left: Cover visual -->
<div class="workshop-cover">
<div class="workshop-cover-main">
<div class="workshop-cover-bg" aria-hidden="true"></div>
<span class="workshop-cover-eyebrow">Online Workshop</span>
<div class="workshop-cover-title">
Social Media<br><strong>im Handwerk.</strong>
</div>
<div class="workshop-cover-divider"></div>
<div class="workshop-cover-speaker">
<div class="workshop-cover-speaker-name">Marcel Trautwein</div>
<div class="workshop-cover-speaker-role">Co-Geschäftsführer · TGA Solutions GmbH</div>
</div>
<div class="workshop-cover-stat">
<span class="workshop-cover-stat-num">891K</span>
<span class="workshop-cover-stat-label">Impressionen / 30 Tage</span>
</div>
</div>
<!-- Curriculum preview -->
<div class="curriculum">
<div class="curriculum-header">Kursinhalt — 10 Module</div>
<div class="curriculum-item">
<span class="curriculum-num">01</span>
<div>
<div class="curriculum-title">Das Schaufenster-Prinzip</div>
<div class="curriculum-sub">Warum dein Instagram dein 24/7-Schaufenster ist — und wie du es richtig nutzt.</div>
</div>
</div>
<div class="curriculum-item">
<span class="curriculum-num">02</span>
<div>
<div class="curriculum-title">Plattform-Strategie SHK</div>
<div class="curriculum-sub">Instagram, TikTok, YouTube — welche Plattform bringt welche Ergebnisse für Handwerksbetriebe.</div>
</div>
</div>
<div class="curriculum-item">
<span class="curriculum-num">03</span>
<div>
<div class="curriculum-title">Contentstrategie & Formate</div>
<div class="curriculum-sub">Reels, Stories, Karussells. Was funktioniert — direkt aus der Praxis von TGA Solutions.</div>
</div>
</div>
<div class="curriculum-item">
<span class="curriculum-num">04</span>
<div>
<div class="curriculum-title">Markenbotschafter im Betrieb</div>
<div class="curriculum-sub">Wie du dein Team als authentische Influencer aufbaust, ohne Cringe.</div>
</div>
</div>
<div class="curriculum-item">
<span class="curriculum-num">05</span>
<div>
<div class="curriculum-title">Recruiting durch Reichweite</div>
<div class="curriculum-sub">Social Media als Azubi- und Fachkräfte-Magnet — echte Fallzahlen aus Ahlerstedt.</div>
</div>
</div>
<div class="curriculum-item">
<span class="curriculum-num">0610</span>
<div>
<div class="curriculum-title">+ 5 weitere Module</div>
<div class="curriculum-sub">Analytics & KPIs · Content-Produktion ohne Agentur · SEO & AIO-Sichtbarkeit · Kundenbindung · Dein 90-Tage-Aktionsplan.</div>
</div>
</div>
</div>
</div>
<!-- Right: Info + ATC -->
<div class="pdp-info" style="display:flex;flex-direction:column;gap:var(--sp-sm);">
<nav class="pdp-breadcrumb" aria-label="Brotkrümelpfad">
<a href="/">Start</a>
<span aria-hidden="true"></span>
<a href="/shop">Shop</a>
<span aria-hidden="true"></span>
<span aria-current="page">Online Workshop</span>
</nav>
<span class="pdp-badge-digital">Digitales Produkt</span>
<h1 class="pdp-name">Social Media<br>im Handwerk.</h1>
<div class="pdp-price-row">
<span class="pdp-price">49,00 €</span>
<span class="pdp-price-note">inkl. MwSt. · Sofort-Zugang</span>
</div>
<div class="pdp-divider"></div>
<p class="pdp-desc">
891.000 Instagram-Impressionen in 30 Tagen. 8,59 % Engagement-Rate. 86,9 % davon neue Accounts.
Das ist nicht Glück — das ist System. Marcel Trautwein, Co-Geschäftsführer von TGA Solutions,
zeigt dir in 10 kompakten Modulen, wie ein SHK-Betrieb aus Ahlerstedt zur bekanntesten
Handwerkermarke im deutschsprachigen Raum wird.
</p>
<!-- Key stats -->
<div class="ws-stats">
<div class="ws-stat">
<span class="ws-stat-num">891K</span>
<span class="ws-stat-label">Impressionen / 30 Tage</span>
</div>
<div class="ws-stat">
<span class="ws-stat-num">8,59%</span>
<span class="ws-stat-label">Engagement-Rate</span>
</div>
<div class="ws-stat">
<span class="ws-stat-num">10 Mod.</span>
<span class="ws-stat-label">Direkt anwendbar</span>
</div>
</div>
<!-- Speaker -->
<div class="speaker-card">
<div class="speaker-avatar">MT</div>
<div>
<div class="speaker-name">Marcel Trautwein</div>
<div class="speaker-role">Co-Geschäftsführer · TGA Solutions GmbH</div>
<div class="speaker-bio">
Marcel hat TGA Solutions im Februar 2021 mit aufgebaut und den Betrieb
von Grund auf digital gedacht — von Social-Media-Strategie bis
zu Recruiting über Instagram. Seine Methode: ehrlich, messbar, wiederholbar.
</div>
</div>
</div>
<div class="pdp-divider"></div>
<form class="atc-form" id="product-form" data-product-id="p07" novalidate>
<input type="hidden" name="size" value="Digital">
<button type="submit" class="btn btn-primary btn-full btn-lg" id="add-to-cart-btn">
Jetzt Workshop kaufen — 49 €
</button>
<div style="display:flex; flex-direction:column; gap:8px;">
<span class="atc-trust">Sofort-Zugang nach Bezahlung</span>
<span class="atc-trust">Für alle SHK-Betriebsgrößen geeignet</span>
<span class="atc-trust">Aus echten TGA-Solutions-Daten entwickelt</span>
</div>
</form>
<div class="pdp-divider"></div>
<div class="pdp-specs">
<h3>Was du bekommst</h3>
<ul class="spec-list">
<li class="spec-item">10 Video-Module · direkt anwendbar</li>
<li class="spec-item">Vorlagen, Templates &amp; Checklisten</li>
<li class="spec-item">90-Tage-Aktionsplan für deinen Betrieb</li>
<li class="spec-item">Zugang über Browser · kein Download nötig</li>
<li class="spec-item">Lebenslanger Zugang nach Kauf</li>
</ul>
</div>
</div>
</div>
<!-- Related products -->
<section class="related-section">
<div class="related-inner">
<h2 class="related-heading">Dazu passt Merch</h2>
<div class="related-grid">
<a class="rel-card" href="/produkt/hoodie-pumpenzange">
<div class="rel-card-img"><img src="/product-images/p06-hoodie-pumpenzange-1-freigestellt.png" alt="Hoodie Pumpenzange" width="400" height="500" loading="lazy"></div>
<div class="rel-card-info"><div class="rel-card-name">Hoodie Pumpenzange</div><div class="rel-card-price">54,99 €</div></div>
</a>
<a class="rel-card" href="/produkt/tshirt-pumpenzange">
<div class="rel-card-img"><img src="/product-images/p05-tshirt-pumpenzange-2-freigestellt.png" alt="T-Shirt Pumpenzange" width="400" height="500" loading="lazy"></div>
<div class="rel-card-info"><div class="rel-card-name">T-Shirt Pumpenzange</div><div class="rel-card-price">27,99 €</div></div>
</a>
<a class="rel-card" href="/produkt/cap-shk">
<div class="rel-card-img"><img src="/product-images/p04-cap-shk-1-freigestellt.png" alt="Cap SHK" width="400" height="500" loading="lazy"></div>
<div class="rel-card-info"><div class="rel-card-name">Cap SHK</div><div class="rel-card-price">19,99 €</div></div>
</a>
</div>
</div>
</section>`;
---
<Base title={"Online Workshop · Social Media im Handwerk — PMPNZNG"} page="product" active="">
<Fragment set:html={main} />
</Base>
<style is:global>
/* ——— PDP base (shared with physical products) ——— */
.pdp-breadcrumb {
display: flex; align-items: center; gap: 8px;
font-size: 10px; letter-spacing: 0.14em; color: var(--c-text-muted);
text-transform: uppercase; font-weight: 600;
}
.pdp-breadcrumb a { color: var(--c-text-muted); }
.pdp-breadcrumb a:hover { color: var(--c-text); }
.pdp-breadcrumb span { color: var(--c-text-dim); }
.pdp-badge {
display: inline-block; background: var(--c-red); color: #fff;
font-size: 8px; font-weight: 900; letter-spacing: 0.2em;
text-transform: uppercase; padding: 4px 12px; align-self: flex-start;
}
.pdp-badge-digital {
display: inline-block; background: #1a3a1a; border: 1px solid #22c55e;
color: #22c55e; font-size: 8px; font-weight: 900; letter-spacing: 0.2em;
text-transform: uppercase; padding: 4px 12px; align-self: flex-start;
}
.pdp-name { font-family: var(--font-display); font-size: clamp(26px, 3.5vw, 40px); font-weight: 900; line-height: 1.05; text-transform: uppercase; }
.pdp-price-row { display: flex; align-items: baseline; gap: 16px; }
.pdp-price { font-family: var(--font-display); font-size: 32px; font-weight: 900; }
.pdp-price-note { font-size: 11px; color: var(--c-text-muted); }
.pdp-divider { height: 1px; background: var(--c-border); margin: 4px 0; }
.pdp-desc { font-size: 14px; color: var(--c-text-muted); line-height: 1.75; }
.atc-trust { display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--c-text-muted); }
.atc-trust::before { content: '✓'; color: var(--c-red-bright); font-weight: 900; }
.pdp-specs { border-top: 1px solid var(--c-border); padding-top: var(--sp-sm); }
.pdp-specs h3 { font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase; font-weight: 700; color: var(--c-text-muted); margin-bottom: 12px; }
.spec-list { display: flex; flex-direction: column; gap: 8px; }
.spec-item { display: flex; align-items: center; gap: 10px; font-size: 12px; color: var(--c-text-muted); }
.spec-item::before { content: '—'; color: var(--c-red-bright); flex-shrink: 0; }
/* ——— Workshop-specific layout ——— */
.workshop-layout {
max-width: var(--max-w); margin: 0 auto;
padding: var(--sp-lg) var(--gutter) var(--sp-2xl);
display: grid; grid-template-columns: 1fr 1fr;
gap: clamp(32px, 5vw, 80px); align-items: start;
}
/* Cover visual */
.workshop-cover {
position: sticky; top: calc(var(--header-h) + 24px);
}
.workshop-cover-main {
background: #0a0a0a;
border: 1px solid var(--c-border);
aspect-ratio: 4/5;
overflow: hidden;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
position: relative;
padding: 40px;
gap: 0;
}
.workshop-cover-bg {
position: absolute; inset: 0;
background:
repeating-linear-gradient(
90deg,
transparent, transparent 39px,
rgba(255,255,255,0.03) 39px, rgba(255,255,255,0.03) 40px
),
repeating-linear-gradient(
0deg,
transparent, transparent 39px,
rgba(255,255,255,0.03) 39px, rgba(255,255,255,0.03) 40px
);
}
.workshop-cover-eyebrow {
font-size: 9px; letter-spacing: 0.4em; text-transform: uppercase;
color: var(--c-red-bright); font-weight: 700;
position: relative; z-index: 1; margin-bottom: 24px;
}
.workshop-cover-title {
font-family: var(--font-display); font-size: clamp(28px, 4vw, 44px);
font-weight: 900; text-transform: uppercase; line-height: 0.95;
text-align: center; position: relative; z-index: 1;
}
.workshop-cover-title strong { color: var(--c-red-bright); display: block; }
.workshop-cover-divider {
width: 48px; height: 3px; background: var(--c-red);
margin: 24px auto; position: relative; z-index: 1;
}
.workshop-cover-speaker {
position: relative; z-index: 1; text-align: center;
}
.workshop-cover-speaker-name {
font-family: var(--font-display); font-size: 16px; font-weight: 900;
text-transform: uppercase; letter-spacing: 0.05em;
}
.workshop-cover-speaker-role {
font-size: 10px; color: var(--c-text-muted); letter-spacing: 0.18em;
text-transform: uppercase; margin-top: 4px;
}
.workshop-cover-stat {
position: absolute; bottom: 20px; right: 20px;
background: var(--c-red); padding: 10px 14px; text-align: center;
z-index: 2;
}
.workshop-cover-stat-num {
font-family: var(--font-display); font-size: 18px; font-weight: 900;
line-height: 1; display: block;
}
.workshop-cover-stat-label {
font-size: 7px; letter-spacing: 0.18em; text-transform: uppercase;
color: rgba(255,255,255,0.7); margin-top: 2px; display: block;
}
/* Curriculum section */
.curriculum {
border: 1px solid var(--c-border);
margin-top: var(--sp-sm);
}
.curriculum-header {
padding: 12px 16px;
background: var(--c-surface-1);
border-bottom: 1px solid var(--c-border);
font-size: 9px; letter-spacing: 0.28em; text-transform: uppercase;
font-weight: 700; color: var(--c-text-muted);
}
.curriculum-item {
display: flex; align-items: flex-start; gap: 12px;
padding: 12px 16px; border-bottom: 1px solid var(--c-border);
font-size: 12px;
}
.curriculum-item:last-child { border-bottom: none; }
.curriculum-num {
font-family: var(--font-display); font-size: 11px; font-weight: 900;
color: var(--c-red-bright); flex-shrink: 0; min-width: 20px; margin-top: 1px;
}
.curriculum-title { font-weight: 700; margin-bottom: 2px; font-size: 12px; }
.curriculum-sub { font-size: 11px; color: var(--c-text-muted); line-height: 1.5; }
/* Stats row */
.ws-stats {
display: grid; grid-template-columns: repeat(3, 1fr);
gap: 3px; margin: var(--sp-sm) 0;
}
.ws-stat {
background: var(--c-surface-1); border: 1px solid var(--c-border);
padding: 16px 12px; text-align: center;
}
.ws-stat-num {
font-family: var(--font-display); font-size: clamp(18px, 2.5vw, 24px);
font-weight: 900; color: var(--c-red-bright); display: block; line-height: 1;
}
.ws-stat-label { font-size: 8px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--c-text-muted); margin-top: 4px; display: block; }
/* Speaker card */
.speaker-card {
display: flex; gap: 16px; align-items: flex-start;
background: var(--c-surface-1); border: 1px solid var(--c-border);
padding: var(--sp-md); margin: var(--sp-sm) 0;
}
.speaker-avatar {
width: 56px; height: 56px; border-radius: 50%;
background: var(--c-red); display: flex; align-items: center; justify-content: center;
font-family: var(--font-display); font-size: 20px; font-weight: 900;
flex-shrink: 0; overflow: hidden;
}
.speaker-avatar img { width: 100%; height: 100%; object-fit: cover; }
.speaker-name { font-family: var(--font-display); font-size: 15px; font-weight: 900; text-transform: uppercase; }
.speaker-role { font-size: 10px; color: var(--c-text-muted); letter-spacing: 0.12em; text-transform: uppercase; margin-top: 2px; }
.speaker-bio { font-size: 12px; color: var(--c-text-muted); line-height: 1.65; margin-top: 8px; }
/* Related */
.related-section { border-top: 1px solid var(--c-border); padding: var(--sp-xl) var(--gutter); }
.related-inner { max-width: var(--max-w); margin: 0 auto; }
.related-heading { font-family: var(--font-display); font-size: clamp(20px, 2.5vw, 28px); font-weight: 900; text-transform: uppercase; margin-bottom: var(--sp-md); }
.related-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 3px; }
.rel-card { background: var(--c-card); color: var(--c-card-text); text-decoration: none; display: block; }
.rel-card-img { aspect-ratio: 4/5; overflow: hidden; background: var(--c-card); }
.rel-card-img img { width: 100%; height: 100%; object-fit: contain; padding: 12px 16px 0; transition: transform 0.4s var(--ease-out); }
.rel-card:hover .rel-card-img img { transform: scale(1.04); }
.rel-card-info { padding: 14px 16px; }
.rel-card-name { font-family: var(--font-display); font-size: 13px; font-weight: 900; text-transform: uppercase; margin-bottom: 4px; }
.rel-card-price { font-size: 13px; font-weight: 700; color: #333; }
@media (max-width: 1024px) { .workshop-layout { grid-template-columns: 1fr; } .workshop-cover { position: static; } }
@media (max-width: 600px) { .related-grid { grid-template-columns: repeat(2, 1fr); } .ws-stats { grid-template-columns: 1fr; } }
</style>
+122
View File
@@ -0,0 +1,122 @@
---
import Base from '../layouts/Base.astro';
import { getProducts, formatPrice } from '../lib/products';
const products = await getProducts();
const catKey = (c) => ({ 'Hoodie':'hoodie','Cap':'cap','T-Shirt':'tshirt','Tasche':'tasche','Digital':'workshop' }[c] || (c||'').toLowerCase());
const filters = [
{ key:'alle', label:'Alle' }, { key:'hoodie', label:'Hoodies' }, { key:'cap', label:'Caps' },
{ key:'tshirt', label:'T-Shirts' }, { key:'tasche', label:'Taschen' }, { key:'workshop', label:'Workshop' },
];
const count = (k) => k==='alle' ? products.length + 1 : (k==='workshop' ? 1 : products.filter(p=>catKey(p.category)===k).length);
---
<Base title={"Shop — PMPNZNG"} page="shop" active="shop">
<div class="shop-header"><div class="shop-header-inner">
<div class="shop-header-eyebrow">Kollektion 2025</div>
<h1 class="shop-header-title">Alle Produkte</h1>
<p class="shop-header-sub">Bio-Baumwolle · Fairtrade · Für die SHK-Community</p>
</div></div>
<div class="filter-bar" role="navigation" aria-label="Produktfilter"><div class="filter-inner">
{filters.map((f,i) => (
<button class={`filter-btn${i===0?' active':''}`} data-filter={f.key}>{f.label} <span class="filter-count">{count(f.key)}</span></button>
))}
</div></div>
<section class="shop-grid-wrap"><div class="shop-grid" id="shop-grid">
{products.map((p) => {
const free = p.freigestellt && p.freigestellt.length > 0;
const img = free ? p.freigestellt : p.cardImage;
return (
<a class="shop-product-card" href={`/produkt/${p.slug}`} data-category={catKey(p.category)}>
<div class={`card-img-wrap${free ? ' freigestellt' : ''}`}>
{p.badge && <span class="card-badge-shop">{p.badge}</span>}
<img src={img} alt={p.name} width="600" height="750" loading="lazy" />
</div>
<div class="card-body-shop">
<span class="card-category-shop">{p.category}</span>
<span class="card-name-shop" set:html={p.name} />
<div class="card-footer-shop">
<span class="card-price-shop">{formatPrice(p.priceCents)}</span>
<span class="card-cta-shop">Ansehen</span>
</div>
</div>
</a>
);
})}
<a class="shop-product-card" href="/produkt/online-workshop" data-category="workshop">
<div class="card-digital-cover">
<div class="card-digital-cover-bg"></div>
<span class="card-digital-eyebrow">Digital · Workshop</span>
<span class="card-digital-title">Social Media<strong>im Handwerk</strong></span>
<div class="card-digital-stat"><span class="card-digital-stat-num">2,1 Mio</span><span class="card-digital-stat-label">Impressionen / 30 Tage</span></div>
</div>
<div class="card-body-shop">
<span class="card-category-shop">Digitales Produkt</span>
<span class="card-name-shop">Social Media im<br/>Handwerk</span>
<div class="card-footer-shop"><span class="card-price-shop">49,00 €</span><span class="card-cta-shop">Ansehen</span></div>
</div>
</a>
</div></section>
<div class="shop-cta-band">
<h2>Kostenloser Versand ab 50 €</h2>
<p>Alle Produkte in Bio-Baumwolle und Fairtrade-zertifiziert. Produktion in Europa.</p>
<a href="/faq" class="btn btn-outline">Häufige Fragen</a>
</div>
<script is:inline>
const fbtns = document.querySelectorAll('.filter-btn');
fbtns.forEach((b) => b.addEventListener('click', () => {
fbtns.forEach((x) => x.classList.remove('active')); b.classList.add('active');
const f = b.dataset.filter;
document.querySelectorAll('.shop-product-card').forEach((c) => { c.style.display = (f === 'alle' || c.dataset.category === f) ? '' : 'none'; });
}));
</script>
</Base>
<style is:global>
.shop-header { background: var(--c-surface-1); border-bottom: 1px solid var(--c-border); padding: var(--sp-xl) var(--gutter) var(--sp-lg); }
.shop-header-inner { max-width: var(--max-w); margin: 0 auto; }
.shop-header-eyebrow { font-size: 9px; letter-spacing: 0.32em; color: var(--c-red-bright); text-transform: uppercase; font-weight: 700; margin-bottom: 12px; display: flex; align-items: center; gap: 10px; }
.shop-header-eyebrow::before { content:''; display:block; width:24px; height:2px; background:var(--c-red-bright); }
.shop-header-title { font-family: var(--font-display); font-size: clamp(36px, 5vw, 64px); font-weight: 900; line-height: 1; text-transform: uppercase; margin-bottom: var(--sp-sm); }
.shop-header-sub { font-size: 13px; color: var(--c-text-muted); }
.filter-bar { border-bottom: 1px solid var(--c-border); background: var(--c-bg); position: sticky; top: var(--header-h); z-index: 100; }
.filter-inner { max-width: var(--max-w); margin: 0 auto; padding: 0 var(--gutter); display: flex; align-items: center; gap: 0; overflow-x: auto; scrollbar-width: none; }
.filter-inner::-webkit-scrollbar { display: none; }
.filter-btn { flex-shrink: 0; padding: 14px 24px; font-size: 10px; letter-spacing: 0.18em; font-weight: 700; text-transform: uppercase; color: var(--c-text-muted); border-bottom: 2px solid transparent; transition: all var(--dur-fast) var(--ease-out); background: none; cursor: pointer; white-space: nowrap; }
.filter-btn:hover { color: var(--c-text); }
.filter-btn.active { color: var(--c-text); border-bottom-color: var(--c-red); }
.filter-count { display: inline-block; background: var(--c-surface-2); padding: 2px 6px; font-size: 8px; font-weight: 900; margin-left: 6px; color: var(--c-text-muted); }
.filter-btn.active .filter-count { background: var(--c-red); color: #fff; }
.shop-grid-wrap { max-width: var(--max-w); margin: 0 auto; padding: var(--sp-lg) var(--gutter) var(--sp-2xl); }
.shop-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 3px; }
.shop-product-card { background: var(--c-card); position: relative; cursor: pointer; display: flex; flex-direction: column; transition: transform var(--dur-med) var(--ease-out); text-decoration: none; color: var(--c-card-text); }
.shop-product-card:hover { z-index: 2; }
.card-img-wrap { aspect-ratio: 4/5; overflow: hidden; position: relative; background: var(--c-card); }
.card-img-wrap img { width: 100%; height: 100%; object-fit: cover; transition: transform var(--dur-slow) var(--ease-out); }
.card-img-wrap.freigestellt { background: var(--c-card); }
.card-img-wrap.freigestellt img { object-fit: contain; padding: 20px 24px 0; transform-origin: bottom center; }
.shop-product-card:hover .card-img-wrap img { transform: scale(1.04); }
.card-badge-shop { position: absolute; top: 16px; left: 0; background: var(--c-red); color: #fff; font-size: 8px; font-weight: 900; letter-spacing: 0.2em; text-transform: uppercase; padding: 5px 12px; z-index: 1; }
.card-body-shop { padding: 18px 20px 20px; border-top: 1px solid rgba(17,17,17,0.1); display: flex; flex-direction: column; gap: 6px; flex: 1; }
.card-category-shop { font-size: 8px; letter-spacing: 0.22em; color: #777; text-transform: uppercase; font-weight: 700; }
.card-name-shop { font-family: var(--font-display); font-size: 15px; font-weight: 900; line-height: 1.15; text-transform: uppercase; color: #111; }
.card-footer-shop { display: flex; align-items: center; justify-content: space-between; margin-top: auto; padding-top: 12px; }
.card-price-shop { font-family: var(--font-display); font-size: 18px; font-weight: 900; color: #111; }
.card-cta-shop { font-size: 8px; letter-spacing: 0.2em; font-weight: 900; text-transform: uppercase; color: var(--c-red); display: flex; align-items: center; gap: 6px; }
.card-cta-shop::after { content: '→'; }
.card-digital-cover { aspect-ratio: 4/5; overflow: hidden; position: relative; background: #0a0a0a; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0; padding: 28px; }
.card-digital-cover-bg { position: absolute; inset: 0; background: repeating-linear-gradient(90deg, transparent, transparent 19px, rgba(255,255,255,0.025) 19px, rgba(255,255,255,0.025) 20px), repeating-linear-gradient(0deg, transparent, transparent 19px, rgba(255,255,255,0.025) 19px, rgba(255,255,255,0.025) 20px); }
.card-digital-eyebrow { font-size: 7px; letter-spacing: 0.35em; text-transform: uppercase; color: var(--c-red-bright); font-weight: 700; position: relative; z-index: 1; margin-bottom: 16px; }
.card-digital-title { font-family: var(--font-display); font-size: 22px; font-weight: 900; text-transform: uppercase; line-height: 0.95; text-align: center; position: relative; z-index: 1; color: #f0f0f0; }
.card-digital-title strong { color: var(--c-red-bright); display: block; }
.card-digital-stat { position: absolute; bottom: 14px; right: 14px; background: var(--c-red); padding: 8px 10px; text-align: center; z-index: 2; }
.card-digital-stat-num { font-family: var(--font-display); font-size: 13px; font-weight: 900; line-height: 1; display: block; color: #fff; }
.card-digital-stat-label { font-size: 6px; letter-spacing: 0.15em; text-transform: uppercase; color: rgba(255,255,255,0.7); margin-top: 2px; display: block; }
.shop-cta-band { background: var(--c-surface-1); border-top: 1px solid var(--c-border); border-bottom: 1px solid var(--c-border); padding: var(--sp-xl) var(--gutter); text-align: center; }
.shop-cta-band h2 { font-family: var(--font-display); font-size: clamp(22px, 3vw, 36px); font-weight: 900; text-transform: uppercase; margin-bottom: 12px; }
.shop-cta-band p { font-size: 13px; color: var(--c-text-muted); margin-bottom: var(--sp-md); }
@media (max-width: 1024px) { .shop-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 600px) { .shop-grid { grid-template-columns: 1fr; } }
</style>
+204
View File
@@ -0,0 +1,204 @@
---
import Base from '../layouts/Base.astro';
const main = `<!-- Hero -->
<div class="about-hero">
<div class="about-hero-inner">
<p class="about-eyebrow">Die Geschichte</p>
<h1 class="about-hero-heading">Heizungsbauer<br>aus Leidenschaft.</h1>
<p class="about-hero-sub">PMPNZNG steht für eine Branche, die ihren Spaß versteht — und nicht erklärt werden muss. Von Handwerkern, für Handwerker. Aus Ahlerstedt.</p>
</div>
</div>
<!-- Content -->
<div class="about-content">
<div class="about-section">
<p class="about-section-label">Warum das alles</p>
<h2 class="about-h2">Von der Werkzeugkiste zum Kleiderschrank.</h2>
<p class="about-text">
TGA Solutions GmbH ist ein Meisterfachbetrieb für Sanitär, Heizung &amp; Klima
aus Ahlerstedt — gegründet im Februar 2021 von Co-Geschäftsführer Marcel Trautwein
mit dem Anspruch, Handwerk neu zu denken. Innovation, Respekt, Nachhaltigkeit.
Und ein Team, das seinen Job nicht nur macht, sondern lebt.
</p>
<p class="about-text">
Was als Fachbetrieb anfing, wurde schnell zur Community — auf der Baustelle,
in der Werkstatt, auf Instagram. Irgendwann war klar: Die SHK-Welt braucht
Merch, der genauso ehrlich ist wie die Arbeit dahinter. Kein Werbeartikel,
kein Promo-Ding. Kleidung für Leute, die wissen, was eine Pumpenzange ist.
</p>
</div>
<div class="about-quote">
<p class="about-quote-text">„Pumpenzange kennt jeder. Aber wer trägt sie auch auf dem Rücken?"</p>
<p class="about-quote-author">TGA Solutions GmbH · Ahlerstedt</p>
</div>
<div class="about-section">
<p class="about-section-label">Was wir machen</p>
<h2 class="about-h2">Merch mit Haltung.</h2>
<p class="about-text">
Jedes Stück in der PMPNZNG-Kollektion ist aus Bio-Baumwolle, Fairtrade-zertifiziert
und in Europa produziert. Kein Greenwashing, kein Marketingbla.
Wir bestellen das selbst, wir tragen das selbst.
</p>
<div class="stats-row">
<div class="stat"><span class="stat-num">100%</span><span class="stat-label">Bio-Baumwolle</span></div>
<div class="stat"><span class="stat-num">6</span><span class="stat-label">Produkte</span></div>
<div class="stat"><span class="stat-num">EST 2021</span><span class="stat-label">Ahlerstedt, DE</span></div>
</div>
</div>
<div class="about-section">
<p class="about-section-label">Unsere Haltung</p>
<h2 class="about-h2">Was uns antreibt.</h2>
<div class="values-grid">
<div class="value-card">
<div class="value-num">01</div>
<div class="value-title">Handwerk hat Wert</div>
<p class="value-desc">SHK ist keine Restgröße des Arbeitsmarkts. Es ist das Fundament, auf dem alles andere steht. Das verdient Sichtbarkeit.</p>
</div>
<div class="value-card">
<div class="value-num">02</div>
<div class="value-title">Ehrlichkeit first</div>
<p class="value-desc">Wir erzählen keine Geschichten, die wir selbst nicht glauben. Bio-Baumwolle, weil wir es so wollen — nicht weil es sich besser verkauft.</p>
</div>
<div class="value-card">
<div class="value-num">03</div>
<div class="value-title">Community über Reichweite</div>
<p class="value-desc">Lieber 500 Leute, die es wirklich verstehen, als 50.000 Follower, die einfach scrollen. Die SHK-Community ist echt.</p>
</div>
<div class="value-card">
<div class="value-num">04</div>
<div class="value-title">Spaß erlaubt</div>
<p class="value-desc">Ein Warmduscher-Beutel ist kein Witz — er ist ein Augenzwinkern. Handwerk darf Charakter haben.</p>
</div>
</div>
</div>
<!-- CTA -->
<div class="about-cta">
<h2>Jetzt die Kollektion entdecken.</h2>
<p>Alle Produkte in Bio-Baumwolle, Fairtrade-zertifiziert. Versand innerhalb von 24 Werktagen.</p>
<div class="about-cta-btns">
<a href="/shop" class="btn btn-primary">Zum Shop</a>
<a href="https://www.instagram.com/tgasolutions" class="btn btn-outline" rel="noopener noreferrer" target="_blank">@tgasolutions auf Instagram</a>
</div>
</div>
</div>`;
---
<Base title={"Über uns — PMPNZNG"} page="about" active="ueber-uns">
<Fragment set:html={main} />
</Base>
<style is:global>
.about-hero {
background: var(--c-surface-1);
border-bottom: 1px solid var(--c-border);
padding: var(--sp-xl) var(--gutter);
position: relative; overflow: hidden;
}
.about-hero::before {
content: 'PMPNZNG';
position: absolute; right: -20px; top: 50%; transform: translateY(-50%);
font-family: var(--font-display); font-size: clamp(100px, 16vw, 200px);
font-weight: 900; color: var(--c-surface-2); line-height: 1;
pointer-events: none; letter-spacing: -0.02em;
}
.about-hero-inner { max-width: var(--max-w); margin: 0 auto; position: relative; z-index: 1; max-width: 720px; }
.about-eyebrow { font-size: 9px; letter-spacing: 0.32em; color: var(--c-red-bright); text-transform: uppercase; font-weight: 700; margin-bottom: 12px; display: flex; align-items: center; gap: 10px; }
.about-eyebrow::before { content:''; display:block; width:24px; height:2px; background:var(--c-red-bright); }
.about-hero-heading {
font-family: var(--font-display);
font-size: clamp(36px, 6vw, 72px);
font-weight: 900; line-height: 0.95; text-transform: uppercase;
margin-bottom: var(--sp-sm);
}
.about-hero-sub { font-size: 15px; color: var(--c-text-muted); line-height: 1.75; max-width: 600px; }
.about-content {
max-width: 760px; margin: 0 auto;
padding: var(--sp-xl) var(--gutter) var(--sp-2xl);
}
.about-section { margin-bottom: var(--sp-xl); }
.about-section-label {
font-size: 9px; letter-spacing: 0.3em; text-transform: uppercase;
color: var(--c-red-bright); font-weight: 700; margin-bottom: 16px;
display: flex; align-items: center; gap: 10px;
}
.about-section-label::after { content:''; flex:1; height:1px; background:var(--c-border); }
.about-h2 {
font-family: var(--font-display);
font-size: clamp(22px, 3vw, 32px);
font-weight: 900; text-transform: uppercase;
margin-bottom: var(--sp-sm); line-height: 1.05;
}
.about-text { font-size: 15px; color: var(--c-text-muted); line-height: 1.8; }
.about-text + .about-text { margin-top: 16px; }
/* Quote block */
.about-quote {
border-left: 4px solid var(--c-red);
padding: var(--sp-md);
background: var(--c-surface-1);
margin: var(--sp-md) 0;
}
.about-quote-text {
font-family: var(--font-display);
font-size: clamp(18px, 2.5vw, 24px);
font-weight: 900; text-transform: uppercase; line-height: 1.2;
margin-bottom: 10px;
}
.about-quote-author { font-size: 11px; color: var(--c-text-muted); letter-spacing: 0.14em; text-transform: uppercase; }
/* Values grid */
.values-grid {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 3px;
margin-top: var(--sp-md);
}
.value-card {
background: var(--c-surface-1); border: 1px solid var(--c-border);
padding: var(--sp-md);
}
.value-num {
font-family: var(--font-display); font-size: 40px; font-weight: 900;
color: var(--c-surface-3); line-height: 1; margin-bottom: 8px;
}
.value-title {
font-family: var(--font-display); font-size: 14px; font-weight: 900;
text-transform: uppercase; margin-bottom: 8px;
}
.value-desc { font-size: 12px; color: var(--c-text-muted); line-height: 1.65; }
/* Stats */
.stats-row {
display: grid; grid-template-columns: repeat(3, 1fr);
gap: 3px; margin: var(--sp-md) 0;
}
.stat {
background: var(--c-surface-1); border: 1px solid var(--c-border);
padding: var(--sp-md); text-align: center;
}
.stat-num {
font-family: var(--font-display); font-size: clamp(28px,4vw,44px);
font-weight: 900; color: var(--c-red-bright); display: block; line-height: 1;
}
.stat-label { font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--c-text-muted); margin-top: 6px; }
/* CTA */
.about-cta {
background: var(--c-surface-1); border: 1px solid var(--c-border);
padding: var(--sp-lg); text-align: center; margin-top: var(--sp-xl);
}
.about-cta h2 { font-family: var(--font-display); font-size: clamp(20px,2.5vw,28px); font-weight: 900; text-transform: uppercase; margin-bottom: 12px; }
.about-cta p { font-size: 13px; color: var(--c-text-muted); margin-bottom: var(--sp-md); }
.about-cta-btns { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
@media (max-width: 640px) {
.values-grid { grid-template-columns: 1fr; }
.stats-row { grid-template-columns: 1fr; }
}
</style>
+228
View File
@@ -0,0 +1,228 @@
---
import Base from '../layouts/Base.astro';
const main = `<div class="cart-page">
<h1 class="cart-page-heading">Warenkorb</h1>
<!-- Cart Items -->
<div class="cart-main">
<div id="cart-items" aria-live="polite" aria-label="Artikel im Warenkorb"></div>
<div id="cart-empty" role="status">
<h2>Dein Warenkorb ist leer.</h2>
<p>Noch nichts drin? Das lässt sich ändern.<br>Hier ist die Pumpenzange noch kein Klischee.</p>
<a href="/shop" class="btn btn-primary">Zum Shop</a>
</div>
<a href="/shop" class="continue-btn" aria-label="Zurück zum Shop">Weiter einkaufen</a>
</div>
<!-- Order Summary -->
<aside class="cart-summary" id="cart-summary" aria-label="Bestellübersicht">
<div class="summary-heading">Bestellübersicht</div>
<div class="summary-line">
<span>Zwischensumme</span>
<strong class="js-cart-subtotal">0,00 €</strong>
</div>
<div class="summary-line">
<span>Versand (DE)</span>
<span class="js-shipping-display">wird berechnet</span>
</div>
<div class="summary-divider"></div>
<div class="summary-total">
<span class="summary-total-label">Gesamt</span>
<span class="summary-total-price js-cart-total-display">0,00 €</span>
</div>
<p class="summary-note">Inkl. MwSt. · Versandkosten werden an der Kasse berechnet. Kostenloser Versand ab 50 €.</p>
<div class="promo-form" role="form" aria-label="Rabattcode einlösen">
<input type="text" class="promo-input" id="promo-input" placeholder="Rabattcode" aria-label="Rabattcode eingeben" autocomplete="off" spellcheck="false">
<button type="button" class="promo-btn" id="promo-btn">Einlösen</button>
</div>
<div class="summary-cta">
<a href="/checkout" class="btn btn-primary btn-full btn-lg" id="checkout-link">
Zur Kasse →
</a>
<button class="btn btn-outline btn-full" onclick="window.location='shop.html'">
Weiter einkaufen
</button>
</div>
<div class="cart-trust-row">
<span class="cart-trust-item">SSL-verschlüsselt · sicher bezahlen</span>
<span class="cart-trust-item">30 Tage Rückgaberecht</span>
<span class="cart-trust-item">Versand innerhalb von 24 Werktagen</span>
</div>
</aside>
</div>`;
---
<Base title={"Warenkorb — PMPNZNG"} page="cart" active="">
<Fragment set:html={main} />
</Base>
<style is:global>
.cart-page {
max-width: var(--max-w); margin: 0 auto;
padding: var(--sp-lg) var(--gutter) var(--sp-2xl);
display: grid;
grid-template-columns: 1fr 360px;
gap: clamp(32px, 4vw, 64px);
align-items: start;
}
.cart-page-heading {
font-family: var(--font-display);
font-size: clamp(28px, 4vw, 48px);
font-weight: 900; text-transform: uppercase; line-height: 1;
grid-column: 1 / -1;
padding-bottom: var(--sp-sm);
border-bottom: 1px solid var(--c-border);
}
/* Cart items */
#cart-items { display: flex; flex-direction: column; gap: 2px; }
.cart-item {
display: grid; grid-template-columns: 100px 1fr auto;
gap: 20px; align-items: center;
background: var(--c-surface-1);
padding: 16px;
border: 1px solid var(--c-border);
}
.cart-item-img { background: var(--c-card); aspect-ratio: 4/5; overflow: hidden; }
.cart-item-img img { width: 100%; height: 100%; object-fit: contain; padding: 4px; }
.cart-item-info { display: flex; flex-direction: column; gap: 6px; }
.cart-item-name {
font-family: var(--font-display); font-size: 14px; font-weight: 900;
text-transform: uppercase; line-height: 1.2;
}
.cart-item-size { font-size: 10px; letter-spacing: 0.14em; color: var(--c-text-muted); text-transform: uppercase; }
.cart-item-qty { display: flex; align-items: center; gap: 0; margin-top: 4px; }
.qty-btn {
width: 32px; height: 32px; background: var(--c-surface-2);
border: 1px solid var(--c-border-mid);
font-size: 14px; font-weight: 900; color: var(--c-text);
display: flex; align-items: center; justify-content: center;
transition: background 0.15s;
}
.qty-btn:hover { background: var(--c-surface-3); }
.qty-val {
width: 36px; text-align: center; font-size: 13px; font-weight: 700;
border-top: 1px solid var(--c-border-mid);
border-bottom: 1px solid var(--c-border-mid);
height: 32px; line-height: 30px;
background: var(--c-surface-1);
}
.cart-item-price {
display: flex; flex-direction: column; align-items: flex-end; gap: 8px;
}
.cart-item-price > span {
font-family: var(--font-display); font-size: 16px; font-weight: 900;
}
.cart-remove {
font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--c-text-dim); text-decoration: underline;
transition: color 0.15s;
}
.cart-remove:hover { color: var(--c-red-bright); }
#cart-empty {
display: none; text-align: center;
padding: var(--sp-2xl) var(--gutter);
border: 1px dashed var(--c-border-mid);
}
#cart-empty p { color: var(--c-text-muted); font-size: 14px; line-height: 1.7; margin-bottom: var(--sp-md); }
#cart-empty h2 { font-family: var(--font-display); font-size: clamp(20px,2.5vw,28px); font-weight: 900; text-transform: uppercase; margin-bottom: 12px; }
/* Order Summary */
.cart-summary {
background: var(--c-surface-1);
border: 1px solid var(--c-border);
padding: var(--sp-md);
position: sticky;
top: calc(var(--header-h) + 16px);
display: flex; flex-direction: column; gap: 16px;
}
.summary-heading {
font-family: var(--font-display); font-size: 12px; font-weight: 900;
letter-spacing: 0.22em; text-transform: uppercase;
padding-bottom: 12px; border-bottom: 1px solid var(--c-border);
}
.summary-line {
display: flex; align-items: center; justify-content: space-between;
font-size: 12px; color: var(--c-text-muted);
}
.summary-line strong { color: var(--c-text); }
.summary-free { color: #22c55e; font-weight: 700; font-size: 11px; }
.summary-divider { height: 1px; background: var(--c-border); }
.summary-total {
display: flex; align-items: baseline; justify-content: space-between;
}
.summary-total-label {
font-family: var(--font-display); font-size: 11px; letter-spacing: 0.18em;
text-transform: uppercase; font-weight: 900;
}
.summary-total-price {
font-family: var(--font-display); font-size: 24px; font-weight: 900;
}
.summary-note {
font-size: 10px; color: var(--c-text-muted); line-height: 1.6;
}
.summary-cta {
display: flex; flex-direction: column; gap: 8px;
}
/* Promo code */
.promo-form {
display: flex; gap: 0; border: 1px solid var(--c-border-mid);
}
.promo-input {
flex: 1; background: var(--c-surface-2); border: none;
padding: 10px 14px; font-size: 12px; letter-spacing: 0.1em;
color: var(--c-text); text-transform: uppercase;
}
.promo-input:focus { outline: none; }
.promo-input::placeholder { color: var(--c-text-dim); }
.promo-btn {
background: var(--c-surface-3); border: none;
padding: 10px 16px; font-size: 10px; font-weight: 900;
letter-spacing: 0.15em; text-transform: uppercase;
color: var(--c-text-muted); transition: all 0.15s;
}
.promo-btn:hover { background: var(--c-red); color: #fff; }
/* Trust mini */
.cart-trust-row {
display: flex; flex-direction: column; gap: 8px;
padding-top: var(--sp-sm);
border-top: 1px solid var(--c-border);
}
.cart-trust-item {
display: flex; align-items: center; gap: 8px;
font-size: 11px; color: var(--c-text-muted);
}
.cart-trust-item::before { content:'✓'; color: var(--c-red-bright); font-weight: 900; flex-shrink: 0; }
/* Continue shopping */
.continue-btn {
display: inline-flex; align-items: center; gap: 8px;
font-size: 10px; letter-spacing: 0.16em; text-transform: uppercase;
color: var(--c-text-muted); font-weight: 700;
margin-top: var(--sp-sm);
transition: color 0.15s;
}
.continue-btn::before { content:'←'; }
.continue-btn:hover { color: var(--c-text); }
@media (max-width: 900px) {
.cart-page { grid-template-columns: 1fr; }
.cart-summary { position: static; }
}
@media (max-width: 640px) {
.cart-item { grid-template-columns: 80px 1fr; }
.cart-item-price { grid-column: 1 / -1; flex-direction: row; justify-content: space-between; align-items: center; }
}
</style>
+99
View File
@@ -0,0 +1,99 @@
/* Shopify-inspiriertes Admin — hell, grau, grüne Akzente */
:root{
--s-bg:#f1f1f1; --s-surface:#ffffff; --s-sunken:#f6f6f7;
--s-border:#e1e3e5; --s-border-2:#d2d5d8;
--s-ink:#1a1a1a; --s-text:#303030; --s-subtle:#616161; --s-faint:#8a8a8a;
--s-green:#008060; --s-green-d:#004c3f; --s-green-l:#e3f1ed;
--s-amber:#ffd79d; --s-amber-t:#5e4200; --s-amber-bg:#fff5ea;
--s-red:#fee9e8; --s-red-t:#8e1f0b;
--s-blue:#ebf0ff; --s-blue-t:#1f3d7a;
--s-gray:#e3e3e3; --s-gray-t:#4a4a4a;
--s-radius:10px; --s-shadow:0 1px 0 rgba(0,0,0,.04),0 1px 3px rgba(0,0,0,.06);
--s-font:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
}
*{box-sizing:border-box}
.admin-body{margin:0;background:var(--s-bg);color:var(--s-text);font-family:var(--s-font);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased}
.admin-body a{color:inherit;text-decoration:none}
.admin-shell{display:grid;grid-template-columns:232px 1fr;min-height:100vh}
/* Sidebar */
.s-side{background:var(--s-surface);border-right:1px solid var(--s-border);display:flex;flex-direction:column;position:sticky;top:0;height:100vh}
.s-brand{display:flex;align-items:center;gap:10px;padding:16px 16px 14px;border-bottom:1px solid var(--s-border)}
.s-brand-logo{width:30px;height:30px;border-radius:7px;background:var(--s-green);color:#fff;display:grid;place-items:center;font-weight:800;font-size:13px;font-family:var(--s-font)}
.s-brand-name{font-weight:700;font-size:14px;color:var(--s-ink);line-height:1.1}
.s-brand-sub{font-size:11px;color:var(--s-faint)}
.s-nav{padding:10px 8px;display:flex;flex-direction:column;gap:2px;flex:1;overflow:auto}
.s-nav a{display:flex;align-items:center;gap:10px;padding:7px 10px;border-radius:8px;font-weight:500;font-size:13.5px;color:var(--s-text)}
.s-nav a svg{width:18px;height:18px;flex:none;color:var(--s-subtle)}
.s-nav a:hover{background:var(--s-sunken)}
.s-nav a.active{background:var(--s-green-l);color:var(--s-green-d);font-weight:600}
.s-nav a.active svg{color:var(--s-green-d)}
.s-nav-sec{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--s-faint);padding:14px 12px 6px;font-weight:700}
.s-side-foot{padding:12px 14px;border-top:1px solid var(--s-border);font-size:12px;color:var(--s-faint)}
.s-side-foot a{color:var(--s-green);font-weight:600}
/* Main */
.s-main{display:flex;flex-direction:column;min-width:0}
.s-topbar{position:sticky;top:0;z-index:10;background:var(--s-surface);border-bottom:1px solid var(--s-border);padding:14px 28px;display:flex;align-items:center;justify-content:space-between;gap:16px}
.s-crumbs{font-size:12px;color:var(--s-faint);margin-bottom:3px;display:flex;align-items:center;gap:6px}
.s-crumbs a{color:var(--s-subtle)}.s-crumbs a:hover{color:var(--s-green)}
.s-title{font-size:20px;font-weight:700;color:var(--s-ink);letter-spacing:-.01em}
.s-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.s-content{padding:24px 28px 60px;max-width:1100px;width:100%}
/* Buttons */
.s-btn{display:inline-flex;align-items:center;gap:7px;border-radius:8px;font-size:13px;font-weight:600;padding:8px 14px;cursor:pointer;border:1px solid var(--s-border-2);background:var(--s-surface);color:var(--s-text);transition:.12s}
.s-btn:hover{background:var(--s-sunken)}
.s-btn-primary{background:var(--s-green);border-color:var(--s-green);color:#fff;box-shadow:0 1px 0 rgba(0,0,0,.08)}
.s-btn-primary:hover{background:var(--s-green-d)}
.s-btn-danger{color:var(--s-red-t);border-color:#f0c6c0}.s-btn-danger:hover{background:var(--s-red)}
.s-btn-sm{padding:5px 10px;font-size:12px}
/* Cards */
.s-card{background:var(--s-surface);border:1px solid var(--s-border);border-radius:var(--s-radius);box-shadow:var(--s-shadow)}
.s-card-pad{padding:18px 20px}
.s-card-head{padding:14px 20px;border-bottom:1px solid var(--s-border);font-weight:700;color:var(--s-ink);font-size:14px;display:flex;justify-content:space-between;align-items:center}
.s-grid{display:grid;gap:16px}
.s-stack{display:flex;flex-direction:column;gap:16px}
/* KPI cards */
.s-kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:16px}
.s-kpi{background:var(--s-surface);border:1px solid var(--s-border);border-radius:var(--s-radius);padding:16px 18px;box-shadow:var(--s-shadow)}
.s-kpi-label{font-size:12px;color:var(--s-subtle);font-weight:600;display:flex;align-items:center;gap:7px}
.s-kpi-val{font-size:26px;font-weight:700;color:var(--s-ink);margin-top:8px;letter-spacing:-.02em}
.s-kpi-sub{font-size:12px;color:var(--s-faint);margin-top:2px}
.s-kpi-trend{font-size:12px;font-weight:600;color:var(--s-green)}
/* Tables */
.s-table-wrap{overflow:auto}
.s-table{width:100%;border-collapse:collapse;font-size:13.5px}
.s-table th{text-align:left;padding:11px 16px;font-size:12px;color:var(--s-subtle);font-weight:600;background:var(--s-sunken);border-bottom:1px solid var(--s-border);white-space:nowrap}
.s-table td{padding:12px 16px;border-bottom:1px solid var(--s-border);vertical-align:middle}
.s-table tr:last-child td{border-bottom:none}
.s-table tbody tr{cursor:pointer}
.s-table tbody tr:hover{background:var(--s-sunken)}
.s-table .num{text-align:right;font-variant-numeric:tabular-nums}
.s-prodcell{display:flex;align-items:center;gap:12px}
.s-prodcell img{width:38px;height:46px;object-fit:cover;border-radius:6px;border:1px solid var(--s-border);background:#f0ede8}
.s-prodcell .nm{font-weight:600;color:var(--s-ink)}
.s-muted{color:var(--s-faint)}
.s-link{color:var(--s-green);font-weight:600}
/* Badges */
.s-badge{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:999px;font-size:12px;font-weight:600;line-height:1.4}
.s-badge::before{content:'';width:7px;height:7px;border-radius:50%;background:currentColor;opacity:.85}
.s-badge.green{background:var(--s-green-l);color:var(--s-green-d)}
.s-badge.amber{background:var(--s-amber-bg);color:var(--s-amber-t)}
.s-badge.gray{background:var(--s-gray);color:var(--s-gray-t)}
.s-badge.red{background:var(--s-red);color:var(--s-red-t)}
.s-badge.blue{background:var(--s-blue);color:var(--s-blue-t)}
/* Forms */
.s-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.s-field{display:flex;flex-direction:column;gap:6px;margin-bottom:16px}
.s-field.full{grid-column:1/-1}
.s-label{font-size:13px;font-weight:600;color:var(--s-text)}
.s-input,.s-textarea,.s-select{border:1px solid var(--s-border-2);border-radius:8px;padding:9px 12px;font:inherit;font-size:14px;background:var(--s-surface);color:var(--s-ink);width:100%}
.s-input:focus,.s-textarea:focus,.s-select:focus{outline:none;border-color:var(--s-green);box-shadow:0 0 0 2px var(--s-green-l)}
.s-help{font-size:12px;color:var(--s-faint)}
.s-check{display:flex;align-items:center;gap:8px;font-size:14px}
.s-check input{width:16px;height:16px;accent-color:var(--s-green)}
.s-two-col{display:grid;grid-template-columns:1fr 320px;gap:16px;align-items:start}
/* misc */
.s-flash{background:var(--s-green-l);color:var(--s-green-d);border:1px solid #b9ddd2;padding:10px 16px;border-radius:8px;font-weight:600;margin-bottom:16px;display:flex;align-items:center;gap:8px}
.s-empty{padding:40px;text-align:center;color:var(--s-faint)}
.s-section-title{font-size:13px;font-weight:700;color:var(--s-ink);margin:0 0 4px}
.s-dot-row{display:flex;gap:20px;flex-wrap:wrap}
@media(max-width:860px){.admin-shell{grid-template-columns:1fr}.s-side{position:static;height:auto;flex-direction:column}.s-nav{flex-direction:row;flex-wrap:wrap}.s-kpis{grid-template-columns:1fr 1fr}.s-form-grid{grid-template-columns:1fr}.s-two-col{grid-template-columns:1fr}}
+969
View File
@@ -0,0 +1,969 @@
/* ============================================================
PMPNZNG SHOP — Design System
Tokens → Reset → Layout → Components → Utilities
============================================================ */
/* ============================================================
1. DESIGN TOKENS
============================================================ */
:root {
/* Brand */
--c-red: #c41432;
--c-red-bright: #e01c2e;
--c-red-dark: #8c0e22;
--c-red-muted: rgba(196, 20, 50, 0.10);
/* Backgrounds */
--c-bg: #0d0d0d;
--c-surface-1: #141414;
--c-surface-2: #1c1c1c;
--c-surface-3: #252525;
/* Borders */
--c-border: rgba(255,255,255,0.10);
--c-border-mid: rgba(255,255,255,0.18);
--c-border-str: rgba(255,255,255,0.30);
/* Product cards (light) */
--c-card: #f0ede8;
--c-card-dark: #e5e1db;
--c-card-text: #111111;
/* Text */
--c-text: #f4f2ef;
--c-text-muted: #909090; /* vorher #6a6a6a — zu wenig Kontrast auf dunklem BG */
--c-text-dim: #555555; /* vorher #3a3a3a — zu wenig Kontrast auf dunklem BG */
/* Typography */
--font-display: 'Arial Black', 'Arial Bold', Gadget, sans-serif;
--font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
/* Spacing */
--sp-xs: 8px;
--sp-sm: 16px;
--sp-md: 32px;
--sp-lg: 56px;
--sp-xl: 96px;
--sp-2xl: 140px;
/* Layout */
--max-w: 1320px;
--gutter: clamp(20px, 4vw, 52px);
--header-h: 64px;
/* Motion */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in: cubic-bezier(0.7, 0, 0.84, 0);
--dur-fast: 0.18s;
--dur-med: 0.28s;
--dur-slow: 0.48s;
}
/* ============================================================
2. RESET & BASE
============================================================ */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html {
scroll-behavior: smooth;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
font-size: 16px;
}
body {
background: var(--c-bg);
color: var(--c-text);
font-family: var(--font-body);
line-height: 1.55;
overflow-x: hidden;
min-height: 100vh;
}
img { display: block; max-width: 100%; }
a { color: inherit; text-decoration: none; }
button { cursor: pointer; border: none; background: none; font: inherit; }
input, select, textarea { font: inherit; }
ul, ol { list-style: none; }
:focus-visible {
outline: 2px solid var(--c-red-bright);
outline-offset: 3px;
}
/* Skip link */
.skip-link {
position: absolute; top: -100%; left: var(--gutter);
background: var(--c-red); color: #fff;
padding: 10px 20px; font-size: 13px; font-weight: 700;
letter-spacing: .1em; text-transform: uppercase; z-index: 999;
transition: top 0.2s;
}
.skip-link:focus { top: 8px; }
/* ============================================================
3. PASSWORD OVERLAY
============================================================ */
#pw-overlay {
position: fixed; inset: 0; z-index: 9999;
background: var(--c-bg);
display: flex; align-items: center; justify-content: center;
}
.pw-box {
text-align: center; max-width: 400px; width: 100%; padding: var(--sp-xl) var(--sp-lg);
}
.pw-logo {
font-family: var(--font-display);
font-size: 28px; font-weight: 900; letter-spacing: 0.22em;
color: var(--c-text); margin-bottom: 6px;
}
.pw-logo strong { color: var(--c-red-bright); }
.pw-sub {
font-size: 9px; letter-spacing: 0.28em; color: var(--c-text-muted);
text-transform: uppercase; margin-bottom: var(--sp-md);
}
.pw-desc {
font-size: 13px; color: var(--c-text-muted); line-height: 1.7;
margin-bottom: var(--sp-md);
}
.pw-form { display: flex; flex-direction: column; gap: 10px; }
.pw-input {
background: var(--c-surface-1);
border: 1px solid var(--c-border-mid);
color: var(--c-text); padding: 14px 20px; font-size: 14px;
text-align: center; letter-spacing: 0.1em;
transition: border-color 0.2s;
}
.pw-input:focus { outline: none; border-color: var(--c-red); }
.pw-btn {
background: var(--c-red); color: #fff;
padding: 14px 32px; font-size: 11px; font-weight: 900;
letter-spacing: 0.2em; text-transform: uppercase;
font-family: var(--font-display);
transition: background 0.2s;
}
.pw-btn:hover { background: var(--c-red-bright); }
.pw-error { color: var(--c-red-bright); font-size: 12px; margin-top: 8px; min-height: 18px; }
@keyframes pw-shake {
0%,100% { transform: translateX(0); }
20%,60% { transform: translateX(-8px); }
40%,80% { transform: translateX(8px); }
}
.pw-shake { animation: pw-shake 0.5s ease; }
/* ============================================================
4. HEADER
============================================================ */
.site-header {
position: sticky; top: 0; z-index: 200;
height: var(--header-h);
background: rgba(13,13,13,0.85);
backdrop-filter: blur(16px) saturate(1.4);
-webkit-backdrop-filter: blur(16px) saturate(1.4);
border-bottom: 1px solid var(--c-border);
display: flex; align-items: center;
padding: 0 var(--gutter);
gap: var(--sp-md);
}
.header-logo {
flex-shrink: 0;
display: flex; flex-direction: column; gap: 2px;
text-decoration: none;
}
.logo-mark {
font-family: var(--font-display);
font-size: 20px; font-weight: 900; letter-spacing: 0.22em;
color: var(--c-text); line-height: 1;
}
.logo-mark strong { color: var(--c-red-bright); }
.logo-sub {
font-size: 7.5px; letter-spacing: 0.28em; color: var(--c-text-muted);
text-transform: uppercase;
}
.header-nav {
flex: 1; display: flex; align-items: center; justify-content: center;
gap: clamp(16px, 2.5vw, 36px);
}
.header-nav a {
font-size: 10.5px; letter-spacing: 0.15em; color: var(--c-text-muted);
text-transform: uppercase; font-weight: 600;
transition: color var(--dur-fast) var(--ease-out);
}
.header-nav a:hover,
.header-nav a[aria-current] { color: var(--c-text); }
.header-actions {
flex-shrink: 0; display: flex; align-items: center; gap: 12px;
}
.cart-btn {
display: inline-flex; align-items: center; gap: 8px;
border: 1px solid var(--c-border-mid); padding: 8px 16px;
font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase;
font-weight: 700; color: var(--c-text-muted);
transition: all var(--dur-fast) var(--ease-out);
text-decoration: none;
}
.cart-btn:hover { border-color: var(--c-red); color: var(--c-red); }
.js-cart-count {
display: inline-flex; align-items: center; justify-content: center;
width: 18px; height: 18px; background: var(--c-red);
color: #fff; font-size: 9px; font-weight: 900; border-radius: 50%;
}
/* Mobile menu */
.menu-toggle {
display: none; flex-direction: column; gap: 5px;
width: 28px; padding: 4px;
}
.menu-toggle span {
display: block; height: 2px; background: var(--c-text);
transition: all 0.2s;
}
/* ============================================================
5. MARQUEE
============================================================ */
.marquee-band {
overflow: hidden; background: var(--c-red);
border-top: 1px solid var(--c-red-dark);
border-bottom: 1px solid var(--c-red-dark);
padding: 11px 0; user-select: none;
}
.marquee-inner {
display: flex; white-space: nowrap; width: max-content;
animation: marquee-scroll 30s linear infinite;
}
.marquee-item {
display: inline-flex; align-items: center; gap: 24px; padding: 0 32px;
font-family: var(--font-display); font-size: 10px; font-weight: 900;
letter-spacing: 0.22em; text-transform: uppercase; color: rgba(255,255,255,0.9);
}
.marquee-sep {
display: inline-block; width: 5px; height: 5px;
background: rgba(255,255,255,0.4); transform: rotate(45deg); flex-shrink: 0;
}
@keyframes marquee-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
@media (prefers-reduced-motion: reduce) { .marquee-inner { animation: none; } }
/* ============================================================
6. BUTTONS
============================================================ */
.btn {
display: inline-flex; align-items: center; gap: 8px;
font-size: 11px; font-weight: 900; letter-spacing: 0.16em;
text-transform: uppercase; padding: 14px 32px;
transition: all var(--dur-med) var(--ease-out);
font-family: var(--font-display);
}
.btn-primary {
background: var(--c-red); color: #fff; border: none;
}
.btn-primary:hover { background: var(--c-red-bright); transform: translateY(-1px); }
.btn-primary:disabled {
background: var(--c-surface-3); color: var(--c-text-dim); cursor: not-allowed; transform: none;
}
.btn-outline {
background: transparent; color: var(--c-text-muted);
border: 1px solid var(--c-border-mid);
}
.btn-outline:hover { border-color: var(--c-text-muted); color: var(--c-text); }
.btn-full { width: 100%; justify-content: center; }
.btn-lg { padding: 18px 40px; font-size: 12px; }
/* ============================================================
7. PRODUCT CARDS
============================================================ */
.product-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 3px;
}
.product-card {
background: var(--c-card);
position: relative; cursor: pointer;
display: flex; flex-direction: column;
transition: transform var(--dur-med) var(--ease-out);
}
.product-card:hover { z-index: 2; }
.card-image-wrap {
aspect-ratio: 4/5; overflow: hidden;
position: relative; background: var(--c-card);
}
.card-image-wrap img {
width: 100%; height: 100%;
object-fit: cover;
transform-origin: center;
transition: transform var(--dur-slow) var(--ease-out);
}
.card-image-wrap.freigestellt img {
object-fit: contain; padding: 16px 20px 0;
transform-origin: bottom center;
}
.product-card:hover .card-image-wrap img {
transform: scale(1.04) translateY(-4px);
}
.card-overlay {
position: absolute; bottom: 0; left: 0; right: 0;
background: var(--c-red); color: #fff;
padding: 13px; text-align: center;
font-size: 10px; font-weight: 900; letter-spacing: 0.2em;
text-transform: uppercase;
transform: translateY(100%);
transition: transform var(--dur-med) var(--ease-out);
}
.product-card:hover .card-overlay { transform: translateY(0); }
.card-badge {
position: absolute; top: 12px; left: 12px; z-index: 1;
background: var(--c-bg); color: var(--c-red-bright);
padding: 4px 9px; font-size: 8px; font-weight: 900;
letter-spacing: 0.2em; text-transform: uppercase;
}
.card-body {
padding: 18px 20px 20px; flex: 1;
display: flex; flex-direction: column; gap: 4px;
}
.card-cat { font-size: 9px; letter-spacing: 0.22em; text-transform: uppercase; color: var(--c-text-dim); font-weight: 600; }
.card-name { font-size: 15px; font-weight: 700; color: var(--c-card-text); line-height: 1.25; }
.card-desc { font-size: 11.5px; color: #5a5a5a; line-height: 1.55; flex: 1; }
.card-footer {
display: flex; justify-content: space-between; align-items: center;
margin-top: 12px; padding-top: 12px;
border-top: 1px solid rgba(0,0,0,0.08);
}
.card-price {
font-family: var(--font-display);
font-size: 19px; font-weight: 900; color: var(--c-card-text);
}
.card-sizes { font-size: 9px; color: #888; letter-spacing: 0.1em; text-transform: uppercase; }
/* ============================================================
8. SECTION HELPERS
============================================================ */
.section-container { max-width: var(--max-w); margin: 0 auto; padding: 0 var(--gutter); }
.section-wrap { padding: var(--sp-xl) 0; }
.section-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: var(--sp-lg); padding-bottom: var(--sp-sm);
border-bottom: 1px solid var(--c-border);
}
.section-label { display: flex; align-items: center; gap: 14px; }
.section-label-line { width: 24px; height: 3px; background: var(--c-red); flex-shrink: 0; }
.section-label h2, .section-label h3 {
font-size: 10px; font-weight: 700; letter-spacing: 0.32em;
text-transform: uppercase; color: var(--c-text-muted);
}
.section-meta { font-size: 10px; letter-spacing: 0.14em; color: var(--c-text-dim); }
/* Divider */
.section-divider {
border: none; border-top: 1px solid var(--c-border);
margin: 0;
}
/* ============================================================
9. TRUST BAR
============================================================ */
.trust-bar {
background: var(--c-surface-1); border-bottom: 1px solid var(--c-border);
padding: 18px var(--gutter);
display: flex; gap: clamp(24px, 5vw, 56px); flex-wrap: nowrap; overflow-x: auto;
scrollbar-width: none; justify-content: center;
}
.trust-bar::-webkit-scrollbar { display: none; }
.trust-item-bar {
display: flex; align-items: center; gap: 9px;
font-size: 11px; color: var(--c-text-muted); flex-shrink: 0;
}
.trust-icon { color: var(--c-red-bright); font-size: 14px; }
/* ============================================================
10. SPECS ROW
============================================================ */
.specs-row {
background: var(--c-surface-1); border-bottom: 1px solid var(--c-border);
padding: 20px var(--gutter);
display: flex; gap: clamp(32px, 5vw, 64px); overflow-x: auto;
scrollbar-width: none;
}
.specs-row::-webkit-scrollbar { display: none; }
.spec-item { flex-shrink: 0; }
.spec-item dt { font-size: 9px; letter-spacing: 0.24em; color: var(--c-text-dim); text-transform: uppercase; margin-bottom: 4px; }
.spec-item dd { font-size: 13px; font-weight: 700; color: var(--c-text); }
.spec-item dd.accent { color: var(--c-red-bright); }
/* ============================================================
11. FOOTER
============================================================ */
.site-footer {
background: var(--c-surface-1); border-top: 1px solid var(--c-border);
padding: var(--sp-xl) var(--gutter) var(--sp-md);
}
.footer-grid {
max-width: var(--max-w); margin: 0 auto;
display: grid; grid-template-columns: 2fr 1fr 1fr 1fr;
gap: clamp(24px, 5vw, 56px);
padding-bottom: var(--sp-lg); border-bottom: 1px solid var(--c-border);
}
.footer-brand-logo {
font-family: var(--font-display); font-size: 18px; font-weight: 900;
letter-spacing: 0.2em; color: var(--c-text); margin-bottom: 14px;
display: block;
}
.footer-brand-logo strong { color: var(--c-red-bright); }
.footer-brand p { font-size: 12px; color: var(--c-text-muted); line-height: 1.7; max-width: 280px; }
.footer-badge {
display: inline-flex; align-items: center; gap: 7px;
margin-top: 14px; border: 1px solid var(--c-border-mid);
padding: 6px 12px; font-size: 9px; letter-spacing: 0.18em;
text-transform: uppercase; color: var(--c-text-muted);
}
.footer-col h4 {
font-size: 9px; letter-spacing: 0.26em; color: var(--c-text-muted);
text-transform: uppercase; font-weight: 700; margin-bottom: var(--sp-sm);
}
.footer-col a {
display: block; font-size: 12px; color: var(--c-text-muted);
margin-bottom: 10px; letter-spacing: 0.02em;
transition: color var(--dur-fast);
}
.footer-col a:hover { color: var(--c-text); }
.footer-bar {
max-width: var(--max-w); margin: var(--sp-md) auto 0;
display: flex; justify-content: space-between; align-items: center;
flex-wrap: wrap; gap: 12px;
font-size: 10px; color: var(--c-text-dim); letter-spacing: 0.07em;
}
.footer-bar a { color: var(--c-text-dim); transition: color var(--dur-fast); }
.footer-bar a:hover { color: var(--c-text); }
/* ============================================================
12. TOAST NOTIFICATIONS
============================================================ */
#toast-container {
position: fixed; bottom: var(--sp-md); right: var(--sp-md);
z-index: 8000; display: flex; flex-direction: column; gap: 8px;
pointer-events: none;
}
.toast {
display: flex; align-items: center; gap: 10px;
background: var(--c-surface-2); border: 1px solid var(--c-border-mid);
border-left: 3px solid var(--c-red);
padding: 12px 18px; max-width: 340px;
font-size: 12px; color: var(--c-text); letter-spacing: 0.03em;
opacity: 0; transform: translateX(20px);
transition: all var(--dur-med) var(--ease-out);
pointer-events: auto;
}
.toast-visible { opacity: 1; transform: translateX(0); }
.toast-icon { color: var(--c-red-bright); font-weight: 900; font-size: 14px; flex-shrink: 0; }
.toast-info { border-left-color: var(--c-text-muted); }
.toast-warning { border-left-color: #e0a020; }
/* ============================================================
13. EXIT INTENT POPUP
============================================================ */
#exit-popup {
position: fixed; inset: 0; z-index: 7000;
display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none;
transition: opacity var(--dur-med);
}
#exit-popup.exit-visible { opacity: 1; pointer-events: auto; }
.exit-backdrop {
position: absolute; inset: 0;
background: rgba(0,0,0,0.8);
backdrop-filter: blur(4px);
}
.exit-box {
position: relative; background: var(--c-surface-1);
border: 1px solid var(--c-border-mid);
max-width: 460px; width: 90%; padding: 48px 40px;
text-align: center;
transform: translateY(20px);
transition: transform var(--dur-med) var(--ease-out);
}
#exit-popup.exit-visible .exit-box { transform: translateY(0); }
.exit-close {
position: absolute; top: 16px; right: 16px;
font-size: 16px; color: var(--c-text-muted);
transition: color var(--dur-fast);
}
.exit-close:hover { color: var(--c-text); }
.exit-eyebrow { font-size: 9px; letter-spacing: 0.28em; color: var(--c-red-bright); text-transform: uppercase; font-weight: 700; margin-bottom: 12px; }
.exit-headline {
font-family: var(--font-display); font-size: clamp(26px, 5vw, 38px);
font-weight: 900; text-transform: uppercase; line-height: 1.1; margin-bottom: 14px;
}
.exit-sub { font-size: 13px; color: var(--c-text-muted); line-height: 1.6; margin-bottom: 24px; }
.exit-form { display: flex; gap: 0; }
.exit-input {
flex: 1; background: var(--c-bg); border: 1px solid var(--c-border-mid);
border-right: none; color: var(--c-text); padding: 12px 16px; font-size: 13px;
outline: none;
}
.exit-input:focus { border-color: var(--c-red); }
.exit-submit {
background: var(--c-red); color: #fff; border: none;
padding: 12px 20px; font-size: 10px; font-weight: 900;
letter-spacing: 0.18em; text-transform: uppercase;
font-family: var(--font-display);
transition: background var(--dur-fast);
}
.exit-submit:hover { background: var(--c-red-bright); }
.exit-legal { font-size: 10px; color: var(--c-text-dim); margin-top: 10px; }
.exit-skip { color: var(--c-text-dim); font-size: 11px; margin-top: 16px; text-decoration: underline; text-underline-offset: 3px; }
.exit-skip:hover { color: var(--c-text-muted); }
.exit-success { padding: 16px; font-size: 14px; color: var(--c-text); }
/* ============================================================
14. CART PAGE
============================================================ */
.cart-page { max-width: var(--max-w); margin: 0 auto; padding: var(--sp-xl) var(--gutter); }
.cart-layout { display: grid; grid-template-columns: 1fr 360px; gap: var(--sp-xl); align-items: start; }
.cart-items-list { display: flex; flex-direction: column; gap: 2px; }
.cart-item {
display: grid; grid-template-columns: 96px 1fr auto;
gap: var(--sp-sm); background: var(--c-surface-1);
padding: var(--sp-sm); border: 1px solid var(--c-border); align-items: start;
}
.cart-item-img { background: var(--c-card); aspect-ratio: 4/5; overflow: hidden; }
.cart-item-img img { width: 100%; height: 100%; object-fit: cover; }
.cart-item-info { display: flex; flex-direction: column; gap: 6px; padding: 4px 0; }
.cart-item-name { font-size: 14px; font-weight: 700; }
.cart-item-size { font-size: 11px; color: var(--c-text-muted); }
.cart-item-qty { display: flex; align-items: center; gap: 12px; margin-top: 8px; }
.qty-btn {
width: 28px; height: 28px; border: 1px solid var(--c-border-mid);
display: flex; align-items: center; justify-content: center;
font-size: 16px; color: var(--c-text-muted); transition: all var(--dur-fast);
}
.qty-btn:hover { border-color: var(--c-text); color: var(--c-text); }
.qty-val { font-size: 14px; font-weight: 700; min-width: 24px; text-align: center; }
.cart-item-price { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; padding: 4px 0; }
.cart-item-price span { font-size: 16px; font-weight: 700; }
.cart-remove { font-size: 10px; color: var(--c-text-dim); letter-spacing: 0.1em; text-decoration: underline; transition: color var(--dur-fast); }
.cart-remove:hover { color: var(--c-red); }
.cart-summary {
background: var(--c-surface-1); border: 1px solid var(--c-border);
padding: var(--sp-md); position: sticky; top: calc(var(--header-h) + 20px);
}
.cart-summary-title { font-size: 11px; letter-spacing: 0.24em; text-transform: uppercase; font-weight: 700; color: var(--c-text-muted); margin-bottom: var(--sp-sm); }
.cart-summary-row {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 0; border-bottom: 1px solid var(--c-border); font-size: 13px;
}
.cart-summary-row:last-of-type { border-bottom: none; }
.cart-summary-total { font-weight: 700; font-size: 16px; }
/* ============================================================
15. PRODUCT DETAIL PAGE
============================================================ */
.product-detail { max-width: var(--max-w); margin: 0 auto; padding: var(--sp-xl) var(--gutter); }
.product-layout { display: grid; grid-template-columns: 1fr 1fr; gap: clamp(40px, 6vw, 80px); }
.gallery-main-wrap { background: var(--c-card); aspect-ratio: 4/5; overflow: hidden; margin-bottom: 4px; }
#gallery-main { width: 100%; height: 100%; object-fit: cover; transition: opacity 0.2s; }
.gallery-thumbs { display: flex; gap: 4px; }
.gallery-thumb {
width: 80px; aspect-ratio: 1; background: var(--c-card-dark);
overflow: hidden; cursor: pointer; opacity: 0.6;
transition: opacity var(--dur-fast); flex-shrink: 0;
}
.gallery-thumb img { width: 100%; height: 100%; object-fit: cover; }
.gallery-thumb:hover,
.gallery-thumb.active { opacity: 1; outline: 2px solid var(--c-red); }
.product-info { display: flex; flex-direction: column; gap: var(--sp-sm); }
.product-info-cat { font-size: 9px; letter-spacing: 0.28em; color: var(--c-red-bright); text-transform: uppercase; font-weight: 700; }
.product-info-name { font-family: var(--font-display); font-size: clamp(22px, 3vw, 34px); font-weight: 900; text-transform: uppercase; line-height: 1.1; }
.product-info-price { font-family: var(--font-display); font-size: 32px; font-weight: 900; letter-spacing: -0.01em; }
.product-info-desc { font-size: 13.5px; color: var(--c-text-muted); line-height: 1.75; }
.size-label { font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--c-text-muted); margin-bottom: 10px; }
.size-label a { color: var(--c-text-dim); text-decoration: underline; text-underline-offset: 2px; font-size: 10px; }
.size-grid { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: var(--sp-sm); }
.size-btn {
border: 1px solid var(--c-border-mid); padding: 10px 16px;
font-size: 12px; font-weight: 700; color: var(--c-text-muted);
letter-spacing: 0.06em; transition: all var(--dur-fast);
min-width: 48px; text-align: center;
}
.size-btn:hover { border-color: var(--c-text); color: var(--c-text); }
.size-btn.active { background: var(--c-text); color: var(--c-bg); border-color: var(--c-text); }
.product-features { border-top: 1px solid var(--c-border); padding-top: var(--sp-sm); }
.product-features h4 { font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--c-text-dim); margin-bottom: 12px; }
.product-features li { font-size: 12.5px; color: var(--c-text-muted); padding: 6px 0; border-bottom: 1px solid var(--c-border); display: flex; align-items: center; gap: 10px; }
.product-features li::before { content: '—'; color: var(--c-red); font-weight: 700; }
.product-material { font-size: 11px; color: var(--c-text-dim); background: var(--c-surface-1); padding: 12px 14px; letter-spacing: 0.04em; }
/* ============================================================
16. PAGE HEADER
============================================================ */
.page-header {
background: var(--c-surface-1); border-bottom: 1px solid var(--c-border);
padding: var(--sp-lg) var(--gutter);
}
.page-header-inner { max-width: var(--max-w); margin: 0 auto; }
.page-header-eyebrow { font-size: 9px; letter-spacing: 0.28em; color: var(--c-red-bright); text-transform: uppercase; font-weight: 700; margin-bottom: 10px; }
.page-header h1 { font-family: var(--font-display); font-size: clamp(24px, 4vw, 44px); font-weight: 900; text-transform: uppercase; line-height: 1.05; }
.page-header p { font-size: 13px; color: var(--c-text-muted); margin-top: 12px; max-width: 560px; line-height: 1.7; }
/* Breadcrumb */
.breadcrumb { font-size: 10px; color: var(--c-text-dim); letter-spacing: 0.1em; margin-bottom: 10px; }
.breadcrumb a { color: var(--c-text-dim); transition: color var(--dur-fast); }
.breadcrumb a:hover { color: var(--c-text-muted); }
.breadcrumb span { margin: 0 6px; }
/* ============================================================
17. FAQ
============================================================ */
.faq-list { max-width: 720px; }
.faq-item { border-bottom: 1px solid var(--c-border); }
.faq-question {
width: 100%; text-align: left; padding: 18px 0;
font-size: 14px; font-weight: 700; color: var(--c-text);
display: flex; align-items: center; justify-content: space-between; gap: 16px;
transition: color var(--dur-fast);
}
.faq-question:hover { color: var(--c-red-bright); }
.faq-icon { color: var(--c-red); font-size: 20px; flex-shrink: 0; transition: transform var(--dur-fast); }
.faq-item.open .faq-icon { transform: rotate(45deg); }
.faq-answer { font-size: 13px; color: var(--c-text-muted); line-height: 1.75; padding-bottom: 18px; display: none; }
.faq-item.open .faq-answer { display: block; }
/* ============================================================
18. SIZE GUIDE TABLE
============================================================ */
.size-table { width: 100%; border-collapse: collapse; }
.size-table th, .size-table td {
padding: 12px 16px; text-align: center; font-size: 12px; border-bottom: 1px solid var(--c-border);
}
.size-table th { background: var(--c-surface-2); color: var(--c-text-muted); font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase; font-weight: 700; }
.size-table td { color: var(--c-text-muted); }
.size-table tr:last-child td { border-bottom: none; }
/* ============================================================
19. SUCCESS PAGE
============================================================ */
.success-page { max-width: 600px; margin: 0 auto; padding: var(--sp-2xl) var(--gutter); text-align: center; }
.success-icon { font-size: 52px; margin-bottom: var(--sp-md); }
.success-headline { font-family: var(--font-display); font-size: clamp(24px, 4vw, 40px); font-weight: 900; text-transform: uppercase; margin-bottom: var(--sp-sm); }
.success-sub { font-size: 14px; color: var(--c-text-muted); line-height: 1.75; margin-bottom: var(--sp-lg); }
.success-order-box { background: var(--c-surface-1); border: 1px solid var(--c-border); padding: var(--sp-md); text-align: left; margin-bottom: var(--sp-lg); }
.success-order-box h3 { font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase; color: var(--c-text-dim); margin-bottom: var(--sp-sm); }
.success-meta dt { font-size: 10px; color: var(--c-text-dim); letter-spacing: 0.12em; text-transform: uppercase; }
.success-meta dd { font-size: 13px; color: var(--c-text); margin-bottom: 10px; }
.success-next-steps { display: flex; flex-direction: column; gap: 8px; }
/* ============================================================
20. LEGAL PAGES
============================================================ */
.legal-content { max-width: 720px; margin: 0 auto; padding: var(--sp-xl) var(--gutter); }
.legal-content h2 { font-size: 14px; font-weight: 700; margin: var(--sp-md) 0 10px; letter-spacing: 0.04em; }
.legal-content h3 { font-size: 12px; font-weight: 700; margin: var(--sp-sm) 0 8px; color: var(--c-text-muted); }
.legal-content p { font-size: 13px; color: var(--c-text-muted); line-height: 1.75; margin-bottom: 14px; }
.legal-content a { color: var(--c-red-bright); text-decoration: underline; text-underline-offset: 2px; }
.legal-content ul { padding-left: 20px; margin-bottom: 14px; }
.legal-content li { font-size: 13px; color: var(--c-text-muted); line-height: 1.75; list-style: disc; }
/* ============================================================
21. UTILITY
============================================================ */
.visually-hidden {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0;
}
.text-red { color: var(--c-red-bright); }
.text-muted { color: var(--c-text-muted); }
.text-center { text-align: center; }
.mt-sm { margin-top: var(--sp-sm); }
.mt-md { margin-top: var(--sp-md); }
.gap-sm { gap: var(--sp-sm); }
/* ============================================================
22. SCROLLBAR
============================================================ */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--c-bg); }
::-webkit-scrollbar-thumb { background: var(--c-text-dim); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--c-text-muted); }
/* ============================================================
23. RESPONSIVE
============================================================ */
@media (max-width: 1024px) {
.product-grid { grid-template-columns: repeat(2, 1fr); }
.footer-grid { grid-template-columns: 1fr 1fr; gap: 32px; }
.cart-layout { grid-template-columns: 1fr; }
.cart-summary { position: static; }
.newsletter-inner { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
:root { --gutter: 20px; --header-h: 56px; }
.header-nav { display: none; position: fixed; top: var(--header-h); left: 0; right: 0; bottom: 0; background: var(--c-bg); flex-direction: column; justify-content: center; align-items: center; gap: 28px; z-index: 190; }
.header-nav.nav-open { display: flex; }
.header-nav a { font-size: 20px; letter-spacing: 0.2em; }
.menu-toggle { display: flex; }
.product-grid { grid-template-columns: 1fr 1fr; gap: 2px; }
.product-layout { grid-template-columns: 1fr; }
.trust-grid { grid-template-columns: 1fr 1fr; }
.footer-grid { grid-template-columns: 1fr; }
.hero { grid-template-columns: 1fr; min-height: auto; }
.hero-visual { display: none; }
.hero { padding: var(--sp-xl) var(--gutter); }
.exit-box { padding: 36px 24px; }
.exit-form { flex-direction: column; }
.exit-input { border-right: 1px solid var(--c-border-mid); border-bottom: none; }
}
@media (max-width: 480px) {
.product-grid { grid-template-columns: 1fr; }
.cart-item { grid-template-columns: 72px 1fr; }
.cart-item-price { display: none; }
}
/* ============================================================
27. STICKY MOBILE ADD-TO-CART
============================================================ */
#sticky-atc {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 300;
display: flex; align-items: center; gap: 12px;
padding: 12px var(--gutter) calc(12px + env(safe-area-inset-bottom));
background: var(--c-surface-1); border-top: 1px solid var(--c-border-mid);
transform: translateY(100%);
transition: transform 0.3s cubic-bezier(.4,0,.2,1);
box-shadow: 0 -4px 24px rgba(0,0,0,.4);
}
#sticky-atc.sticky-atc-visible { transform: translateY(0); }
.sticky-atc-name {
flex: 1; font-size: 12px; font-weight: 700; color: var(--c-text);
letter-spacing: 0.04em; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis;
}
.sticky-atc-btn {
flex-shrink: 0; background: var(--c-red); color: #fff; border: none;
padding: 12px 20px; font-size: 11px; font-weight: 900;
letter-spacing: 0.14em; text-transform: uppercase; cursor: pointer;
transition: background var(--dur-fast); font-family: var(--font-display);
white-space: nowrap;
}
.sticky-atc-btn:hover { background: var(--c-red-bright); }
/* ── Responsive fixes ───────────────────────────────────── */
@media (max-width: 768px) {
/* Checkout layout stacks on mobile */
.checkout-layout { grid-template-columns: 1fr !important; }
.checkout-sidebar { position: static !important; }
/* FAQ size table — horizontal scroll */
.size-guide-table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.size-table { min-width: 520px; }
/* About page stats row */
.stats-row { grid-template-columns: 1fr 1fr !important; }
}
@media (max-width: 480px) {
/* Success page actions */
.success-next-steps { gap: 8px; }
.success-next-steps .btn { width: 100%; text-align: center; }
/* Easter popup tighter on small screens */
.easter-headline { font-size: 24px; }
.easter-product { flex-direction: column; text-align: center; }
.easter-product-info { text-align: center; }
}
/* ============================================================
24. FOOTER — extended classes (used in subpages)
============================================================ */
.footer-inner {
max-width: var(--max-w); margin: 0 auto;
display: grid; grid-template-columns: 2fr 1fr 1fr 1fr;
gap: clamp(24px, 5vw, 56px);
padding-bottom: var(--sp-lg);
border-bottom: 1px solid var(--c-border);
}
.footer-brand { display: flex; flex-direction: column; gap: 8px; }
.footer-logo {
font-family: var(--font-display); font-size: 18px; font-weight: 900;
text-transform: uppercase; color: var(--c-text); letter-spacing: 0.04em;
}
.footer-logo strong { color: var(--c-red-bright); }
.footer-tagline {
font-size: 12px; color: var(--c-text-muted); line-height: 1.6;
margin-top: 2px;
}
.footer-addr {
font-size: 11px; color: var(--c-text-dim); line-height: 1.6; margin-top: 4px;
}
.footer-heading {
font-size: 9px; letter-spacing: 0.26em; color: var(--c-text-dim);
text-transform: uppercase; font-weight: 700; margin-bottom: 14px;
}
.footer-links { list-style: none; display: flex; flex-direction: column; gap: 6px; }
.footer-links li a {
display: block; font-size: 12px; color: var(--c-text-muted);
transition: color var(--dur-fast); text-decoration: none;
}
.footer-links li a:hover { color: var(--c-text); }
.footer-bottom {
max-width: var(--max-w); margin: var(--sp-md) auto 0;
display: flex; justify-content: space-between; align-items: center;
font-size: 10px; color: var(--c-text-dim); letter-spacing: 0.06em;
flex-wrap: wrap; gap: 8px;
}
.footer-pay { font-size: 10px; color: var(--c-text-dim); letter-spacing: 0.06em; }
@media (max-width: 1024px) {
.footer-inner { grid-template-columns: 1fr 1fr; gap: 32px; }
}
@media (max-width: 768px) {
.footer-inner { grid-template-columns: 1fr; gap: 28px; }
.footer-bottom { flex-direction: column; align-items: flex-start; gap: 6px; }
}
/* ============================================================
25. EASTER POPUP
============================================================ */
#easter-popup {
position: fixed; inset: 0; z-index: 1200;
display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none;
transition: opacity 0.35s;
}
#easter-popup.easter-visible { opacity: 1; pointer-events: auto; }
.easter-backdrop {
position: absolute; inset: 0;
background: rgba(13,13,13,.88); backdrop-filter: blur(5px);
}
.easter-box {
position: relative; z-index: 1;
background: var(--c-surface-1);
border: 1px solid var(--c-border-mid);
padding: clamp(28px, 5vw, 48px) clamp(24px, 5vw, 44px);
max-width: 440px; width: calc(100% - 32px);
text-align: center;
transform: translateY(28px) scale(0.96);
transition: transform 0.4s cubic-bezier(.22,.68,0,1.2);
}
#easter-popup.easter-visible .easter-box { transform: translateY(0) scale(1); }
.easter-close {
position: absolute; top: 14px; right: 14px;
background: none; border: none; color: var(--c-text-dim);
font-size: 18px; cursor: pointer; padding: 6px 8px; line-height: 1;
transition: color var(--dur-fast);
}
.easter-close:hover { color: var(--c-text); }
.easter-egg-icon { font-size: 34px; margin-bottom: 10px; display: block; }
.easter-eyebrow {
font-size: 9px; letter-spacing: 0.28em; color: var(--c-red-bright);
text-transform: uppercase; font-weight: 700; margin-bottom: 14px;
}
.easter-headline {
font-family: var(--font-display); font-size: clamp(26px, 6vw, 38px);
font-weight: 900; text-transform: uppercase; line-height: 1.05;
margin-bottom: 12px;
}
.easter-sub {
font-size: 13px; color: var(--c-text-muted); line-height: 1.65;
margin-bottom: 20px;
}
.easter-product {
display: flex; align-items: center; gap: 14px;
background: var(--c-surface-2); border: 1px solid var(--c-border);
padding: 12px 14px; margin-bottom: 20px; text-align: left;
}
.easter-product-img {
width: 72px; height: 72px; flex-shrink: 0;
background: var(--c-card); display: flex; align-items: center; justify-content: center;
}
.easter-product-img img { width: 72px; height: 72px; object-fit: contain; }
.easter-product-name { font-size: 13px; font-weight: 700; color: var(--c-text); margin-bottom: 3px; }
.easter-product-price { font-size: 12px; color: var(--c-text-muted); margin-bottom: 8px; }
.easter-code-wrap { font-size: 11px; color: var(--c-text-dim); }
.easter-code { color: var(--c-red-bright); letter-spacing: 0.12em; font-size: 13px; font-family: var(--font-display); }
.easter-code-note { display: block; font-size: 10px; color: var(--c-text-dim); margin-top: 2px; }
.easter-cta {
display: block; background: var(--c-red); color: #fff;
padding: 14px 24px; font-size: 12px; font-weight: 700;
letter-spacing: 0.1em; text-transform: uppercase; text-decoration: none;
transition: background var(--dur-fast); margin-bottom: 12px;
}
.easter-cta:hover { background: var(--c-red-bright); color: #fff; }
.easter-skip {
background: none; border: none; color: var(--c-text-dim);
font-size: 11px; cursor: pointer; text-decoration: underline;
text-underline-offset: 2px; transition: color var(--dur-fast);
}
.easter-skip:hover { color: var(--c-text-muted); }
/* ============================================================
26. INSTAGRAM FEED SECTION
============================================================ */
.insta-section {
padding: var(--sp-xl) var(--gutter) var(--sp-lg);
border-top: 1px solid var(--c-border);
}
.insta-section-inner { max-width: var(--max-w); margin: 0 auto; }
.insta-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: var(--sp-lg); flex-wrap: wrap; gap: 12px;
}
.insta-title-wrap { display: flex; flex-direction: column; gap: 4px; }
.insta-eyebrow {
font-size: 9px; letter-spacing: 0.28em; color: var(--c-red-bright);
text-transform: uppercase; font-weight: 700;
}
.insta-title {
font-family: var(--font-display); font-size: clamp(22px, 3.5vw, 36px);
font-weight: 900; text-transform: uppercase; line-height: 1.05;
}
.insta-handle {
display: flex; align-items: center; gap: 6px;
font-size: 12px; color: var(--c-text-muted); margin-top: 6px; letter-spacing: 0.06em;
}
.insta-post-icon { font-size: 20px; }
.insta-follow-btn {
display: inline-flex; align-items: center; gap: 8px;
border: 1px solid var(--c-border-mid); padding: 10px 18px;
font-size: 11px; font-weight: 700; letter-spacing: 0.12em;
text-transform: uppercase; color: var(--c-text); text-decoration: none;
transition: border-color var(--dur-fast), color var(--dur-fast);
flex-shrink: 0;
}
.insta-follow-btn:hover { border-color: var(--c-text); }
.insta-grid {
display: grid; grid-template-columns: repeat(6, 1fr); gap: 3px;
}
.insta-post {
position: relative; aspect-ratio: 1; overflow: hidden;
background: var(--c-surface-1); cursor: pointer;
display: block; text-decoration: none;
}
.insta-post img {
width: 100%; height: 100%; object-fit: cover;
transition: transform 0.45s cubic-bezier(.25,.46,.45,.94), filter 0.3s;
filter: brightness(0.92) saturate(0.88);
}
.insta-post:hover img { transform: scale(1.07); filter: brightness(0.72) saturate(1.1); }
.insta-post-overlay {
position: absolute; inset: 0;
background: rgba(13,13,13,.52);
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
opacity: 0; transition: opacity 0.3s;
}
.insta-post:hover .insta-post-overlay { opacity: 1; }
.insta-post-likes { font-size: 13px; font-weight: 700; color: #fff; letter-spacing: 0.04em; }
.insta-post-tag {
font-size: 9px; letter-spacing: 0.2em; color: rgba(255,255,255,.7);
text-transform: uppercase;
}
.insta-footer {
margin-top: 16px; text-align: center;
font-size: 11px; color: var(--c-text-muted); letter-spacing: 0.06em;
}
.insta-footer a { color: var(--c-red-bright); text-decoration: none; }
.insta-footer a:hover { text-decoration: underline; }
@media (max-width: 900px) {
.insta-grid { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 480px) {
.insta-grid { grid-template-columns: repeat(2, 1fr); gap: 2px; }
}
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}