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