| 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">«</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">»</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) |
|
|