shop / app.py
Kgshop's picture
Update app.py
721c4e0 verified
Raw
History Blame Contribute Delete
173 kB
import os
import io
import base64
import json
import logging
import threading
import time
import math
import re
import urllib.parse
from datetime import datetime, timedelta, timezone
from uuid import uuid4
import random
import string
from flask import Flask, render_template_string, request, redirect, url_for, flash, jsonify, session, make_response
from PIL import Image
import google.generativeai as genai
from huggingface_hub import HfApi, hf_hub_download
from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError
from werkzeug.utils import secure_filename
from dotenv import load_dotenv
import requests
load_dotenv()
app = Flask(__name__)
app.secret_key = 'your_unique_secret_key_gippo_312_shop_54321_no_login'
DATA_FILE = 'data.json'
SYNC_FILES = [DATA_FILE]
REPO_ID = "Kgshop/qrex"
HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
DOWNLOAD_RETRIES = 3
DOWNLOAD_DELAY = 5
ALMATY_TZ = timezone(timedelta(hours=6))
db_lock = threading.RLock()
TRANSLATIONS = {
'Zaloguj się do Panelu': {'en': 'Login to Admin Panel', 'de': 'Anmeldung zum Admin-Panel'},
'Zaloguj': {'en': 'Login', 'de': 'Anmelden'},
'Wprowadź hasło': {'en': 'Enter password', 'de': 'Passwort eingeben'},
'Główny Panel Administracyjny': {'en': 'Main Admin Panel', 'de': 'Haupt-Admin-Panel'},
'Zarządzanie Wizytówkami': {'en': 'vCard Management', 'de': 'Visitenkartenverwaltung'},
'Utwórz nową wizytówkę': {'en': 'Create new vCard', 'de': 'Neue Visitenkarte erstellen'},
'Szukaj po ID lub Nazwie...': {'en': 'Search by ID or Name...', 'de': 'Suchen nach ID oder Name...'},
'Istniejące wizytówki': {'en': 'Existing vCards', 'de': 'Vorhandene Visitenkarten'},
'Ustawienia': {'en': 'Settings', 'de': 'Einstellungen'},
'Zobacz': {'en': 'View', 'de': 'Ansehen'},
'Włącz hasło admina': {'en': 'Enable admin pwd', 'de': 'Admin-Passwort aktivieren'},
'Hasło': {'en': 'Password', 'de': 'Passwort'},
'Zapisz': {'en': 'Save', 'de': 'Speichern'},
'Nie utworzono jeszcze żadnych wizytówek.': {'en': 'No vCards created yet.', 'de': 'Noch keine Visitenkarten erstellt.'},
'Zapisz w kontaktach': {'en': 'Save to contacts', 'de': 'In Kontakten speichern'},
'Katalog / Usługi': {'en': 'Catalog / Services', 'de': 'Katalog / Dienstleistungen'},
'Szukaj...': {'en': 'Search...', 'de': 'Suchen...'},
'Wszystkie': {'en': 'All', 'de': 'Alle'},
'Do koszyka': {'en': 'Add to cart', 'de': 'In den Warenkorb'},
'Mój kod QR': {'en': 'My QR code', 'de': 'Mein QR-Code'},
'Zeskanuj, aby otworzyć wizytówkę': {'en': 'Scan to open vCard', 'de': 'Scannen, um Visitenkarte zu öffnen'},
'Koszyk': {'en': 'Cart', 'de': 'Warenkorb'},
'Twoje Imię': {'en': 'Your Name', 'de': 'Ihr Name'},
'Twój Telefon': {'en': 'Your Phone', 'de': 'Ihre Telefonnummer'},
'Złóż zamówienie': {'en': 'Checkout', 'de': 'Kasse'},
'Razem:': {'en': 'Total:', 'de': 'Gesamt:'},
'Koszyk jest pusty': {'en': 'Cart is empty', 'de': 'Warenkorb ist leer'},
'Brak wyników.': {'en': 'Nothing found.', 'de': 'Nichts gefunden.'},
'Przetwarzanie...': {'en': 'Processing...', 'de': 'Verarbeitung...'},
'Błąd składania zamówienia': {'en': 'Checkout error', 'de': 'Bestellfehler'},
'Błąd sieci': {'en': 'Network error', 'de': 'Netzwerkfehler'},
'Wypełnij imię i telefon': {'en': 'Fill in name and phone', 'de': 'Bitte Name und Telefon ausfüllen'},
'Otwórz wizytówkę': {'en': 'Open vCard', 'de': 'Visitenkarte öffnen'},
'Historia zamówień': {'en': 'Order History', 'de': 'Bestellhistorie'},
'Kod QR': {'en': 'QR Code', 'de': 'QR-Code'},
'Wyloguj': {'en': 'Logout', 'de': 'Abmelden'},
'Profil i wygląd': {'en': 'Profile & Appearance', 'de': 'Profil & Aussehen'},
'Imię:': {'en': 'First Name:', 'de': 'Vorname:'},
'Nazwisko:': {'en': 'Last Name:', 'de': 'Nachname:'},
'Stanowisko / Specjalizacja:': {'en': 'Job Title:', 'de': 'Beruf / Spezialisierung:'},
'Nazwa organizacji:': {'en': 'Organization:', 'de': 'Organisation:'},
'O mnie / Status:': {'en': 'About / Status:', 'de': 'Über mich / Status:'},
'Awatar (Zdjęcie profilowe):': {'en': 'Avatar (Profile Photo):', 'de': 'Avatar (Profilbild):'},
'Schemat kolorów:': {'en': 'Color Scheme:', 'de': 'Farbschema:'},
'Waluta (dla produktów/usług):': {'en': 'Currency (for products):', 'de': 'Währung (für Produkte):'},
'Ustawienia koszyka i zamówień': {'en': 'Cart & Orders Settings', 'de': 'Warenkorb & Bestellungen'},
'Włącz koszyk (zakupy)': {'en': 'Enable cart (purchases)', 'de': 'Warenkorb aktivieren (Käufe)'},
'Komunikator dla zamówień:': {'en': 'Order messenger:', 'de': 'Bestell-Messenger:'},
'Nie przyjmuj zamówień': {'en': 'Do not accept orders', 'de': 'Keine Bestellungen annehmen'},
'Numer (WhatsApp) lub Username (Telegram):': {'en': 'Number (WhatsApp) / Username (Telegram):', 'de': 'Nummer (WhatsApp) / Benutzername (Telegram):'},
'Hasło dostępu (dla admina)': {'en': 'Access Password (for admin)', 'de': 'Zugangspasswort (für Admin)'},
'Wymagaj hasła': {'en': 'Require password', 'de': 'Passwort erforderlich'},
'Zapisz ustawienia': {'en': 'Save settings', 'de': 'Einstellungen speichern'},
'Przyciski i Linki': {'en': 'Buttons & Links', 'de': 'Schaltflächen & Links'},
'Dodaj przycisk': {'en': 'Add button', 'de': 'Schaltfläche hinzufügen'},
'Typ bloku:': {'en': 'Block type:', 'de': 'Blocktyp:'},
'Przycisk-link': {'en': 'Link button', 'de': 'Link-Schaltfläche'},
'Blok tekstowy': {'en': 'Text block', 'de': 'Textblock'},
'Katalog PDF': {'en': 'PDF Catalog', 'de': 'PDF-Katalog'},
'Tytuł / Tekst przycisku:': {'en': 'Title / Button text:', 'de': 'Titel / Schaltflächentext:'},
'Ikona:': {'en': 'Icon:', 'de': 'Symbol:'},
'Bez ikony': {'en': 'No icon', 'de': 'Kein Symbol'},
'URL / Numer / Email...': {'en': 'URL / Number / Email...', 'de': 'URL / Nummer / E-Mail...'},
'Prześlij PDF:': {'en': 'Upload PDF:', 'de': 'PDF hochladen:'},
'Zmień PDF (zastąpi obecny):': {'en': 'Upload PDF (replaces current):', 'de': 'PDF hochladen (ersetzt aktuelles):'},
'Tekst bloku:': {'en': 'Block text:', 'de': 'Blocktext:'},
'Dodaj': {'en': 'Add', 'de': 'Hinzufügen'},
'Czy na pewno usunąć?': {'en': 'Are you sure to delete?', 'de': 'Wirklich löschen?'},
'Edytuj': {'en': 'Edit', 'de': 'Bearbeiten'},
'Kategorie produktów/usług': {'en': 'Product Categories', 'de': 'Produktkategorien'},
'Dodaj nową kategorię': {'en': 'Add new category', 'de': 'Neue Kategorie hinzufügen'},
'Nazwa nowej kategorii:': {'en': 'New category name:', 'de': 'Name der neuen Kategorie:'},
'Produkty i Usługi': {'en': 'Products & Services', 'de': 'Produkte & Dienstleistungen'},
'Dodaj produkt/usługę': {'en': 'Add product/service', 'de': 'Produkt/Dienstleistung hinzufügen'},
'Nazwa *:': {'en': 'Name *:', 'de': 'Name *:'},
'Cena (0 aby ukryć):': {'en': 'Price (0 to hide):', 'de': 'Preis (0 zum Ausblenden):'},
'Zdjęcia (max 10):': {'en': 'Photos (max 10):', 'de': 'Fotos (max. 10):'},
'Opis:': {'en': 'Description:', 'de': 'Beschreibung:'},
'Kategoria:': {'en': 'Category:', 'de': 'Kategorie:'},
'Bez kategorii': {'en': 'Uncategorized', 'de': 'Nicht kategorisiert'},
'Szukaj': {'en': 'Search', 'de': 'Suchen'},
'Resetuj': {'en': 'Reset', 'de': 'Zurücksetzen'},
'Usuń': {'en': 'Delete', 'de': 'Löschen'},
'Brak rekordów.': {'en': 'No records found.', 'de': 'Keine Einträge gefunden.'},
'Zamówienie #': {'en': 'Order #', 'de': 'Bestellung #'},
'Faktura zamówienia': {'en': 'Order Invoice', 'de': 'Bestellrechnung'},
'Kupujący:': {'en': 'Buyer:', 'de': 'Käufer:'},
'Telefon:': {'en': 'Phone:', 'de': 'Telefon:'},
'Sprzedawca:': {'en': 'Seller:', 'de': 'Verkäufer:'},
'szt.': {'en': 'pcs', 'de': 'Stk.'},
'Wyślij do sprzedawcy': {'en': 'Send to seller', 'de': 'An Verkäufer senden'},
'Wróć do katalogu': {'en': 'Back to catalog', 'de': 'Zurück zum Katalog'},
'Wróć do panelu': {'en': 'Back to panel', 'de': 'Zurück zum Panel'},
'Generuj opis AI z pierwszego zdjęcia': {'en': 'Generate AI description from 1st photo', 'de': 'KI-Beschreibung aus 1. Foto generieren'},
'Zapisywanie...': {'en': 'Saving...', 'de': 'Speichern...'},
'Faktura': {'en': 'Invoice', 'de': 'Rechnung'},
'Cena:': {'en': 'Price:', 'de': 'Preis:'},
'Obecny awatar:': {'en': 'Current avatar:', 'de': 'Aktueller Avatar:'},
'Obecny PDF:': {'en': 'Current PDF:', 'de': 'Aktuelles PDF:'},
'Brak': {'en': 'None', 'de': 'Keine'},
'Ustawienia wizytówki': {'en': 'vCard Settings', 'de': 'Visitenkarten-Einstellungen'},
'Link': {'en': 'Link', 'de': 'Link'},
'Tekst': {'en': 'Text', 'de': 'Text'},
'Zmień zdjęcia (stare zostaną usunięte):': {'en': 'Replace photos (old will be removed):', 'de': 'Fotos ersetzen (alte werden entfernt):'},
'Utworzono wizytówkę ID:': {'en': 'Created vCard ID:', 'de': 'Visitenkarte erstellt ID:'},
'Zaktualizowano hasło dla:': {'en': 'Password updated for:', 'de': 'Passwort aktualisiert für:'},
'Nie znaleziono wizytówki:': {'en': 'vCard not found:', 'de': 'Visitenkarte nicht gefunden:'},
'Usunięto wizytówkę:': {'en': 'Deleted vCard:', 'de': 'Visitenkarte gelöscht:'},
'Nieprawidłowe hasło': {'en': 'Invalid password', 'de': 'Ungültiges Passwort'},
'Dodano blok.': {'en': 'Block added.', 'de': 'Block hinzugefügt.'},
'Zaktualizowano blok.': {'en': 'Block updated.', 'de': 'Block aktualisiert.'},
'Usunięto blok.': {'en': 'Block deleted.', 'de': 'Block gelöscht.'},
'Przeniesiono blok wyżej.': {'en': 'Block moved up.', 'de': 'Block nach oben verschoben.'},
'Przeniesiono blok niżej.': {'en': 'Block moved down.', 'de': 'Block nach unten verschoben.'},
'Dodano kategorię:': {'en': 'Category added:', 'de': 'Kategorie hinzugefügt:'},
'Nazwa kategorii nie może być pusta.': {'en': 'Category name cannot be empty.', 'de': 'Kategoriename darf nicht leer sein.'},
'Kategoria już istnieje:': {'en': 'Category already exists:', 'de': 'Kategorie existiert bereits:'},
'Usunięto kategorię:': {'en': 'Category deleted:', 'de': 'Kategorie gelöscht:'},
'Zaktualizowano pomyślnie.': {'en': 'Updated successfully.', 'de': 'Erfolgreich aktualisiert.'},
'Błąd podczas aktualizacji:': {'en': 'Error during update:', 'de': 'Fehler bei der Aktualisierung:'},
'Nazwa jest wymagana.': {'en': 'Name is required.', 'de': 'Name ist erforderlich.'},
'Zapisano:': {'en': 'Saved:', 'de': 'Gespeichert:'},
'Usunięto rekord:': {'en': 'Record deleted:', 'de': 'Eintrag gelöscht:'},
'Usunięto zamówienie z historii.': {'en': 'Order deleted from history.', 'de': 'Bestellung aus der Historie gelöscht.'},
'Błąd:': {'en': 'Error:', 'de': 'Fehler:'},
'Błąd ładowania PDF:': {'en': 'Error uploading PDF:', 'de': 'Fehler beim Hochladen von PDF:'},
'Awatar pomyślnie zaktualizowany.': {'en': 'Avatar successfully updated.', 'de': 'Avatar erfolgreich aktualisiert.'},
'Błąd podczas ładowania awatara:': {'en': 'Error uploading avatar:', 'de': 'Fehler beim Hochladen des Avatars:'},
'Zapisano ustawienia.': {'en': 'Settings saved.', 'de': 'Einstellungen gespeichert.'},
'Błąd usuwania:': {'en': 'Delete error:', 'de': 'Löschfehler:'},
'Nieznana akcja:': {'en': 'Unknown action:', 'de': 'Unbekannte Aktion:'},
'Błąd wykonania akcji:': {'en': 'Action execution error:', 'de': 'Fehler bei der Aktionsausführung:'},
'Czy na pewno usunąć tę wizytówkę? To działanie jest nieodwracalne.': {'en': 'Are you sure you want to delete this vCard? This action is irreversible.', 'de': 'Möchten Sie diese Visitenkarte wirklich löschen? Diese Aktion ist unumkehrbar.'},
'Czy na pewno? Produkty w tej kategorii zostaną oznaczone jako "Bez kategorii".': {'en': 'Are you sure? Products in this category will be marked as "Uncategorized".', 'de': 'Sind Sie sicher? Produkte in dieser Kategorie werden als "Nicht kategorisiert" markiert.'}
}
CURRENCIES = {
'PLN': 'Polski Złoty',
'EUR': 'Euro',
'USD': 'Dolar Amerykański',
'GBP': 'Funt Szterling',
'UAH': 'Hrywna ukraińska',
'CHF': 'Frank Szwajcarski'
}
COLOR_SCHEMES = {
'default': 'Turkusowy (domyślny)',
'forest': 'Leśna zieleń',
'ocean': 'Głęboki błękit',
'sunset': 'Zachód słońca',
'lavender': 'Lawenda',
'vintage': 'Vintage',
'dark': 'Ciemny (Północ)',
'cosmic': 'Kosmiczna noc',
'minty': 'Świeża mięta',
'mocha': 'Mokka',
'crimson': 'Karmazynowy świt',
'solar': 'Słoneczny blask',
'cyberpunk': 'Cyberpunk neon',
'neon': 'Neonowy błysk',
'pastel': 'Pastelowy (jasny)',
'emerald': 'Szmaragdowe miasto',
'gold': 'Luksusowe złoto',
'sakura': 'Kwiat wiśni',
'arctic': 'Arktyczny lód',
'volcano': 'Magma',
'monochrome_light': 'Klasyczny (Jasny)',
'monochrome_dark': 'Klasyczny (Ciemny)',
'nord': 'Skandynawski Nord',
'dracula': 'Dracula (Ciemny)',
'ruby': 'Głęboki Rubin',
'sapphire': 'Królewski Szafir',
'amethyst': 'Ametystowy blask'
}
ICONS = {
'fa-link': 'Link (strona www)',
'fa-phone': 'Telefon',
'fa-whatsapp': 'WhatsApp',
'fa-telegram': 'Telegram',
'fa-instagram': 'Instagram',
'fa-envelope': 'Email',
'fa-map-marker-alt': 'Lokalizacja/Adres',
'fa-youtube': 'YouTube',
'fa-tiktok': 'TikTok'
}
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
def get_lang():
try:
from flask import session, request
return session.get('lang') or request.cookies.get('lang') or 'pl'
except:
return 'pl'
def t_text(pl_string):
lang = get_lang()
if lang == 'pl':
return pl_string
return TRANSLATIONS.get(pl_string, {}).get(lang, pl_string)
@app.context_processor
def inject_t():
return dict(t=t_text, current_lang=get_lang())
def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
if not HF_TOKEN_READ and not HF_TOKEN_WRITE:
pass
token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
files_to_download = [specific_file] if specific_file else SYNC_FILES
all_successful = True
for file_name in files_to_download:
success = False
for attempt in range(retries + 1):
try:
local_path = hf_hub_download(
repo_id=REPO_ID,
filename=file_name,
repo_type="dataset",
token=token_to_use,
local_dir=".",
local_dir_use_symlinks=False,
force_download=True,
resume_download=False
)
success = True
break
except RepositoryNotFoundError:
return False
except HfHubHTTPError as e:
if e.response.status_code == 404:
if attempt == 0 and not os.path.exists(file_name):
try:
if file_name == DATA_FILE:
with open(file_name, 'w', encoding='utf-8') as f:
json.dump({}, f)
except Exception:
pass
success = False
break
else:
pass
except requests.exceptions.RequestException:
pass
except Exception:
pass
if attempt < retries:
time.sleep(delay)
if not success:
all_successful = False
return all_successful
def upload_db_to_hf(specific_file=None):
if not HF_TOKEN_WRITE:
return
try:
api = HfApi()
files_to_upload = [specific_file] if specific_file else SYNC_FILES
for file_name in files_to_upload:
if os.path.exists(file_name):
try:
api.upload_file(
path_or_fileobj=file_name,
path_in_repo=file_name,
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE,
commit_message=f"Sync {file_name} {datetime.now(ALMATY_TZ).strftime('%Y-%m-%d %H:%M:%S')}"
)
except Exception:
pass
except Exception:
pass
def periodic_backup():
backup_interval = 1800
while True:
time.sleep(backup_interval)
upload_db_to_hf()
def load_data():
with db_lock:
try:
with open(DATA_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
if not isinstance(data, dict):
data = {}
except (FileNotFoundError, json.JSONDecodeError):
if download_db_from_hf(specific_file=DATA_FILE):
try:
with open(DATA_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
if not isinstance(data, dict):
data = {}
except (FileNotFoundError, json.JSONDecodeError):
data = {}
else:
data = {}
return data
def save_data(data):
with db_lock:
try:
with open(DATA_FILE, 'w', encoding='utf-8') as file:
json.dump(data, file, ensure_ascii=False, indent=4)
except Exception:
pass
upload_db_to_hf(specific_file=DATA_FILE)
def get_env_data(env_id):
with db_lock:
all_data = load_data()
default_settings = {
"vcard_firstname": "Imię",
"vcard_lastname": "Nazwisko",
"vcard_job": "Specjalista",
"organization_name": "Moja Firma",
"currency_code": "PLN",
"chat_avatar": None,
"color_scheme": "default",
"admin_password_enabled": False,
"admin_password": "",
"categories_as_lines": False,
"about_text": "Cześć! To moja wirtualna wizytówka.",
"enable_cart": False,
"order_messenger": "whatsapp",
"order_contact": ""
}
env_data = all_data.get(env_id, {})
if not env_data:
env_data = {
'products': [], 'categories':[], 'blocks':[], 'orders': [],
'settings': default_settings
}
if 'products' not in env_data: env_data['products'] = []
if 'categories' not in env_data: env_data['categories'] = []
if 'settings' not in env_data: env_data['settings'] = default_settings
if 'blocks' not in env_data: env_data['blocks'] = []
if 'orders' not in env_data: env_data['orders'] = []
settings_changed = False
for key, value in default_settings.items():
if key not in env_data['settings']:
env_data['settings'][key] = value
settings_changed = True
products_changed = False
for product in env_data['products']:
if 'product_id' not in product:
product['product_id'] = uuid4().hex
products_changed = True
if 'views' not in product:
product['views'] = 0
products_changed = True
if 'price' not in product:
product['price'] = 0.0
products_changed = True
if products_changed or settings_changed:
save_env_data(env_id, env_data)
return env_data
def save_env_data(env_id, env_data):
with db_lock:
all_data = load_data()
all_data[env_id] = env_data
save_data(all_data)
def configure_gemini():
if not GOOGLE_API_KEY:
return False
try:
genai.configure(api_key=GOOGLE_API_KEY)
return True
except Exception:
return False
def generate_ai_description_from_image(image_data, language):
if not configure_gemini():
raise ValueError(t_text("Błąd: Google AI API nie jest skonfigurowane."))
try:
if not image_data:
raise ValueError(t_text("Błąd: Nie znaleziono pliku obrazu."))
image_stream = io.BytesIO(image_data)
image = Image.open(image_stream).convert('RGB')
except Exception:
raise ValueError(t_text("Błąd: Nie udało się przetworzyć obrazu."))
base_prompt = "Napisz atrakcyjny opis dla tego produktu/usługi. Tekst powinien być zwięzły, sprzedażowy, z użyciem emoji. Nie pisz cen, adresów ani telefonów."
if language == "English":
base_prompt = "Write an attractive description for this product/service. The text should be concise, sales-oriented, using emojis. Do not write prices, addresses, or phone numbers."
elif language == "Deutsch":
base_prompt = "Schreiben Sie eine attraktive Beschreibung für dieses Produkt/Dienstleistung. Der Text sollte prägnant, verkaufsorientiert und mit Emojis versehen sein. Schreiben Sie keine Preise, Adressen oder Telefonnummern."
try:
model = genai.GenerativeModel('gemma-4-31b-it')
response = model.generate_content([base_prompt, image])
if hasattr(response, 'text'):
return response.text
else:
if response.parts:
return "".join(part.text for part in response.parts if hasattr(part, 'text'))
else:
response.resolve()
return response.text
except Exception as e:
raise ValueError(f"{t_text('Błąd wykonania akcji:')} {e}")
LANDING_PAGE_TEMPLATE = '''
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Platforma Wizytówek Online</title>
<style>
body, html { margin: 0; padding: 0; height: 100%; overflow: hidden; }
iframe { border: none; width: 100%; height: 100%; }
</style>
</head>
<body>
<iframe src="https://v0-ai-agent-landing-page-smoky-six.vercel.app/"></iframe>
</body>
</html>
'''
LOGIN_TEMPLATE = '''
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ t('Zaloguj się do Panelu') }}</title>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; }
body { font-family: 'Montserrat', sans-serif; background-color: #f4f6f9; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; padding: 20px; }
.login-container { background: #fff; padding: 30px; border-radius: 10px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); text-align: center; width: 100%; max-width: 320px; }
h2 { color: #135D66; margin-bottom: 20px; font-size: 1.4rem; }
input[type="password"] { width: 100%; padding: 10px; margin-bottom: 20px; border: 1px solid #ccc; border-radius: 6px; font-size: 0.95rem; min-height: 38px; }
button { width: 100%; padding: 10px; background-color: #48D1CC; color: #003C43; border: none; border-radius: 6px; font-weight: 600; cursor: pointer; font-size: 0.95rem; transition: background 0.3s; min-height: 38px; }
button:hover { background-color: #77E4D8; }
.error { color: #E57373; margin-bottom: 15px; font-size: 0.9rem; }
</style>
</head>
<body>
<div style="position: absolute; top: 10px; right: 20px; z-index: 1000; display: flex; gap: 10px; background: #fff; padding: 5px 15px; border-radius: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
<a href="#" onclick="setAppLang('pl')" style="text-decoration:none; color: #135D66; font-weight: {{ 'bold' if current_lang == 'pl' else 'normal' }}; opacity: {{ '1' if current_lang == 'pl' else '0.6' }};">PL</a>
<a href="#" onclick="setAppLang('en')" style="text-decoration:none; color: #135D66; font-weight: {{ 'bold' if current_lang == 'en' else 'normal' }}; opacity: {{ '1' if current_lang == 'en' else '0.6' }};">EN</a>
<a href="#" onclick="setAppLang('de')" style="text-decoration:none; color: #135D66; font-weight: {{ 'bold' if current_lang == 'de' else 'normal' }}; opacity: {{ '1' if current_lang == 'de' else '0.6' }};">DE</a>
</div>
<script>
function setAppLang(l) {
fetch('/set_lang', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({lang:l})})
.then(()=>location.reload());
}
</script>
<div class="login-container">
<h2>{{ t('Zaloguj') }}</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="error">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST">
<input type="password" name="password" placeholder="{{ t('Wprowadź hasło') }}" required autofocus>
<button type="submit">{{ t('Zaloguj') }}</button>
</form>
</div>
</body>
</html>
'''
ADMHOSTO_TEMPLATE = '''
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ t('Główny Panel Administracyjny') }}</title>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root { --bg-light: #f4f6f9; --bg-medium: #135D66; --accent: #48D1CC; --accent-hover: #77E4D8; --text-dark: #333; --text-on-accent: #003C43; --danger: #E57373; }
* { box-sizing: border-box; }
body { font-family: 'Montserrat', sans-serif; background-color: var(--bg-light); color: var(--text-dark); padding: 15px; margin: 0; font-size: 0.95rem; }
.container { max-width: 900px; margin: 25px auto; background-color: #fff; padding: 25px; border-radius: 8px; box-shadow: 0 3px 10px rgba(0,0,0,0.05); }
h1 { font-weight: 600; color: var(--bg-medium); margin-bottom: 25px; text-align: center; font-size: 1.5rem; }
.section { margin-bottom: 25px; }
.add-env-form { margin-bottom: 15px; text-align: center; }
#search-env { width: 100%; padding: 10px 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 0.95rem; font-family: 'Montserrat', sans-serif; min-height: 38px;}
.button { padding: 8px 14px; border: none; border-radius: 6px; background-color: var(--accent); color: var(--text-on-accent); font-weight: 600; cursor: pointer; transition: background-color 0.3s ease; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 5px; min-height: 36px; font-size: 0.9rem; }
.button:hover { background-color: var(--accent-hover); }
.env-list { list-style: none; padding: 0; }
.env-item { background: #fdfdff; border: 1px solid #e0e0e0; border-radius: 6px; padding: 12px 15px; margin-bottom: 10px; display: flex; flex-direction: column; gap: 10px; }
.env-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
.env-id { font-weight: 600; color: var(--bg-medium); font-size: 1.1rem; }
.env-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.env-pwd { background: #f1f3f5; padding: 8px 12px; border-radius: 6px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.env-pwd input[type="text"] { padding: 8px 10px; border: 1px solid #ccc; border-radius: 6px; flex-grow: 1; min-height: 36px; font-size: 0.9rem;}
.delete-button { background-color: var(--danger); color: white; }
.message { padding: 8px 12px; border-radius: 6px; margin-bottom: 15px; text-align: center; font-size: 0.9rem; }
.message.success { background-color: #d4edda; color: #155724; }
.message.error { background-color: #f8d7da; color: #721c24; }
</style>
</head>
<body>
<div style="position: absolute; top: 10px; right: 20px; z-index: 1000; display: flex; gap: 10px; background: #fff; padding: 5px 15px; border-radius: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
<a href="#" onclick="setAppLang('pl')" style="text-decoration:none; color: #135D66; font-weight: {{ 'bold' if current_lang == 'pl' else 'normal' }}; opacity: {{ '1' if current_lang == 'pl' else '0.6' }};">PL</a>
<a href="#" onclick="setAppLang('en')" style="text-decoration:none; color: #135D66; font-weight: {{ 'bold' if current_lang == 'en' else 'normal' }}; opacity: {{ '1' if current_lang == 'en' else '0.6' }};">EN</a>
<a href="#" onclick="setAppLang('de')" style="text-decoration:none; color: #135D66; font-weight: {{ 'bold' if current_lang == 'de' else 'normal' }}; opacity: {{ '1' if current_lang == 'de' else '0.6' }};">DE</a>
</div>
<script>
function setAppLang(l) {
fetch('/set_lang', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({lang:l})})
.then(()=>location.reload());
}
</script>
<div class="container">
<h1><i class="fas fa-id-card"></i> {{ t('Zarządzanie Wizytówkami') }}</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="message {{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="section">
<form method="POST" action="{{ url_for('create_environment') }}" class="add-env-form">
<button type="submit" class="button" style="font-size:1rem; padding: 12px 25px;"><i class="fas fa-plus-circle"></i> {{ t('Utwórz nową wizytówkę') }}</button>
</form>
</div>
<div class="section">
<input type="text" id="search-env" placeholder="{{ t('Szukaj po ID lub Nazwie...') }}">
</div>
<div class="section">
<h2 style="font-size: 1.2rem;"><i class="fas fa-list-ul"></i> {{ t('Istniejące wizytówki') }}</h2>
{% if environments %}
<ul class="env-list">
{% for env in environments %}
<li class="env-item">
<div class="env-header">
<div style="display:flex; align-items:center; gap: 10px; flex-wrap: wrap;">
<span class="env-id">{{ env.org_name }} (ID: {{ env.id }})</span>
</div>
<div class="env-actions">
<a href="{{ url_for('admin', env_id=env.id) }}" class="button" target="_blank"><i class="fas fa-tools"></i> {{ t('Ustawienia') }}</a>
<a href="{{ url_for('catalog', env_id=env.id) }}" class="button" target="_blank"><i class="fas fa-external-link-alt"></i> {{ t('Zobacz') }}</a>
<form method="POST" action="{{ url_for('delete_environment', env_id=env.id) }}" style="display:inline;" onsubmit="if(!confirm('{{ t('Czy na pewno usunąć wizytówkę') }} {{ env.id }}? {{ t('To działanie jest nieodwracalne.') }}')) return false;">
<button type="submit" class="button delete-button"><i class="fas fa-trash-alt"></i></button>
</form>
</div>
</div>
<div class="env-pwd">
<form method="POST" action="{{ url_for('update_env_pwd', env_id=env.id) }}" style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap; width: 100%;">
<label style="display: flex; align-items: center; gap: 5px; font-size: 0.9rem; margin: 0;"><input type="checkbox" name="pwd_enabled" {% if env.pwd_enabled %}checked{% endif %} style="width: 16px; height: 16px;"> {{ t('Włącz hasło admina') }}</label>
<input type="text" name="password" value="{{ env.password }}" placeholder="{{ t('Hasło') }}">
<button type="submit" class="button" style="font-size: 0.85rem; min-height: 32px; padding: 6px 12px;">{{ t('Zapisz') }}</button>
</form>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p>{{ t('Nie utworzono jeszcze żadnych wizytówek.') }}</p>
{% endif %}
</div>
</div>
<script>
document.getElementById('search-env').addEventListener('input', function() {
const searchTerm = this.value.toLowerCase().trim();
const envItems = document.querySelectorAll('.env-item');
envItems.forEach(item => {
const envId = item.querySelector('.env-id').textContent.toLowerCase();
if (envId.includes(searchTerm)) { item.style.display = 'flex'; } else { item.style.display = 'none'; }
});
});
</script>
</body>
</html>
'''
CATALOG_TEMPLATE = '''
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>{{ settings.vcard_firstname }} {{ settings.vcard_lastname }} - {{ t('Wizytówka') }}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.css">
<style>
{% if settings.color_scheme == 'forest' %}
:root { --bg-dark: #2F4F4F; --bg-medium: #556B2F; --accent: #8FBC8F; --accent-hover: #98FB98; --text-light: #F5F5DC; --text-dark: #1A2F1A; --danger: #CD5C5C; --card-bg: rgba(255,255,255,0.1); }
{% elif settings.color_scheme == 'ocean' %}
:root { --bg-dark: #000080; --bg-medium: #1E90FF; --accent: #87CEEB; --accent-hover: #ADD8E6; --text-light: #F0F8FF; --text-dark: #000033; --danger: #FF6347; --card-bg: rgba(255,255,255,0.1); }
{% elif settings.color_scheme == 'sunset' %}
:root { --bg-dark: #4A2511; --bg-medium: #D2691E; --accent: #FFA500; --accent-hover: #FFB733; --text-light: #FFF8DC; --text-dark: #4A2511; --danger: #DC143C; --card-bg: rgba(255,255,255,0.1); }
{% elif settings.color_scheme == 'lavender' %}
:root { --bg-dark: #483D8B; --bg-medium: #8A2BE2; --accent: #D8BFD8; --accent-hover: #E6E6FA; --text-light: #F8F4FF; --text-dark: #2D1B36; --danger: #DB7093; --card-bg: rgba(255,255,255,0.1); }
{% elif settings.color_scheme == 'vintage' %}
:root { --bg-dark: #5D4037; --bg-medium: #8D6E63; --accent: #D7CCC8; --accent-hover: #EFEBE9; --text-light: #EFEBE9; --text-dark: #3E2723; --danger: #BF360C; --card-bg: rgba(255,255,255,0.1); }
{% elif settings.color_scheme == 'dark' %}
:root { --bg-dark: #121212; --bg-medium: #1E1E1E; --accent: #BB86FC; --accent-hover: #A764FC; --text-light: #E1E1E1; --text-dark: #121212; --danger: #CF6679; --card-bg: rgba(255,255,255,0.05); }
{% elif settings.color_scheme == 'cosmic' %}
:root { --bg-dark: #0D1136; --bg-medium: #303F9F; --accent: #536DFE; --accent-hover: #7986CB; --text-light: #FFFFFF; --text-dark: #FFFFFF; --danger: #F50057; --card-bg: rgba(255,255,255,0.1); }
{% elif settings.color_scheme == 'minty' %}
:root { --bg-dark: #004D40; --bg-medium: #00796B; --accent: #4DB6AC; --accent-hover: #80CBC4; --text-light: #E0F2F1; --text-dark: #00332A; --danger: #ef5350; --card-bg: rgba(255,255,255,0.1); }
{% elif settings.color_scheme == 'mocha' %}
:root { --bg-dark: #3E2723; --bg-medium: #5D4037; --accent: #A1887F; --accent-hover: #BCAAA4; --text-light: #EFEBE9; --text-dark: #261412; --danger: #D32F2F; --card-bg: rgba(255,255,255,0.1); }
{% elif settings.color_scheme == 'crimson' %}
:root { --bg-dark: #4A148C; --bg-medium: #9C27B0; --accent: #CE93D8; --accent-hover: #E1BEE7; --text-light: #FFFFFF; --text-dark: #2D0854; --danger: #E91E63; --card-bg: rgba(255,255,255,0.1); }
{% elif settings.color_scheme == 'solar' %}
:root { --bg-dark: #BF360C; --bg-medium: #FB8C00; --accent: #FFCA28; --accent-hover: #FFD54F; --text-light: #FFF3E0; --text-dark: #3E2723; --danger: #D32F2F; --card-bg: rgba(255,255,255,0.1); }
{% elif settings.color_scheme == 'cyberpunk' %}
:root { --bg-dark: #000000; --bg-medium: #0D0221; --accent: #00F0FF; --accent-hover: #81F5FF; --text-light: #FFFFFF; --text-dark: #000000; --danger: #F50057; --card-bg: rgba(255,255,255,0.1); }
{% elif settings.color_scheme == 'neon' %}
:root { --bg-dark: #0F0C29; --bg-medium: #302B63; --accent: #FF00CC; --accent-hover: #FF66CC; --text-light: #FFFFFF; --text-dark: #FFFFFF; --danger: #FF0000; --card-bg: rgba(255,255,255,0.1); }
{% elif settings.color_scheme == 'pastel' %}
:root { --bg-dark: #FCE4EC; --bg-medium: #F8BBD0; --accent: #F06292; --accent-hover: #F48FB1; --text-light: #4A148C; --text-dark: #FFFFFF; --danger: #D32F2F; --card-bg: rgba(255,255,255,0.5); }
{% elif settings.color_scheme == 'emerald' %}
:root { --bg-dark: #004D40; --bg-medium: #00695C; --accent: #1DE9B6; --accent-hover: #64FFDA; --text-light: #E0F2F1; --text-dark: #00332A; --danger: #FF5252; --card-bg: rgba(255,255,255,0.1); }
{% elif settings.color_scheme == 'gold' %}
:root { --bg-dark: #1C1C1C; --bg-medium: #3A3A3A; --accent: #D4AF37; --accent-hover: #F3E5AB; --text-light: #F5F5F5; --text-dark: #1C1C1C; --danger: #B71C1C; --card-bg: rgba(255,255,255,0.05); }
{% elif settings.color_scheme == 'sakura' %}
:root { --bg-dark: #FFEBEE; --bg-medium: #FFCDD2; --accent: #FF8A80; --accent-hover: #FFBCAF; --text-light: #4A148C; --text-dark: #FFFFFF; --danger: #D32F2F; --card-bg: rgba(255,255,255,0.5); }
{% elif settings.color_scheme == 'arctic' %}
:root { --bg-dark: #E3F2FD; --bg-medium: #BBDEFB; --accent: #4FC3F7; --accent-hover: #81D4FA; --text-light: #0D47A1; --text-dark: #000000; --danger: #EF5350; --card-bg: rgba(255,255,255,0.5); }
{% elif settings.color_scheme == 'volcano' %}
:root { --bg-dark: #212121; --bg-medium: #B71C1C; --accent: #FF3D00; --accent-hover: #FF6E40; --text-light: #FFFFFF; --text-dark: #FFFFFF; --danger: #D50000; --card-bg: rgba(255,255,255,0.05); }
{% elif settings.color_scheme == 'monochrome_light' %}
:root { --bg-dark: #F5F5F5; --bg-medium: #E0E0E0; --accent: #000000; --accent-hover: #333333; --text-light: #000000; --text-dark: #FFFFFF; --danger: #D32F2F; --card-bg: rgba(255,255,255,0.5); }
{% elif settings.color_scheme == 'monochrome_dark' %}
:root { --bg-dark: #121212; --bg-medium: #2A2A2A; --accent: #FFFFFF; --accent-hover: #E0E0E0; --text-light: #FFFFFF; --text-dark: #000000; --danger: #EF5350; --card-bg: rgba(255,255,255,0.05); }
{% elif settings.color_scheme == 'nord' %}
:root { --bg-dark: #2E3440; --bg-medium: #3B4252; --accent: #88C0D0; --accent-hover: #81A1C1; --text-light: #D8DEE9; --text-dark: #2E3440; --danger: #BF616A; --card-bg: rgba(255,255,255,0.05); }
{% elif settings.color_scheme == 'dracula' %}
:root { --bg-dark: #282A36; --bg-medium: #44475A; --accent: #FF79C6; --accent-hover: #BD93F9; --text-light: #F8F8F2; --text-dark: #282A36; --danger: #FF5555; --card-bg: rgba(255,255,255,0.05); }
{% elif settings.color_scheme == 'ruby' %}
:root { --bg-dark: #4A0E17; --bg-medium: #7B1826; --accent: #FFB3B3; --accent-hover: #FFD9D9; --text-light: #FFF0F0; --text-dark: #4A0E17; --danger: #FF4D4D; --card-bg: rgba(255,255,255,0.1); }
{% elif settings.color_scheme == 'sapphire' %}
:root { --bg-dark: #0B192C; --bg-medium: #1A365D; --accent: #FFD700; --accent-hover: #FFF176; --text-light: #F7FAFC; --text-dark: #0B192C; --danger: #E53E3E; --card-bg: rgba(255,255,255,0.05); }
{% elif settings.color_scheme == 'amethyst' %}
:root { --bg-dark: #2D1B36; --bg-medium: #4A2C59; --accent: #B28DFF; --accent-hover: #D4C4FB; --text-light: #F4EBFF; --text-dark: #2D1B36; --danger: #FF6B6B; --card-bg: rgba(255,255,255,0.1); }
{% else %}
:root { --bg-dark: #003C43; --bg-medium: #135D66; --accent: #48D1CC; --accent-hover: #77E4D8; --text-light: #E3FEF7; --text-dark: #003C43; --danger: #E57373; --card-bg: rgba(255,255,255,0.1); }
{% endif %}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { -webkit-tap-highlight-color: transparent; scroll-behavior: smooth; }
body { font-family: 'Montserrat', sans-serif; background-color: var(--bg-dark); color: var(--text-light); line-height: 1.6; }
.main-wrapper { max-width: 680px; margin: 0 auto; padding: 60px 20px 80px 20px; display: flex; flex-direction: column; align-items: center; position: relative; }
.qr-btn { position: absolute; top: 20px; right: 20px; font-size: 1.2rem; color: var(--accent); background: var(--card-bg); border: 1px solid rgba(255,255,255,0.2); border-radius: 50%; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; cursor: pointer; backdrop-filter: blur(5px); transition: all 0.2s; z-index: 100; }
.qr-btn:hover { background: var(--accent); color: var(--text-dark); transform: scale(1.05); }
.profile-header { text-align: center; margin-bottom: 30px; width: 100%; }
.avatar { width: 120px; height: 120px; border-radius: 50%; border: 3px solid var(--accent); box-shadow: 0 0 20px rgba(0,0,0,0.2); object-fit: cover; margin-bottom: 15px; }
.profile-name { font-size: 1.6rem; font-weight: 700; margin-bottom: 5px; color: var(--text-light); }
.profile-job { font-size: 1.1rem; font-weight: 500; opacity: 0.9; margin-bottom: 5px; }
.profile-company { font-size: 1rem; font-weight: 400; opacity: 0.7; }
.profile-about { margin-top: 15px; font-size: 0.95rem; line-height: 1.5; opacity: 0.85; max-width: 500px; margin-left: auto; margin-right: auto;}
.save-contact-btn { display: flex; align-items: center; justify-content: center; gap: 10px; width: 100%; max-width: 400px; padding: 14px; background: var(--accent); color: var(--text-dark); border-radius: 30px; text-decoration: none; font-weight: 700; font-size: 1rem; transition: transform 0.2s, box-shadow 0.2s; box-shadow: 0 4px 15px rgba(0,0,0,0.2); margin-bottom: 30px; border: none; cursor: pointer; }
.save-contact-btn:hover { transform: translateY(-3px); box-shadow: 0 6px 20px rgba(0,0,0,0.3); background: var(--accent-hover); }
.blocks-container { width: 100%; display: flex; flex-direction: column; gap: 12px; margin-bottom: 40px; }
.block-link { display: flex; align-items: center; justify-content: center; position: relative; background: var(--card-bg); color: var(--text-light); text-align: center; padding: 14px 20px; border-radius: 12px; text-decoration: none; font-weight: 600; font-size: 1rem; backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,0.1); transition: transform 0.2s, background 0.2s; min-height: 50px; overflow: hidden; }
.block-link:hover { transform: scale(1.02); background: var(--bg-medium); border-color: var(--accent); }
.block-icon { position: absolute; left: 20px; font-size: 1.3rem; color: var(--accent); }
.block-text { background: var(--card-bg); padding: 15px; border-radius: 12px; border: 1px solid rgba(255,255,255,0.1); text-align: center; color: var(--text-light); backdrop-filter: blur(10px); }
.block-text h3 { margin-bottom: 10px; font-size: 1.1rem; color: var(--accent); }
.catalog-section { width: 100%; margin-top: 20px; }
.catalog-title { font-size: 1.3rem; font-weight: 700; margin-bottom: 20px; text-align: center; color: var(--accent); text-transform: uppercase; letter-spacing: 1px; }
.search-wrapper { position: relative; margin-bottom: 15px; width: 100%; }
#search-input { width: 100%; padding: 12px 20px 12px 40px; font-size: 0.95rem; border: 1px solid rgba(255,255,255,0.2); border-radius: 30px; outline: none; background-color: var(--card-bg); color: var(--text-light); transition: all 0.3s ease; backdrop-filter: blur(5px); }
#search-input::placeholder { color: var(--text-light); opacity: 0.6; }
#search-input:focus { background-color: rgba(255,255,255,0.15); border-color: var(--accent); }
.search-wrapper .fa-search { position: absolute; top: 50%; left: 16px; transform: translateY(-50%); color: var(--text-light); opacity: 0.6; font-size: 1rem; }
.category-chips-container { margin-bottom: 15px; overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; scrollbar-width: none; padding-bottom: 5px; }
.category-chips-container::-webkit-scrollbar { display: none; }
.category-chips { display: inline-flex; gap: 8px; }
.chip { padding: 8px 16px; border-radius: 20px; background-color: var(--card-bg); color: var(--text-light); border: 1px solid rgba(255,255,255,0.2); font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; backdrop-filter: blur(5px); }
.chip:hover, .chip.active { background-color: var(--accent); color: var(--text-dark); border-color: var(--accent); }
.product-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; width: 100%; }
.product-card { background: var(--card-bg); border: 1px solid rgba(255,255,255,0.1); border-radius: 10px; overflow: hidden; cursor: pointer; transition: transform 0.2s; backdrop-filter: blur(5px); display: flex; flex-direction: column; }
.product-card:hover { transform: translateY(-3px); border-color: rgba(255,255,255,0.3); }
.product-image-container { width: 100%; aspect-ratio: 1/1; position: relative; background-color: rgba(0,0,0,0.2); overflow: hidden; }
.product-image-container img { width: 100%; height: 100%; object-fit: cover; }
.product-info { padding: 10px; display: flex; flex-direction: column; flex-grow: 1; justify-content: space-between; }
.product-title { font-size: 0.9rem; font-weight: 600; color: var(--text-light); margin-bottom: 5px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.product-price { font-size: 0.95rem; font-weight: 700; color: var(--accent); }
.no-results-message { text-align: center; padding: 25px; font-size: 1rem; opacity: 0.7; grid-column: 1 / -1; }
.modal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.8); backdrop-filter: blur(8px); overflow-y: auto; -webkit-overflow-scrolling: touch; }
.modal-content { background: var(--bg-dark); color: var(--text-light); margin: auto; padding: 0; border-radius: 15px; width: 100%; max-width: 500px; min-height: 100vh; position: relative; box-shadow: 0 15px 40px rgba(0,0,0,0.5); animation: slideUp 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); }
@media (min-width: 500px) { .modal-content { min-height: auto; margin: 5% auto; } }
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
.qr-modal-content { background: var(--bg-dark); color: var(--text-light); margin: 15vh auto; padding: 30px 20px; border-radius: 15px; width: 90%; max-width: 320px; position: relative; box-shadow: 0 15px 40px rgba(0,0,0,0.5); text-align: center; animation: slideUp 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); }
.close-btn { position: absolute; top: 12px; right: 12px; width: 35px; height: 35px; background: rgba(0,0,0,0.5); border-radius: 50%; display: flex; justify-content: center; align-items: center; color: white; font-size: 1.2rem; cursor: pointer; z-index: 10; border: none; backdrop-filter: blur(5px); }
.close-btn:hover { background: var(--danger); }
.modal-body { padding: 20px; }
.modal-title { font-size: 1.3rem; font-weight: 700; margin-bottom: 8px; color: var(--text-light); }
.modal-price { font-size: 1.2rem; font-weight: 800; color: var(--accent); margin-bottom: 15px; }
.modal-desc { font-size: 0.95rem; line-height: 1.6; opacity: 0.9; margin-bottom: 20px; white-space: pre-wrap; }
.modal-category { display: inline-block; padding: 4px 10px; background: var(--card-bg); border-radius: 12px; font-size: 0.8rem; border: 1px solid rgba(255,255,255,0.2); margin-bottom: 12px; }
.swiper-container { width: 100%; aspect-ratio: 1/1; background: #000; border-radius: 15px 15px 0 0; }
@media (min-width: 500px) { .swiper-container { border-radius: 15px 15px 0 0; } }
.swiper-slide { display: flex; justify-content: center; align-items: center; }
.swiper-slide img { width: 100%; height: 100%; object-fit: contain; }
.pagination { display: flex; justify-content: center; gap: 8px; margin-top: 25px; padding-bottom: 20px; align-items: center; flex-wrap: wrap; }
.pagination button { width: 36px; height: 36px; border: 1px solid rgba(255,255,255,0.2); border-radius: 8px; background: var(--card-bg); color: var(--text-light); font-weight: 600; cursor: pointer; transition: all 0.2s; font-size: 0.9rem; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(5px); }
.pagination button.active { background: var(--accent); color: var(--text-dark); border-color: var(--accent); }
.pagination button:hover:not(.active) { background: rgba(255,255,255,0.2); }
.dark-theme .modal-content, .dark-theme .qr-modal-content { background: #222; }
.cart-fab { position: fixed; bottom: 20px; right: 20px; background: var(--accent); color: var(--text-dark); width: 55px; height: 55px; border-radius: 50%; display: flex; justify-content: center; align-items: center; font-size: 1.3rem; cursor: pointer; box-shadow: 0 4px 15px rgba(0,0,0,0.3); z-index: 999; display: none; }
.cart-badge { position: absolute; top: -3px; right: -3px; background: var(--danger); color: white; border-radius: 50%; width: 22px; height: 22px; font-size: 0.75rem; display: flex; justify-content: center; align-items: center; font-weight: bold; }
.cart-controls { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; background: var(--bg-medium); border-radius: 6px; overflow: hidden; height: 32px;}
.cart-controls button { background: var(--accent); color: var(--text-dark); border: none; width: 32px; height: 100%; font-weight: bold; cursor: pointer; font-size: 1.1rem; }
.cart-controls input { width: 36px; text-align: center; border: none; background: transparent; color: white; font-weight: bold; font-size: 0.9rem; outline: none; -moz-appearance: textfield; }
.cart-controls input::-webkit-outer-spin-button, .cart-controls input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.add-to-cart-btn { background: var(--accent); color: var(--text-dark); border: none; padding: 8px; border-radius: 6px; font-weight: bold; cursor: pointer; width: 100%; margin-top: 8px; font-size: 0.9rem; }
.add-to-cart-btn:hover { background: var(--accent-hover); }
.cart-item { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.1); gap: 10px;}
.cart-item img { width: 45px; height: 45px; border-radius: 6px; object-fit: cover; }
.cart-item-info { flex-grow: 1; }
.cart-item-title { font-size: 0.85rem; font-weight: bold; }
.cart-item-price { font-size: 0.8rem; color: var(--accent); }
</style>
</head>
<body class="{{ 'dark-theme' if settings.color_scheme in['dark', 'cyberpunk', 'neon', 'volcano', 'gold', 'monochrome_dark', 'nord', 'dracula', 'ruby', 'sapphire', 'amethyst', 'cosmic', 'crimson', 'mocha', 'minty', 'solar'] else '' }}">
<div style="position: absolute; top: 20px; left: 20px; z-index: 100; display: flex; gap: 8px; background: var(--card-bg); padding: 6px 12px; border-radius: 20px; border: 1px solid rgba(255,255,255,0.2); backdrop-filter: blur(5px);">
<a href="#" onclick="setAppLang('pl')" style="text-decoration:none; color: var(--text-light); font-weight: {{ 'bold' if current_lang == 'pl' else 'normal' }}; opacity: {{ '1' if current_lang == 'pl' else '0.5' }}; font-size: 0.85rem;">PL</a>
<a href="#" onclick="setAppLang('en')" style="text-decoration:none; color: var(--text-light); font-weight: {{ 'bold' if current_lang == 'en' else 'normal' }}; opacity: {{ '1' if current_lang == 'en' else '0.5' }}; font-size: 0.85rem;">EN</a>
<a href="#" onclick="setAppLang('de')" style="text-decoration:none; color: var(--text-light); font-weight: {{ 'bold' if current_lang == 'de' else 'normal' }}; opacity: {{ '1' if current_lang == 'de' else '0.5' }}; font-size: 0.85rem;">DE</a>
</div>
<div class="main-wrapper">
<div class="qr-btn" onclick="openQrModal()" title="{{ t('Mój kod QR') }}">
<i class="fas fa-qrcode"></i>
</div>
<div class="profile-header">
<img src="{{ chat_avatar_url }}" alt="Avatar" class="avatar">
<div class="profile-name">{{ settings.vcard_firstname }} {{ settings.vcard_lastname }}</div>
{% if settings.vcard_job %}<div class="profile-job">{{ settings.vcard_job }}</div>{% endif %}
{% if settings.organization_name %}<div class="profile-company">{{ settings.organization_name }}</div>{% endif %}
{% if settings.about_text %}<div class="profile-about">{{ settings.about_text|replace('\\n', '<br>')|safe }}</div>{% endif %}
</div>
<a href="{{ url_for('download_vcard', env_id=env_id) }}" class="save-contact-btn">
<i class="fas fa-address-book"></i> {{ t('Zapisz w kontaktach') }}
</a>
{% if blocks %}
<div class="blocks-container">
{% for block in blocks %}
{% if block.type == 'link' %}
<a href="{{ block.url }}" class="block-link" target="_blank" rel="noopener noreferrer">
{% if block.icon %}
{% set prefix = 'fab' if block.icon in ['fa-whatsapp', 'fa-telegram', 'fa-instagram', 'fa-youtube', 'fa-tiktok'] else 'fas' %}
<i class="{{ prefix }} {{ block.icon }} block-icon"></i>
{% endif %}
<span>{{ block.title }}</span>
</a>
{% elif block.type == 'text' %}
<div class="block-text">
{% if block.title %}<h3>{{ block.title }}</h3>{% endif %}
<p>{{ block.content|replace('\\n', '<br>')|safe }}</p>
</div>
{% elif block.type == 'pdf' %}
<div class="block-link" onclick="openPdfModal('https://huggingface.co/datasets/{{ repo_id }}/resolve/main/pdfs/{{ block.url }}')" style="cursor: pointer;">
{% if block.icon %}
{% set prefix = 'fab' if block.icon in ['fa-whatsapp', 'fa-telegram', 'fa-instagram', 'fa-youtube', 'fa-tiktok'] else 'fas' %}
<i class="{{ prefix }} {{ block.icon }} block-icon"></i>
{% endif %}
<span>{{ block.title }}</span>
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% if products_json != '[]' %}
<div class="catalog-section">
<h2 class="catalog-title">{{ t('Katalog / Usługi') }}</h2>
<div class="search-wrapper">
<i class="fas fa-search"></i>
<input type="text" id="search-input" placeholder="{{ t('Szukaj...') }}">
</div>
<div class="category-chips-container">
<div class="category-chips" id="category-chips"></div>
</div>
<div id="catalog-content"></div>
</div>
{% endif %}
</div>
<div id="cart-fab" class="cart-fab" onclick="openCartModal()">
<i class="fas fa-shopping-cart"></i>
<span id="cart-badge" class="cart-badge">0</span>
</div>
<div id="productModal" class="modal">
<div class="modal-content">
<button class="close-btn" onclick="closeModal('productModal')"><i class="fas fa-times"></i></button>
<div id="modalContent"></div>
</div>
</div>
<div id="qrModal" class="modal">
<div class="qr-modal-content">
<button class="close-btn" onclick="closeModal('qrModal')" style="top: -12px; right: -12px; background: var(--danger);"><i class="fas fa-times"></i></button>
<h2 style="margin-bottom: 15px; color: var(--accent); font-size: 1.3rem;">{{ t('Mój kod QR') }}</h2>
<div style="background: white; padding: 12px; border-radius: 12px; display: inline-block;">
<img id="qrImage" src="" alt="QR Code" style="width: 180px; height: 180px; display: block;">
</div>
<p style="margin-top: 15px; font-size: 0.85rem; opacity: 0.8;">{{ t('Zeskanuj, aby otworzyć wizytówkę') }}</p>
</div>
</div>
<div id="pdfModal" class="modal">
<div class="modal-content" style="max-width: 800px; height: 90vh; display: flex; flex-direction: column;">
<button class="close-btn" onclick="closeModal('pdfModal')" style="top: 10px; right: 10px;"><i class="fas fa-times"></i></button>
<div style="flex-grow: 1; padding: 20px; padding-top: 60px;">
<iframe id="pdfIframe" src="" width="100%" height="100%" style="border: none; border-radius: 10px;" type="application/pdf"></iframe>
</div>
</div>
</div>
<div id="cartModal" class="modal">
<div class="modal-content" style="padding: 20px; display: flex; flex-direction: column; min-height: 100vh;">
<button class="close-btn" onclick="closeModal('cartModal')"><i class="fas fa-times"></i></button>
<h2 style="margin-top: 30px; margin-bottom: 20px; color: var(--accent); text-align: center; font-size: 1.3rem;">{{ t('Koszyk') }}</h2>
<div id="cartItems" style="flex-grow: 1;"></div>
<div id="cartTotal" style="font-size: 1.2rem; font-weight: bold; margin-top: 15px; text-align: right; color: var(--accent);"></div>
<div style="margin-top: 15px; background: var(--card-bg); padding: 15px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.1);">
<input type="text" id="customerName" placeholder="{{ t('Twoje Imię') }}" required style="width: 100%; padding: 10px; margin-bottom: 10px; border-radius: 6px; border: none; outline: none; font-size: 0.95rem;">
<input type="tel" id="customerPhone" placeholder="{{ t('Twój Telefon') }}" required style="width: 100%; padding: 10px; margin-bottom: 10px; border-radius: 6px; border: none; outline: none; font-size: 0.95rem;">
<button onclick="submitOrder()" class="save-contact-btn" style="width: 100%; margin-bottom: 0; padding: 12px; border-radius: 8px;">{{ t('Złóż zamówienie') }}</button>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
<script>
function setAppLang(l) {
fetch('/set_lang', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({lang:l})})
.then(()=>location.reload());
}
const JS_TEXTS = {
all: "{{ t('Wszystkie') }}",
addToCart: "{{ t('Do koszyka') }}",
noPhoto: "{{ t('Brak') }}",
cartEmpty: "{{ t('Koszyk jest pusty') }}",
total: "{{ t('Razem:') }}",
processing: "{{ t('Przetwarzanie...') }}",
checkoutError: "{{ t('Błąd składania zamówienia') }}",
networkError: "{{ t('Błąd sieci') }}",
fillNamePhone: "{{ t('Wypełnij imię i telefon') }}",
noResults: "{{ t('Brak wyników.') }}"
};
const allProducts = {{ products_json|safe }};
const orderedCategories = {{ ordered_categories|tojson|safe }};
const repoId = '{{ repo_id }}';
const currencyCode = '{{ currency_code }}';
const orgName = `{{ settings.organization_name }}`.replace(/`/g, '');
const enableCart = {{ 'true' if settings.get('enable_cart', False) else 'false' }};
const envId = '{{ env_id }}';
const itemsPerPage = 10;
let currentPage = 1;
let currentCategory = 'all';
let cart = {};
try {
let saved = localStorage.getItem('cart_' + envId);
if (saved) {
cart = JSON.parse(saved);
if (Array.isArray(cart)) cart = {};
}
} catch(e) { cart = {}; }
document.addEventListener('DOMContentLoaded', () => {
const chipsContainer = document.getElementById('category-chips');
if(chipsContainer && orderedCategories.length > 0) {
let chipsHtml = `<button class="chip active" onclick="setCategory('all', this)">${JS_TEXTS.all}</button>`;
orderedCategories.forEach(cat => {
chipsHtml += `<button class="chip" onclick="setCategory('${cat.replace(/'/g, "\\'")}', this)">${cat}</button>`;
});
chipsContainer.innerHTML = chipsHtml;
}
const searchInput = document.getElementById('search-input');
if(searchInput) {
searchInput.addEventListener('input', () => {
currentPage = 1;
renderCatalog();
});
}
window.addEventListener('click', function(event) { if (event.target.classList.contains('modal')) { closeModal(event.target.id); } });
updateCartBadge();
renderCatalog();
});
function saveCart() {
localStorage.setItem('cart_' + envId, JSON.stringify(cart));
}
function updateCartBadge() {
if(!enableCart) return;
const badge = document.getElementById('cart-badge');
const fab = document.getElementById('cart-fab');
let totalItems = 0;
for(let id in cart) { totalItems += cart[id].qty; }
badge.innerText = totalItems;
if(totalItems > 0) {
fab.style.display = 'flex';
} else {
fab.style.display = 'none';
closeModal('cartModal');
}
}
function addToCart(productId) {
if(!cart[productId]) {
let p = getProductById(productId);
cart[productId] = {qty: 1, price: parseFloat(p.price)||0, name: p.name, photo: p.photos && p.photos.length > 0 ? p.photos[0] : null};
} else {
cart[productId].qty += 1;
}
saveCart();
updateCartBadge();
renderCatalog();
renderModalCartControls(productId);
}
function updateQty(productId, delta) {
if(cart[productId]) {
cart[productId].qty += delta;
if(cart[productId].qty <= 0) delete cart[productId];
saveCart();
updateCartBadge();
renderCatalog();
renderCartModal();
renderModalCartControls(productId);
}
}
function setQty(productId, inputElem) {
let val = parseInt(inputElem.value);
if(isNaN(val) || val <= 0) {
delete cart[productId];
} else {
cart[productId].qty = val;
}
saveCart();
updateCartBadge();
renderCatalog();
renderCartModal();
renderModalCartControls(productId);
}
function setCategory(cat, btn) {
document.querySelectorAll('.chip').forEach(c => c.classList.remove('active'));
if(btn) btn.classList.add('active');
currentCategory = cat;
currentPage = 1;
const searchInput = document.getElementById('search-input');
if(searchInput) searchInput.value = '';
renderCatalog();
}
function buildProductCard(product) {
let photoUrl = (product.photos && product.photos.length > 0)
? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${product.photos[0]}`
: `https://via.placeholder.com/300x300.png?text=${JS_TEXTS.noPhoto}`;
let priceText = product.price > 0 ? `<div class="product-price">${parseFloat(product.price).toFixed(0)} ${currencyCode}</div>` : '';
let cartControlsHtml = '';
if(enableCart && product.price > 0) {
if(cart[product.product_id]) {
let qty = cart[product.product_id].qty;
cartControlsHtml = `
<div class="cart-controls" onclick="event.stopPropagation()">
<button onclick="updateQty('${product.product_id}', -1)">-</button>
<input type="number" value="${qty}" onchange="setQty('${product.product_id}', this)">
<button onclick="updateQty('${product.product_id}', 1)">+</button>
</div>
`;
} else {
cartControlsHtml = `
<button class="add-to-cart-btn" onclick="event.stopPropagation(); addToCart('${product.product_id}')">${JS_TEXTS.addToCart}</button>
`;
}
}
return `
<div class="product-card" onclick="openModalById('${product.product_id}')">
<div class="product-image-container">
<img src="${photoUrl}" alt="${product.name}" loading="lazy">
</div>
<div class="product-info">
<div class="product-title">${product.name}</div>
${priceText}
${cartControlsHtml}
</div>
</div>
`;
}
function renderCatalog() {
const searchInput = document.getElementById('search-input');
const searchTerm = searchInput ? searchInput.value.toLowerCase().trim() : '';
const container = document.getElementById('catalog-content');
if(!container) return;
let filtered = allProducts.filter(p => {
let matchCat = currentCategory === 'all' || p.category === currentCategory;
let matchSearch = searchTerm === '' || (p.name || '').toLowerCase().includes(searchTerm) || (p.description || '').toLowerCase().includes(searchTerm);
return matchCat && matchSearch;
});
const totalPages = Math.ceil(filtered.length / itemsPerPage) || 1;
if (currentPage > totalPages) currentPage = totalPages;
const start = (currentPage - 1) * itemsPerPage;
const paginated = filtered.slice(start, start + itemsPerPage);
if (filtered.length === 0) {
container.innerHTML = `<div class="no-results-message">${JS_TEXTS.noResults}</div>`;
return;
}
let html = '<div class="product-grid">';
paginated.forEach(product => {
html += buildProductCard(product);
});
html += '</div>';
if (totalPages > 1) {
html += '<div class="pagination">';
if (currentPage > 1) {
html += `<button onclick="changePage(${currentPage - 1})"><i class="fas fa-chevron-left"></i></button>`;
}
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= currentPage - 1 && i <= currentPage + 1)) {
html += `<button class="${i === currentPage ? 'active' : ''}" onclick="changePage(${i})">${i}</button>`;
} else if (i === currentPage - 2 || i === currentPage + 2) {
html += `<span style="color:var(--text-light)">...</span>`;
}
}
if (currentPage < totalPages) {
html += `<button onclick="changePage(${currentPage + 1})"><i class="fas fa-chevron-right"></i></button>`;
}
html += '</div>';
}
container.innerHTML = html;
}
function changePage(page) {
currentPage = page;
renderCatalog();
const catalogSec = document.querySelector('.catalog-section');
if(catalogSec) catalogSec.scrollIntoView({behavior: 'smooth'});
}
function getProductById(productId) { return allProducts.find(p => p.product_id === productId); }
function renderModalCartControls(productId) {
const container = document.getElementById('modal-cart-controls-container');
if(!container) return;
const product = getProductById(productId);
if(!product || !enableCart || product.price <= 0) {
container.innerHTML = '';
return;
}
let html = '';
if(cart[productId]) {
let qty = cart[productId].qty;
html = `
<div class="cart-controls" style="max-width: 120px;">
<button onclick="updateQty('${productId}', -1)">-</button>
<input type="number" value="${qty}" onchange="setQty('${productId}', this)">
<button onclick="updateQty('${productId}', 1)">+</button>
</div>
`;
} else {
html = `<button class="add-to-cart-btn" style="max-width: 120px; margin-top: 0; padding:6px; font-size:0.85rem;" onclick="addToCart('${productId}')">${JS_TEXTS.addToCart}</button>`;
}
container.innerHTML = html;
}
function openModalById(productId) {
const product = getProductById(productId);
if (!product) return;
const modalContent = document.getElementById('modalContent');
let photosHtml = '';
let paginationHtml = '';
if (product.photos && product.photos.length > 0) {
photosHtml = product.photos.map(photo => `
<div class="swiper-slide">
<div class="swiper-zoom-container">
<img src="https://huggingface.co/datasets/${repoId}/resolve/main/photos/${photo}" alt="${product.name}">
</div>
</div>
`).join('');
if(product.photos.length > 1) {
paginationHtml = `<div class="swiper-pagination"></div>`;
}
} else {
photosHtml = `
<div class="swiper-slide">
<div class="swiper-zoom-container">
<img src="https://via.placeholder.com/500x500.png?text=${JS_TEXTS.noPhoto}" alt="No image">
</div>
</div>
`;
}
let priceHtml = product.price > 0 ? `<div class="modal-price">${parseFloat(product.price).toFixed(2)} ${currencyCode}</div>` : '';
let catHtml = product.category && product.category !== 'Без категории' ? `<div class="modal-category">${product.category}</div>` : '';
let descHtml = product.description ? `<div class="modal-desc">${product.description.replace(/\\n/g, '<br>')}</div>` : '';
modalContent.innerHTML = `
<div class="swiper-container">
<div class="swiper-wrapper">
${photosHtml}
</div>
${paginationHtml}
</div>
<div class="modal-body">
${catHtml}
<h2 class="modal-title">${product.name}</h2>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
${priceHtml}
<div id="modal-cart-controls-container"></div>
</div>
${descHtml}
</div>
`;
const modal = document.getElementById('productModal');
modal.style.display = "block";
document.body.style.overflow = 'hidden';
renderModalCartControls(productId);
new Swiper('#productModal .swiper-container', {
zoom: true,
slidesPerView: 1,
pagination: { el: '.swiper-pagination', clickable: true }
});
}
function openQrModal() {
const currentDomain = window.location.origin;
const targetUrl = currentDomain + "{{ url_for('catalog', env_id=env_id) }}";
const qrApiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=500x500&data=${encodeURIComponent(targetUrl)}&margin=10`;
document.getElementById('qrImage').src = qrApiUrl;
document.getElementById('qrModal').style.display = 'block';
document.body.style.overflow = 'hidden';
}
function openPdfModal(url) {
document.getElementById('pdfIframe').src = url;
const modal = document.getElementById('pdfModal');
modal.style.display = 'block';
document.body.style.overflow = 'hidden';
}
function openCartModal() {
renderCartModal();
const modal = document.getElementById('cartModal');
modal.style.display = 'block';
document.body.style.overflow = 'hidden';
}
function renderCartModal() {
const container = document.getElementById('cartItems');
const totalContainer = document.getElementById('cartTotal');
let html = '';
let total = 0;
for(let id in cart) {
let item = cart[id];
total += item.qty * item.price;
let photoUrl = item.photo ? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${item.photo}` : `https://via.placeholder.com/50x50.png?text=${JS_TEXTS.noPhoto}`;
html += `
<div class="cart-item">
<img src="${photoUrl}" alt="${item.name}">
<div class="cart-item-info">
<div class="cart-item-title">${item.name}</div>
<div class="cart-item-price">${item.price.toFixed(0)} ${currencyCode}</div>
</div>
<div class="cart-controls" style="margin-top:0;">
<button onclick="updateQty('${id}', -1)">-</button>
<input type="number" value="${item.qty}" onchange="setQty('${id}', this)">
<button onclick="updateQty('${id}', 1)">+</button>
</div>
</div>
`;
}
if(html === '') {
html = `<p style="text-align:center; opacity:0.7;">${JS_TEXTS.cartEmpty}</p>`;
totalContainer.innerHTML = '';
} else {
totalContainer.innerHTML = `${JS_TEXTS.total} ${total.toFixed(0)} ${currencyCode}`;
}
container.innerHTML = html;
}
async function submitOrder() {
if(Object.keys(cart).length === 0) return alert(JS_TEXTS.cartEmpty);
const cName = document.getElementById('customerName').value.trim();
const cPhone = document.getElementById('customerPhone').value.trim();
if(!cName || !cPhone) return alert(JS_TEXTS.fillNamePhone);
const payload = { items: cart, customer_name: cName, customer_phone: cPhone };
const btn = document.querySelector('#cartModal .save-contact-btn');
const originalText = btn.innerText;
btn.innerText = JS_TEXTS.processing;
btn.disabled = true;
try {
const res = await fetch(`/${envId}/checkout`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const data = await res.json();
if(data.success) {
cart = {};
saveCart();
window.location.href = data.url;
} else {
alert(JS_TEXTS.checkoutError);
}
} catch(e) {
alert(JS_TEXTS.networkError);
} finally {
btn.innerText = originalText;
btn.disabled = false;
}
}
function closeModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.style.display = "none";
document.body.style.overflow = 'auto';
if (modalId === 'pdfModal') {
document.getElementById('pdfIframe').src = '';
}
}
}
</script>
</body>
</html>
'''
ADMIN_TEMPLATE = '''
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ t('Ustawienia wizytówki') }} - {{ settings.organization_name }}</title>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root { --bg-light: #f4f6f9; --bg-medium: #135D66; --accent: #48D1CC; --accent-hover: #77E4D8; --text-dark: #333; --text-on-accent: #003C43; --danger: #E57373; --danger-hover: #EF5350; --input-pad: 8px 12px; --btn-pad: 8px 16px; --radius: 6px; --fs-normal: 0.9rem; }
* { box-sizing: border-box; }
body { font-family: 'Montserrat', sans-serif; background-color: var(--bg-light); color: var(--text-dark); padding: 15px; line-height: 1.5; margin: 0; font-size: var(--fs-normal); }
.container { max-width: 1000px; margin: 30px auto; background-color: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 3px 10px rgba(0,0,0,0.05); }
.header { padding-bottom: 15px; margin-bottom: 20px; border-bottom: 1px solid #e0e0e0; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;}
.header .logo-title-container img { height: 45px; width: 45px; border-radius: 50%; object-fit: cover; border: 2px solid var(--bg-medium);}
h1, h2, h3 { font-weight: 600; color: var(--bg-medium); margin-bottom: 12px; }
h1 { font-size: 1.4rem; }
h2 { font-size: 1.2rem; margin-top: 25px; display: flex; align-items: center; gap: 8px; }
h3 { font-size: 1.1rem; color: #004D40; margin-top: 15px; }
.section { margin-bottom: 20px; padding: 15px; background-color: #fdfdff; border: 1px solid #e0e0e0; border-radius: 6px; }
form { margin-bottom: 15px; }
label { font-weight: 500; margin-top: 8px; margin-bottom: 2px; display: block; color: #555; font-size: var(--fs-normal);}
input[type="text"], input[type="number"], input[type="password"], input[type="tel"], textarea, select { width: 100%; padding: var(--input-pad); margin-top: 4px; border: 1px solid #d0d0d0; border-radius: var(--radius); font-size: var(--fs-normal); box-sizing: border-box; transition: border-color 0.3s ease; background-color: #fff; min-height: 36px;}
input:focus, textarea:focus, select:focus { border-color: var(--bg-medium); outline: none; box-shadow: 0 0 0 2px rgba(19, 93, 102, 0.1); }
textarea { min-height: 70px; resize: vertical; }
input[type="file"] { padding: 6px; background-color: #ffffff; cursor: pointer; border: 1px solid #e0e0e0; min-height: auto;}
input[type="file"]::file-selector-button { padding: 6px 10px; border-radius: 4px; background-color: #f0f0f0; border: 1px solid #d0d0d0; cursor: pointer; margin-right: 10px;}
input[type="checkbox"] { margin-right: 6px; vertical-align: middle; width: 16px; height: 16px; cursor: pointer;}
button, .button { padding: var(--btn-pad); border: none; border-radius: var(--radius); background-color: var(--accent); color: var(--text-on-accent); font-weight: 600; cursor: pointer; transition: background-color 0.3s ease, transform 0.1s ease; margin-top: 10px; font-size: var(--fs-normal); display: inline-flex; align-items: center; gap: 6px; text-decoration: none; line-height: 1.2; min-height: 36px; justify-content: center;}
button:hover, .button:hover { background-color: var(--accent-hover); }
button:active, .button:active { transform: scale(0.98); }
.delete-button { background-color: var(--danger); color: white; }
.delete-button:hover { background-color: var(--danger-hover); }
.add-button { background-color: var(--bg-medium); color: white; }
.add-button:hover { background-color: #003C43; }
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; align-items: start;}
.full-width { grid-column: 1 / -1; }
.item-list { display: flex; flex-direction: column; gap: 10px; }
.item { background: #fff; padding: 12px 15px; border-radius: 8px; box-shadow: 0 2px 6px rgba(0,0,0,0.04); border: 1px solid #f0f0f0; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px;}
.item-info { display: flex; flex-direction: column; gap: 4px; flex-grow: 1; }
.item p { margin: 0; font-size: 0.85rem; color: #666; }
.item strong { color: var(--text-dark); }
.item-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.edit-form-container { margin-top: 10px; padding: 15px; background: #E0F2F1; border: 1px dashed #B2DFDB; border-radius: 8px; display: none; width: 100%; }
details { background-color: #fdfdff; border: 1px solid #e0e0e0; border-radius: 6px; margin-bottom: 15px; }
details > summary { cursor: pointer; font-weight: 600; color: var(--bg-medium); display: block; padding: 12px 15px; list-style: none; position: relative; font-size: 1rem; }
details > summary:hover { background-color: #fafafa; }
details > summary::after { content: '\\f078'; font-family: 'Font Awesome 6 Free'; font-weight: 900; position: absolute; right: 15px; top: 50%; transform: translateY(-50%); transition: transform 0.2s ease; color: var(--bg-medium); }
details[open] > summary::after { transform: translateY(-50%) rotate(180deg); }
details[open] > summary { border-bottom: 1px solid #e0e0e0; }
details .form-content { padding: 15px; }
.photo-preview img { width: 50px; height: 50px; border-radius: 6px; margin: 0 10px 0 0; border: 1px solid #e0e0e0; object-fit: cover;}
.message { padding: 10px 12px; border-radius: 6px; margin-bottom: 15px; font-size: 0.9rem; font-weight: 500;}
.message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb;}
.message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb;}
.message.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; }
.ai-generate-button { background-color: #8D6EC8; color: white; margin-top: 5px; margin-bottom: 5px; }
.ai-generate-button:hover { background-color: #7B4DB5; }
.current-avatar { max-width: 45px; max-height: 45px; border-radius: 50%; vertical-align: middle; margin-left: 10px; border: 2px solid var(--bg-medium);}
.block-item { display: flex; justify-content: space-between; align-items: center; background: #fff; padding: 10px 15px; border: 1px solid #e0e0e0; border-radius: 8px; margin-bottom: 8px; flex-wrap: wrap; gap: 10px;}
.block-controls { display: flex; gap: 6px; }
.btn-small { padding: 6px 10px; font-size: 0.85rem; min-height: auto;}
.pagination { display: flex; justify-content: center; gap: 6px; margin-top: 25px; flex-wrap: wrap; align-items: center; }
.pagination .button { min-width: 36px; text-align: center; padding: 8px 12px; margin: 0; border-radius: 6px; }
#loadingOverlay {
display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.8); z-index: 10000; flex-direction: column;
justify-content: center; align-items: center; color: white; text-align: center;
}
.spinner {
width: 50px; height: 50px; border: 5px solid #f3f3f3;
border-top: 5px solid var(--accent); border-radius: 50%;
animation: spin 1s linear infinite; margin-bottom: 15px;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
@media (max-width: 600px) {
.header { flex-direction: column; align-items: flex-start; }
}
</style>
</head>
<body>
<div style="position: absolute; top: 10px; right: 20px; z-index: 1000; display: flex; gap: 10px; background: #fff; padding: 5px 15px; border-radius: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
<a href="#" onclick="setAppLang('pl')" style="text-decoration:none; color: #135D66; font-weight: {{ 'bold' if current_lang == 'pl' else 'normal' }}; opacity: {{ '1' if current_lang == 'pl' else '0.6' }};">PL</a>
<a href="#" onclick="setAppLang('en')" style="text-decoration:none; color: #135D66; font-weight: {{ 'bold' if current_lang == 'en' else 'normal' }}; opacity: {{ '1' if current_lang == 'en' else '0.6' }};">EN</a>
<a href="#" onclick="setAppLang('de')" style="text-decoration:none; color: #135D66; font-weight: {{ 'bold' if current_lang == 'de' else 'normal' }}; opacity: {{ '1' if current_lang == 'de' else '0.6' }};">DE</a>
</div>
<script>
function setAppLang(l) {
fetch('/set_lang', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({lang:l})})
.then(()=>location.reload());
}
</script>
<div id="loadingOverlay">
<div class="spinner"></div>
<h2>{{ t('Zapisywanie...') }}</h2>
</div>
<div class="container">
<div class="header">
<div class="logo-title-container" style="display: flex; align-items: center; gap: 10px;">
<img src="{{ chat_avatar_url }}" alt="Logo">
<h1><i class="fas fa-id-badge"></i> {{ t('Ustawienia wizytówki') }}</h1>
</div>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<a href="{{ url_for('catalog', env_id=env_id) }}" class="button" style="background-color: var(--bg-medium); color: white;"><i class="fas fa-external-link-alt"></i> {{ t('Otwórz wizytówkę') }}</a>
<a href="{{ url_for('admin_orders', env_id=env_id) }}" class="button" style="background-color: {% if new_orders_count > 0 %}#E57373{% else %}var(--bg-medium){% endif %}; color: white; position: relative;">
<i class="fas fa-history"></i> {{ t('Historia zamówień') }}
{% if new_orders_count > 0 %}
<span style="background-color: white; color: #E57373; border-radius: 50%; padding: 2px 6px; font-size: 0.75rem; font-weight: bold; margin-left: 5px; border: 1.5px solid #E57373;">{{ new_orders_count }}</span>
{% endif %}
</a>
<button onclick="downloadQR()" class="button" style="background-color: #8A2BE2; color: white;"><i class="fas fa-qrcode"></i> {{ t('Kod QR') }}</button>
{% if settings.admin_password_enabled %}
<a href="{{ url_for('admin_logout', env_id=env_id) }}" class="button" style="background-color: #6c757d; color: white;"><i class="fas fa-sign-out-alt"></i> {{ t('Wyloguj') }}</a>
{% endif %}
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="message {{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="section">
<details open>
<summary><i class="fas fa-user-edit"></i> {{ t('Profil i wygląd') }}</summary>
<div class="form-content">
<form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" enctype="multipart/form-data" onsubmit="showLoadingOverlay()">
<input type="hidden" name="action" value="update_settings">
<div class="form-grid">
<div>
<label>{{ t('Imię:') }}</label>
<input type="text" name="vcard_firstname" value="{{ settings.vcard_firstname }}">
</div>
<div>
<label>{{ t('Nazwisko:') }}</label>
<input type="text" name="vcard_lastname" value="{{ settings.vcard_lastname }}">
</div>
<div>
<label>{{ t('Stanowisko / Specjalizacja:') }}</label>
<input type="text" name="vcard_job" value="{{ settings.vcard_job }}">
</div>
<div>
<label>{{ t('Nazwa organizacji:') }}</label>
<input type="text" name="organization_name" value="{{ settings.organization_name }}">
</div>
</div>
<label style="margin-top: 10px;">{{ t('O mnie / Status:') }}</label>
<textarea name="about_text" rows="3">{{ settings.about_text }}</textarea>
<div class="form-grid" style="margin-top: 10px;">
<div>
<label>{{ t('Schemat kolorów:') }}</label>
<select name="color_scheme">
{% for key, name in color_schemes.items() %}
<option value="{{ key }}" {% if settings.color_scheme == key %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
</div>
<div>
<label>{{ t('Waluta (dla produktów/usług):') }}</label>
<select name="currency_code">
{% for code, name in currencies.items() %}
<option value="{{ code }}" {% if settings.currency_code == code %}selected{% endif %}>{{ name }} ({{ code }})</option>
{% endfor %}
</select>
</div>
</div>
<label style="margin-top: 10px;">{{ t('Awatar (Zdjęcie profilowe):') }}</label>
<div style="display:flex; align-items:center; gap:10px;">
<input type="file" name="chat_avatar" accept="image/*">
{% if settings.chat_avatar %}
<span style="font-size: 0.85rem;">{{ t('Obecny awatar:') }} <img src="{{ chat_avatar_url }}" class="current-avatar"></span>
{% endif %}
</div>
<h4 style="margin-top: 25px; margin-bottom: 10px;"><i class="fas fa-shopping-cart"></i> {{ t('Ustawienia koszyka i zamówień') }}</h4>
<label style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" name="enable_cart" {% if settings.enable_cart %}checked{% endif %}>
{{ t('Włącz koszyk (zakupy)') }}
</label>
<div class="form-grid" style="margin-top: 10px;">
<div>
<label>{{ t('Komunikator dla zamówień:') }}</label>
<select name="order_messenger">
<option value="whatsapp" {% if settings.order_messenger == 'whatsapp' %}selected{% endif %}>WhatsApp</option>
<option value="telegram" {% if settings.order_messenger == 'telegram' %}selected{% endif %}>Telegram</option>
<option value="none" {% if settings.order_messenger == 'none' %}selected{% endif %}>{{ t('Nie przyjmuj zamówień') }}</option>
</select>
</div>
<div>
<label>{{ t('Numer (WhatsApp) lub Username (Telegram):') }}</label>
<input type="text" name="order_contact" value="{{ settings.order_contact }}" placeholder="np: +48555... lub moj_nick">
</div>
</div>
<div style="background: #f1f3f5; padding: 15px; border-radius: 8px; margin-top: 20px;">
<h4 style="margin-top: 0; color: var(--bg-medium); font-size: 1rem;"><i class="fas fa-lock"></i> {{ t('Hasło dostępu (dla admina)') }}</h4>
<div class="form-grid">
<div style="display:flex; align-items:center;">
<label style="display: flex; align-items: center; gap: 8px; margin:0;"><input type="checkbox" name="admin_password_enabled" {% if settings.admin_password_enabled %}checked{% endif %}> {{ t('Wymagaj hasła') }}</label>
</div>
<div>
<input type="text" name="admin_password" value="{{ settings.admin_password }}" placeholder="{{ t('Hasło') }}">
</div>
</div>
</div>
<button type="submit" class="add-button" style="margin-top: 15px;"><i class="fas fa-save"></i> {{ t('Zapisz ustawienia') }}</button>
</form>
</div>
</details>
</div>
<div class="section">
<h2><i class="fas fa-link"></i> {{ t('Przyciski i Linki') }}</h2>
<details>
<summary><i class="fas fa-plus-circle"></i> {{ t('Dodaj przycisk') }}</summary>
<div class="form-content">
<form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" enctype="multipart/form-data" onsubmit="showLoadingOverlay()">
<input type="hidden" name="action" value="add_block">
<div class="form-grid">
<div>
<label>{{ t('Typ bloku:') }}</label>
<select name="block_type" id="add_block_type" onchange="toggleBlockFields('add_')">
<option value="link">{{ t('Przycisk-link') }}</option>
<option value="text">{{ t('Blok tekstowy') }}</option>
<option value="pdf">{{ t('Katalog PDF') }}</option>
</select>
</div>
<div>
<label>{{ t('Tytuł / Tekst przycisku:') }}</label>
<input type="text" name="block_title" required>
</div>
<div id="add_block_icon_div">
<label>{{ t('Ikona:') }}</label>
<select name="block_icon">
<option value="">-- {{ t('Bez ikony') }} --</option>
{% for icon_class, icon_name in icons.items() %}
<option value="{{ icon_class }}">{{ icon_name }}</option>
{% endfor %}
</select>
</div>
<div id="add_block_url_div">
<label>{{ t('URL / Numer / Email...') }}</label>
<input type="text" name="block_url" placeholder="https://... / +48... / mail@.com">
</div>
</div>
<div id="add_block_pdf_div" style="display: none; margin-top: 10px;">
<label>{{ t('Prześlij PDF:') }}</label>
<input type="file" name="block_pdf" accept="application/pdf">
</div>
<div id="add_block_content_div" style="display: none; margin-top: 10px;">
<label>{{ t('Tekst bloku:') }}</label>
<textarea name="block_content" rows="3"></textarea>
</div>
<button type="submit" class="add-button" style="margin-top: 10px;"><i class="fas fa-plus"></i> {{ t('Dodaj') }}</button>
</form>
</div>
</details>
<div style="margin-top: 15px;">
{% if blocks %}
{% for block in blocks %}
<div class="block-item">
<div style="flex-grow: 1;">
<strong style="font-size: 1rem;">
{% if block.icon %}
{% set prefix = 'fab' if block.icon in ['fa-whatsapp', 'fa-telegram', 'fa-instagram', 'fa-youtube', 'fa-tiktok'] else 'fas' %}
<i class="{{ prefix }} {{ block.icon }}" style="color:var(--accent);"></i>
{% endif %}
{{ block.title }}
</strong>
<span style="color: #888; font-size: 0.85rem;">
({% if block.type == 'link' %}{{ t('Link') }}{% elif block.type == 'pdf' %}PDF{% else %}{{ t('Tekst') }}{% endif %})
</span>
{% if block.type == 'link' %}<br><small style="font-size: 0.85rem;"><a href="{{ block.url }}" target="_blank" rel="noopener noreferrer">{{ block.url }}</a></small>{% endif %}
</div>
<div class="block-controls">
<button type="button" class="button btn-small" style="background: #1E90FF;" onclick="toggleEditForm('edit-block-{{ block.id }}'); toggleBlockFields('edit_{{ block.id }}_');"><i class="fas fa-edit"></i></button>
<form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" style="margin: 0;">
<input type="hidden" name="action" value="move_block_up">
<input type="hidden" name="block_id" value="{{ block.id }}">
<button type="submit" class="button btn-small" style="background: #6c757d;" {% if loop.first %}disabled{% endif %}><i class="fas fa-arrow-up"></i></button>
</form>
<form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" style="margin: 0;">
<input type="hidden" name="action" value="move_block_down">
<input type="hidden" name="block_id" value="{{ block.id }}">
<button type="submit" class="button btn-small" style="background: #6c757d;" {% if loop.last %}disabled{% endif %}><i class="fas fa-arrow-down"></i></button>
</form>
<form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" style="margin: 0;" onsubmit="if(!confirm('{{ t('Czy na pewno usunąć?') }}')) return false; showLoadingOverlay(); return true;">
<input type="hidden" name="action" value="delete_block">
<input type="hidden" name="block_id" value="{{ block.id }}">
<button type="submit" class="button delete-button btn-small"><i class="fas fa-trash-alt"></i></button>
</form>
</div>
</div>
<div id="edit-block-{{ block.id }}" class="edit-form-container">
<form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" enctype="multipart/form-data" onsubmit="showLoadingOverlay()">
<input type="hidden" name="action" value="edit_block">
<input type="hidden" name="block_id" value="{{ block.id }}">
<input type="hidden" name="old_block_url" value="{{ block.url }}">
<div class="form-grid">
<div>
<label>{{ t('Typ bloku:') }}</label>
<select name="block_type" id="edit_{{ block.id }}_block_type" onchange="toggleBlockFields('edit_{{ block.id }}_')">
<option value="link" {% if block.type == 'link' %}selected{% endif %}>{{ t('Przycisk-link') }}</option>
<option value="text" {% if block.type == 'text' %}selected{% endif %}>{{ t('Blok tekstowy') }}</option>
<option value="pdf" {% if block.type == 'pdf' %}selected{% endif %}>{{ t('Katalog PDF') }}</option>
</select>
</div>
<div>
<label>{{ t('Tytuł / Tekst przycisku:') }}</label>
<input type="text" name="block_title" value="{{ block.title }}" required>
</div>
<div id="edit_{{ block.id }}_block_icon_div">
<label>{{ t('Ikona:') }}</label>
<select name="block_icon">
<option value="">-- {{ t('Bez ikony') }} --</option>
{% for icon_class, icon_name in icons.items() %}
<option value="{{ icon_class }}" {% if block.icon == icon_class %}selected{% endif %}>{{ icon_name }}</option>
{% endfor %}
</select>
</div>
<div id="edit_{{ block.id }}_block_url_div">
<label>{{ t('URL / Numer / Email...') }}</label>
<input type="text" name="block_url" value="{{ block.url if block.type == 'link' else '' }}">
</div>
</div>
<div id="edit_{{ block.id }}_block_pdf_div" style="display: none; margin-top: 10px;">
<label style="font-size: 0.85rem;">{{ t('Obecny PDF:') }} {% if block.type == 'pdf' and block.url %}{{ block.url }}{% else %}{{ t('Brak') }}{% endif %}</label>
<label>{{ t('Zmień PDF (zastąpi obecny):') }}</label>
<input type="file" name="block_pdf" accept="application/pdf">
</div>
<div id="edit_{{ block.id }}_block_content_div" style="display: none; margin-top: 10px;">
<label>{{ t('Tekst bloku:') }}</label>
<textarea name="block_content" rows="3">{{ block.content }}</textarea>
</div>
<button type="submit" class="add-button" style="margin-top: 10px;"><i class="fas fa-save"></i> {{ t('Zapisz') }}</button>
</form>
</div>
{% endfor %}
{% else %}
<p style="font-size: 0.95rem;">{{ t('Brak rekordów.') }}</p>
{% endif %}
</div>
<script>
function toggleBlockFields(prefix = 'add_') {
const typeSelect = document.getElementById(prefix + 'block_type');
if (!typeSelect) return;
const type = typeSelect.value;
const urlDiv = document.getElementById(prefix + 'block_url_div');
const iconDiv = document.getElementById(prefix + 'block_icon_div');
const pdfDiv = document.getElementById(prefix + 'block_pdf_div');
const contentDiv = document.getElementById(prefix + 'block_content_div');
if (urlDiv) urlDiv.style.display = type === 'link' ? 'block' : 'none';
if (iconDiv) iconDiv.style.display = (type === 'link' || type === 'pdf') ? 'block' : 'none';
if (pdfDiv) pdfDiv.style.display = type === 'pdf' ? 'block' : 'none';
if (contentDiv) contentDiv.style.display = type === 'text' ? 'block' : 'none';
}
document.addEventListener("DOMContentLoaded", function() {
toggleBlockFields('add_');
});
</script>
</div>
<div class="section">
<h2><i class="fas fa-tags"></i> {{ t('Kategorie produktów/usług') }}</h2>
<details>
<summary><i class="fas fa-plus-circle"></i> {{ t('Dodaj nową kategorię') }}</summary>
<div class="form-content">
<form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}">
<input type="hidden" name="action" value="add_category">
<label>{{ t('Nazwa nowej kategorii:') }}</label>
<div style="display:flex; gap:10px;">
<input type="text" name="category_name" required style="margin:0;">
<button type="submit" class="add-button" style="margin:0;"><i class="fas fa-plus"></i> {{ t('Dodaj') }}</button>
</div>
</form>
</div>
</details>
{% if categories %}
<div class="item-list" style="margin-top:15px; flex-direction:row; flex-wrap:wrap;">
{% for category in categories %}
<div class="item" style="padding: 6px 12px; width:auto; border-radius: 20px;">
<span style="font-size: 0.9rem; font-weight: 500;">{{ category }}</span>
<form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" style="margin: 0;" onsubmit="if(!confirm('{{ t('Czy na pewno? Produkty w tej kategorii zostaną oznaczone jako \\"Bez kategorii\\".') }}')) return false;">
<input type="hidden" name="action" value="delete_category">
<input type="hidden" name="category_name" value="{{ category }}">
<button type="submit" class="delete-button" style="margin: 0; padding: 4px 8px; border-radius: 50%; min-height:auto;"><i class="fas fa-times"></i></button>
</form>
</div>
{% endfor %}
</div>
{% else %}
<p style="font-size: 0.95rem; margin-top:10px;">{{ t('Brak rekordów.') }}</p>
{% endif %}
</div>
<div class="section">
<h2><i class="fas fa-box-open"></i> {{ t('Produkty i Usługi') }}</h2>
<details>
<summary><i class="fas fa-plus-circle"></i> {{ t('Dodaj produkt/usługę') }}</summary>
<div class="form-content">
<form id="add-product-form" method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" enctype="multipart/form-data" onsubmit="showLoadingOverlay()">
<input type="hidden" name="action" value="add_product">
<div class="form-grid">
<div class="full-width">
<label>{{ t('Nazwa *:') }}</label>
<input type="text" name="name" required>
</div>
<div>
<label>{{ t('Cena (0 aby ukryć):') }}</label>
<input type="number" name="price" step="0.01" value="0">
</div>
<div>
<label>{{ t('Kategoria:') }}</label>
<select name="category">
<option value="Bez kategorii">{{ t('Bez kategorii') }}</option>
{% for category in categories %}
<option value="{{ category }}">{{ category }}</option>
{% endfor %}
</select>
</div>
</div>
<label style="margin-top: 10px;">{{ t('Zdjęcia (max 10):') }}</label>
<input type="file" name="photos" accept="image/*" multiple>
<label style="margin-top: 10px;">{{ t('Opis:') }}</label>
<textarea id="add_description" name="description" rows="3"></textarea>
<div style="display: flex; gap: 8px; align-items: center; margin-top: 5px; flex-wrap: wrap;">
<button type="button" class="button ai-generate-button" style="margin: 0; padding: 6px 12px; font-size: 0.85rem;" onclick="generateDescription('add-product-form', 'add_description', 'add_gen_lang')"><i class="fas fa-magic"></i> {{ t('Generuj opis AI z pierwszego zdjęcia') }}</button>
<select id="add_gen_lang" name="gen_lang" style="width: auto; margin: 0; padding: 6px; min-height:auto;">
<option value="Polski">Polski</option><option value="English">English</option><option value="Deutsch">Deutsch</option>
</select>
</div>
<button type="submit" class="add-button" style="margin-top: 15px;"><i class="fas fa-save"></i> {{ t('Dodaj') }}</button>
</form>
</div>
</details>
<h3 style="margin-top: 25px;">{{ t('Produkty i Usługi') }}:</h3>
<form method="GET" action="{{ url_for('admin', env_id=env_id) }}" style="margin-bottom: 15px; display: flex; gap: 8px; flex-wrap: wrap;" id="admin-search-form">
<input type="text" name="q" value="{{ search_q }}" placeholder="{{ t('Szukaj...') }}" style="flex-grow: 1; margin: 0;" id="admin-search-input">
<button type="submit" class="button" style="margin: 0;"><i class="fas fa-search"></i> {{ t('Szukaj') }}</button>
{% if search_q %}
<a href="{{ url_for('admin', env_id=env_id) }}" class="button" style="background-color: #6c757d; margin: 0; justify-content: center;"><i class="fas fa-times"></i> {{ t('Resetuj') }}</a>
{% endif %}
</form>
{% if paginated_products %}
<div class="item-list" id="admin-products-list">
{% for product in paginated_products %}
<div class="item">
<div class="photo-preview" style="flex-shrink: 0;">
{% if product.get('photos') %}
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}" alt="Photo">
{% else %}
<img src="https://via.placeholder.com/50x50.png?text={{ t('Brak') }}" alt="No photo">
{% endif %}
</div>
<div class="item-info">
<strong style="font-size: 1rem;">{{ product['name'] }}</strong>
<div style="display:flex; gap:15px;">
<p><strong>{{ t('Kategoria:') }}</strong> {{ product.get('category', 'Bez kategorii') }}</p>
<p><strong>{{ t('Cena:') }}</strong> {% if product.get('price', 0) > 0 %}{{ "%.2f"|format(product.get('price', 0)) }} {{ currency_code }}{% else %}{{ t('Brak') }}{% endif %}</p>
</div>
</div>
<div class="item-actions">
<button type="button" class="button btn-small" onclick="toggleEditForm('edit-form-{{ product.product_id }}')"><i class="fas fa-edit"></i> {{ t('Edytuj') }}</button>
<form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" style="margin:0;" onsubmit="if(!confirm('{{ t('Czy na pewno usunąć?') }}')) return false; showLoadingOverlay(); return true;">
<input type="hidden" name="action" value="delete_product">
<input type="hidden" name="product_id" value="{{ product.get('product_id', '') }}">
<button type="submit" class="button delete-button btn-small"><i class="fas fa-trash-alt"></i></button>
</form>
</div>
<div id="edit-form-{{ product.product_id }}" class="edit-form-container">
<h4 style="margin-top: 0; font-size: 1rem; margin-bottom:10px;"><i class="fas fa-edit"></i> {{ t('Edytuj') }}</h4>
<form id="edit-form-internal-{{ product.product_id }}" method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" enctype="multipart/form-data" onsubmit="showLoadingOverlay()">
<input type="hidden" name="action" value="edit_product">
<input type="hidden" name="product_id" value="{{ product.get('product_id', '') }}">
<div class="form-grid">
<div class="full-width">
<label>{{ t('Nazwa *:') }}</label>
<input type="text" name="name" value="{{ product['name'] }}" required>
</div>
<div>
<label>{{ t('Cena (0 aby ukryć):') }}</label>
<input type="number" name="price" step="0.01" value="{{ product.get('price', 0) }}">
</div>
<div>
<label>{{ t('Kategoria:') }}</label>
<select name="category">
<option value="Bez kategorii">{{ t('Bez kategorii') }}</option>
{% for category in categories %}
<option value="{{ category }}" {% if product.get('category') == category %}selected{% endif %}>{{ category }}</option>
{% endfor %}
</select>
</div>
</div>
<label style="margin-top:10px;">{{ t('Zmień zdjęcia (stare zostaną usunięte):') }}</label>
<input type="file" name="photos" accept="image/*" multiple>
<label style="margin-top:10px;">{{ t('Opis:') }}</label>
<textarea id="edit_description_{{ product.product_id }}" name="description" rows="3">{{ product.get('description', '') }}</textarea>
<div style="display: flex; gap: 8px; align-items: center; margin-top: 5px; flex-wrap: wrap;">
<button type="button" class="button ai-generate-button" style="margin: 0; padding: 6px 12px; font-size: 0.85rem;" onclick="generateDescription('edit-form-internal-{{ product.product_id }}', 'edit_description_{{ product.product_id }}', 'edit_gen_lang_{{ product.product_id }}')"><i class="fas fa-magic"></i> {{ t('Generuj opis AI z pierwszego zdjęcia') }}</button>
<select id="edit_gen_lang_{{ product.product_id }}" name="gen_lang" style="width: auto; margin: 0; padding: 6px; min-height:auto;">
<option value="Polski">Polski</option><option value="English">English</option><option value="Deutsch">Deutsch</option>
</select>
</div>
<button type="submit" class="add-button" style="margin-top: 15px;"><i class="fas fa-save"></i> {{ t('Zapisz') }}</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% if total_pages > 1 %}
<div class="pagination">
{% if page > 1 %}
<a href="{{ url_for('admin', env_id=env_id, p=page-1, q=search_q) }}" class="button">&laquo;</a>
{% endif %}
{% for p_num in range(1, total_pages + 1) %}
{% if p_num == 1 or p_num == total_pages or (p_num >= page - 2 and p_num <= page + 2) %}
<a href="{{ url_for('admin', env_id=env_id, p=p_num, q=search_q) }}" class="button {% if p_num == page %}active{% endif %}" style="{% if p_num == page %}background-color: var(--accent); color: var(--text-dark);{% else %}background-color: var(--bg-medium); color: white;{% endif %}">{{ p_num }}</a>
{% elif p_num == page - 3 or p_num == page + 3 %}
<span style="padding: 5px; color: var(--bg-medium); font-weight: bold;">...</span>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a href="{{ url_for('admin', env_id=env_id, p=page+1, q=search_q) }}" class="button">&raquo;</a>
{% endif %}
</div>
{% endif %}
{% else %}
<p style="font-size: 1rem; text-align: center; padding: 30px;">{{ t('Brak rekordów.') }}</p>
{% endif %}
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", function() {
const adminSearchInput = document.getElementById('admin-search-input');
const adminSearchForm = document.getElementById('admin-search-form');
if (adminSearchInput && adminSearchForm) {
if (adminSearchInput.value.length > 0) {
adminSearchInput.focus();
const valLen = adminSearchInput.value.length;
adminSearchInput.setSelectionRange(valLen, valLen);
}
let debounceTimer;
adminSearchInput.addEventListener('input', function() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
adminSearchForm.submit();
}, 700);
});
}
});
function showLoadingOverlay() {
document.getElementById('loadingOverlay').style.display = 'flex';
}
function toggleEditForm(formId) {
const formContainer = document.getElementById(formId);
if (formContainer) {
formContainer.style.display = formContainer.style.display === 'none' || formContainer.style.display === '' ? 'block' : 'none';
}
}
async function generateDescription(formId, descriptionTextareaId, languageSelectId) {
const form = document.getElementById(formId);
const photoInput = form.querySelector('input[type="file"]');
const descriptionTextarea = document.getElementById(descriptionTextareaId);
const languageSelect = document.getElementById(languageSelectId);
if (!photoInput || !photoInput.files || photoInput.files.length === 0) {
return alert("{{ t('Błąd: Nie znaleziono pliku obrazu.') }}");
}
descriptionTextarea.value = '{{ t("Przetwarzanie...") }}';
const reader = new FileReader();
reader.onload = async (e) => {
const base64Image = e.target.result.split(',')[1];
try {
const response = await fetch('/generate_description_ai', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: base64Image, language: languageSelect.value })
});
const result = await response.json();
descriptionTextarea.value = result.text || result.error;
} catch (error) { descriptionTextarea.value = `{{ t('Błąd:') }} ${error.message}`; }
};
reader.readAsDataURL(photoInput.files[0]);
}
function downloadQR() {
const currentDomain = window.location.origin;
const targetUrl = currentDomain + "{{ url_for('catalog', env_id=env_id) }}";
const qrApiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=500x500&data=${encodeURIComponent(targetUrl)}`;
fetch(qrApiUrl)
.then(response => response.blob())
.then(blob => {
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'QR_code_{{ env_id }}.png';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
})
.catch(err => alert("{{ t('Błąd sieci') }}"));
}
</script>
</body>
</html>
'''
ORDER_TEMPLATE = '''
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ t('Zamówienie #') }}{{ order['id'] }}</title>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
body { font-family: 'Montserrat', sans-serif; background: #f4f6f9; margin: 0; padding: 15px; color: #333; font-size: 0.95rem; }
.invoice-box { max-width: 600px; margin: 0 auto; background: #fff; padding: 25px; border-radius: 10px; box-shadow: 0 5px 20px rgba(0,0,0,0.05); }
.header { text-align: center; border-bottom: 1px solid #eee; padding-bottom: 15px; margin-bottom: 15px; }
.header h1 { margin: 0; color: #135D66; font-size: 1.5rem; }
.info { margin-bottom: 15px; font-size: 0.9rem; line-height: 1.5; }
.info strong { color: #135D66; }
.item { display: flex; align-items: center; border-bottom: 1px dashed #eee; padding: 10px 0; gap: 10px; }
.item img { width: 50px; height: 50px; object-fit: cover; border-radius: 6px; border: 1px solid #eee; }
.item-details { flex-grow: 1; }
.item-name { font-weight: 600; font-size: 0.95rem; margin-bottom: 3px; }
.item-meta { font-size: 0.85rem; color: #666; }
.item-sum { font-weight: 700; font-size: 1rem; color: #135D66; text-align: right; }
.total { text-align: right; margin-top: 15px; font-size: 1.3rem; font-weight: 700; color: #135D66; }
.btn { display: flex; justify-content: center; align-items: center; gap: 8px; width: 100%; padding: 12px; margin-top: 25px; background: #25D366; color: white; text-decoration: none; border-radius: 6px; font-weight: 700; font-size: 1rem; transition: background 0.3s; box-sizing: border-box; border: none; cursor: pointer;}
.btn.telegram { background: #0088cc; }
.btn:hover { opacity: 0.9; }
.back-btn { background: #6c757d; margin-top: 10px; }
</style>
</head>
<body>
<div style="position: absolute; top: 10px; right: 20px; z-index: 1000; display: flex; gap: 10px; background: #fff; padding: 5px 15px; border-radius: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
<a href="#" onclick="setAppLang('pl')" style="text-decoration:none; color: #135D66; font-weight: {{ 'bold' if current_lang == 'pl' else 'normal' }}; opacity: {{ '1' if current_lang == 'pl' else '0.6' }};">PL</a>
<a href="#" onclick="setAppLang('en')" style="text-decoration:none; color: #135D66; font-weight: {{ 'bold' if current_lang == 'en' else 'normal' }}; opacity: {{ '1' if current_lang == 'en' else '0.6' }};">EN</a>
<a href="#" onclick="setAppLang('de')" style="text-decoration:none; color: #135D66; font-weight: {{ 'bold' if current_lang == 'de' else 'normal' }}; opacity: {{ '1' if current_lang == 'de' else '0.6' }};">DE</a>
</div>
<script>
function setAppLang(l) {
fetch('/set_lang', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({lang:l})})
.then(()=>location.reload());
}
</script>
<div class="invoice-box">
<div class="header">
<h1>{{ t('Faktura zamówienia') }}</h1>
<p style="margin-top: 5px; color: #777;">#{{ order['id'] }} / {{ order['date'] }}</p>
</div>
<div class="info">
<p><strong>{{ t('Kupujący:') }}</strong> {{ order['customer_name'] }}</p>
<p><strong>{{ t('Telefon:') }}</strong> {{ order['customer_phone'] }}</p>
<p><strong>{{ t('Sprzedawca:') }}</strong> {{ settings['organization_name'] }}</p>
</div>
<div class="items">
{% for pid, item in order['items'].items() %}
<div class="item">
{% if item['photo'] %}
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ item['photo'] }}" alt="{{ item['name'] }}">
{% else %}
<img src="https://via.placeholder.com/50x50.png?text={{ t('Brak') }}" alt="No photo">
{% endif %}
<div class="item-details">
<div class="item-name">{{ item['name'] }}</div>
<div class="item-meta">{{ item['qty'] }} {{ t('szt.') }} × {{ "%.2f"|format(item['price']|float) }} {{ currency }}</div>
</div>
<div class="item-sum">
{{ "%.2f"|format(item['qty']|int * item['price']|float) }} {{ currency }}
</div>
</div>
{% endfor %}
</div>
<div class="total">
{{ t('Razem:') }} {{ "%.2f"|format(order['total']|float) }} {{ currency }}
</div>
{% if send_link != "#" %}
<a href="{{ send_link }}" target="_blank" class="btn {{ 'telegram' if messenger_name == 'Telegram' else '' }}">
<i class="fab fa-{{ 'telegram-plane' if messenger_name == 'Telegram' else 'whatsapp' }}"></i> {{ t('Wyślij do sprzedawcy') }} ({{ messenger_name }})
</a>
{% endif %}
<a href="{{ url_for('catalog', env_id=env_id) }}" class="btn back-btn">{{ t('Wróć do katalogu') }}</a>
</div>
</body>
</html>
'''
ADMIN_ORDERS_TEMPLATE = '''
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ t('Historia zamówień') }} - {{ settings.organization_name }}</title>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root { --bg-light: #f4f6f9; --bg-medium: #135D66; --accent: #48D1CC; --accent-hover: #77E4D8; --text-dark: #333; --text-on-accent: #003C43; --danger: #E57373; --danger-hover: #EF5350; }
* { box-sizing: border-box; }
body { font-family: 'Montserrat', sans-serif; background-color: var(--bg-light); color: var(--text-dark); padding: 15px; line-height: 1.5; margin: 0; font-size: 0.95rem; }
.container { max-width: 900px; margin: 30px auto; background-color: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 3px 10px rgba(0,0,0,0.05); }
.header { padding-bottom: 15px; margin-bottom: 20px; border-bottom: 1px solid #e0e0e0; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
h1, h2 { font-weight: 600; color: var(--bg-medium); margin-bottom: 10px; }
h1 { font-size: 1.5rem; margin: 0; }
.button { padding: 8px 16px; border: none; border-radius: 6px; background-color: var(--accent); color: var(--text-on-accent); font-weight: 600; cursor: pointer; transition: background-color 0.3s ease, transform 0.1s ease; text-decoration: none; display: inline-flex; align-items: center; gap: 6px; justify-content: center; min-height: 36px; font-size: 0.9rem; }
.button:hover { background-color: var(--accent-hover); }
.button:active { transform: scale(0.98); }
.back-button { background-color: #6c757d; color: white; }
.back-button:hover { background-color: #5a6268; }
.delete-button { background-color: var(--danger); color: white; padding: 6px 12px; min-height: 32px; }
.delete-button:hover { background-color: var(--danger-hover); }
.order-item { background: #fdfdff; padding: 15px; border: 1px solid #e0e0e0; border-radius: 8px; margin-bottom: 15px; box-shadow: 0 2px 5px rgba(0,0,0,0.02); }
.order-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; border-bottom: 1px dashed #f0f0f0; padding-bottom: 10px; margin-bottom: 10px; }
.order-id { font-weight: 700; color: var(--bg-medium); font-size: 1.05rem; }
.order-date { color: #888; font-size: 0.85rem; }
.customer-info p { margin: 3px 0; font-size: 0.9rem; }
.customer-info strong { color: var(--bg-medium); }
.order-details { margin-top: 10px; background: #fff; border: 1px solid #f0f0f0; border-radius: 6px; padding: 10px; }
.product-row { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; border-bottom: 1px dashed #eee; font-size: 0.9rem; }
.product-row:last-child { border-bottom: none; }
.order-total { text-align: right; font-weight: 700; color: var(--bg-medium); font-size: 1.1rem; margin-top: 10px; }
.empty-state { text-align: center; padding: 40px 15px; color: #888; font-size: 1rem; }
.empty-state i { font-size: 2.5rem; margin-bottom: 10px; color: #ccc; }
</style>
</head>
<body>
<div style="position: absolute; top: 10px; right: 20px; z-index: 1000; display: flex; gap: 10px; background: #fff; padding: 5px 15px; border-radius: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
<a href="#" onclick="setAppLang('pl')" style="text-decoration:none; color: #135D66; font-weight: {{ 'bold' if current_lang == 'pl' else 'normal' }}; opacity: {{ '1' if current_lang == 'pl' else '0.6' }};">PL</a>
<a href="#" onclick="setAppLang('en')" style="text-decoration:none; color: #135D66; font-weight: {{ 'bold' if current_lang == 'en' else 'normal' }}; opacity: {{ '1' if current_lang == 'en' else '0.6' }};">EN</a>
<a href="#" onclick="setAppLang('de')" style="text-decoration:none; color: #135D66; font-weight: {{ 'bold' if current_lang == 'de' else 'normal' }}; opacity: {{ '1' if current_lang == 'de' else '0.6' }};">DE</a>
</div>
<script>
function setAppLang(l) {
fetch('/set_lang', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({lang:l})})
.then(()=>location.reload());
}
</script>
<div class="container">
<div class="header">
<h1><i class="fas fa-history"></i> {{ t('Historia zamówień') }}</h1>
<a href="{{ url_for('admin', env_id=env_id) }}" class="button back-button"><i class="fas fa-arrow-left"></i> {{ t('Wróć do panelu') }}</a>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div style="padding: 10px; border-radius: 6px; margin-bottom: 15px; background: #d4edda; color: #155724;">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% if orders %}
{% for order in orders %}
<div class="order-item">
<div class="order-header">
<div>
<span class="order-id">{{ t('Zamówienie #') }}{{ order['id'] }}</span>
<span class="order-date">{{ order['date'] }}</span>
</div>
<div style="display: flex; gap: 8px; align-items: center;">
<a href="{{ url_for('order_invoice', env_id=env_id, order_id=order['id']) }}" target="_blank" class="button" style="padding: 6px 12px; font-size: 0.85rem; min-height: 32px;"><i class="fas fa-file-invoice"></i> {{ t('Faktura') }}</a>
<form method="POST" action="{{ url_for('delete_order', env_id=env_id, order_id=order['id']) }}" style="margin: 0;" onsubmit="return confirm('{{ t('Czy na pewno usunąć?') }}');">
<button type="submit" class="button delete-button"><i class="fas fa-trash-alt"></i></button>
</form>
</div>
</div>
<div class="customer-info">
<p><strong>{{ t('Kupujący:') }}</strong> {{ order['customer_name'] }}</p>
<p><strong>{{ t('Telefon:') }}</strong> <a href="tel:{{ order['customer_phone'] }}">{{ order['customer_phone'] }}</a></p>
</div>
<div class="order-details">
{% for pid, item in order['items'].items() %}
<div class="product-row">
<span>{{ item['name'] }} ({{ item['qty'] }} {{ t('szt.') }})</span>
<span>{{ "%.2f"|format(item['price']|float * item['qty']|int) }} {{ currency }}</span>
</div>
{% endfor %}
<div class="order-total">
{{ t('Razem:') }} {{ "%.2f"|format(order['total']|float) }} {{ currency }}
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<i class="fas fa-shopping-cart"></i>
<p>{{ t('Brak rekordów.') }}</p>
</div>
{% endif %}
</div>
</body>
</html>
'''
@app.route('/')
def index():
return render_template_string(LANDING_PAGE_TEMPLATE)
@app.route('/set_lang', methods=['POST'])
def set_lang():
lang = request.json.get('lang', 'pl')
session['lang'] = lang
resp = jsonify(success=True)
resp.set_cookie('lang', lang, max_age=60*60*24*365)
return resp
@app.route('/admhosto', methods=['GET'])
def admhosto():
data = load_data()
environments_data = []
for env_id, env_data in data.items():
settings = env_data.get('settings', {})
org_name = settings.get("organization_name", f"Wizytówka {env_id}")
environments_data.append({
"id": env_id,
"org_name": org_name,
"pwd_enabled": settings.get("admin_password_enabled", False),
"password": settings.get("admin_password", "")
})
environments_data.sort(key=lambda x: x['id'])
return render_template_string(ADMHOSTO_TEMPLATE, environments=environments_data)
@app.route('/admhosto/create', methods=['POST'])
def create_environment():
all_data = load_data()
while True:
new_id = ''.join(random.choices(string.digits, k=6))
if new_id not in all_data:
break
all_data[new_id] = {
'products': [], 'categories':[], 'blocks':[], 'orders': [],
'settings': {
"vcard_firstname": "Imię",
"vcard_lastname": "Nazwisko",
"vcard_job": "Specjalista",
"organization_name": "Moja Firma",
"currency_code": "PLN",
"chat_avatar": None,
"color_scheme": "default",
"admin_password_enabled": False,
"admin_password": "",
"categories_as_lines": False,
"about_text": "Cześć! To moja wirtualna wizytówka.",
"enable_cart": False,
"order_messenger": "whatsapp",
"order_contact": ""
}
}
save_data(all_data)
flash(f"{t_text('Utworzono wizytówkę ID:')} {new_id}", 'success')
return redirect(url_for('admhosto'))
@app.route('/admhosto/update_pwd/<env_id>', methods=['POST'])
def update_env_pwd(env_id):
all_data = load_data()
if env_id in all_data:
pwd_enabled = 'pwd_enabled' in request.form
password = request.form.get('password', '').strip()
all_data[env_id]['settings']['admin_password_enabled'] = pwd_enabled
all_data[env_id]['settings']['admin_password'] = password
save_data(all_data)
flash(f"{t_text('Zaktualizowano hasło dla:')} {env_id}", 'success')
else:
flash(f"{t_text('Nie znaleziono wizytówki:')} {env_id}", 'error')
return redirect(url_for('admhosto'))
@app.route('/admhosto/delete/<env_id>', methods=['POST'])
def delete_environment(env_id):
all_data = load_data()
if env_id in all_data:
del all_data[env_id]
save_data(all_data)
flash(f"{t_text('Usunięto wizytówkę:')} {env_id}", 'success')
else:
flash(f"{t_text('Nie znaleziono wizytówki:')} {env_id}", 'error')
return redirect(url_for('admhosto'))
@app.route('/<env_id>/login', methods=['GET', 'POST'])
def admin_login(env_id):
data = get_env_data(env_id)
settings = data.get('settings', {})
if not settings.get('admin_password_enabled'):
return redirect(url_for('admin', env_id=env_id))
if request.method == 'POST':
pwd = request.form.get('password', '')
if pwd == settings.get('admin_password', ''):
session[f'admin_auth_{env_id}'] = True
return redirect(url_for('admin', env_id=env_id))
else:
flash(t_text('Nieprawidłowe hasło'), 'error')
return render_template_string(LOGIN_TEMPLATE, env_id=env_id)
@app.route('/<env_id>/logout')
def admin_logout(env_id):
session.pop(f'admin_auth_{env_id}', None)
return redirect(url_for('admin_login', env_id=env_id))
@app.route('/<env_id>/vcard')
def download_vcard(env_id):
data = get_env_data(env_id)
settings = data.get('settings', {})
blocks = data.get('blocks', [])
first_name = settings.get("vcard_firstname", "")
last_name = settings.get("vcard_lastname", "")
org = settings.get("organization_name", "")
title = settings.get("vcard_job", "")
note = settings.get("about_text", "").replace('\n', '\\n')
card_url = url_for('catalog', env_id=env_id, _external=True)
vcard = [
"BEGIN:VCARD",
"VERSION:3.0",
f"N:{last_name};{first_name};;;",
f"FN:{first_name} {last_name}".strip(),
]
if org: vcard.append(f"ORG:{org}")
if title: vcard.append(f"TITLE:{title}")
if note: vcard.append(f"NOTE:{note}")
vcard.append(f"URL;type=pref:{card_url}")
for b in blocks:
if b.get('type') == 'link' and b.get('url'):
url = b.get('url', '').strip()
if url.startswith('+') or url.replace('-', '').replace(' ', '').isdigit():
vcard.append(f"TEL;TYPE=CELL:{url}")
elif '@' in url and not url.startswith('http'):
vcard.append(f"EMAIL;TYPE=WORK:{url.replace('mailto:', '')}")
else:
vcard.append(f"URL:{url}")
vcard.append("END:VCARD")
vcard_str = "\r\n".join(vcard)
response = make_response(vcard_str)
response.headers["Content-Disposition"] = f"attachment; filename=contact_{env_id}.vcf"
response.headers["Content-Type"] = "text/vcard; charset=utf-8"
return response
@app.route('/<env_id>/catalog')
def catalog(env_id):
data = get_env_data(env_id)
all_products = data.get('products',[])
settings = data.get('settings', {})
blocks = data.get('blocks',[])
product_categories = set(p.get('category', t_text('Bez kategorii')) for p in all_products)
admin_categories = set(data.get('categories',[]))
all_cat_names = sorted(list(product_categories.union(admin_categories)))
products_sorted_for_js = sorted(all_products, key=lambda p: p.get('name', '').lower())
products_by_category = {cat:[] for cat in all_cat_names}
for product in all_products:
products_by_category[product.get('category', t_text('Bez kategorii'))].append(product)
ordered_categories = [cat for cat in all_cat_names if products_by_category.get(cat)]
chat_avatar_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/avatars/{settings['chat_avatar']}" if settings.get('chat_avatar') else "https://huggingface.co/spaces/gippo312/admin/resolve/main/Picsart_25-11-04_12-02-21-390.png"
return render_template_string(
CATALOG_TEMPLATE, ordered_categories=ordered_categories,
products_json=json.dumps(products_sorted_for_js), repo_id=REPO_ID, currency_code=settings.get('currency_code', 'PLN'),
settings=settings, chat_avatar_url=chat_avatar_url, env_id=env_id, blocks=blocks
)
@app.route('/<env_id>/checkout', methods=['POST'])
def checkout(env_id):
req_data = request.get_json()
env_data = get_env_data(env_id)
items = req_data.get('items', {})
if not items:
return jsonify({"success": False, "error": t_text('Koszyk jest pusty')})
total = sum(item.get('qty', 0) * item.get('price', 0) for item in items.values())
order = {
"id": ''.join(random.choices(string.digits, k=8)),
"date": datetime.now(ALMATY_TZ).strftime('%Y-%m-%d %H:%M:%S'),
"customer_name": req_data.get('customer_name', ''),
"customer_phone": req_data.get('customer_phone', ''),
"items": items,
"total": total,
"viewed": False
}
if 'orders' not in env_data:
env_data['orders'] = []
env_data['orders'].append(order)
save_env_data(env_id, env_data)
return jsonify({"success": True, "url": url_for('order_invoice', env_id=env_id, order_id=order['id'])})
@app.route('/<env_id>/order/<order_id>')
def order_invoice(env_id, order_id):
env_data = get_env_data(env_id)
settings = env_data.get('settings', {})
orders = env_data.get('orders', [])
order = next((o for o in orders if o.get('id') == order_id), None)
if not order:
return t_text("Brak wyników."), 404
currency = settings.get('currency_code', 'PLN')
lang = get_lang()
if lang == 'en':
text = f"Hello! I placed an order #{order['id']} for {order['total']} {currency}.\nInvoice link: {request.url}"
elif lang == 'de':
text = f"Hallo! Ich habe eine Bestellung #{order['id']} über {order['total']} {currency} aufgegeben.\nRechnungslink: {request.url}"
else:
text = f"Dzień dobry! Złożyłem zamówienie #{order['id']} na kwotę {order['total']} {currency}.\nLink do faktury: {request.url}"
encoded_text = urllib.parse.quote(text)
messenger = settings.get('order_messenger', 'whatsapp')
contact = settings.get('order_contact', '')
if messenger == 'whatsapp':
num = re.sub(r'[^0-9+]', '', contact)
send_link = f"https://wa.me/{num}?text={encoded_text}"
messenger_name = "WhatsApp"
elif messenger == 'telegram':
username = contact.replace('@', '').strip()
send_link = f"tg://resolve?domain={username}&text={encoded_text}"
messenger_name = "Telegram"
else:
send_link = "#"
messenger_name = t_text("Brak")
return render_template_string(
ORDER_TEMPLATE,
order=order,
currency=currency,
send_link=send_link,
messenger_name=messenger_name,
repo_id=REPO_ID,
settings=settings,
env_id=env_id
)
@app.route('/<env_id>/admin', methods=['GET', 'POST'])
def admin(env_id):
data = get_env_data(env_id)
settings = data.get('settings', {})
if settings.get('admin_password_enabled') and not session.get(f'admin_auth_{env_id}'):
return redirect(url_for('admin_login', env_id=env_id))
products = data.get('products',[])
categories = data.get('categories',[])
blocks = data.get('blocks',[])
page = request.args.get('p', 1, type=int)
search_q = request.args.get('q', '').strip()
if request.method == 'POST':
action = request.form.get('action')
try:
if action == 'add_block':
b_type = request.form.get('block_type')
b_title = request.form.get('block_title', '').strip()
b_icon = request.form.get('block_icon', '')
b_url = request.form.get('block_url', '').strip()
b_content = request.form.get('block_content', '').strip()
if b_type == 'link' and b_url:
if not b_url.startswith(('http://', 'https://', 'mailto:', 'tel:')) and not b_url.startswith('+'):
if '@' in b_url:
b_url = 'mailto:' + b_url
else:
b_url = 'https://' + b_url
elif b_url.startswith('+'):
b_url = 'tel:' + b_url.replace(' ', '').replace('-', '')
elif b_type == 'pdf':
b_url = ''
pdf_file = request.files.get('block_pdf')
if pdf_file and pdf_file.filename and HF_TOKEN_WRITE:
try:
ext = os.path.splitext(pdf_file.filename)[1].lower()
pdf_filename = f"pdf_{uuid4().hex[:8]}{ext}"
uploads_dir = 'uploads_temp'
os.makedirs(uploads_dir, exist_ok=True)
temp_path = os.path.join(uploads_dir, pdf_filename)
pdf_file.save(temp_path)
HfApi().upload_file(path_or_fileobj=temp_path, path_in_repo=f"pdfs/{pdf_filename}", repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
os.remove(temp_path)
b_url = pdf_filename
except Exception as e:
flash(f"{t_text('Błąd ładowania PDF:')} {e}", 'error')
blocks.append({
'id': uuid4().hex[:8],
'type': b_type,
'title': b_title,
'icon': b_icon if b_type in ['link', 'pdf'] else '',
'url': b_url,
'content': b_content
})
data['blocks'] = blocks
save_env_data(env_id, data)
flash(t_text('Dodano blok.'), "success")
elif action == 'edit_block':
b_id = request.form.get('block_id')
b_type = request.form.get('block_type')
b_title = request.form.get('block_title', '').strip()
b_icon = request.form.get('block_icon', '')
b_url = request.form.get('block_url', '').strip()
b_content = request.form.get('block_content', '').strip()
old_url = request.form.get('old_block_url', '')
for b in blocks:
if b.get('id') == b_id:
b['type'] = b_type
b['title'] = b_title
b['icon'] = b_icon if b_type in ['link', 'pdf'] else ''
b['content'] = b_content
if b_type == 'link':
if b_url:
if not b_url.startswith(('http://', 'https://', 'mailto:', 'tel:')) and not b_url.startswith('+'):
if '@' in b_url:
b_url = 'mailto:' + b_url
else:
b_url = 'https://' + b_url
elif b_url.startswith('+'):
b_url = 'tel:' + b_url.replace(' ', '').replace('-', '')
b['url'] = b_url
elif b_type == 'pdf':
pdf_file = request.files.get('block_pdf')
if pdf_file and pdf_file.filename and HF_TOKEN_WRITE:
if old_url and old_url.startswith('pdf_'):
try:
HfApi().delete_files(repo_id=REPO_ID, paths_in_repo=[f"pdfs/{old_url}"], repo_type="dataset", token=HF_TOKEN_WRITE)
except Exception: pass
try:
ext = os.path.splitext(pdf_file.filename)[1].lower()
pdf_filename = f"pdf_{uuid4().hex[:8]}{ext}"
uploads_dir = 'uploads_temp'
os.makedirs(uploads_dir, exist_ok=True)
temp_path = os.path.join(uploads_dir, pdf_filename)
pdf_file.save(temp_path)
HfApi().upload_file(path_or_fileobj=temp_path, path_in_repo=f"pdfs/{pdf_filename}", repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
os.remove(temp_path)
b['url'] = pdf_filename
except Exception as e:
flash(f"{t_text('Błąd ładowania PDF:')} {e}", 'error')
b['url'] = old_url
else:
b['url'] = old_url
else:
b['url'] = ''
break
data['blocks'] = blocks
save_env_data(env_id, data)
flash(t_text('Zaktualizowano blok.'), "success")
elif action == 'delete_block':
b_id = request.form.get('block_id')
block_to_del = next((b for b in blocks if b.get('id') == b_id), None)
if block_to_del:
if block_to_del.get('type') == 'pdf' and block_to_del.get('url') and block_to_del.get('url').startswith('pdf_') and HF_TOKEN_WRITE:
try:
HfApi().delete_files(repo_id=REPO_ID, paths_in_repo=[f"pdfs/{block_to_del.get('url')}"], repo_type="dataset", token=HF_TOKEN_WRITE)
except Exception: pass
blocks = [b for b in blocks if b.get('id') != b_id]
data['blocks'] = blocks
save_env_data(env_id, data)
flash(t_text('Usunięto blok.'), "success")
elif action == 'move_block_up':
b_id = request.form.get('block_id')
idx = next((i for i, b in enumerate(blocks) if b.get('id') == b_id), -1)
if idx > 0:
blocks[idx], blocks[idx-1] = blocks[idx-1], blocks[idx]
data['blocks'] = blocks
save_env_data(env_id, data)
flash(t_text('Przeniesiono blok wyżej.'), "success")
elif action == 'move_block_down':
b_id = request.form.get('block_id')
idx = next((i for i, b in enumerate(blocks) if b.get('id') == b_id), -1)
if idx != -1 and idx < len(blocks) - 1:
blocks[idx], blocks[idx+1] = blocks[idx+1], blocks[idx]
data['blocks'] = blocks
save_env_data(env_id, data)
flash(t_text('Przeniesiono blok niżej.'), "success")
elif action == 'add_category':
category_name = request.form.get('category_name', '').strip()
if category_name and category_name not in categories:
categories.append(category_name)
data['categories'] = categories
save_env_data(env_id, data)
flash(f"{t_text('Dodano kategorię:')} {category_name}", 'success')
elif not category_name:
flash(t_text('Nazwa kategorii nie może być pusta.'), 'error')
else:
flash(f"{t_text('Kategoria już istnieje:')} {category_name}", 'error')
elif action == 'delete_category':
category_to_delete = request.form.get('category_name')
if category_to_delete and category_to_delete in categories:
categories.remove(category_to_delete)
for product in products:
if product.get('category') == category_to_delete:
product['category'] = t_text('Bez kategorii')
data['categories'] = categories
data['products'] = products
save_env_data(env_id, data)
flash(f"{t_text('Usunięto kategorię:')} {category_to_delete}", 'success')
else:
flash(f"{t_text('Błąd usuwania:')} {category_to_delete}", 'error')
elif action == 'update_settings':
settings['admin_password_enabled'] = 'admin_password_enabled' in request.form
settings['admin_password'] = request.form.get('admin_password', '').strip()
settings['vcard_firstname'] = request.form.get('vcard_firstname', '').strip()
settings['vcard_lastname'] = request.form.get('vcard_lastname', '').strip()
settings['vcard_job'] = request.form.get('vcard_job', '').strip()
settings['organization_name'] = request.form.get('organization_name', '').strip()
settings['about_text'] = request.form.get('about_text', '').strip()
settings['currency_code'] = request.form.get('currency_code', 'PLN')
settings['color_scheme'] = request.form.get('color_scheme', 'default')
settings['enable_cart'] = 'enable_cart' in request.form
settings['order_messenger'] = request.form.get('order_messenger', 'whatsapp')
settings['order_contact'] = request.form.get('order_contact', '').strip()
avatar_file = request.files.get('chat_avatar')
if avatar_file and avatar_file.filename:
if HF_TOKEN_WRITE:
try:
api = HfApi()
old_avatar = settings.get('chat_avatar')
if old_avatar:
try: api.delete_files(repo_id=REPO_ID, paths_in_repo=[f"avatars/{old_avatar}"], repo_type="dataset", token=HF_TOKEN_WRITE)
except Exception: pass
ext = os.path.splitext(avatar_file.filename)[1].lower()
avatar_filename = f"avatar_{env_id}_{int(time.time())}{ext}"
uploads_dir = 'uploads_temp'
os.makedirs(uploads_dir, exist_ok=True)
temp_path = os.path.join(uploads_dir, avatar_filename)
avatar_file.save(temp_path)
api.upload_file(path_or_fileobj=temp_path, path_in_repo=f"avatars/{avatar_filename}", repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
settings['chat_avatar'] = avatar_filename
os.remove(temp_path)
flash(t_text("Awatar pomyślnie zaktualizowany."), 'success')
except Exception as e:
flash(f"{t_text('Błąd podczas ładowania awatara:')} {e}", 'error')
else:
pass
data['settings'] = settings
save_env_data(env_id, data)
flash(t_text("Zapisano ustawienia."), 'success')
elif action == 'add_product' or action == 'edit_product':
product_id = request.form.get('product_id')
product_data = {}
is_edit = action == 'edit_product'
if is_edit:
product_data = next((p for p in products if p.get('product_id') == product_id), None)
if not product_data:
flash(t_text("Błąd: nie znaleziono rekordu."), 'error')
return redirect(url_for('admin', env_id=env_id, p=page, q=search_q))
else:
product_data['views'] = 0
product_data['name'] = request.form.get('name', '').strip()
product_data['description'] = request.form.get('description', '').strip()
try:
product_data['price'] = float(request.form.get('price', 0))
except ValueError:
product_data['price'] = 0.0
category = request.form.get('category')
product_data['category'] = category if category in categories else t_text('Bez kategorii')
if not product_data['name']:
flash(t_text("Nazwa jest wymagana."), 'error')
return redirect(url_for('admin', env_id=env_id, p=page, q=search_q))
photos_files = request.files.getlist('photos')
if photos_files and any(f.filename for f in photos_files):
if HF_TOKEN_WRITE:
uploads_dir = 'uploads_temp'
os.makedirs(uploads_dir, exist_ok=True)
api = HfApi()
new_photos_list = []
photo_limit = 10
uploaded_count = 0
for photo in photos_files:
if uploaded_count >= photo_limit: break
if photo and photo.filename:
try:
ext = os.path.splitext(photo.filename)[1].lower()
if ext not in ['.jpg', '.jpeg', '.png', '.gif', '.webp']: continue
safe_name = secure_filename(product_data['name'].replace(' ', '_'))[:50]
photo_filename = f"{safe_name}_{uuid4().hex[:8]}{ext}"
temp_path = os.path.join(uploads_dir, photo_filename)
photo.save(temp_path)
api.upload_file(path_or_fileobj=temp_path, path_in_repo=f"photos/{photo_filename}", repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
new_photos_list.append(photo_filename)
os.remove(temp_path)
uploaded_count += 1
except Exception as e:
flash(f"{t_text('Błąd:')} {e}", 'error')
if new_photos_list and is_edit and product_data.get('photos'):
try: api.delete_files(repo_id=REPO_ID, paths_in_repo=[f"photos/{p}" for p in product_data['photos']], repo_type="dataset", token=HF_TOKEN_WRITE)
except Exception: pass
if new_photos_list:
product_data['photos'] = new_photos_list
else:
pass
if is_edit:
product_index = next((i for i, p in enumerate(products) if p.get('product_id') == product_id), -1)
if product_index != -1:
products[product_index] = product_data
flash(f"{t_text('Zaktualizowano pomyślnie.')} '{product_data['name']}'", 'success')
else:
product_data['product_id'] = uuid4().hex
products.append(product_data)
flash(f"{t_text('Zapisano:')} '{product_data['name']}'", 'success')
data['products'] = products
save_env_data(env_id, data)
elif action == 'delete_product':
product_id = request.form.get('product_id')
product_index = next((i for i, p in enumerate(products) if p.get('product_id') == product_id), -1)
if product_index == -1:
flash(t_text("Błąd usuwania: nie znaleziono rekordu."), 'error')
return redirect(url_for('admin', env_id=env_id, p=page, q=search_q))
deleted_product = products.pop(product_index)
product_name = deleted_product.get('name', 'N/A')
photos_to_delete = deleted_product.get('photos', [])
if photos_to_delete and HF_TOKEN_WRITE:
try:
api = HfApi()
api.delete_files(repo_id=REPO_ID, paths_in_repo=[f"photos/{p}" for p in photos_to_delete], repo_type="dataset", token=HF_TOKEN_WRITE)
except Exception: pass
data['products'] = products
save_env_data(env_id, data)
flash(f"{t_text('Usunięto rekord:')} '{product_name}'", 'success')
else:
flash(f"{t_text('Nieznana akcja:')} {action}", 'warning')
return redirect(url_for('admin', env_id=env_id, p=page, q=search_q))
except Exception as e:
flash(f"{t_text('Błąd wykonania akcji:')} {e}", 'error')
return redirect(url_for('admin', env_id=env_id, p=page, q=search_q))
filtered_products = products
if search_q:
q_lower = search_q.lower()
filtered_products = [p for p in products if q_lower in p.get('name', '').lower() or q_lower in p.get('description', '').lower()]
filtered_products = sorted(filtered_products, key=lambda p: p.get('name', '').lower())
PER_PAGE = 20
total_items = len(filtered_products)
total_pages = math.ceil(total_items / PER_PAGE) if total_items > 0 else 1
if page < 1: page = 1
if page > total_pages: page = total_pages
start_idx = (page - 1) * PER_PAGE
end_idx = start_idx + PER_PAGE
paginated_products = filtered_products[start_idx:end_idx]
display_categories = sorted(categories)
display_settings = settings
chat_avatar_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/avatars/{display_settings['chat_avatar']}" if display_settings.get('chat_avatar') else "https://huggingface.co/spaces/gippo312/admin/resolve/main/Picsart_25-11-04_12-02-21-390.png"
new_orders_count = sum(1 for o in data.get('orders', []) if not o.get('viewed', False))
return render_template_string(
ADMIN_TEMPLATE, paginated_products=paginated_products, total_pages=total_pages, page=page, search_q=search_q, categories=display_categories,
settings=display_settings, blocks=blocks, repo_id=REPO_ID, currency_code=display_settings.get('currency_code', 'PLN'), chat_avatar_url=chat_avatar_url,
currencies=CURRENCIES, color_schemes=COLOR_SCHEMES, icons=ICONS, env_id=env_id, new_orders_count=new_orders_count, current_lang=get_lang()
)
@app.route('/<env_id>/admin/orders')
def admin_orders(env_id):
data = get_env_data(env_id)
settings = data.get('settings', {})
if settings.get('admin_password_enabled') and not session.get(f'admin_auth_{env_id}'):
return redirect(url_for('admin_login', env_id=env_id))
orders = data.get('orders', [])
changed = False
for o in orders:
if not o.get('viewed', False):
o['viewed'] = True
changed = True
if changed:
save_env_data(env_id, data)
orders_sorted = sorted(orders, key=lambda x: x.get('date', ''), reverse=True)
return render_template_string(ADMIN_ORDERS_TEMPLATE, orders=orders_sorted, env_id=env_id, settings=settings, currency=settings.get('currency_code', 'PLN'), current_lang=get_lang())
@app.route('/<env_id>/admin/orders/delete/<order_id>', methods=['POST'])
def delete_order(env_id, order_id):
data = get_env_data(env_id)
settings = data.get('settings', {})
if settings.get('admin_password_enabled') and not session.get(f'admin_auth_{env_id}'):
return redirect(url_for('admin_login', env_id=env_id))
orders = data.get('orders', [])
data['orders'] = [o for o in orders if o.get('id') != order_id]
save_env_data(env_id, data)
flash(t_text("Usunięto zamówienie z historii."), "success")
return redirect(url_for('admin_orders', env_id=env_id))
@app.route('/generate_description_ai', methods=['POST'])
def handle_generate_description_ai():
request_data = request.get_json()
base64_image = request_data.get('image')
language = request_data.get('language', 'Polski')
if not base64_image: return jsonify({"error": t_text("Błąd: Nie znaleziono pliku obrazu.")}), 400
try:
image_data = base64.b64decode(base64_image)
result_text = generate_ai_description_from_image(image_data, language)
return jsonify({"text": result_text})
except ValueError as ve: return jsonify({"error": str(ve)}), 400
except Exception as e: return jsonify({"error": f"{t_text('Błąd:')} {e}"}), 500
if __name__ == '__main__':
configure_gemini()
download_db_from_hf()
load_data()
if HF_TOKEN_WRITE:
backup_thread = threading.Thread(target=periodic_backup, daemon=True)
backup_thread.start()
port = int(os.environ.get('PORT', 7860))
app.run(debug=False, host='0.0.0.0', port=port)