Refactor email system and add contact form backend
Refactored email sending to use a single HTML template with a base layout, removed plain text email templates, and updated all related backend logic. Introduced a new ContactMe model, serializer, Celery task, and API endpoints for handling contact form submissions, including email notifications. Renamed ShopConfiguration to SiteConfiguration throughout the backend for consistency. Updated frontend to remove unused components, add a new Services section, and adjust navigation and contact form integration.
This commit is contained in:
@@ -10,34 +10,25 @@ from .models import CustomUser
|
|||||||
|
|
||||||
logger = get_task_logger(__name__)
|
logger = get_task_logger(__name__)
|
||||||
|
|
||||||
# TODO: předělat funkci ať funguje s base.html šablonou na emaily !!!
|
def send_email_with_context(recipients, subject, template_path=None, context=None, message: str | None = None):
|
||||||
def send_email_with_context(recipients, subject, message=None, template_name=None, html_template_name=None, context=None):
|
|
||||||
"""
|
"""
|
||||||
General function to send emails with a specific context.
|
Send emails rendering a single HTML template.
|
||||||
Supports rendering plain text and HTML templates.
|
- `template_name` is a simple base name without extension, e.g. "email/test".
|
||||||
Converts `user` in context to a plain dict to avoid template access to the model.
|
- Renders only HTML (".html"), no ".txt" support.
|
||||||
|
- Converts `user` in context to a plain dict to avoid passing models to templates.
|
||||||
"""
|
"""
|
||||||
if isinstance(recipients, str):
|
if isinstance(recipients, str):
|
||||||
recipients = [recipients]
|
recipients = [recipients]
|
||||||
|
|
||||||
html_message = None
|
html_message = None
|
||||||
if template_name or html_template_name:
|
if template_path:
|
||||||
# Best effort to resolve both templates if only one provided
|
|
||||||
if not template_name and html_template_name:
|
|
||||||
template_name = html_template_name.replace(".html", ".txt")
|
|
||||||
if not html_template_name and template_name:
|
|
||||||
html_template_name = template_name.replace(".txt", ".html")
|
|
||||||
|
|
||||||
ctx = dict(context or {})
|
ctx = dict(context or {})
|
||||||
# Sanitize user if someone passes the model by mistake
|
# Render base layout and include the provided template as the main content.
|
||||||
if "user" in ctx and not isinstance(ctx["user"], dict):
|
# The included template receives the same context as the base.
|
||||||
try:
|
html_message = render_to_string(
|
||||||
ctx["user"] = _build_user_template_ctx(ctx["user"])
|
"email/components/base.html",
|
||||||
except Exception:
|
{"content_template": template_path, **ctx},
|
||||||
ctx["user"] = {}
|
)
|
||||||
|
|
||||||
message = render_to_string(template_name, ctx)
|
|
||||||
html_message = render_to_string(html_template_name, ctx)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
send_mail(
|
send_mail(
|
||||||
@@ -48,33 +39,13 @@ def send_email_with_context(recipients, subject, message=None, template_name=Non
|
|||||||
fail_silently=False,
|
fail_silently=False,
|
||||||
html_message=html_message,
|
html_message=html_message,
|
||||||
)
|
)
|
||||||
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
|
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend' and message:
|
||||||
logger.debug(f"\nEMAIL OBSAH:\n{message}\nKONEC OBSAHU")
|
logger.debug(f"\nEMAIL OBSAH:\n{message}\nKONEC OBSAHU")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"E-mail se neodeslal: {e}")
|
logger.error(f"E-mail se neodeslal: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _build_user_template_ctx(user: CustomUser) -> dict:
|
|
||||||
"""
|
|
||||||
Return a plain dict for templates instead of passing the DB model.
|
|
||||||
Provides aliases to avoid template errors (firstname vs first_name).
|
|
||||||
Adds a backward-compatible key 'get_full_name' for templates using `user.get_full_name`.
|
|
||||||
"""
|
|
||||||
first_name = getattr(user, "first_name", "") or ""
|
|
||||||
last_name = getattr(user, "last_name", "") or ""
|
|
||||||
full_name = f"{first_name} {last_name}".strip()
|
|
||||||
return {
|
|
||||||
"id": user.pk,
|
|
||||||
"email": getattr(user, "email", "") or "",
|
|
||||||
"first_name": first_name,
|
|
||||||
"firstname": first_name, # alias for templates using `firstname`
|
|
||||||
"last_name": last_name,
|
|
||||||
"lastname": last_name, # alias for templates using `lastname`
|
|
||||||
"full_name": full_name,
|
|
||||||
"get_full_name": full_name, # compatibility for templates using method-style access
|
|
||||||
}
|
|
||||||
|
|
||||||
#----------------------------------------------------------------------------------------------------
|
#----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -93,7 +64,7 @@ def send_email_verification_task(user_id):
|
|||||||
verify_url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}"
|
verify_url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}"
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"user": _build_user_template_ctx(user),
|
"user": user,
|
||||||
"action_url": verify_url,
|
"action_url": verify_url,
|
||||||
"frontend_url": settings.FRONTEND_URL,
|
"frontend_url": settings.FRONTEND_URL,
|
||||||
"cta_label": "Ověřit e‑mail",
|
"cta_label": "Ověřit e‑mail",
|
||||||
@@ -102,8 +73,7 @@ def send_email_verification_task(user_id):
|
|||||||
send_email_with_context(
|
send_email_with_context(
|
||||||
recipients=user.email,
|
recipients=user.email,
|
||||||
subject="Ověření e‑mailu",
|
subject="Ověření e‑mailu",
|
||||||
template_name="email/email_verification.txt",
|
template_path="email/email_verification.html",
|
||||||
html_template_name="email/email_verification.html",
|
|
||||||
context=context,
|
context=context,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -119,8 +89,7 @@ def send_email_test_task(email):
|
|||||||
send_email_with_context(
|
send_email_with_context(
|
||||||
recipients=email,
|
recipients=email,
|
||||||
subject="Testovací e‑mail",
|
subject="Testovací e‑mail",
|
||||||
template_name="email/test.txt",
|
template_path="email/test.html",
|
||||||
html_template_name="email/test.html",
|
|
||||||
context=context,
|
context=context,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -138,7 +107,7 @@ def send_password_reset_email_task(user_id):
|
|||||||
reset_url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}"
|
reset_url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}"
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"user": _build_user_template_ctx(user),
|
"user": user,
|
||||||
"action_url": reset_url,
|
"action_url": reset_url,
|
||||||
"frontend_url": settings.FRONTEND_URL,
|
"frontend_url": settings.FRONTEND_URL,
|
||||||
"cta_label": "Obnovit heslo",
|
"cta_label": "Obnovit heslo",
|
||||||
@@ -147,7 +116,6 @@ def send_password_reset_email_task(user_id):
|
|||||||
send_email_with_context(
|
send_email_with_context(
|
||||||
recipients=user.email,
|
recipients=user.email,
|
||||||
subject="Obnova hesla",
|
subject="Obnova hesla",
|
||||||
template_name="email/password_reset.txt",
|
template_path="email/password_reset.html",
|
||||||
html_template_name="email/password_reset.html",
|
|
||||||
context=context,
|
context=context,
|
||||||
)
|
)
|
||||||
@@ -1,46 +1,21 @@
|
|||||||
<!doctype html>
|
<h1 style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px; margin:0;">Ověření e‑mailu</h1>
|
||||||
<html lang="cs">
|
|
||||||
<body style="margin:0; padding:0; background-color:#f5f7fb;">
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f5f7fb;">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="padding:24px;">
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; background-color:#ffffff; border:1px solid #e5e7eb;">
|
|
||||||
<tr>
|
|
||||||
<td style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px;">
|
|
||||||
Ověření e‑mailu
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
|
|
||||||
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}
|
|
||||||
<p style="margin:0 0 12px 0;">Dobrý den{% if name %} {{ name }}{% endif %},</p>
|
|
||||||
{% endwith %}
|
|
||||||
<p style="margin:0 0 16px 0;">Děkujeme za registraci. Prosíme, ověřte svou e‑mailovou adresu kliknutím na tlačítko níže.</p>
|
|
||||||
|
|
||||||
{% if action_url and cta_label %}
|
<div style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
|
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}
|
||||||
<tr>
|
<p style="margin:0 0 12px 0;">Dobrý den{% if name %} {{ name }}{% endif %},</p>
|
||||||
<td bgcolor="#2563eb" style="border-radius:6px;">
|
{% endwith %}
|
||||||
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
|
<p style="margin:0 0 16px 0;">Děkujeme za registraci. Prosíme, ověřte svou e‑mailovou adresu kliknutím na tlačítko níže.</p>
|
||||||
{{ cta_label }}
|
|
||||||
</a>
|
{% if action_url and cta_label %}
|
||||||
</td>
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
|
||||||
</tr>
|
<tr>
|
||||||
</table>
|
<td bgcolor="#2563eb" style="border-radius:6px;">
|
||||||
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
|
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
|
||||||
{% endif %}
|
{{ cta_label }}
|
||||||
</td>
|
</a>
|
||||||
</tr>
|
</td>
|
||||||
</table>
|
</tr>
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; margin-top:12px;">
|
</table>
|
||||||
<tr>
|
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
|
||||||
<td align="center" style="font-family:Arial, Helvetica, sans-serif; font-size:12px; color:#6b7280;">
|
{% endif %}
|
||||||
Tento e‑mail byl odeslán z aplikace e‑tržnice.
|
</div>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}Dobrý den{% if name %} {{ name }}{% endif %},{% endwith %}
|
|
||||||
|
|
||||||
Děkujeme za registraci. Prosíme, ověřte svou e‑mailovou adresu kliknutím na následující odkaz:
|
|
||||||
|
|
||||||
{{ action_url }}
|
|
||||||
|
|
||||||
Pokud jste účet nevytvořili vy, tento e‑mail ignorujte.
|
|
||||||
@@ -1,46 +1,21 @@
|
|||||||
<!doctype html>
|
<h1 style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px; margin:0;">Obnova hesla</h1>
|
||||||
<html lang="cs">
|
|
||||||
<body style="margin:0; padding:0; background-color:#f5f7fb;">
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f5f7fb;">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="padding:24px;">
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; background-color:#ffffff; border:1px solid #e5e7eb;">
|
|
||||||
<tr>
|
|
||||||
<td style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px;">
|
|
||||||
Obnova hesla
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
|
|
||||||
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}
|
|
||||||
<p style="margin:0 0 12px 0;">Dobrý den{% if name %} {{ name }}{% endif %},</p>
|
|
||||||
{% endwith %}
|
|
||||||
<p style="margin:0 0 12px 0;">Obdrželi jste tento e‑mail, protože byla požádána obnova hesla k vašemu účtu. Pokud jste o změnu nepožádali, tento e‑mail ignorujte.</p>
|
|
||||||
|
|
||||||
{% if action_url and cta_label %}
|
<div style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
|
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}
|
||||||
<tr>
|
<p style="margin:0 0 12px 0;">Dobrý den{% if name %} {{ name }}{% endif %},</p>
|
||||||
<td bgcolor="#2563eb" style="border-radius:6px;">
|
{% endwith %}
|
||||||
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
|
<p style="margin:0 0 12px 0;">Obdrželi jste tento e‑mail, protože byla požádána obnova hesla k vašemu účtu. Pokud jste o změnu nepožádali, tento e‑mail ignorujte.</p>
|
||||||
{{ cta_label }}
|
|
||||||
</a>
|
{% if action_url and cta_label %}
|
||||||
</td>
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
|
||||||
</tr>
|
<tr>
|
||||||
</table>
|
<td bgcolor="#2563eb" style="border-radius:6px;">
|
||||||
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
|
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
|
||||||
{% endif %}
|
{{ cta_label }}
|
||||||
</td>
|
</a>
|
||||||
</tr>
|
</td>
|
||||||
</table>
|
</tr>
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; margin-top:12px;">
|
</table>
|
||||||
<tr>
|
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
|
||||||
<td align="center" style="font-family:Arial, Helvetica, sans-serif; font-size:12px; color:#6b7280;">
|
{% endif %}
|
||||||
Tento e‑mail byl odeslán z aplikace e‑tržnice.
|
</div>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}Dobrý den{% if name %} {{ name }}{% endif %},{% endwith %}
|
|
||||||
|
|
||||||
Obdrželi jste tento e‑mail, protože byla požádána obnova hesla k vašemu účtu.
|
|
||||||
Pokud jste o změnu nepožádali, tento e‑mail ignorujte.
|
|
||||||
|
|
||||||
Pro nastavení nového hesla použijte tento odkaz:
|
|
||||||
{{ action_url }}
|
|
||||||
@@ -1,44 +1,19 @@
|
|||||||
<!doctype html>
|
<h1 style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px; margin:0;">Testovací e‑mail</h1>
|
||||||
<html lang="cs">
|
|
||||||
<body style="margin:0; padding:0; background-color:#f5f7fb;">
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f5f7fb;">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="padding:24px;">
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; background-color:#ffffff; border:1px solid #e5e7eb;">
|
|
||||||
<tr>
|
|
||||||
<td style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px;">
|
|
||||||
Testovací e‑mail
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
|
|
||||||
<p style="margin:0 0 12px 0;">Dobrý den,</p>
|
|
||||||
<p style="margin:0 0 16px 0;">Toto je testovací e‑mail z aplikace e‑tržnice.</p>
|
|
||||||
|
|
||||||
{% if action_url and cta_label %}
|
<div style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
|
<p style="margin:0 0 12px 0;">Dobrý den,</p>
|
||||||
<tr>
|
<p style="margin:0 0 16px 0;">Toto je testovací e‑mail z aplikace e‑tržnice.</p>
|
||||||
<td bgcolor="#2563eb" style="border-radius:6px;">
|
|
||||||
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
|
{% if action_url and cta_label %}
|
||||||
{{ cta_label }}
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
|
||||||
</a>
|
<tr>
|
||||||
</td>
|
<td bgcolor="#2563eb" style="border-radius:6px;">
|
||||||
</tr>
|
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
|
||||||
</table>
|
{{ cta_label }}
|
||||||
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
|
</a>
|
||||||
{% endif %}
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
</table>
|
||||||
</table>
|
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; margin-top:12px;">
|
{% endif %}
|
||||||
<tr>
|
</div>
|
||||||
<td align="center" style="font-family:Arial, Helvetica, sans-serif; font-size:12px; color:#6b7280;">
|
|
||||||
Tento e‑mail byl odeslán z aplikace e‑tržnice.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
Dobrý den,
|
|
||||||
|
|
||||||
Toto je testovací e‑mail z aplikace e‑tržnice.
|
|
||||||
|
|
||||||
Odkaz na aplikaci:
|
|
||||||
{{ action_url }}
|
|
||||||
@@ -1,3 +1,14 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
# Create your models here.
|
# Create your models here.
|
||||||
|
|
||||||
|
class ContactMe(models.Model):
|
||||||
|
client_email = models.EmailField()
|
||||||
|
content = models.TextField()
|
||||||
|
|
||||||
|
sent_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Email to {self.client_email} sent at {self.sent_at}"
|
||||||
|
|
||||||
|
|
||||||
9
backend/advertisement/serializer.py
Normal file
9
backend/advertisement/serializer.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from .models import ContactMe
|
||||||
|
|
||||||
|
|
||||||
|
class ContactMeSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ContactMe
|
||||||
|
fields = ["id", "client_email", "content", "sent_at"]
|
||||||
|
read_only_fields = ["id", "sent_at"]
|
||||||
@@ -1,2 +1,17 @@
|
|||||||
#udělat zasílaní reklamních emailů uživatelům.
|
from account.tasks import send_email_with_context
|
||||||
#newletter --> když se vytvoří nový record s reklamou email se uloží pomocí zaškrtnutí tlačítka v záznamu
|
from configuration.models import SiteConfiguration
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_contact_me_email_task(client_email, message_content):
|
||||||
|
context = {
|
||||||
|
"client_email": client_email,
|
||||||
|
"message_content": message_content
|
||||||
|
}
|
||||||
|
send_email_with_context(
|
||||||
|
recipients=SiteConfiguration.get_solo().contact_email,
|
||||||
|
subject="Poptávka z kontaktního formuláře!!!",
|
||||||
|
template_path="email/contact_me.html",
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
6
backend/advertisement/templates/email/contact_me.html
Normal file
6
backend/advertisement/templates/email/contact_me.html
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<h2 style="margin:0 0 12px 0; font-family:Arial, Helvetica, sans-serif;">Nová zpráva z kontaktního formuláře</h2>
|
||||||
|
<div style="border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; font-family:Arial, Helvetica, sans-serif;">
|
||||||
|
<p><span style="font-weight:600;">Email odesílatele:</span> {{ client_email }}</p>
|
||||||
|
<p style="font-weight:600;">Zpráva:</p>
|
||||||
|
<pre style="white-space: pre-wrap; word-wrap: break-word;">{{ message_content }}</pre>
|
||||||
|
</div>
|
||||||
15
backend/advertisement/urls.py
Normal file
15
backend/advertisement/urls.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from .views import ContactMePublicView, ContactMeAdminViewSet
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r"contact-messages", ContactMeAdminViewSet, basename="contactme")
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
# Public endpoint
|
||||||
|
path("contact-me/", ContactMePublicView.as_view(), name="contact-me"),
|
||||||
|
|
||||||
|
# Admin endpoints
|
||||||
|
path("", include(router.urls)),
|
||||||
|
]
|
||||||
@@ -1,3 +1,46 @@
|
|||||||
from django.shortcuts import render
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import status, viewsets
|
||||||
|
from rest_framework.permissions import AllowAny, IsAdminUser
|
||||||
|
from rest_framework.authentication import SessionAuthentication
|
||||||
|
|
||||||
|
from .models import ContactMe
|
||||||
|
from .serializer import ContactMeSerializer
|
||||||
|
from .tasks import send_contact_me_email_task
|
||||||
|
|
||||||
|
|
||||||
|
class ContactMePublicView(APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
# Avoid CSRF for public endpoint by disabling SessionAuthentication
|
||||||
|
authentication_classes = []
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
email = request.data.get("email")
|
||||||
|
message = request.data.get("message")
|
||||||
|
honeypot = request.data.get("hp") # hidden honeypot field
|
||||||
|
|
||||||
|
# If honeypot is filled, pretend success without processing
|
||||||
|
if honeypot:
|
||||||
|
return Response({"status": "ok"}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
if not email or not message:
|
||||||
|
return Response({"detail": "Missing email or message."}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Save to DB
|
||||||
|
cm = ContactMe.objects.create(client_email=email, content=message)
|
||||||
|
|
||||||
|
# Send email via Celery task
|
||||||
|
try:
|
||||||
|
send_contact_me_email_task.delay(email, message)
|
||||||
|
except Exception:
|
||||||
|
# Fallback to direct call if Celery is not running in DEV
|
||||||
|
send_contact_me_email_task(email, message)
|
||||||
|
|
||||||
|
return Response({"id": cm.id, "status": "queued"}, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactMeAdminViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = ContactMe.objects.all().order_by("-sent_at")
|
||||||
|
serializer_class = ContactMeSerializer
|
||||||
|
permission_classes = [IsAdminUser]
|
||||||
|
|
||||||
# Create your views here.
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator
|
|||||||
from weasyprint import HTML
|
from weasyprint import HTML
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from configuration.models import ShopConfiguration
|
from configuration.models import SiteConfiguration
|
||||||
|
|
||||||
from thirdparty.zasilkovna.models import ZasilkovnaPacket
|
from thirdparty.zasilkovna.models import ZasilkovnaPacket
|
||||||
from thirdparty.stripe.models import StripeModel
|
from thirdparty.stripe.models import StripeModel
|
||||||
@@ -240,7 +240,7 @@ class Carrier(models.Model):
|
|||||||
|
|
||||||
def get_price(self):
|
def get_price(self):
|
||||||
if self.shipping_method == self.SHIPPING.ZASILKOVNA:
|
if self.shipping_method == self.SHIPPING.ZASILKOVNA:
|
||||||
return ShopConfiguration.get_solo().zasilkovna_shipping_price
|
return SiteConfiguration.get_solo().zasilkovna_shipping_price
|
||||||
else:
|
else:
|
||||||
return Decimal('0.0')
|
return Decimal('0.0')
|
||||||
|
|
||||||
@@ -285,10 +285,10 @@ class Carrier(models.Model):
|
|||||||
|
|
||||||
class Payment(models.Model):
|
class Payment(models.Model):
|
||||||
class PAYMENT(models.TextChoices):
|
class PAYMENT(models.TextChoices):
|
||||||
SHOP = "shop", "cz#Platba v obchodě"
|
Site = "Site", "cz#Platba v obchodě"
|
||||||
STRIPE = "stripe", "cz#Bankovní převod"
|
STRIPE = "stripe", "cz#Bankovní převod"
|
||||||
CASH_ON_DELIVERY = "cash_on_delivery", "cz#Dobírka"
|
CASH_ON_DELIVERY = "cash_on_delivery", "cz#Dobírka"
|
||||||
payment_method = models.CharField(max_length=30, choices=PAYMENT.choices, default=PAYMENT.SHOP)
|
payment_method = models.CharField(max_length=30, choices=PAYMENT.choices, default=PAYMENT.Site)
|
||||||
|
|
||||||
#FIXME: potvrdit že logika platby funguje správně
|
#FIXME: potvrdit že logika platby funguje správně
|
||||||
#veškera logika a interakce bude na stripu (třeba aktualizovaní objednávky na zaplacenou apod.)
|
#veškera logika a interakce bude na stripu (třeba aktualizovaní objednávky na zaplacenou apod.)
|
||||||
@@ -360,7 +360,7 @@ class OrderItem(models.Model):
|
|||||||
def get_total_price(self, discounts: list[DiscountCode] = None):
|
def get_total_price(self, discounts: list[DiscountCode] = None):
|
||||||
"""Vrátí celkovou cenu položky po aplikaci relevantních kupónů.
|
"""Vrátí celkovou cenu položky po aplikaci relevantních kupónů.
|
||||||
|
|
||||||
Logika dle ShopConfiguration:
|
Logika dle SiteConfiguration:
|
||||||
- multiplying_coupons=True: procentuální slevy se násobí (sekvenčně)
|
- multiplying_coupons=True: procentuální slevy se násobí (sekvenčně)
|
||||||
P * (1 - p1) -> výsledné * (1 - p2) ...
|
P * (1 - p1) -> výsledné * (1 - p2) ...
|
||||||
jinak se použije pouze nejlepší (nejvyšší procento).
|
jinak se použije pouze nejlepší (nejvyšší procento).
|
||||||
@@ -375,7 +375,7 @@ class OrderItem(models.Model):
|
|||||||
return base_price
|
return base_price
|
||||||
|
|
||||||
|
|
||||||
config = ShopConfiguration.get_solo()
|
config = SiteConfiguration.get_solo()
|
||||||
|
|
||||||
#seznám slev
|
#seznám slev
|
||||||
applicable_percent_discounts: list[int] = []
|
applicable_percent_discounts: list[int] = []
|
||||||
|
|||||||
@@ -6,14 +6,13 @@ class ConfigurationConfig(AppConfig):
|
|||||||
name = 'configuration'
|
name = 'configuration'
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
"""Ensure the ShopConfiguration singleton exists at startup.
|
"""Ensure the SiteConfiguration singleton exists at startup.
|
||||||
|
|
||||||
Wrapped in broad DB error handling so that commands like
|
Wrapped in broad DB error handling so that commands like
|
||||||
makemigrations/migrate don't fail when the table does not yet exist.
|
makemigrations/migrate don't fail when the table does not yet exist.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from .models import ShopConfiguration # local import to avoid premature app registry access
|
from .models import SiteConfiguration # local import to avoid premature app registry access
|
||||||
ShopConfiguration.get_solo() # creates if missing
|
SiteConfiguration.get_solo() # creates if missing
|
||||||
|
|
||||||
except (OperationalError, ProgrammingError):
|
except (OperationalError, ProgrammingError):
|
||||||
# DB not ready (e.g., before initial migrate); ignore silently
|
# DB not ready (e.g., before initial migrate); ignore silently
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from django.db import models
|
|||||||
|
|
||||||
# Create your models here.
|
# Create your models here.
|
||||||
|
|
||||||
class ShopConfiguration(models.Model):
|
class SiteConfiguration(models.Model):
|
||||||
name = models.CharField(max_length=100, default="Shop name", unique=True)
|
name = models.CharField(max_length=100, default="Shop name", unique=True)
|
||||||
|
|
||||||
logo = models.ImageField(upload_to='shop_logos/', blank=True, null=True)
|
logo = models.ImageField(upload_to='shop_logos/', blank=True, null=True)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from .models import ShopConfiguration
|
from .models import SiteConfiguration
|
||||||
|
|
||||||
|
|
||||||
class ShopConfigurationAdminSerializer(serializers.ModelSerializer):
|
class SiteConfigurationAdminSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ShopConfiguration
|
model = SiteConfiguration
|
||||||
fields = [
|
fields = [
|
||||||
"id",
|
"id",
|
||||||
"name",
|
"name",
|
||||||
@@ -29,9 +29,9 @@ class ShopConfigurationAdminSerializer(serializers.ModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class ShopConfigurationPublicSerializer(serializers.ModelSerializer):
|
class SiteConfigurationPublicSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ShopConfiguration
|
model = SiteConfiguration
|
||||||
# Expose only non-sensitive fields
|
# Expose only non-sensitive fields
|
||||||
fields = [
|
fields = [
|
||||||
"id",
|
"id",
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
from .views import ShopConfigurationAdminViewSet, ShopConfigurationPublicViewSet
|
from .views import SiteConfigurationAdminViewSet, SiteConfigurationPublicViewSet
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register(r"admin/shop-configuration", ShopConfigurationAdminViewSet, basename="shop-config-admin")
|
router.register(r"admin/shop-configuration", SiteConfigurationAdminViewSet, basename="shop-config-admin")
|
||||||
router.register(r"public/shop-configuration", ShopConfigurationPublicViewSet, basename="shop-config-public")
|
router.register(r"public/shop-configuration", SiteConfigurationPublicViewSet, basename="shop-config-public")
|
||||||
|
|
||||||
urlpatterns = router.urls
|
urlpatterns = router.urls
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
from rest_framework import viewsets, mixins
|
from rest_framework import viewsets, mixins
|
||||||
from rest_framework.permissions import IsAdminUser, AllowAny
|
from rest_framework.permissions import IsAdminUser, AllowAny
|
||||||
from .models import ShopConfiguration
|
from .models import SiteConfiguration
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
ShopConfigurationAdminSerializer,
|
SiteConfigurationAdminSerializer,
|
||||||
ShopConfigurationPublicSerializer,
|
SiteConfigurationPublicSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class _SingletonQuerysetMixin:
|
class _SingletonQuerysetMixin:
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return ShopConfiguration.objects.filter(pk=1)
|
return SiteConfiguration.objects.filter(pk=1)
|
||||||
|
|
||||||
def get_object(self):
|
def get_object(self):
|
||||||
return ShopConfiguration.get_solo()
|
return SiteConfiguration.get_solo()
|
||||||
|
|
||||||
|
|
||||||
class ShopConfigurationAdminViewSet(_SingletonQuerysetMixin, viewsets.ModelViewSet):
|
class SiteConfigurationAdminViewSet(_SingletonQuerysetMixin, viewsets.ModelViewSet):
|
||||||
permission_classes = [IsAdminUser]
|
permission_classes = [IsAdminUser]
|
||||||
serializer_class = ShopConfigurationAdminSerializer
|
serializer_class = SiteConfigurationAdminSerializer
|
||||||
|
|
||||||
|
|
||||||
class ShopConfigurationPublicViewSet(_SingletonQuerysetMixin, viewsets.ReadOnlyModelViewSet):
|
class SiteConfigurationPublicViewSet(_SingletonQuerysetMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
serializer_class = ShopConfigurationPublicSerializer
|
serializer_class = SiteConfigurationPublicSerializer
|
||||||
6
backend/thirdparty/zasilkovna/models.py
vendored
6
backend/thirdparty/zasilkovna/models.py
vendored
@@ -26,7 +26,7 @@ from rest_framework.exceptions import ValidationError
|
|||||||
|
|
||||||
from .client import PacketaAPI
|
from .client import PacketaAPI
|
||||||
|
|
||||||
from configuration.models import ShopConfiguration
|
from configuration.models import SiteConfiguration
|
||||||
|
|
||||||
packeta_client = PacketaAPI() # single reusable instance
|
packeta_client = PacketaAPI() # single reusable instance
|
||||||
|
|
||||||
@@ -103,8 +103,8 @@ class ZasilkovnaPacket(models.Model):
|
|||||||
cod=order.total_price if cash_on_delivery else 0, # dobírka
|
cod=order.total_price if cash_on_delivery else 0, # dobírka
|
||||||
value=order.total_price,
|
value=order.total_price,
|
||||||
|
|
||||||
currency=ShopConfiguration.get_solo().currency, #CZK
|
currency=SiteConfiguration.get_solo().currency, #CZK
|
||||||
eshop= ShopConfiguration.get_solo().name,
|
eSite= SiteConfiguration.get_solo().name,
|
||||||
)
|
)
|
||||||
self.packet_id = response['packet_id']
|
self.packet_id = response['packet_id']
|
||||||
self.barcode = response['barcode']
|
self.barcode = response['barcode']
|
||||||
|
|||||||
4
backend/thirdparty/zasilkovna/views.py
vendored
4
backend/thirdparty/zasilkovna/views.py
vendored
@@ -5,7 +5,7 @@ from django.template import loader
|
|||||||
|
|
||||||
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiResponse, OpenApiParameter, OpenApiTypes
|
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiResponse, OpenApiParameter, OpenApiTypes
|
||||||
|
|
||||||
from backend.configuration.models import ShopConfiguration
|
from backend.configuration.models import SiteConfiguration
|
||||||
|
|
||||||
from .models import ZasilkovnaShipment, ZasilkovnaPacket
|
from .models import ZasilkovnaShipment, ZasilkovnaPacket
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
@@ -135,7 +135,7 @@ class ZasilkovnaPacketViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet
|
|||||||
widget_html = loader.render_to_string(
|
widget_html = loader.render_to_string(
|
||||||
"zasilkovna/pickup_point_widget.html",
|
"zasilkovna/pickup_point_widget.html",
|
||||||
{
|
{
|
||||||
"api_key": ShopConfiguration.get_solo().zasilkovna_widget_api_key,
|
"api_key": SiteConfiguration.get_solo().zasilkovna_widget_api_key,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ urlpatterns = [
|
|||||||
path('api/account/', include('account.urls')),
|
path('api/account/', include('account.urls')),
|
||||||
path('api/commerce/', include('commerce.urls')),
|
path('api/commerce/', include('commerce.urls')),
|
||||||
path('api/configuration/', include('configuration.urls')),
|
path('api/configuration/', include('configuration.urls')),
|
||||||
#path('api/advertisments/', include('advertisements.urls')),
|
path('api/advertisement/', include('advertisement.urls')),
|
||||||
|
|
||||||
path('api/stripe/', include('thirdparty.stripe.urls')),
|
path('api/stripe/', include('thirdparty.stripe.urls')),
|
||||||
path('api/trading212/', include('thirdparty.trading212.urls')),
|
path('api/trading212/', include('thirdparty.trading212.urls')),
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import PrivateRoute from "./routes/PrivateRoute";
|
|||||||
|
|
||||||
// Pages
|
// Pages
|
||||||
import PortfolioPage from "./pages/portfolio/PortfolioPage";
|
import PortfolioPage from "./pages/portfolio/PortfolioPage";
|
||||||
import HostingSecurityPage from "./pages/hosting/HostingSecurityPage";
|
|
||||||
import ContactPage from "./pages/contact/ContactPage";
|
import ContactPage from "./pages/contact/ContactPage";
|
||||||
import ScrollToTop from "./components/common/ScrollToTop";
|
import ScrollToTop from "./components/common/ScrollToTop";
|
||||||
|
|
||||||
@@ -21,7 +20,6 @@ export default function App() {
|
|||||||
<Route path="/" element={<HomeLayout />}>
|
<Route path="/" element={<HomeLayout />}>
|
||||||
<Route index element={<Home />} />
|
<Route index element={<Home />} />
|
||||||
<Route path="portfolio" element={<PortfolioPage />} />
|
<Route path="portfolio" element={<PortfolioPage />} />
|
||||||
<Route path="hosting-security" element={<HostingSecurityPage />} />
|
|
||||||
<Route path="contact" element={<ContactPage />} />
|
<Route path="contact" element={<ContactPage />} />
|
||||||
|
|
||||||
{/* Utilities */}
|
{/* Utilities */}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export default function ContactMeForm() {
|
|||||||
placeholder="Vaše zpráva"
|
placeholder="Vaše zpráva"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<input type="hidden" name="state" />
|
||||||
<input type="submit"/>
|
<input type="submit"/>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
export default function HeroCarousel() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
|
|
||||||
export default function HostingSecuritySection() {
|
|
||||||
return (
|
|
||||||
<section id="hosting" className="py-20">
|
|
||||||
<div className="container mx-auto px-4">
|
|
||||||
<h2 className="text-3xl md:text-4xl font-bold mb-6 text-rainbow">Hosting & Protection</h2>
|
|
||||||
<div className="card p-6 md:p-8">
|
|
||||||
<p className="text-brand-text opacity-80">We host our applications ourselves, which reduces hosting costs as projects scale.</p>
|
|
||||||
<p className="text-brand-text opacity-80 mt-2">All websites are protected by Cloudflare and optimized for performance.</p>
|
|
||||||
<div className="grid grid-cols-3 md:grid-cols-6 gap-4 text-center text-sm mt-6">
|
|
||||||
{['Server', 'Cloudflare', 'Docker', 'SSL', 'Monitoring', 'Scaling'].map(item => (
|
|
||||||
<div key={item} className="p-3 rounded-lg bg-brandGradient text-white shadow-glow transition-transform duration-200 hover:scale-105 flex flex-col items-center justify-center">
|
|
||||||
<span className="text-xs tracking-wide">{item}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -113,7 +113,6 @@ export default function Navbar({ user, onLogin, onLogout }: NavbarProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a className={styles.linkSimple} href="#contacts"><FaGlobe className={styles.iconSmall}/> Kontakt</a>
|
<a className={styles.linkSimple} href="#contacts"><FaGlobe className={styles.iconSmall}/> Kontakt</a>
|
||||||
<a className={styles.linkSimple} href="/projects"><FaProjectDiagram className={styles.iconSmall}/> Projekty</a>
|
|
||||||
|
|
||||||
{/* right: user area */}
|
{/* right: user area */}
|
||||||
{!user ? (
|
{!user ? (
|
||||||
@@ -136,9 +135,9 @@ export default function Navbar({ user, onLogin, onLogout }: NavbarProps) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className={styles.dropdown} role="menu" aria-label="Uživatelské menu">
|
<div className={styles.dropdown} role="menu" aria-label="Uživatelské menu">
|
||||||
<a href="/profile" role="menuitem">Profil</a>
|
<a href="/me/profile" role="menuitem">Profil</a>
|
||||||
<a href="/billing" role="menuitem">Nastavení</a>
|
<a href="/me/settings" role="menuitem">Nastavení</a>
|
||||||
<a href="/billing" role="menuitem">Platby</a>
|
<a href="/me/billing" role="menuitem">Platby</a>
|
||||||
|
|
||||||
<button className={styles.logoutBtn} onClick={onLogout} role="menuitem">
|
<button className={styles.logoutBtn} onClick={onLogout} role="menuitem">
|
||||||
<FaSignOutAlt className={styles.iconSmall} /> Odhlásit se
|
<FaSignOutAlt className={styles.iconSmall} /> Odhlásit se
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
.navbar {
|
.navbar {
|
||||||
width: 80%;
|
width: 50%;
|
||||||
|
width: max-content;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
padding: 0 2em;
|
padding: 0 2em;
|
||||||
background-color: var(--c-boxes);
|
background-color: var(--c-boxes);
|
||||||
@@ -11,7 +12,7 @@
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
gap: 1rem;
|
gap: 1em;
|
||||||
border-bottom-left-radius: 2em;
|
border-bottom-left-radius: 2em;
|
||||||
border-bottom-right-radius: 2em;
|
border-bottom-right-radius: 2em;
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@
|
|||||||
/* Links container */
|
/* Links container */
|
||||||
.links {
|
.links {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1.6rem;
|
gap: 3em;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
width: -webkit-fill-available;
|
width: -webkit-fill-available;
|
||||||
|
|||||||
115
frontend/src/components/services/Services.tsx
Normal file
115
frontend/src/components/services/Services.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { FaLaptopCode, FaVideo, FaCog } from "react-icons/fa";
|
||||||
|
import { MdLaunch, MdFolder, MdPhone } from "react-icons/md";
|
||||||
|
import styles from "./services.module.css";
|
||||||
|
|
||||||
|
type ServiceItem = {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
description: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
demoLink?: string;
|
||||||
|
portfolioLink?: string;
|
||||||
|
contactLink?: string;
|
||||||
|
align: "left" | "right";
|
||||||
|
};
|
||||||
|
|
||||||
|
const services: ServiceItem[] = [
|
||||||
|
{
|
||||||
|
title: "Weby",
|
||||||
|
subtitle: "Od prezentačních až po komplexní",
|
||||||
|
description:
|
||||||
|
"Tvorba webových stránek všech velikostí - od jednoduchých prezentačních po složité aplikace s databází, e-shopy a portály.",
|
||||||
|
icon: FaLaptopCode,
|
||||||
|
demoLink: "/demo/websites",
|
||||||
|
portfolioLink: "/portfolio#websites",
|
||||||
|
contactLink: "/contact?service=web",
|
||||||
|
align: "left",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Filmařina",
|
||||||
|
subtitle: "Natáčení, drony, střih",
|
||||||
|
description:
|
||||||
|
"Natáčení na místě, drony, reels/TikTok videa a samozřejmě střih. Kompletní video produkce od námětu po finální video.",
|
||||||
|
icon: FaVideo,
|
||||||
|
portfolioLink: "/portfolio#videos",
|
||||||
|
contactLink: "/contact?service=video",
|
||||||
|
align: "right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Servis Dronů",
|
||||||
|
subtitle: "Opravy a údržba",
|
||||||
|
description:
|
||||||
|
"Profesionální servis a údržba dronů. Diagnostika, opravy, kalibrace a poradenství pro bezpečný provoz.",
|
||||||
|
icon: FaCog,
|
||||||
|
contactLink: "/contact?service=drone",
|
||||||
|
align: "left",
|
||||||
|
},
|
||||||
|
];export default function Services() {
|
||||||
|
return (
|
||||||
|
<section className={`section ${styles.wrapper}`} aria-labelledby="sluzby-heading">
|
||||||
|
<div className="container">
|
||||||
|
<header className={styles.header}>
|
||||||
|
<h2 id="sluzby-heading" className="text-3xl md:text-4xl font-bold tracking-tight">
|
||||||
|
Služby, které nabízím
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-neutral-300 max-w-2xl">
|
||||||
|
Od návrhu až po produkční nasazení. Stavím udržitelné systémy s důrazem na
|
||||||
|
výkon, bezpečnost a spolehlivost. Základem jsou jasné procesy, čistý kód a
|
||||||
|
měřitelné výsledky.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className={styles.servicesStack}>
|
||||||
|
{services.map((service, idx) => {
|
||||||
|
const IconComponent = service.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={service.title}
|
||||||
|
className={`${styles.card} ${styles[service.align]} ${styles[`card${(idx % 3) + 1}`]}`}
|
||||||
|
>
|
||||||
|
<div className={styles.cardBg} />
|
||||||
|
<div className={styles.cardInner}>
|
||||||
|
<div className={styles.cardHeader}>
|
||||||
|
<div className={styles.iconWrapper}>
|
||||||
|
<IconComponent className={styles.icon} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl md:text-2xl font-semibold">{service.title}</h3>
|
||||||
|
<span className="text-sm md:text-base opacity-80">{service.subtitle}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm md:text-base text-neutral-300">{service.description}</p>
|
||||||
|
|
||||||
|
<div className={styles.actionButtons}>
|
||||||
|
{service.demoLink && (
|
||||||
|
<a href={service.demoLink} className={styles.button}>
|
||||||
|
<MdLaunch className={styles.buttonIcon} />
|
||||||
|
Demo
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{service.portfolioLink && (
|
||||||
|
<a href={service.portfolioLink} className={styles.button}>
|
||||||
|
<MdFolder className={styles.buttonIcon} />
|
||||||
|
Portfolio
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{service.contactLink && (
|
||||||
|
<a href={service.contactLink} className={styles.buttonPrimary}>
|
||||||
|
<MdPhone className={styles.buttonIcon} />
|
||||||
|
Kontakt
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div> <footer className={styles.cta}>
|
||||||
|
<a href="/contact" className="glass px-5 py-3 rounded-lg font-medium">
|
||||||
|
Pojďme to probrat →
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
93
frontend/src/components/services/services.module.css
Normal file
93
frontend/src/components/services/services.module.css
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/* Services section styles with clip-path animations */
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
position: relative;
|
||||||
|
padding-top: 4rem;
|
||||||
|
padding-bottom: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.servicesStack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}.card {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
backdrop-filter: saturate(140%) blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardBg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
background: radial-gradient(1200px 400px at 10% 10%, rgba(99, 102, 241, 0.25), transparent 60%),
|
||||||
|
radial-gradient(900px 300px at 90% 90%, rgba(16, 185, 129, 0.25), transparent 60%);
|
||||||
|
clip-path: polygon(0% 0%, 60% 0%, 100% 40%, 100% 100%, 0% 100%);
|
||||||
|
animation: floatClip 10s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardInner {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardHeader {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}/* Removed tags styles as not needed for simplified version */
|
||||||
|
|
||||||
|
/* Variant hues per card for subtle differentiation */
|
||||||
|
.card1 .cardBg {
|
||||||
|
background: radial-gradient(1200px 400px at 10% 10%, rgba(99, 102, 241, 0.35), transparent 60%),
|
||||||
|
radial-gradient(900px 300px at 90% 90%, rgba(56, 189, 248, 0.25), transparent 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card2 .cardBg {
|
||||||
|
background: radial-gradient(1200px 400px at 10% 10%, rgba(16, 185, 129, 0.35), transparent 60%),
|
||||||
|
radial-gradient(900px 300px at 90% 90%, rgba(245, 158, 11, 0.25), transparent 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card3 .cardBg {
|
||||||
|
background: radial-gradient(1200px 400px at 10% 10%, rgba(236, 72, 153, 0.35), transparent 60%),
|
||||||
|
radial-gradient(900px 300px at 90% 90%, rgba(59, 130, 246, 0.25), transparent 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Removed card4 as we only have 3 services now */@keyframes floatClip {
|
||||||
|
0% {
|
||||||
|
clip-path: polygon(0% 0%, 60% 0%, 100% 40%, 100% 100%, 0% 100%);
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
clip-path: polygon(0% 0%, 55% 0%, 100% 35%, 100% 100%, 0% 100%);
|
||||||
|
transform: translate3d(0, -6px, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
clip-path: polygon(0% 0%, 60% 0%, 100% 40%, 100% 100%, 0% 100%);
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
const categories: { name: string; items: string[] }[] = [
|
|
||||||
{ name: 'Experience', items: ['Freelance projects', 'Collaborations', 'Open-source contributions'] },
|
|
||||||
{ name: 'Backend', items: ['Django', 'Python', 'REST API', 'PostgreSQL', 'Celery', 'Docker'] },
|
|
||||||
{ name: 'Frontend', items: ['React', 'Tailwind', 'Vite', 'TypeScript', 'ShadCN', 'Bootstrap'] },
|
|
||||||
{ name: 'DevOps / Hosting', items: ['Nginx', 'Docker Compose', 'SSL', 'Cloudflare', 'Self-hosting'] },
|
|
||||||
{ name: 'Other Tools', items: ['Git', 'VSCode', 'WebRTC', 'ESP32', 'Automation'] },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function SkillsSection() {
|
|
||||||
return (
|
|
||||||
<section id="skills" className="py-20 bg-gradient-to-b from-brand-bg to-brand-bgLight">
|
|
||||||
<div className="container mx-auto px-4">
|
|
||||||
<div className="text-center mb-12">
|
|
||||||
<h2 className="text-4xl font-bold mb-4 text-rainbow">Skills</h2>
|
|
||||||
<p className="text-brand-text/80 max-w-2xl mx-auto">Core technologies and tools I use across backend, frontend, infrastructure and hardware.</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{categories.map(cat => (
|
|
||||||
<div key={cat.name} className="card p-6">
|
|
||||||
<h3 className="font-semibold mb-4 text-rainbow tracking-wide">{cat.name}</h3>
|
|
||||||
<ul className="space-y-2 text-sm">
|
|
||||||
{cat.items.map(i => (
|
|
||||||
<li key={i} className="flex items-center gap-2 text-brand-text/80">
|
|
||||||
<span className="inline-block w-2 h-2 rounded-full bg-brand-accent" />
|
|
||||||
<span className="group-hover:text-brand-text transition-colors">{i}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import HeroCarousel from "../../components/hero/HeroCarousel";
|
|
||||||
import PortfolioGrid from "../../components/portfolio/PortfolioGrid";
|
import PortfolioGrid from "../../components/portfolio/PortfolioGrid";
|
||||||
import TradingGraph from "../../components/trading/TradingGraph";
|
import TradingGraph from "../../components/trading/TradingGraph";
|
||||||
import DonationShop from "../../components/donate/DonationShop";
|
import DonationShop from "../../components/donate/DonationShop";
|
||||||
import SkillsSection from "../../components/skills/SkillsSection";
|
import ContactMeForm from "../../components/Forms/ContactMe/ContactMeForm";
|
||||||
import HostingSecuritySection from "../../components/hosting/HostingSecuritySection";
|
import Services from "../../components/services/Services";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
// Optional: keep spark effect for fun
|
// Optional: keep spark effect for fun
|
||||||
@@ -33,7 +32,7 @@ export default function Home() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<HeroCarousel />
|
<Services />
|
||||||
<div className="divider" />
|
<div className="divider" />
|
||||||
<PortfolioGrid />
|
<PortfolioGrid />
|
||||||
<div className="divider" />
|
<div className="divider" />
|
||||||
@@ -41,9 +40,7 @@ export default function Home() {
|
|||||||
<div className="divider" />
|
<div className="divider" />
|
||||||
<DonationShop />
|
<DonationShop />
|
||||||
<div className="divider" />
|
<div className="divider" />
|
||||||
<SkillsSection />
|
<ContactMeForm />
|
||||||
<div className="divider" />
|
|
||||||
<HostingSecuritySection />
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
import HostingSecuritySection from "../../components/hosting/HostingSecuritySection";
|
|
||||||
export default function HostingSecurityPage(){
|
|
||||||
return <HostingSecuritySection />;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user