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/`.
Перед публикацией сайта замените `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"
DEBUG = True
ALLOWED_HOSTS = []
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
INSTALLED_APPS = [
"django.contrib.admin",
@@ -66,4 +66,3 @@ STATIC_URL = "static/"
STATICFILES_DIRS = [BASE_DIR / "static"]
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
+3 -7
View File
@@ -1,14 +1,10 @@
"""URL configuration for the teacher site."""
from django.contrib import admin
from django.urls import include, path
from consultations.admin import teacher_admin_site
urlpatterns = [
path("admin/", admin.site.urls),
path("admin/", teacher_admin_site.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):
list_display = (
"name",
"student_name",
"subject",
"exam",
"preferred_date",
"preferred_time",
"phone",
"is_processed",
"appointment_time",
"format",
"status_badge",
"contact_details",
)
list_filter = ("subject", "exam", "is_processed", "preferred_date")
search_fields = ("name", "phone", "contact", "comment")
list_editable = ("is_processed",)
readonly_fields = ("created_at",)
list_filter = ("status", "subject", "exam", "format", "student_grade", "preferred_date", "reminder_sent")
search_fields = ("name", "phone", "contact", "comment", "teacher_note")
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)
+42 -14
View File
@@ -1,31 +1,46 @@
from django import forms
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):
slot = AvailableSlotChoiceField(
queryset=ConsultationSlot.objects.none(),
empty_label="Выберите свободное время",
label="Удобное время",
)
class Meta:
model = ConsultationRequest
fields = (
"name",
"phone",
"contact",
"student_grade",
"subject",
"exam",
"preferred_date",
"preferred_time",
"format",
"slot",
"comment",
"consent_given",
)
widgets = {
"name": forms.TextInput(attrs={"placeholder": "Как к вам обращаться"}),
"phone": forms.TelInput(attrs={"placeholder": "+7 (999) 123-45-67"}),
"contact": forms.TextInput(attrs={"placeholder": "@username, WhatsApp и т. п."}),
"preferred_date": forms.DateInput(attrs={"type": "date"}),
"preferred_time": forms.TimeInput(attrs={"type": "time"}),
"comment": forms.Textarea(
attrs={
"placeholder": "Например: текущий класс, темы, которые вызывают сложности",
"placeholder": "Класс, темы и вопросы, которые хотите разобрать",
"rows": 4,
}
),
@@ -33,15 +48,28 @@ class ConsultationRequestForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.fields.values():
field.widget.attrs["class"] = "form-control"
self.fields["preferred_date"].widget.attrs["min"] = timezone.localdate().isoformat()
self.fields["slot"].queryset = ConsultationSlot.objects.available()
for name, field in self.fields.items():
if name != "consent_given":
field.widget.attrs["class"] = "form-control"
self.fields["consent_given"].widget.attrs["class"] = "consent-control"
self.fields["contact"].required = False
self.fields["student_grade"].required = False
self.fields["comment"].required = False
def clean_preferred_date(self):
preferred_date = self.cleaned_data["preferred_date"]
if preferred_date < timezone.localdate():
raise forms.ValidationError("Выберите сегодняшнюю дату или более позднюю.")
return preferred_date
def clean_slot(self):
slot = self.cleaned_data["slot"]
if not slot.is_available:
raise forms.ValidationError("Это время уже занято. Выберите другой свободный слот.")
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.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):
@@ -10,9 +72,22 @@ class ConsultationRequest(models.Model):
class Exam(models.TextChoices):
OGE = "oge", "ОГЭ"
EGE = "ege", "ЕГЭ"
SCHOOL = "school", "Школьная программа"
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(
"Телефон",
max_length=30,
@@ -24,19 +99,54 @@ class ConsultationRequest(models.Model):
],
)
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)
exam = models.CharField("Цель подготовки", max_length=20, choices=Exam.choices)
preferred_date = models.DateField("Желаемая дата")
preferred_time = models.TimeField("Желаемое время")
comment = models.TextField("Комментарий", max_length=1000, blank=True)
format = models.CharField("Предпочтительный формат", max_length=20, choices=Format.choices, default=Format.ONLINE)
slot = models.ForeignKey(
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)
is_processed = models.BooleanField("Заявка обработана", default=False)
updated_at = models.DateTimeField("Изменена", auto_now=True)
class Meta:
verbose_name = "Заявка на консультацию"
verbose_name_plural = "Заявки на консультации"
ordering = ("is_processed", "preferred_date", "preferred_time")
verbose_name = "Запись на консультацию"
verbose_name_plural = "Записи на консультации"
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):
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 %}
+133 -103
View File
@@ -4,191 +4,221 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Подготовка к ОГЭ и ЕГЭ по физике и математике с преподавателем Игорем Олеговичем Безрукавовым.">
<title>Физика и математика — Игорь Олегович Безрукавов</title>
<meta name="description" content="Подготовка к ОГЭ и ЕГЭ по физике и математике с Игорем Олеговичем Безрукавовым.">
<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 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' %}">
</head>
<body>
<header class="site-header">
<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="Основная навигация">
<a href="#about">О преподавателе</a>
<a href="#subjects">Направления</a>
<a href="#booking">Запись</a>
<a href="#programs">Направления</a>
<a href="#faq">Вопросы</a>
</nav>
<a class="nav-button" href="#booking">Записаться</a>
<a class="header-action" href="#booking">Выбрать время <span aria-hidden="true"></span></a>
</div>
</header>
<main id="top">
<section class="hero">
<div class="container hero-grid">
<div class="hero-content">
<p class="eyebrow">Физика и математика · 5–11 классы</p>
<h1>Готовимся к экзаменам <em>с пониманием</em>, а не наугад.</h1>
<p class="hero-text">Индивидуальные консультации по подготовке к ОГЭ и ЕГЭ: разбираем сложные темы, выстраиваем логику решения и спокойно идём к цели.</p>
<div class="container hero-layout">
<div class="hero-copy">
<p class="eyebrow"><span></span> Физика · математика · 5–11 классы</p>
<h1>Экзамен — это задача. <em>Её можно решить.</em></h1>
<p class="hero-lead">Индивидуальная подготовка к ОГЭ и ЕГЭ: от пробелов в темах до уверенности в каждом шаге решения.</p>
<div class="hero-actions">
<a class="button button-primary" href="#booking">Записаться на консультацию</a>
<a class="text-link" href="#about">Узнать обо мне <span aria-hidden="true"></span></a>
<a class="button button-primary" href="#booking">Записаться на консультацию <span aria-hidden="true"></span></a>
<a class="button button-ghost" href="#about">О преподавателе</a>
</div>
<div class="hero-stats" aria-label="Ключевые факты">
<div><strong>10+</strong><span>лет опыта</span></div>
<div><strong>2</strong><span>предмета</span></div>
<div><strong>ОГЭ · ЕГЭ</strong><span>подготовка к экзаменам</span></div>
<div class="hero-numbers" aria-label="Опыт преподавателя">
<div><strong>10+</strong><span>лет преподавания<br>и репетиторства</span></div>
<div><strong>2</strong><span>предмета для<br>уверенного результата</span></div>
<div><strong>АГУ</strong><span>бакалавриат<br>и магистратура</span></div>
</div>
</div>
<div class="formula-card" aria-label="Физика и математика">
<div class="card-orbit orbit-one"></div>
<div class="card-orbit orbit-two"></div>
<span class="formula formula-e">E = mc²</span>
<span class="formula formula-root">√x</span>
<span class="formula formula-sum">Σ</span>
<div class="formula-center">
<p>точные науки</p>
<strong>понятно<br>и спокойно</strong>
<div class="portrait-zone">
<div class="portrait-frame">
<img src="{% static 'images/igor-bezrukavov.jpg' %}" alt="Игорь Олегович Безрукавов">
</div>
<div class="portrait-note"><span></span> Разбираем<br>до понимания</div>
<div class="formula-sticker" aria-hidden="true"><i>F</i><small>= ma</small></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>
</section>
<section class="section about" id="about">
<div class="container split-layout">
<p class="section-kicker">О преподавателе</p>
<div>
<h2>Игорь Олегович<br>Безрукавов</h2>
<p class="lead">Преподаватель физики и математики с опытом преподавания и репетиторства более 10 лет.</p>
<p>Окончил Астраханский государственный университет: бакалавриат и магистратуру по специальности «Преподавание физики и математики».</p>
<p>На консультации важно не просто получить ответ, а разобраться, почему решение работает — тогда знания остаются с учеником и на контрольной, и на экзамене.</p>
<div class="container about-layout">
<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>
<p>Окончил Астраханский государственный университет: бакалавриат и магистратуру по специальности «Преподавание физики и математики».</p>
<p>На занятии ученик не заучивает шаблон, а учится видеть логику задачи — это помогает уверенно работать и на уроке, и на экзамене.</p>
</div>
</div>
<div class="education-mark"><span>АГУ</span><p>Педагогическое образование<br>по физике и математике</p></div>
</div>
</div>
</section>
<section class="section subjects" id="subjects">
<section class="section programs" id="programs">
<div class="container">
<div class="section-heading">
<div>
<p class="section-kicker">Направления</p>
<h2>Подготовка в нужном темпе</h2>
</div>
<p>Подберём фокус консультации под текущий уровень, цели и дату экзамена.</p>
<div class="section-label"><span>02</span><p>Направления</p></div>
<h2>Работаем на<br><em>вашу цель</em></h2>
<p>Выбираем темп и программу с учётом класса, стартового уровня и даты экзамена.</p>
</div>
<div class="subject-grid">
<article class="subject-card physics-card">
<span class="subject-icon">F</span>
<p class="card-kicker">Предмет</p>
<div class="program-grid">
<article class="program-card physics-card">
<div class="program-top"><span class="program-code">01 / PH</span><span class="program-symbol">F</span></div>
<h3>Физика</h3>
<p>От формул и единиц измерения до сложных задач второй части.</p>
<ul>
<li>Механика, термодинамика, электродинамика</li>
<li>Задачи с формулами и графиками</li>
<li>Подготовка к ОГЭ и ЕГЭ</li>
<li>ОГЭ и ЕГЭ</li>
<li>Механика, электричество, оптика</li>
<li>Графики, эксперименты, расчёты</li>
</ul>
<a href="#booking">Записаться <span aria-hidden="true"></span></a>
<a href="#booking">Выбрать консультацию <span></span></a>
</article>
<article class="subject-card math-card">
<span class="subject-icon">π</span>
<p class="card-kicker">Предмет</p>
<article class="program-card math-card">
<div class="program-top"><span class="program-code">02 / MTH</span><span class="program-symbol">π</span></div>
<h3>Математика</h3>
<p>Понятная алгебра и геометрия вместо набора непонятных правил.</p>
<ul>
<li>Алгебра, геометрия, вероятность</li>
<li>Уравнения, неравенства, задачи</li>
<li>Подготовка к ОГЭ и ЕГЭ</li>
<li>ОГЭ и ЕГЭ</li>
<li>Уравнения, неравенства, функции</li>
<li>Планиметрия и стереометрия</li>
</ul>
<a href="#booking">Записаться <span aria-hidden="true"></span></a>
<a href="#booking">Выбрать консультацию <span></span></a>
</article>
</div>
</div>
</section>
<section class="section process">
<div class="container">
<p class="section-kicker">Как это работает</p>
<h2>Три простых шага</h2>
<div class="steps">
<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>
<section class="section approach">
<div class="container approach-layout">
<div class="approach-copy">
<div class="section-label"><span>03</span><p>Как проходят занятия</p></div>
<h2>Не «натаскивание»,<br>а <em>система.</em></h2>
</div>
<div class="approach-steps">
<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>
</section>
<section class="booking" id="booking">
<section class="booking-section" id="booking">
<div class="container booking-layout">
<div class="booking-copy">
<p class="section-kicker">Запись на консультацию</p>
<h2>Сделайте первый шаг к уверенному экзамену.</h2>
<p>Заполните форму — время будет окончательно подтверждено после связи с преподавателем.</p>
<p class="privacy-note">Контакты используются только для ответа на вашу заявку.</p>
<div class="booking-intro">
<p class="eyebrow eyebrow-light"><span></span> Онлайн-запись</p>
<h2>Выберите удобное время <em>для старта.</em></h2>
<p>Слот предварительно бронируется за вами. Игорь Олегович подтвердит консультацию по указанным контактам.</p>
<div class="availability-card">
<span class="availability-dot"></span>
<div><strong>{{ available_slot_count }}</strong><p>свободных окон<br>для записи сейчас</p></div>
</div>
<p class="booking-help">Не нашли подходящее время? Напишите желаемый день в комментарии — постараемся подобрать вариант.</p>
</div>
<div class="form-card">
<div class="booking-form-card">
{% if 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 %}
{% endif %}
{% if form.non_field_errors %}
<div class="form-errors">{{ form.non_field_errors }}</div>
{% endif %}
<div class="form-title"><span>Запись на консультацию</span><p>Поля со звёздочкой обязательны</p></div>
{% if form.non_field_errors %}<div class="form-errors">{{ form.non_field_errors }}</div>{% endif %}
<form method="post" action="#booking" novalidate>
{% csrf_token %}
<div class="form-grid">
<div class="field full-width">
<label for="{{ form.name.id_for_label }}">{{ form.name.label }}</label>
{{ form.name }}
{% for error in form.name.errors %}<span class="error">{{ error }}</span>{% endfor %}
<div class="field full">
<label for="{{ form.name.id_for_label }}">{{ form.name.label }} <b>*</b></label>
{{ form.name }}{% for error in form.name.errors %}<span class="error">{{ error }}</span>{% endfor %}
</div>
<div class="field">
<label for="{{ form.phone.id_for_label }}">{{ form.phone.label }}</label>
{{ form.phone }}
{% for error in form.phone.errors %}<span class="error">{{ error }}</span>{% endfor %}
<label for="{{ form.phone.id_for_label }}">{{ form.phone.label }} <b>*</b></label>
{{ form.phone }}{% for error in form.phone.errors %}<span class="error">{{ error }}</span>{% endfor %}
</div>
<div class="field">
<label for="{{ form.contact.id_for_label }}">{{ form.contact.label }}</label>
{{ form.contact }}
{% for error in form.contact.errors %}<span class="error">{{ error }}</span>{% endfor %}
{{ form.contact }}{% for error in form.contact.errors %}<span class="error">{{ error }}</span>{% endfor %}
</div>
<div class="field">
<label for="{{ form.subject.id_for_label }}">{{ form.subject.label }}</label>
{{ form.subject }}
{% for error in form.subject.errors %}<span class="error">{{ error }}</span>{% endfor %}
<label for="{{ form.student_grade.id_for_label }}">{{ form.student_grade.label }}</label>
{{ form.student_grade }}{% for error in form.student_grade.errors %}<span class="error">{{ error }}</span>{% endfor %}
</div>
<div class="field">
<label for="{{ form.exam.id_for_label }}">{{ form.exam.label }}</label>
{{ form.exam }}
{% for error in form.exam.errors %}<span class="error">{{ error }}</span>{% endfor %}
<label for="{{ form.subject.id_for_label }}">{{ form.subject.label }} <b>*</b></label>
{{ form.subject }}{% for error in form.subject.errors %}<span class="error">{{ error }}</span>{% endfor %}
</div>
<div class="field">
<label for="{{ form.preferred_date.id_for_label }}">{{ form.preferred_date.label }}</label>
{{ form.preferred_date }}
{% for error in form.preferred_date.errors %}<span class="error">{{ error }}</span>{% endfor %}
<label for="{{ form.exam.id_for_label }}">{{ form.exam.label }} <b>*</b></label>
{{ form.exam }}{% for error in form.exam.errors %}<span class="error">{{ error }}</span>{% endfor %}
</div>
<div class="field">
<label for="{{ form.preferred_time.id_for_label }}">{{ form.preferred_time.label }}</label>
{{ form.preferred_time }}
{% for error in form.preferred_time.errors %}<span class="error">{{ error }}</span>{% endfor %}
<label for="{{ form.format.id_for_label }}">{{ form.format.label }} <b>*</b></label>
{{ form.format }}{% for error in form.format.errors %}<span class="error">{{ error }}</span>{% endfor %}
</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>
{{ form.comment }}
{% for error in form.comment.errors %}<span class="error">{{ error }}</span>{% endfor %}
{{ form.comment }}{% for error in form.comment.errors %}<span class="error">{{ error }}</span>{% endfor %}
</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>
</div>
</div>
</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>
<footer class="site-footer">
<div class="container footer-content">
<a class="brand" href="#top">ИО<span>·</span>учёба</a>
<p>Физика и математика · подготовка к ОГЭ и ЕГЭ</p>
<p>© {% now "Y" %} Игорь Олегович Безрукавов</p>
<div class="container footer-main">
<a class="brand brand-footer" href="#top"><span>ИО</span> physics &amp; math</a>
<p>Физика и математика без лишней тревоги.</p>
<a href="#booking">Записаться <span></span></a>
</div>
<div class="container footer-bottom"><span>© {% now "Y" %} Игорь Олегович Безрукавов</span><a href="{% url 'consultations:privacy_policy' %}">Политика конфиденциальности</a></div>
</footer>
</body>
</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 = [
path("", views.home, name="home"),
path("privacy/", views.privacy_policy, name="privacy_policy"),
]
+25 -7
View File
@@ -1,21 +1,39 @@
from django.contrib import messages
from django.db import IntegrityError, transaction
from django.shortcuts import redirect, render
from django.urls import reverse
from .forms import ConsultationRequestForm
from .models import ConsultationSlot
def home(request):
if request.method == "POST":
form = ConsultationRequestForm(request.POST)
if form.is_valid():
form.save()
messages.success(
request,
"Заявка отправлена. Игорь Олегович свяжется с вами, чтобы подтвердить время консультации.",
)
return redirect(f"{reverse('consultations:home')}#booking")
try:
with transaction.atomic():
form.save()
except IntegrityError:
form.add_error("slot", "Это время только что занял другой ученик. Выберите другой слот.")
else:
messages.success(
request,
"Заявка принята. Игорь Олегович свяжется с вами для подтверждения консультации.",
)
return redirect(f"{reverse('consultations:home')}#booking")
else:
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 {
--ink: #12212d;
--muted: #5f6a72;
--paper: #f8f8f3;
--sand: #eeece3;
--blue: #195d78;
--blue-dark: #10475f;
--orange: #e86d3e;
--line: #d8ddd6;
--ink: #142e3b;
--ink-soft: #3d5661;
--blue: #0f5369;
--blue-deep: #0a3445;
--sky: #dcecf1;
--cream: #f8f5ee;
--orange: #ee7046;
--yellow: #f6c85f;
--line: #cfdbdb;
--white: #ffffff;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
color: var(--ink);
background: var(--paper);
font-family: "Manrope", Arial, sans-serif;
font-size: 16px;
line-height: 1.6;
}
body { margin: 0; color: var(--ink); background: var(--cream); font-family: "Manrope", Arial, sans-serif; font-size: 16px; line-height: 1.6; }
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); }
.navigation { min-height: 76px; display: flex; align-items: center; justify-content: space-between; gap: 28px; }
.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; }
.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; }
.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; }
h1, h2, h3, p { margin-top: 0; }
h1, h2, h3 { font-family: "Source Serif 4", Georgia, serif; line-height: 1.08; }
h1 { max-width: 680px; margin-bottom: 24px; font-size: clamp(44px, 5vw, 72px); font-weight: 700; letter-spacing: -.05em; }
h1 em { color: var(--blue); font-style: italic; }
.hero-text { max-width: 595px; margin-bottom: 32px; color: #42515a; font-size: 18px; }
h1, h2, h3 { font-family: "Source Serif 4", Georgia, serif; line-height: 1.03; letter-spacing: -.055em; }
h1 { max-width: 680px; margin-bottom: 25px; font-size: clamp(48px, 5.55vw, 78px); }
h1 em, h2 em { color: var(--blue); font-style: italic; }
.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; }
.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; }
.button-primary { color: var(--white); background: var(--orange); box-shadow: 0 8px 20px rgba(232, 109, 62, .18); }
.button-primary:hover { background: #d75f32; transform: translateY(-2px); }
.text-link { color: var(--blue); font-size: 14px; font-weight: 800; }
.text-link span { margin-left: 6px; font-size: 18px; }
.portrait-zone { position: relative; min-height: 490px; }
.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; }
.portrait-frame img { width: 121%; height: 100%; object-fit: cover; object-position: 68% center; transform: translateX(-12%); }
.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; }
.portrait-note span { margin-right: 6px; color: var(--yellow); 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; }
.hero-stats div { display: grid; gap: 2px; }
.hero-stats strong { color: var(--blue); font-size: 18px; font-weight: 800; }
.hero-stats span { color: var(--muted); font-size: 11px; }
.trust-strip { color: #e5f0eb; background: var(--blue-deep); }
.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); }
.formula-card { position: relative; min-height: 420px; overflow: hidden; border-radius: 50% 50% 4% 4%; background: var(--blue); box-shadow: 23px 27px 0 #d5e2dc; }
.formula-card::after { position: absolute; width: 100%; height: 30%; left: 0; bottom: 0; background: #10475f; content: ""; }
.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%); }
.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: 113px 0; }
.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; }
.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; }
.section { padding: 110px 0; }
.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); }
.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); }
.subjects { background: var(--sand); }
.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; }
.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; }
.process { background: #f2f6f4; }
.process h2 { margin-bottom: 47px; }
.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-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; }
.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; }
.booking { padding: 108px 0; color: var(--white); background: #153545; }
.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; }
.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; }
.form-card { padding: 33px; color: var(--ink); background: var(--white); border-radius: 4px; }
.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 { 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); }
.site-footer { padding: 27px 0; color: #b5c0c3; background: #0f2937; font-size: 12px; }
.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; }
}
.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; }
@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