From 156166b4e5ce75647620ed7fa276313a63920b8d Mon Sep 17 00:00:00 2001 From: rogalik27 Date: Tue, 14 Jul 2026 16:10:15 +0200 Subject: [PATCH] Add booking stack, client deploys, and back-office updates - deploy/booking: shared Easy!Appointments stack with brand-matched wizard (flatpickr recolor, single-tenant provider hide, iframe auto-fit height reporter) - deploy/clients: per-client isolated nginx compose stacks with _template scaffold, new-client.sh, and happynails live site - deploy/backup: smb-db backup script - n8n: booking-sync workflow; onboarding tweaks - playbooks: lead-to-customer lifecycle + outreach - templates: nail-studio landing previews - backoffice: app/db/init/compose updates Co-Authored-By: Claude Opus 4.8 --- backoffice/app/app.py | 54 +++ backoffice/app/db.py | 8 +- backoffice/app/static/index.html | 12 +- backoffice/db/init.sql | 1 + backoffice/docker-compose.yml | 1 + deploy/backup/smb-db-backup.sh | 17 + deploy/booking/.env.example | 3 + deploy/booking/booking_layout.js | 93 ++++ deploy/booking/docker-compose.yml | 70 +++ deploy/booking/frontend.css | 155 +++++++ deploy/clients/README.md | 46 ++ deploy/clients/_template/.env.example | 7 + deploy/clients/_template/docker-compose.yml | 30 ++ deploy/clients/_template/site/index.html | 22 + deploy/clients/happynails/.env.example | 7 + deploy/clients/happynails/docker-compose.yml | 30 ++ deploy/clients/happynails/site/index.html | 434 ++++++++++++++++++ deploy/clients/new-client.sh | 53 +++ n8n/booking-sync.json | 132 ++++++ n8n/onboarding.json | 311 ++++++++++++- playbooks/lead-to-customer.md | 56 +++ playbooks/outreach.md | 82 ++++ .../landing/preview-bestnails/index.html | 433 +++++++++++++++++ .../landing/preview-happynails/index.html | 433 +++++++++++++++++ templates/landing/preview-nailbar/index.html | 433 +++++++++++++++++ 25 files changed, 2900 insertions(+), 23 deletions(-) create mode 100644 deploy/backup/smb-db-backup.sh create mode 100644 deploy/booking/.env.example create mode 100644 deploy/booking/booking_layout.js create mode 100644 deploy/booking/docker-compose.yml create mode 100644 deploy/booking/frontend.css create mode 100644 deploy/clients/README.md create mode 100644 deploy/clients/_template/.env.example create mode 100644 deploy/clients/_template/docker-compose.yml create mode 100644 deploy/clients/_template/site/index.html create mode 100644 deploy/clients/happynails/.env.example create mode 100644 deploy/clients/happynails/docker-compose.yml create mode 100644 deploy/clients/happynails/site/index.html create mode 100755 deploy/clients/new-client.sh create mode 100644 n8n/booking-sync.json create mode 100644 playbooks/lead-to-customer.md create mode 100644 playbooks/outreach.md create mode 100644 templates/landing/preview-bestnails/index.html create mode 100644 templates/landing/preview-happynails/index.html create mode 100644 templates/landing/preview-nailbar/index.html diff --git a/backoffice/app/app.py b/backoffice/app/app.py index 54ccd3c..637446a 100644 --- a/backoffice/app/app.py +++ b/backoffice/app/app.py @@ -23,6 +23,9 @@ from sheets import Sheets app = Flask(__name__, static_folder="static", static_url_path="") CRM_TOKEN = os.environ.get("CRM_API_TOKEN", "") +# Separate read-only token for the public iCal feed (calendar apps can't send +# the basic-auth header, so the feed is gated by this query token instead). +ICS_TOKEN = os.environ.get("ICS_TOKEN", "") # DB -> Sheets one-way mirror. Postgres is the source of truth; the Sheet is a # best-effort projection. A mirror failure never fails the DB write. @@ -270,6 +273,57 @@ def sync_all(): return jsonify({"synced": out}) +def _ics_dt(v): + if isinstance(v, datetime): + u = v if v.tzinfo else v.replace(tzinfo=timezone.utc) + return u.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + return None + + +def _ics_esc(t): + return (str(t or "").replace("\\", "\\\\").replace(";", "\\;") + .replace(",", "\\,").replace("\n", "\\n")) + + +@app.get("/api/bookings.ics") +def bookings_ics(): + """Read-only iCal feed for Apple/Google Calendar subscription. Gated by the + ICS_TOKEN query param (no header auth, so calendar apps can fetch it).""" + if not ICS_TOKEN or request.args.get("token") != ICS_TOKEN: + return Response("forbidden\n", status=403, mimetype="text/plain") + cid = request.args.get("client_id") + with db.connect() as conn, conn.cursor() as cur: + if cid: + cur.execute("SELECT * FROM bookings WHERE client_id = %s ORDER BY start_time", (cid,)) + else: + cur.execute("SELECT * FROM bookings ORDER BY start_time") + rows = cur.fetchall() + now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + out = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//smb-crm//bookings//DE", + "CALSCALE:GREGORIAN", "METHOD:PUBLISH", + "X-WR-CALNAME:" + _ics_esc("Buchungen " + cid if cid else "Buchungen")] + for r in rows: + st = _ics_dt(r.get("start_time")) + if not st: + continue + summary = r.get("service") or "Termin" + if r.get("customer_name"): + summary += " – " + r["customer_name"] + out += ["BEGIN:VEVENT", + "UID:%s@smb-crm" % (r.get("booking_id") or now), + "DTSTAMP:%s" % (_ics_dt(r.get("created_at")) or now), + "DTSTART:%s" % st] + en = _ics_dt(r.get("end_time")) + if en: + out.append("DTEND:%s" % en) + out.append("SUMMARY:" + _ics_esc(summary)) + if r.get("customer_contact"): + out.append("DESCRIPTION:" + _ics_esc("Kontakt: " + r["customer_contact"])) + out += ["STATUS:CONFIRMED", "END:VEVENT"] + out.append("END:VCALENDAR") + return Response("\r\n".join(out) + "\r\n", mimetype="text/calendar") + + @app.get("/") def index(): return Response(INDEX_HTML, mimetype="text/html") diff --git a/backoffice/app/db.py b/backoffice/app/db.py index 58c5213..554933a 100644 --- a/backoffice/app/db.py +++ b/backoffice/app/db.py @@ -21,7 +21,7 @@ TABLES = { "cols": ["client_id", "business_name", "owner_name", "email", "phone", "niche", "tier", "status", "domain", "stack_notes", "vault_ref", "services", "billing_cycle", "monthly_fee_eur", "start_date", - "renewal_date", "created_at", "notes"], + "renewal_date", "created_at", "notes", "notify_channel"], "dates": ["start_date", "renewal_date"], "timestamps": ["created_at"], "numbers": ["monthly_fee_eur"], @@ -101,6 +101,12 @@ def parse_ts(v): if isinstance(v, datetime): return v s = str(v).strip() + # ISO-8601 / RFC3339 (incl. trailing Z and +hh:mm offsets — what Google + # Calendar / booking tools emit). fromisoformat handles Z on Python 3.11+. + try: + return datetime.fromisoformat(s.replace("Z", "+00:00")) + except ValueError: + pass for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d"): try: diff --git a/backoffice/app/static/index.html b/backoffice/app/static/index.html index b0ca802..52938cb 100644 --- a/backoffice/app/static/index.html +++ b/backoffice/app/static/index.html @@ -43,11 +43,12 @@ .pillv { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: .74rem; font-weight: 600; background: var(--teal-soft); color: var(--teal-2); } .empty { padding: 40px; text-align: center; color: var(--muted); } - .del { background: none; border: 1px solid var(--line); border-radius: 7px; cursor: pointer; - padding: 3px 8px; font-size: .9rem; } - .del:hover { background: #fdecec; border-color: #f3c7c7; } + .actcol { white-space: nowrap; width: 1%; text-align: right; } + .del { background: none; border: 1px solid #e6b9b9; color: #b4322e; border-radius: 7px; cursor: pointer; + padding: 4px 10px; font-size: .8rem; font-weight: 600; } + .del:hover { background: #fdecec; border-color: #e08a86; } .act { background: none; border: 1px solid var(--line); border-radius: 7px; cursor: pointer; - padding: 3px 8px; font-size: .9rem; margin-right: 4px; } + padding: 4px 10px; font-size: .8rem; font-weight: 600; margin-right: 6px; } .act:hover { background: var(--teal-soft); border-color: var(--teal); } .overlay { position: fixed; inset: 0; background: rgba(8,20,19,.5); display: none; align-items: flex-start; justify-content: center; padding: 40px 16px; z-index: 20; overflow: auto; } @@ -151,7 +152,7 @@ if((c==='received_at'||c==='created_at') && v) v = String(v).replace('T',' ').slice(0,16); return `${esc(v)}`; }).join(''); - return `${cells}`; + return `${cells}`; }).join(''); document.getElementById('table').innerHTML = `${head}${body}
`; } @@ -183,6 +184,7 @@ {k:'status',t:'select',opts:['lead','onboarding','active','churned']}, {k:'billing_cycle',t:'select',opts:['monthly','yearly','one-off']}, {k:'monthly_fee_eur'},{k:'domain'}, + {k:'notify_channel',t:'select',opts:['calendar','telegram','email','sms','whatsapp']}, {k:'start_date',t:'date'},{k:'renewal_date',t:'date'}, {k:'services',wide:true},{k:'stack_notes',t:'textarea',wide:true}, {k:'vault_ref'},{k:'notes',t:'textarea',wide:true}, diff --git a/backoffice/db/init.sql b/backoffice/db/init.sql index 5b12ac4..eade021 100644 --- a/backoffice/db/init.sql +++ b/backoffice/db/init.sql @@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS clients ( renewal_date date, created_at timestamptz, notes text, + notify_channel text, updated_at timestamptz NOT NULL DEFAULT now() ); diff --git a/backoffice/docker-compose.yml b/backoffice/docker-compose.yml index e6fb925..b9d8e75 100644 --- a/backoffice/docker-compose.yml +++ b/backoffice/docker-compose.yml @@ -24,6 +24,7 @@ services: environment: DATABASE_URL: postgresql://smbcrm:${DB_PASSWORD}@smb-db:5432/smbcrm CRM_API_TOKEN: ${CRM_API_TOKEN} + ICS_TOKEN: ${ICS_TOKEN} SHEET_ID: ${SHEET_ID} GOOGLE_SA_JSON: /run/secrets/gcp-sa.json volumes: diff --git a/deploy/backup/smb-db-backup.sh b/deploy/backup/smb-db-backup.sh new file mode 100644 index 0000000..9010a18 --- /dev/null +++ b/deploy/backup/smb-db-backup.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Daily backup of smb-db (the CRM Postgres = source of truth). Keeps 14 days. +# Installed on the homelab at /home/mivanchenko/backups/bin/ and run via cron. +set -euo pipefail + +DEST=/home/mivanchenko/backups/smb-crm +mkdir -p "$DEST" +TS=$(date +%Y%m%d-%H%M%S) +FILE="$DEST/smbcrm-$TS.sql.gz" + +# pg_dump runs inside the container; gzip on the host. +docker exec smb-db pg_dump -U smbcrm -d smbcrm | gzip > "$FILE" + +# keep the 14 most recent dumps, delete older +ls -1t "$DEST"/smbcrm-*.sql.gz 2>/dev/null | tail -n +15 | xargs -r rm -f + +echo "$(date -Is) backup ok: $FILE ($(du -h "$FILE" | cut -f1))" diff --git a/deploy/booking/.env.example b/deploy/booking/.env.example new file mode 100644 index 0000000..5e0644c --- /dev/null +++ b/deploy/booking/.env.example @@ -0,0 +1,3 @@ +# Copy to .env on the homelab and fill with strong secrets (gitignored). +EA_DB_PASSWORD=change-me +EA_DB_ROOT_PASSWORD=change-me-too diff --git a/deploy/booking/booking_layout.js b/deploy/booking/booking_layout.js new file mode 100644 index 0000000..1f1dca0 --- /dev/null +++ b/deploy/booking/booking_layout.js @@ -0,0 +1,93 @@ +/* ---------------------------------------------------------------------------- + * Easy!Appointments - Online Appointment Scheduler + * + * @package EasyAppointments + * @author A.Tselegidis + * @copyright Copyright (c) Alex Tselegidis + * @license https://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link https://easyappointments.org + * @since v1.5.0 + * ---------------------------------------------------------------------------- */ + +/** + * Booking layout. + * + * This module implements the booking layout functionality. + */ +window.App.Layouts.Booking = (function () { + const $selectLanguage = $('#select-language'); + + /** + * Initialize the module. + */ + function initialize() { + App.Utils.Lang.enableLanguageSelection($selectLanguage); + } + + document.addEventListener('DOMContentLoaded', initialize); + + return {}; +})(); + +/* ---------------------------------------------------------------------------- + * SMB customisation — auto-fit iframe embed. + * + * EA doesn't report its rendered height, so when embedded in a fixed-height + * iframe the wizard overflows and gets its own scrollbar. Here we measure the + * wizard card and post its height to the parent page, which resizes the iframe + * to fit exactly (see the .booking-embed listener on the client site). Fires on + * load, on step changes, when available hours load, and on resize. + * ---------------------------------------------------------------------------- */ +(function () { + function reportHeight() { + var el = document.getElementById('book-appointment-wizard'); + var h; + if (el) { + h = Math.ceil(el.getBoundingClientRect().height) + 40; // breathing room + } else if (document.body) { + h = document.body.scrollHeight; + } + if (h && h > 0) { + try { + window.parent.postMessage({ eaBookingHeight: h }, '*'); + } catch (e) { /* not embedded / blocked — ignore */ } + } + } + + function start() { + if (window.parent === window) { + return; // not in an iframe, nothing to report + } + + reportHeight(); + + var target = document.getElementById('book-appointment-wizard') || document.body; + + if (window.ResizeObserver) { + new ResizeObserver(reportHeight).observe(target); + } + if (window.MutationObserver) { + new MutationObserver(reportHeight).observe(target, { + subtree: true, + childList: true, + attributes: true, + }); + } + window.addEventListener('resize', reportHeight); + + // Catch late async renders (available hours, fade transitions) for a few seconds. + var ticks = 0; + var iv = setInterval(function () { + reportHeight(); + if (++ticks > 25) { + clearInterval(iv); + } + }, 250); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', start); + } else { + start(); + } +})(); diff --git a/deploy/booking/docker-compose.yml b/deploy/booking/docker-compose.yml new file mode 100644 index 0000000..831ee2b --- /dev/null +++ b/deploy/booking/docker-compose.yml @@ -0,0 +1,70 @@ +# booking/ — self-hosted appointment scheduler (Easy!Appointments) + its DB. +# +# One shared instance serves all clients; each client = a provider/service with +# its own booking-page URL. Lightweight (PHP + MariaDB) to fit the 16 GB box. +# Booking events fire a webhook -> n8n "booking-sync" -> CRM `bookings` + notify. +# Routed by Caddy: booking.mivanchenko.de { reverse_proxy easyappointments:80 } +name: smb-booking + +services: + ea-db: + image: mariadb:11 + container_name: ea-db + restart: unless-stopped + environment: + MARIADB_DATABASE: easyappointments + MARIADB_USER: ea + MARIADB_PASSWORD: ${EA_DB_PASSWORD} + MARIADB_ROOT_PASSWORD: ${EA_DB_ROOT_PASSWORD} + volumes: + - ea-db-data:/var/lib/mysql + networks: [booking-net] + mem_limit: 512m + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 6 + + easyappointments: + image: alextselegidis/easyappointments:latest + container_name: easyappointments + restart: unless-stopped + environment: + BASE_URL: https://booking.mivanchenko.de + DEBUG_MODE: "FALSE" + DB_HOST: ea-db + DB_NAME: easyappointments + DB_USERNAME: ea + DB_PASSWORD: ${EA_DB_PASSWORD} + # Email is optional — booking notifications go via webhook -> n8n. Fill to + # also send customers EA's own confirmation emails. + MAIL_PROTOCOL: mail + MAIL_FROM_ADDRESS: info@booking.mivanchenko.de + MAIL_FROM_NAME: Buchung + volumes: + # Brand-matched booking wizard: recolours the flatpickr datepicker to the + # client's --bs-primary and compacts the layout so the embed fits its + # iframe without an outer scrollbar. (Wizard chrome colour itself comes + # from the EA "company colour" setting.) Re-sync from upstream on EA upgrade. + # NOTE: production (DEBUG_MODE=FALSE) serves the .min.* bundles, so those + # are the ones that must be overridden — the plain files are dev-only. + - ./frontend.css:/var/www/html/assets/css/frontend.css:ro + - ./frontend.css:/var/www/html/assets/css/frontend.min.css:ro + # Reports the wizard's rendered height to the embedding page so the iframe + # auto-fits to its content (no inner scrollbar). Re-sync on EA upgrade. + - ./booking_layout.js:/var/www/html/assets/js/layouts/booking_layout.js:ro + - ./booking_layout.js:/var/www/html/assets/js/layouts/booking_layout.min.js:ro + depends_on: + ea-db: + condition: service_healthy + networks: [booking-net, proxy] # proxy: reached by Caddy as easyappointments:80 + mem_limit: 256m + +volumes: + ea-db-data: + +networks: + booking-net: + proxy: + external: true diff --git a/deploy/booking/frontend.css b/deploy/booking/frontend.css new file mode 100644 index 0000000..30cdfcd --- /dev/null +++ b/deploy/booking/frontend.css @@ -0,0 +1,155 @@ +/* ---------------------------------------------------------------------------- + * Easy!Appointments - Online Appointment Scheduler + * + * @package EasyAppointments + * @author A.Tselegidis + * @copyright Copyright (c) Alex Tselegidis + * @license https://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link https://easyappointments.org + * @since v1.5.0 + * ---------------------------------------------------------------------------- */ +/* Responsive booking wizard styles (rounded corners and shadow on md+ screens) */ +@media (min-width: 768px) { + #book-appointment-wizard { + border-radius: var(--bs-border-radius) !important; + box-shadow: var(--bs-box-shadow-sm) !important; + } +} +/* Active step indicator - dynamic state that changes via JavaScript */ +#book-appointment-wizard .book-step.active-step { + height: 45px !important; + width: 45px !important; + background: var(--bs-white) !important; + padding: 7px !important; + margin-right: 13px !important; + margin-top: 0 !important; +} + +#book-appointment-wizard .book-step.active-step strong { + color: var(--bs-primary) !important; + font-size: 21px !important; +} + +/* Inactive step indicator - ensures consistent styling when step becomes inactive */ +#book-appointment-wizard .book-step:not(.active-step) { + height: 35px !important; + width: 35px !important; + background: rgba(0, 0, 0, 0.2) !important; + padding: 8px !important; + margin-right: 12px !important; + margin-top: 6px !important; +} + +#book-appointment-wizard .book-step:not(.active-step) strong { + color: rgba(255, 255, 255, 0.5) !important; + font-size: 12px !important; +} + +/* Selected hour button state */ +#book-appointment-wizard #available-hours .available-hour { + margin-bottom: 10px; +} + +#book-appointment-wizard #available-hours .selected-hour { + background-color: var(--bs-primary) !important; + border-color: var(--bs-primary) !important; + color: var(--bs-white) !important; +} + +/* Captcha refresh icon hover effect */ +#book-appointment-wizard .captcha-title .fa-sync-alt { + cursor: pointer; + transition: all 0.3s linear; +} + +#book-appointment-wizard .captcha-title .fa-sync-alt:hover { + color: var(--bs-primary); +} + +/* Language popover list */ +.popover .popover-title { + text-align: center; +} + +.popover .popover-content #language-list .language { + margin: 15px 0; +} + +#book-appointment-wizard .flatpickr-calendar.inline { + margin: auto; +} + +/* ============================================================================ + * SMB customisation — match the client's landing-page brand (rose/blush) and + * make the embed fit its iframe without an outer scrollbar. + * The brand colour follows --bs-primary, which is set per-instance by the EA + * "company colour" setting (Settings → Business → Company colour), so this file + * stays client-agnostic. Only the flatpickr datepicker + compact sizing live here. + * ============================================================================ */ + +/* --- Flatpickr datepicker: recolour the green (material_green) theme to brand --- */ +.flatpickr-calendar .flatpickr-months .flatpickr-month, +.flatpickr-calendar .flatpickr-weekdays, +.flatpickr-calendar span.flatpickr-weekday, +.flatpickr-calendar .flatpickr-current-month .flatpickr-monthDropdown-months { + background: var(--bs-primary) !important; + color: #fff !important; + fill: #fff !important; +} + +.flatpickr-calendar .flatpickr-day.selected, +.flatpickr-calendar .flatpickr-day.selected:hover, +.flatpickr-calendar .flatpickr-day.selected:focus, +.flatpickr-calendar .flatpickr-day.startRange, +.flatpickr-calendar .flatpickr-day.endRange { + background: var(--bs-primary) !important; + border-color: var(--bs-primary) !important; + color: #fff !important; +} + +.flatpickr-calendar .flatpickr-day.today { + border-color: var(--bs-primary) !important; +} + +.flatpickr-calendar .flatpickr-day.today:hover, +.flatpickr-calendar .flatpickr-day.today:focus { + background: var(--bs-primary) !important; + border-color: var(--bs-primary) !important; + color: #fff !important; +} + +/* --- Single-tenant embed: the provider is pinned per site via ?provider=, + * so the customer must never see or switch it (booking a different studio on + * e.g. happynails.mivanchenko.de would be wrong). EA's JS un-hides the + * provider control when a service is chosen, so we force it hidden here. + * The + +
+
+
+ +
+
+ +
✓ Danke! Ihre Anfrage ist eingegangen — wir melden uns.
+

Mit dem Absenden stimmen Sie der Verarbeitung Ihrer Angaben zur Kontaktaufnahme zu.

+ + + + + +
+
+
+
HAPPYNAILS
+

Nagelstudio · Nürnberg

+
+
+

Öffnungszeiten

+

Di–Fr 09:30–19 · Sa 09–16

+
+
+

Kontakt

+

0176 6742 6558 · @happynailsnuernberg

+
+
+
© 2026 Happy Nails Nürnberg · Impressum · Datenschutz
+
+ + + + + + + + +
+ +
+ + + + + + diff --git a/deploy/clients/new-client.sh b/deploy/clients/new-client.sh new file mode 100755 index 0000000..e19cd1c --- /dev/null +++ b/deploy/clients/new-client.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Scaffold a new ISOLATED client stack from _template/. +# +# Usage: ./new-client.sh [source-site-dir] +# ./new-client.sh happynails happynails.de ../../templates/landing/preview-happynails +# +# Creates deploy/clients// with its own .env + site/, then prints the +# Caddy block and the deploy command. Run it from deploy/clients/. +set -euo pipefail +cd "$(dirname "$0")" + +SLUG="${1:?usage: new-client.sh [source-site-dir]}" +DOMAIN="${2:?domain required (e.g. happynails.de)}" +SRC="${3:-}" + +[[ "$SLUG" =~ ^[a-z0-9-]+$ ]] || { echo "slug must be lowercase letters/digits/dashes"; exit 1; } +[ -e "$SLUG" ] && { echo "client '$SLUG' already exists at deploy/clients/$SLUG"; exit 1; } + +cp -r _template "$SLUG" +cat > "$SLUG/.env" < 188.193.49.3. + +3) Deploy the stack: + scp -r $SLUG mivanchenko@mivanchenko.de:/home/mivanchenko/clients/ + ssh mivanchenko@mivanchenko.de 'cd ~/clients/$SLUG && docker compose up -d' + +Remove this client later (isolated — touches nothing else): + ssh mivanchenko@mivanchenko.de 'cd ~/clients/$SLUG && docker compose down' +EOF diff --git a/n8n/booking-sync.json b/n8n/booking-sync.json new file mode 100644 index 0000000..195ba9d --- /dev/null +++ b/n8n/booking-sync.json @@ -0,0 +1,132 @@ +{ + "name": "SMB · Booking Sync", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "booking", + "responseMode": "onReceived", + "options": { + "allowedOrigins": "*" + } + }, + "id": "b0000000-1111-2222-3333-444444444444", + "name": "Booking webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + 240, + 300 + ], + "webhookId": "smb-booking-webhook-0001" + }, + { + "parameters": { + "jsCode": "const w=$input.first().json; const b=w.body||w;\nconst s=v=>{v=(v==null?'':String(v));return /^[=+\\-@\\t\\r]/.test(v)?\"'\"+v:v;};\nconst EA='http://easyappointments';\nconst AUTH='Basic __EA_AUTH__';\nconst get=p=>this.helpers.httpRequest({method:'GET',url:EA+'/index.php/api/v1/'+p,headers:{Authorization:AUTH},json:true});\nlet booking, business_name='', notify_channel='telegram';\nif(b.action&&b.payload){\n const p=b.payload;\n const r=await Promise.all([get('customers/'+p.id_users_customer),get('services/'+p.id_services),get('providers/'+p.id_users_provider)]);\n const cust=r[0], svc=r[1], prov=r[2];\n const tz=prov.timezone||'UTC';\n const toUtc=dt=>{ if(!dt) return null; const d=DateTime.fromFormat(String(dt),'yyyy-MM-dd HH:mm:ss',{zone:tz}); return d.isValid?d.toUTC().toISO():String(dt).replace(' ','T'); };\n booking={booking_id:'EA-'+p.id,client_id:String(prov.notes||'').trim(),\n customer_name:s(((cust.firstName||'')+' '+(cust.lastName||'')).trim()),\n customer_contact:s(cust.email||cust.phone||''),service:s(svc.name),\n start_time:toUtc(p.start_datetime),end_time:toUtc(p.end_datetime),\n source:'easyappointments',status:p.status||'confirmed'};\n business_name=s((prov.firstName||'')+' '+(prov.lastName||''));\n} else {\n booking={booking_id:b.booking_id||'B-'+Date.now(),client_id:s(b.client_id),\n customer_name:s(b.customer_name||b.name),customer_contact:s(b.customer_contact||b.contact||b.email||b.phone),\n service:s(b.service||b.service_interest),start_time:b.start_time||null,end_time:b.end_time||null,\n source:s(b.source||'manual'),status:s(b.status||'confirmed')};\n business_name=s(b.business_name); notify_channel=s(b.notify_channel||'telegram');\n}\nreturn [{json:{booking,business_name,notify_channel}}];" + }, + "id": "b0000000-1111-2222-3333-555555555555", + "name": "Build booking row", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 460, + 300 + ] + }, + { + "parameters": { + "method": "POST", + "url": "http://smb-crm:8080/api/bookings", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "X-CRM-Token", + "value": "__CRM_TOKEN__" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json.booking) }}", + "options": {} + }, + "id": "b0000000-1111-2222-3333-666666666666", + "name": "Save to CRM (bookings)", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 680, + 300 + ], + "retryOnFail": true, + "maxTries": 4, + "waitBetweenTries": 3000 + }, + { + "parameters": { + "resource": "message", + "operation": "sendMessage", + "chatId": "5499280257", + "text": "=📅 Neue Buchung\nBetrieb: {{ $('Build booking row').item.json.business_name || $('Build booking row').item.json.booking.client_id }}\nKunde: {{ $('Build booking row').item.json.booking.customer_name || '—' }}\nLeistung: {{ $('Build booking row').item.json.booking.service || '—' }}\nWann: {{ $('Build booking row').item.json.booking.start_time || '—' }}\nKontakt: {{ $('Build booking row').item.json.booking.customer_contact || '—' }}\nQuelle: {{ $('Build booking row').item.json.booking.source }}", + "additionalFields": { + "appendAttribution": false + } + }, + "id": "b0000000-1111-2222-3333-777777777777", + "name": "Notify owner (Telegram)", + "type": "n8n-nodes-base.telegram", + "typeVersion": 1.2, + "position": [ + 900, + 300 + ], + "credentials": { + "telegramApi": { + "id": "d9s4Ec5ocEH8FAxh", + "name": "SMB Telegram (leads bot)" + } + } + } + ], + "connections": { + "Booking webhook": { + "main": [ + [ + { + "node": "Build booking row", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build booking row": { + "main": [ + [ + { + "node": "Save to CRM (bookings)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Save to CRM (bookings)": { + "main": [ + [ + { + "node": "Notify owner (Telegram)", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false + } +} \ No newline at end of file diff --git a/n8n/onboarding.json b/n8n/onboarding.json index 029ab85..d5bf2ed 100644 --- a/n8n/onboarding.json +++ b/n8n/onboarding.json @@ -6,13 +6,18 @@ "httpMethod": "POST", "path": "onboard", "responseMode": "onReceived", - "options": { "allowedOrigins": "*" } + "options": { + "allowedOrigins": "*" + } }, "id": "d1a08bba-5cc5-48fa-8063-bda2d58cab15", "name": "Onboard webhook", "type": "n8n-nodes-base.webhook", "typeVersion": 2, - "position": [240, 300], + "position": [ + 240, + 300 + ], "webhookId": "96c28466-ee4b-4aa5-8f06-52d098b497a7" }, { @@ -23,7 +28,10 @@ "name": "Compute", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [460, 300] + "position": [ + 460, + 300 + ] }, { "parameters": { @@ -31,7 +39,12 @@ "url": "http://smb-crm:8080/api/clients", "sendHeaders": true, "headerParameters": { - "parameters": [ { "name": "X-CRM-Token", "value": "__CRM_TOKEN__" } ] + "parameters": [ + { + "name": "X-CRM-Token", + "value": "__CRM_TOKEN__" + } + ] }, "sendBody": true, "specifyBody": "json", @@ -42,7 +55,10 @@ "name": "Save client", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, - "position": [680, 300], + "position": [ + 680, + 300 + ], "retryOnFail": true, "maxTries": 4, "waitBetweenTries": 3000 @@ -55,7 +71,10 @@ "name": "Build project", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [900, 300] + "position": [ + 900, + 300 + ] }, { "parameters": { @@ -63,7 +82,12 @@ "url": "http://smb-crm:8080/api/projects", "sendHeaders": true, "headerParameters": { - "parameters": [ { "name": "X-CRM-Token", "value": "__CRM_TOKEN__" } ] + "parameters": [ + { + "name": "X-CRM-Token", + "value": "__CRM_TOKEN__" + } + ] }, "sendBody": true, "specifyBody": "json", @@ -74,7 +98,10 @@ "name": "Save project", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, - "position": [1120, 300], + "position": [ + 1120, + 300 + ], "retryOnFail": true, "maxTries": 4, "waitBetweenTries": 3000 @@ -85,28 +112,278 @@ "operation": "sendMessage", "chatId": "5499280257", "text": "=✅ Neuer Kunde angelegt: {{ $('Save client').item.json.added }}\n{{ $('Compute').item.json.business_name }} — Tier {{ $('Compute').item.json.tier }}\nServices: {{ $('Compute').item.json.services }}\nGebühr: {{ $('Compute').item.json.monthly_fee_eur || '—' }}€/{{ $('Compute').item.json.cycle }}\nGo-Live Ziel: {{ $('Build project').item.json.go_live_date }}", - "additionalFields": { "appendAttribution": false } + "additionalFields": { + "appendAttribution": false + } }, "id": "ae19e543-1a6b-4466-8af8-bb436f51091b", "name": "Notify Telegram", "type": "n8n-nodes-base.telegram", "typeVersion": 1.2, - "position": [1340, 300], + "position": [ + 1340, + 300 + ], "credentials": { - "telegramApi": { "id": "d9s4Ec5ocEH8FAxh", "name": "SMB Telegram (leads bot)" } + "telegramApi": { + "id": "d9s4Ec5ocEH8FAxh", + "name": "SMB Telegram (leads bot)" + } } + }, + { + "parameters": { + "jsCode": "const cid=$('Save client').first().json.added;\nconst biz=$('Compute').first().json.business_name||'Kunde';\nconst slug=String(cid).toLowerCase().replace(/[^a-z0-9]/g,'');\nconst password=Math.random().toString(36).slice(2,10)+Math.random().toString(36).slice(2,6);\nconst serviceBody={name:'Termin',duration:30,price:0,currency:'EUR',availabilitiesType:'flexible',attendantsNumber:1,isPrivate:false};\nreturn [{json:{cid,biz,username:slug,password,serviceBody}}];" + }, + "id": "ea-ea-build-service", + "name": "EA build service", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 680, + 520 + ] + }, + { + "parameters": { + "options": {}, + "method": "POST", + "url": "http://easyappointments/index.php/api/v1/services", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "Basic __EA_AUTH__" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json.serviceBody) }}" + }, + "id": "ea-ea-create-service", + "name": "EA create service", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 900, + 520 + ], + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 2000, + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "const prep=$('EA build service').first().json;\nconst svcId=$json.id;\nconst c=($('Compute').first().json.client)||{};\nconst parts=String(prep.biz).trim().split(/\\s+/);\nconst firstName=parts[0]||prep.biz;\nconst lastName=parts.slice(1).join(' ')||'Studio';\nconst wp={start:'09:00',end:'18:00',breaks:[]};\nconst providerBody={firstName,lastName,email:'provider+'+prep.username+'@booking.mivanchenko.de',\n phone:c.phone||'',services:[svcId],isPrivate:false,timezone:'Europe/Berlin',notes:prep.cid,\n settings:{username:prep.username,password:prep.password,\n workingPlan:{monday:wp,tuesday:wp,wednesday:wp,thursday:wp,friday:wp,saturday:wp,sunday:null}}};\nreturn [{json:{providerBody,svcId,cid:prep.cid,username:prep.username,biz:prep.biz}}];" + }, + "id": "ea-ea-build-provider", + "name": "EA build provider", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1120, + 520 + ] + }, + { + "parameters": { + "options": {}, + "method": "POST", + "url": "http://easyappointments/index.php/api/v1/providers", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "Basic __EA_AUTH__" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($('EA build provider').item.json.providerBody) }}" + }, + "id": "ea-ea-create-provider", + "name": "EA create provider", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1340, + 520 + ], + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 2000, + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "const bp=$('EA build provider').first().json;\nconst provId=$json.id;\nconst embed='https://booking.mivanchenko.de/index.php/booking?service='+bp.svcId+'&provider='+provId;\nconst stack_notes='Buchung-Embed: '+embed+' | EA provider '+provId+' / svc '+bp.svcId+' / login '+bp.username;\nreturn [{json:{cid:bp.cid,embed,provId,patch:{stack_notes}}}];" + }, + "id": "ea-ea-booking-info", + "name": "EA booking info", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1560, + 520 + ] + }, + { + "parameters": { + "options": {}, + "method": "PATCH", + "url": "=http://smb-crm:8080/api/clients/{{ $json.cid }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "X-CRM-Token", + "value": "__CRM_TOKEN__" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json.patch) }}" + }, + "id": "ea-ea-update-client", + "name": "EA update client", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1780, + 520 + ], + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 2000, + "continueOnFail": true } ], "connections": { - "Onboard webhook": { "main": [[{ "node": "Compute", "type": "main", "index": 0 }]] }, - "Compute": { "main": [[{ "node": "Save client", "type": "main", "index": 0 }]] }, - "Save client": { "main": [[{ "node": "Build project", "type": "main", "index": 0 }]] }, - "Build project": { "main": [[{ "node": "Save project", "type": "main", "index": 0 }]] }, - "Save project": { "main": [[{ "node": "Notify Telegram", "type": "main", "index": 0 }]] } + "Onboard webhook": { + "main": [ + [ + { + "node": "Compute", + "type": "main", + "index": 0 + } + ] + ] + }, + "Compute": { + "main": [ + [ + { + "node": "Save client", + "type": "main", + "index": 0 + } + ] + ] + }, + "Save client": { + "main": [ + [ + { + "node": "Build project", + "type": "main", + "index": 0 + }, + { + "node": "EA build service", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build project": { + "main": [ + [ + { + "node": "Save project", + "type": "main", + "index": 0 + } + ] + ] + }, + "Save project": { + "main": [ + [ + { + "node": "Notify Telegram", + "type": "main", + "index": 0 + } + ] + ] + }, + "EA build service": { + "main": [ + [ + { + "node": "EA create service", + "type": "main", + "index": 0 + } + ] + ] + }, + "EA create service": { + "main": [ + [ + { + "node": "EA build provider", + "type": "main", + "index": 0 + } + ] + ] + }, + "EA build provider": { + "main": [ + [ + { + "node": "EA create provider", + "type": "main", + "index": 0 + } + ] + ] + }, + "EA create provider": { + "main": [ + [ + { + "node": "EA booking info", + "type": "main", + "index": 0 + } + ] + ] + }, + "EA booking info": { + "main": [ + [ + { + "node": "EA update client", + "type": "main", + "index": 0 + } + ] + ] + } }, "settings": { "executionOrder": "v1", "callerPolicy": "workflowsFromSameOwner", "availableInMCP": false } -} +} \ No newline at end of file diff --git a/playbooks/lead-to-customer.md b/playbooks/lead-to-customer.md new file mode 100644 index 0000000..cab876f --- /dev/null +++ b/playbooks/lead-to-customer.md @@ -0,0 +1,56 @@ +# Playbook — Lead → Customer + +The full lifecycle for turning a lead into a live, paying, bookable client. CRM lives at +`onboard.mivanchenko.de/crm`. Booking is self-hosted (Easy!Appointments), embedded on the +client's own site. + +## 1. Lead exists +The lead is in the CRM **Leads** tab, status `new` — from outreach (see `outreach.md`) or an +inbound form submission. + +## 2. Reach out & qualify +Contact them (DM / call / walk-in). In the CRM, ✎-edit the lead's status: +`new` → `contacted` → `qualified` (interested) — or `lost` (dead). + +## 3. Close the deal +On a short call, confirm scope (site + online booking), agree the **setup fee + monthly** +(Tier-A pricing in `tier-a-google.md`), and note their services + opening hours. + +## 4. Onboard → creates the customer (automated) +Click **📝 Onboarding-Formular** in the CRM header (or go to `onboard.mivanchenko.de`). +Fill: business name, owner, email, phone, niche, tier, **domain = `.mivanchenko.de`**, +billing cycle, monthly fee → Submit. + +This automatically: +- creates the **Client** (`C-xxxx`, status `onboarding`) + **Project** in the CRM, +- provisions their **Easy!Appointments booking** (service + provider, mapped to their `C-id`), +- stores their **booking embed URL** in the client's `stack_notes`. + +Then ✎-edit the original **Lead → `won`**. + +## 5. Build & ship their site +1. Copy the **embed URL** from the new client's `stack_notes` in the CRM. +2. Clone a template/preview (`templates/landing/…`), rebrand it (name, colours, services, + hours, photos), and wire the booking section to the client's EA. +3. Ship the isolated stack: + ```bash + cd deploy/clients + ./new-client.sh .mivanchenko.de + ``` + → scp to the homelab → add the printed Caddy block + reload → `docker compose up -d`. + Live at `https://.mivanchenko.de` (wildcard DNS + auto-TLS). + +## 6. Tune their booking +In the EA backend (admin login — see `.secrets/easyappointments.txt`), set the provider's real +**services** (duration / price) and **working hours / timezone**. Optionally hand the studio +their **provider login** so they manage their own calendar. + +## 7. Hand over & go live +- Give them either their **EA provider login** (self-hosted calendar view) or the **iCal feed** + `…/crm/api/bookings.ics?client_id=C-xxxx&token=…` to subscribe in Apple / Google Calendar. +- A customer booking now flows: their site → EA → CRM `bookings` → your Telegram. +- Set the **Client → `active`**. + +## 8. Ongoing +Bookings auto-land in the CRM (source of truth); renewal reminders fire; bill monthly. +Update status to `churned` if they leave (then `docker compose down` their stack to offboard). diff --git a/playbooks/outreach.md b/playbooks/outreach.md new file mode 100644 index 0000000..353bc16 --- /dev/null +++ b/playbooks/outreach.md @@ -0,0 +1,82 @@ +# Playbook — Outreach (getting the first real leads) + +Goal: turn local SMBs **without a (decent) website** into booked calls. The winning angle for +local businesses is **preview-first**: don't ask "do you want a website?" — show them one you +already built for *their* business. Costs you ~20 min per prospect (clone a demo, rebrand the +name/colours/hours) and converts far better than a cold pitch. + +## The funnel (track every prospect in the CRM) +Each prospect is a row in **Leads** (`source = outreach`), moving through status: +`new` → `contacted` → `qualified` (they replied / want to talk) → `won` (booked/closed) / `lost`. +So the same dashboard you already run doubles as the outreach pipeline. + +## Step 1 — Build the target list (aim for 20–30 to start) +Search Google Maps for a niche + town (e.g. "Friseur ", "Physiotherapie ", +"Pizzeria "). For each result, record: +- Business name, owner name (if visible), phone, email, Instagram/FB handle, Maps URL. +- **Website?** none / outdated / "only Facebook" / "only Lieferando". These are your best targets. +- A 1-line hook (no online booking, ugly mobile site, no menu online, etc.). + +Prioritise: **no website at all** > only-Facebook > outdated site. Skip anyone with a clean, +modern, booking-enabled site — not worth your time. + +## Step 2 — Build the preview (the hook) +Clone the matching demo and rebrand it in ~20 min: +- Casual/booking (barber, salon, café) → `templates/landing/demo-tier-a/` +- Privacy/practice (physio, tax, clinic) → `templates/landing/demo-tier-b/` +- Food/ordering (pizzeria, imbiss) → `templates/landing/demo-pizzeria/` + +Swap name, colours, hours, services, a couple of photos. Deploy under a throwaway path +(e.g. `demos.mivanchenko.de/preview-`). That personalised URL is what you send. + +## Step 3 — Reach out (pick ONE primary channel, follow up on a second) + +### A) Cold email (best when you have the address) +> **Betreff:** Kurze Website-Idee für [BUSINESS] +> +> Hallo [NAME / „Team von BUSINESS"], +> +> ich bin [MY_NAME] aus [CITY] und baue einfache, schnelle Websites mit Online-Terminbuchung +> für lokale Betriebe. Für [BUSINESS] habe ich schon eine Beispielseite gebaut, damit Sie sehen, +> wie es aussehen könnte — ganz unverbindlich: +> +> 👉 [PREVIEW_URL] +> +> Wenn es Ihnen gefällt, mache ich daraus in wenigen Tagen Ihre echte Seite (inkl. Terminbuchung +> und Anfragen direkt aufs Handy). Hätten Sie diese Woche 10 Minuten für einen kurzen Anruf? +> +> Viele Grüße +> [MY_NAME] · [PHONE] · [BUSINESS_DOMAIN/EMAIL] + +### B) Instagram / Facebook DM (best for food, salons, cafés) +> Hallo [BUSINESS] 👋 ich baue Websites mit Online-Terminbuchung/-Bestellung für lokale Betriebe. +> Hab für euch mal eine Beispielseite gebaut — schaut mal: [PREVIEW_URL]. Wenn's gefällt, mache +> ich daraus eure echte Seite. Interesse an einem kurzen Gespräch? + +### C) Phone / walk-in opener +> „Guten Tag, ich bin [MY_NAME], ich baue Websites mit Online-Terminbuchung für Betriebe hier in +> [CITY]. Ich hab für [BUSINESS] schon eine Beispielseite gebaut — darf ich Ihnen den Link +> schicken, dann schauen Sie unverbindlich rein?" + +Walk-in is the strongest for food/retail — bring the preview on your phone and show it live. + +### Follow-up (once, after ~3 days, if no reply) +> Hallo nochmal — falls die Beispielseite untergegangen ist: [PREVIEW_URL]. Kein Stress, ich +> wollte nur kurz nachhaken, ob's für Sie interessant ist. 🙂 + +## Step 4 — On reply → qualify → onboard +- Reply = move the lead to `qualified`, book a 10-min call. +- On the call: confirm what they want (page, booking, online orders), agree setup fee + monthly + (see `tier-a-google.md` / `tier-b-selfhosted.md` for pricing), pick the tier. +- Closed = run the **Onboarding-Formular** (onboard.mivanchenko.de) → creates the Client + Project + in the CRM. Then follow the matching tier playbook to build the real site. + +## Cadence & targets +- Daily: 5–10 new prospects added + contacted. ~20 min/preview, so batch previews for your best 3–5. +- Expect rough numbers: ~20 contacted → ~3–5 replies → ~1 first paying client. Volume is the lever. +- Log everything in Leads so you can see reply-rate by niche/channel and double down on what works. + +## Don'ts +- Don't mass-blast identical emails (spam + low reply). Personalise the name + preview link. +- Don't write "DSGVO-konform" or make compliance promises on the public pages. +- Don't over-polish the preview — it's a hook, not the final product. 20 min, then send. diff --git a/templates/landing/preview-bestnails/index.html b/templates/landing/preview-bestnails/index.html new file mode 100644 index 0000000..9f60c45 --- /dev/null +++ b/templates/landing/preview-bestnails/index.html @@ -0,0 +1,433 @@ + + + + + + Best Nails Nürnberg — Nagelstudio | Termin online buchen + + + + + + + +
Beispielseite · So könnte Ihr Online-Auftritt aussehen
+ + + +
+ +
+
+ Nagelstudio · Nürnberg +

Schöne Nägel.
Zum Wohlfühlen.

+

Maniküre, Gel- & Shellac-Nägel und individuelles Nageldesign — mit Liebe und ruhiger Hand gemacht. Sichern Sie sich Ihren Termin in 30 Sekunden online.

+ +
+ 4,9 · Top bewertet + 💅 Liebe zum Detail + 📍 Mitten in Nürnberg +
+
+
+ + +
+
+
+ Leistungen +

Was wir für Sie tun

+

Faire Preise, gepflegte Atmosphäre — und ein Ergebnis, das man gerne herzeigt.

+
+
+
💅

Maniküre

Pflege, Form und Lack für gepflegte, natürliche Hände.

ab 25 €
+

Gel- & Shellac-Nägel

Langanhaltend, glänzend und in Ihrer Wunschfarbe.

ab 45 €
+
🦶

Pediküre

Wohltuende Fußpflege — entspannt und gründlich.

ab 35 €
+
🎨

Nageldesign & Nail Art

Individuelle Designs, French, Steinchen, Muster — ganz nach Wunsch.

ab 5 € / Nagel
+
+
+
+ + +
+
+
+ Termin buchen +

Wählen Sie Ihren Termin.
Den Rest machen wir.

+
    +
  • Sofortige Bestätigung per E-Mail
  • +
  • Automatische Erinnerung am Vortag
  • +
  • Bequem online — kein Hin und Her per DM
  • +
+ + Verfügbare Zeiten ansehen → +
+
+
Online-Terminbuchung — heute
+
+
09:30
+
10:30
+
11:30
+
13:00
+
14:00
+
15:30
+
16:30
+
17:30
+
18:30
+
+

Demo-Vorschau. In der Live-Version buchen Ihre Kundinnen hier rund um die Uhr selbst einen freien Termin — mit sofortiger Bestätigung.

+
+
+
+ + +
+
+
+ Über uns +

Ihr Wohlfühl-Moment in Nürnberg.

+

Bei Best Nails nehmen wir uns Zeit für Sie. In ruhiger, gepflegter Atmosphäre kümmern wir uns um Ihre Hände und Füße — sorgfältig, hygienisch und mit einem Auge fürs Detail. Kommen Sie vorbei und gönnen Sie sich eine kleine Auszeit.

+
+
+ Öffnungszeiten + + + + + +
Dienstag – Freitag09:30 – 19:00
Samstag09:00 – 16:00
Sonntag & Montaggeschlossen
+

📍 Nürnberg · genaue Adresse in der Live-Version

+
+
+
+ + +
+
+
+ Kontakt +

Frage stellen oder Rückruf anfordern

+

Schreiben Sie uns kurz — wir melden uns am selben Tag.

+
+ +
+ + +
+
+
+ +
+
+ +
✓ Danke! Demo-Formular — in der Live-Version landet Ihre Anfrage automatisch im System.
+

Mit dem Absenden stimmen Sie der Verarbeitung Ihrer Angaben zur Kontaktaufnahme zu.

+
+
+
+
+ +
+
+
+
BESTNAILS
+

Nagelstudio · Nürnberg

+
+
+

Öffnungszeiten

+

Di–Fr 09:30–19 · Sa 09–16

+
+
+

Kontakt

+

@bestnailsbeautysalon

+
+
+
© 2026 Best Nails Nürnberg · Beispielseite · Impressum · Datenschutz
+
+ + + + + + + + +
+ +
+ + + + + + diff --git a/templates/landing/preview-happynails/index.html b/templates/landing/preview-happynails/index.html new file mode 100644 index 0000000..d4d7ba5 --- /dev/null +++ b/templates/landing/preview-happynails/index.html @@ -0,0 +1,433 @@ + + + + + + Happy Nails Nürnberg — Nagelstudio | Termin online buchen + + + + + + + +
Beispielseite · So könnte Ihr Online-Auftritt aussehen
+ + + +
+ +
+
+ Nagelstudio · Nürnberg +

Schöne Nägel.
Zum Wohlfühlen.

+

Maniküre, Gel- & Shellac-Nägel und individuelles Nageldesign — mit Liebe und ruhiger Hand gemacht. Sichern Sie sich Ihren Termin in 30 Sekunden online.

+ +
+ 4,9 · Top bewertet + 💅 Liebe zum Detail + 📍 Mitten in Nürnberg +
+
+
+ + +
+
+
+ Leistungen +

Was wir für Sie tun

+

Faire Preise, gepflegte Atmosphäre — und ein Ergebnis, das man gerne herzeigt.

+
+
+
💅

Maniküre

Pflege, Form und Lack für gepflegte, natürliche Hände.

ab 25 €
+

Gel- & Shellac-Nägel

Langanhaltend, glänzend und in Ihrer Wunschfarbe.

ab 45 €
+
🦶

Pediküre

Wohltuende Fußpflege — entspannt und gründlich.

ab 35 €
+
🎨

Nageldesign & Nail Art

Individuelle Designs, French, Steinchen, Muster — ganz nach Wunsch.

ab 5 € / Nagel
+
+
+
+ + +
+
+
+ Termin buchen +

Wählen Sie Ihren Termin.
Den Rest machen wir.

+
    +
  • Sofortige Bestätigung per E-Mail
  • +
  • Automatische Erinnerung am Vortag
  • +
  • Bequem online — kein Hin und Her per DM
  • +
+ + Verfügbare Zeiten ansehen → +
+
+
Online-Terminbuchung — heute
+
+
09:30
+
10:30
+
11:30
+
13:00
+
14:00
+
15:30
+
16:30
+
17:30
+
18:30
+
+

Demo-Vorschau. In der Live-Version buchen Ihre Kundinnen hier rund um die Uhr selbst einen freien Termin — mit sofortiger Bestätigung.

+
+
+
+ + +
+
+
+ Über uns +

Ihr Wohlfühl-Moment in Nürnberg.

+

Bei Happy Nails nehmen wir uns Zeit für Sie. In ruhiger, gepflegter Atmosphäre kümmern wir uns um Ihre Hände und Füße — sorgfältig, hygienisch und mit einem Auge fürs Detail. Kommen Sie vorbei und gönnen Sie sich eine kleine Auszeit.

+
+
+ Öffnungszeiten + + + + + +
Dienstag – Freitag09:30 – 19:00
Samstag09:00 – 16:00
Sonntag & Montaggeschlossen
+

📍 Nürnberg · genaue Adresse in der Live-Version

+
+
+
+ + +
+
+
+ Kontakt +

Frage stellen oder Rückruf anfordern

+

Schreiben Sie uns kurz — wir melden uns am selben Tag.

+
+ +
+ + +
+
+
+ +
+
+ +
✓ Danke! Demo-Formular — in der Live-Version landet Ihre Anfrage automatisch im System.
+

Mit dem Absenden stimmen Sie der Verarbeitung Ihrer Angaben zur Kontaktaufnahme zu.

+
+
+
+
+ +
+
+
+
HAPPYNAILS
+

Nagelstudio · Nürnberg

+
+
+

Öffnungszeiten

+

Di–Fr 09:30–19 · Sa 09–16

+
+
+

Kontakt

+

0176 6742 6558 · @happynailsnuernberg

+
+
+
© 2026 Happy Nails Nürnberg · Beispielseite · Impressum · Datenschutz
+
+ + + + + + + + +
+ +
+ + + + + + diff --git a/templates/landing/preview-nailbar/index.html b/templates/landing/preview-nailbar/index.html new file mode 100644 index 0000000..bc311ec --- /dev/null +++ b/templates/landing/preview-nailbar/index.html @@ -0,0 +1,433 @@ + + + + + + NAILBAR Nürnberg — Nagelstudio | Termin online buchen + + + + + + + +
Beispielseite · So könnte Ihr Online-Auftritt aussehen
+ + + +
+ +
+
+ Nagelstudio · Nürnberg +

Schöne Nägel.
Zum Wohlfühlen.

+

Maniküre, Gel- & Shellac-Nägel und individuelles Nageldesign — mit Liebe und ruhiger Hand gemacht. Sichern Sie sich Ihren Termin in 30 Sekunden online.

+ +
+ 4,9 · Top bewertet + 💅 Liebe zum Detail + 📍 Mitten in Nürnberg +
+
+
+ + +
+
+
+ Leistungen +

Was wir für Sie tun

+

Faire Preise, gepflegte Atmosphäre — und ein Ergebnis, das man gerne herzeigt.

+
+
+
💅

Maniküre

Pflege, Form und Lack für gepflegte, natürliche Hände.

ab 25 €
+

Gel- & Shellac-Nägel

Langanhaltend, glänzend und in Ihrer Wunschfarbe.

ab 45 €
+
🦶

Pediküre

Wohltuende Fußpflege — entspannt und gründlich.

ab 35 €
+
🎨

Nageldesign & Nail Art

Individuelle Designs, French, Steinchen, Muster — ganz nach Wunsch.

ab 5 € / Nagel
+
+
+
+ + +
+
+
+ Termin buchen +

Wählen Sie Ihren Termin.
Den Rest machen wir.

+
    +
  • Sofortige Bestätigung per E-Mail
  • +
  • Automatische Erinnerung am Vortag
  • +
  • Bequem online — kein Hin und Her per DM
  • +
+ + Verfügbare Zeiten ansehen → +
+
+
Online-Terminbuchung — heute
+
+
09:30
+
10:30
+
11:30
+
13:00
+
14:00
+
15:30
+
16:30
+
17:30
+
18:30
+
+

Demo-Vorschau. In der Live-Version buchen Ihre Kundinnen hier rund um die Uhr selbst einen freien Termin — mit sofortiger Bestätigung.

+
+
+
+ + +
+
+
+ Über uns +

Ihr Wohlfühl-Moment in Nürnberg.

+

Bei NAILBAR nehmen wir uns Zeit für Sie. In ruhiger, gepflegter Atmosphäre kümmern wir uns um Ihre Hände und Füße — sorgfältig, hygienisch und mit einem Auge fürs Detail. Kommen Sie vorbei und gönnen Sie sich eine kleine Auszeit.

+
+
+ Öffnungszeiten + + + + + +
Dienstag – Freitag09:30 – 19:00
Samstag09:00 – 16:00
Sonntag & Montaggeschlossen
+

📍 Nürnberg · genaue Adresse in der Live-Version

+
+
+
+ + +
+
+
+ Kontakt +

Frage stellen oder Rückruf anfordern

+

Schreiben Sie uns kurz — wir melden uns am selben Tag.

+
+ +
+ + +
+
+
+ +
+
+ +
✓ Danke! Demo-Formular — in der Live-Version landet Ihre Anfrage automatisch im System.
+

Mit dem Absenden stimmen Sie der Verarbeitung Ihrer Angaben zur Kontaktaufnahme zu.

+
+
+
+
+ +
+
+
+
NAILBAR
+

Nagelstudio · Nürnberg

+
+
+

Öffnungszeiten

+

Di–Fr 09:30–19 · Sa 09–16

+
+
+

Kontakt

+

@nailbar.nuernberg

+
+
+
© 2026 NAILBAR Nürnberg · Beispielseite · Impressum · Datenschutz
+
+ + + + + + + + +
+ +
+ + + + + +