Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d867933823 |
Binary file not shown.
@ -150,9 +150,13 @@ STATIC_URL = 'static/'
|
|||||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
|
|
||||||
STATICFILES_DIRS = [
|
STATICFILES_DIRS = [
|
||||||
|
directory
|
||||||
|
for directory in [
|
||||||
BASE_DIR / 'static',
|
BASE_DIR / 'static',
|
||||||
BASE_DIR / 'assets',
|
BASE_DIR / 'assets',
|
||||||
BASE_DIR / 'node_modules',
|
BASE_DIR / 'node_modules',
|
||||||
|
]
|
||||||
|
if directory.exists()
|
||||||
]
|
]
|
||||||
|
|
||||||
# Email
|
# Email
|
||||||
@ -180,3 +184,7 @@ if EMAIL_USE_SSL:
|
|||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
|
LOGIN_URL = 'login'
|
||||||
|
LOGIN_REDIRECT_URL = 'organizer_dashboard'
|
||||||
|
LOGOUT_REDIRECT_URL = 'home'
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/tests.cpython-311.pyc
Normal file
BIN
core/__pycache__/tests.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,3 +1,18 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
# Register your models here.
|
from .models import Event, Registration
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Event)
|
||||||
|
class EventAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('title', 'start_at', 'venue', 'capacity', 'is_published')
|
||||||
|
list_filter = ('is_published', 'start_at')
|
||||||
|
prepopulated_fields = {'slug': ('title',)}
|
||||||
|
search_fields = ('title', 'summary', 'venue')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Registration)
|
||||||
|
class RegistrationAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('full_name', 'email', 'event', 'status', 'created_at')
|
||||||
|
list_filter = ('status', 'event')
|
||||||
|
search_fields = ('full_name', 'email', 'company')
|
||||||
|
|||||||
114
core/forms.py
Normal file
114
core/forms.py
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
from django import forms
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .models import Event, Registration
|
||||||
|
|
||||||
|
|
||||||
|
class EventSearchForm(forms.Form):
|
||||||
|
q = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
max_length=80,
|
||||||
|
label='Search',
|
||||||
|
widget=forms.TextInput(
|
||||||
|
attrs={
|
||||||
|
'placeholder': 'Search by title, venue, or keyword',
|
||||||
|
'class': 'form-control form-control-lg',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Registration
|
||||||
|
fields = ['full_name', 'email', 'company', 'notes']
|
||||||
|
widgets = {
|
||||||
|
'full_name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Jordan Lee'}),
|
||||||
|
'email': forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'you@example.com'}),
|
||||||
|
'company': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Studio North'}),
|
||||||
|
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 4, 'placeholder': 'Accessibility needs, dietary notes, or questions'}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, event=None, **kwargs):
|
||||||
|
self.event = event
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
for field in self.fields.values():
|
||||||
|
css = field.widget.attrs.get('class', '')
|
||||||
|
if 'form-control' not in css:
|
||||||
|
field.widget.attrs['class'] = f'{css} form-control'.strip()
|
||||||
|
|
||||||
|
def clean_email(self):
|
||||||
|
email = self.cleaned_data['email'].strip().lower()
|
||||||
|
if self.event and Registration.objects.filter(event=self.event, email__iexact=email).exists():
|
||||||
|
raise forms.ValidationError('This email is already registered for this event.')
|
||||||
|
return email
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizerEventForm(forms.ModelForm):
|
||||||
|
datetime_format = '%Y-%m-%dT%H:%M'
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Event
|
||||||
|
fields = [
|
||||||
|
'title',
|
||||||
|
'summary',
|
||||||
|
'description',
|
||||||
|
'venue',
|
||||||
|
'start_at',
|
||||||
|
'end_at',
|
||||||
|
'capacity',
|
||||||
|
'is_published',
|
||||||
|
'registration_opens',
|
||||||
|
'registration_closes',
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
'title': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Spring Product Launch'}),
|
||||||
|
'summary': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'A short one-line overview for the catalog card.'}),
|
||||||
|
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 6, 'placeholder': 'Describe the agenda, audience, and what attendees should expect.'}),
|
||||||
|
'venue': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Northstar Studio, Austin'}),
|
||||||
|
'start_at': forms.DateTimeInput(format='%Y-%m-%dT%H:%M', attrs={'class': 'form-control', 'type': 'datetime-local'}),
|
||||||
|
'end_at': forms.DateTimeInput(format='%Y-%m-%dT%H:%M', attrs={'class': 'form-control', 'type': 'datetime-local'}),
|
||||||
|
'capacity': forms.NumberInput(attrs={'class': 'form-control', 'placeholder': 'Optional'}),
|
||||||
|
'is_published': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||||
|
'registration_opens': forms.DateTimeInput(format='%Y-%m-%dT%H:%M', attrs={'class': 'form-control', 'type': 'datetime-local'}),
|
||||||
|
'registration_closes': forms.DateTimeInput(format='%Y-%m-%dT%H:%M', attrs={'class': 'form-control', 'type': 'datetime-local'}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
for field_name in ('start_at', 'end_at', 'registration_opens', 'registration_closes'):
|
||||||
|
self.fields[field_name].input_formats = [self.datetime_format]
|
||||||
|
value = self.initial.get(field_name)
|
||||||
|
if value:
|
||||||
|
self.initial[field_name] = timezone.localtime(value).strftime(self.datetime_format)
|
||||||
|
for field in self.fields.values():
|
||||||
|
css = field.widget.attrs.get('class', '')
|
||||||
|
if isinstance(field.widget, forms.CheckboxInput):
|
||||||
|
field.widget.attrs['class'] = 'form-check-input'
|
||||||
|
elif 'form-control' not in css:
|
||||||
|
field.widget.attrs['class'] = f'{css} form-control'.strip()
|
||||||
|
self.fields['capacity'].required = False
|
||||||
|
self.fields['registration_opens'].required = False
|
||||||
|
self.fields['registration_closes'].required = False
|
||||||
|
self.fields['is_published'].required = False
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
start_at = cleaned_data.get('start_at')
|
||||||
|
end_at = cleaned_data.get('end_at')
|
||||||
|
registration_opens = cleaned_data.get('registration_opens')
|
||||||
|
registration_closes = cleaned_data.get('registration_closes')
|
||||||
|
capacity = cleaned_data.get('capacity')
|
||||||
|
|
||||||
|
if start_at and end_at and end_at <= start_at:
|
||||||
|
self.add_error('end_at', 'End time must be after the event start time.')
|
||||||
|
if registration_opens and start_at and registration_opens > start_at:
|
||||||
|
self.add_error('registration_opens', 'Registration should open before the event begins.')
|
||||||
|
if registration_closes and start_at and registration_closes > start_at:
|
||||||
|
self.add_error('registration_closes', 'Registration should close before the event begins.')
|
||||||
|
if registration_opens and registration_closes and registration_closes <= registration_opens:
|
||||||
|
self.add_error('registration_closes', 'Registration close must be after registration open.')
|
||||||
|
if capacity is not None and capacity < 1:
|
||||||
|
self.add_error('capacity', 'Capacity must be at least 1 when provided.')
|
||||||
|
|
||||||
|
return cleaned_data
|
||||||
54
core/migrations/0001_initial.py
Normal file
54
core/migrations/0001_initial.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-04-12 07:26
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Event',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(max_length=180)),
|
||||||
|
('slug', models.SlugField(blank=True, max_length=200, unique=True)),
|
||||||
|
('summary', models.CharField(max_length=260)),
|
||||||
|
('description', models.TextField()),
|
||||||
|
('venue', models.CharField(max_length=180)),
|
||||||
|
('start_at', models.DateTimeField()),
|
||||||
|
('end_at', models.DateTimeField()),
|
||||||
|
('capacity', models.PositiveIntegerField(blank=True, null=True)),
|
||||||
|
('is_published', models.BooleanField(default=True)),
|
||||||
|
('registration_opens', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('registration_closes', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['start_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Registration',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('full_name', models.CharField(max_length=160)),
|
||||||
|
('email', models.EmailField(max_length=254)),
|
||||||
|
('company', models.CharField(blank=True, max_length=160)),
|
||||||
|
('notes', models.TextField(blank=True)),
|
||||||
|
('status', models.CharField(choices=[('confirmed', 'Confirmed'), ('waitlist', 'Waitlist')], default='confirmed', max_length=24)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='registrations', to='core.event')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['created_at'],
|
||||||
|
'constraints': [models.UniqueConstraint(fields=('event', 'email'), name='unique_registration_per_event_email')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
104
core/migrations/0002_seed_demo_events.py
Normal file
104
core/migrations/0002_seed_demo_events.py
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
def seed_demo_data(apps, schema_editor):
|
||||||
|
Event = apps.get_model("core", "Event")
|
||||||
|
Registration = apps.get_model("core", "Registration")
|
||||||
|
|
||||||
|
if Event.objects.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
events = [
|
||||||
|
{
|
||||||
|
"title": "Product Launch Breakfast",
|
||||||
|
"slug": "product-launch-breakfast",
|
||||||
|
"summary": "A polished morning showcase for partners and customers.",
|
||||||
|
"description": "Join our organizer team for a concise launch briefing, networking breakfast, and a live product walkthrough.",
|
||||||
|
"venue": "Harbor Room, San Francisco",
|
||||||
|
"start_at": now + timedelta(days=10, hours=9),
|
||||||
|
"end_at": now + timedelta(days=10, hours=12),
|
||||||
|
"capacity": 40,
|
||||||
|
"registration_opens": now - timedelta(days=2),
|
||||||
|
"registration_closes": now + timedelta(days=9),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Community Design Workshop",
|
||||||
|
"slug": "community-design-workshop",
|
||||||
|
"summary": "A collaborative session on event experience and community building.",
|
||||||
|
"description": "This hands-on workshop covers attendee journeys, facilitation prompts, and lightweight planning rituals.",
|
||||||
|
"venue": "Northstar Studio, Oakland",
|
||||||
|
"start_at": now + timedelta(days=16, hours=14),
|
||||||
|
"end_at": now + timedelta(days=16, hours=17),
|
||||||
|
"capacity": 24,
|
||||||
|
"registration_opens": now - timedelta(days=1),
|
||||||
|
"registration_closes": now + timedelta(days=15),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Founder Evening Meetup",
|
||||||
|
"slug": "founder-evening-meetup",
|
||||||
|
"summary": "An informal evening gathering with short talks and open networking.",
|
||||||
|
"description": "Meet local founders, hear two fast talks, and stay for the community mixer.",
|
||||||
|
"venue": "Lumen Loft, San Jose",
|
||||||
|
"start_at": now + timedelta(days=25, hours=18),
|
||||||
|
"end_at": now + timedelta(days=25, hours=21),
|
||||||
|
"capacity": 60,
|
||||||
|
"registration_opens": now,
|
||||||
|
"registration_closes": now + timedelta(days=24),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
created = {}
|
||||||
|
for payload in events:
|
||||||
|
event = Event.objects.create(**payload)
|
||||||
|
created[event.slug] = event
|
||||||
|
|
||||||
|
Registration.objects.create(
|
||||||
|
event=created["product-launch-breakfast"],
|
||||||
|
full_name="Maya Chen",
|
||||||
|
email="maya@example.com",
|
||||||
|
company="Juniper Labs",
|
||||||
|
notes="Interested in partnership opportunities.",
|
||||||
|
status="confirmed",
|
||||||
|
)
|
||||||
|
Registration.objects.create(
|
||||||
|
event=created["product-launch-breakfast"],
|
||||||
|
full_name="Noah Patel",
|
||||||
|
email="noah@example.com",
|
||||||
|
company="Signal House",
|
||||||
|
notes="Needs wheelchair-accessible seating.",
|
||||||
|
status="confirmed",
|
||||||
|
)
|
||||||
|
Registration.objects.create(
|
||||||
|
event=created["community-design-workshop"],
|
||||||
|
full_name="Sofia Reyes",
|
||||||
|
email="sofia@example.com",
|
||||||
|
company="Craft Bureau",
|
||||||
|
notes="Bringing one notebook and many questions.",
|
||||||
|
status="confirmed",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_demo_data(apps, schema_editor):
|
||||||
|
Event = apps.get_model("core", "Event")
|
||||||
|
Event.objects.filter(
|
||||||
|
slug__in=[
|
||||||
|
"product-launch-breakfast",
|
||||||
|
"community-design-workshop",
|
||||||
|
"founder-evening-meetup",
|
||||||
|
]
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("core", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(seed_demo_data, clear_demo_data),
|
||||||
|
]
|
||||||
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
@ -1,3 +1,88 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.template.defaultfilters import slugify
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
# Create your models here.
|
|
||||||
|
class Event(models.Model):
|
||||||
|
title = models.CharField(max_length=180)
|
||||||
|
slug = models.SlugField(unique=True, max_length=200, blank=True)
|
||||||
|
summary = models.CharField(max_length=260)
|
||||||
|
description = models.TextField()
|
||||||
|
venue = models.CharField(max_length=180)
|
||||||
|
start_at = models.DateTimeField()
|
||||||
|
end_at = models.DateTimeField()
|
||||||
|
capacity = models.PositiveIntegerField(blank=True, null=True)
|
||||||
|
is_published = models.BooleanField(default=True)
|
||||||
|
registration_opens = models.DateTimeField(blank=True, null=True)
|
||||||
|
registration_closes = models.DateTimeField(blank=True, null=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['start_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.slug:
|
||||||
|
base_slug = slugify(self.title)[:180] or 'event'
|
||||||
|
slug = base_slug
|
||||||
|
counter = 2
|
||||||
|
while Event.objects.exclude(pk=self.pk).filter(slug=slug).exists():
|
||||||
|
slug = f'{base_slug}-{counter}'[:200]
|
||||||
|
counter += 1
|
||||||
|
self.slug = slug
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse('event_detail', args=[self.slug])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def confirmed_registrations_count(self):
|
||||||
|
return self.registrations.filter(status=Registration.Status.CONFIRMED).count()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def waitlist_count(self):
|
||||||
|
return self.registrations.filter(status=Registration.Status.WAITLIST).count()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def spots_remaining(self):
|
||||||
|
if self.capacity is None:
|
||||||
|
return None
|
||||||
|
return max(self.capacity - self.confirmed_registrations_count, 0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def registration_is_open(self):
|
||||||
|
now = timezone.now()
|
||||||
|
if not self.is_published:
|
||||||
|
return False
|
||||||
|
if self.registration_opens and now < self.registration_opens:
|
||||||
|
return False
|
||||||
|
if self.registration_closes and now > self.registration_closes:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class Registration(models.Model):
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
CONFIRMED = 'confirmed', 'Confirmed'
|
||||||
|
WAITLIST = 'waitlist', 'Waitlist'
|
||||||
|
|
||||||
|
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='registrations')
|
||||||
|
full_name = models.CharField(max_length=160)
|
||||||
|
email = models.EmailField()
|
||||||
|
company = models.CharField(max_length=160, blank=True)
|
||||||
|
notes = models.TextField(blank=True)
|
||||||
|
status = models.CharField(max_length=24, choices=Status.choices, default=Status.CONFIRMED)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['created_at']
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(fields=['event', 'email'], name='unique_registration_per_event_email'),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.full_name} · {self.event.title}'
|
||||||
|
|||||||
@ -1,25 +1,90 @@
|
|||||||
|
{% load static %}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>{% block title %}Knowledge Base{% endblock %}</title>
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
{% if project_description %}
|
<title>{% block title %}{{ page_title|default:project_name }}{% endblock %}</title>
|
||||||
<meta name="description" content="{{ project_description }}">
|
<meta name="description" content="{% block meta_description %}{{ meta_description|default:project_description }}{% endblock %}">
|
||||||
<meta property="og:description" content="{{ project_description }}">
|
<meta name="author" content="Northstar Events">
|
||||||
<meta property="twitter:description" content="{{ project_description }}">
|
|
||||||
{% endif %}
|
|
||||||
{% if project_image_url %}
|
{% if project_image_url %}
|
||||||
<meta property="og:image" content="{{ project_image_url }}">
|
<meta property="og:image" content="{{ project_image_url }}">
|
||||||
<meta property="twitter:image" content="{{ project_image_url }}">
|
<meta property="twitter:image" content="{{ project_image_url }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% load static %}
|
<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=Inter:wght@400;500;600;700;800&family=Fraunces:opsz,wght@9..144,600;9..144,700&display=swap" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||||
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
|
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
|
<body class="{% block body_class %}app-shell{% endblock %}">
|
||||||
|
<header class="site-header sticky-top">
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light py-3">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand brand-wordmark" href="{% url 'home' %}">
|
||||||
|
<span class="brand-mark">✦</span>
|
||||||
|
Northstar Events
|
||||||
|
</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#primaryNav" aria-controls="primaryNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="primaryNav">
|
||||||
|
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'home' %}#events">Events</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'home' %}#how-it-works">How it works</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'organizer_dashboard' %}">Dashboard</a></li>
|
||||||
|
{% if request.user.is_authenticated %}
|
||||||
|
<li class="nav-item ms-lg-2"><a class="btn btn-ghost" href="/admin/">Admin</a></li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<form method="post" action="{% url 'logout' %}" class="d-inline">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="btn btn-link nav-link" type="submit">Log out</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="nav-item ms-lg-2"><a class="btn btn-ghost" href="{% url 'login' %}">Organizer login</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
<div class="container pt-3">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-{{ message.tags|default:'info' }} app-alert" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<body>
|
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</body>
|
|
||||||
|
|
||||||
|
<footer class="site-footer py-5">
|
||||||
|
<div class="container d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-center">
|
||||||
|
<div>
|
||||||
|
<div class="footer-brand">Northstar Events</div>
|
||||||
|
<p class="footer-copy mb-0">A polished event listing and registration workspace for one organizer team.</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer-links d-flex flex-wrap gap-3 align-items-center">
|
||||||
|
<a href="{% url 'home' %}">Home</a>
|
||||||
|
<a href="{% url 'organizer_dashboard' %}">Organizer dashboard</a>
|
||||||
|
{% if request.user.is_authenticated %}
|
||||||
|
<a href="/admin/">Admin</a>
|
||||||
|
<form method="post" action="{% url 'logout' %}" class="d-inline">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-link p-0 text-decoration-none align-baseline">Log out</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'login' %}">Organizer login</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous" defer></script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
94
core/templates/core/event_detail.html
Normal file
94
core/templates/core/event_detail.html
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ page_title }}{% endblock %}
|
||||||
|
{% block meta_description %}{{ meta_description }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="section-space">
|
||||||
|
<div class="container">
|
||||||
|
<a href="{% url 'home' %}#events" class="text-link">← Back to events</a>
|
||||||
|
<section class="detail-hero card-surface mt-3">
|
||||||
|
<div class="row g-4 align-items-start">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<span class="eyebrow-pill">{{ event.start_at|date:"l, F j" }}</span>
|
||||||
|
<h1 class="detail-title mt-3">{{ event.title }}</h1>
|
||||||
|
<p class="detail-copy mt-3">{{ event.summary }}</p>
|
||||||
|
<div class="detail-meta-grid mt-4">
|
||||||
|
<div class="detail-meta-item"><span>Venue</span><strong>{{ event.venue }}</strong></div>
|
||||||
|
<div class="detail-meta-item"><span>Time</span><strong>{{ event.start_at|date:"g:i A" }} – {{ event.end_at|date:"g:i A" }}</strong></div>
|
||||||
|
<div class="detail-meta-item"><span>Status</span><strong>{% if event.registration_is_open %}Registration open{% else %}Registration closed{% endif %}</strong></div>
|
||||||
|
<div class="detail-meta-item"><span>Capacity</span><strong>{% if event.capacity %}{{ event.confirmed_registrations }}/{{ event.capacity }} booked{% else %}Flexible{% endif %}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="event-description mt-4">
|
||||||
|
<h2>About this event</h2>
|
||||||
|
<p>{{ event.description|linebreaksbr }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<aside class="registration-panel card-surface card-surface-inner">
|
||||||
|
<div class="d-flex justify-content-between align-items-center gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<p class="section-kicker mb-1">Reserve your seat</p>
|
||||||
|
<h2 class="h3 mb-0">Registration form</h2>
|
||||||
|
</div>
|
||||||
|
{% if event.capacity and event.confirmed_registrations >= event.capacity %}
|
||||||
|
<span class="status-badge badge-warm">Waitlist</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="text-muted">Submit once per email address. We will confirm your status right away.</p>
|
||||||
|
<form method="post" novalidate class="mt-4">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="alert alert-danger">{{ form.non_field_errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% for field in form %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||||
|
{{ field }}
|
||||||
|
{% if field.errors %}
|
||||||
|
<div class="invalid-feedback d-block">{{ field.errors|join:', ' }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<button class="btn btn-primary btn-lg w-100" type="submit" {% if not event.registration_is_open %}disabled{% endif %}>Register now</button>
|
||||||
|
</form>
|
||||||
|
<div class="panel-note mt-4">
|
||||||
|
{% if event.capacity %}
|
||||||
|
<strong>{{ event.spots_remaining }}</strong> spots remaining before waitlist.
|
||||||
|
{% else %}
|
||||||
|
Open attendance with no fixed seat limit.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if related_events %}
|
||||||
|
<section class="mt-5">
|
||||||
|
<div class="d-flex justify-content-between align-items-end gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<p class="section-kicker mb-2">More events</p>
|
||||||
|
<h2 class="section-title mb-0">Keep exploring the calendar.</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-4">
|
||||||
|
{% for related in related_events %}
|
||||||
|
<div class="col-lg-4 col-md-6">
|
||||||
|
<article class="event-card card-surface h-100">
|
||||||
|
<div class="event-card-top">
|
||||||
|
<span class="date-chip">{{ related.start_at|date:"M d" }}</span>
|
||||||
|
<span class="status-badge">Upcoming</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="event-card-title mt-3">{{ related.title }}</h3>
|
||||||
|
<p class="event-card-summary">{{ related.summary }}</p>
|
||||||
|
<a class="btn btn-ghost mt-auto" href="{{ related.get_absolute_url }}">View event</a>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@ -1,145 +1,172 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}{{ project_name }}{% endblock %}
|
{% block title %}{{ page_title }}{% endblock %}
|
||||||
|
{% block meta_description %}{{ meta_description }}{% endblock %}
|
||||||
{% block head %}
|
|
||||||
<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=Inter:wght@400;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg-color-start: #6a11cb;
|
|
||||||
--bg-color-end: #2575fc;
|
|
||||||
--text-color: #ffffff;
|
|
||||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
|
||||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
|
||||||
color: var(--text-color);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
text-align: center;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
body::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
|
|
||||||
animation: bg-pan 20s linear infinite;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bg-pan {
|
|
||||||
0% {
|
|
||||||
background-position: 0% 0%;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
background-position: 100% 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--card-bg-color);
|
|
||||||
border: 1px solid var(--card-border-color);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 2.5rem 2rem;
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0 0 1.2rem;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
opacity: 0.92;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader {
|
|
||||||
margin: 1.5rem auto;
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
border: 4px solid rgba(255, 255, 255, 0.25);
|
|
||||||
border-top-color: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.runtime code {
|
|
||||||
background: rgba(0, 0, 0, 0.25);
|
|
||||||
padding: 0.15rem 0.45rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sr-only {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 1rem;
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main>
|
<main>
|
||||||
<div class="card">
|
<section class="hero-section section-space">
|
||||||
<h1>Analyzing your requirements and generating your app…</h1>
|
<div class="container position-relative">
|
||||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
<div class="row align-items-center g-5">
|
||||||
<span class="sr-only">Loading…</span>
|
<div class="col-lg-7">
|
||||||
|
<span class="eyebrow-pill">Single-team event operations</span>
|
||||||
|
<h1 class="hero-title mt-4">Publish beautiful event pages and collect registrations in minutes.</h1>
|
||||||
|
<p class="hero-copy mt-4">Northstar gives one organizer team a modern public event catalog, quick attendee sign-up flow, and a dashboard to monitor registrations without wrestling with spreadsheets.</p>
|
||||||
|
<div class="d-flex flex-wrap gap-3 mt-4">
|
||||||
|
<a class="btn btn-primary btn-lg" href="#events">Browse events</a>
|
||||||
|
<a class="btn btn-ghost btn-lg" href="{% url 'organizer_dashboard' %}">Open dashboard</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
|
<div class="hero-stats row g-3 mt-4">
|
||||||
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
|
<div class="col-sm-4">
|
||||||
<p class="runtime">
|
<div class="stat-card">
|
||||||
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
|
<div class="stat-value">{{ stats.upcoming_events }}</div>
|
||||||
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
|
<div class="stat-label">Upcoming events</div>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ stats.confirmed_attendees }}</div>
|
||||||
|
<div class="stat-label">Confirmed attendees</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ stats.waitlist_total }}</div>
|
||||||
|
<div class="stat-label">Waitlist entries</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="hero-spotlight card-surface">
|
||||||
|
<div class="orb orb-one"></div>
|
||||||
|
<div class="orb orb-two"></div>
|
||||||
|
<div class="event-preview-card">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="mini-label">Featured next up</div>
|
||||||
|
{% if featured_event %}
|
||||||
|
<h2 class="preview-title">{{ featured_event.title }}</h2>
|
||||||
|
{% else %}
|
||||||
|
<h2 class="preview-title">Ready for your first event</h2>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<span class="status-badge">Live</span>
|
||||||
|
</div>
|
||||||
|
{% if featured_event %}
|
||||||
|
<ul class="preview-list list-unstyled mt-4 mb-0">
|
||||||
|
<li><strong>Date</strong><span>{{ featured_event.start_at|date:"M d, Y" }} · {{ featured_event.start_at|date:"g:i A" }}</span></li>
|
||||||
|
<li><strong>Venue</strong><span>{{ featured_event.venue }}</span></li>
|
||||||
|
<li><strong>Capacity</strong><span>{% if featured_event.capacity %}{{ featured_event.confirmed_registrations }}/{{ featured_event.capacity }} booked{% else %}Open attendance{% endif %}</span></li>
|
||||||
|
</ul>
|
||||||
|
<a href="{{ featured_event.get_absolute_url }}" class="btn btn-primary w-100 mt-4">Register for featured event</a>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted mt-4 mb-0">Create events in Django Admin and the landing page will automatically showcase them here.</p>
|
||||||
|
<a href="/admin/" class="btn btn-primary w-100 mt-4">Go to admin</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section-space section-search">
|
||||||
|
<div class="container">
|
||||||
|
<div class="search-panel card-surface">
|
||||||
|
<div>
|
||||||
|
<p class="section-kicker mb-2">Event finder</p>
|
||||||
|
<h2 class="section-title mb-0">Find the right session fast.</h2>
|
||||||
|
</div>
|
||||||
|
<form method="get" class="row g-3 align-items-end mt-2">
|
||||||
|
<div class="col-lg-9">
|
||||||
|
<label for="id_q" class="form-label">Search upcoming events</label>
|
||||||
|
{{ search_form.q }}
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-3 d-grid">
|
||||||
|
<button class="btn btn-primary btn-lg" type="submit">Search events</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="events" class="section-space">
|
||||||
|
<div class="container">
|
||||||
|
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-end gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<p class="section-kicker mb-2">Upcoming events</p>
|
||||||
|
<h2 class="section-title mb-0">Public listing with instant registration.</h2>
|
||||||
|
</div>
|
||||||
|
<a class="text-link" href="{% url 'organizer_dashboard' %}">See organizer dashboard →</a>
|
||||||
|
</div>
|
||||||
|
{% if events %}
|
||||||
|
<div class="row g-4">
|
||||||
|
{% for event in events %}
|
||||||
|
<div class="col-lg-4 col-md-6">
|
||||||
|
<article class="event-card card-surface h-100">
|
||||||
|
<div class="event-card-top">
|
||||||
|
<span class="date-chip">{{ event.start_at|date:"M d" }}</span>
|
||||||
|
{% if event.capacity and event.confirmed_registrations >= event.capacity %}
|
||||||
|
<span class="status-badge badge-warm">Waitlist only</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-badge">Open</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<h3 class="event-card-title mt-3">{{ event.title }}</h3>
|
||||||
|
<p class="event-card-summary">{{ event.summary }}</p>
|
||||||
|
<dl class="meta-list mb-4">
|
||||||
|
<div><dt>Venue</dt><dd>{{ event.venue }}</dd></div>
|
||||||
|
<div><dt>Starts</dt><dd>{{ event.start_at|date:"M d, Y · g:i A" }}</dd></div>
|
||||||
|
<div><dt>Availability</dt><dd>{% if event.capacity %}{{ event.confirmed_registrations }}/{{ event.capacity }} reserved{% else %}Open attendance{% endif %}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-auto gap-3">
|
||||||
|
<span class="small text-muted">{{ event.waitlist_registrations }} on waitlist</span>
|
||||||
|
<a class="btn btn-primary" href="{{ event.get_absolute_url }}">View details</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state card-surface text-center">
|
||||||
|
<h3>No matching events yet</h3>
|
||||||
|
<p class="mb-4">{% if query %}Try a different search term or browse all events from the dashboard.{% else %}Create your first event in admin to populate the public catalog.{% endif %}</p>
|
||||||
|
<div class="d-flex justify-content-center flex-wrap gap-3">
|
||||||
|
<a class="btn btn-primary" href="/admin/">Open admin</a>
|
||||||
|
<a class="btn btn-ghost" href="{% url 'organizer_dashboard' %}">View dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="how-it-works" class="section-space section-muted">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="feature-card card-surface h-100">
|
||||||
|
<div class="feature-number">01</div>
|
||||||
|
<h3>Publish events</h3>
|
||||||
|
<p>Create or edit event details in admin, then let the polished landing page surface them instantly.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="feature-card card-surface h-100">
|
||||||
|
<div class="feature-number">02</div>
|
||||||
|
<h3>Collect registrations</h3>
|
||||||
|
<p>Attendees browse events, submit a secure form, and receive confirmation or waitlist handling with capacity checks.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="feature-card card-surface h-100">
|
||||||
|
<div class="feature-number">03</div>
|
||||||
|
<h3>Manage attendees</h3>
|
||||||
|
<p>Monitor counts in the dashboard, review recent signups, and export attendee CSVs for operations.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<footer>
|
|
||||||
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
|
|
||||||
</footer>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
35
core/templates/core/login.html
Normal file
35
core/templates/core/login.html
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Organizer login | Northstar Events{% endblock %}
|
||||||
|
{% block meta_description %}Sign in to the Northstar Events organizer dashboard and admin workspace.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="section-space">
|
||||||
|
<div class="container">
|
||||||
|
<section class="success-shell card-surface mx-auto" style="max-width: 36rem;">
|
||||||
|
<span class="eyebrow-pill">Organizer access</span>
|
||||||
|
<h1 class="hero-title mt-4">Sign in to manage events.</h1>
|
||||||
|
<p class="hero-copy mt-3">Use your organizer account to open the protected dashboard, export attendee lists, and jump into Django Admin for event editing.</p>
|
||||||
|
<form method="post" class="mt-4">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="alert alert-danger app-alert">{{ form.non_field_errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="id_username">Username</label>
|
||||||
|
<input type="text" name="username" autofocus autocapitalize="none" autocomplete="username" maxlength="150" class="form-control" required id="id_username">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="id_password">Password</label>
|
||||||
|
<input type="password" name="password" autocomplete="current-password" class="form-control" required id="id_password">
|
||||||
|
</div>
|
||||||
|
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
|
||||||
|
<div class="d-grid gap-3">
|
||||||
|
<button class="btn btn-primary btn-lg" type="submit">Sign in</button>
|
||||||
|
<a class="btn btn-ghost" href="/admin/">Open Django Admin</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
115
core/templates/core/organizer_dashboard.html
Normal file
115
core/templates/core/organizer_dashboard.html
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ page_title }}{% endblock %}
|
||||||
|
{% block meta_description %}{{ meta_description }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="section-space">
|
||||||
|
<div class="container">
|
||||||
|
<section class="dashboard-hero card-surface">
|
||||||
|
<div class="row g-4 align-items-center">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<span class="eyebrow-pill">Organizer dashboard</span>
|
||||||
|
<h1 class="hero-title mt-4">Keep every event, signup, and export in one place.</h1>
|
||||||
|
<p class="hero-copy mt-3">Create new events inside the dashboard, review registration volume, and keep a quick path into attendee records without leaving this workspace.</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="dashboard-actions d-grid gap-3">
|
||||||
|
<a class="btn btn-primary btn-lg" href="{% url 'organizer_event_create' %}">Create event</a>
|
||||||
|
<a class="btn btn-ghost btn-lg" href="/admin/core/registration/">Open registration records</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 mt-4">
|
||||||
|
<div class="col-md-4"><div class="stat-card stat-card-light"><div class="stat-value">{{ dashboard_stats.upcoming_events }}</div><div class="stat-label">Upcoming events</div></div></div>
|
||||||
|
<div class="col-md-4"><div class="stat-card stat-card-light"><div class="stat-value">{{ dashboard_stats.confirmed_attendees }}</div><div class="stat-label">Confirmed attendees</div></div></div>
|
||||||
|
<div class="col-md-4"><div class="stat-card stat-card-light"><div class="stat-value">{{ dashboard_stats.waitlist_total }}</div><div class="stat-label">Waitlist total</div></div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-5">
|
||||||
|
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-end gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<p class="section-kicker mb-2">Event roster</p>
|
||||||
|
<h2 class="section-title mb-0">See registration volume at a glance.</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if events %}
|
||||||
|
<div class="row g-4">
|
||||||
|
{% for event in events %}
|
||||||
|
<div class="col-xl-6">
|
||||||
|
<article class="dashboard-card card-surface h-100">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h3 class="event-card-title mb-1">{{ event.title }}</h3>
|
||||||
|
<p class="text-muted mb-0">{{ event.start_at|date:"M d, Y · g:i A" }} · {{ event.venue }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<a class="btn btn-ghost btn-sm" href="{{ event.get_absolute_url }}">Public page</a>
|
||||||
|
<a class="btn btn-ghost btn-sm" href="{% url 'organizer_event_edit' event.slug %}">Edit</a>
|
||||||
|
<a class="btn btn-primary btn-sm" href="{% url 'export_attendees_csv' event.slug %}">Export CSV</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-metrics row g-3 mt-1">
|
||||||
|
<div class="col-sm-4"><div class="metric-box"><span>Confirmed</span><strong>{{ event.confirmed_registrations }}</strong></div></div>
|
||||||
|
<div class="col-sm-4"><div class="metric-box"><span>Waitlist</span><strong>{{ event.waitlist_registrations }}</strong></div></div>
|
||||||
|
<div class="col-sm-4"><div class="metric-box"><span>Total</span><strong>{{ event.total_registrations }}</strong></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-note mt-4">{% if event.capacity %}Capacity {{ event.capacity }} seats.{% else %}Flexible attendance limit.{% endif %}</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state card-surface text-center">
|
||||||
|
<h3>No events created yet</h3>
|
||||||
|
<p class="mb-4">Start by creating your first event from the dashboard, then share the public event page for registrations.</p>
|
||||||
|
<a class="btn btn-primary" href="{% url 'organizer_event_create' %}">Create first event</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-5">
|
||||||
|
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-end gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<p class="section-kicker mb-2">Recent attendees</p>
|
||||||
|
<h2 class="section-title mb-0">Latest registrations across events.</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if recent_registrations %}
|
||||||
|
<div class="table-surface card-surface overflow-hidden">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table align-middle mb-0 dashboard-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Event</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Registered</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for registration in recent_registrations %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ registration.full_name }}</td>
|
||||||
|
<td>{{ registration.event.title }}</td>
|
||||||
|
<td><span class="status-badge {% if registration.status == 'waitlist' %}badge-warm{% endif %}">{{ registration.get_status_display }}</span></td>
|
||||||
|
<td>{{ registration.email }}</td>
|
||||||
|
<td>{{ registration.created_at|date:"M d, g:i A" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state card-surface text-center">
|
||||||
|
<h3>No registrations yet</h3>
|
||||||
|
<p class="mb-0">Once attendees submit the public form, their records will appear here instantly.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
113
core/templates/core/organizer_event_form.html
Normal file
113
core/templates/core/organizer_event_form.html
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ page_title }}{% endblock %}
|
||||||
|
{% block meta_description %}{{ meta_description }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="page-shell py-5">
|
||||||
|
<div class="container">
|
||||||
|
<section class="dashboard-hero card-surface card-surface-inner">
|
||||||
|
<div class="row g-4 align-items-start">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<span class="eyebrow-pill">Organizer tools</span>
|
||||||
|
<h1 class="hero-title mt-4">{{ form_title }}</h1>
|
||||||
|
<p class="hero-copy mt-3">{{ form_intro }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="dashboard-actions d-grid gap-3">
|
||||||
|
<a class="btn btn-ghost btn-lg" href="{% url 'organizer_dashboard' %}">Back to dashboard</a>
|
||||||
|
<a class="btn btn-ghost btn-lg" href="/admin/core/event/">Open all events in admin</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-5">
|
||||||
|
<div class="card-surface card-surface-inner">
|
||||||
|
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-end gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<p class="section-kicker mb-2">Event setup</p>
|
||||||
|
<h2 class="section-title mb-0">{{ form_section_title }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="alert alert-danger">{{ form.non_field_errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="{{ form.title.id_for_label }}">{{ form.title.label }}</label>
|
||||||
|
{{ form.title }}
|
||||||
|
{% if form.title.errors %}<div class="invalid-feedback d-block">{{ form.title.errors|join:', ' }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="{{ form.summary.id_for_label }}">{{ form.summary.label }}</label>
|
||||||
|
{{ form.summary }}
|
||||||
|
{% if form.summary.errors %}<div class="invalid-feedback d-block">{{ form.summary.errors|join:', ' }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="{{ form.description.id_for_label }}">{{ form.description.label }}</label>
|
||||||
|
{{ form.description }}
|
||||||
|
{% if form.description.errors %}<div class="invalid-feedback d-block">{{ form.description.errors|join:', ' }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="mb-0">
|
||||||
|
<label class="form-label" for="{{ form.venue.id_for_label }}">{{ form.venue.label }}</label>
|
||||||
|
{{ form.venue }}
|
||||||
|
{% if form.venue.errors %}<div class="invalid-feedback d-block">{{ form.venue.errors|join:', ' }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card-surface bg-white border-0 shadow-sm h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="h5 mb-3">Schedule & access</h3>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="{{ form.start_at.id_for_label }}">Start</label>
|
||||||
|
{{ form.start_at }}
|
||||||
|
{% if form.start_at.errors %}<div class="invalid-feedback d-block">{{ form.start_at.errors|join:', ' }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="{{ form.end_at.id_for_label }}">End</label>
|
||||||
|
{{ form.end_at }}
|
||||||
|
{% if form.end_at.errors %}<div class="invalid-feedback d-block">{{ form.end_at.errors|join:', ' }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="{{ form.capacity.id_for_label }}">Capacity</label>
|
||||||
|
{{ form.capacity }}
|
||||||
|
<div class="form-text">Leave blank for open attendance.</div>
|
||||||
|
{% if form.capacity.errors %}<div class="invalid-feedback d-block">{{ form.capacity.errors|join:', ' }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="{{ form.registration_opens.id_for_label }}">Registration opens</label>
|
||||||
|
{{ form.registration_opens }}
|
||||||
|
{% if form.registration_opens.errors %}<div class="invalid-feedback d-block">{{ form.registration_opens.errors|join:', ' }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label" for="{{ form.registration_closes.id_for_label }}">Registration closes</label>
|
||||||
|
{{ form.registration_closes }}
|
||||||
|
{% if form.registration_closes.errors %}<div class="invalid-feedback d-block">{{ form.registration_closes.errors|join:', ' }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-0">
|
||||||
|
{{ form.is_published }}
|
||||||
|
<label class="form-check-label" for="{{ form.is_published.id_for_label }}">Visible on the public events page</label>
|
||||||
|
{% if form.is_published.errors %}<div class="invalid-feedback d-block">{{ form.is_published.errors|join:', ' }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column flex-sm-row gap-3 mt-4">
|
||||||
|
<button class="btn btn-primary btn-lg" type="submit">{{ submit_label }}</button>
|
||||||
|
<a class="btn btn-ghost btn-lg" href="{% url 'organizer_dashboard' %}">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
26
core/templates/core/registration_success.html
Normal file
26
core/templates/core/registration_success.html
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ page_title }}{% endblock %}
|
||||||
|
{% block meta_description %}{{ meta_description }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="section-space">
|
||||||
|
<div class="container">
|
||||||
|
<section class="success-shell card-surface text-center mx-auto">
|
||||||
|
<span class="eyebrow-pill">Registration received</span>
|
||||||
|
<h1 class="hero-title mt-4">{% if registration.status == 'waitlist' %}You are on the waitlist.{% else %}Your spot is secured.{% endif %}</h1>
|
||||||
|
<p class="hero-copy mt-3">{{ registration.full_name }}, your registration for <strong>{{ event.title }}</strong> has been saved. {% if registration.status == 'waitlist' %}We will contact you if a seat opens up.{% else %}Check your inbox for the confirmation email details.{% endif %}</p>
|
||||||
|
<div class="success-meta-grid mt-4 text-start">
|
||||||
|
<div class="detail-meta-item"><span>Event</span><strong>{{ event.title }}</strong></div>
|
||||||
|
<div class="detail-meta-item"><span>When</span><strong>{{ event.start_at|date:"M d, Y · g:i A" }}</strong></div>
|
||||||
|
<div class="detail-meta-item"><span>Where</span><strong>{{ event.venue }}</strong></div>
|
||||||
|
<div class="detail-meta-item"><span>Status</span><strong>{{ registration.get_status_display }}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap justify-content-center gap-3 mt-4">
|
||||||
|
<a class="btn btn-primary" href="{% url 'home' %}">Browse more events</a>
|
||||||
|
<a class="btn btn-ghost" href="{% url 'organizer_dashboard' %}">View organizer dashboard</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
118
core/tests.py
118
core/tests.py
@ -1,3 +1,117 @@
|
|||||||
from django.test import TestCase
|
from datetime import timedelta
|
||||||
|
|
||||||
# Create your tests here.
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .models import Event, Registration
|
||||||
|
|
||||||
|
|
||||||
|
class EventFlowTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
now = timezone.now() + timedelta(days=7)
|
||||||
|
self.user = get_user_model().objects.create_user(username='organizer', password='testpass123')
|
||||||
|
|
||||||
|
self.event = Event.objects.create(
|
||||||
|
title='Design Systems Lab',
|
||||||
|
summary='Hands-on session for collaborative design systems.',
|
||||||
|
description='A practical session for teams building consistent digital products.',
|
||||||
|
venue='Northstar Studio',
|
||||||
|
start_at=now,
|
||||||
|
end_at=now + timedelta(hours=2),
|
||||||
|
capacity=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_home_page_loads_event(self):
|
||||||
|
response = self.client.get(reverse('home'))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, self.event.title)
|
||||||
|
|
||||||
|
def test_registration_creates_attendee_and_redirects(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('event_detail', args=[self.event.slug]),
|
||||||
|
{
|
||||||
|
'full_name': 'Jordan Lee',
|
||||||
|
'email': 'jordan@example.com',
|
||||||
|
'company': 'Studio North',
|
||||||
|
'notes': 'Vegetarian meal',
|
||||||
|
},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(Registration.objects.count(), 1)
|
||||||
|
self.assertContains(response, 'Your spot is secured')
|
||||||
|
|
||||||
|
def test_capacity_overflow_goes_to_waitlist(self):
|
||||||
|
Registration.objects.create(event=self.event, full_name='One', email='one@example.com')
|
||||||
|
Registration.objects.create(event=self.event, full_name='Two', email='two@example.com')
|
||||||
|
self.client.post(
|
||||||
|
reverse('event_detail', args=[self.event.slug]),
|
||||||
|
{
|
||||||
|
'full_name': 'Three',
|
||||||
|
'email': 'three@example.com',
|
||||||
|
'company': '',
|
||||||
|
'notes': '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(Registration.objects.get(email='three@example.com').status, Registration.Status.WAITLIST)
|
||||||
|
def test_dashboard_requires_login(self):
|
||||||
|
response = self.client.get(reverse('organizer_dashboard'))
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn(reverse('login'), response.url)
|
||||||
|
|
||||||
|
def test_logged_in_organizer_can_open_dashboard(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(reverse('organizer_dashboard'))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, 'Organizer dashboard')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_logged_in_organizer_can_create_event_from_dashboard_form(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('organizer_event_create'),
|
||||||
|
{
|
||||||
|
'title': 'Community Breakfast',
|
||||||
|
'summary': 'Meet peers before the keynote.',
|
||||||
|
'description': 'A low-key networking breakfast for early arrivals.',
|
||||||
|
'venue': 'Northstar Cafe',
|
||||||
|
'start_at': (timezone.localtime(self.event.start_at) + timedelta(days=3)).strftime('%Y-%m-%dT%H:%M'),
|
||||||
|
'end_at': (timezone.localtime(self.event.end_at) + timedelta(days=3)).strftime('%Y-%m-%dT%H:%M'),
|
||||||
|
'capacity': 24,
|
||||||
|
'is_published': 'on',
|
||||||
|
'registration_opens': '',
|
||||||
|
'registration_closes': '',
|
||||||
|
},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(Event.objects.filter(title='Community Breakfast', venue='Northstar Cafe').exists())
|
||||||
|
self.assertContains(response, 'created successfully')
|
||||||
|
|
||||||
|
def test_logged_in_organizer_can_edit_event_from_dashboard_form(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('organizer_event_edit', args=[self.event.slug]),
|
||||||
|
{
|
||||||
|
'title': 'Design Systems Lab Updated',
|
||||||
|
'summary': 'Hands-on session for collaborative design systems.',
|
||||||
|
'description': 'A practical session for teams building consistent digital products.',
|
||||||
|
'venue': 'Northstar Auditorium',
|
||||||
|
'start_at': timezone.localtime(self.event.start_at).strftime('%Y-%m-%dT%H:%M'),
|
||||||
|
'end_at': timezone.localtime(self.event.end_at).strftime('%Y-%m-%dT%H:%M'),
|
||||||
|
'capacity': 40,
|
||||||
|
'is_published': 'on',
|
||||||
|
'registration_opens': '',
|
||||||
|
'registration_closes': '',
|
||||||
|
},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.event.refresh_from_db()
|
||||||
|
self.assertEqual(self.event.title, 'Design Systems Lab Updated')
|
||||||
|
self.assertEqual(self.event.venue, 'Northstar Auditorium')
|
||||||
|
self.assertEqual(self.event.capacity, 40)
|
||||||
|
self.assertContains(response, 'updated successfully')
|
||||||
|
|||||||
13
core/urls.py
13
core/urls.py
@ -1,7 +1,16 @@
|
|||||||
|
from django.contrib.auth import views as auth_views
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import home
|
from .views import event_detail, export_attendees_csv, home, organizer_dashboard, organizer_event_create, organizer_event_edit, registration_success
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", home, name="home"),
|
path('login/', auth_views.LoginView.as_view(template_name='core/login.html', redirect_authenticated_user=True), name='login'),
|
||||||
|
path('logout/', auth_views.LogoutView.as_view(next_page='home'), name='logout'),
|
||||||
|
path('', home, name='home'),
|
||||||
|
path('events/<slug:slug>/', event_detail, name='event_detail'),
|
||||||
|
path('events/<slug:slug>/registered/<int:registration_id>/', registration_success, name='registration_success'),
|
||||||
|
path('dashboard/', organizer_dashboard, name='organizer_dashboard'),
|
||||||
|
path('dashboard/events/new/', organizer_event_create, name='organizer_event_create'),
|
||||||
|
path('dashboard/events/<slug:slug>/edit/', organizer_event_edit, name='organizer_event_edit'),
|
||||||
|
path('dashboard/events/<slug:slug>/attendees.csv', export_attendees_csv, name='export_attendees_csv'),
|
||||||
]
|
]
|
||||||
|
|||||||
254
core/views.py
254
core/views.py
@ -1,25 +1,251 @@
|
|||||||
|
import csv
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
|
|
||||||
from django import get_version as django_version
|
from django.contrib import messages
|
||||||
from django.shortcuts import render
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import Count, Q
|
||||||
|
from django.http import Http404, HttpResponse
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .forms import EventSearchForm, OrganizerEventForm, RegistrationForm
|
||||||
|
from .models import Event, Registration
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _base_context(request):
|
||||||
|
host_name = request.get_host().lower()
|
||||||
|
agent_brand = 'AppWizzy' if host_name == 'appwizzy.com' else 'Flatlogic'
|
||||||
|
now = timezone.now()
|
||||||
|
return {
|
||||||
|
'project_name': 'Northstar Events',
|
||||||
|
'agent_brand': agent_brand,
|
||||||
|
'django_version': __import__('django').get_version(),
|
||||||
|
'python_version': platform.python_version(),
|
||||||
|
'current_time': now,
|
||||||
|
'host_name': host_name,
|
||||||
|
'project_description': os.getenv('PROJECT_DESCRIPTION', 'Modern event publishing and registration for a single organizer team.'),
|
||||||
|
'project_image_url': os.getenv('PROJECT_IMAGE_URL', ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def home(request):
|
def home(request):
|
||||||
"""Render the landing screen with loader and environment details."""
|
|
||||||
host_name = request.get_host().lower()
|
|
||||||
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
|
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
|
search_form = EventSearchForm(request.GET or None)
|
||||||
|
events = Event.objects.filter(is_published=True, end_at__gte=now).annotate(
|
||||||
|
confirmed_registrations=Count('registrations', filter=Q(registrations__status=Registration.Status.CONFIRMED)),
|
||||||
|
waitlist_registrations=Count('registrations', filter=Q(registrations__status=Registration.Status.WAITLIST)),
|
||||||
|
)
|
||||||
|
query = ''
|
||||||
|
if search_form.is_valid():
|
||||||
|
query = search_form.cleaned_data.get('q', '').strip()
|
||||||
|
if query:
|
||||||
|
events = events.filter(
|
||||||
|
Q(title__icontains=query)
|
||||||
|
| Q(summary__icontains=query)
|
||||||
|
| Q(description__icontains=query)
|
||||||
|
| Q(venue__icontains=query)
|
||||||
|
)
|
||||||
|
|
||||||
|
event_list = list(events)
|
||||||
|
featured_event = event_list[0] if event_list else None
|
||||||
|
stats = {
|
||||||
|
'upcoming_events': Event.objects.filter(is_published=True, end_at__gte=now).count(),
|
||||||
|
'confirmed_attendees': Registration.objects.filter(status=Registration.Status.CONFIRMED).count(),
|
||||||
|
'waitlist_total': Registration.objects.filter(status=Registration.Status.WAITLIST).count(),
|
||||||
|
}
|
||||||
|
context = {
|
||||||
|
**_base_context(request),
|
||||||
|
'page_title': 'Northstar Events | Browse upcoming events and register online',
|
||||||
|
'meta_description': 'Discover upcoming workshops, talks, and community events, then register in minutes with instant confirmation.',
|
||||||
|
'search_form': search_form,
|
||||||
|
'events': event_list,
|
||||||
|
'featured_event': featured_event,
|
||||||
|
'stats': stats,
|
||||||
|
'query': query,
|
||||||
|
}
|
||||||
|
return render(request, 'core/index.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
def event_detail(request, slug):
|
||||||
|
event = get_object_or_404(
|
||||||
|
Event.objects.annotate(
|
||||||
|
confirmed_registrations=Count('registrations', filter=Q(registrations__status=Registration.Status.CONFIRMED)),
|
||||||
|
waitlist_registrations=Count('registrations', filter=Q(registrations__status=Registration.Status.WAITLIST)),
|
||||||
|
),
|
||||||
|
slug=slug,
|
||||||
|
is_published=True,
|
||||||
|
)
|
||||||
|
form = RegistrationForm(request.POST or None, event=event)
|
||||||
|
if request.method == 'POST':
|
||||||
|
if not event.registration_is_open:
|
||||||
|
form.add_error(None, 'Registration is not open for this event right now.')
|
||||||
|
elif form.is_valid():
|
||||||
|
with transaction.atomic():
|
||||||
|
locked_event = Event.objects.select_for_update().get(pk=event.pk)
|
||||||
|
if Registration.objects.filter(event=locked_event, email__iexact=form.cleaned_data['email']).exists():
|
||||||
|
form.add_error('email', 'This email is already registered for this event.')
|
||||||
|
else:
|
||||||
|
registration = form.save(commit=False)
|
||||||
|
registration.event = locked_event
|
||||||
|
if locked_event.capacity and locked_event.confirmed_registrations_count >= locked_event.capacity:
|
||||||
|
registration.status = Registration.Status.WAITLIST
|
||||||
|
else:
|
||||||
|
registration.status = Registration.Status.CONFIRMED
|
||||||
|
registration.save()
|
||||||
|
email_sent = _send_registration_email(registration)
|
||||||
|
if email_sent:
|
||||||
|
messages.success(request, 'Registration saved and confirmation email sent.')
|
||||||
|
else:
|
||||||
|
messages.warning(request, 'Registration saved. Email delivery is not configured yet, so no confirmation email was sent.')
|
||||||
|
return redirect('registration_success', slug=locked_event.slug, registration_id=registration.pk)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"project_name": "New Style",
|
**_base_context(request),
|
||||||
"agent_brand": agent_brand,
|
'page_title': f'{event.title} | Register with Northstar Events',
|
||||||
"django_version": django_version(),
|
'meta_description': event.summary,
|
||||||
"python_version": platform.python_version(),
|
'event': event,
|
||||||
"current_time": now,
|
'form': form,
|
||||||
"host_name": host_name,
|
'related_events': Event.objects.filter(is_published=True, end_at__gte=timezone.now()).exclude(pk=event.pk)[:3],
|
||||||
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
|
|
||||||
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
|
|
||||||
}
|
}
|
||||||
return render(request, "core/index.html", context)
|
return render(request, 'core/event_detail.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
def registration_success(request, slug, registration_id):
|
||||||
|
registration = get_object_or_404(
|
||||||
|
Registration.objects.select_related('event'),
|
||||||
|
pk=registration_id,
|
||||||
|
event__slug=slug,
|
||||||
|
)
|
||||||
|
context = {
|
||||||
|
**_base_context(request),
|
||||||
|
'page_title': f'Registration confirmed | {registration.event.title}',
|
||||||
|
'meta_description': f'Confirmation details for {registration.event.title}.',
|
||||||
|
'registration': registration,
|
||||||
|
'event': registration.event,
|
||||||
|
}
|
||||||
|
return render(request, 'core/registration_success.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def organizer_dashboard(request):
|
||||||
|
now = timezone.now()
|
||||||
|
events = Event.objects.annotate(
|
||||||
|
confirmed_registrations=Count('registrations', filter=Q(registrations__status=Registration.Status.CONFIRMED)),
|
||||||
|
waitlist_registrations=Count('registrations', filter=Q(registrations__status=Registration.Status.WAITLIST)),
|
||||||
|
total_registrations=Count('registrations'),
|
||||||
|
).order_by('start_at')
|
||||||
|
recent_registrations = Registration.objects.select_related('event').order_by('-created_at')[:8]
|
||||||
|
context = {
|
||||||
|
**_base_context(request),
|
||||||
|
'page_title': 'Organizer dashboard | Northstar Events',
|
||||||
|
'meta_description': 'Review upcoming events, attendee counts, and recent registrations for your organizer team.',
|
||||||
|
'events': events,
|
||||||
|
'recent_registrations': recent_registrations,
|
||||||
|
'dashboard_stats': {
|
||||||
|
'upcoming_events': events.filter(end_at__gte=now, is_published=True).count(),
|
||||||
|
'confirmed_attendees': Registration.objects.filter(status=Registration.Status.CONFIRMED).count(),
|
||||||
|
'waitlist_total': Registration.objects.filter(status=Registration.Status.WAITLIST).count(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return render(request, 'core/organizer_dashboard.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def organizer_event_create(request):
|
||||||
|
form = OrganizerEventForm(request.POST or None)
|
||||||
|
if request.method == 'POST' and form.is_valid():
|
||||||
|
event = form.save()
|
||||||
|
messages.success(request, f'Event "{event.title}" created successfully.')
|
||||||
|
return redirect('organizer_dashboard')
|
||||||
|
|
||||||
|
context = {
|
||||||
|
**_base_context(request),
|
||||||
|
'page_title': 'Create event | Northstar Events',
|
||||||
|
'meta_description': 'Create a new public event from the organizer dashboard.',
|
||||||
|
'form': form,
|
||||||
|
'form_mode': 'create',
|
||||||
|
'form_title': 'Create a new event without opening Django Admin.',
|
||||||
|
'form_intro': 'This custom organizer form covers the core publishing fields: schedule, venue, capacity, registration window, and public visibility.',
|
||||||
|
'form_section_title': 'Fill in the event details.',
|
||||||
|
'submit_label': 'Create event',
|
||||||
|
}
|
||||||
|
return render(request, 'core/organizer_event_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def organizer_event_edit(request, slug):
|
||||||
|
event = get_object_or_404(Event, slug=slug)
|
||||||
|
form = OrganizerEventForm(request.POST or None, instance=event)
|
||||||
|
if request.method == 'POST' and form.is_valid():
|
||||||
|
updated_event = form.save()
|
||||||
|
messages.success(request, f'Event "{updated_event.title}" updated successfully.')
|
||||||
|
return redirect('organizer_dashboard')
|
||||||
|
|
||||||
|
context = {
|
||||||
|
**_base_context(request),
|
||||||
|
'page_title': f'Edit {event.title} | Northstar Events',
|
||||||
|
'meta_description': f'Update the event details for {event.title} from the organizer dashboard.',
|
||||||
|
'form': form,
|
||||||
|
'event': event,
|
||||||
|
'form_mode': 'edit',
|
||||||
|
'form_title': f'Edit “{event.title}” from the organizer dashboard.',
|
||||||
|
'form_intro': 'Update the same publishing fields used during event creation, while keeping the public event URL stable.',
|
||||||
|
'form_section_title': 'Update the event details.',
|
||||||
|
'submit_label': 'Save changes',
|
||||||
|
}
|
||||||
|
return render(request, 'core/organizer_event_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def export_attendees_csv(request, slug):
|
||||||
|
event = get_object_or_404(Event, slug=slug)
|
||||||
|
registrations = event.registrations.order_by('created_at')
|
||||||
|
response = HttpResponse(content_type='text/csv')
|
||||||
|
response['Content-Disposition'] = f'attachment; filename="{event.slug}-attendees.csv"'
|
||||||
|
writer = csv.writer(response)
|
||||||
|
writer.writerow(['Full name', 'Email', 'Company', 'Status', 'Registered at'])
|
||||||
|
for registration in registrations:
|
||||||
|
writer.writerow([
|
||||||
|
registration.full_name,
|
||||||
|
registration.email,
|
||||||
|
registration.company,
|
||||||
|
registration.get_status_display(),
|
||||||
|
timezone.localtime(registration.created_at).strftime('%Y-%m-%d %H:%M'),
|
||||||
|
])
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _send_registration_email(registration):
|
||||||
|
subject = f'Your registration for {registration.event.title}'
|
||||||
|
if registration.status == Registration.Status.WAITLIST:
|
||||||
|
intro = 'You have been added to the waitlist.'
|
||||||
|
else:
|
||||||
|
intro = 'Your spot is confirmed.'
|
||||||
|
message = (
|
||||||
|
f'Hi {registration.full_name},\n\n'
|
||||||
|
f'{intro}\n\n'
|
||||||
|
f'Event: {registration.event.title}\n'
|
||||||
|
f'When: {timezone.localtime(registration.event.start_at).strftime("%B %d, %Y at %I:%M %p")}\n'
|
||||||
|
f'Where: {registration.event.venue}\n\n'
|
||||||
|
'We look forward to seeing you.\n'
|
||||||
|
'Northstar Events'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
send_mail(
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
os.getenv('DEFAULT_FROM_EMAIL', 'no-reply@example.com'),
|
||||||
|
[registration.email],
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception('Registration email failed for %s: %s', registration.email, exc)
|
||||||
|
return False
|
||||||
|
|||||||
@ -1,4 +1,501 @@
|
|||||||
/* Custom styles for the application */
|
:root {
|
||||||
body {
|
--bg: #f6f8fb;
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
--surface: rgba(255, 255, 255, 0.88);
|
||||||
|
--surface-strong: #ffffff;
|
||||||
|
--surface-muted: #eef3f8;
|
||||||
|
--border: rgba(27, 47, 74, 0.1);
|
||||||
|
--text: #132238;
|
||||||
|
--muted: #617086;
|
||||||
|
--primary: #0f8f7a;
|
||||||
|
--primary-deep: #0a6f61;
|
||||||
|
--secondary: #ff8457;
|
||||||
|
--accent: #ffd166;
|
||||||
|
--shadow: 0 30px 60px rgba(19, 34, 56, 0.12);
|
||||||
|
--radius-xl: 28px;
|
||||||
|
--radius-lg: 20px;
|
||||||
|
--radius-md: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.app-shell {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(15, 143, 122, 0.14), transparent 28%),
|
||||||
|
radial-gradient(circle at 80% 10%, rgba(255, 132, 87, 0.16), transparent 22%),
|
||||||
|
linear-gradient(180deg, #fbfdff 0%, #f4f7fb 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
.brand-wordmark,
|
||||||
|
.section-title,
|
||||||
|
.preview-title,
|
||||||
|
.footer-brand {
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
a,
|
||||||
|
span,
|
||||||
|
label,
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
table,
|
||||||
|
div {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
background: rgba(246, 248, 251, 0.82);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
--bs-navbar-color: var(--muted);
|
||||||
|
--bs-navbar-hover-color: var(--text);
|
||||||
|
--bs-navbar-active-color: var(--text);
|
||||||
|
--bs-navbar-brand-color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-wordmark {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(135deg, rgba(15, 143, 122, 0.14), rgba(255, 209, 102, 0.45));
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-space {
|
||||||
|
padding: 4.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section {
|
||||||
|
padding: 5rem 0 3rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title,
|
||||||
|
.detail-title {
|
||||||
|
font-size: clamp(2.8rem, 5vw, 4.8rem);
|
||||||
|
line-height: 0.98;
|
||||||
|
max-width: 12ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
font-size: clamp(2.4rem, 4vw, 4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy,
|
||||||
|
.detail-copy {
|
||||||
|
max-width: 62ch;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1.08rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow-pill,
|
||||||
|
.status-badge,
|
||||||
|
.date-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow-pill,
|
||||||
|
.status-badge {
|
||||||
|
background: rgba(15, 143, 122, 0.12);
|
||||||
|
color: var(--primary-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warm,
|
||||||
|
.date-chip {
|
||||||
|
background: rgba(255, 132, 87, 0.14);
|
||||||
|
color: #b14c26;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-kicker,
|
||||||
|
.mini-label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: clamp(2rem, 3vw, 3rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-surface {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 2rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-surface-inner {
|
||||||
|
background: var(--surface-strong);
|
||||||
|
border-radius: calc(var(--radius-xl) - 8px);
|
||||||
|
box-shadow: 0 16px 32px rgba(19, 34, 56, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-spotlight {
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(244, 247, 251, 0.92));
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-preview-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.88);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-list li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.2rem;
|
||||||
|
padding: 0.85rem 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-list li:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-list strong,
|
||||||
|
.preview-list span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-list span {
|
||||||
|
text-align: right;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orb {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 999px;
|
||||||
|
filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orb-one {
|
||||||
|
width: 190px;
|
||||||
|
height: 190px;
|
||||||
|
right: -1.5rem;
|
||||||
|
top: -1.5rem;
|
||||||
|
background: radial-gradient(circle, rgba(255, 209, 102, 0.52), rgba(255, 209, 102, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.orb-two {
|
||||||
|
width: 230px;
|
||||||
|
height: 230px;
|
||||||
|
left: -2rem;
|
||||||
|
bottom: -3rem;
|
||||||
|
background: radial-gradient(circle, rgba(15, 143, 122, 0.26), rgba(15, 143, 122, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card,
|
||||||
|
.metric-box {
|
||||||
|
padding: 1.2rem;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-light {
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: clamp(1.8rem, 3vw, 2.4rem);
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label,
|
||||||
|
.panel-note,
|
||||||
|
.event-card-summary,
|
||||||
|
.footer-copy,
|
||||||
|
.metric-box span,
|
||||||
|
.text-muted,
|
||||||
|
.meta-list dd,
|
||||||
|
.detail-meta-item span {
|
||||||
|
color: var(--muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-panel {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card,
|
||||||
|
.dashboard-card,
|
||||||
|
.feature-card,
|
||||||
|
.success-shell,
|
||||||
|
.detail-hero,
|
||||||
|
.dashboard-hero,
|
||||||
|
.empty-state {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card,
|
||||||
|
.dashboard-card,
|
||||||
|
.feature-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-title {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-list div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-bottom: 0.7rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-list dt {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-list dd {
|
||||||
|
margin: 0;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-number {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: linear-gradient(135deg, rgba(15, 143, 122, 0.14), rgba(255, 132, 87, 0.18));
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-muted {
|
||||||
|
background: linear-gradient(180deg, rgba(238, 243, 248, 0.55), rgba(246, 248, 251, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-link {
|
||||||
|
color: var(--primary-deep);
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-meta-grid,
|
||||||
|
.success-meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-meta-item {
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.72);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-meta-item strong {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-panel {
|
||||||
|
position: sticky;
|
||||||
|
top: 6.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(19, 34, 56, 0.12);
|
||||||
|
padding: 0.95rem 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
border-color: rgba(15, 143, 122, 0.5);
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(15, 143, 122, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.86rem 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
--bs-btn-bg: var(--primary);
|
||||||
|
--bs-btn-border-color: var(--primary);
|
||||||
|
--bs-btn-hover-bg: var(--primary-deep);
|
||||||
|
--bs-btn-hover-border-color: var(--primary-deep);
|
||||||
|
--bs-btn-active-bg: var(--primary-deep);
|
||||||
|
--bs-btn-active-border-color: var(--primary-deep);
|
||||||
|
--bs-btn-disabled-bg: rgba(15, 143, 122, 0.4);
|
||||||
|
--bs-btn-disabled-border-color: rgba(15, 143, 122, 0.4);
|
||||||
|
box-shadow: 0 14px 30px rgba(15, 143, 122, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
border: 1px solid rgba(19, 34, 56, 0.08);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover,
|
||||||
|
.text-link:hover,
|
||||||
|
.footer-links a:hover {
|
||||||
|
color: var(--primary-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-metrics .metric-box strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-table {
|
||||||
|
--bs-table-bg: transparent;
|
||||||
|
--bs-table-color: var(--text);
|
||||||
|
--bs-table-border-color: rgba(19, 34, 56, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-table thead th {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-table tbody td {
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-alert {
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: 0 16px 30px rgba(19, 34, 56, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
border-top: 1px solid rgba(19, 34, 56, 0.06);
|
||||||
|
margin-top: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-brand {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.section-space {
|
||||||
|
padding: 3.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section {
|
||||||
|
padding-top: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-panel {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
.card-surface,
|
||||||
|
.hero-spotlight,
|
||||||
|
.event-preview-card,
|
||||||
|
.registration-panel,
|
||||||
|
.success-shell {
|
||||||
|
padding: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title,
|
||||||
|
.detail-title {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,21 +1,501 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg-color-start: #6a11cb;
|
--bg: #f6f8fb;
|
||||||
--bg-color-end: #2575fc;
|
--surface: rgba(255, 255, 255, 0.88);
|
||||||
--text-color: #ffffff;
|
--surface-strong: #ffffff;
|
||||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
--surface-muted: #eef3f8;
|
||||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
--border: rgba(27, 47, 74, 0.1);
|
||||||
|
--text: #132238;
|
||||||
|
--muted: #617086;
|
||||||
|
--primary: #0f8f7a;
|
||||||
|
--primary-deep: #0a6f61;
|
||||||
|
--secondary: #ff8457;
|
||||||
|
--accent: #ffd166;
|
||||||
|
--shadow: 0 30px 60px rgba(19, 34, 56, 0.12);
|
||||||
|
--radius-xl: 28px;
|
||||||
|
--radius-lg: 20px;
|
||||||
|
--radius-md: 14px;
|
||||||
}
|
}
|
||||||
body {
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.app-shell {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: 'Inter', sans-serif;
|
||||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
color: var(--text);
|
||||||
color: var(--text-color);
|
background:
|
||||||
display: flex;
|
radial-gradient(circle at top left, rgba(15, 143, 122, 0.14), transparent 28%),
|
||||||
justify-content: center;
|
radial-gradient(circle at 80% 10%, rgba(255, 132, 87, 0.16), transparent 22%),
|
||||||
align-items: center;
|
linear-gradient(180deg, #fbfdff 0%, #f4f7fb 100%);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
text-align: center;
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
.brand-wordmark,
|
||||||
|
.section-title,
|
||||||
|
.preview-title,
|
||||||
|
.footer-brand {
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
a,
|
||||||
|
span,
|
||||||
|
label,
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
table,
|
||||||
|
div {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
background: rgba(246, 248, 251, 0.82);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
--bs-navbar-color: var(--muted);
|
||||||
|
--bs-navbar-hover-color: var(--text);
|
||||||
|
--bs-navbar-active-color: var(--text);
|
||||||
|
--bs-navbar-brand-color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-wordmark {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(135deg, rgba(15, 143, 122, 0.14), rgba(255, 209, 102, 0.45));
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-space {
|
||||||
|
padding: 4.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section {
|
||||||
|
padding: 5rem 0 3rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title,
|
||||||
|
.detail-title {
|
||||||
|
font-size: clamp(2.8rem, 5vw, 4.8rem);
|
||||||
|
line-height: 0.98;
|
||||||
|
max-width: 12ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
font-size: clamp(2.4rem, 4vw, 4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy,
|
||||||
|
.detail-copy {
|
||||||
|
max-width: 62ch;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1.08rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow-pill,
|
||||||
|
.status-badge,
|
||||||
|
.date-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow-pill,
|
||||||
|
.status-badge {
|
||||||
|
background: rgba(15, 143, 122, 0.12);
|
||||||
|
color: var(--primary-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warm,
|
||||||
|
.date-chip {
|
||||||
|
background: rgba(255, 132, 87, 0.14);
|
||||||
|
color: #b14c26;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-kicker,
|
||||||
|
.mini-label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: clamp(2rem, 3vw, 3rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-surface {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 2rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-surface-inner {
|
||||||
|
background: var(--surface-strong);
|
||||||
|
border-radius: calc(var(--radius-xl) - 8px);
|
||||||
|
box-shadow: 0 16px 32px rgba(19, 34, 56, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-spotlight {
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(244, 247, 251, 0.92));
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-preview-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.88);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-list li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.2rem;
|
||||||
|
padding: 0.85rem 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-list li:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-list strong,
|
||||||
|
.preview-list span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-list span {
|
||||||
|
text-align: right;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orb {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 999px;
|
||||||
|
filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orb-one {
|
||||||
|
width: 190px;
|
||||||
|
height: 190px;
|
||||||
|
right: -1.5rem;
|
||||||
|
top: -1.5rem;
|
||||||
|
background: radial-gradient(circle, rgba(255, 209, 102, 0.52), rgba(255, 209, 102, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.orb-two {
|
||||||
|
width: 230px;
|
||||||
|
height: 230px;
|
||||||
|
left: -2rem;
|
||||||
|
bottom: -3rem;
|
||||||
|
background: radial-gradient(circle, rgba(15, 143, 122, 0.26), rgba(15, 143, 122, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card,
|
||||||
|
.metric-box {
|
||||||
|
padding: 1.2rem;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-light {
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: clamp(1.8rem, 3vw, 2.4rem);
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label,
|
||||||
|
.panel-note,
|
||||||
|
.event-card-summary,
|
||||||
|
.footer-copy,
|
||||||
|
.metric-box span,
|
||||||
|
.text-muted,
|
||||||
|
.meta-list dd,
|
||||||
|
.detail-meta-item span {
|
||||||
|
color: var(--muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-panel {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card,
|
||||||
|
.dashboard-card,
|
||||||
|
.feature-card,
|
||||||
|
.success-shell,
|
||||||
|
.detail-hero,
|
||||||
|
.dashboard-hero,
|
||||||
|
.empty-state {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card,
|
||||||
|
.dashboard-card,
|
||||||
|
.feature-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-title {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-list div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-bottom: 0.7rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-list dt {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-list dd {
|
||||||
|
margin: 0;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-number {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: linear-gradient(135deg, rgba(15, 143, 122, 0.14), rgba(255, 132, 87, 0.18));
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-muted {
|
||||||
|
background: linear-gradient(180deg, rgba(238, 243, 248, 0.55), rgba(246, 248, 251, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-link {
|
||||||
|
color: var(--primary-deep);
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-meta-grid,
|
||||||
|
.success-meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-meta-item {
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.72);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-meta-item strong {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-panel {
|
||||||
|
position: sticky;
|
||||||
|
top: 6.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(19, 34, 56, 0.12);
|
||||||
|
padding: 0.95rem 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
border-color: rgba(15, 143, 122, 0.5);
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(15, 143, 122, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.86rem 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
--bs-btn-bg: var(--primary);
|
||||||
|
--bs-btn-border-color: var(--primary);
|
||||||
|
--bs-btn-hover-bg: var(--primary-deep);
|
||||||
|
--bs-btn-hover-border-color: var(--primary-deep);
|
||||||
|
--bs-btn-active-bg: var(--primary-deep);
|
||||||
|
--bs-btn-active-border-color: var(--primary-deep);
|
||||||
|
--bs-btn-disabled-bg: rgba(15, 143, 122, 0.4);
|
||||||
|
--bs-btn-disabled-border-color: rgba(15, 143, 122, 0.4);
|
||||||
|
box-shadow: 0 14px 30px rgba(15, 143, 122, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
border: 1px solid rgba(19, 34, 56, 0.08);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover,
|
||||||
|
.text-link:hover,
|
||||||
|
.footer-links a:hover {
|
||||||
|
color: var(--primary-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-metrics .metric-box strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-table {
|
||||||
|
--bs-table-bg: transparent;
|
||||||
|
--bs-table-color: var(--text);
|
||||||
|
--bs-table-border-color: rgba(19, 34, 56, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-table thead th {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-table tbody td {
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-alert {
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: 0 16px 30px rgba(19, 34, 56, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
border-top: 1px solid rgba(19, 34, 56, 0.06);
|
||||||
|
margin-top: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-brand {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.section-space {
|
||||||
|
padding: 3.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section {
|
||||||
|
padding-top: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-panel {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
.card-surface,
|
||||||
|
.hero-spotlight,
|
||||||
|
.event-preview-card,
|
||||||
|
.registration-panel,
|
||||||
|
.success-shell {
|
||||||
|
padding: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title,
|
||||||
|
.detail-title {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user