Rate-limit public bookings per contact; add direct owner contact endpoint
Test backoffice (smb-crm) / test (push) Successful in 1m43s
Test backoffice (smb-crm) / test (push) Successful in 1m43s
Public booking API now rejects a 6th active booking from the same customer_contact within 24h (429), stopping one contact from filling every slot on every resource, while owner-entered manual bookings stay unaffected. Add POST /api/contact: client sites can reach their own owner's inbox directly (via their existing login email) for general inquiries, separate from the agency's leads/Telegram pipeline (n8n/lead-intake.json), which stays reserved for actual prospects contacting the agency itself. Paris Barber Shop's contact form and Rückruf widget now point here; the Rückruf floating widget itself has been removed from the site. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import booking_db as bdb
|
|||||||
import db
|
import db
|
||||||
import owner_mail
|
import owner_mail
|
||||||
from booking_api import bp as booking_bp
|
from booking_api import bp as booking_bp
|
||||||
|
from contact_api import bp as contact_bp
|
||||||
from public_booking import bp as public_booking_bp
|
from public_booking import bp as public_booking_bp
|
||||||
from manage_booking import bp as manage_booking_bp
|
from manage_booking import bp as manage_booking_bp
|
||||||
from owner_auth import bp as owner_auth_bp
|
from owner_auth import bp as owner_auth_bp
|
||||||
@@ -31,6 +32,7 @@ app = Flask(__name__, static_folder="static", static_url_path="")
|
|||||||
# way to know the original request was HTTPS. One hop of proxy (Caddy).
|
# way to know the original request was HTTPS. One hop of proxy (Caddy).
|
||||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)
|
||||||
app.register_blueprint(booking_bp)
|
app.register_blueprint(booking_bp)
|
||||||
|
app.register_blueprint(contact_bp)
|
||||||
app.register_blueprint(public_booking_bp)
|
app.register_blueprint(public_booking_bp)
|
||||||
app.register_blueprint(manage_booking_bp)
|
app.register_blueprint(manage_booking_bp)
|
||||||
app.register_blueprint(owner_auth_bp)
|
app.register_blueprint(owner_auth_bp)
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ bp = Blueprint("booking_api", __name__, url_prefix="/api/booking")
|
|||||||
TOKEN_SECRET = os.environ.get("BOOKING_TOKEN_SECRET", "")
|
TOKEN_SECRET = os.environ.get("BOOKING_TOKEN_SECRET", "")
|
||||||
TOKEN_TTL_DAYS = 30
|
TOKEN_TTL_DAYS = 30
|
||||||
|
|
||||||
|
# Per-contact rate limit on the public booking endpoint: stops one phone
|
||||||
|
# number/email from filling every slot on every resource. Deliberately not
|
||||||
|
# applied to owner_booking.py's manual-entry path (source="owner") -- it
|
||||||
|
# calls create_booking_row directly, never through this route.
|
||||||
|
CONTACT_BOOKING_LIMIT = 5
|
||||||
|
CONTACT_BOOKING_WINDOW_HOURS = 24
|
||||||
|
|
||||||
|
|
||||||
def _mint_manage_token(client_id, booking_id):
|
def _mint_manage_token(client_id, booking_id):
|
||||||
payload = {
|
payload = {
|
||||||
@@ -290,6 +297,13 @@ def create_booking():
|
|||||||
and body.get("customer_name") and body.get("customer_contact")):
|
and body.get("customer_name") and body.get("customer_contact")):
|
||||||
return jsonify({"error": "client_id, resource_id, service_id, start_time, "
|
return jsonify({"error": "client_id, resource_id, service_id, start_time, "
|
||||||
"customer_name, customer_contact are required"}), 400
|
"customer_name, customer_contact are required"}), 400
|
||||||
|
|
||||||
|
since = datetime.now(timezone.utc) - timedelta(hours=CONTACT_BOOKING_WINDOW_HOURS)
|
||||||
|
recent = bdb.count_recent_bookings_by_contact(
|
||||||
|
client_id, body["customer_contact"], since)
|
||||||
|
if recent >= CONTACT_BOOKING_LIMIT:
|
||||||
|
return jsonify({"error": "too many recent bookings for this contact"}), 429
|
||||||
|
|
||||||
try:
|
try:
|
||||||
booking, token = create_booking_row(
|
booking, token = create_booking_row(
|
||||||
client_id, resource_id, service_id, start_time,
|
client_id, resource_id, service_id, start_time,
|
||||||
|
|||||||
@@ -383,6 +383,22 @@ def list_active_bookings_for_resource(client_id, resource_id, start, end,
|
|||||||
return cur.fetchall()
|
return cur.fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def count_recent_bookings_by_contact(client_id, customer_contact, since):
|
||||||
|
"""Count of client_id's non-cancelled bookings for customer_contact
|
||||||
|
created at or after `since` -- the public booking API's per-contact rate
|
||||||
|
limit reads this to stop one contact from filling every slot on every
|
||||||
|
resource. Owner-entered bookings (source="owner") count here too, since
|
||||||
|
an owner double-booking themselves in isn't the scenario this guards
|
||||||
|
against and excluding it would only add a footgun for no benefit."""
|
||||||
|
with db.connect() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT count(*) AS n FROM bookings WHERE client_id = %s "
|
||||||
|
"AND customer_contact = %s AND status != 'cancelled' "
|
||||||
|
"AND created_at >= %s",
|
||||||
|
(client_id, customer_contact, since))
|
||||||
|
return cur.fetchone()["n"]
|
||||||
|
|
||||||
|
|
||||||
def update_booking(client_id, booking_id, **fields):
|
def update_booking(client_id, booking_id, **fields):
|
||||||
"""Update a booking scoped to client_id (e.g. reschedule/cancel).
|
"""Update a booking scoped to client_id (e.g. reschedule/cancel).
|
||||||
Returns the updated row, or None if no such booking exists for this
|
Returns the updated row, or None if no such booking exists for this
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Public "contact the owner" API: a client's own site (a top-level page on
|
||||||
|
its own domain, e.g. barbershop.mivanchenko.de -- not an iframe of this app,
|
||||||
|
unlike the booking widget) posts here directly, cross-origin. See
|
||||||
|
contact_mail.py for why this is a separate path from n8n/lead-intake.json.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
import booking_db as bdb
|
||||||
|
import contact_mail
|
||||||
|
|
||||||
|
bp = Blueprint("contact_api", __name__, url_prefix="/api/contact")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.after_request
|
||||||
|
def _add_cors_headers(resp):
|
||||||
|
# Client sites live on their own domains (barbershop.mivanchenko.de, a
|
||||||
|
# future client's own domain, ...), never this app's own origin -- a
|
||||||
|
# fixed allowlist would mean editing this file for every new client, so
|
||||||
|
# this mirrors n8n/lead-intake.json's existing allowedOrigins: "*" for
|
||||||
|
# the same public, unauthenticated, abuse-limited-by-content form.
|
||||||
|
# Runs on every response from this blueprint, including Flask's
|
||||||
|
# automatic OPTIONS response to the browser's CORS preflight (a JSON
|
||||||
|
# POST isn't a CORS-simple request) -- no separate OPTIONS route needed.
|
||||||
|
resp.headers["Access-Control-Allow-Origin"] = "*"
|
||||||
|
resp.headers["Access-Control-Allow-Methods"] = "POST, OPTIONS"
|
||||||
|
resp.headers["Access-Control-Allow-Headers"] = "Content-Type"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("")
|
||||||
|
def create_contact():
|
||||||
|
body = request.get_json(force=True, silent=True) or {}
|
||||||
|
if (body.get("website") or "").strip():
|
||||||
|
# Honeypot field, same convention as booking_api.py's create_booking:
|
||||||
|
# real visitors never see or fill it, so a filled value means a bot.
|
||||||
|
# Fake a normal-looking success so a scripted client has no signal.
|
||||||
|
return jsonify({"sent": True}), 201
|
||||||
|
|
||||||
|
client_id = body.get("client_id")
|
||||||
|
name = (body.get("name") or "").strip()
|
||||||
|
contact = (body.get("contact") or "").strip()
|
||||||
|
if not (client_id and name and contact):
|
||||||
|
return jsonify({"error": "client_id, name, contact are required"}), 400
|
||||||
|
|
||||||
|
if bdb.get_client(client_id) is None:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"name": name,
|
||||||
|
"contact": contact,
|
||||||
|
"service_interest": (body.get("service_interest") or body.get("service") or "").strip(),
|
||||||
|
"message": (body.get("message") or "").strip(),
|
||||||
|
}
|
||||||
|
if not contact_mail.notify_owner_of_contact(client_id, payload):
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
return jsonify({"sent": True}), 201
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Direct customer-to-owner contact email: a client's own site visitor asking
|
||||||
|
a question or requesting a callback (contact_api.py) reaches the business
|
||||||
|
owner's inbox directly. This is deliberately NOT the agency's leads pipeline
|
||||||
|
(n8n/lead-intake.json, the leads table, the agency's Telegram) -- that path
|
||||||
|
still exists separately for prospects contacting the agency itself (the
|
||||||
|
"DEMO" client_id on the marketing/demo pages), which are genuine sales leads
|
||||||
|
for the agency, not a client's own customers.
|
||||||
|
|
||||||
|
Recipient is resolved via booking_db.list_users(client_id) (the owner's own
|
||||||
|
login email, e.g. inhaber@<slug>.mivanchenko.de) -- every provisioned client
|
||||||
|
already has at least one owner account by the time their site can receive
|
||||||
|
contact requests. Fire-and-forget via mailer.py, same as booking_mail.py/
|
||||||
|
owner_mail.py.
|
||||||
|
"""
|
||||||
|
import booking_db as bdb
|
||||||
|
import booking_mail
|
||||||
|
import mailer
|
||||||
|
from flask import render_template
|
||||||
|
|
||||||
|
|
||||||
|
def notify_owner_of_contact(client_id, payload):
|
||||||
|
"""Returns True if at least one owner account was found and an email was
|
||||||
|
queued, False if this client has no owner account yet (nothing to notify
|
||||||
|
-- the caller turns that into a clean 404, since there is no one to
|
||||||
|
receive the message)."""
|
||||||
|
users = bdb.list_users(client_id)
|
||||||
|
if not users:
|
||||||
|
return False
|
||||||
|
client = bdb.get_client(client_id)
|
||||||
|
business_name = (client or {}).get("business_name") or "Ihre Website"
|
||||||
|
html = render_template(
|
||||||
|
"emails/contact_notification.html",
|
||||||
|
business_name=business_name,
|
||||||
|
name=payload.get("name") or "",
|
||||||
|
contact=payload.get("contact") or "",
|
||||||
|
service_interest=payload.get("service_interest") or "",
|
||||||
|
message=payload.get("message") or "",
|
||||||
|
)
|
||||||
|
from_addr = booking_mail._sender_for(client)
|
||||||
|
for user in users:
|
||||||
|
mailer.send_email(
|
||||||
|
user["email"],
|
||||||
|
f"Neue Anfrage über Ihre Website – {business_name}",
|
||||||
|
html,
|
||||||
|
from_addr=from_addr)
|
||||||
|
return True
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Neue Anfrage</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0; padding:20px; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color:#16302f;">
|
||||||
|
<h1 style="font-size:1.2rem; margin:0 0 14px;">{{ business_name }}</h1>
|
||||||
|
<p>Über Ihre Website ist eine neue Anfrage eingegangen:</p>
|
||||||
|
<table style="border-collapse:collapse; margin:14px 0;">
|
||||||
|
<tr><td style="padding:4px 12px 4px 0; color:#5b6b73;">Name</td><td style="padding:4px 0;"><b>{{ name }}</b></td></tr>
|
||||||
|
<tr><td style="padding:4px 12px 4px 0; color:#5b6b73;">Kontakt</td><td style="padding:4px 0;"><b>{{ contact }}</b></td></tr>
|
||||||
|
{% if service_interest %}<tr><td style="padding:4px 12px 4px 0; color:#5b6b73;">Interesse</td><td style="padding:4px 0;">{{ service_interest }}</td></tr>{% endif %}
|
||||||
|
</table>
|
||||||
|
{% if message %}<p style="white-space:pre-wrap;">{{ message }}</p>{% endif %}
|
||||||
|
<p style="color:#5b6b73; font-size:.85rem; margin-top:24px;">Bitte antworten Sie direkt an die oben genannte Kontaktadresse.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -185,6 +185,38 @@ def test_concurrent_booking_requests_only_one_succeeds(client):
|
|||||||
assert len(bdb.list_bookings(CLIENT_A)) == 1
|
assert len(bdb.list_bookings(CLIENT_A)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_contact_rate_limit_blocks_after_five_recent_bookings(client):
|
||||||
|
resource, service = _setup_resource_and_service(
|
||||||
|
min_notice_minutes=0, max_advance_days=365)
|
||||||
|
day = _next_monday(date.today())
|
||||||
|
slots = client.get("/api/booking/slots", query_string={
|
||||||
|
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||||
|
"service_id": service["service_id"],
|
||||||
|
"date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"]
|
||||||
|
assert len(slots) >= 6 # 09:00-17:00, 60min slots -- 8 available
|
||||||
|
|
||||||
|
for slot in slots[:5]:
|
||||||
|
resp = client.post("/api/booking", json={
|
||||||
|
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||||
|
"service_id": service["service_id"], "start_time": slot,
|
||||||
|
"customer_name": "Serial Booker", "customer_contact": "serial@example.com"})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
|
||||||
|
resp = client.post("/api/booking", json={
|
||||||
|
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||||
|
"service_id": service["service_id"], "start_time": slots[5],
|
||||||
|
"customer_name": "Serial Booker", "customer_contact": "serial@example.com"})
|
||||||
|
assert resp.status_code == 429
|
||||||
|
assert len(bdb.list_bookings(CLIENT_A)) == 5
|
||||||
|
|
||||||
|
# A different contact is unaffected by the first contact's count.
|
||||||
|
resp = client.post("/api/booking", json={
|
||||||
|
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||||
|
"service_id": service["service_id"], "start_time": slots[5],
|
||||||
|
"customer_name": "Someone Else", "customer_contact": "else@example.com"})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
def test_cancel_with_valid_token_cancels_booking(client):
|
def test_cancel_with_valid_token_cancels_booking(client):
|
||||||
resource, service = _setup_resource_and_service(
|
resource, service = _setup_resource_and_service(
|
||||||
min_notice_minutes=0, max_advance_days=365)
|
min_notice_minutes=0, max_advance_days=365)
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Flask test client / real-DB integration tests for the public "contact the
|
||||||
|
owner" API (contact_api.py) -- the customer-to-owner path, deliberately
|
||||||
|
separate from the agency's leads/Telegram pipeline. See contact_mail.py.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import booking_db as bdb
|
||||||
|
from app import app as flask_app
|
||||||
|
|
||||||
|
CLIENT_A = "C-TEST-CONTACT-A"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
flask_app.config["TESTING"] = True
|
||||||
|
return flask_app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_booking_client(client_id, business_name="Café Test"):
|
||||||
|
with bdb.db.connect() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO clients (client_id, business_name) VALUES (%s, %s) "
|
||||||
|
"ON CONFLICT (client_id) DO NOTHING",
|
||||||
|
(client_id, business_name))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_contact_request_emails_the_owner(client, monkeypatch):
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"contact_mail.mailer.send_email",
|
||||||
|
lambda to, subject, html, from_addr=None: sent.append(
|
||||||
|
{"to": to, "subject": subject, "html": html}))
|
||||||
|
_insert_booking_client(CLIENT_A)
|
||||||
|
bdb.create_user(CLIENT_A, "inhaber@contact-test.example", "irrelevant-pw")
|
||||||
|
|
||||||
|
resp = client.post("/api/contact", json={
|
||||||
|
"client_id": CLIENT_A, "name": "Jamie", "contact": "jamie@example.com",
|
||||||
|
"service_interest": "Herrenschnitt", "message": "Gibt es heute noch einen Termin?"})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.get_json() == {"sent": True}
|
||||||
|
assert resp.headers["Access-Control-Allow-Origin"] == "*"
|
||||||
|
|
||||||
|
assert len(sent) == 1
|
||||||
|
assert sent[0]["to"] == "inhaber@contact-test.example"
|
||||||
|
assert "Jamie" in sent[0]["html"]
|
||||||
|
assert "jamie@example.com" in sent[0]["html"]
|
||||||
|
assert "Herrenschnitt" in sent[0]["html"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_contact_request_falls_back_to_service_field(client, monkeypatch):
|
||||||
|
"""The Rückruf widget sends service_interest; the plain contact form
|
||||||
|
sends the same concept under the field name "service" -- both work."""
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"contact_mail.mailer.send_email",
|
||||||
|
lambda to, subject, html, from_addr=None: sent.append(html))
|
||||||
|
client_id = "C-TEST-CONTACT-SVCFALLBACK"
|
||||||
|
_insert_booking_client(client_id)
|
||||||
|
bdb.create_user(client_id, "inhaber@svcfallback.example", "irrelevant-pw")
|
||||||
|
|
||||||
|
resp = client.post("/api/contact", json={
|
||||||
|
"client_id": client_id, "name": "Robin", "contact": "robin@example.com",
|
||||||
|
"service": "Bartpflege"})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert "Bartpflege" in sent[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_honeypot_fakes_success_without_sending(client, monkeypatch):
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"contact_mail.mailer.send_email",
|
||||||
|
lambda to, subject, html, from_addr=None: sent.append(to))
|
||||||
|
client_id = "C-TEST-CONTACT-HONEYPOT"
|
||||||
|
_insert_booking_client(client_id)
|
||||||
|
bdb.create_user(client_id, "inhaber@honeypot.example", "irrelevant-pw")
|
||||||
|
|
||||||
|
resp = client.post("/api/contact", json={
|
||||||
|
"client_id": client_id, "name": "Bot", "contact": "bot@example.com",
|
||||||
|
"website": "http://spam.example"})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.get_json() == {"sent": True}
|
||||||
|
assert sent == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_required_fields_is_rejected(client):
|
||||||
|
resp = client.post("/api/contact", json={"client_id": CLIENT_A, "name": "NoContact"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_client_is_rejected(client):
|
||||||
|
resp = client.post("/api/contact", json={
|
||||||
|
"client_id": "C-DOES-NOT-EXIST", "name": "Alex", "contact": "a@example.com"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_without_owner_account_is_rejected(client):
|
||||||
|
"""A client that exists in the CRM but hasn't been provisioned into the
|
||||||
|
booking schema yet (no owner account) -- nothing to notify, so this is a
|
||||||
|
404 rather than a silent success the visitor would wrongly trust."""
|
||||||
|
client_id = "C-TEST-CONTACT-NOOWNER"
|
||||||
|
_insert_booking_client(client_id)
|
||||||
|
resp = client.post("/api/contact", json={
|
||||||
|
"client_id": client_id, "name": "Alex", "contact": "a@example.com"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_options_returns_cors_headers(client):
|
||||||
|
resp = client.options("/api/contact")
|
||||||
|
assert resp.status_code < 300
|
||||||
|
assert resp.headers["Access-Control-Allow-Origin"] == "*"
|
||||||
|
assert "POST" in resp.headers["Access-Control-Allow-Methods"]
|
||||||
@@ -231,7 +231,7 @@
|
|||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="sec-head">
|
<div class="sec-head">
|
||||||
<span class="eyebrow">Kontakt</span>
|
<span class="eyebrow">Kontakt</span>
|
||||||
<h2>Frage stellen oder Rückruf anfordern</h2>
|
<h2>Frage stellen</h2>
|
||||||
<p>Schreiben Sie uns kurz — wir melden uns am selben Tag.</p>
|
<p>Schreiben Sie uns kurz — wir melden uns am selben Tag.</p>
|
||||||
</div>
|
</div>
|
||||||
<form class="lead" onsubmit="return submitLead(event, this)">
|
<form class="lead" onsubmit="return submitLead(event, this)">
|
||||||
@@ -375,8 +375,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Lead-Formular -> n8n Webhook -> CRM (Leads) + Telegram-Benachrichtigung.
|
// Kontaktformular -> direkt eine E-Mail an den Inhaber (kein CRM-Lead, kein Telegram).
|
||||||
const LEAD_WEBHOOK = 'https://n8n.mivanchenko.de/webhook/lead-intake';
|
const LEAD_WEBHOOK = 'https://onboard.mivanchenko.de/api/contact';
|
||||||
async function submitLead(e, form) {
|
async function submitLead(e, form) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const btn = form.querySelector('button[type=submit]');
|
const btn = form.querySelector('button[type=submit]');
|
||||||
@@ -404,110 +404,5 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script>window.CB_CTX = { client_id: "C-0004", source: "paris-barbershop-callback" };</script>
|
|
||||||
<!-- ===== Rückruf-Widget (lead capture popup) ===== -->
|
|
||||||
<style>
|
|
||||||
.cb-fab { position: fixed; right: 18px; bottom: 18px; z-index: 900; background: var(--gold); color: #1a1410; border: 0; border-radius: 999px; padding: 13px 20px; font: 700 .95rem system-ui, -apple-system, Segoe UI, Roboto, sans-serif; box-shadow: 0 10px 26px rgba(200,160,90,.32); cursor: pointer; transition: transform .15s, background .2s; }
|
|
||||||
.cb-fab:hover { transform: translateY(-2px); background: var(--gold-2); }
|
|
||||||
.cb-overlay { position: fixed; inset: 0; z-index: 1000; background: rgba(10,8,7,.6); backdrop-filter: blur(3px); display: none; align-items: center; justify-content: center; padding: 18px; }
|
|
||||||
.cb-overlay.open { display: flex; }
|
|
||||||
.cb-modal { position: relative; width: min(440px, 100%); background: var(--card); color: var(--ink); border: 1px solid var(--line); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 28px 26px 22px; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; max-height: 92vh; overflow: auto; animation: cbIn .18s ease; }
|
|
||||||
@keyframes cbIn { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
|
|
||||||
.cb-modal h3 { margin: 0 0 4px; font-size: 1.35rem; font-family: 'Bebas Neue', sans-serif; letter-spacing: .5px; }
|
|
||||||
.cb-sub { margin: 0 0 18px; color: var(--muted); font-size: .9rem; }
|
|
||||||
.cb-x { position: absolute; top: 10px; right: 13px; background: none; border: 0; font-size: 1.7rem; line-height: 1; color: var(--muted); cursor: pointer; }
|
|
||||||
.cb-x:hover { color: var(--ink); }
|
|
||||||
.cb-modal label { display: block; font-size: .82rem; font-weight: 600; margin-bottom: 13px; }
|
|
||||||
.cb-modal label .o { color: var(--muted); font-weight: 400; }
|
|
||||||
.cb-modal input, .cb-modal textarea { width: 100%; margin-top: 5px; font: inherit; font-size: .94rem; color: var(--ink); background: #1a1611; border: 1px solid var(--line); border-radius: 9px; padding: 10px 12px; resize: vertical; }
|
|
||||||
.cb-modal input:focus, .cb-modal textarea:focus { outline: none; border-color: var(--gold); background: #1e1a15; }
|
|
||||||
.cb-submit { width: 100%; margin-top: 4px; background: var(--gold); color: #1a1410; border: 0; border-radius: 999px; padding: 13px; font: 700 1rem system-ui; cursor: pointer; transition: background .2s; }
|
|
||||||
.cb-submit:hover { background: var(--gold-2); }
|
|
||||||
.cb-submit:disabled { opacity: .6; cursor: default; }
|
|
||||||
.cb-toast { display: none; margin-top: 14px; padding: 11px 13px; border-radius: 9px; font-size: .88rem; font-weight: 500; }
|
|
||||||
.cb-toast.ok { background: #1e2a1c; color: #bfe6b6; border: 1px solid #2f5a2c; }
|
|
||||||
.cb-toast.err { background: #2e1c1c; color: #f3c7c7; border: 1px solid #5a2c2c; }
|
|
||||||
@media (max-width: 560px) { .cb-fab { right: 12px; bottom: 12px; padding: 11px 17px; } }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<button class="cb-fab" type="button" onclick="cbOpen()" aria-label="Rückruf anfordern">📞 Rückruf</button>
|
|
||||||
|
|
||||||
<div class="cb-overlay" id="cbOverlay" onclick="cbBg(event)">
|
|
||||||
<div class="cb-modal" role="dialog" aria-modal="true" aria-labelledby="cbTitle">
|
|
||||||
<button class="cb-x" type="button" onclick="cbClose()" aria-label="Schließen">×</button>
|
|
||||||
<h3 id="cbTitle">Rückruf anfordern</h3>
|
|
||||||
<p class="cb-sub">Name & Nummer genügen — wir melden uns bei Ihnen. Alles andere ist optional.</p>
|
|
||||||
<form onsubmit="return cbSubmit(event, this)">
|
|
||||||
<input type="hidden" name="client_id" />
|
|
||||||
<input type="hidden" name="source" />
|
|
||||||
<label>Name *
|
|
||||||
<input name="name" required autocomplete="name" placeholder="Vor- und Nachname" />
|
|
||||||
</label>
|
|
||||||
<label>Telefon *
|
|
||||||
<input name="phone" required autocomplete="tel" inputmode="tel" placeholder="+49 …" />
|
|
||||||
</label>
|
|
||||||
<label>Gewünschte Leistung <span class="o">(optional)</span>
|
|
||||||
<input name="service" placeholder="Herrenschnitt, Bartpflege …" />
|
|
||||||
</label>
|
|
||||||
<label>Nachricht <span class="o">(optional)</span>
|
|
||||||
<textarea name="message" rows="2" placeholder="Worum geht es?"></textarea>
|
|
||||||
</label>
|
|
||||||
<button class="cb-submit" type="submit">Rückruf anfordern</button>
|
|
||||||
<div class="cb-toast" id="cbToast"></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const CB_WEBHOOK = 'https://n8n.mivanchenko.de/webhook/lead-intake';
|
|
||||||
const cbCtx = window.CB_CTX || { client_id: 'PREVIEW', source: 'callback' };
|
|
||||||
function cbOpen() {
|
|
||||||
const o = document.getElementById('cbOverlay');
|
|
||||||
o.querySelector('[name=client_id]').value = cbCtx.client_id;
|
|
||||||
o.querySelector('[name=source]').value = cbCtx.source;
|
|
||||||
o.classList.add('open');
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
setTimeout(() => o.querySelector('[name=name]').focus(), 60);
|
|
||||||
}
|
|
||||||
function cbClose() {
|
|
||||||
document.getElementById('cbOverlay').classList.remove('open');
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
}
|
|
||||||
function cbBg(e) { if (e.target.id === 'cbOverlay') cbClose(); }
|
|
||||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') cbClose(); });
|
|
||||||
async function cbSubmit(e, form) {
|
|
||||||
e.preventDefault();
|
|
||||||
const btn = form.querySelector('.cb-submit');
|
|
||||||
const toast = document.getElementById('cbToast');
|
|
||||||
const d = Object.fromEntries(new FormData(form).entries());
|
|
||||||
let msg = (d.message || '').trim();
|
|
||||||
const payload = {
|
|
||||||
client_id: d.client_id, source: d.source,
|
|
||||||
name: d.name, phone: d.phone,
|
|
||||||
service_interest: d.service || '', message: msg
|
|
||||||
};
|
|
||||||
const old = btn.textContent;
|
|
||||||
btn.disabled = true; btn.textContent = 'Senden…';
|
|
||||||
toast.style.display = 'none';
|
|
||||||
try {
|
|
||||||
const r = await fetch(CB_WEBHOOK, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
|
||||||
if (!r.ok) throw new Error(r.status);
|
|
||||||
toast.className = 'cb-toast ok';
|
|
||||||
toast.textContent = '✓ Danke! Wir rufen Sie zurück.';
|
|
||||||
toast.style.display = 'block';
|
|
||||||
form.reset();
|
|
||||||
setTimeout(cbClose, 2200);
|
|
||||||
} catch (err) {
|
|
||||||
toast.className = 'cb-toast err';
|
|
||||||
toast.textContent = '⚠ Senden fehlgeschlagen. Bitte erneut versuchen.';
|
|
||||||
toast.style.display = 'block';
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false; btn.textContent = old;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<!-- ===== /Rückruf-Widget ===== -->
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user