v4.6

Gestalte dein persönliches Shirt

Wähle Symbole für Hobbys, Reisen, Insider und Lieblingsmomente.

1 Vorlage wählen 2 Symbole tauschen & anpassen 3 Texte setzen — fertig!

★★★★★  ·  Über 100 Shirts gestaltet  ·  5,0 auf Etsy

Shirt-Farbe:
Symbol-Farbe:
Layout:
Ansicht: Rückseite · Druckbereich 305 × 406 mm
59,99 € inkl. MwSt.

1 · Für wen ist das Shirt?

Wähle einen Startpunkt — du bekommst sofort ein fertiges Design und tauschst nur die Symbole, die nicht passen.

2 · Tippe direkt auf ein Symbol

Es landet automatisch auf der nächsten freien Position. Oder tippe zuerst auf ein Feld im Shirt, um genau dort zu platzieren. Symbole lassen sich auch per Drag & Drop zwischen Positionen verschieben.

3 · Eure Texte (optional)

Bis zu drei Texte — Datum, Namen, Insider. Jeden direkt auf dem Shirt an seine Position ziehen.

4 · Fast geschafft!

Größe: lädt …

📦 Für Max: Bestellung laden

Den Inhalt von „_design_json" aus der Shopify-Bestellung einfügen — das Kundendesign wird exakt wiederhergestellt, dann oben „Druckdatei" klicken. Wunsch-Slots erscheinen als Skizze: Symbol nachzeichnen, einsetzen, exportieren.

Erst den Wunsch-Slot im Design anklicken, dann die gezeichnete SVG laden — danach frei positionieren/skalieren, dann „Druckdatei".
59,99 €
'; throw new Error("cockpit locked"); })(); // ===== Shopify-Anbindung (vom Betreiber zu setzen) ========================== // Handle des Shopify-Produkts, das in den Warenkorb gelegt wird (= der Teil // hinter /products/ in der Produkt-URL). WICHTIG: Das Produkt MUSS veröffentlicht // sein (Status „Aktiv" + Vertriebskanal „Online Store"), sonst liefert der // Storefront nichts und Größen/Warenkorb bleiben leer. Zum Sofort-Testen ohne // Veröffentlichen vorübergehend "this-is-me-tshirt" (Bestseller) eintragen. const PP_PRODUCT_HANDLE = "personalisiertes-doodle-t-shirt-aus-bio-baumwolle-kopie"; const PP_WISH_HANDLE = "wunschsymbol"; // Aufpreis-Produkt (4,99 €) für handgezeichnete Wunschmotive const PP_REDIRECT_AFTER_ADD = "/cart"; // nach dem Hinzufügen: "/cart" oder "/checkout" let PP_PRODUCT = null; // wird von ppLoadProduct() aus /products/.js gefüllt let PP_WISH_VARIANT_ID = null; // Varianten-ID des Wunschsymbol-Produkts (zur Laufzeit geladen) // Betreiber-Ansicht: URL mit #max (oder ?max) öffnen — Kunden sehen interne // Werkzeuge (Justier-Modus, Druckdatei, Bestellung laden, Technik-Notizen) nie. // Cockpit ist IMMER Betreiber-Modus (Zugang nur über die passwortgeschützte Cockpit-Seite) document.documentElement.classList.add("operator-boot"); document.body.classList.add("operator"); /* ========================================================================= Pencil Poetry Konfigurator — Prototyp v0.1 (Hybrid: Slots + Größen + DnD) Single File, kein Build, kein Backend. Später: gleiche Logik als Einbettung in einer Shopify-Page; "addToCart" schreibt dann echte line item properties. ========================================================================= */ // ---- Dummy-Doodle-Symbole (Platzhalter für Max' echte SVGs) -------------- // Stil: stroke-only, runde Kappen → wirkt handgezeichnet. const S = (paths, extra = "") => '' + paths + extra + ""; const DUMMY_SYMBOLS = [ // Essen & Trinken // Hobbys // Reisen & Orte // Liebe & Momente { id: "stern", label: "Stern", cat: "Liebe & Beziehung", svg: S('') }, // --- Echte Symbole: Hunde (Vektor, aus Max' Bibliothek) --- // Nachträgliche Dummies für die Top-Symbole aus den Bestelldaten // (Bier 35,9 % · Auto 22,8 % · Fußball 23,9 %) — bis Max' echte kommen. ]; // Platzhalter nur für noch fehlende Top-Symbole — echte Bibliothek kommt // aus konfigurator-symbole.js (generiert aus dem symbole/-Ordner). const WISH_SYMBOL = { id: "wunsch", label: "Wunschsymbol (+4,99 €)", cat: "✏️ Wunsch", svg: '', }; const PRIVATE_SYMBOLS = (window.PP_SYMBOLS_PRIVATE || []).map(s => ({ ...s, cat: "🔒 Privat" })); const SYMBOLS = [...DUMMY_SYMBOLS, WISH_SYMBOL, ...(window.PP_SYMBOLS_REAL || []), ...PRIVATE_SYMBOLS]; const WISH_PRICE = 4.99; // Bestelldaten-Ranking (92 Orders, Juni 2026): nur IDs, die bereits existieren — // wächst automatisch mit, sobald echte Symbole (Reisen, Cocktails …) eingebaut sind. // Symbole des Bestseller-Designs (Etsy-Listing) — Italien-Karte & Pärchen // fehlen noch in der Bibliothek, Liste wird mit Max' finaler Version ersetzt. const BESTSELLER_IDS = ["bs-berge", "bs-koffer", "bs-flugzeug", "bs-globus", "bs-passport", "bs-rennrad", "bs-tennis", "herzpaar", "italien", "bs-hundkatze", "bs-kaffeemaschine", "bs-bierkruege", "bs-fussball", "bs-ringe", "bs-paerchen", "bs-kochmuetze", "bs-surfbus"]; // Such-Synonyme — Suchbegriff findet ganze Motiv-Familien const SEARCH_TAGS = { "limousine": "auto", "oldtimer": "auto", "sportwagen": "auto", "cabrio": "auto", "taxi": "auto", "polizeiauto": "auto", "pickup": "auto", "van": "auto", "krankenwagen": "auto", "gelaendewagen": "auto suv", "rennwagen": "auto formel", "bs-rennrad": "fahrrad rad", "rennrad": "fahrrad rad", "fahrrad": "rad", "motorrad": "bike", "roller": "vespa moped", "bs-flugzeug": "flugzeug fliegen urlaub", "flugzeug": "fliegen urlaub", "bs-berge": "berge wandern natur", "espresso": "kaffee", "cappuccino": "kaffee", "kaffeebohnen": "kaffee", "kaffeetuete": "kaffee", "kaffeemaschine": "kaffee", "bs-kaffeemaschine": "kaffee", "bier": "trinken", "bierflasche": "bier", "bierkruege": "bier", "bs-bierkruege": "bier", "weinglaeser": "wein", "sektglaeser": "sekt feiern", "cocktail": "drink", "longdrink": "drink cocktail", "fussballer": "fussball", "fussballtor": "fussball tor", "bs-fussball": "fussball tor", "herzpaar": "liebe herz", "bs-paerchen": "liebe paar umarmung", "bs-ringe": "hochzeit liebe", "liebesbrief": "liebe brief", "buecherstapel": "buch lesen", "buchherzen": "buch lesen", }; // Deutsch→Englisch: damit "Hund" alle dog-* IDs findet, "Katze" alle cat-* usw. const SEARCH_ALIAS = { "hund": "dog", "hunde": "dog", "welpe": "dog puppy", "katze": "cat", "katzen": "cat", "kater": "cat", "pferd": "horse", "pferde": "horse", "vogel": "bird", "voegel": "bird", "hase": "rabbit", "kaninchen": "rabbit", "fisch": "fish", }; const POPULAR_IDS = [ "espresso", "bier", "herzpaar", "musiknote", "fussballtor", "auto-suv", "dog-mixedbreed", "cat-sitting", "flugzeug", "berge", "rennrad", "fahrrad", "welle", "cocktail", "sonne", "kamera", "koffer", "kopfhoerer", "bierkruege", "campervan", ]; // Max' Shop-Taxonomie — feste Reihenfolge; leere Kategorien zeigen einen // Hinweis statt zu verschwinden. („Text & Insider" wird ein eigenes // Text-Slot-Feature, keine Symbol-Kategorie.) // Reihenfolge = Bestellnachfrage (92-Orders-Analyse). „Reisen & Orte" und // „Natur & Outdoor" sind zu EINER Kategorie verschmolzen — sie teilten sich // 5+ Symbole (Meer, Sonne, Palme, Berge, Camping) und verwirrten nur. // Feste Reihenfolge der Kern-Kategorien; neue Kategorien aus der Bibliothek // werden automatisch hinten angehängt — so muss man hier nichts ändern wenn // neue Symbol-Sets (z.B. "🎀 Beste Freundin") hochgeladen werden. const CATS_ORDER = [ "⭐ Beliebt", "💛 Bestseller", "Essen", "Trinken", "Sport & Bewegung", "Tiere", "Fahrzeuge", "Reisen & Orte", "Natur & Pflanzen", "Liebe & Beziehung", "Hobbys & Interessen", "Musik & Feiern", ]; const EXTRA_CATS = [...new Set(SYMBOLS.map(s => s.cat))] .filter(c => c && !CATS_ORDER.includes(c)); const CATS = [...CATS_ORDER, ...EXTRA_CATS]; const SLOT_COUNT = 17; // Max' Empfehlung: ~15 Symbole pro Design // Kuratierte Layouts — abgeleitet aus Max' Referenz-Designs: x/y = Position // in % der Druckfläche (Zentrum), w = Basisbreite in % der Druckbreite. // Anker / mittlere Symbole / kleine Füller — bewusst asymmetrisch. const LAYOUT_STYLES = { Geordnet: [ { x: 13, y: 11, w: 21, h: 16 }, { x: 38, y: 11, w: 21, h: 16 }, { x: 62, y: 11, w: 21, h: 16 }, { x: 87, y: 11, w: 21, h: 16 }, { x: 13, y: 37, w: 21, h: 16 }, { x: 38, y: 37, w: 21, h: 16 }, { x: 62, y: 37, w: 21, h: 16 }, { x: 87, y: 37, w: 21, h: 16 }, { x: 13, y: 63, w: 21, h: 16 }, { x: 38, y: 63, w: 21, h: 16 }, { x: 62, y: 63, w: 21, h: 16 }, { x: 87, y: 63, w: 21, h: 16 }, { x: 13, y: 89, w: 21, h: 16 }, { x: 38, y: 89, w: 21, h: 16 }, { x: 62, y: 89, w: 21, h: 16 }, { x: 87, y: 89, w: 21, h: 16 }, ], Klassisch: [ // Max-Kalibrierung v4 (eingefroren) { x: 21.2, y: 14.2, w: 38.9, h: 24.6 }, { x: 94.5, y: 31.1, w: 15.1, h: 11.3 }, { x: 76.1, y: 11.2, w: 42, h: 18.4 }, { x: 53.7, y: 23.4, w: 22.2, h: 16.8 }, { x: 76.7, y: 25.1, w: 14.4, h: 12.4 }, { x: 18.1, y: 35.4, w: 31.4, h: 23.6 }, { x: 43.9, y: 39.1, w: 21.6, h: 14.3 }, { x: 69.2, y: 39.2, w: 27.3, h: 12.1 }, { x: 20.6, y: 61.6, w: 37.4, h: 33.1 }, { x: 52.8, y: 54.5, w: 38, h: 16.8 }, { x: 84.7, y: 48.8, w: 18, h: 15.8 }, { x: 54.5, y: 71.2, w: 29.5, h: 20.6 }, { x: 88, y: 66.4, w: 27, h: 26.2 }, { x: 30.6, y: 87.9, w: 17.7, h: 10.8 }, { x: 10.1, y: 92.46, w: 20.8, h: 20.6 }, { x: 48.9, y: 91.47, w: 19.9, h: 16 }, { x: 80.4, y: 88.6, w: 31, h: 23.3 }, ], }; // Max' eigene Feinjustierung: überschreibt die eingebauten Layouts und bleibt // in localStorage erhalten — verschieben + Größe ändern im 🔧 Justier-Modus. const LAYOUT_VERSION = "max-2026-06-22-a"; try { const saved = JSON.parse(localStorage.getItem("pp.layout.custom") || "null"); if (saved && saved.v === LAYOUT_VERSION) { for (const k of Object.keys(saved.styles || {})) if (LAYOUT_STYLES[k]) LAYOUT_STYLES[k] = saved.styles[k]; } else { localStorage.removeItem("pp.layout.custom"); } } catch (e) { /* ignorieren */ } function saveLayouts() { // Nicht speichern wenn ein Template mit eigenen Layouts aktiv ist — // sonst überschreiben Template-Positionen die globalen Layouts für alle anderen Templates. if (state.usingTemplateLayout) return; try { localStorage.setItem("pp.layout.custom", JSON.stringify({ v: LAYOUT_VERSION, styles: LAYOUT_STYLES })); } catch (e) {} } const BUILTIN_LAYOUTS = JSON.parse(JSON.stringify(LAYOUT_STYLES)); const activeLayout = () => LAYOUT_STYLES[state.layoutStyle] || LAYOUT_STYLES.Klassisch; // Druckbereichs-Klemme: das Symbol (inkl. S/M/L/XL-Faktor) bleibt IMMER komplett // im zulässigen Gelato-Druckfenster — Ecksymbole rutschen bei XL nach innen. const ZONE_RATIO = 305 / 406; // Breite/Höhe der Druckfläche function clampPos(L, mult) { // Anschnitt ist erlaubt (Überstand wird von Zone + Druckdatei gekappt) — // nur das Zentrum bleibt im Druckbereich, damit nichts ganz verschwindet. return { x: Math.max(1, Math.min(99, L.x)), y: Math.max(1, Math.min(99, L.y)) }; } // ---- State ---------------------------------------------------------------- // Leichte Default-Neigungen — kein Excel-Raster. const ROT_PATTERN = [-7, 5, -3, 6, -5, 4, 8, -6, 3, -8, 6, -4, 5, -3, 7]; const state = { slots: Array.from({ length: SLOT_COUNT }, () => ({ symbolId: null, scale: 1, rot: 0 })), selected: null, // index of selected slot activeCat: CATS[0], shirtColor: "#ffffff", size: null, // gewählte Shirtgröße (kommt live aus den Produkt-Varianten) inkColor: "#1800ad", // Druckfarbe der Symbole (5 Optionen) layoutStyle: "Klassisch", usingTemplateLayout: false, // true wenn ein Template mit eigenen Layouts aktiv ist → saveLayouts() blockiert editMode: false, // bis zu 3 frei platzierbare Textfelder (Referenz-Bestellungen nutzen z. B. // zwei Jahreszahlen an verschiedenen Stellen des Designs) texts: [{ text: "", x: 50, y: 95, fs: 7 }], }; const MAX_TEXTS = 3; // ---- DOM refs --------------------------------------------------------------- const zone = document.getElementById("printZone"); const textEls = []; // DOM-Elemente der Textfelder (Index = state.texts-Index) const tabsEl = document.getElementById("catTabs"); const gridEl = document.getElementById("symbolGrid"); const toolsEl = document.getElementById("slotTools"); const titleEl = document.getElementById("pickerTitle"); const statusEl = document.getElementById("fillStatus"); const shirtBody = document.getElementById("shirtBody"); const bySym = id => SYMBOLS.find(s => s.id === id) || null; // ---- Render ----------------------------------------------------------------- function renderSlots() { zone.innerHTML = ""; zone.style.color = state.inkColor; state.slots.forEach((slot, i) => { if (!activeLayout()[i]) return; // Layout hat weniger Positionen → Slot ausblenden const el = document.createElement("div"); el.className = "slot" + (slot.symbolId ? "" : " empty show-hint"); if (state.selected === i) el.classList.add("selected"); el.dataset.idx = i; if (slot.symbolId) { el.innerHTML = slot.customSvg || bySym(slot.symbolId).svg; el.draggable = !state.editMode; } if (state.editMode) { el.style.outline = "1.5px dashed rgba(224,120,86,.8)"; el.style.cursor = "grab"; el.style.touchAction = "none"; // sonst scrollt das Handy statt zu ziehen el.style.webkitUserSelect = "none"; // iOS: keine Textauswahl bei langem Druck el.style.webkitTouchCallout = "none"; // iOS: kein Kontext-Popup — Geste bleibt beim Drag let drag = false; el.addEventListener("pointerdown", e => { drag = true; el.setPointerCapture(e.pointerId); // Auswahl OHNE renderSlots — sonst wird das gegriffene Element zerstört // und das Pointer-Capture bricht ab (gleicher Bug wie beim Text-Drag). state.selected = i; zone.querySelectorAll(".slot").forEach(s => s.classList.remove("selected")); el.classList.add("selected"); titleEl.textContent = "Position " + (i + 1) + " · justieren"; toolsEl.style.display = "flex"; e.preventDefault(); }); el.addEventListener("pointermove", e => { if (!drag) return; const r = zone.getBoundingClientRect(); const L = activeLayout()[i]; if (!L) return; // dieser Stil hat weniger Positionen — Slot bleibt unsichtbar L.x = Math.round(((e.clientX - r.left) / r.width) * 1000) / 10; L.y = Math.round(((e.clientY - r.top) / r.height) * 1000) / 10; const mult = state.slots[i].scale || 1; const pos = clampPos(L, mult); L.x = pos.x; L.y = pos.y; el.style.left = pos.x + "%"; el.style.top = pos.y + "%"; }); el.addEventListener("pointerup", () => { drag = false; saveLayouts(); }); } // kuratierte Position + Größe (Basisbreite × S/M/L/XL) + Neigung const L = activeLayout()[i]; if (!L) { el.style.display = "none"; zone.appendChild(el); return; } const mult = slot.scale || 1; const pos = clampPos(L, mult); const px = Math.max(1, Math.min(99, pos.x + (slot.dx || 0))); const py = Math.max(1, Math.min(99, pos.y + (slot.dy || 0))); el.style.left = px + "%"; el.style.top = py + "%"; el.style.width = L.w * mult + "%"; el.style.height = (L.h || L.w) * mult + "%"; el.style.setProperty("--slot-rot", (slot.rot || 0) + "deg"); el.style.setProperty("--slot-sx", slot.flip ? -1 : 1); el.style.transform = "translate(-50%,-50%) rotate(var(--slot-rot)) scaleX(var(--slot-sx))"; el.addEventListener("click", () => selectSlot(i)); el.addEventListener("dragstart", e => { e.dataTransfer.setData("text/plain", String(i)); }); el.addEventListener("dragover", e => { e.preventDefault(); el.classList.add("dragover"); }); el.addEventListener("dragleave", () => el.classList.remove("dragover")); el.addEventListener("drop", e => { e.preventDefault(); el.classList.remove("dragover"); const from = parseInt(e.dataTransfer.getData("text/plain"), 10); if (!Number.isNaN(from) && from !== i) swapSlots(from, i); }); zone.appendChild(el); }); textEls.forEach(el => zone.appendChild(el)); // Textfelder liegen über den Slots const filled = state.slots.filter(s => s.symbolId).length; const wishes = state.slots.filter(s => s.symbolId === "wunsch").length; const priceEl = document.getElementById("priceLine"); const priceTxt = (59.99 + wishes * WISH_PRICE).toFixed(2).replace(".", ",") + " €"; if (priceEl) priceEl.textContent = priceTxt + (wishes ? " (inkl. " + wishes + " Wunschsymbol" + (wishes > 1 ? "e" : "") + ")" : ""); const ctaP = document.getElementById("ctaPrice"); if (ctaP) ctaP.textContent = priceTxt; statusEl.textContent = filled + " von " + state.slots.length + " Positionen gefüllt" + (filled < 12 ? " — für ein volles Design empfehlen wir ca. 15 Symbole." : ". Sieht gut aus!"); } function renderTabs() { tabsEl.innerHTML = ""; const cats = state.editMode && PRIVATE_SYMBOLS.length ? [...CATS, "🔒 Privat"] : CATS; cats.forEach(cat => { const b = document.createElement("button"); b.className = "tab" + (cat === state.activeCat ? " active" : ""); b.textContent = cat; b.addEventListener("click", () => { state.activeCat = cat; renderTabs(); renderGrid(); }); tabsEl.appendChild(b); }); } // Lazy-SVG: rendert Symbol erst wenn es in den sichtbaren Bereich scrollt let _svgObs = null; function _getSvgObs() { if (_svgObs) return _svgObs; _svgObs = new IntersectionObserver(entries => { entries.forEach(entry => { if (!entry.isIntersecting) return; const b = entry.target; const raw = b.dataset.lazySvg; if (raw) { const parser = new DOMParser(); const doc = parser.parseFromString(raw, "image/svg+xml"); const newSvg = doc.documentElement; if (newSvg && newSvg.tagName === "svg") { const oldSvg = b.querySelector("svg"); if (oldSvg) b.replaceChild(newSvg, oldSvg); else b.prepend(newSvg); } delete b.dataset.lazySvg; } _svgObs.unobserve(b); }); }, { root: gridEl, rootMargin: "80px" }); return _svgObs; } function _makeSymBtn(sym) { const b = document.createElement("button"); b.className = "symbol-btn"; b.title = sym.label; b.style.color = "#2e2a24"; b.dataset.lazySvg = sym.svg; // Platzhalter SVG + Label — SVG wird durch Observer ersetzt sobald sichtbar const placeholder = document.createElement("svg"); placeholder.setAttribute("viewBox", "0 0 1 1"); placeholder.setAttribute("xmlns", "http://www.w3.org/2000/svg"); placeholder.style.opacity = "0"; const lbl = document.createElement("span"); lbl.className = "sym-label"; lbl.textContent = sym.label; b.appendChild(placeholder); b.appendChild(lbl); _getSvgObs().observe(b); b.addEventListener("click", () => placeSymbol(sym.id)); return b; } function renderGrid() { // Observer zurücksetzen damit alte Buttons nicht mehr feuern if (_svgObs) { _svgObs.disconnect(); _svgObs = null; } gridEl.innerHTML = ""; const q = (state.search || "").toLowerCase().trim(); if (q) { // Suche ist kategorie-übergreifend — durchsucht die gesamte Bibliothek const qAlias = SEARCH_ALIAS[q] || q; SYMBOLS.filter(s => { if (!state.editMode && s.cat === "🔒 Privat") return false; const hay = s.label.toLowerCase() + " " + (SEARCH_TAGS[s.id] || "") + " " + s.id; return hay.includes(q) || (qAlias !== q && hay.includes(qAlias)); }).slice(0, 60).forEach(sym => { const b = _makeSymBtn(sym); gridEl.appendChild(b); }); if (!gridEl.children.length) { const e2 = document.createElement("div"); e2.style.cssText = "grid-column:1/-1; font-size:12.5px; color:var(--sand-700); padding:14px 4px;"; e2.textContent = "Kein Symbol gefunden — wir zeichnen es dir:"; gridEl.appendChild(e2); } addWishTile(); return; } const pool = state.activeCat === "⭐ Beliebt" ? POPULAR_IDS.map(bySym).filter(Boolean) // Reihenfolge = Bestellhäufigkeit : state.activeCat === "💛 Bestseller" ? BESTSELLER_IDS.map(bySym).filter(Boolean) : SYMBOLS.filter(s => s.cat === state.activeCat); const addWishTile = () => { const b = document.createElement("button"); b.className = "symbol-btn"; b.style.cssText = "border-style:dashed; color:var(--sand-700); font-size:10.5px; line-height:1.25; padding:6px;"; b.innerHTML = "✏️
Fehlt dir was?
+4,99 €"; b.addEventListener("click", () => placeSymbol("wunsch")); gridEl.appendChild(b); }; pool.filter(s => !q || s.label.toLowerCase().includes(q)).forEach(sym => { gridEl.appendChild(_makeSymBtn(sym)); }); if (state.activeCat !== "🔒 Privat") addWishTile(); if (!gridEl.children.length) { const empty = document.createElement("div"); empty.style.cssText = "grid-column:1/-1; font-size:12.5px; color:var(--sand-700); padding:14px 4px;"; empty.textContent = q ? "Kein Symbol gefunden — andere Schreibweise probieren." : "Diese Kategorie ist noch leer — die Symbole kommen, sobald sie hochgeladen sind."; gridEl.appendChild(empty); } } function ensureTextEl(i) { if (textEls[i]) return textEls[i]; const el = document.createElement("div"); el.className = "name-line"; let dragging = false; el.addEventListener("pointerdown", e => { dragging = true; el.setPointerCapture(e.pointerId); e.preventDefault(); }); el.addEventListener("pointermove", e => { if (!dragging) return; const r = zone.getBoundingClientRect(); const t = state.texts[i]; t.x = Math.max(2, Math.min(98, ((e.clientX - r.left) / r.width) * 100)); t.y = Math.max(2, Math.min(98, ((e.clientY - r.top) / r.height) * 100)); el.style.left = t.x + "%"; // nur Styles anfassen — kein DOM-Umbau, el.style.top = t.y + "%"; // sonst bricht das Pointer-Capture und ruckelt }); el.addEventListener("pointerup", () => { dragging = false; }); textEls[i] = el; return el; } function renderName() { while (textEls.length > state.texts.length) textEls.pop().remove(); state.texts.forEach((t, i) => { const el = ensureTextEl(i); if (el.textContent !== t.text) el.textContent = t.text; el.style.fontSize = ((t.fs || 7) / 100) * zone.getBoundingClientRect().width + "px"; el.style.color = state.inkColor; el.style.left = t.x + "%"; el.style.top = t.y + "%"; el.style.display = t.text ? "block" : "none"; if (!el.isConnected) zone.appendChild(el); }); renderTextInputs(); } function renderTextInputs() { const box = document.getElementById("textFields"); if (box.children.length !== state.texts.length) { box.innerHTML = ""; state.texts.forEach((t, i) => { const row = document.createElement("div"); row.className = "row"; row.style.marginBottom = "6px"; const inp = document.createElement("input"); inp.type = "text"; inp.maxLength = 40; inp.placeholder = i === 0 ? "z. B. Lena & Mara · seit 2014" : "z. B. 2504 oder ein Insider"; inp.value = t.text; inp.style.flex = "1"; inp.addEventListener("input", () => { state.texts[i].text = inp.value; renderName(); }); row.appendChild(inp); const dn = document.createElement("button"); dn.className = "btn ghost small"; dn.textContent = "A−"; dn.addEventListener("click", () => { t.fs = Math.max(3.5, (t.fs || 7) - 1.2); renderName(); }); const up = document.createElement("button"); up.className = "btn ghost small"; up.textContent = "A+"; up.addEventListener("click", () => { t.fs = Math.min(16, (t.fs || 7) + 1.2); renderName(); }); row.appendChild(dn); row.appendChild(up); if (state.texts.length > 1) { const del = document.createElement("button"); del.className = "btn ghost small"; del.textContent = "✕"; del.addEventListener("click", () => { state.texts.splice(i, 1); textEls.forEach(e2 => e2.remove()); textEls.length = 0; // Indizes neu binden document.getElementById("textFields").innerHTML = ""; renderName(); }); row.appendChild(del); } box.appendChild(row); }); document.getElementById("addText").disabled = state.texts.length >= MAX_TEXTS; } } document.getElementById("addText").addEventListener("click", () => { if (state.texts.length >= MAX_TEXTS) return; state.texts.push({ text: "", x: 50, y: 18 + state.texts.length * 30, fs: 7 }); document.getElementById("textFields").innerHTML = ""; renderName(); }); // ---- Undo ------------------------------------------------------------------- const HISTORY = []; const MAX_HISTORY = 20; function pushHistory() { HISTORY.push({ slots: JSON.parse(JSON.stringify(state.slots)), texts: JSON.parse(JSON.stringify(state.texts)), }); if (HISTORY.length > MAX_HISTORY) HISTORY.shift(); _syncUndoBtn(); } function undo() { if (!HISTORY.length) return; const prev = HISTORY.pop(); state.slots = prev.slots; state.texts = prev.texts; state.selected = null; toolsEl.style.display = "none"; titleEl.textContent = "2 · Tippe direkt auf ein Symbol"; document.getElementById("textFields").innerHTML = ""; renderSlots(); renderName(); _syncUndoBtn(); } function _syncUndoBtn() { const b = document.getElementById("undoBtn"); if (b) b.disabled = HISTORY.length === 0; } document.addEventListener("keydown", e => { if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) { e.preventDefault(); undo(); } }); // ---- Actions ------------------------------------------------------------------ function selectSlot(i) { state.selected = i; const slot = state.slots[i]; titleEl.textContent = "Position " + (i + 1) + (slot.symbolId ? " · " + bySym(slot.symbolId).label : " · leer"); toolsEl.style.display = slot.symbolId ? "flex" : "none"; renderSlots(); } function placeSymbol(symbolId) { if (symbolId !== "wunsch") pushHistory(); const wasAutoSelect = state.selected === null; let i = state.selected; if (i === null) { i = state.slots.findIndex(s => !s.symbolId); // nächster freier Slot if (i === -1) i = 0; } const wasEmpty = !state.slots[i].symbolId; if (symbolId === "wunsch") { openWishModal(i); return; // Slot wird erst nach Bestätigung im Formular gesetzt } delete state.slots[i].wish; state.slots[i].symbolId = symbolId; if (wasEmpty) { state.slots[i].scale = 1; // Größen-Varianz steckt im Layout state.slots[i].rot = ROT_PATTERN[i % ROT_PATTERN.length]; // leichte Default-Neigung } // Auto-Advance: wenn kein Slot manuell gewählt war → nächsten leeren vorauswählen if (wasAutoSelect) { const nextEmpty = state.slots.findIndex((s, idx) => idx > i && !s.symbolId); state.selected = nextEmpty !== -1 ? nextEmpty : i; titleEl.textContent = nextEmpty !== -1 ? "Position " + (nextEmpty + 1) + " · klicke das nächste Symbol" : "Position " + (i + 1) + " · " + bySym(symbolId).label; toolsEl.style.display = nextEmpty !== -1 ? "none" : "flex"; renderSlots(); } else { selectSlot(i); } // Bounce-Animation auf dem platzierten Slot const placedEl = zone.querySelector(`.slot[data-idx="${i}"]`); if (placedEl) { placedEl.classList.add("sym-bounce"); placedEl.addEventListener("animationend", () => placedEl.classList.remove("sym-bounce"), { once: true }); } } function swapSlots(a, b) { pushHistory(); [state.slots[a], state.slots[b]] = [state.slots[b], state.slots[a]]; state.selected = b; renderSlots(); } document.getElementById("clearSlot").addEventListener("click", () => { if (state.selected === null) return; pushHistory(); state.slots[state.selected] = { symbolId: null, scale: 1, rot: 0 }; selectSlot(state.selected); }); const rotateSelected = delta => { if (state.selected === null) return; const s = state.slots[state.selected]; s.rot = Math.max(-20, Math.min(20, (s.rot || 0) + delta)); renderSlots(); }; const resizeField = f => { if (state.selected === null) return; const L = activeLayout()[state.selected]; L.w = Math.max(4, Math.round(L.w * f * 10) / 10); if (L.h) L.h = Math.max(3, Math.round(L.h * f * 10) / 10); saveLayouts(); renderSlots(); }; document.getElementById("fieldFit").addEventListener("click", () => { if (state.selected === null) return; const slot = state.slots[state.selected]; const L = activeLayout()[state.selected]; if (!slot.symbolId || !L) return; const vb = (bySym(slot.symbolId).svg.match(/viewBox="([^"]+)"/) || [])[1]; if (!vb) return; const p = vb.split(" ").map(Number); const ar = p[2] / p[3]; L.h = Math.round((L.w * (305 / 406) / ar) * 10) / 10; saveLayouts(); renderSlots(); }); const nudge = (dx, dy) => { if (state.selected === null) return; if (state.editMode) { // Justier-Modus: verschiebt das Master-Layout (für alle Kunden) const L = activeLayout()[state.selected]; if (!L) return; L.x = Math.max(1, Math.min(99, Math.round((L.x + dx) * 10) / 10)); L.y = Math.max(1, Math.min(99, Math.round((L.y + dy) * 10) / 10)); saveLayouts(); } else { // Kundin: verschiebt nur das eigene Design — begrenzt auf ±10 % const s = state.slots[state.selected]; s.dx = Math.max(-10, Math.min(10, Math.round(((s.dx || 0) + dx) * 10) / 10)); s.dy = Math.max(-10, Math.min(10, Math.round(((s.dy || 0) + dy) * 10) / 10)); } renderSlots(); }; document.getElementById("nudgeL").addEventListener("click", () => nudge(-1.2, 0)); document.getElementById("nudgeR").addEventListener("click", () => nudge(1.2, 0)); document.getElementById("nudgeU").addEventListener("click", () => nudge(0, -1.2)); document.getElementById("nudgeD").addEventListener("click", () => nudge(0, 1.2)); document.getElementById("fieldSmaller").addEventListener("click", () => resizeField(1 / 1.08)); document.getElementById("fieldBigger").addEventListener("click", () => resizeField(1.08)); document.getElementById("copyLayout").addEventListener("click", async () => { const data = JSON.stringify(LAYOUT_STYLES, null, 1); try { await navigator.clipboard.writeText(data); alert("Kopiert! Einfach im Chat einfügen."); } catch (e) { window.prompt("Manuell kopieren:", data); } }); document.getElementById("downloadLayout").addEventListener("click", () => { // Positionen + aktuelle Symbole zusammenführen const enriched = {}; for (const style of Object.keys(LAYOUT_STYLES)) { enriched[style] = LAYOUT_STYLES[style].map((L, i) => { const s = state.slots[i]; return { x: L.x, y: L.y, w: L.w, h: L.h || L.w, symbol: (s && s.symbolId) ? s.symbolId : null, label: (s && s.symbolId && bySym(s.symbolId)) ? bySym(s.symbolId).label : null, scale: (s && s.scale) || 1, rot: (s && s.rot) || 0, }; }); } const data = JSON.stringify(enriched, null, 1); const blob = new Blob([data], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); const ts = new Date().toISOString().slice(0,16).replace("T","_").replace(/:/g,"-"); a.download = "pp-layout-" + ts + ".json"; a.click(); }); document.getElementById("resetLayout").addEventListener("click", () => { if (!confirm("Justierung verwerfen und eingebaute Standard-Layouts laden?")) return; localStorage.removeItem("pp.layout.custom"); for (const k of Object.keys(BUILTIN_LAYOUTS)) LAYOUT_STYLES[k] = JSON.parse(JSON.stringify(BUILTIN_LAYOUTS[k])); renderSlots(); }); document.getElementById("editToggle").addEventListener("change", e => { state.editMode = e.target.checked; if (!state.editMode && state.activeCat === "🔒 Privat") state.activeCat = CATS[0]; renderTabs(); renderGrid(); document.querySelectorAll(".edit-only").forEach(el2 => (el2.style.display = state.editMode ? "" : "none")); renderSlots(); }); document.getElementById("rotZero").addEventListener("click", () => { if (state.selected === null) return; state.slots[state.selected].rot = 0; renderSlots(); }); document.getElementById("rotLeft").addEventListener("click", () => rotateSelected(-6)); document.getElementById("rotRight").addEventListener("click", () => rotateSelected(6)); document.getElementById("flipH").addEventListener("click", () => { if (state.selected === null) return; const s = state.slots[state.selected]; s.flip = !s.flip; renderSlots(); }); const scaleSelected = f => { if (state.selected === null) return; const s = state.slots[state.selected]; s.scale = Math.max(0.5, Math.min(2, Math.round(((s.scale || 1) * f) * 100) / 100)); renderSlots(); }; document.getElementById("symSmaller").addEventListener("click", () => scaleSelected(1 / 1.1)); document.getElementById("symBigger").addEventListener("click", () => scaleSelected(1.1)); document.querySelectorAll(".swatch:not(.ink-swatch)").forEach(sw => sw.addEventListener("click", () => { document.querySelectorAll(".swatch:not(.ink-swatch)").forEach(s => s.classList.remove("active")); sw.classList.add("active"); state.shirtColor = sw.dataset.color; shirtBody.setAttribute("fill", state.shirtColor); document.querySelector(".stage").style.setProperty("--shirtcol", state.shirtColor); if (typeof ppRefreshSizeAvailability === "function") ppRefreshSizeAvailability(); renderSlots(); renderName(); }) ); document.querySelectorAll(".ink-swatch").forEach(sw => sw.addEventListener("click", () => { document.querySelectorAll(".ink-swatch").forEach(s => s.classList.remove("active")); sw.classList.add("active"); state.inkColor = sw.dataset.ink; renderSlots(); renderName(); }) ); document.getElementById("gridToggle").addEventListener("change", e => { zone.classList.toggle("show-grid", e.target.checked); zone.querySelectorAll(".slot.empty").forEach(s => s.classList.toggle("show-hint", e.target.checked)); }); document.getElementById("symbolSearch").addEventListener("input", e => { state.search = e.target.value; renderGrid(); }); document.getElementById("randomFill").addEventListener("click", () => { const shuffled = [...SYMBOLS].sort(() => Math.random() - 0.5); state.slots = state.slots.map((_, i) => ({ symbolId: shuffled[i % shuffled.length].id, scale: [1, 1, 1, 0.85, 1.2][Math.floor(Math.random() * 5)], rot: Math.round((Math.random() * 16 - 8)), })); state.selected = null; renderSlots(); }); document.getElementById("undoBtn").addEventListener("click", undo); document.getElementById("resetAll").addEventListener("click", () => { pushHistory(); state.slots = Array.from({ length: SLOT_COUNT }, () => ({ symbolId: null, scale: 1, rot: 0 })); state.selected = null; state.texts = [{ text: "", x: 50, y: 95, fs: 7 }]; document.getElementById("textFields").innerHTML = ""; toolsEl.style.display = "none"; titleEl.textContent = "2 · Tippe direkt auf ein Symbol"; renderSlots(); renderName(); }); // ---- Layout-Stil-Umschalter ------------------------------------------------ document.querySelectorAll(".layout-btn").forEach(btn => btn.addEventListener("click", () => { state.layoutStyle = btn.dataset.layout; document.querySelectorAll(".layout-btn").forEach(b => (b.style.borderColor = "")); btn.style.borderColor = "var(--peach-600)"; renderSlots(); }) ); // ---- „Für wen?"-Templates ---------------------------------------------------- // Startpunkte statt leerer Slots: Kundin wählt Empfänger, bekommt ein fertig // kuratiertes Design und tauscht nur noch. (Demo-Kuration mit den vorhandenen // Symbolen — wird mit jeder neuen echten Kategorie nachgeschärft.) const TEMPLATES = [ { id: "bestseller", label: "💛 Bestseller-Design", layoutStyle: "Klassisch", slotData: [ { symbolId: "bs-berge", scale: 1, rot: 0 }, { symbolId: "bs-koffer", scale: 1.33, rot: 5 }, { symbolId: "bs-flugzeug", scale: 1, rot: -3 }, { symbolId: "bs-globus", scale: 1, rot: 6 }, { symbolId: "bs-passport", scale: 1, rot: -5 }, { symbolId: "bs-rennrad", scale: 1.1, rot: 4 }, { symbolId: "bs-tennis", scale: 1, rot: 8 }, { symbolId: "herzpaar", scale: 1, rot: -6 }, { symbolId: "italien", scale: 1, rot: 3 }, { symbolId: "bs-hundkatze", scale: 1, rot: 0 }, { symbolId: "bs-kaffeemaschine", scale: 1, rot: 0 }, { symbolId: "bs-bierkruege", scale: 0.83, rot: -4 }, { symbolId: "bs-fussball", scale: 1, rot: 5 }, { symbolId: "bs-ringe", scale: 1, rot: -3 }, { symbolId: "bs-paerchen", scale: 1, rot: 7 }, { symbolId: "bs-kochmuetze", scale: 1, rot: -7 }, { symbolId: "bs-surfbus", scale: 1.21, rot: -1 }, ], layouts: { Geordnet: [ { x: 13, y: 11, w: 21, h: 16 }, { x: 38, y: 11, w: 21, h: 16 }, { x: 62, y: 11, w: 21, h: 16 }, { x: 87, y: 11, w: 21, h: 16 }, { x: 13, y: 37, w: 21, h: 16 }, { x: 38, y: 37, w: 21, h: 16 }, { x: 62, y: 37, w: 21, h: 16 }, { x: 87, y: 37, w: 21, h: 16 }, { x: 13, y: 63, w: 21, h: 16 }, { x: 38, y: 63, w: 21, h: 16 }, { x: 62, y: 63, w: 21, h: 16 }, { x: 87, y: 63, w: 21, h: 16 }, { x: 13, y: 89, w: 21, h: 16 }, { x: 38, y: 89, w: 21, h: 16 }, { x: 62, y: 89, w: 21, h: 16 }, { x: 87, y: 89, w: 21, h: 16 }, ], Klassisch: [ { x: 21.2, y: 14.2, w: 38.9, h: 24.6 }, { x: 94.5, y: 31.1, w: 15.1, h: 11.3 }, { x: 78.1, y: 10.7, w: 42, h: 18.4 }, { x: 53.7, y: 23.4, w: 22.2, h: 16.8 }, { x: 76.7, y: 25.1, w: 14.4, h: 12.4 }, { x: 18.1, y: 35.4, w: 31.4, h: 23.6 }, { x: 43.9, y: 39.1, w: 21.6, h: 14.3 }, { x: 69.2, y: 39.2, w: 27.3, h: 12.1 }, { x: 20.6, y: 61.6, w: 37.4, h: 33.1 }, { x: 52.8, y: 54.5, w: 38, h: 16.8 }, { x: 84.7, y: 48.8, w: 18, h: 15.8 }, { x: 53.2, y: 72.4, w: 29.5, h: 20.6 }, { x: 88, y: 66.4, w: 27, h: 26.2 }, { x: 30.6, y: 87.9, w: 17.7, h: 10.8 }, { x: 10.1, y: 92.5, w: 20.8, h: 20.6 }, { x: 48.9, y: 91.5, w: 19.9, h: 16 }, { x: 80.4, y: 88.6, w: 31, h: 23.3 }, ], }, texts: [{ text: "", x: 50, y: 95, fs: 7 }] }, { id: "bestseller2", label: "💛 Bestseller 2", layoutStyle: "Klassisch", slotCount: 20, slotData: [ { symbolId: "bs-berge", scale: 1, rot: 0 }, { symbolId: "bs-passport", scale: 0.75, rot: 5 }, { symbolId: "bs-flugzeug", scale: 1, rot: -3 }, { symbolId: "globus-reisen", scale: 1, rot: 0 }, { symbolId: "herzen-umarmen", scale: 1.1, rot: 0 }, { symbolId: "bs-bierkruege", scale: 1, rot: 4 }, { symbolId: "sushi-teller", scale: 1, rot: 0 }, { symbolId: "longdrink", scale: 1, rot: 0 }, { symbolId: "welle-meer", scale: 1, rot: 0 }, { symbolId: "bs-rennrad", scale: 0.91, rot: -2 }, { symbolId: "sonne", scale: 0.91, rot: 6 }, { symbolId: "gitarre", scale: 0.83, rot: -4 }, { symbolId: "bs-fussball", scale: 0.91, rot: 0 }, { symbolId: "bs-kochmuetze", scale: 1, rot: 0 }, { symbolId: "vw-bus", scale: 0.75, rot: 0 }, { symbolId: "limonade", scale: 1, rot: 0 }, { symbolId: "cat-tabby", scale: 0.75, rot: 0 }, { symbolId: "espresso", scale: 1, rot: -3 }, { symbolId: "bs-globus", scale: 1, rot: 0 }, { symbolId: "dog-labrador", scale: 1, rot: 0 }, ], layouts: { Geordnet: [ { x: 13, y: 8, w: 21, h: 14 }, { x: 38, y: 8, w: 21, h: 14 }, { x: 62, y: 8, w: 21, h: 14 }, { x: 87, y: 8, w: 21, h: 14 }, { x: 13, y: 28, w: 21, h: 14 }, { x: 38, y: 28, w: 21, h: 14 }, { x: 62, y: 28, w: 21, h: 14 }, { x: 87, y: 28, w: 21, h: 14 }, { x: 13, y: 48, w: 21, h: 14 }, { x: 38, y: 48, w: 21, h: 14 }, { x: 62, y: 48, w: 21, h: 14 }, { x: 87, y: 48, w: 21, h: 14 }, { x: 13, y: 68, w: 21, h: 14 }, { x: 38, y: 68, w: 21, h: 14 }, { x: 62, y: 68, w: 21, h: 14 }, { x: 87, y: 68, w: 21, h: 14 }, { x: 13, y: 88, w: 21, h: 14 }, { x: 38, y: 88, w: 21, h: 14 }, { x: 62, y: 88, w: 21, h: 14 }, { x: 87, y: 88, w: 21, h: 14 }, ], Klassisch: [ { x: 20.1, y: 12.9, w: 38.9, h: 24.6 }, { x: 47.6, y: 9.6, w: 23.9, h: 17.9 }, { x: 77.7, y: 10, w: 42, h: 18.4 }, { x: 88, y: 34, w: 20.6, h: 21.6 }, { x: 49.5, y: 50.2, w: 22.8, h: 17.1 }, { x: 37.8, y: 36.1, w: 19.8, h: 13.9 }, { x: 22.7, y: 49.8, w: 29.4, h: 19.3 }, { x: 50.9, y: 67.4, w: 9.3, h: 15.8 }, { x: 20.4, y: 72, w: 47.1, h: 17.2 }, { x: 75.8, y: 56.9, w: 41, h: 30.8 }, { x: 35.4, y: 68.5, w: 15.5, h: 13.5 }, { x: 65.9, y: 72.4, w: 27.3, h: 19.1 }, { x: 88.7, y: 69.3, w: 27, h: 26.2 }, { x: 65, y: 90.7, w: 20.6, h: 12.6 }, { x: 18.1, y: 91.4, w: 44.8, h: 22 }, { x: 43.9, y: 86.8, w: 18.4, h: 18.9 }, { x: 87, y: 90, w: 33.5, h: 29.5 }, { x: 11.3, y: 59.8, w: 20.5, h: 9.1 }, { x: 60.7, y: 29.7, w: 25.8, h: 20.4 }, { x: 15.2, y: 34, w: 27.9, h: 22 }, ], } }, { id: "partner", label: "💑 Partner-Shirt", layoutStyle: "Klassisch", slotCount: 20, slotData: [ { symbolId: "pt-haus", scale: 1, rot: 0 }, { symbolId: "pt-wein-cheers", scale: 1, rot: 0 }, { symbolId: "flugzeug", scale: 1, rot: 0 }, { symbolId: "pt-polaroid", scale: 1, rot: 0 }, { symbolId: "pt-puzzle", scale: 1, rot: 0 }, { symbolId: "pt-blumenstrauss", scale: 1, rot: 0 }, { symbolId: "pt-ehering", scale: 1, rot: 0 }, { symbolId: "pt-socken", scale: 1, rot: 0 }, { symbolId: "bs-kochmuetze", scale: 1, rot: 0 }, { symbolId: "pt-mond", scale: 1, rot: 0 }, { symbolId: "pt-tictac", scale: 1, rot: 0 }, { symbolId: "pt-no1", scale: 1, rot: 0 }, { symbolId: "pt-herzen-figuren", scale: 1, rot: 0 }, { symbolId: "pt-flamme", scale: 1, rot: 0 }, { symbolId: "pt-brief-herz", scale: 1, rot: 0 }, { symbolId: "pt-pizza", scale: 1, rot: 0 }, { symbolId: "salz", scale: 1, rot: 0 }, { symbolId: "weltreise", scale: 1.46, rot: 0 }, { symbolId: "pt-stern", scale: 1, rot: 0 }, { symbolId: "musiknote", scale: 1.33, rot: -5 }, ], layouts: { Geordnet: [ { x: 13, y: 11, w: 21, h: 16 }, { x: 38, y: 11, w: 21, h: 16 }, { x: 62, y: 11, w: 21, h: 16 }, { x: 87, y: 11, w: 21, h: 16 }, { x: 13, y: 31, w: 21, h: 16 }, { x: 38, y: 31, w: 21, h: 16 }, { x: 62, y: 31, w: 21, h: 16 }, { x: 87, y: 31, w: 21, h: 16 }, { x: 13, y: 51, w: 21, h: 16 }, { x: 38, y: 51, w: 21, h: 16 }, { x: 62, y: 51, w: 21, h: 16 }, { x: 87, y: 51, w: 21, h: 16 }, { x: 13, y: 71, w: 21, h: 16 }, { x: 38, y: 71, w: 21, h: 16 }, { x: 62, y: 71, w: 21, h: 16 }, { x: 87, y: 71, w: 21, h: 16 }, { x: 13, y: 91, w: 21, h: 16 }, { x: 38, y: 91, w: 21, h: 16 }, { x: 62, y: 91, w: 21, h: 16 }, { x: 87, y: 91, w: 21, h: 16 }, ], Klassisch: [ { x: 17.1, y: 45.6, w: 32.6, h: 24.4 }, { x: 52.5, y: 88.1, w: 27.2, h: 20.5 }, { x: 20.9, y: 8.6, w: 39.1, h: 16.4 }, { x: 51.1, y: 35.1, w: 29.1, h: 21.9 }, { x: 85.8, y: 36.2, w: 24.3, h: 18.2 }, { x: 86.7, y: 12.6, w: 28.6, h: 21.6 }, { x: 54.4, y: 50.3, w: 17.8, h: 11.3 }, { x: 84.1, y: 87.7, w: 28.8, h: 21.6 }, { x: 55.8, y: 11.9, w: 24, h: 19.5 }, { x: 11.2, y: 26.7, w: 15.9, h: 12.1 }, { x: 39.5, y: 64.4, w: 21.6, h: 16.2 }, { x: 87.2, y: 67.6, w: 21.1, h: 15.9 }, { x: 87.7, y: 52.1, w: 30.1, h: 24 }, { x: 61.4, y: 67.6, w: 27.2, h: 20.4 }, { x: 15.1, y: 66.2, w: 21.6, h: 16.2 }, { x: 15.9, y: 87.4, w: 30.1, h: 22.6 }, { x: 73.6, y: 23.9, w: 11.5, h: 12.1 }, { x: 28.6, y: 23.5, w: 14.8, h: 11.1 }, { x: 34, y: 39.6, w: 11.9, h: 8.9 }, { x: 37.6, y: 77.1, w: 11.7, h: 6.3 }, ], }, }, { id: "bestie", label: "💛 Beste Freundin", layoutStyle: "Klassisch", slotCount: 25, slotData: [ { symbolId: "bf-discokugel", scale: 1.1, rot: 0 }, { symbolId: "bf-reise", scale: 1.21, rot: 0 }, { symbolId: "bf-versprechen", scale: 0.91, rot: 0 }, { symbolId: "bf-sekt", scale: 2.0, rot: 6 }, { symbolId: "bf-prost", scale: 0.83, rot: 6 }, { symbolId: "bf-film", scale: 1.0, rot: -4 }, { symbolId: "bf-smiley", scale: 1.1, rot: 0 }, { symbolId: "bf-unendlich", scale: 0.99, rot: 3 }, { symbolId: "bf-kino", scale: 1.0, rot: 3 }, { symbolId: "bf-kommunikation", scale: 1.0, rot: 0 }, { symbolId: "bf-freundinnen", scale: 1.09, rot: 0 }, { symbolId: "bf-sushiplatte", scale: 1.0, rot: 5 }, { symbolId: "bf-sushi", scale: 1.46, rot: 0 }, { symbolId: "bf-shopping", scale: 1.0, rot: 0 }, { symbolId: "bf-makeup", scale: 1.45, rot: 9 }, { symbolId: "bf-bilder", scale: 1.0, rot: -5 }, { symbolId: "bf-kamera", scale: 1.0, rot: 0 }, { symbolId: "bf-karten", scale: 1.0, rot: -6 }, { symbolId: "bf-blumen", scale: 1.0, rot: 0 }, { symbolId: "bf-musik", scale: 0.9, rot: -2 }, { symbolId: "bf-kaffee", scale: 1.0, rot: 0 }, { symbolId: "bf-wellen", scale: 1.33, rot: 0 }, { symbolId: "bf-kuchen", scale: 1.0, rot: -4 }, { symbolId: "bf-ticket", scale: 1.0, rot: 0 }, { symbolId: "bf-pizza", scale: 1.0, rot: 3 }, ], layouts: { Geordnet: [ { x: 10, y: 9, w: 17, h: 14 }, { x: 29, y: 9, w: 17, h: 14 }, { x: 50, y: 9, w: 17, h: 14 }, { x: 71, y: 9, w: 17, h: 14 }, { x: 90, y: 9, w: 17, h: 14 }, { x: 10, y: 27, w: 17, h: 14 }, { x: 29, y: 27, w: 17, h: 14 }, { x: 50, y: 27, w: 17, h: 14 }, { x: 71, y: 27, w: 17, h: 14 }, { x: 90, y: 27, w: 17, h: 14 }, { x: 10, y: 45, w: 17, h: 14 }, { x: 29, y: 45, w: 17, h: 14 }, { x: 50, y: 45, w: 17, h: 14 }, { x: 71, y: 45, w: 17, h: 14 }, { x: 90, y: 45, w: 17, h: 14 }, { x: 10, y: 63, w: 17, h: 14 }, { x: 29, y: 63, w: 17, h: 14 }, { x: 50, y: 63, w: 17, h: 14 }, { x: 71, y: 63, w: 17, h: 14 }, { x: 90, y: 63, w: 17, h: 14 }, { x: 10, y: 81, w: 17, h: 14 }, { x: 29, y: 81, w: 17, h: 14 }, { x: 50, y: 81, w: 17, h: 14 }, { x: 71, y: 81, w: 17, h: 14 }, { x: 90, y: 81, w: 17, h: 14 }, ], Klassisch: [ { x: 13.4, y: 9.6, w: 22, h: 19 }, { x: 45.7, y: 10, w: 30, h: 22.5 }, { x: 82.8, y: 8.2, w: 32.4, h: 25.1 }, { x: 9.1, y: 30.1, w: 11, h: 8.3 }, { x: 26, y: 28.7, w: 22, h: 18 }, { x: 47.6, y: 29.7, w: 22, h: 18 }, { x: 70, y: 25, w: 16, h: 15 }, { x: 90.8, y: 23.7, w: 14, h: 10 }, { x: 91.2, y: 32.2, w: 17, h: 12.8 }, { x: 18.1, y: 43.1, w: 19, h: 16 }, { x: 47.6, y: 51.2, w: 35, h: 32 }, { x: 76.2, y: 48.4, w: 28, h: 19 }, { x: 87.5, y: 43.8, w: 17, h: 12.8 }, { x: 12.4, y: 54.4, w: 21, h: 18 }, { x: 25.4, y: 68.8, w: 11, h: 8.3 }, { x: 39.6, y: 73.8, w: 17, h: 16 }, { x: 59.8, y: 75.2, w: 17, h: 15 }, { x: 83, y: 65, w: 22, h: 18 }, { x: 12, y: 77.3, w: 24, h: 18 }, { x: 73.4, y: 80.5, w: 12.6, h: 9.5 }, { x: 89.8, y: 77.6, w: 17, h: 12.8 }, { x: 19, y: 90.7, w: 27, h: 20.3 }, { x: 41, y: 88.6, w: 16.7, h: 11.1 }, { x: 63.1, y: 92.5, w: 21, h: 15.8 }, { x: 87.5, y: 93.2, w: 23.3, h: 16.3 }, ], }, texts: [{ text: "", x: 50, y: 97, fs: 7 }] }, { id: "freund", label: "Freund / Partner", slots: ["sonne", "fussballtor", "berge", "konsole", "pizza", "herzpaar", "dog-labrador", "espresso", "auto-suv", "musiknote", "bierkruege", "kamera", "flugzeug", "grill", "rennrad"] }, { id: "mama", label: "Mama", slots: ["sonne", "liebesbrief", "blume", "sonnenblume", "buecherstapel", "tulpen", "herzpaar", "espresso", "cat-sitting", "musiknote", "croissant", "kamera", "weinglaeser", "koffer", "palme"] }, { id: "papa", label: "Papa", slots: ["berge", "fussballtor", "sonne", "auto-suv", "grill", "espresso", "dog-germanshepherd", "herzpaar", "campervan", "musiknote", "bier", "kamera", "weinglaeser", "zeitung", "pickup"] }, { id: "selbst", label: "Für mich", slots: ["sonne", "musiknote", "palme", "herzpaar", "buecherstapel", "mond", "dog-frenchbulldog", "espresso", "weltreise", "kamera", "croissant", "konsole", "cocktail", "blume", "berge"] }, { id: "leer", label: "Leer starten", slots: null }, ]; function applyTemplate(tpl) { // Template-spezifische Layouts: überschreiben die globalen LAYOUT_STYLES, // oder setzen sie auf BUILTIN zurück wenn das Template keine eigenen hat. if (tpl.layouts) { for (const k of Object.keys(tpl.layouts)) LAYOUT_STYLES[k] = JSON.parse(JSON.stringify(tpl.layouts[k])); state.usingTemplateLayout = true; } else { for (const k of Object.keys(BUILTIN_LAYOUTS)) LAYOUT_STYLES[k] = JSON.parse(JSON.stringify(BUILTIN_LAYOUTS[k])); state.usingTemplateLayout = false; } // Layout-Stil erzwingen wenn im Template angegeben (z.B. Bestseller 2 → Klassisch) if (tpl.layoutStyle) { state.layoutStyle = tpl.layoutStyle; document.querySelectorAll(".layout-btn").forEach(b => { b.style.borderColor = b.dataset.layout === tpl.layoutStyle ? "var(--peach-600)" : ""; }); } if (tpl.texts) { state.texts = tpl.texts.map(t => ({ ...t })); document.getElementById("textFields").innerHTML = ""; renderName(); } const slotCount = tpl.slotCount || SLOT_COUNT; state.slots = Array.from({ length: slotCount }, (_, i) => { if (tpl.slotData && tpl.slotData[i]) { const d = tpl.slotData[i]; return bySym(d.symbolId) ? { symbolId: d.symbolId, scale: d.scale ?? 1, rot: d.rot ?? ROT_PATTERN[i % ROT_PATTERN.length] } : { symbolId: null, scale: 1, rot: 0 }; } const id = tpl.slots ? tpl.slots[i] : null; return id && bySym(id) ? { symbolId: id, scale: 1, rot: ROT_PATTERN[i % ROT_PATTERN.length] } : { symbolId: null, scale: 1, rot: 0 }; }); state.selected = null; toolsEl.style.display = "none"; titleEl.textContent = "2 · Tippe direkt auf ein Symbol"; renderSlots(); document.querySelectorAll("#templateChips .tab").forEach(b => b.classList.toggle("active", b.dataset.tpl === tpl.id), ); } const chipsEl = document.getElementById("templateChips"); TEMPLATES.forEach(tpl => { const b = document.createElement("button"); b.className = "tab"; b.dataset.tpl = tpl.id; b.textContent = tpl.label; b.addEventListener("click", () => applyTemplate(tpl)); chipsEl.appendChild(b); }); // ---- Warenkorb-Demo (→ Shopify line item properties) --------------------------- function designProperties() { const INK_NAMES = { "#1800ad": "Royalblau", "#ffffff": "Weiß", "#111111": "Schwarz", "#1e4632": "Dunkelgrün", "#c62828": "Rot", "#d4628f": "Pink" }; const filled = state.slots.filter(s => s.symbolId).length; const wishes = state.slots.filter(s => s.symbolId === "wunsch").length; let summary = filled + (filled === 1 ? " Motiv" : " Motive"); if (wishes > 0) summary += " · inkl. " + wishes + (wishes === 1 ? " Wunschsymbol" : " Wunschsymbole"); const props = { // --- für den Kunden im Warenkorb sichtbar --- "Symbol-Farbe": INK_NAMES[state.inkColor] || state.inkColor, "Dein Design": summary, // --- nur für dich: Schlüssel mit „_" blendet Shopify beim Kunden aus, // bleibt aber auf der Bestellung sichtbar (für Druckdatei/Operator) --- "_design_version": "proto-0.2", "_Shirt-Farbe": (document.querySelector('.swatch[data-color="' + state.shirtColor + '"]') || {}).ariaLabel || state.shirtColor, "_Layout": state.layoutStyle, "_Druck": "Rückseite · 305×406 mm", ...Object.fromEntries(state.texts.filter(t => t.text).map((t, i) => [ "_Text " + (i + 1), t.text + " (" + Math.round(t.x) + "% / " + Math.round(t.y) + "%)"])), }; state.slots.forEach((s, i) => { if (s.symbolId) { const rot = s.rot ? ", " + (s.rot > 0 ? "+" : "") + s.rot + "°" : ""; props["_Position " + (i + 1)] = s.symbolId === "wunsch" ? "✏️ WUNSCHSYMBOL: „" + (s.wish || "?") + "\u201c (+4,99 €)" : bySym(s.symbolId).label + " (" + Math.round((s.scale || 1) * 100) + " %" + rot + ")"; } }); // v5: jeder Slot bekommt seine exakte Position (x/y/w/h in % der Druckzone), // Skalierung und Winkel — genug um das Design 1:1 zu rekonstruieren. props["_design_json"] = JSON.stringify({ v: 5, shirt: state.shirtColor, ink: state.inkColor, layout: state.layoutStyle, texts: state.texts.filter(t => t.text), slots: state.slots.map((s, i) => { if (!s.symbolId) return null; const L = activeLayout()[i] || { x: 50, y: 50, w: 14, h: 11 }; return { id: s.symbolId, x: Math.round(L.x * 10) / 10, y: Math.round(L.y * 10) / 10, w: Math.round(L.w * 10) / 10, h: Math.round((L.h || L.w) * 10) / 10, scale: s.scale || 1, rot: s.rot || 0, dx: s.dx || 0, dy: s.dy || 0, ...(s.flip ? { flip: true } : {}), ...(s.wish ? { wish: s.wish } : {}), }; }), }); return props; } const GESCHUETZT = ["nike","adidas","puma","disney","mickey","micky","harry potter","hogwarts","pokemon","pokémon","pikachu","star wars","darth","yoda","marvel","batman","superman","spiderman","spider-man","bvb","borussia","bayern münchen","fc bayern","schalke","mercedes","bmw","audi","porsche","vw","volkswagen","gucci","louis vuitton","chanel","rolex","barbie","lego","mario","nintendo","minions","paw patrol","elsa","frozen","hello kitty","snoopy","pistole","gewehr","waffe","ak47","ak-47","granate","hakenkreuz","swastika","sieg heil","isis","blutbad"]; let pendingWishSlot = null; const wishBg = document.getElementById("wishBg"); function openWishModal(i) { pendingWishSlot = i; document.getElementById("wishText").value = state.slots[i].wish || ""; wishBg.classList.add("open"); } document.getElementById("wishCancel").addEventListener("click", () => wishBg.classList.remove("open")); document.getElementById("wishOk").addEventListener("click", () => { const txt = document.getElementById("wishText").value.trim(); if (!txt) { wishBg.classList.remove("open"); return; } const low = txt.toLowerCase(); // mehrere Motive in einem Feld? (Aufzählungen) — nachfragen statt durchwinken if (/(,| und | sowie | & | \+ )/.test(" " + low + " ")) { const ok = confirm("Das klingt nach mehreren Motiven. Pro Wunschfeld zeichnen wir EIN Motiv (je 4,99 €) — Bestandteile eines Motivs (z. B. Wohnwagen mit Surfbrett) sind okay. Beschreibst du ein einzelnes Motiv?"); if (!ok) return; } const hit = GESCHUETZT.find(g => low.includes(g)); if (hit) { alert("Dieser Begriff ist markenrechtlich geschützt — das dürfen wir leider nicht zeichnen. Beschreib das Motiv stattdessen allgemein."); return; } const i = pendingWishSlot; state.slots[i].symbolId = "wunsch"; state.slots[i].wish = txt.slice(0, 120); state.slots[i].scale = state.slots[i].scale || 1; wishBg.classList.remove("open"); selectSlot(i); }); // ============================================================================ // SHOPIFY-WARENKORB · Größen live aus dem Produkt + echter /cart/add.js // ============================================================================ const PP_SIZE_RE = /^(XXS|XS|S|M|L|XL|XXL|XXXL|2XL|3XL|4XL|5XL)$/i; function ppEqi(a, b) { return String(a == null ? "" : a).trim().toLowerCase() === String(b == null ? "" : b).trim().toLowerCase(); } function ppVariantOpts(v) { return [v.option1, v.option2, v.option3].filter(x => x != null && x !== ""); } function ppVariantSize(v) { return ppVariantOpts(v).find(o => PP_SIZE_RE.test(String(o).trim())); } function ppVariantColor(v) { return ppVariantOpts(v).find(o => !PP_SIZE_RE.test(String(o).trim())); } function ppCurrentColorName() { const sw = document.querySelector(".swatch:not(.ink-swatch).active"); return sw ? (sw.getAttribute("aria-label") || "").trim() : ""; } function ppFindVariant(colorName, size) { if (!PP_PRODUCT || !PP_PRODUCT.variants) return null; return PP_PRODUCT.variants.find(v => ppEqi(ppVariantColor(v), colorName) && ppEqi(ppVariantSize(v), size)) || null; } function ppRefreshSizeAvailability() { const row = document.getElementById("sizeRow"); if (!row || !PP_PRODUCT) return; const color = ppCurrentColorName(); row.querySelectorAll(".size-btn").forEach(b => { const v = ppFindVariant(color, b.dataset.size); const ok = !!(v && v.available); b.disabled = !ok; if (!ok && b.classList.contains("active")) { b.classList.remove("active"); if (state.size === b.dataset.size) state.size = null; } }); } function ppBuildSizes() { const row = document.getElementById("sizeRow"); if (!row || !PP_PRODUCT) return; const seen = []; PP_PRODUCT.variants.forEach(v => { const s = ppVariantSize(v); if (s && !seen.includes(s)) seen.push(s); }); row.innerHTML = 'Größe:'; seen.forEach(sz => { const b = document.createElement("button"); b.type = "button"; b.className = "size-btn"; b.textContent = sz; b.dataset.size = sz; if (state.size === sz) b.classList.add("active"); b.addEventListener("click", () => { if (b.disabled) return; state.size = sz; row.querySelectorAll(".size-btn").forEach(x => x.classList.remove("active")); b.classList.add("active"); }); row.appendChild(b); }); ppRefreshSizeAvailability(); } function ppGateColors() { if (!PP_PRODUCT) return; const colors = new Set(PP_PRODUCT.variants.map(v => String(ppVariantColor(v) || "").trim().toLowerCase())); document.querySelectorAll(".swatch:not(.ink-swatch)").forEach(sw => { const name = (sw.getAttribute("aria-label") || "").trim().toLowerCase(); const ok = colors.has(name); sw.disabled = !ok; sw.style.opacity = ok ? "" : "0.3"; if (!ok) sw.title = "in dieser Farbe (noch) nicht verfügbar"; }); } async function ppLoadProduct() { const row = document.getElementById("sizeRow"); try { const r = await fetch("/products/" + PP_PRODUCT_HANDLE + ".js", { headers: { "Accept": "application/json" } }); if (!r.ok) throw new Error("HTTP " + r.status); PP_PRODUCT = await r.json(); ppBuildSizes(); ppGateColors(); } catch (e) { console.warn("[PP] Produkt '" + PP_PRODUCT_HANDLE + "' nicht ladbar:", e); if (row) row.innerHTML = 'Größe:Produkt nicht gefunden — Handle prüfen.'; } // Wunschsymbol-Aufpreis-Produkt laden (für die 4,99-€-Positionen) try { const rw = await fetch("/products/" + PP_WISH_HANDLE + ".js", { headers: { "Accept": "application/json" } }); if (rw.ok) { const wp = await rw.json(); PP_WISH_VARIANT_ID = wp.variants && wp.variants[0] && wp.variants[0].id; } } catch (e) { console.warn("[PP] Wunschsymbol-Produkt '" + PP_WISH_HANDLE + "' nicht ladbar:", e); } } async function ppAddToCart() { const colorName = ppCurrentColorName(); const size = state.size; const row = document.getElementById("sizeRow"); if (!size) { if (row) { row.scrollIntoView({ behavior: "smooth", block: "center" }); row.querySelectorAll(".size-btn:not(:disabled)").forEach(b => { b.style.boxShadow = "0 0 0 2px var(--peach-600)"; setTimeout(() => { b.style.boxShadow = ""; }, 1200); }); } alert("Bitte zuerst eine Größe wählen."); return; } if (!PP_PRODUCT) { alert("Das Produkt lädt noch — einen Moment, dann nochmal."); return; } if (state.slots.filter(s => s.symbolId).length === 0) { alert("Füge mindestens ein Motiv hinzu, bevor du in den Warenkorb legst."); return; } const v = ppFindVariant(colorName, size); if (!v) { alert("Die Kombination „" + colorName + " / " + size + "“ ist gerade nicht verfügbar. Bitte andere Farbe oder Größe wählen."); return; } const props = designProperties(); // Größe steckt bereits in der Variante (zeigt Shopify als „Größe: XS") — // keine zusätzliche Property nötig, sonst stünde sie doppelt im Warenkorb. const items = [{ id: v.id, quantity: 1, properties: props }]; // Pro genutztem Wunsch-Slot eine eigene 4,99-€-Position mit dem Wunschtext. let wishCount = 0; state.slots.forEach((s, i) => { if (s.symbolId === "wunsch") { wishCount++; if (PP_WISH_VARIANT_ID) items.push({ id: PP_WISH_VARIANT_ID, quantity: 1, properties: { "Wunsch": (s.wish || "—"), "_Slot": String(i + 1) } }); } }); if (wishCount > 0 && !PP_WISH_VARIANT_ID) { alert("Der Wunschsymbol-Aufpreis konnte gerade nicht geladen werden. Bitte lade die Seite neu und versuch es nochmal."); return; } const btn = document.getElementById("addToCart"); const cta = document.getElementById("ctaCart"); const prev = btn ? btn.textContent : ""; if (btn) { btn.disabled = true; btn.textContent = "Wird hinzugefügt …"; } if (cta) cta.disabled = true; try { const r = await fetch("/cart/add.js", { method: "POST", headers: { "Content-Type": "application/json", "Accept": "application/json" }, body: JSON.stringify({ items: items }), }); if (!r.ok) { const t = await r.text().catch(() => ""); throw new Error(t || ("HTTP " + r.status)); } window.location.href = PP_REDIRECT_AFTER_ADD; } catch (e) { console.error("[PP] /cart/add.js fehlgeschlagen:", e); alert("Hoppla — das Hinzufügen hat nicht geklappt. Bitte nochmal versuchen."); if (btn) { btn.disabled = false; btn.textContent = prev; } if (cta) cta.disabled = false; } } const modalBg = document.getElementById("modalBg"); document.getElementById("ctaCart").addEventListener("click", () => { document.getElementById("addToCart").click(); }); document.getElementById("addToCart").addEventListener("click", ppAddToCart); document.getElementById("copyDesignJson").addEventListener("click", async () => { const dj = designProperties()["_design_json"]; try { await navigator.clipboard.writeText(dj); alert("Kopiert — unten bei Bestellung-laden einfügen."); } catch (e) { window.prompt("Manuell kopieren:", dj); } }); document.getElementById("modalClose").addEventListener("click", () => modalBg.classList.remove("open")); modalBg.addEventListener("click", e => { if (e.target === modalBg) modalBg.classList.remove("open"); }); // ---- Druckdatei: Gelato-Rückendruck 305 × 406 mm @ 300 DPI ---------------------- // 305/25.4*300 = 3602 px · 406/25.4*300 = 4795 px · transparenter Hintergrund. // Der pHYs-Patch unten schreibt echte 300-DPI-Metadaten ins PNG (Canvas liefert // sonst 72 DPI im Header, auch wenn die Pixelmaße stimmen). // CRC32 für den PNG-Chunk (Standard-Implementierung, einmalige Tabelle) const CRC_TABLE = (() => { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } return t; })(); function crc32(bytes) { let c = 0xffffffff; for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; } // Fügt nach dem IHDR-Chunk (endet bei Byte 33) einen pHYs-Chunk mit 300 DPI ein. function withDpi300(pngBytes) { const PPM = 11811; // 300 dpi ≈ 11811 Pixel/Meter const chunk = new Uint8Array(21); const dv = new DataView(chunk.buffer); dv.setUint32(0, 9); // length chunk.set([0x70, 0x48, 0x59, 0x73], 4); // "pHYs" dv.setUint32(8, PPM); dv.setUint32(12, PPM); chunk[16] = 1; // unit: meter dv.setUint32(17, crc32(chunk.subarray(4, 17))); const out = new Uint8Array(pngBytes.length + 21); out.set(pngBytes.subarray(0, 33), 0); out.set(chunk, 33); out.set(pngBytes.subarray(33), 54); return out; } document.getElementById("exportPng").addEventListener("click", async () => { const W = 3602, H = 4795; // 305 × 406 mm @ 300 DPI const canvas = document.createElement("canvas"); canvas.width = W; canvas.height = H; const ctx = canvas.getContext("2d"); const ink = state.inkColor; // transparenter Hintergrund (Druck auf Stoff) — Symbole in Stofffarbe-Kontrast const tasks = state.slots.map((slot, i) => { if (!slot.symbolId || !activeLayout()[i]) return Promise.resolve(); if (slot.symbolId === "wunsch" && !slot.customSvg) return Promise.resolve(); // leerer Wunsch-Platzhalter wird nicht gedruckt const mult = slot.scale || 1; const sym = bySym(slot.symbolId); const rawSvg = slot.customSvg || sym.svg; // Wunsch-Upload: gezeichnetes SVG statt Platzhalter const L = activeLayout()[i] || { x: 50, y: 50, w: 14, h: 11 }; // Seitenverhältnis IMMER aus der viewBox — img.width/height ist unzuverlässig // (viele SVGs haben fixe width/height="960" trotz nicht-quadratischer viewBox → verzerrter Export) let ar = 1; const vbm = (rawSvg.match(/viewBox="([^"]+)"/) || [])[1]; if (vbm) { const p = vbm.trim().split(/[\s,]+/).map(Number); if (p.length === 4 && p[2] && p[3]) ar = p[2] / p[3]; } // identische Geometrie wie die Vorschau: Layout-% → Druck-Pixel, Symbol "meet" in die Box const boxW = (L.w / 100) * W * mult; const boxH = ((L.h || L.w) / 100) * H * mult; let dw = boxW, dh = boxW / ar; if (dh > boxH) { dh = boxH; dw = boxH * ar; } // Quelle einfärben + fixe width/height im Root- durch die echte Zielgröße ersetzen, // damit der Browser exakt in dw×dh rastert (kein 960er-Quadrat-Letterbox mehr) const svgMarkup = rawSvg.split("currentColor").join(ink).replace(/]*?>/, tag => tag.replace(/\s+(width|height)="[^"]*"/g, "").replace(/ { const img = new Image(); img.onload = () => { const pos = clampPos(L, mult); const px = Math.max(1, Math.min(99, pos.x + (slot.dx || 0))); const py = Math.max(1, Math.min(99, pos.y + (slot.dy || 0))); ctx.save(); ctx.translate((px / 100) * W, (py / 100) * H); ctx.rotate(((slot.rot || 0) * Math.PI) / 180); if (slot.flip) ctx.scale(-1, 1); ctx.drawImage(img, -dw / 2, -dh / 2, dw, dh); ctx.restore(); URL.revokeObjectURL(url); resolve(); }; img.onerror = () => resolve(); img.src = url; }); }); await Promise.all(tasks); const withText = state.texts.filter(t => t.text); if (withText.length) { try { await document.fonts.load('130px "Chewy"'); } catch (e) { /* offline → Fallback */ } ctx.fillStyle = ink; ctx.textAlign = "center"; ctx.textBaseline = "middle"; for (const t of withText) { ctx.font = Math.round(((t.fs || 7) / 100) * W) + 'px "Chewy", Georgia, serif'; ctx.fillText(t.text, (t.x / 100) * W, (t.y / 100) * H); } } // PNG erzeugen → 300-DPI-Metadaten patchen → Download const dataUrl = canvas.toDataURL("image/png"); const raw = atob(dataUrl.split(",")[1]); const bytes = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i); const blob = new Blob([withDpi300(bytes)], { type: "image/png" }); const a = document.createElement("a"); a.download = "pencil-poetry-rueckendruck-305x406mm-300dpi.png"; a.href = URL.createObjectURL(blob); a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); }); // ---- Design-JSON Export -------------------------------------------------------- document.getElementById("exportDesignJson").addEventListener("click", () => { const props = designProperties(); const json = props["_design_json"]; const blob = new Blob([json], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); const ts = new Date().toISOString().slice(0,16).replace("T","_").replace(/:/g,"-"); a.download = "pp-design-" + ts + ".json"; a.click(); }); // ---- Init ------------------------------------------------------------------------ // Entwurfwiederherstellen — platzierte Symbole und Texte überleben das Neuladen try { const d = JSON.parse(localStorage.getItem("pp.design.draft") || "null"); if (d && Array.isArray(d.slots)) { state.slots = d.slots.slice(0, SLOT_COUNT).map(s => s || { symbolId: null, scale: 1, rot: 0 }); while (state.slots.length < SLOT_COUNT) state.slots.push({ symbolId: null, scale: 1, rot: 0 }); if (d.texts && d.texts.length) state.texts = d.texts; if (d.ink) state.inkColor = d.ink; if (d.shirt) state.shirtColor = d.shirt; if (d.size) state.size = d.size; if (d.layoutStyle && LAYOUT_STYLES[d.layoutStyle]) state.layoutStyle = d.layoutStyle; shirtBody.setAttribute("fill", state.shirtColor); } } catch (e) { /* ignorieren */ } // Erster Besuch (kein gespeicherter Entwurf): Bestseller-Design zeigen if (state.slots.every(s => !s.symbolId)) { applyTemplate(TEMPLATES[0]); } document.querySelector(".stage").style.setProperty("--shirtcol", state.shirtColor); function saveDraft() { try { localStorage.setItem("pp.design.draft", JSON.stringify({ slots: state.slots, texts: state.texts, ink: state.inkColor, shirt: state.shirtColor, size: state.size, layoutStyle: state.layoutStyle, })); } catch (e) {} } setInterval(saveDraft, 1500); document.getElementById("loadOrder").addEventListener("click", () => { let raw = document.getElementById("orderJson").value.trim(); let d; try { d = JSON.parse(raw); } catch (e) { alert("Das ist kein gültiges JSON — bitte die Bestelldaten oder den _design_json-Wert einfügen."); return; } // Auch das komplette Properties-Paket akzeptieren: _design_json herausziehen if (d && !Array.isArray(d.slots) && typeof d._design_json === "string") { try { d = JSON.parse(d._design_json); } catch (e) { /* fällt unten durch */ } } if (typeof d === "string") { try { d = JSON.parse(d); } catch (e) {} } if (!d || !Array.isArray(d.slots)) { alert("Keine Design-Slots gefunden — bitte den kompletten Bestelldaten-Block oder den _design_json-Wert einfügen."); return; } // Layout-Stil setzen const loadedLayout = (d.layout && LAYOUT_STYLES[d.layout]) ? d.layout : state.layoutStyle; state.layoutStyle = loadedLayout; document.querySelectorAll(".layout-btn").forEach(b => { b.style.borderColor = b.dataset.layout === loadedLayout ? "var(--peach-600)" : ""; }); // Slot-Positionen (x/y/w/h) aus v5-JSON direkt in LAYOUT_STYLES schreiben // → das Kundendesign wird pixelgenau rekonstruiert if (d.v >= 5 && LAYOUT_STYLES[loadedLayout]) { d.slots.forEach((s, i) => { if (!s || s.x === undefined) return; if (!LAYOUT_STYLES[loadedLayout][i]) LAYOUT_STYLES[loadedLayout][i] = {}; LAYOUT_STYLES[loadedLayout][i].x = s.x; LAYOUT_STYLES[loadedLayout][i].y = s.y; LAYOUT_STYLES[loadedLayout][i].w = s.w; LAYOUT_STYLES[loadedLayout][i].h = s.h; }); } // Slot-Anzahl aus dem Design übernehmen (Designs können 17/20/25 Slots haben) — // vorher war das fest auf SLOT_COUNT (17), wodurch größere Designs abgeschnitten wurden. const loadedCount = Math.max(d.slots.length, SLOT_COUNT); state.usingTemplateLayout = true; // geladene Positionen schützen state.slots = Array.from({ length: loadedCount }, (_, i) => { const s = d.slots[i]; return s && s.id ? { symbolId: s.id, scale: s.scale || 1, rot: s.rot || 0, dx: s.dx || 0, dy: s.dy || 0, wish: s.wish } : { symbolId: null, scale: 1, rot: 0 }; }); if (d.ink) { state.inkColor = d.ink; document.querySelectorAll(".ink-swatch").forEach(sw => { sw.classList.toggle("active", sw.dataset.ink === d.ink); }); } if (d.shirt) { state.shirtColor = d.shirt; shirtBody.setAttribute("fill", d.shirt); document.querySelector(".stage").style.setProperty("--shirtcol", d.shirt); document.querySelectorAll(".swatch:not(.ink-swatch)").forEach(sw => { sw.classList.toggle("active", sw.dataset.color === d.shirt); }); } if (d.texts) { state.texts = d.texts.map(t => ({ ...t })); document.getElementById("textFields").innerHTML = ""; } state.selected = null; renderSlots(); renderName(); alert("Design geladen — Vorschau prüfen, dann Druckdatei exportieren."); }); // Wunsch-Upload (Betreiber): gezeichnete SVG in den ausgewählten Slot setzen const wishUploadEl = document.getElementById("wishUpload"); if (wishUploadEl) wishUploadEl.addEventListener("change", (e) => { const file = e.target.files && e.target.files[0]; e.target.value = ""; if (!file) return; const i = state.selected; if (i == null) { alert("Bitte zuerst den Wunsch-Slot im Design anklicken, dann die SVG hochladen."); return; } const reader = new FileReader(); reader.onload = () => { const svg = String(reader.result || ""); const m = svg.match(//i); if (!m) { alert("Das sieht nicht nach einer gültigen SVG-Datei aus."); return; } state.slots[i].customSvg = m[0]; if (!state.slots[i].symbolId) state.slots[i].symbolId = "wunsch"; renderSlots(); renderName(); alert("Wunschsymbol eingesetzt — jetzt positionieren/skalieren, dann „Druckdatei (PNG)\"."); }; reader.readAsText(file); }); // Mobile: Farb-/Layout-Optionen in ein aufklappbares Element bündeln, // damit die angepinnte Shirt-Vorschau kompakt bleibt (function () { const stage = document.querySelector(".stage"); const det = document.createElement("details"); det.id = "stageControls"; det.open = window.innerWidth >= 880; const sum = document.createElement("summary"); sum.textContent = "⚙️ Layout & Extras"; det.appendChild(sum); const movers = [...stage.querySelectorAll(":scope > .controls:not(.keep-visible), :scope > .hint")]; movers.forEach(el => det.appendChild(el)); const price = stage.querySelector(".price"); stage.appendChild(det); if (price) stage.appendChild(price); })(); // Mobil: Shirt verkleinert sich beim Scrollen (Kontext bleibt, Picker bekommt Platz) if (window.matchMedia("(max-width: 879px)").matches) { const stageEl = document.querySelector(".stage"); const panelEl = document.querySelector(".panel"); let ticking = false; window.addEventListener("scroll", () => { if (ticking) return; ticking = true; requestAnimationFrame(() => { // erst umschalten, wenn Schritt 2 (Picker) im Blick ist — ±40px gegen Flackern const trigger = panelEl.offsetTop - 300; const isShrunk = stageEl.classList.contains("shrunk"); if (!isShrunk && window.scrollY > trigger + 40) stageEl.classList.add("shrunk"); else if (isShrunk && window.scrollY < trigger - 40) stageEl.classList.remove("shrunk"); ticking = false; }); }, { passive: true }); } // Werkzeuge (Größe/Neigung/Position) direkt unters Design — beim Scrollen // bleiben sie mit der angepinnten Vorschau sichtbar, statt im Picker unterzugehen (function () { const stage = document.querySelector(".stage"); const area = stage.querySelector(".shirt-area"); area.after(toolsEl); if (window.matchMedia("(max-width: 879px)").matches) { document.getElementById("clearSlot").textContent = "✕"; } toolsEl.style.justifyContent = "center"; toolsEl.style.marginTop = "8px"; })(); renderTabs(); renderGrid(); renderSlots(); renderName(); ppLoadProduct();