diff --git a/.gitignore b/.gitignore index 28e4c9f..cbcf3ae 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ __pycache__/ /icons/ .claude/worktrees/ Ban Yaro - Google Play package/ +/unsplash/ diff --git a/MARKETING.md b/MARKETING.md new file mode 100644 index 0000000..d232fe2 --- /dev/null +++ b/MARKETING.md @@ -0,0 +1,72 @@ +# đŸŸ Ban Yaro — Marketing-Cockpit + +**Single Source of Truth fĂŒrs Marketing.** Vor jeder Aktion hier prĂŒfen, danach updaten — so wird nichts doppelt gemacht, vergessen oder ĂŒbersehen. Pflege: RenĂ© + Claude. + +_Stand: 2026-06-03_ + +> Diese Datei = Planung & Checkliste. FĂŒr **Live-Daten** (User-Meilenstein, Kanal-Tracking) lohnt zusĂ€tzlich ein Marketing-Tab im **Admin-Bereich** — siehe „Ausbau" unten. + +## 📊 Kanal-Überblick +| Kanal / Bereich | Status | NĂ€chster Schritt | +|---|---|---| +| Flyer Print | 🟱 1000 gedruckt (03.06.) | lokal verteilen | +| Flyer Digital | 💡 Idee | Doppelseiten-PDF + Empfehlungs-QR | +| Lokal (Ebersberg) | ⬜ offen | TierĂ€rzte, Hundeschulen, FutterlĂ€den, Tierheim | +| Online-Communities | ⬜ offen | FB-Gruppen Landkreis EBE + nebenan.de | +| Empfehlung / Referral | 🟡 Infra da (`referral_code`) | Empfehlungs-QR + Tracking sichtbar machen | +| Influencer | 🟡 2 Runden (Mai), kaum Resonanz | Runde 3 erst ab ~50 aktiven Usern | +| Presse / Blogs | 🟡 1 Runde, kaum Resonanz | keine Massenwelle; Nische zuerst | +| Verzeichnisse / Listings | ⬜ offen | Product Hunt, PWA-Dirs, Google Business EBE | +| SEO / KI-Auffindbarkeit | 🟡 technisch optimiert | Backlinks (Blog-Testberichte) | +| Landing Page | 🟡 Redesign-Briefing da | 3 Einstiege, Outcomes statt Features | +| App Store (iOS) | 🟱 in Review (1.0 (3), 03.06.) | Freigabe abwarten | +| Play Store (Android) | 🔮 ON HOLD | 12 Closed-Tester / 14 Tage fehlen | +| Merch / NFC-Halsband | 💡 recherchiert | 20 Tags fĂŒr Beta (~33 €) | + +Legende: 🟱 lĂ€uft/erledigt · 🟡 angefangen · ⬜ offen · 💡 Idee · 🔮 blockiert + +## ⏳ Gates / Trigger (nicht zu frĂŒh starten) +- **Influencer & Presse Runde 3** erst ab **~50 aktiven Usern** — vorher zu frĂŒh (Großredaktionen fragen zuerst nach Zahlen). → Bei jeder Session aktuelle User-Zahl checken. +- iOS-App ist nativ gebaut & in Review — **ĂŒberholt** die alte „iOS erst ab 10k via Rork/PWABuilder"-Strategie. + +## 📋 Backlog (konkret als NĂ€chstes) +- [ ] **Flyer lokal verteilen (Ebersberg)** — TierĂ€rzte (Wartezimmer), Hundeschulen/Welpengruppen, FutterlĂ€den, Hundesalons, Tierheim, Hundewiesen-AushĂ€nge, hundefreundliche CafĂ©s. Persönlich erklĂ€ren; AufhĂ€nger: Giftköder-Radar + „Daten in Deutschland". **Lokal bĂŒndeln, nicht streuen** (Community-Dichte fĂŒr Gassi-Treffen/Giftköder). +- [ ] **Digitaler Doppelseiten-Flyer (PDF)** mit **Empfehlungs-QR** fĂŒr Online-/Gruppen-Verteilung. Quelle: `promotion/flyer_a5_*.html`. _Offene Frage: generischer `?ref=empfehlung`-Link vs. pro-User `referral_code`._ +- [ ] **Lokale FB-Gruppen + nebenan.de** — Flyer-Foto + Link posten. +- [ ] **Verzeichnisse** — Product Hunt, progressivewebappstore.com, pwafire.org/directory, Google Business (Ebersberg). +- [ ] **Landing-Page-Redesign** nach Briefing (3 Zielgruppen-Einstiege Hundebesitzer/ZĂŒchter/WelpenkĂ€ufer, Outcomes statt Features, ZĂŒchter-SaaS prominent, Datenschutz als Argument, GrĂŒnder-Story + Foto). +- [ ] **Messung einbauen** — „Wie hast du von uns gehört?" im Onboarding + QR-refs pro Kanal. + +## ✅ Erledigt +- [x] 1000 Flyer A5 (zweiseitig) gedruckt — 03.06.2026 +- [x] iOS-App nativ gebaut + eingereicht (1.0 (3), in Review) — Details im Repo `banyaro-ios` +- [x] Influencer-Outreach Runde 1 (5) + Runde 2 (13) — Mai 2026 +- [x] SEO-Grundlagen (llms.txt, Landing About-Section) + +## 📈 Messung — was bringt wirklich Nutzer? +- **Onboarding-Frage „Wie hast du von uns gehört?"** (1 Klick) = billigste & wichtigste Kontrolle. _(noch einzubauen)_ +- **QR-refs pro Ort/Kanal** (z. B. `banyaro.app/?ref=tierarzt-grafing`) → ab nĂ€chster Flyer-Charge. +- **`referral_code`** (in DB, `routes/auth.py`) → Empfehlungen zĂ€hlbar. +- Aktive User aktuell: _[aus Admin eintragen]_ + +## 🗂 Details je Kanal + +### Influencer +2 Runden im Mai gesendet (`partner@banyaro.app`; DKIM/SPF/DMARC aktiv), **kaum Resonanz** — zu frĂŒh (wenige User), teils falsche Adressen (z. B. GEO → richtig `chefredaktion@geo.de`). +**Runde 3:** keine Massenwelle ohne PR-Agentur; **Hundeschulen/-trainer zuerst** (kleines Netzwerk, empfehlen aktiv Tools, Trainingsfeature ist stark), persönliche Mails, AufhĂ€nger = neue Features + echte Nutzerzahlen. +→ **Wer schon kontaktiert wurde:** AI-Memory `project_influencer_outreach` (Runde 1: verpinscht, missyminzi, wanderlust_samoyed, viviundholly, doguniversity, dogstv; Runde 2: nami.and.tommy, brina.explores, heimatherzen, pfotentick, flummis_diary, verwolft, wildwildwilli, knutini_, ninja.vom.wolfstor, pupsonality, osman_theparson, babybearyuki, dogswiss). **Vor neuer Runde dort prĂŒfen.** + +### Play Store (Android TWA) +PWABuilder-Paket fertig (`Ban Yaro - Google Play package/`, Package `app.banyaro.twa`). **BLOCKER:** Google verlangt 12 Closed-Tester ĂŒber 14 Tage — Tester fehlen (Engpass, nicht die Technik). assetlinks.json + Play-Console-Eintrag stehen bereit. Nicht priorisieren bis Tester da. + +### Merch / NFC-Halsband +Tag recherchiert: **HID Laundry Tag 16 mm** (shopnfc, SKU RE-ICO2-16, ~1 €/Stk ab 500), fĂŒr `banyaro.app/hund/{id}`. Beta: 20 Stk (~33 €) an erste Nutzer. + +### Flyer +Print: A5 zweiseitig, Quelle `promotion/flyer_a5_allgemein.html` + `flyer_a5_rueckseite.html`, QR → banyaro.app. Vorderseite = alle Hundebesitzer, RĂŒckseite stark ZĂŒchter-fokussiert. + +## 🚀 Ausbau: Live-Tool im Admin-Bereich (optional) +Diese Datei deckt Planung/Checkliste ab (Claude pflegt sie). Der **Admin-Bereich** lohnt sich fĂŒr die Teile mit echten Daten: +- **User-Meilenstein-Anzeige** (aktive User) → blendet automatisch den „Outreach Runde 3"-Hinweis ein, sobald ~50 erreicht. +- **Kanal-Tracking**: Auswertung „Wie gehört?" + QR-ref-ZĂ€hler + `referral_code`-Statistik. +- Optional: das Kanal-Board (Status/Backlog) als editierbare Admin-Seite. diff --git a/VERSION b/VERSION index 41edc23..0948691 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1141 \ No newline at end of file +1155 \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index df5124d..e954c83 100644 --- a/backend/main.py +++ b/backend/main.py @@ -511,11 +511,11 @@ async def sitemap(): urls = [ ("https://banyaro.app/", "weekly", "1.0"), ("https://banyaro.app/zuechter", "weekly", "0.9"), - ("https://banyaro.app/info", "monthly", "0.8"), - ("https://banyaro.app/presse", "monthly", "0.7"), - ("https://banyaro.app/wiki/rassen", "weekly", "0.8"), - ("https://banyaro.app/knigge", "monthly", "0.7"), ("https://banyaro.app/wurfboerse", "daily", "0.8"), + ("https://banyaro.app/wiki/rassen", "weekly", "0.8"), + ("https://banyaro.app/help", "monthly", "0.7"), + ("https://banyaro.app/knigge", "monthly", "0.7"), + ("https://banyaro.app/partner", "monthly", "0.6"), ] try: @@ -526,12 +526,6 @@ async def sitemap(): for r in rassen: urls.append((f"https://banyaro.app/wiki/rasse/{r['slug']}", "monthly", "0.7")) - events = conn.execute( - "SELECT id FROM events WHERE datum >= date('now') LIMIT 200" - ).fetchall() - for e in events: - urls.append((f"https://banyaro.app/api/events/{e['id']}", "weekly", "0.5")) - # Öffentliche ZĂŒchter-Profile breeders = conn.execute( "SELECT bp.zwingername FROM breeder_profiles bp " @@ -1348,12 +1342,47 @@ async def public_dog_page(dog_id: int): # ------------------------------------------------------------------ @app.get("/teilen/{token}") async def invite_page(token: str): - return FileResponse(f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-store, no-cache"}) + from fastapi.responses import HTMLResponse + with open(f"{STATIC_DIR}/index.html", encoding="utf-8") as _f: + _html = _f.read() + _html = _html.replace( + '', + '' + ) + return HTMLResponse(content=_html, headers={"Cache-Control": "no-store, no-cache"}) @app.get("/breeder/{zwingername}") async def breeder_profile_page(zwingername: str): - return FileResponse(f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-store, no-cache"}) + from fastapi.responses import HTMLResponse + from urllib.parse import unquote + from database import db as _db + import html as _html_mod + name = unquote(zwingername) + desc = f"HundezĂŒchter {_html_mod.escape(name)} auf Ban Yaro — Wurfbörse, Stammbaum und mehr." + try: + with _db() as conn: + bp = conn.execute( + "SELECT bp.rasse, bp.beschreibung FROM breeder_profiles bp " + "JOIN users u ON u.id = bp.user_id WHERE bp.zwingername=? AND u.rolle='breeder' LIMIT 1", + (name,) + ).fetchone() + if bp and bp["beschreibung"]: + desc = _html_mod.escape(bp["beschreibung"][:160]) + except Exception: + pass + with open(f"{STATIC_DIR}/index.html", encoding="utf-8") as _f: + _page = _f.read() + _page = _page.replace( + '', + f'' + ).replace( + 'Ban Yaro', + f'{_html_mod.escape(name)} — HundezĂŒchter auf Ban Yaro' + f'\n ' + f'\n ' + ) + return HTMLResponse(content=_page, headers={"Cache-Control": "no-store, no-cache"}) @app.get("/litters") @@ -1471,6 +1500,7 @@ async def ausweis_page(dog_id: int, request: Request): + Heimtierausweis – {esc(dog["name"])} +
@@ -1727,7 +1765,9 @@ async def help_page(): k for k in by_kat.keys() if k not in KAT_LABEL ] + import json as _json sections_html = "" + faq_items = [] for kat in kat_order: label = KAT_LABEL.get(kat, kat.replace("_", " ").title()) items = "".join( @@ -1736,6 +1776,13 @@ async def help_page(): for a in by_kat[kat] ) sections_html += f'

{_html.escape(label)}

{items}
' + for a in by_kat[kat]: + faq_items.append({ + "@type": "Question", + "name": a["frage"], + "acceptedAnswer": {"@type": "Answer", "text": a["antwort"]} + }) + faq_json_ld = _json.dumps(faq_items, ensure_ascii=False) html = f""" @@ -1744,6 +1791,8 @@ async def help_page(): Hilfe & FAQ — Ban Yaro + + + @@ -1851,6 +1905,8 @@ async def konto_loeschen(): Konto löschen — Ban Yaro + + + + +
+

Wurfbörse

+

Hundewelpen von geprĂŒften ZĂŒchtern

+
+
+

{count_text}

+ {litters_html or '

Aktuell keine WĂŒrfe eingetragen.
Schau bald wieder vorbei!

'} + +
+ +""" + return HTMLResponse(content=html, headers={"Cache-Control": "max-age=1800"}) + + # SPA Fallback — ALLE nicht-API-Routen gehen zur index.html @app.get("/{full_path:path}") async def spa_fallback(full_path: str): diff --git a/backend/routes/forum.py b/backend/routes/forum.py index f8ee5e0..33eb726 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -586,6 +586,25 @@ async def toggle_like(data: LikeBody, user=Depends(get_current_user)): return {"liked": liked, "count": count} +# ------------------------------------------------------------------ +# GET /api/forum/likes/{target_type}/{target_id} — Wer hat geliked? +# ------------------------------------------------------------------ +@router.get("/likes/{target_type}/{target_id}") +async def list_likers(target_type: str, target_id: int): + if target_type not in _LIKE_TABLE: + raise HTTPException(400, "UngĂŒltiger Typ.") + with db() as conn: + rows = conn.execute( + """SELECT u.name AS name, u.founder_number AS founder_number + FROM forum_likes fl + JOIN users u ON u.id = fl.user_id + WHERE fl.target_type = ? AND fl.target_id = ? + ORDER BY fl.id DESC""", + (target_type, target_id) + ).fetchall() + return [dict(r) for r in rows] + + # ------------------------------------------------------------------ # POST /api/forum/report # ------------------------------------------------------------------ diff --git a/backend/routes/osm.py b/backend/routes/osm.py index 5fc22b9..71ed5d2 100644 --- a/backend/routes/osm.py +++ b/backend/routes/osm.py @@ -423,3 +423,65 @@ async def submit_poi_edit(osm_id: str, data: PoiEditCreate, poi[data.field], data.new_value.strip(), user["id"]) ) return {"status": "pending", "message": "Korrektur wurde zur PrĂŒfung eingereicht."} + + +# ------------------------------------------------------------------ +# Geocoding-Proxy GET /api/osm/geocode?q=
 +# Nominatim-Rate-Limit: 1 req/s — serverseitig throttled +# ------------------------------------------------------------------ +_nominatim_sem = asyncio.Semaphore(1) +_nominatim_last = 0.0 + +@router.get('/geocode') +async def geocode_search(q: str = Query(..., min_length=2, max_length=200)): + import time + global _nominatim_last + async with _nominatim_sem: + wait = 1.1 - (time.monotonic() - _nominatim_last) + if wait > 0: + await asyncio.sleep(wait) + _nominatim_last = time.monotonic() + try: + async with httpx.AsyncClient(timeout=6.0) as client: + resp = await client.get( + 'https://nominatim.openstreetmap.org/search', + params={ + 'q': q, + 'format': 'jsonv2', + 'limit': 6, + 'countrycodes': 'de,at,ch', + 'addressdetails': 1, + 'accept-language': 'de', + }, + headers={ + 'User-Agent': _OVERPASS_UA, + 'Referer': 'https://banyaro.app/', + } + ) + resp.raise_for_status() + data = resp.json() + except Exception as e: + logger.warning("Nominatim-Fehler: %s", e) + raise HTTPException(502, "Geocoding nicht verfĂŒgbar") + + out = [] + for r in data[:6]: + addr = r.get('address', {}) + short = ( + addr.get('amenity') or addr.get('shop') or addr.get('leisure') or + addr.get('road') or addr.get('village') or addr.get('town') or + addr.get('city') or r.get('name') or + r.get('display_name', '').split(',')[0] + ) + city = addr.get('city') or addr.get('town') or addr.get('village') or addr.get('municipality') or '' + state = addr.get('state', '') + subtitle = ', '.join(filter(None, [city, state])) + out.append({ + 'lat': float(r['lat']), + 'lon': float(r['lon']), + 'name': short, + 'subtitle': subtitle, + 'full': r.get('display_name', ''), + 'type': r.get('type', ''), + }) + return out diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 1f724ec..1f22ba8 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -3256,6 +3256,182 @@ html.modal-open { } } +/* Orts-Suche — Panel schiebt von oben rein wenn aktiv */ +.map-search-wrap { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 1002; + padding: 8px 12px 4px; + background: rgba(255,255,255,0.97); + backdrop-filter: blur(8px); + box-shadow: 0 3px 14px rgba(0,0,0,0.18); + transform: translateY(-110%); + transition: transform 0.22s ease; + pointer-events: none; +} +.map-search-wrap.active { + transform: translateY(0); + pointer-events: auto; +} +:root[data-theme="dark"] .map-search-wrap { background: rgba(22,22,24,0.97); } +.map-search-row { + display: flex; + align-items: center; + gap: 8px; + background: var(--c-bg, #fff); + border-radius: var(--radius-full); + border: 1px solid var(--c-border, #e5e7eb); + padding: 8px 12px; +} +.map-search-input { + flex: 1; + border: none; + outline: none; + font-size: 15px; + font-family: inherit; + background: transparent; + color: var(--c-text); + min-width: 0; +} +.map-search-input::placeholder { color: #aaa; } +.map-search-clear { + background: none; + border: none; + padding: 4px; + cursor: pointer; + color: #999; + line-height: 1; + flex-shrink: 0; + border-radius: 50%; +} +.map-search-clear:hover { color: var(--c-text); background: var(--c-bg-subtle); } +.map-search-results { + background: var(--c-bg, #fff); + border-radius: 12px; + border: 1px solid var(--c-border, #e5e7eb); + margin-top: 6px; + margin-bottom: 4px; + overflow: hidden; + max-height: 240px; + overflow-y: auto; +} +.map-search-item { + padding: 10px 14px; + cursor: pointer; + border-bottom: 1px solid var(--c-border-light, rgba(0,0,0,0.05)); +} +.map-search-item:last-child { border-bottom: none; } +.map-search-item:hover, +.map-search-item:active { background: var(--c-primary-subtle, #fef3c7); } +.map-search-item-name { + font-size: 13px; + font-weight: 600; + color: var(--c-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.map-search-item-sub { + font-size: 11px; + color: var(--c-text-secondary); + margin-top: 1px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.map-search-loading, +.map-search-empty { + padding: 12px 14px; + font-size: 13px; + color: var(--c-text-secondary); + text-align: center; +} + +/* Speed Dial — Ein Trigger-Button, Sub-Buttons fĂ€chern nach oben auf */ +.map-speed-dial { + position: absolute; + bottom: calc(var(--safe-bottom) + 82px); + right: 20px; + z-index: 1000; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: var(--space-2); +} +.map-sd-items { + display: flex; + flex-direction: column-reverse; /* unterste Item = erstes im DOM */ + align-items: flex-end; + gap: var(--space-2); + pointer-events: none; +} +.map-speed-dial.open .map-sd-items { pointer-events: auto; } + +.map-sd-item { + display: flex; + align-items: center; + gap: 10px; + opacity: 0; + transform: translateY(8px) scale(0.88); + transition: opacity 0.16s ease, transform 0.16s ease; +} +.map-speed-dial.open .map-sd-item { opacity: 1; transform: translateY(0) scale(1); } +.map-speed-dial.open .map-sd-item:nth-child(1) { transition-delay: 0ms; } +.map-speed-dial.open .map-sd-item:nth-child(2) { transition-delay: 50ms; } +.map-speed-dial.open .map-sd-item:nth-child(3) { transition-delay: 100ms; } +.map-speed-dial.open .map-sd-item:nth-child(4) { transition-delay: 150ms; } +.map-speed-dial.open .map-sd-item:nth-child(5) { transition-delay: 200ms; } + +.map-sd-label { + background: rgba(20,20,20,0.72); + color: #fff; + font-size: 12px; + font-weight: 600; + padding: 5px 11px; + border-radius: var(--radius-full); + white-space: nowrap; + backdrop-filter: blur(4px); + pointer-events: none; + letter-spacing: 0.01em; +} +.map-sd-btn { + width: 46px; + height: 46px; + border-radius: 50%; + background: #fff; + color: #C4843A; + border: 2px solid rgba(196,132,58,0.25); + box-shadow: 0 2px 8px rgba(0,0,0,0.22); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; + flex-shrink: 0; + transition: background 0.12s, color 0.12s; + -webkit-tap-highlight-color: transparent; +} +.map-sd-btn:hover, +.map-sd-btn:active { background: #fef3c7; } +.map-sd-btn.active { background: #C4843A; color: #fff; border-color: #C4843A; } +.map-sd-btn.map-fab--pin.active { background: var(--c-danger); border-color: var(--c-danger); color: #fff; } +#map-radar-btn.active { background: #1d4ed8; color: #fff; border-color: #1d4ed8; } +#map-temp-btn.active { background: #dc2626; color: #fff; border-color: #dc2626; } + +.map-sd-trigger { + transition: background 0.15s, transform 0.2s ease; +} +.map-speed-dial.open .map-sd-trigger { + background: #6b4a20; + transform: rotate(90deg); +} +.map-sd-icon-open { display: block; } +.map-sd-icon-close { display: none; } +.map-speed-dial.open .map-sd-icon-open { display: none; } +.map-speed-dial.open .map-sd-icon-close { display: block; } + /* FAB-Gruppe rechts unten — direkt ĂŒber dem ZurĂŒck-Button */ .map-fabs { position: absolute; diff --git a/backend/static/index.html b/backend/static/index.html index a85a5a8..da37ccd 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -617,11 +617,11 @@ - - - - - + + + + + @@ -631,7 +631,7 @@ - + diff --git a/backend/static/js/api.js b/backend/static/js/api.js index de39835..7fd420e 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -45,9 +45,12 @@ const API = (() => { throw new APIError(msg, 0, 'network'); } - // Versions-Check: Server meldet neue Version → Banner anzeigen (einmalig) + // Versions-Check: Server meldet neue Version → beim nĂ€chsten navigate() aktualisieren. + // Ausnahme: _BY_SW_RELOAD = wir sind gerade von /force-update weitergeleitet worden. + // In dem Fall ist APP_VER kurzzeitig veraltet (SW-Cache lĂ€uft noch aus) — KEIN erneuter + // Pending setzen, sonst entsteht sofort ein Loop beim nĂ€chsten Seitenwechsel. const serverVer = response.headers.get('x-app-version'); - if (serverVer && serverVer !== APP_VER && !window._byUpdatePending) { + if (serverVer && serverVer !== APP_VER && !window._byUpdatePending && !window._BY_SW_RELOAD) { window._byUpdatePending = true; window._byNewVersion = serverVer; } @@ -439,6 +442,9 @@ const API = (() => { like(targetType, targetId) { return post('/forum/like', { target_type: targetType, target_id: targetId }); }, + likers(targetType, targetId) { + return get(`/forum/likes/${targetType}/${targetId}`); + }, report(targetType, targetId, grund) { return post('/forum/report', { target_type: targetType, target_id: targetId, grund }); }, diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 187a0e1..ce91fdf 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '1141'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1155'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt window.APP_VER = APP_VER; // global verfĂŒgbar fĂŒr andere Module (z.B. offline-indicator) window.APP_VERSION = APP_VERSION; diff --git a/backend/static/js/boot.js b/backend/static/js/boot.js index 9f54463..d1ef297 100644 --- a/backend/static/js/boot.js +++ b/backend/static/js/boot.js @@ -57,7 +57,10 @@ if ('serviceWorker' in navigator) { if (!sw) return; sw.addEventListener('statechange', function() { if (sw.state === 'activated') { - if (sessionStorage.getItem('by_skip_sw_reload')) return; + if (sessionStorage.getItem('by_skip_sw_reload')) { + sessionStorage.removeItem('by_skip_sw_reload'); // einmalig konsumieren + return; + } window.location.replace('/?_t=' + Date.now()); } }); diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js index 653b117..686007c 100644 --- a/backend/static/js/pages/diary.js +++ b/backend/static/js/pages/diary.js @@ -1306,37 +1306,7 @@ window.Page_diary = (() => {
- - -
-
- -
- - -
-
-
- - ${UI.escape(entry?.location_name || '')} - -
-
-
- - -
- -
+
${dogPickerHtml}
@@ -1538,140 +1508,15 @@ window.Page_diary = (() => { let _locLat = (entry?.gps_lat != null) ? entry.gps_lat : null; let _locLon = (entry?.gps_lon != null) ? entry.gps_lon : null; let _locName = entry?.location_name || null; - let _miniMap = null, _miniMarker = null; - const _pinSvg = ''; - const _mkIcon = () => L.divIcon({ html: _pinSvg, className: '', iconSize: [32,40], iconAnchor: [16,40] }); - - function _setName(name) { - _locName = name; - document.getElementById('diary-location-label').textContent = name; - document.getElementById('diary-location-chip-wrap').style.display = ''; - document.getElementById('diary-location-suggestions').style.display = 'none'; - } - - function _placeMarker(lat, lon) { - if (_miniMarker) { _miniMarker.setLatLng([lat, lon]); return; } - _miniMarker = L.marker([lat, lon], { draggable: false, icon: _mkIcon() }).addTo(_miniMap); - _miniMarker.on('dragend', () => { - const p = _miniMarker.getLatLng(); _locLat = p.lat; _locLon = p.lng; - document.getElementById('diary-location-btn-label').textContent = 'POI suchen'; + // Location Picker (gemeinsame UI-Komponente) + setTimeout(() => { + const _diaryPicker = UI.locationPicker({ + containerId: 'diary-location-picker', + onSelect: (lat, lon, name) => { _locLat = lat; _locLon = lon; _locName = name; }, }); - } - - document.getElementById('diary-location-clear')?.addEventListener('click', () => { - _locName = null; - document.getElementById('diary-location-chip-wrap').style.display = 'none'; - }); - const _clearBtn = document.getElementById('diary-coords-clear'); - let _clearPending = false; - _clearBtn?.addEventListener('click', () => { - if (!_clearPending) { - _clearPending = true; - _clearBtn.textContent = 'Wirklich entfernen?'; - _clearBtn.style.color = 'var(--c-danger)'; - setTimeout(() => { - if (_clearPending) { - _clearPending = false; - _clearBtn.textContent = 'Ort entfernen'; - _clearBtn.style.color = 'var(--c-text-muted)'; - } - }, 3000); - return; - } - _clearPending = false; - _clearBtn.textContent = 'Ort entfernen'; - _clearBtn.style.color = 'var(--c-text-muted)'; - _locLat = null; _locLon = null; _locName = null; - document.getElementById('diary-location-chip-wrap').style.display = 'none'; - document.getElementById('diary-location-suggestions').style.display = 'none'; - document.getElementById('diary-location-btn-label').textContent = 'POI suchen'; - if (_miniMarker) { _miniMarker.remove(); _miniMarker = null; } - if (_miniMap) { _miniMap.setView([48.0, 11.9], 7); _setMapEditing(false); } - }); - - let _mapEditing = false; - - function _setMapEditing(on) { - _mapEditing = on; - const lbl = document.getElementById('diary-map-edit-label'); - if (lbl) lbl.textContent = on ? 'Fertig' : 'Position Àndern'; - if (!_miniMap) return; - if (on) { - if (_miniMarker) _miniMarker.dragging.enable(); - } else { - if (_miniMarker) _miniMarker.dragging.disable(); - } - } - - document.getElementById('diary-map-edit-btn')?.addEventListener('click', () => { - _setMapEditing(!_mapEditing); - }); - - // Karte beim Formular-Open automatisch laden - UI.loadLeaflet().then(() => { - setTimeout(() => { - const lat = _locLat || 48.0, lon = _locLon || 11.9, zoom = _locLat ? 15 : 7; - _miniMap = L.map('diary-map-wrap', { - zoomControl: true, attributionControl: false, - dragging: true, scrollWheelZoom: false, - }).setView([lat, lon], zoom); - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }) - .addTo(_miniMap); - _miniMap.invalidateSize(); - if (_locLat) { - _placeMarker(lat, lon); - _miniMarker.dragging.disable(); // Lesemodus: kein Drag - } - // Klick nur im Edit-Modus - _miniMap.on('click', e => { - if (!_mapEditing) return; - _locLat = e.latlng.lat; _locLon = e.latlng.lng; - _placeMarker(_locLat, _locLon); - if (!_mapEditing) _miniMarker.dragging.disable(); - document.getElementById('diary-location-btn-label').textContent = 'POI suchen'; - }); - }, 150); - }); - - async function _showSuggestions() { - const btn = document.getElementById('diary-location-btn'); - UI.setLoading(btn, true); - try { - let lat = _locLat, lon = _locLon; - if (lat == null || lon == null) { - const pos = await API.getLocation(); - lat = pos.lat; lon = pos.lon; - _locLat = lat; _locLon = lon; - if (_miniMap) { _miniMap.setView([lat, lon], 15); _placeMarker(lat, lon); } - document.getElementById('diary-location-btn-label').textContent = 'POI suchen'; - } - const suggestions = await API.diary.nearby(_appState.activeDog.id, lat, lon); - const sugEl = document.getElementById('diary-location-suggestions'); - if (suggestions.length === 0) { - sugEl.innerHTML = '

Keine Orte in der NĂ€he gefunden.

'; - } else { - sugEl.innerHTML = suggestions.map(s => ` - `).join(''); - sugEl.querySelectorAll('.diary-location-suggestion').forEach(el => { - el.addEventListener('click', () => _setName(el.dataset.name)); - }); - } - sugEl.style.display = ''; - } catch (err) { - UI.toast.error(err?.message?.includes('GPS') || lat == null - ? 'GPS nicht verfĂŒgbar.' : 'Ortssuche fehlgeschlagen.'); - } finally { - UI.setLoading(btn, false); - } - } - - document.getElementById('diary-location-btn')?.addEventListener('click', _showSuggestions); + if (_locLat != null) _diaryPicker.setValue(_locLat, _locLon, _locName); + }, 50); document.getElementById('diary-form-delete')?.addEventListener('click', async () => { const ok = await UI.modal.confirm({ diff --git a/backend/static/js/pages/forum.js b/backend/static/js/pages/forum.js index e2dfe19..512ed6d 100644 --- a/backend/static/js/pages/forum.js +++ b/backend/static/js/pages/forum.js @@ -640,6 +640,17 @@ function _fmtDate(iso) { } catch (err) { UI.toast.error(err.message); } }); + // Liker-Liste anzeigen (Klick auf die Zahl) + const _thLikeCount = document.getElementById('thread-like-count'); + if (_thLikeCount) { + _thLikeCount.style.cursor = 'pointer'; + _thLikeCount.title = 'Wer hat geliked?'; + _thLikeCount.addEventListener('click', e => { + e.stopPropagation(); + if ((thread.likes || 0) > 0) _showLikers('thread', thread.id); + }); + } + // Report thread document.getElementById('thread-report-btn')?.addEventListener('click', () => { _showReportForm('thread', thread.id); @@ -812,9 +823,9 @@ function _fmtDate(iso) { // Like container.querySelectorAll('.forum-post-like:not([data-bound])').forEach(btn => { btn.dataset.bound = '1'; + const postId = parseInt(btn.dataset.postId); btn.addEventListener('click', async () => { if (!uid) { UI.toast.info('Bitte erst anmelden.'); return; } - const postId = parseInt(btn.dataset.postId); try { const res = await API.forum.like('post', postId); btn.classList.toggle('active', res.liked); @@ -822,6 +833,16 @@ function _fmtDate(iso) { if (countEl) countEl.textContent = res.count; } catch (err) { UI.toast.error(err.message); } }); + // Klick auf die Zahl → Liker-Liste + const countEl = btn.querySelector('.forum-post-like-count'); + if (countEl) { + countEl.style.cursor = 'pointer'; + countEl.title = 'Wer hat geliked?'; + countEl.addEventListener('click', e => { + e.stopPropagation(); + if (parseInt(countEl.textContent) > 0) _showLikers('post', postId); + }); + } }); // Report @@ -874,6 +895,28 @@ function _fmtDate(iso) { }); } + // ---------------------------------------------------------- + // Liker-Liste — wer hat geliked? + // ---------------------------------------------------------- + async function _showLikers(targetType, targetId) { + try { + const likers = await API.forum.likers(targetType, targetId); + if (!likers.length) { UI.toast.info('Noch keine Likes.'); return; } + const rows = likers.map(l => ` +
+
${UI.escape(_initial(l.name))}
+ ${UI.escape(l.name || 'Unbekannt')} + ${l.founder_number ? `GrĂŒnder #${l.founder_number}` : ''} +
`).join(''); + UI.modal.open({ + title: `${UI.icon('heart')} ${likers.length} ${likers.length === 1 ? 'Like' : 'Likes'}`, + body: `
${rows}
`, + footer: ``, + }); + document.getElementById('likers-close')?.addEventListener('click', UI.modal.close); + } catch (err) { UI.toast.error(err.message); } + } + // ---------------------------------------------------------- // Report-Formular // ---------------------------------------------------------- diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index 7b70409..00a4324 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -59,6 +59,7 @@ window.Page_map = (() => { treffpunkt: [], community: [], zuechter: [], + hotel: [], }; const VISIBLE_KEY = 'by_map_visible_v1'; @@ -130,6 +131,10 @@ window.Page_map = (() => { interactive: false, }; + // Orts-Suche + let _searchTimer = null; + let _searchMarker = null; + let _overpassTimer = null; let _overpassActive = false; let _ringClosing = false; @@ -210,13 +215,50 @@ window.Page_map = (() => {
-
- - ${App.hasPro(_appState?.user) ? ` - - - ` : ''} - + +
+
+ + + +
+ +
+ + +
+
+ +
+ Mein Standort + +
+
+ Ort suchen + +
+
+ Marker setzen + +
+ ${App.hasPro(_appState?.user) ? ` +
+ Regenradar + +
+
+ Temperatur + +
+ ` : ''} +
+
@@ -289,7 +331,19 @@ window.Page_map = (() => { _saveVisible(); }); + // Speed Dial + const _sdEl = document.getElementById('map-speed-dial'); + document.getElementById('map-sd-trigger')?.addEventListener('click', e => { + e.stopPropagation(); + _sdEl?.classList.toggle('open'); + }); + // Klick auf Karte / außerhalb schließt Speed Dial + document.getElementById('central-map')?.addEventListener('pointerdown', () => { + _sdEl?.classList.remove('open'); + }); + document.getElementById('map-locate-btn').addEventListener('click', () => { + _sdEl?.classList.remove('open'); if (_userPos) { _map?.setView([_userPos.lat, _userPos.lon], 16); } else { @@ -297,9 +351,54 @@ window.Page_map = (() => { } }); - document.getElementById('map-pin-btn').addEventListener('click', _togglePlacementMode); - document.getElementById('map-radar-btn')?.addEventListener('click', _toggleRadar); - document.getElementById('map-temp-btn')?.addEventListener('click', _toggleTemp); + document.getElementById('map-pin-btn').addEventListener('click', () => { + _sdEl?.classList.remove('open'); + _togglePlacementMode(); + }); + document.getElementById('map-radar-btn')?.addEventListener('click', () => { + _sdEl?.classList.remove('open'); + _toggleRadar(); + }); + document.getElementById('map-temp-btn')?.addEventListener('click', () => { + _sdEl?.classList.remove('open'); + _toggleTemp(); + }); + + // Suche — FAB öffnet Panel + document.getElementById('map-search-btn')?.addEventListener('click', () => { + document.getElementById('map-speed-dial')?.classList.remove('open'); + const wrap = document.getElementById('map-search-wrap'); + const isOpen = wrap?.classList.contains('active'); + if (isOpen) { + _clearSearch(); + } else { + wrap?.classList.add('active'); + setTimeout(() => document.getElementById('map-search-input')?.focus(), 60); + document.getElementById('map-search-btn')?.classList.add('active'); + } + }); + + const searchInput = document.getElementById('map-search-input'); + const searchResults = document.getElementById('map-search-results'); + + searchInput?.addEventListener('input', () => { + const q = searchInput.value.trim(); + clearTimeout(_searchTimer); + if (q.length < 2) { searchResults.style.display = 'none'; return; } + _searchTimer = setTimeout(() => _runSearch(q), 400); + }); + + searchInput?.addEventListener('keydown', e => { + if (e.key === 'Escape') _clearSearch(); + }); + + document.getElementById('map-search-clear')?.addEventListener('click', _clearSearch); + + // Klick auf Karte schließt Ergebnisse (aber behĂ€lt Marker) + document.getElementById('central-map')?.addEventListener('pointerdown', () => { + searchResults.style.display = 'none'; + searchInput?.blur(); + }); } // ---------------------------------------------------------- @@ -907,7 +1006,7 @@ window.Page_map = (() => { const params = new URLSearchParams({ type: osmType, ...bbox }); try { const pois = await fetch(`/api/osm/pois?${params}`).then(r => r.json()); - const osmCount = _layers[layerKey].filter(m => !m._ownPlace).length; + const osmCount = (_layers[layerKey] || []).filter(m => !m._ownPlace).length; if (pois.length !== osmCount) _replaceOsmMarkers(layerKey, pois); _done++; const pct = Math.round(20 + _done / _total * 80); @@ -919,11 +1018,14 @@ window.Page_map = (() => { const pct = Math.round(20 + _done / _total * 80); const total = Object.values(_layers).flat().filter(m => !m._ownPlace).length; _setOsmStatus(pct < 100 ? `Scanne
` : `${total} Marker`, pct); - return _layers[layerKey].filter(m => !m._ownPlace).length; + return (_layers[layerKey] || []).filter(m => !m._ownPlace).length; } }); - await Promise.all(freshTasks); - _overpassActive = false; + try { + await Promise.all(freshTasks); + } finally { + _overpassActive = false; + } const totalLoaded = Object.values(_layers).flat().filter(m => !m._ownPlace).length; const allHidden = Object.keys(OSM_LAYER_MAP).every(k => _visible[k] === false); @@ -931,10 +1033,13 @@ window.Page_map = (() => { _setOsmStatus('Layer deaktiviert — Liste antippen', 100); } - // Wenn 0 OSM-Marker: Hintergrund-Fetch lĂ€uft noch — max 3× automatisch nachfragen - if (totalLoaded === 0 && zoom >= 14 && _autoRetryCount < 3) { + // Wenn 0 OSM-Marker: Hintergrund-Overpass-Fetch lĂ€uft noch — bis zu 8× nachfragen + // Overpass fĂŒr alle Layer sequential: bis zu ~4min → Retries mĂŒssen das abdecken + if (totalLoaded === 0 && zoom >= 14 && _autoRetryCount < 8) { _autoRetryCount++; - const delay = _autoRetryCount * 30000; // 30s, 60s, 90s + // 10s, 20s, 35s, 50s, 70s, 90s, 120s, 150s + const delays = [10000, 20000, 35000, 50000, 70000, 90000, 120000, 150000]; + const delay = delays[_autoRetryCount - 1] || 120000; _setOsmStatus(`Neue Umgebung – Daten werden geladen
`); setTimeout(() => { if (!_overpassActive) _scheduleOsmLoad(); }, delay); } @@ -1944,6 +2049,92 @@ window.Page_map = (() => { } catch { /* still */ } } + // ---------------------------------------------------------- + // Orts-Suche (Nominatim-Proxy) + // ---------------------------------------------------------- + async function _runSearch(q) { + const resultsEl = document.getElementById('map-search-results'); + if (!resultsEl) return; + resultsEl.innerHTML = '
Suche

'; + resultsEl.style.display = ''; + try { + const data = await API.get(`/osm/geocode?q=${encodeURIComponent(q)}`); + if (!data.length) { + resultsEl.innerHTML = '
Keine Ergebnisse
'; + return; + } + resultsEl.innerHTML = data.map((r, i) => + `
+
${UI.escape(r.name)}
+ ${r.subtitle ? `
${UI.escape(r.subtitle)}
` : ''} +
` + ).join(''); + resultsEl.querySelectorAll('.map-search-item').forEach(el => { + el.addEventListener('pointerdown', e => { + e.stopPropagation(); + const r = data[+el.dataset.i]; + _flyToResult(r); + document.getElementById('map-search-input').value = r.name; + document.getElementById('map-search-clear').style.display = ''; + resultsEl.style.display = 'none'; + }); + }); + } catch { + resultsEl.innerHTML = '
Suche nicht verfĂŒgbar
'; + } + } + + function _flyToResult(r) { + if (!_map || !window.L) return; + _searchMarker?.remove(); + _map.flyTo([r.lat, r.lon], 15, { duration: 1.0 }); + _searchMarker = L.marker([r.lat, r.lon], { + icon: L.divIcon({ + className: '', + html: `
+ + + + +
`, + iconSize: [32, 32], + iconAnchor: [16, 32], + }), + zIndexOffset: 1000, + }) + .addTo(_map) + .bindPopup(`
${UI.escape(r.name)}
+ ${r.subtitle ? `
${UI.escape(r.subtitle)}
` : ''} + `, { maxWidth: 240 }) + .openPopup(); + + setTimeout(() => { + document.getElementById('search-marker-close')?.addEventListener('click', () => { + _clearSearch(); + _searchMarker?.closePopup(); + }); + }, 50); + } + + function _clearSearch() { + const input = document.getElementById('map-search-input'); + const results = document.getElementById('map-search-results'); + const wrap = document.getElementById('map-search-wrap'); + const btn = document.getElementById('map-search-btn'); + if (input) { input.value = ''; input.blur(); } + if (results) results.style.display = 'none'; + wrap?.classList.remove('active'); + btn?.classList.remove('active'); + _searchMarker?.remove(); + _searchMarker = null; + clearTimeout(_searchTimer); + } + return { init, refresh, onDogChange, startRecording: _startRecording, stopRecording: _stopRecording, isRecording: () => _recActive }; })(); diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index 0f4e1af..bf3ccaa 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -1698,6 +1698,10 @@ window.Page_routes = (() => { center: [mid.lat, mid.lon], zoom: 15, zoomControl: false, attributionControl: false, }); + // Container hat im frisch eingefĂŒgten Fixed-Overlay erst jetzt seine + // finale Flex-Höhe — Leaflet muss sie neu vermessen, sonst lĂ€dt es nur + // oben Tiles und der Rest bleibt grau. + _navMap.invalidateSize(); // Route-Polylines: erledigt (grĂŒn) + ausstehend (orange) const doneLine = L.polyline([], { color: '#22c55e', weight: 5, opacity: 0.85 }).addTo(_navMap); @@ -1705,6 +1709,14 @@ window.Page_routes = (() => { _navMap.fitBounds(remainLine.getBounds(), { padding: [20, 20] }); _addRouteArrows(_navMap, track, '#3b82f6'); + // iOS rendert das Flex-Layout teils verzögert — nochmal neu vermessen + // und Ausschnitt erneut anpassen. + setTimeout(() => { + if (!_navMap) return; + _navMap.invalidateSize(); + _navMap.fitBounds(remainLine.getBounds(), { padding: [20, 20] }); + }, 250); + // Start/End-Marker (als Variable damit Reverse sie neu setzen kann) const mkPin = (p, color) => L.circleMarker([p.lat, p.lon], { radius: 8, color: '#fff', weight: 2, fillColor: color, fillOpacity: 1 diff --git a/backend/static/js/pages/walks.js b/backend/static/js/pages/walks.js index 2d2072e..dfa11ee 100644 --- a/backend/static/js/pages/walks.js +++ b/backend/static/js/pages/walks.js @@ -897,8 +897,6 @@ window.Page_walks = (() => { let _locLon = v.lon != null ? parseFloat(v.lon) : null; let _locName = v.ort_name || null; - const _pinSvg = ''; - const body = `
@@ -924,48 +922,7 @@ window.Page_walks = (() => {
- - -
-
- -
- - -
-
-
- ${UI.icon('map-pin')} - ${UI.escape(_locName || '')} - -
-
- -
- - -
- - - -
- - - - - +
@@ -996,157 +953,16 @@ window.Page_walks = (() => { document.getElementById('wf-cancel')?.addEventListener('click', UI.modal.close); - // --- Mini-Karte --- - let _miniMap = null, _miniMarker = null, _mapEditing = false; - - const _mkIcon = () => L.divIcon({ html: _pinSvg, className: '', iconSize: [28, 36], iconAnchor: [14, 36] }); - - function _placeMarker(lat, lon) { - if (_miniMarker) { _miniMarker.setLatLng([lat, lon]); return; } - _miniMarker = L.marker([lat, lon], { draggable: true, icon: _mkIcon() }).addTo(_miniMap); - _miniMarker.on('dragend', () => { - const p = _miniMarker.getLatLng(); - _locLat = p.lat; _locLon = p.lng; - document.getElementById('wf-lat').value = _locLat; - document.getElementById('wf-lon').value = _locLon; - document.getElementById('wf-location-btn-label').textContent = 'POI suchen'; + // Location Picker + let _wfPicker = null; + setTimeout(() => { + _wfPicker = UI.locationPicker({ + containerId: 'wf-location-picker', + onSelect: (lat, lon, name) => { _locLat = lat; _locLon = lon; _locName = name; }, }); - } + if (_locLat != null) _wfPicker.setValue(_locLat, _locLon, _locName); + }, 50); - function _setCoords(lat, lon) { - _locLat = lat; _locLon = lon; - document.getElementById('wf-lat').value = lat; - document.getElementById('wf-lon').value = lon; - } - - function _setName(name) { - _locName = name; - document.getElementById('wf-location-label').textContent = name; - document.getElementById('wf-location-chip-wrap').style.display = ''; - document.getElementById('wf-ort-name').value = name; - document.getElementById('wf-location-suggestions').style.display = 'none'; - } - - UI.loadLeaflet().then(() => { - setTimeout(() => { - const lat = _locLat || 48.0, lon = _locLon || 11.9, zoom = _locLat ? 15 : 7; - _miniMap = L.map('wf-map-wrap', { - zoomControl: true, attributionControl: false, - dragging: true, scrollWheelZoom: false, - }).setView([lat, lon], zoom); - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }) - .addTo(_miniMap); - _miniMap.invalidateSize(); - if (_locLat) _placeMarker(lat, lon); - _miniMap.on('click', e => { - _setCoords(e.latlng.lat, e.latlng.lng); - _placeMarker(_locLat, _locLon); - document.getElementById('wf-location-btn-label').textContent = 'POI suchen'; - }); - document.getElementById('wf-map-pin-here')?.addEventListener('click', () => { - const c = _miniMap.getCenter(); - _setCoords(c.lat, c.lng); - _placeMarker(c.lat, c.lng); - document.getElementById('wf-location-btn-label').textContent = 'POI suchen'; - }); - }, 150); - }); - - // Ort-Name-Chip entfernen - document.getElementById('wf-location-clear')?.addEventListener('click', () => { - _locName = null; - document.getElementById('wf-location-chip-wrap').style.display = 'none'; - document.getElementById('wf-ort-name').value = ''; - }); - - // Koordinaten + Name entfernen (Zwei-Klick) - const clearBtn = document.getElementById('wf-coords-clear'); - let _clearPending = false; - clearBtn?.addEventListener('click', () => { - if (!_clearPending) { - _clearPending = true; - clearBtn.textContent = 'Wirklich entfernen?'; - clearBtn.style.color = 'var(--c-danger)'; - setTimeout(() => { - _clearPending = false; - if (clearBtn) { - clearBtn.textContent = 'Ort entfernen'; - clearBtn.style.color = ''; - } - }, 3000); - return; - } - _clearPending = false; - clearBtn.textContent = 'Ort entfernen'; - clearBtn.style.color = ''; - _locLat = null; _locLon = null; _locName = null; - document.getElementById('wf-lat').value = ''; - document.getElementById('wf-lon').value = ''; - document.getElementById('wf-ort-name').value = ''; - document.getElementById('wf-location-chip-wrap').style.display = 'none'; - document.getElementById('wf-location-suggestions').style.display = 'none'; - document.getElementById('wf-location-btn-label').textContent = 'GPS → POI suchen'; - if (_miniMarker) { _miniMarker.remove(); _miniMarker = null; } - if (_miniMap) { _miniMap.setView([48.0, 11.9], 7); } - }); - - // GPS → POI-Suche (wie diary.js) - async function _showSuggestions() { - const btn = document.getElementById('wf-location-btn'); - UI.setLoading(btn, true); - try { - let lat = _locLat, lon = _locLon; - if (lat == null || lon == null) { - const pos = await API.getLocation({ enableHighAccuracy: true }); - lat = pos.lat; lon = pos.lon; - _setCoords(lat, lon); - if (_miniMap) { - _miniMap.setView([lat, lon], 15); - _placeMarker(lat, lon); - if (_miniMarker) _miniMarker.dragging.disable(); - } - document.getElementById('wf-location-btn-label').textContent = 'POI suchen'; - } - - const suggestions = _appState.user - ? await API.walks.nearby(lat, lon) - : []; - - const sugEl = document.getElementById('wf-location-suggestions'); - if (!suggestions.length) { - sugEl.innerHTML = '

Keine Orte in der NĂ€he gefunden.

'; - } else { - sugEl.innerHTML = suggestions.map(s => ` - `).join(''); - sugEl.querySelectorAll('.diary-location-suggestion').forEach(el => { - el.addEventListener('click', () => { - const slat = parseFloat(el.dataset.lat); - const slon = parseFloat(el.dataset.lon); - _setCoords(slat, slon); - _setName(el.dataset.name); - if (_miniMap) { - _miniMap.setView([slat, slon], 16); - _placeMarker(slat, slon); - if (_miniMarker) _miniMarker.dragging.disable(); - } - }); - }); - } - sugEl.style.display = ''; - } catch (err) { - UI.toast.error(err?.message?.includes('GPS') || _locLat == null - ? 'GPS nicht verfĂŒgbar.' : 'Ortssuche fehlgeschlagen.'); - } finally { - UI.setLoading(btn, false); - } - } - - document.getElementById('wf-location-btn')?.addEventListener('click', _showSuggestions); // Formular absenden document.getElementById('walk-form')?.addEventListener('submit', async e => { diff --git a/backend/static/js/ui.js b/backend/static/js/ui.js index 9f50342..4b014c2 100644 --- a/backend/static/js/ui.js +++ b/backend/static/js/ui.js @@ -453,6 +453,10 @@ const UI = (() => { const isDark = document.documentElement.dataset.theme === 'dark'; if (isDark) tiles.getContainer().style.filter = 'brightness(0.7) invert(1) contrast(0.9) hue-rotate(200deg)'; } + // Safety-Net: Container-GrĂ¶ĂŸe nach Layout neu vermessen. Verhindert + // grau bleibende Bereiche wenn die Karte vor dem finalen Layout erstellt + // wird (z.B. in frisch eingefĂŒgten Overlays mit flex:1). + requestAnimationFrame(() => m.invalidateSize()); return m; }, @@ -873,12 +877,35 @@ const UI = (() => { coordsClear: `${p}-coords-clear`, suggestions: `${p}-suggestions`, pinHere: `${p}-pin-here`, + geoInput: `${p}-geo-input`, + geoClear: `${p}-geo-clear`, + geoResults: `${p}-geo-results`, }; // HTML in den Container rendern function _render(container) { container.innerHTML = `
+ +
+
+ + + +
+ +
'; + geoResults.style.display = ''; + } + try { + const data = await API.get(`/osm/geocode?q=${encodeURIComponent(q)}`); + if (!geoResults) return; + if (!data.length) { + geoResults.innerHTML = '
Keine Ergebnisse
'; + return; + } + geoResults.innerHTML = data.map((r, i) => ` +
+
${escape(r.name)}
+ ${r.subtitle ? `
${escape(r.subtitle)}
` : ''} +
`).join(''); + geoResults.querySelectorAll('[data-i]').forEach(el => { + el.addEventListener('pointerdown', e => { + e.preventDefault(); + const r = data[+el.dataset.i]; + _setCoords(r.lat, r.lon); + _setName(r.name); + if (_map) { + _map.flyTo([r.lat, r.lon], 15, { duration: 0.8 }); + _placeMarker(r.lat, r.lon); + } + const lbl = _getEl(ids.locBtnLabel); + if (lbl) lbl.textContent = 'POI suchen'; + geoInput.value = ''; + if (geoClear) geoClear.style.display = 'none'; + geoResults.style.display = 'none'; + onSelect?.(_lat, _lon, _name); + }); + }); + } catch { + if (geoResults) geoResults.innerHTML = '
Suche nicht verfĂŒgbar
'; + } + }, 400); + }); + + geoInput?.addEventListener('keydown', e => { + if (e.key === 'Escape') { + geoInput.value = ''; + if (geoClear) geoClear.style.display = 'none'; + if (geoResults) geoResults.style.display = 'none'; + } + }); + geoClear?.addEventListener('click', () => { + geoInput.value = ''; + geoClear.style.display = 'none'; + if (geoResults) geoResults.style.display = 'none'; + }); + _getEl(ids.mapWrap)?.addEventListener('pointerdown', () => { + if (geoResults) geoResults.style.display = 'none'; + geoInput?.blur(); + }); } // Container initialisieren diff --git a/backend/static/landing.html b/backend/static/landing.html index 59277a8..15f12fe 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App fĂŒr Deutschland, Österreich & Schweiz @@ -149,6 +149,25 @@ } + + + + +
+ + +
+
+
+ +
+ Ban Yaro +
+
Ban Yaro
+
Die App fĂŒr Dich und deinen Hund
+
+
+ +
+
Die App fĂŒr Dich
und deinen Hund.
+
Tagebuch  Â·  GPS  Â·  KI-Trainer  Â·  Gesundheit  Â·  ZĂŒchter
+
+ +
+
+ App +
+
+
+ + +
+
+
+
+
+ + +
+
+ +
+
+
📖
+
+
Tagebuch & Galerie
+
Fotos, Notizen und Meilensteine chronologisch auf einen Blick.
+
+
+
+
đŸ—ș
+
+
Gassi-Tracking & Routen
+
GPS-Strecken aufzeichnen, Lieblingsrunden speichern und hundefreundliche Orte entdecken.
+
+
+
+
đŸ€–
+
+
KI-Trainer & Übungen
+
Über 100 Übungen mit persönlichem Protokoll — der KI passt sich deinem Hund an.
+
+
+
+
❀‍đŸ©č
+
+
Gesundheit & Vorsorge
+
Impfungen, Gewicht, Tierarztbesuche und Befunde — immer griffbereit, nie vergessen.
+
+
+
+
🐕
+
+
FĂŒr ZĂŒchter: Stammbaum & WĂŒrfe
+
Ahnentafel, Genetik, Kaufvertrag und Wurfverwaltung — alles an einem Ort.
+
+
+
+ + +
+ +
+ + \ No newline at end of file diff --git a/promotion/flyer_a5_rueckseite.html b/promotion/flyer_a5_rueckseite.html new file mode 100644 index 0000000..a0f454d --- /dev/null +++ b/promotion/flyer_a5_rueckseite.html @@ -0,0 +1,383 @@ + + + + +Ban Yaro – Flyer A5 RĂŒckseite + + + +
+ + +
+
+ Ban Yaro +
+
Ban Yaro
+
Die App fĂŒr Dich und deinen Hund
+
+
+
+
Entdecke
die App
+
RĂŒckseite
+
+
+ +
+ + +
+
Die App im Überblick
+
+
+
+ Tagebuch +
+
Tagebuch & Galerie
+
+
+
+ Routen +
+
Routen & Karte
+
+
+
+ Wetter +
+
Wetter & Umgebung
+
+
+
+ + + +
+
+
⚠
+
+
Giftköder-Radar
+
Aktuell gemeldete Giftköder in deiner NĂ€he — immer im Blick.
+
+
+
+
💬
+
+
Community-Forum
+
Austausch mit anderen Hundehaltern — Fragen, Tipps, Erfahrungen.
+
+
+
+ + +
+
+
+ +
+
+
🐕
+
+
Speziell fĂŒr ZĂŒchter
+
Professionelle Werkzeuge fĂŒr verantwortungsvolle Zucht
+
+
+ +
+
+
🌳
+
+
Stammbaum & Ahnentafel
+
Ahnentafel bis zur 4. Generation, Rassestandard-Vermerke und Linienzucht-Übersicht.
+
+
+
+
đŸŸ
+
+
Wurfverwaltung & Dokumentation
+
WurfgrĂ¶ĂŸe, Geburtsgewichte, Entwicklungsmeilensteine und Abgabedaten je Welpe.
+
+
+
+
🧬
+
+
Genetik & Gesundheitstests
+
HD/ED, DNA-Tests und ZuchttauglichkeitsprĂŒfungen zentral hinterlegen und teilen.
+
+
+
+
📄
+
+
Kaufvertrag & Übergabe
+
Kaufvertrag direkt in der App erstellen, unterzeichnen und als PDF exportieren.
+
+
+
+ +
+ „Als ZĂŒchterin brauche ich alle Informationen auf einen Blick — Ban Yaro gibt mir genau das." +
+
+
+ + + + +
+ + \ No newline at end of file diff --git a/promotion/flyer_a5_rueckseite.pdf b/promotion/flyer_a5_rueckseite.pdf new file mode 100644 index 0000000..e6b01c8 Binary files /dev/null and b/promotion/flyer_a5_rueckseite.pdf differ diff --git a/promotion/flyer_a5_vorderseite.pdf b/promotion/flyer_a5_vorderseite.pdf new file mode 100644 index 0000000..b4c5184 Binary files /dev/null and b/promotion/flyer_a5_vorderseite.pdf differ