update admin panel and visual
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 %}
|
||||||
@@ -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&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 %}
|
||||||
@@ -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&family=Source+Serif+4:opsz,wght@8..60,600;8..60,700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=DM+Mono&family=Manrope:wght@400;500;600;700;800&family=Source+Serif+4:opsz,wght@8..60,600;8..60,700&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 & 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 & 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&family=Source+Serif+4:opsz,wght@8..60,600;8..60,700&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 & 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>
|
||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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 |
Reference in New Issue
Block a user