update admin panel and visual

This commit is contained in:
greyjoy
2026-08-02 22:56:29 +04:00
parent b53aa305e8
commit 024e28eee4
16 changed files with 866 additions and 309 deletions
+11 -1
View File
@@ -15,5 +15,15 @@
Откройте `http://127.0.0.1:8000/`. Заявки появляются в админ-панели: `http://127.0.0.1:8000/admin/`. Откройте `http://127.0.0.1:8000/`. Заявки появляются в админ-панели: `http://127.0.0.1:8000/admin/`.
Перед публикацией сайта замените `SECRET_KEY`, отключите `DEBUG` и настройте `ALLOWED_HOSTS` в `config/settings.py`. ## Работа с расписанием
1. В админ-панели откройте раздел «Расписание» и добавьте свободные окна.
2. Укажите дату и время, длительность, предмет и формат консультации.
3. После этого свободное время появится в форме на сайте.
4. В разделе «Записи на консультации» новая заявка получает статус «Новая».
5. Подтвердите её вручную или через массовое действие, добавьте ссылку на встречу и при необходимости отметьте напоминание.
6. После занятия измените статус на «Проведена». Отменённые слоты автоматически снова становятся доступными.
На главной странице также доступны политика конфиденциальности, блок вопросов и адаптивная форма записи.
Перед публикацией сайта замените `SECRET_KEY`, отключите `DEBUG` и настройте `ALLOWED_HOSTS` в `config/settings.py`.
+1 -2
View File
@@ -6,7 +6,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = "django-insecure-change-this-key-before-production" SECRET_KEY = "django-insecure-change-this-key-before-production"
DEBUG = True DEBUG = True
ALLOWED_HOSTS = [] ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
INSTALLED_APPS = [ INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.admin",
@@ -66,4 +66,3 @@ STATIC_URL = "static/"
STATICFILES_DIRS = [BASE_DIR / "static"] STATICFILES_DIRS = [BASE_DIR / "static"]
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
+3 -7
View File
@@ -1,14 +1,10 @@
"""URL configuration for the teacher site.""" """URL configuration for the teacher site."""
from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from consultations.admin import teacher_admin_site
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", teacher_admin_site.urls),
path("", include("consultations.urls")), path("", include("consultations.urls")),
] ]
admin.site.site_header = "Записи на консультации"
admin.site.site_title = "Консультации"
admin.site.index_title = "Заявки учеников"
+217 -12
View File
@@ -1,22 +1,227 @@
from django.contrib import admin from datetime import timedelta
from .models import ConsultationRequest from django.contrib import admin, messages
from django.contrib.auth.admin import GroupAdmin, UserAdmin
from django.contrib.auth.models import Group, User
from django.db.models import Count, Q
from django.utils import timezone
from django.utils.html import format_html
from .models import ACTIVE_BOOKING_STATUSES, ConsultationRequest, ConsultationSlot
@admin.register(ConsultationRequest) class TeacherAdminSite(admin.AdminSite):
site_header = "Кабинет преподавателя"
site_title = "Игорь Безрукавов"
index_title = "Обзор записей"
index_template = "admin/index.html"
def index(self, request, extra_context=None):
today = timezone.localdate()
now = timezone.now()
requests = ConsultationRequest.objects.all()
extra_context = {
**(extra_context or {}),
"dashboard": {
"new_requests": requests.filter(status=ConsultationRequest.Status.NEW).count(),
"today_confirmed": requests.filter(
status=ConsultationRequest.Status.CONFIRMED, preferred_date=today
).count(),
"upcoming": requests.filter(
status__in=ACTIVE_BOOKING_STATUSES,
slot__start_at__gte=now,
).count(),
"completed_month": requests.filter(
status=ConsultationRequest.Status.COMPLETED,
preferred_date__year=today.year,
preferred_date__month=today.month,
).count(),
},
"today": today,
"next_bookings": requests.filter(
status__in=ACTIVE_BOOKING_STATUSES,
slot__start_at__gte=now,
).select_related("slot")[:6],
}
return super().index(request, extra_context=extra_context)
teacher_admin_site = TeacherAdminSite(name="teacher_admin")
teacher_admin_site.register(User, UserAdmin)
teacher_admin_site.register(Group, GroupAdmin)
@admin.register(ConsultationSlot, site=teacher_admin_site)
class ConsultationSlotAdmin(admin.ModelAdmin):
list_display = (
"start_at",
"duration_minutes",
"subject",
"format",
"booking_state",
"is_active",
)
list_filter = ("subject", "format", "is_active", "start_at")
search_fields = ("teacher_note",)
list_editable = ("is_active",)
date_hierarchy = "start_at"
ordering = ("start_at",)
readonly_fields = ("created_at",)
actions = ("activate_slots", "deactivate_slots", "duplicate_next_week")
@admin.display(description="Состояние")
def booking_state(self, slot):
booking = next(
(item for item in slot.bookings.all() if item.status in ACTIVE_BOOKING_STATUSES),
None,
)
if booking:
return format_html('<span class="status-chip status-confirmed">Занято: {}</span>', booking.name)
if slot.start_at < timezone.now():
return format_html('<span class="status-chip status-muted">Прошло</span>')
if not slot.is_active:
return format_html('<span class="status-chip status-muted">Скрыто</span>')
return format_html('<span class="status-chip status-new">Свободно</span>')
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related("bookings")
@admin.action(description="Открыть выбранные слоты для записи")
def activate_slots(self, request, queryset):
count = queryset.update(is_active=True)
self.message_user(request, f"Открыто слотов: {count}.", messages.SUCCESS)
@admin.action(description="Скрыть выбранные слоты с сайта")
def deactivate_slots(self, request, queryset):
count = queryset.update(is_active=False)
self.message_user(request, f"Скрыто слотов: {count}.", messages.SUCCESS)
@admin.action(description="Создать копии на следующую неделю")
def duplicate_next_week(self, request, queryset):
created = 0
for slot in queryset:
next_start = slot.start_at + timedelta(days=7)
if not ConsultationSlot.objects.filter(start_at=next_start).exists():
ConsultationSlot.objects.create(
start_at=next_start,
duration_minutes=slot.duration_minutes,
subject=slot.subject,
format=slot.format,
is_active=slot.is_active,
teacher_note=slot.teacher_note,
)
created += 1
self.message_user(request, f"Создано слотов: {created}.", messages.SUCCESS)
@admin.register(ConsultationRequest, site=teacher_admin_site)
class ConsultationRequestAdmin(admin.ModelAdmin): class ConsultationRequestAdmin(admin.ModelAdmin):
list_display = ( list_display = (
"name", "student_name",
"subject", "subject",
"exam", "exam",
"preferred_date", "appointment_time",
"preferred_time", "format",
"phone", "status_badge",
"is_processed", "contact_details",
) )
list_filter = ("subject", "exam", "is_processed", "preferred_date") list_filter = ("status", "subject", "exam", "format", "student_grade", "preferred_date", "reminder_sent")
search_fields = ("name", "phone", "contact", "comment") search_fields = ("name", "phone", "contact", "comment", "teacher_note")
list_editable = ("is_processed",)
readonly_fields = ("created_at",)
date_hierarchy = "preferred_date" date_hierarchy = "preferred_date"
list_select_related = ("slot",)
readonly_fields = ("created_at", "updated_at", "appointment_summary")
actions = ("mark_confirmed", "mark_completed", "mark_cancelled", "mark_reminder_sent")
fieldsets = (
(
"Ученик и контакты",
{
"fields": (("name", "student_grade"), ("phone", "contact"), "consent_given"),
},
),
(
"Запрос",
{
"fields": (("subject", "exam", "format"), "comment"),
},
),
(
"Время консультации",
{
"fields": ("slot", "appointment_summary", ("preferred_date", "preferred_time")),
},
),
(
"Работа с заявкой",
{
"fields": ("status", "meeting_link", "teacher_note", "cancellation_reason", "reminder_sent"),
},
),
(
"Служебные данные",
{
"classes": ("collapse",),
"fields": ("created_at", "updated_at"),
},
),
)
@admin.display(description="Ученик", ordering="name")
def student_name(self, request):
grade = f", {request.student_grade} класс" if request.student_grade else ""
return f"{request.name}{grade}"
@admin.display(description="Консультация", ordering="preferred_date")
def appointment_time(self, request):
if not request.preferred_date:
return "Время не выбрано"
return f"{request.preferred_date:%d.%m.%Y}, {request.preferred_time:%H:%M}"
@admin.display(description="Статус", ordering="status")
def status_badge(self, request):
classes = {
ConsultationRequest.Status.NEW: "status-new",
ConsultationRequest.Status.CONFIRMED: "status-confirmed",
ConsultationRequest.Status.COMPLETED: "status-completed",
ConsultationRequest.Status.CANCELLED: "status-cancelled",
ConsultationRequest.Status.NO_SHOW: "status-muted",
}
return format_html(
'<span class="status-chip {}">{}</span>',
classes[request.status],
request.get_status_display(),
)
@admin.display(description="Контакты")
def contact_details(self, request):
contact = f" · {request.contact}" if request.contact else ""
return f"{request.phone}{contact}"
@admin.display(description="Выбранное время")
def appointment_summary(self, request):
if request.slot:
return str(request.slot)
if request.preferred_date:
return self.appointment_time(request)
return "Не выбрано"
@admin.action(description="Подтвердить выбранные записи")
def mark_confirmed(self, request, queryset):
count = queryset.exclude(status=ConsultationRequest.Status.CANCELLED).update(
status=ConsultationRequest.Status.CONFIRMED
)
self.message_user(request, f"Подтверждено записей: {count}.", messages.SUCCESS)
@admin.action(description="Отметить проведёнными")
def mark_completed(self, request, queryset):
count = queryset.update(status=ConsultationRequest.Status.COMPLETED)
self.message_user(request, f"Проведено консультаций: {count}.", messages.SUCCESS)
@admin.action(description="Отменить выбранные записи")
def mark_cancelled(self, request, queryset):
count = queryset.update(status=ConsultationRequest.Status.CANCELLED)
self.message_user(request, f"Отменено записей: {count}.", messages.SUCCESS)
@admin.action(description="Отметить напоминание отправленным")
def mark_reminder_sent(self, request, queryset):
count = queryset.update(reminder_sent=True)
self.message_user(request, f"Напоминание отмечено у записей: {count}.", messages.SUCCESS)
+41 -13
View File
@@ -1,31 +1,46 @@
from django import forms from django import forms
from django.utils import timezone from django.utils import timezone
from .models import ConsultationRequest from .models import ConsultationRequest, ConsultationSlot
class AvailableSlotChoiceField(forms.ModelChoiceField):
def label_from_instance(self, slot):
local_start = timezone.localtime(slot.start_at)
return (
f"{local_start:%d.%m.%Y} в {local_start:%H:%M} "
f"· {slot.duration_minutes} минут · {slot.get_format_display()}"
)
class ConsultationRequestForm(forms.ModelForm): class ConsultationRequestForm(forms.ModelForm):
slot = AvailableSlotChoiceField(
queryset=ConsultationSlot.objects.none(),
empty_label="Выберите свободное время",
label="Удобное время",
)
class Meta: class Meta:
model = ConsultationRequest model = ConsultationRequest
fields = ( fields = (
"name", "name",
"phone", "phone",
"contact", "contact",
"student_grade",
"subject", "subject",
"exam", "exam",
"preferred_date", "format",
"preferred_time", "slot",
"comment", "comment",
"consent_given",
) )
widgets = { widgets = {
"name": forms.TextInput(attrs={"placeholder": "Как к вам обращаться"}), "name": forms.TextInput(attrs={"placeholder": "Как к вам обращаться"}),
"phone": forms.TelInput(attrs={"placeholder": "+7 (999) 123-45-67"}), "phone": forms.TelInput(attrs={"placeholder": "+7 (999) 123-45-67"}),
"contact": forms.TextInput(attrs={"placeholder": "@username, WhatsApp и т. п."}), "contact": forms.TextInput(attrs={"placeholder": "@username, WhatsApp и т. п."}),
"preferred_date": forms.DateInput(attrs={"type": "date"}),
"preferred_time": forms.TimeInput(attrs={"type": "time"}),
"comment": forms.Textarea( "comment": forms.Textarea(
attrs={ attrs={
"placeholder": "Например: текущий класс, темы, которые вызывают сложности", "placeholder": "Класс, темы и вопросы, которые хотите разобрать",
"rows": 4, "rows": 4,
} }
), ),
@@ -33,15 +48,28 @@ class ConsultationRequestForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
for field in self.fields.values(): self.fields["slot"].queryset = ConsultationSlot.objects.available()
for name, field in self.fields.items():
if name != "consent_given":
field.widget.attrs["class"] = "form-control" field.widget.attrs["class"] = "form-control"
self.fields["preferred_date"].widget.attrs["min"] = timezone.localdate().isoformat() self.fields["consent_given"].widget.attrs["class"] = "consent-control"
self.fields["contact"].required = False self.fields["contact"].required = False
self.fields["student_grade"].required = False
self.fields["comment"].required = False self.fields["comment"].required = False
def clean_preferred_date(self): def clean_slot(self):
preferred_date = self.cleaned_data["preferred_date"] slot = self.cleaned_data["slot"]
if preferred_date < timezone.localdate(): if not slot.is_available:
raise forms.ValidationError("Выберите сегодняшнюю дату или более позднюю.") raise forms.ValidationError("Это время уже занято. Выберите другой свободный слот.")
return preferred_date return slot
def clean(self):
cleaned_data = super().clean()
slot = cleaned_data.get("slot")
subject = cleaned_data.get("subject")
requested_format = cleaned_data.get("format")
if slot and subject and slot.subject not in (ConsultationSlot.Subject.ANY, subject):
self.add_error("slot", "Это время доступно только для другого предмета.")
if slot and requested_format not in (ConsultationRequest.Format.ANY, slot.format):
self.add_error("format", f"Для выбранного времени доступен формат: {slot.get_format_display()}.")
return cleaned_data
@@ -0,0 +1,179 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("consultations", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="ConsultationSlot",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("start_at", models.DateTimeField(unique=True, verbose_name="Начало консультации")),
(
"duration_minutes",
models.PositiveSmallIntegerField(
choices=[(45, "45 минут"), (60, "60 минут"), (90, "90 минут"), (120, "120 минут")],
default=60,
verbose_name="Длительность, минут",
),
),
(
"subject",
models.CharField(
choices=[("any", "Физика или математика"), ("physics", "Физика"), ("mathematics", "Математика")],
default="any",
max_length=20,
verbose_name="Доступный предмет",
),
),
(
"format",
models.CharField(
choices=[("online", "Онлайн"), ("offline", "Очно")],
default="online",
max_length=20,
verbose_name="Формат",
),
),
("is_active", models.BooleanField(default=True, verbose_name="Открыт для записи")),
("teacher_note", models.CharField(blank=True, max_length=300, verbose_name="Заметка преподавателя")),
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Создан")),
],
options={
"verbose_name": "Свободное время",
"verbose_name_plural": "Расписание",
"ordering": ("start_at",),
},
),
migrations.RemoveField(
model_name="consultationrequest",
name="is_processed",
),
migrations.AddField(
model_name="consultationrequest",
name="cancellation_reason",
field=models.CharField(blank=True, max_length=500, verbose_name="Причина отмены"),
),
migrations.AddField(
model_name="consultationrequest",
name="consent_given",
field=models.BooleanField(default=False, verbose_name="Согласие на обработку данных"),
),
migrations.AddField(
model_name="consultationrequest",
name="format",
field=models.CharField(
choices=[("online", "Онлайн"), ("offline", "Очно"), ("any", "Не имеет значения")],
default="online",
max_length=20,
verbose_name="Предпочтительный формат",
),
),
migrations.AddField(
model_name="consultationrequest",
name="meeting_link",
field=models.URLField(blank=True, verbose_name="Ссылка на встречу"),
),
migrations.AddField(
model_name="consultationrequest",
name="reminder_sent",
field=models.BooleanField(default=False, verbose_name="Напоминание отправлено"),
),
migrations.AddField(
model_name="consultationrequest",
name="status",
field=models.CharField(
choices=[
("new", "Новая"),
("confirmed", "Подтверждена"),
("completed", "Проведена"),
("cancelled", "Отменена"),
("no_show", "Не пришёл"),
],
default="new",
max_length=20,
verbose_name="Статус",
),
),
migrations.AddField(
model_name="consultationrequest",
name="student_grade",
field=models.PositiveSmallIntegerField(
blank=True,
choices=[(5, "5 класс"), (6, "6 класс"), (7, "7 класс"), (8, "8 класс"), (9, "9 класс"), (10, "10 класс"), (11, "11 класс")],
null=True,
verbose_name="Класс ученика",
),
),
migrations.AddField(
model_name="consultationrequest",
name="teacher_note",
field=models.TextField(blank=True, max_length=2000, verbose_name="Заметка преподавателя"),
),
migrations.AddField(
model_name="consultationrequest",
name="updated_at",
field=models.DateTimeField(auto_now=True, verbose_name="Изменена"),
),
migrations.AddField(
model_name="consultationrequest",
name="slot",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="bookings",
to="consultations.consultationslot",
verbose_name="Выбранное время",
),
),
migrations.AlterField(
model_name="consultationrequest",
name="exam",
field=models.CharField(
choices=[("oge", "ОГЭ"), ("ege", "ЕГЭ"), ("school", "Школьная программа"), ("other", "Другая консультация")],
max_length=20,
verbose_name="Цель подготовки",
),
),
migrations.AlterField(
model_name="consultationrequest",
name="comment",
field=models.TextField(blank=True, max_length=1000, verbose_name="Комментарий ученика"),
),
migrations.AlterField(
model_name="consultationrequest",
name="name",
field=models.CharField(max_length=100, verbose_name="Имя ученика или родителя"),
),
migrations.AlterField(
model_name="consultationrequest",
name="preferred_date",
field=models.DateField(blank=True, null=True, verbose_name="Дата консультации"),
),
migrations.AlterField(
model_name="consultationrequest",
name="preferred_time",
field=models.TimeField(blank=True, null=True, verbose_name="Время консультации"),
),
migrations.AlterModelOptions(
name="consultationrequest",
options={
"ordering": ("preferred_date", "preferred_time", "-created_at"),
"verbose_name": "Запись на консультацию",
"verbose_name_plural": "Записи на консультации",
},
),
migrations.AddConstraint(
model_name="consultationrequest",
constraint=models.UniqueConstraint(
condition=models.Q(("status__in", ("new", "confirmed"))),
fields=("slot",),
name="unique_active_slot_booking",
),
),
]
+120 -10
View File
@@ -1,5 +1,67 @@
from datetime import timedelta
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.db import models from django.db import models
from django.db.models import Q
from django.utils import timezone
ACTIVE_BOOKING_STATUSES = ("new", "confirmed")
class ConsultationSlotQuerySet(models.QuerySet):
def available(self):
return (
self.filter(is_active=True, start_at__gte=timezone.now())
.exclude(bookings__status__in=ACTIVE_BOOKING_STATUSES)
.distinct()
)
class ConsultationSlot(models.Model):
class Subject(models.TextChoices):
ANY = "any", "Физика или математика"
PHYSICS = "physics", "Физика"
MATHEMATICS = "mathematics", "Математика"
class Format(models.TextChoices):
ONLINE = "online", "Онлайн"
OFFLINE = "offline", "Очно"
start_at = models.DateTimeField("Начало консультации", unique=True)
duration_minutes = models.PositiveSmallIntegerField(
"Длительность, минут",
choices=((45, "45 минут"), (60, "60 минут"), (90, "90 минут"), (120, "120 минут")),
default=60,
)
subject = models.CharField(
"Доступный предмет", max_length=20, choices=Subject.choices, default=Subject.ANY
)
format = models.CharField("Формат", max_length=20, choices=Format.choices, default=Format.ONLINE)
is_active = models.BooleanField("Открыт для записи", default=True)
teacher_note = models.CharField("Заметка преподавателя", max_length=300, blank=True)
created_at = models.DateTimeField("Создан", auto_now_add=True)
objects = ConsultationSlotQuerySet.as_manager()
class Meta:
verbose_name = "Свободное время"
verbose_name_plural = "Расписание"
ordering = ("start_at",)
def __str__(self):
local_start = timezone.localtime(self.start_at)
return f"{local_start:%d.%m.%Y, %H:%M} · {self.get_format_display()}"
@property
def end_at(self):
return self.start_at + timedelta(minutes=self.duration_minutes)
@property
def is_available(self):
return self.is_active and self.start_at >= timezone.now() and not self.bookings.filter(
status__in=ACTIVE_BOOKING_STATUSES
).exists()
class ConsultationRequest(models.Model): class ConsultationRequest(models.Model):
@@ -10,9 +72,22 @@ class ConsultationRequest(models.Model):
class Exam(models.TextChoices): class Exam(models.TextChoices):
OGE = "oge", "ОГЭ" OGE = "oge", "ОГЭ"
EGE = "ege", "ЕГЭ" EGE = "ege", "ЕГЭ"
SCHOOL = "school", "Школьная программа"
OTHER = "other", "Другая консультация" OTHER = "other", "Другая консультация"
name = models.CharField("Имя ученика", max_length=100) class Format(models.TextChoices):
ONLINE = "online", "Онлайн"
OFFLINE = "offline", "Очно"
ANY = "any", "Не имеет значения"
class Status(models.TextChoices):
NEW = "new", "Новая"
CONFIRMED = "confirmed", "Подтверждена"
COMPLETED = "completed", "Проведена"
CANCELLED = "cancelled", "Отменена"
NO_SHOW = "no_show", "Не пришёл"
name = models.CharField("Имя ученика или родителя", max_length=100)
phone = models.CharField( phone = models.CharField(
"Телефон", "Телефон",
max_length=30, max_length=30,
@@ -24,19 +99,54 @@ class ConsultationRequest(models.Model):
], ],
) )
contact = models.CharField("Telegram или другой способ связи", max_length=100, blank=True) contact = models.CharField("Telegram или другой способ связи", max_length=100, blank=True)
student_grade = models.PositiveSmallIntegerField(
"Класс ученика", choices=tuple((grade, f"{grade} класс") for grade in range(5, 12)), blank=True, null=True
)
subject = models.CharField("Предмет", max_length=20, choices=Subject.choices) subject = models.CharField("Предмет", max_length=20, choices=Subject.choices)
exam = models.CharField("Цель подготовки", max_length=20, choices=Exam.choices) exam = models.CharField("Цель подготовки", max_length=20, choices=Exam.choices)
preferred_date = models.DateField("Желаемая дата") format = models.CharField("Предпочтительный формат", max_length=20, choices=Format.choices, default=Format.ONLINE)
preferred_time = models.TimeField("Желаемое время") slot = models.ForeignKey(
comment = models.TextField("Комментарий", max_length=1000, blank=True) ConsultationSlot,
verbose_name="Выбранное время",
related_name="bookings",
on_delete=models.SET_NULL,
blank=True,
null=True,
)
preferred_date = models.DateField("Дата консультации", blank=True, null=True)
preferred_time = models.TimeField("Время консультации", blank=True, null=True)
comment = models.TextField("Комментарий ученика", max_length=1000, blank=True)
status = models.CharField("Статус", max_length=20, choices=Status.choices, default=Status.NEW)
teacher_note = models.TextField("Заметка преподавателя", max_length=2000, blank=True)
meeting_link = models.URLField("Ссылка на встречу", blank=True)
cancellation_reason = models.CharField("Причина отмены", max_length=500, blank=True)
reminder_sent = models.BooleanField("Напоминание отправлено", default=False)
consent_given = models.BooleanField("Согласие на обработку данных", default=False)
created_at = models.DateTimeField("Создана", auto_now_add=True) created_at = models.DateTimeField("Создана", auto_now_add=True)
is_processed = models.BooleanField("Заявка обработана", default=False) updated_at = models.DateTimeField("Изменена", auto_now=True)
class Meta: class Meta:
verbose_name = "Заявка на консультацию" verbose_name = "Запись на консультацию"
verbose_name_plural = "Заявки на консультации" verbose_name_plural = "Записи на консультации"
ordering = ("is_processed", "preferred_date", "preferred_time") ordering = ("preferred_date", "preferred_time", "-created_at")
constraints = [
models.UniqueConstraint(
fields=("slot",),
condition=Q(status__in=ACTIVE_BOOKING_STATUSES),
name="unique_active_slot_booking",
)
]
def save(self, *args, **kwargs):
if self.slot_id:
local_start = timezone.localtime(self.slot.start_at)
self.preferred_date = local_start.date()
self.preferred_time = local_start.time()
if self.format == self.Format.ANY:
self.format = self.slot.format
super().save(*args, **kwargs)
def __str__(self): def __str__(self):
return f"{self.name}: {self.get_subject_display()}{self.preferred_date:%d.%m.%Y}" if self.preferred_date and self.preferred_time:
return f"{self.name}: {self.get_subject_display()}{self.preferred_date:%d.%m.%Y}, {self.preferred_time:%H:%M}"
return f"{self.name}: {self.get_subject_display()}"
@@ -0,0 +1,15 @@
{% extends "admin/base.html" %}
{% load i18n static %}
{% block title %}{{ title }} | Кабинет преподавателя{% endblock %}
{% block extrastyle %}
{{ block.super }}
<link rel="stylesheet" href="{% static 'admin/css/teacher_admin.css' %}">
{% endblock %}
{% block branding %}
<div id="site-name"><a href="{% url 'teacher_admin:index' %}"><span>ИО</span> Кабинет преподавателя</a></div>
{% endblock %}
{% block nav-global %}<a class="admin-site-link" href="/" target="_blank" rel="noopener">Открыть сайт ↗</a>{% endblock %}
+24
View File
@@ -0,0 +1,24 @@
{% extends "admin/base_site.html" %}
{% load i18n %}
{% block content %}
<div class="dashboard">
<section class="dashboard-hero">
<div><p>Рабочий обзор</p><h1>Добрый день, {{ user.get_short_name|default:user.get_username }}.</h1><span>Управляйте расписанием и заявками в одном месте.</span></div>
<a class="dashboard-primary" href="{% url 'teacher_admin:consultations_consultationslot_add' %}">+ Добавить свободное время</a>
</section>
<section class="dashboard-metrics">
<a href="{% url 'teacher_admin:consultations_consultationrequest_changelist' %}?status__exact=new"><strong>{{ dashboard.new_requests }}</strong><span>новых заявок</span></a>
<a href="{% url 'teacher_admin:consultations_consultationrequest_changelist' %}?status__exact=confirmed&amp;preferred_date__exact={{ today|date:'Y-m-d' }}"><strong>{{ dashboard.today_confirmed }}</strong><span>подтверждено на сегодня</span></a>
<a href="{% url 'teacher_admin:consultations_consultationrequest_changelist' %}"><strong>{{ dashboard.upcoming }}</strong><span>ближайших консультаций</span></a>
<a href="{% url 'teacher_admin:consultations_consultationrequest_changelist' %}?status__exact=completed"><strong>{{ dashboard.completed_month }}</strong><span>проведено за месяц</span></a>
</section>
<section class="dashboard-grid">
<div class="dashboard-panel"><div class="panel-heading"><h2>Ближайшие записи</h2><a href="{% url 'teacher_admin:consultations_consultationrequest_changelist' %}">Все заявки →</a></div>
{% if next_bookings %}<div class="booking-list">{% for booking in next_bookings %}<a href="{% url 'teacher_admin:consultations_consultationrequest_change' booking.pk %}"><span class="booking-date">{{ booking.preferred_date|date:"d M" }}<small>{{ booking.preferred_time|time:"H:i" }}</small></span><span><strong>{{ booking.name }}</strong><small>{{ booking.get_subject_display }} · {{ booking.get_exam_display }}</small></span><span class="dashboard-status status-{{ booking.status }}">{{ booking.get_status_display }}</span></a>{% endfor %}</div>{% else %}<p class="empty-panel">Ближайших записей пока нет. Добавьте свободные окна в расписание.</p>{% endif %}
</div>
<div class="dashboard-panel quick-panel"><div class="panel-heading"><h2>Быстрые действия</h2></div><a href="{% url 'teacher_admin:consultations_consultationslot_changelist' %}">Календарь и свободные окна <span></span></a><a href="{% url 'teacher_admin:consultations_consultationrequest_changelist' %}?status__exact=new">Новые заявки <span></span></a><a href="{% url 'teacher_admin:consultations_consultationrequest_add' %}">Добавить запись вручную <span></span></a></div>
</section>
<section class="dashboard-apps">{% include "admin/app_list.html" with app_list=app_list show_changelinks=True %}</section>
</div>
{% endblock %}
+131 -101
View File
@@ -4,191 +4,221 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Подготовка к ОГЭ и ЕГЭ по физике и математике с преподавателем Игорем Олеговичем Безрукавовым."> <meta name="description" content="Подготовка к ОГЭ и ЕГЭ по физике и математике с Игорем Олеговичем Безрукавовым.">
<title>Физика и математика — Игорь Олегович Безрукавов</title> <title>Игорь Безрукавов — физика и математика</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&amp;family=Source+Serif+4:opsz,wght@8..60,600;8..60,700&amp;display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=DM+Mono&amp;family=Manrope:wght@400;500;600;700;800&amp;family=Source+Serif+4:opsz,wght@8..60,600;8..60,700&amp;display=swap" rel="stylesheet">
<link rel="stylesheet" href="{% static 'css/style.css' %}"> <link rel="stylesheet" href="{% static 'css/style.css' %}">
</head> </head>
<body> <body>
<header class="site-header"> <header class="site-header">
<div class="container navigation"> <div class="container navigation">
<a class="brand" href="#top" aria-label="На главную">ИО<span>·</span>учёба</a> <a class="brand" href="#top" aria-label="На главную"><span>ИО</span> physics &amp; math</a>
<nav class="nav-links" aria-label="Основная навигация"> <nav class="nav-links" aria-label="Основная навигация">
<a href="#about">О преподавателе</a> <a href="#about">О преподавателе</a>
<a href="#subjects">Направления</a> <a href="#programs">Направления</a>
<a href="#booking">Запись</a> <a href="#faq">Вопросы</a>
</nav> </nav>
<a class="nav-button" href="#booking">Записаться</a> <a class="header-action" href="#booking">Выбрать время <span aria-hidden="true"></span></a>
</div> </div>
</header> </header>
<main id="top"> <main id="top">
<section class="hero"> <section class="hero">
<div class="container hero-grid"> <div class="container hero-layout">
<div class="hero-content"> <div class="hero-copy">
<p class="eyebrow">Физика и математика · 5–11 классы</p> <p class="eyebrow"><span></span> Физика · математика · 5–11 классы</p>
<h1>Готовимся к экзаменам <em>с пониманием</em>, а не наугад.</h1> <h1>Экзамен — это задача. <em>Её можно решить.</em></h1>
<p class="hero-text">Индивидуальные консультации по подготовке к ОГЭ и ЕГЭ: разбираем сложные темы, выстраиваем логику решения и спокойно идём к цели.</p> <p class="hero-lead">Индивидуальная подготовка к ОГЭ и ЕГЭ: от пробелов в темах до уверенности в каждом шаге решения.</p>
<div class="hero-actions"> <div class="hero-actions">
<a class="button button-primary" href="#booking">Записаться на консультацию</a> <a class="button button-primary" href="#booking">Записаться на консультацию <span aria-hidden="true"></span></a>
<a class="text-link" href="#about">Узнать обо мне <span aria-hidden="true"></span></a> <a class="button button-ghost" href="#about">О преподавателе</a>
</div> </div>
<div class="hero-stats" aria-label="Ключевые факты"> <div class="hero-numbers" aria-label="Опыт преподавателя">
<div><strong>10+</strong><span>лет опыта</span></div> <div><strong>10+</strong><span>лет преподавания<br>и репетиторства</span></div>
<div><strong>2</strong><span>предмета</span></div> <div><strong>2</strong><span>предмета для<br>уверенного результата</span></div>
<div><strong>ОГЭ · ЕГЭ</strong><span>подготовка к экзаменам</span></div> <div><strong>АГУ</strong><span>бакалавриат<br>и магистратура</span></div>
</div> </div>
</div> </div>
<div class="formula-card" aria-label="Физика и математика"> <div class="portrait-zone">
<div class="card-orbit orbit-one"></div> <div class="portrait-frame">
<div class="card-orbit orbit-two"></div> <img src="{% static 'images/igor-bezrukavov.jpg' %}" alt="Игорь Олегович Безрукавов">
<span class="formula formula-e">E = mc²</span> </div>
<span class="formula formula-root">√x</span> <div class="portrait-note"><span></span> Разбираем<br>до понимания</div>
<span class="formula formula-sum">Σ</span> <div class="formula-sticker" aria-hidden="true"><i>F</i><small>= ma</small></div>
<div class="formula-center">
<p>точные науки</p>
<strong>понятно<br>и спокойно</strong>
</div> </div>
</div> </div>
</section>
<section class="trust-strip">
<div class="container trust-content">
<span>Понятная система подготовки</span><b>·</b>
<span>Спокойный темп</span><b>·</b>
<span>Индивидуальный план</span><b>·</b>
<span>Фокус на результате</span>
</div> </div>
</section> </section>
<section class="section about" id="about"> <section class="section about" id="about">
<div class="container split-layout"> <div class="container about-layout">
<p class="section-kicker">О преподавателе</p> <div class="section-label"><span>01</span><p>О преподавателе</p></div>
<div class="about-content">
<h2>Понимание формул начинается с вопроса <em>«почему?»</em></h2>
<div class="about-columns">
<p class="about-intro">Меня зовут Игорь Олегович Безрукавов. Более 10 лет помогаю школьникам выстроить сильную базу по физике и математике.</p>
<div> <div>
<h2>Игорь Олегович<br>Безрукавов</h2>
<p class="lead">Преподаватель физики и математики с опытом преподавания и репетиторства более 10 лет.</p>
<p>Окончил Астраханский государственный университет: бакалавриат и магистратуру по специальности «Преподавание физики и математики».</p> <p>Окончил Астраханский государственный университет: бакалавриат и магистратуру по специальности «Преподавание физики и математики».</p>
<p>На консультации важно не просто получить ответ, а разобраться, почему решение работает — тогда знания остаются с учеником и на контрольной, и на экзамене.</p> <p>На занятии ученик не заучивает шаблон, а учится видеть логику задачиэто помогает уверенно работать и на уроке, и на экзамене.</p>
</div>
</div>
<div class="education-mark"><span>АГУ</span><p>Педагогическое образование<br>по физике и математике</p></div>
</div> </div>
</div> </div>
</section> </section>
<section class="section subjects" id="subjects"> <section class="section programs" id="programs">
<div class="container"> <div class="container">
<div class="section-heading"> <div class="section-heading">
<div> <div class="section-label"><span>02</span><p>Направления</p></div>
<p class="section-kicker">Направления</p> <h2>Работаем на<br><em>вашу цель</em></h2>
<h2>Подготовка в нужном темпе</h2> <p>Выбираем темп и программу с учётом класса, стартового уровня и даты экзамена.</p>
</div> </div>
<p>Подберём фокус консультации под текущий уровень, цели и дату экзамена.</p> <div class="program-grid">
</div> <article class="program-card physics-card">
<div class="subject-grid"> <div class="program-top"><span class="program-code">01 / PH</span><span class="program-symbol">F</span></div>
<article class="subject-card physics-card">
<span class="subject-icon">F</span>
<p class="card-kicker">Предмет</p>
<h3>Физика</h3> <h3>Физика</h3>
<p>От формул и единиц измерения до сложных задач второй части.</p>
<ul> <ul>
<li>Механика, термодинамика, электродинамика</li> <li>ОГЭ и ЕГЭ</li>
<li>Задачи с формулами и графиками</li> <li>Механика, электричество, оптика</li>
<li>Подготовка к ОГЭ и ЕГЭ</li> <li>Графики, эксперименты, расчёты</li>
</ul> </ul>
<a href="#booking">Записаться <span aria-hidden="true"></span></a> <a href="#booking">Выбрать консультацию <span></span></a>
</article> </article>
<article class="subject-card math-card"> <article class="program-card math-card">
<span class="subject-icon">π</span> <div class="program-top"><span class="program-code">02 / MTH</span><span class="program-symbol">π</span></div>
<p class="card-kicker">Предмет</p>
<h3>Математика</h3> <h3>Математика</h3>
<p>Понятная алгебра и геометрия вместо набора непонятных правил.</p>
<ul> <ul>
<li>Алгебра, геометрия, вероятность</li> <li>ОГЭ и ЕГЭ</li>
<li>Уравнения, неравенства, задачи</li> <li>Уравнения, неравенства, функции</li>
<li>Подготовка к ОГЭ и ЕГЭ</li> <li>Планиметрия и стереометрия</li>
</ul> </ul>
<a href="#booking">Записаться <span aria-hidden="true"></span></a> <a href="#booking">Выбрать консультацию <span></span></a>
</article> </article>
</div> </div>
</div> </div>
</section> </section>
<section class="section process"> <section class="section approach">
<div class="container"> <div class="container approach-layout">
<p class="section-kicker">Как это работает</p> <div class="approach-copy">
<h2>Три простых шага</h2> <div class="section-label"><span>03</span><p>Как проходят занятия</p></div>
<div class="steps"> <h2>Не «натаскивание»,<br>а <em>система.</em></h2>
<article><span>01</span><h3>Оставьте заявку</h3><p>Выберите предмет, экзамен и удобное время.</p></article> </div>
<article><span>02</span><h3>Подтвердим время</h3><p>Игорь Олегович свяжется с вами по указанным контактам.</p></article> <div class="approach-steps">
<article><span>03</span><h3>Начнём подготовку</h3><p>Определим цель и разберём первые задачи.</p></article> <article><span>01</span><h3>Диагностика</h3><p>Определяем уровень и темы, на которые стоит обратить внимание в первую очередь.</p></article>
<article><span>02</span><h3>План</h3><p>Собираем маршрут подготовки: теория, практика, повторение и работа с ошибками.</p></article>
<article><span>03</span><h3>Уверенность</h3><p>Тренируем самостоятельное решение и разбираем вопросы до полного понимания.</p></article>
</div> </div>
</div> </div>
</section> </section>
<section class="booking" id="booking"> <section class="booking-section" id="booking">
<div class="container booking-layout"> <div class="container booking-layout">
<div class="booking-copy"> <div class="booking-intro">
<p class="section-kicker">Запись на консультацию</p> <p class="eyebrow eyebrow-light"><span></span> Онлайн-запись</p>
<h2>Сделайте первый шаг к уверенному экзамену.</h2> <h2>Выберите удобное время <em>для старта.</em></h2>
<p>Заполните форму — время будет окончательно подтверждено после связи с преподавателем.</p> <p>Слот предварительно бронируется за вами. Игорь Олегович подтвердит консультацию по указанным контактам.</p>
<p class="privacy-note">Контакты используются только для ответа на вашу заявку.</p> <div class="availability-card">
<span class="availability-dot"></span>
<div><strong>{{ available_slot_count }}</strong><p>свободных окон<br>для записи сейчас</p></div>
</div> </div>
<div class="form-card"> <p class="booking-help">Не нашли подходящее время? Напишите желаемый день в комментарии — постараемся подобрать вариант.</p>
</div>
<div class="booking-form-card">
{% if messages %} {% if messages %}
{% for message in messages %} {% for message in messages %}
<div class="message message-{{ message.tags }}" role="status">{{ message }}</div> <div class="form-message form-message-{{ message.tags }}" role="status">{{ message }}</div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if form.non_field_errors %} <div class="form-title"><span>Запись на консультацию</span><p>Поля со звёздочкой обязательны</p></div>
<div class="form-errors">{{ form.non_field_errors }}</div> {% if form.non_field_errors %}<div class="form-errors">{{ form.non_field_errors }}</div>{% endif %}
{% endif %}
<form method="post" action="#booking" novalidate> <form method="post" action="#booking" novalidate>
{% csrf_token %} {% csrf_token %}
<div class="form-grid"> <div class="form-grid">
<div class="field full-width"> <div class="field full">
<label for="{{ form.name.id_for_label }}">{{ form.name.label }}</label> <label for="{{ form.name.id_for_label }}">{{ form.name.label }} <b>*</b></label>
{{ form.name }} {{ form.name }}{% for error in form.name.errors %}<span class="error">{{ error }}</span>{% endfor %}
{% for error in form.name.errors %}<span class="error">{{ error }}</span>{% endfor %}
</div> </div>
<div class="field"> <div class="field">
<label for="{{ form.phone.id_for_label }}">{{ form.phone.label }}</label> <label for="{{ form.phone.id_for_label }}">{{ form.phone.label }} <b>*</b></label>
{{ form.phone }} {{ form.phone }}{% for error in form.phone.errors %}<span class="error">{{ error }}</span>{% endfor %}
{% for error in form.phone.errors %}<span class="error">{{ error }}</span>{% endfor %}
</div> </div>
<div class="field"> <div class="field">
<label for="{{ form.contact.id_for_label }}">{{ form.contact.label }}</label> <label for="{{ form.contact.id_for_label }}">{{ form.contact.label }}</label>
{{ form.contact }} {{ form.contact }}{% for error in form.contact.errors %}<span class="error">{{ error }}</span>{% endfor %}
{% for error in form.contact.errors %}<span class="error">{{ error }}</span>{% endfor %}
</div> </div>
<div class="field"> <div class="field">
<label for="{{ form.subject.id_for_label }}">{{ form.subject.label }}</label> <label for="{{ form.student_grade.id_for_label }}">{{ form.student_grade.label }}</label>
{{ form.subject }} {{ form.student_grade }}{% for error in form.student_grade.errors %}<span class="error">{{ error }}</span>{% endfor %}
{% for error in form.subject.errors %}<span class="error">{{ error }}</span>{% endfor %}
</div> </div>
<div class="field"> <div class="field">
<label for="{{ form.exam.id_for_label }}">{{ form.exam.label }}</label> <label for="{{ form.subject.id_for_label }}">{{ form.subject.label }} <b>*</b></label>
{{ form.exam }} {{ form.subject }}{% for error in form.subject.errors %}<span class="error">{{ error }}</span>{% endfor %}
{% for error in form.exam.errors %}<span class="error">{{ error }}</span>{% endfor %}
</div> </div>
<div class="field"> <div class="field">
<label for="{{ form.preferred_date.id_for_label }}">{{ form.preferred_date.label }}</label> <label for="{{ form.exam.id_for_label }}">{{ form.exam.label }} <b>*</b></label>
{{ form.preferred_date }} {{ form.exam }}{% for error in form.exam.errors %}<span class="error">{{ error }}</span>{% endfor %}
{% for error in form.preferred_date.errors %}<span class="error">{{ error }}</span>{% endfor %}
</div> </div>
<div class="field"> <div class="field">
<label for="{{ form.preferred_time.id_for_label }}">{{ form.preferred_time.label }}</label> <label for="{{ form.format.id_for_label }}">{{ form.format.label }} <b>*</b></label>
{{ form.preferred_time }} {{ form.format }}{% for error in form.format.errors %}<span class="error">{{ error }}</span>{% endfor %}
{% for error in form.preferred_time.errors %}<span class="error">{{ error }}</span>{% endfor %}
</div> </div>
<div class="field full-width"> <div class="field full">
<label for="{{ form.slot.id_for_label }}">{{ form.slot.label }} <b>*</b></label>
{{ form.slot }}{% for error in form.slot.errors %}<span class="error">{{ error }}</span>{% endfor %}
</div>
<div class="field full">
<label for="{{ form.comment.id_for_label }}">{{ form.comment.label }}</label> <label for="{{ form.comment.id_for_label }}">{{ form.comment.label }}</label>
{{ form.comment }} {{ form.comment }}{% for error in form.comment.errors %}<span class="error">{{ error }}</span>{% endfor %}
{% for error in form.comment.errors %}<span class="error">{{ error }}</span>{% endfor %}
</div> </div>
</div> </div>
<button class="button button-primary submit-button" type="submit">Отправить заявку</button> <label class="consent-label" for="{{ form.consent_given.id_for_label }}">
{{ form.consent_given }}
<span>Я согласен на обработку персональных данных в соответствии с <a href="{% url 'consultations:privacy_policy' %}">политикой конфиденциальности</a>. <b>*</b></span>
</label>
{% for error in form.consent_given.errors %}<span class="error consent-error">{{ error }}</span>{% endfor %}
{% if available_slot_count %}
<button class="button button-primary form-submit" type="submit">Отправить заявку <span aria-hidden="true"></span></button>
{% else %}
<button class="button button-disabled form-submit" type="button" disabled>Свободных окон пока нет</button>
{% endif %}
</form> </form>
</div> </div>
</div> </div>
</section> </section>
<section class="section faq" id="faq">
<div class="container faq-layout">
<div><div class="section-label"><span>04</span><p>Вопросы</p></div><h2>Перед первым<br><em>занятием</em></h2></div>
<div class="faq-list">
<details open><summary>Как записаться на консультацию?<span>+</span></summary><p>Выберите свободное время, заполните форму и дождитесь подтверждения. Контакты нужны, чтобы согласовать детали занятия.</p></details>
<details><summary>Можно ли начать подготовку не с начала года?<span>+</span></summary><p>Да. На первой консультации определим стартовый уровень, ближайшую цель и соберём реалистичный план подготовки.</p></details>
<details><summary>Что указать в комментарии к заявке?<span>+</span></summary><p>Напишите класс, предмет, цель и темы, которые кажутся сложными. Это поможет подготовиться к первой встрече.</p></details>
</div>
</div>
</section>
</main> </main>
<footer class="site-footer"> <footer class="site-footer">
<div class="container footer-content"> <div class="container footer-main">
<a class="brand" href="#top">ИО<span>·</span>учёба</a> <a class="brand brand-footer" href="#top"><span>ИО</span> physics &amp; math</a>
<p>Физика и математика · подготовка к ОГЭ и ЕГЭ</p> <p>Физика и математика без лишней тревоги.</p>
<p>© {% now "Y" %} Игорь Олегович Безрукавов</p> <a href="#booking">Записаться <span></span></a>
</div> </div>
<div class="container footer-bottom"><span>© {% now "Y" %} Игорь Олегович Безрукавов</span><a href="{% url 'consultations:privacy_policy' %}">Политика конфиденциальности</a></div>
</footer> </footer>
</body> </body>
</html> </html>
@@ -0,0 +1,33 @@
{% load static %}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Политика конфиденциальности — Игорь Безрукавов</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&amp;family=Source+Serif+4:opsz,wght@8..60,600;8..60,700&amp;display=swap" rel="stylesheet">
<link rel="stylesheet" href="{% static 'css/style.css' %}">
</head>
<body class="privacy-page">
<header class="site-header"><div class="container navigation"><a class="brand" href="{% url 'consultations:home' %}"><span>ИО</span> physics &amp; math</a><a class="header-action" href="{% url 'consultations:home' %}#booking">Записаться <span></span></a></div></header>
<main class="privacy-content container">
<p class="eyebrow"><span></span> Юридическая информация</p>
<h1>Политика<br><em>конфиденциальности</em></h1>
<p class="privacy-lead">Этот сайт использует персональные данные только для обработки заявок на консультации.</p>
<div class="privacy-text">
<h2>Какие данные собираются</h2>
<p>При заполнении формы могут быть указаны имя, номер телефона, способ связи, класс ученика, предмет, цель подготовки и комментарий к заявке.</p>
<h2>Зачем они нужны</h2>
<p>Данные используются исключительно для связи с заявителем, подтверждения времени консультации и подготовки к занятию.</p>
<h2>Хранение и защита</h2>
<p>Доступ к заявкам имеет только преподаватель. Данные не передаются третьим лицам и не используются для рекламных рассылок.</p>
<h2>Отзыв согласия</h2>
<p>Чтобы уточнить, изменить или удалить сведения из заявки, свяжитесь с преподавателем тем же способом, который был указан при записи.</p>
</div>
<a class="button button-primary" href="{% url 'consultations:home' %}#booking">Вернуться к записи <span></span></a>
</main>
<footer class="site-footer"><div class="container footer-bottom"><span>© {% now "Y" %} Игорь Олегович Безрукавов</span><a href="{% url 'consultations:home' %}">На главную</a></div></footer>
</body>
</html>
+1 -1
View File
@@ -7,5 +7,5 @@ app_name = "consultations"
urlpatterns = [ urlpatterns = [
path("", views.home, name="home"), path("", views.home, name="home"),
path("privacy/", views.privacy_policy, name="privacy_policy"),
] ]
+20 -2
View File
@@ -1,21 +1,39 @@
from django.contrib import messages from django.contrib import messages
from django.db import IntegrityError, transaction
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
from .forms import ConsultationRequestForm from .forms import ConsultationRequestForm
from .models import ConsultationSlot
def home(request): def home(request):
if request.method == "POST": if request.method == "POST":
form = ConsultationRequestForm(request.POST) form = ConsultationRequestForm(request.POST)
if form.is_valid(): if form.is_valid():
try:
with transaction.atomic():
form.save() form.save()
except IntegrityError:
form.add_error("slot", "Это время только что занял другой ученик. Выберите другой слот.")
else:
messages.success( messages.success(
request, request,
"Заявка отправлена. Игорь Олегович свяжется с вами, чтобы подтвердить время консультации.", "Заявка принята. Игорь Олегович свяжется с вами для подтверждения консультации.",
) )
return redirect(f"{reverse('consultations:home')}#booking") return redirect(f"{reverse('consultations:home')}#booking")
else: else:
form = ConsultationRequestForm() form = ConsultationRequestForm()
return render(request, "consultations/home.html", {"form": form}) return render(
request,
"consultations/home.html",
{
"form": form,
"available_slot_count": ConsultationSlot.objects.available().count(),
},
)
def privacy_policy(request):
return render(request, "consultations/privacy.html")
+2
View File
@@ -0,0 +1,2 @@
:root { --primary: #0f5369; --primary-dark: #0a3445; --accent: #ee7046; --cream: #f8f5ee; --ink: #19333e; --muted: #63777c; }
body { color: var(--ink); background: var(--cream); font-family: Arial, sans-serif; }.skip-to-content-link { background: var(--primary); }#header { min-height: 66px; padding: 0 28px; color: #fff; background: var(--primary-dark); }#site-name { font-size: 18px; font-weight: 700; }#site-name a { color: #fff; }#site-name span { display: inline-block; margin-right: 7px; padding: 5px 6px; border-radius: 50%; color: var(--primary-dark); background: #f6c85f; font-size: 10px; }.admin-site-link { display: inline-block; margin-left: 18px; color: #f6c85f; font-size: 12px; font-weight: 700; }div.breadcrumbs { padding: 12px 28px; color: #c6e0de; background: var(--primary); }div.breadcrumbs a { color: #fff; }#content { padding: 30px 28px; }h1, h2 { color: var(--ink); }.module caption, .module h2 { background: var(--primary); }.button, input[type=submit], input[type=button], .submit-row input, a.button { border-radius: 3px; color: #fff; background: var(--accent); }.button:hover, input[type=submit]:hover, input[type=button]:hover, .submit-row input:hover, a.button:hover { background: #d95e36; }.object-tools a:link, .object-tools a:visited { border-radius: 3px; background: var(--primary); }.object-tools a:hover { background: var(--primary-dark); }a:link, a:visited { color: var(--primary); }a:hover { color: var(--accent); }.selector-chosen h2 { background: var(--primary); }.inline-group h2 { background: var(--primary); }.dashboard { max-width: 1240px; }.dashboard-hero { display: flex; align-items: end; justify-content: space-between; gap: 30px; margin-bottom: 25px; padding: 30px; color: #fff; background: linear-gradient(120deg, var(--primary-dark), var(--primary)); }.dashboard-hero p { margin: 0 0 5px; color: #f6c85f; font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }.dashboard-hero h1 { margin: 0 0 8px; color: #fff; font-size: 29px; }.dashboard-hero span { color: #c2d9d7; font-size: 13px; }.dashboard-primary { padding: 11px 15px; color: var(--primary-dark) !important; background: #f6c85f; font-size: 12px; font-weight: 700; white-space: nowrap; }.dashboard-metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 23px; }.dashboard-metrics a { padding: 20px; border: 1px solid #d5dedc; background: #fff; }.dashboard-metrics a:hover { border-color: var(--accent); }.dashboard-metrics strong { display: block; color: var(--primary); font-size: 31px; line-height: 1; }.dashboard-metrics span { display: block; margin-top: 7px; color: var(--muted); font-size: 12px; }.dashboard-grid { display: grid; grid-template-columns: 1.6fr .9fr; gap: 22px; margin-bottom: 28px; }.dashboard-panel { padding: 23px; border: 1px solid #d5dedc; background: #fff; }.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding-bottom: 15px; border-bottom: 1px solid #e3e9e7; }.panel-heading h2 { margin: 0; font-size: 18px; }.panel-heading a { font-size: 12px; font-weight: 700; }.booking-list > a { display: grid; grid-template-columns: 65px 1fr auto; align-items: center; gap: 15px; padding: 13px 0; border-bottom: 1px solid #e3e9e7; }.booking-list > a:last-child { border-bottom: 0; }.booking-date { color: var(--primary); font-size: 13px; font-weight: 700; }.booking-date small, .booking-list small { display: block; margin-top: 2px; color: var(--muted); font-size: 11px; font-weight: 400; }.booking-list strong { color: var(--ink); font-size: 13px; }.dashboard-status { padding: 4px 8px; border-radius: 999px; font-size: 10px; font-weight: 700; }.status-new { color: #9f5615; background: #fff0d6; }.status-confirmed { color: #176042; background: #dbf2e5; }.status-completed { color: #285c86; background: #ddecfa; }.empty-panel { color: var(--muted); font-size: 13px; }.quick-panel > a { display: flex; justify-content: space-between; padding: 16px 0; border-bottom: 1px solid #e3e9e7; color: var(--ink); font-size: 13px; font-weight: 700; }.quick-panel > a span { color: var(--accent); }.dashboard-apps { margin-top: 25px; }.status-chip { display: inline-block; padding: 4px 8px; border-radius: 999px; font-size: 11px; font-weight: 700; }.status-cancelled { color: #8d3b3b; background: #f9e1e1; }.status-muted { color: #63777c; background: #edf0ef; }@media (max-width: 800px) { #header { padding: 0 15px; }#content { padding: 20px 15px; }.dashboard-metrics, .dashboard-grid { grid-template-columns: 1fr 1fr; }.dashboard-hero { align-items: start; flex-direction: column; }.booking-list > a { grid-template-columns: 55px 1fr; }.dashboard-status { display: none; } }@media (max-width: 500px) { .dashboard-metrics, .dashboard-grid { grid-template-columns: 1fr; }.dashboard-hero { padding: 20px; }.dashboard-hero h1 { font-size: 23px; } }
+60 -152
View File
@@ -1,172 +1,80 @@
:root { :root {
--ink: #12212d; --ink: #142e3b;
--muted: #5f6a72; --ink-soft: #3d5661;
--paper: #f8f8f3; --blue: #0f5369;
--sand: #eeece3; --blue-deep: #0a3445;
--blue: #195d78; --sky: #dcecf1;
--blue-dark: #10475f; --cream: #f8f5ee;
--orange: #e86d3e; --orange: #ee7046;
--line: #d8ddd6; --yellow: #f6c85f;
--line: #cfdbdb;
--white: #ffffff; --white: #ffffff;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
html { scroll-behavior: smooth; } html { scroll-behavior: smooth; }
body { margin: 0; color: var(--ink); background: var(--cream); font-family: "Manrope", Arial, sans-serif; font-size: 16px; line-height: 1.6; }
body {
margin: 0;
color: var(--ink);
background: var(--paper);
font-family: "Manrope", Arial, sans-serif;
font-size: 16px;
line-height: 1.6;
}
a { color: inherit; text-decoration: none; } a { color: inherit; text-decoration: none; }
button, input, select, textarea { font: inherit; }
.container { width: min(1160px, calc(100% - 48px)); margin: 0 auto; }
.container { width: min(1120px, calc(100% - 48px)); margin: 0 auto; } .site-header { position: sticky; top: 0; z-index: 20; border-bottom: 1px solid rgba(20, 46, 59, .1); background: rgba(248, 245, 238, .93); backdrop-filter: blur(14px); }
.navigation { display: flex; min-height: 76px; align-items: center; justify-content: space-between; gap: 30px; }
.brand { font-family: "DM Mono", monospace; font-size: 13px; font-weight: 700; letter-spacing: -.05em; text-transform: uppercase; white-space: nowrap; }
.brand span { display: inline-flex; width: 31px; height: 31px; align-items: center; justify-content: center; margin-right: 7px; border-radius: 50%; color: var(--cream); background: var(--blue); font-size: 11px; letter-spacing: -.12em; }
.nav-links { display: flex; gap: 31px; color: var(--ink-soft); font-size: 13px; font-weight: 700; }
.nav-links a:hover { color: var(--orange); }
.header-action { display: inline-flex; gap: 8px; align-items: center; color: var(--blue); font-size: 13px; font-weight: 800; }
.header-action span { color: var(--orange); font-size: 19px; }
.site-header { background: rgba(248, 248, 243, .93); border-bottom: 1px solid rgba(18, 33, 45, .08); position: sticky; top: 0; z-index: 10; backdrop-filter: blur(12px); } .hero { overflow: hidden; padding: 74px 0 63px; background: radial-gradient(circle at 76% 20%, #d6e9e7 0, transparent 26%), linear-gradient(121deg, #edf4f0 0%, var(--cream) 56%, #f5e8d8 100%); }
.hero-layout { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(350px, .85fr); align-items: center; gap: 68px; }
.navigation { min-height: 76px; display: flex; align-items: center; justify-content: space-between; gap: 28px; } .eyebrow { display: flex; align-items: center; gap: 9px; margin: 0 0 18px; color: var(--orange); font-family: "DM Mono", monospace; font-size: 10px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; }
.eyebrow span { width: 22px; height: 1px; background: currentColor; }
.brand { color: var(--ink); font-size: 20px; font-weight: 800; letter-spacing: -.07em; }
.brand span { color: var(--orange); padding: 0 2px; }
.nav-links { display: flex; gap: 28px; font-size: 14px; font-weight: 600; color: #43515b; }
.nav-links a:hover, .text-link:hover, .subject-card a:hover { color: var(--orange); }
.nav-button { padding: 9px 17px; color: var(--blue); border: 1px solid var(--blue); border-radius: 999px; font-size: 13px; font-weight: 700; }
.nav-button:hover { background: var(--blue); color: var(--white); }
.hero { overflow: hidden; padding: 84px 0 74px; background: linear-gradient(115deg, #e8f1ee 0%, #f8f8f3 60%, #f8eee5 100%); }
.hero-grid { display: grid; grid-template-columns: 1.22fr .78fr; align-items: center; gap: 76px; }
.eyebrow, .section-kicker, .card-kicker { margin: 0 0 14px; color: var(--orange); font-size: 12px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
h1, h2, h3, p { margin-top: 0; } h1, h2, h3, p { margin-top: 0; }
h1, h2, h3 { font-family: "Source Serif 4", Georgia, serif; line-height: 1.08; } h1, h2, h3 { font-family: "Source Serif 4", Georgia, serif; line-height: 1.03; letter-spacing: -.055em; }
h1 { max-width: 680px; margin-bottom: 24px; font-size: clamp(44px, 5vw, 72px); font-weight: 700; letter-spacing: -.05em; } h1 { max-width: 680px; margin-bottom: 25px; font-size: clamp(48px, 5.55vw, 78px); }
h1 em { color: var(--blue); font-style: italic; } h1 em, h2 em { color: var(--blue); font-style: italic; }
.hero-text { max-width: 595px; margin-bottom: 32px; color: #42515a; font-size: 18px; } .hero-lead { max-width: 560px; margin-bottom: 33px; color: var(--ink-soft); font-size: 18px; line-height: 1.65; }
.hero-actions { display: flex; flex-wrap: wrap; gap: 13px; }
.button { display: inline-flex; align-items: center; justify-content: center; gap: 15px; border: 0; border-radius: 3px; padding: 15px 20px; font-size: 13px; font-weight: 800; cursor: pointer; transition: transform .2s, background .2s; }
.button:hover { transform: translateY(-2px); }
.button-primary { color: var(--white); background: var(--orange); box-shadow: 0 9px 19px rgba(238, 112, 70, .2); }
.button-primary:hover { background: #d95e36; }
.button-ghost { color: var(--blue); border: 1px solid var(--blue); background: transparent; }
.button-ghost:hover { color: var(--white); background: var(--blue); }
.hero-numbers { display: flex; gap: 30px; margin-top: 56px; }
.hero-numbers div { min-width: 100px; padding-left: 12px; border-left: 2px solid #a9c7c5; }
.hero-numbers strong { display: block; color: var(--blue); font-family: "Source Serif 4", Georgia, serif; font-size: 24px; line-height: 1.1; }
.hero-numbers span { display: block; margin-top: 4px; color: #66777c; font-size: 10px; line-height: 1.45; }
.hero-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 24px; } .portrait-zone { position: relative; min-height: 490px; }
.button { display: inline-block; border: 0; border-radius: 5px; padding: 15px 22px; font: inherit; font-size: 14px; font-weight: 800; cursor: pointer; transition: transform .2s, background .2s; } .portrait-frame { position: absolute; top: 0; right: 23px; width: min(100%, 385px); height: 460px; overflow: hidden; border-radius: 192px 192px 9px 9px; background: var(--blue); box-shadow: 21px 24px 0 #bed4d2; }
.button-primary { color: var(--white); background: var(--orange); box-shadow: 0 8px 20px rgba(232, 109, 62, .18); } .portrait-frame img { width: 121%; height: 100%; object-fit: cover; object-position: 68% center; transform: translateX(-12%); }
.button-primary:hover { background: #d75f32; transform: translateY(-2px); } .portrait-note { position: absolute; right: 0; bottom: 8px; padding: 13px 18px; color: var(--white); background: var(--blue-deep); font-family: "DM Mono", monospace; font-size: 11px; line-height: 1.35; letter-spacing: -.05em; }
.text-link { color: var(--blue); font-size: 14px; font-weight: 800; } .portrait-note span { margin-right: 6px; color: var(--yellow); font-size: 18px; }
.text-link span { margin-left: 6px; font-size: 18px; } .formula-sticker { position: absolute; top: 45px; left: -5px; display: flex; align-items: baseline; gap: 2px; width: 88px; height: 88px; padding: 22px 0 0 15px; border-radius: 50%; color: var(--ink); background: var(--yellow); transform: rotate(-10deg); }
.formula-sticker i { font-family: "Source Serif 4", Georgia, serif; font-size: 41px; font-weight: 700; line-height: 1; }.formula-sticker small { font-family: "DM Mono", monospace; font-size: 11px; }
.hero-stats { display: flex; gap: 31px; margin-top: 55px; } .trust-strip { color: #e5f0eb; background: var(--blue-deep); }
.hero-stats div { display: grid; gap: 2px; } .trust-content { display: flex; align-items: center; justify-content: space-between; gap: 18px; min-height: 61px; font-family: "DM Mono", monospace; font-size: 10px; letter-spacing: .04em; text-transform: uppercase; }.trust-content b { color: var(--yellow); }
.hero-stats strong { color: var(--blue); font-size: 18px; font-weight: 800; }
.hero-stats span { color: var(--muted); font-size: 11px; }
.formula-card { position: relative; min-height: 420px; overflow: hidden; border-radius: 50% 50% 4% 4%; background: var(--blue); box-shadow: 23px 27px 0 #d5e2dc; } .section { padding: 113px 0; }
.formula-card::after { position: absolute; width: 100%; height: 30%; left: 0; bottom: 0; background: #10475f; content: ""; } .about-layout { display: grid; grid-template-columns: 210px 1fr; gap: 62px; }.section-label { display: flex; align-items: center; gap: 11px; color: var(--orange); font-family: "DM Mono", monospace; font-size: 10px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }.section-label span { display: inline-grid; width: 25px; height: 25px; place-items: center; border: 1px solid currentColor; border-radius: 50%; }.section-label p { margin: 0; }
.formula-center { position: absolute; top: 50%; left: 50%; z-index: 2; width: 210px; height: 210px; padding-top: 60px; border: 1px solid rgba(255, 255, 255, .45); border-radius: 50%; text-align: center; color: white; transform: translate(-50%, -50%); } .about-content h2 { max-width: 800px; margin-bottom: 46px; font-size: clamp(38px, 4.3vw, 60px); }.about-columns { display: grid; grid-template-columns: 1.12fr .88fr; gap: 58px; }.about-columns p { color: var(--ink-soft); font-size: 14px; }.about-intro { margin: 0; color: var(--blue) !important; font-family: "Source Serif 4", Georgia, serif; font-size: 25px !important; line-height: 1.35; }.education-mark { display: flex; align-items: center; gap: 16px; margin-top: 45px; padding-top: 22px; border-top: 1px solid var(--line); }.education-mark span { color: var(--blue); font-family: "DM Mono", monospace; font-size: 20px; font-weight: 700; }.education-mark p { margin: 0; color: var(--ink-soft); font-size: 11px; line-height: 1.4; }
.formula-center p { margin-bottom: 6px; color: #b9d4dc; font-size: 10px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
.formula-center strong { font-family: "Source Serif 4", Georgia, serif; font-size: 28px; line-height: 1; }
.card-orbit { position: absolute; top: 50%; left: 50%; border: 1px solid rgba(255, 255, 255, .27); border-radius: 50%; transform: translate(-50%, -50%); }
.orbit-one { width: 310px; height: 310px; }
.orbit-two { width: 465px; height: 465px; }
.formula { position: absolute; z-index: 3; color: #f5c05a; font-family: "Source Serif 4", Georgia, serif; }
.formula-e { top: 64px; left: 48px; font-size: 30px; transform: rotate(-11deg); }
.formula-root { right: 47px; bottom: 80px; font-size: 56px; }
.formula-sum { top: 115px; right: 64px; color: #e88c68; font-size: 62px; }
.section { padding: 110px 0; } .programs { background: #dde9e6; }.section-heading { display: grid; grid-template-columns: .75fr 1.12fr .85fr; align-items: end; gap: 38px; margin-bottom: 44px; }.section-heading h2 { margin: 0; font-size: clamp(39px, 4.2vw, 59px); }.section-heading > p { margin: 0 0 4px; color: var(--ink-soft); font-size: 13px; }.program-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 23px; }.program-card { min-height: 408px; padding: 31px 33px; background: var(--cream); }.physics-card { border-top: 5px solid var(--blue); }.math-card { border-top: 5px solid var(--orange); }.program-top { display: flex; justify-content: space-between; align-items: start; }.program-code { color: var(--orange); font-family: "DM Mono", monospace; font-size: 10px; font-weight: 700; }.program-symbol { color: #b8ccc9; font-family: "Source Serif 4", Georgia, serif; font-size: 59px; line-height: .75; }.math-card .program-symbol { color: #e9c7b7; }.program-card h3 { margin: 31px 0 13px; font-size: 40px; }.program-card > p { max-width: 370px; color: var(--ink-soft); font-size: 13px; }.program-card ul { min-height: 80px; margin: 22px 0 22px; padding: 0; list-style: none; color: var(--ink-soft); font-size: 12px; }.program-card li { position: relative; padding: 3px 0 3px 14px; }.program-card li::before { position: absolute; left: 0; color: var(--orange); content: "·"; font-size: 22px; line-height: .75; }.program-card a { color: var(--blue); font-size: 12px; font-weight: 800; }.program-card a span { margin-left: 6px; color: var(--orange); font-size: 18px; }.program-card a:hover { color: var(--orange); }
.split-layout { display: grid; grid-template-columns: .72fr 1.28fr; gap: 80px; }
.split-layout h2, .section-heading h2, .process h2, .booking h2 { margin-bottom: 25px; font-size: clamp(36px, 4vw, 54px); letter-spacing: -.045em; }
.split-layout .lead { max-width: 610px; color: var(--blue); font-family: "Source Serif 4", Georgia, serif; font-size: 25px; line-height: 1.35; }
.split-layout p:not(.lead) { max-width: 650px; color: var(--muted); }
.subjects { background: var(--sand); } .approach { background: var(--cream); }.approach-layout { display: grid; grid-template-columns: .95fr 1.05fr; gap: 85px; }.approach-copy h2 { margin: 32px 0 0; font-size: clamp(38px, 4vw, 57px); }.approach-steps { display: grid; gap: 0; }.approach-steps article { display: grid; grid-template-columns: 47px 150px 1fr; gap: 18px; padding: 23px 0; border-top: 1px solid var(--line); }.approach-steps article:last-child { border-bottom: 1px solid var(--line); }.approach-steps span { color: var(--orange); font-family: "DM Mono", monospace; font-size: 11px; font-weight: 700; }.approach-steps h3 { margin: 0; font-size: 23px; }.approach-steps p { margin: 3px 0 0; color: var(--ink-soft); font-size: 12px; }
.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 40px; margin-bottom: 46px; }
.section-heading h2 { margin-bottom: 0; }
.section-heading > p { max-width: 350px; margin-bottom: 6px; color: var(--muted); font-size: 14px; }
.subject-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; }
.subject-card { min-height: 351px; padding: 35px; background: var(--white); border-radius: 3px; }
.subject-card.physics-card { border-top: 5px solid var(--blue); }
.subject-card.math-card { border-top: 5px solid var(--orange); }
.subject-icon { display: inline-flex; width: 50px; height: 50px; align-items: center; justify-content: center; margin-bottom: 35px; border-radius: 50%; color: var(--white); background: var(--blue); font-family: "Source Serif 4", Georgia, serif; font-size: 28px; font-weight: 700; }
.math-card .subject-icon { background: var(--orange); }
.subject-card .card-kicker { margin-bottom: 6px; font-size: 10px; }
.subject-card h3 { margin-bottom: 18px; font-size: 34px; letter-spacing: -.04em; }
.subject-card ul { min-height: 90px; margin: 0 0 23px; padding: 0; list-style: none; color: var(--muted); font-size: 13px; }
.subject-card li { padding: 3px 0 3px 18px; position: relative; }
.subject-card li::before { position: absolute; left: 0; color: var(--orange); content: "•"; }
.subject-card a { color: var(--blue); font-size: 13px; font-weight: 800; }
.subject-card a span { padding-left: 4px; }
.process { background: #f2f6f4; } .booking-section { padding: 104px 0; color: var(--white); background: linear-gradient(120deg, #0b3d50, #0a2f3e); }.booking-layout { display: grid; grid-template-columns: .84fr 1.16fr; gap: 74px; align-items: start; }.eyebrow-light { color: #f4a181; }.booking-intro h2 { max-width: 505px; margin: 0 0 22px; color: var(--white); font-size: clamp(39px, 4.5vw, 61px); }.booking-intro h2 em { color: var(--yellow); }.booking-intro > p:not(.eyebrow):not(.booking-help) { max-width: 435px; color: #c6d8d8; font-size: 14px; }.availability-card { display: flex; align-items: center; gap: 14px; width: max-content; margin: 40px 0 23px; padding: 15px 21px; border: 1px solid rgba(231, 245, 240, .22); background: rgba(255, 255, 255, .06); }.availability-dot { width: 9px; height: 9px; border-radius: 50%; background: #77dcad; box-shadow: 0 0 0 5px rgba(119, 220, 173, .12); }.availability-card div { display: flex; align-items: center; gap: 10px; }.availability-card strong { color: var(--yellow); font-family: "Source Serif 4", Georgia, serif; font-size: 34px; line-height: 1; }.availability-card p { margin: 0; color: #cee1df; font-size: 10px; line-height: 1.35; }.booking-help { max-width: 380px; color: #8ca9ae; font-size: 11px; line-height: 1.5; }
.process h2 { margin-bottom: 47px; } .booking-form-card { padding: 33px; color: var(--ink); background: var(--cream); box-shadow: 15px 15px 0 rgba(0, 0, 0, .13); }.form-title { display: flex; align-items: baseline; justify-content: space-between; gap: 20px; padding-bottom: 21px; border-bottom: 1px solid var(--line); }.form-title span { font-family: "Source Serif 4", Georgia, serif; font-size: 26px; font-weight: 700; letter-spacing: -.04em; }.form-title p { margin: 0; color: #7a898c; font-size: 10px; }.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 17px; margin-top: 22px; }.field { display: grid; gap: 6px; }.field.full { grid-column: 1 / -1; }.field label { color: var(--ink-soft); font-size: 11px; font-weight: 800; }.field label b, .consent-label b { color: var(--orange); }.form-control { width: 100%; min-height: 43px; padding: 10px 11px; border: 1px solid #cbd6d5; border-radius: 2px; outline: none; color: var(--ink); background: #fffefa; font-size: 12px; }.form-control:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(15, 83, 105, .12); }textarea.form-control { min-height: 90px; resize: vertical; }.consent-label { display: flex; gap: 9px; align-items: start; margin-top: 18px; color: #617477; font-size: 10px; line-height: 1.5; }.consent-control { margin: 2px 0 0; accent-color: var(--blue); }.consent-label a { color: var(--blue); text-decoration: underline; }.form-submit { width: 100%; margin-top: 22px; }.button-disabled { color: #8d9997; background: #d8dfdc; cursor: not-allowed; }.button-disabled:hover { transform: none; }.error { color: #b7442b; font-size: 10px; line-height: 1.3; }.consent-error { display: block; margin-top: 4px; }.form-errors, .form-message { margin: 0 0 16px; padding: 11px 13px; font-size: 12px; }.form-errors { color: #9c351f; background: #fae6df; }.form-message-success { color: #1d6444; background: #e0f2e8; border-left: 3px solid #49a475; }
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 32px; }
.steps article { position: relative; padding: 30px 25px 10px 0; border-top: 1px solid #aec1bb; }
.steps span { display: block; margin-bottom: 35px; color: var(--orange); font-size: 13px; font-weight: 800; }
.steps h3 { margin-bottom: 11px; font-size: 25px; letter-spacing: -.03em; }
.steps p { color: var(--muted); font-size: 14px; }
.booking { padding: 108px 0; color: var(--white); background: #153545; } .faq-layout { display: grid; grid-template-columns: .72fr 1.28fr; gap: 76px; }.faq h2 { margin: 27px 0 0; font-size: clamp(40px, 4.2vw, 58px); }.faq-list { border-top: 1px solid var(--line); }.faq details { border-bottom: 1px solid var(--line); }.faq summary { display: flex; align-items: center; justify-content: space-between; gap: 25px; padding: 21px 0; cursor: pointer; list-style: none; font-family: "Source Serif 4", Georgia, serif; font-size: 23px; font-weight: 700; letter-spacing: -.03em; }.faq summary::-webkit-details-marker { display: none; }.faq summary span { color: var(--orange); font-family: "Manrope", sans-serif; font-size: 21px; font-weight: 400; }.faq details[open] summary span { transform: rotate(45deg); }.faq details p { max-width: 650px; padding: 0 10px 20px 0; color: var(--ink-soft); font-size: 13px; }
.booking-layout { display: grid; grid-template-columns: .82fr 1.18fr; gap: 70px; align-items: start; }
.booking .section-kicker { color: #f4a483; }
.booking h2 { max-width: 500px; }
.booking-copy > p:not(.section-kicker) { max-width: 415px; color: #c0d0d2; }
.privacy-note { margin-top: 42px; font-size: 12px; }
.privacy-note::before { margin-right: 8px; content: "⌁"; color: #f4a483; }
.form-card { padding: 33px; color: var(--ink); background: var(--white); border-radius: 4px; } .site-footer { color: #cfddda; background: var(--blue-deep); }.footer-main { display: flex; align-items: center; justify-content: space-between; gap: 22px; min-height: 107px; border-bottom: 1px solid rgba(207, 221, 218, .17); }.brand-footer { color: var(--white); }.brand-footer span { color: var(--blue-deep); background: var(--yellow); }.footer-main p { margin: 0; color: #a8c0bf; font-size: 12px; }.footer-main > a:last-child { color: var(--yellow); font-size: 12px; font-weight: 800; }.footer-main > a:last-child span { margin-left: 5px; font-size: 17px; }.footer-bottom { display: flex; justify-content: space-between; gap: 20px; padding: 18px 0; color: #87a3a7; font-size: 10px; }.footer-bottom a:hover { color: var(--white); }
.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 18px; }
.field { display: grid; gap: 7px; }
.field.full-width { grid-column: 1 / -1; }
.field label { font-size: 12px; font-weight: 800; }
.form-control { width: 100%; min-height: 43px; padding: 9px 11px; border: 1px solid #ccd4d2; border-radius: 3px; outline: none; color: var(--ink); background: #fdfdfb; font: inherit; font-size: 13px; }
textarea.form-control { min-height: 89px; resize: vertical; }
.form-control:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(25, 93, 120, .12); }
.submit-button { width: 100%; margin-top: 25px; }
.error { color: #b33f26; font-size: 11px; line-height: 1.3; }
.form-errors { margin-bottom: 15px; padding: 11px; color: #8f341f; background: #fce9e4; font-size: 13px; }
.message { margin-bottom: 18px; padding: 13px 15px; font-size: 13px; font-weight: 600; }
.message-success { color: #275c43; background: #e3f2e8; border-left: 3px solid #4b9a71; }
.site-footer { padding: 27px 0; color: #b5c0c3; background: #0f2937; font-size: 12px; } .privacy-page { min-height: 100vh; display: flex; flex-direction: column; }.privacy-content { flex: 1; padding: 92px 0; }.privacy-content h1 { margin-bottom: 27px; }.privacy-lead { max-width: 640px; margin-bottom: 51px; color: var(--ink-soft); font-family: "Source Serif 4", Georgia, serif; font-size: 26px; line-height: 1.35; }.privacy-text { max-width: 760px; margin-bottom: 44px; }.privacy-text h2 { margin: 27px 0 8px; font-size: 27px; }.privacy-text p { color: var(--ink-soft); font-size: 14px; }
.footer-content { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.site-footer .brand { color: var(--white); }
.site-footer p { margin: 0; }
@media (max-width: 800px) {
.nav-links { display: none; }
.hero { padding: 64px 0; }
.hero-grid, .booking-layout { grid-template-columns: 1fr; gap: 48px; }
.formula-card { min-height: 350px; max-width: 500px; width: calc(100% - 20px); margin: 0 auto; }
.split-layout { grid-template-columns: 1fr; gap: 5px; }
.section-heading { display: block; }
.section-heading > p { margin-top: 18px; }
.section { padding: 78px 0; }
.booking { padding: 78px 0; }
}
@media (max-width: 560px) {
.container { width: min(100% - 32px, 1120px); }
.navigation { min-height: 65px; }
.nav-button { padding: 7px 12px; }
h1 { font-size: 43px; }
.hero-text { font-size: 16px; }
.hero-stats { gap: 16px; margin-top: 38px; }
.hero-stats strong { font-size: 15px; }
.hero-stats span { font-size: 9px; }
.formula-card { min-height: 300px; }
.orbit-one { width: 245px; height: 245px; }
.orbit-two { width: 365px; height: 365px; }
.formula-e { top: 40px; left: 28px; }
.formula-sum { top: 66px; right: 35px; }
.formula-root { right: 28px; bottom: 48px; }
.subject-grid, .steps, .form-grid { grid-template-columns: 1fr; }
.subject-card { min-height: auto; }
.subject-card ul { min-height: auto; }
.steps { gap: 12px; }
.steps span { margin-bottom: 18px; }
.form-card { padding: 22px 18px; }
.footer-content { align-items: flex-start; flex-direction: column; gap: 7px; }
}
@media (max-width: 860px) { .nav-links { display: none; }.hero-layout, .booking-layout, .faq-layout, .approach-layout { grid-template-columns: 1fr; }.hero-layout { gap: 42px; }.portrait-zone { width: min(100%, 450px); min-height: 450px; margin: 0 auto; }.portrait-frame { right: 25px; }.about-layout { grid-template-columns: 1fr; gap: 26px; }.section-heading { grid-template-columns: 1fr; gap: 20px; }.section-heading > p { max-width: 480px; }.approach-layout { gap: 42px; }.booking-layout { gap: 48px; }.faq-layout { gap: 40px; }.section { padding: 85px 0; } }
@media (max-width: 560px) { .container { width: min(100% - 32px, 1160px); }.navigation { min-height: 64px; }.brand { font-size: 10px; }.brand span { width: 27px; height: 27px; }.header-action { font-size: 11px; }.hero { padding: 54px 0 44px; }.hero-lead { font-size: 16px; }.hero-numbers { gap: 12px; margin-top: 42px; }.hero-numbers div { min-width: 0; padding-left: 8px; }.hero-numbers strong { font-size: 19px; }.hero-numbers span { font-size: 8px; }.portrait-zone { min-height: 385px; }.portrait-frame { right: 16px; width: calc(100% - 28px); height: 358px; box-shadow: 13px 15px 0 #bed4d2; }.portrait-note { right: 0; bottom: 0; font-size: 9px; }.formula-sticker { top: 29px; left: -2px; width: 68px; height: 68px; padding: 17px 0 0 11px; }.formula-sticker i { font-size: 31px; }.formula-sticker small { font-size: 8px; }.trust-content { justify-content: center; min-height: 52px; flex-wrap: wrap; gap: 6px 13px; padding: 10px 0; font-size: 8px; }.about-columns, .program-grid { grid-template-columns: 1fr; gap: 22px; }.about-content h2 { margin-bottom: 31px; }.program-card { min-height: auto; padding: 27px 23px; }.program-card h3 { margin-top: 23px; }.approach-steps article { grid-template-columns: 36px 1fr; gap: 8px; }.approach-steps p { grid-column: 2; }.booking-section { padding: 75px 0; }.booking-form-card { padding: 23px 17px; box-shadow: 8px 8px 0 rgba(0, 0, 0, .13); }.form-title { align-items: start; flex-direction: column; gap: 2px; }.form-grid { grid-template-columns: 1fr; }.field.full { grid-column: auto; }.faq summary { font-size: 20px; }.footer-main { align-items: start; flex-direction: column; justify-content: center; gap: 8px; padding: 22px 0; }.footer-bottom { align-items: start; flex-direction: column; gap: 5px; } }
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB