diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc
index d79d6a7..94a4110 100644
Binary files a/config/__pycache__/settings.cpython-311.pyc and b/config/__pycache__/settings.cpython-311.pyc differ
diff --git a/config/settings.py b/config/settings.py
index 291d043..9af947f 100644
--- a/config/settings.py
+++ b/config/settings.py
@@ -150,9 +150,13 @@ STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [
- BASE_DIR / 'static',
- BASE_DIR / 'assets',
- BASE_DIR / 'node_modules',
+ directory
+ for directory in [
+ BASE_DIR / 'static',
+ BASE_DIR / 'assets',
+ BASE_DIR / 'node_modules',
+ ]
+ if directory.exists()
]
# Email
@@ -180,3 +184,7 @@ if EMAIL_USE_SSL:
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
+
+LOGIN_URL = 'login'
+LOGIN_REDIRECT_URL = 'organizer_dashboard'
+LOGOUT_REDIRECT_URL = 'home'
diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc
index 5e8987a..e9abcdf 100644
Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ
diff --git a/core/__pycache__/context_processors.cpython-311.pyc b/core/__pycache__/context_processors.cpython-311.pyc
index 75bf223..2f0013b 100644
Binary files a/core/__pycache__/context_processors.cpython-311.pyc and b/core/__pycache__/context_processors.cpython-311.pyc differ
diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc
new file mode 100644
index 0000000..f01cf58
Binary files /dev/null and b/core/__pycache__/forms.cpython-311.pyc differ
diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc
index a251b5f..fabd70c 100644
Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ
diff --git a/core/__pycache__/tests.cpython-311.pyc b/core/__pycache__/tests.cpython-311.pyc
new file mode 100644
index 0000000..1587e3f
Binary files /dev/null and b/core/__pycache__/tests.cpython-311.pyc differ
diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc
index f705988..72f9f98 100644
Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ
diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc
index 2f0989c..efabeee 100644
Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ
diff --git a/core/admin.py b/core/admin.py
index 8c38f3f..29fcede 100644
--- a/core/admin.py
+++ b/core/admin.py
@@ -1,3 +1,18 @@
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')
diff --git a/core/forms.py b/core/forms.py
new file mode 100644
index 0000000..1b41cf8
--- /dev/null
+++ b/core/forms.py
@@ -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
diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py
new file mode 100644
index 0000000..4b5cff6
--- /dev/null
+++ b/core/migrations/0001_initial.py
@@ -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')],
+ },
+ ),
+ ]
diff --git a/core/migrations/0002_seed_demo_events.py b/core/migrations/0002_seed_demo_events.py
new file mode 100644
index 0000000..5861a27
--- /dev/null
+++ b/core/migrations/0002_seed_demo_events.py
@@ -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),
+ ]
diff --git a/core/migrations/__pycache__/0001_initial.cpython-311.pyc b/core/migrations/__pycache__/0001_initial.cpython-311.pyc
new file mode 100644
index 0000000..9092545
Binary files /dev/null and b/core/migrations/__pycache__/0001_initial.cpython-311.pyc differ
diff --git a/core/migrations/__pycache__/0002_seed_demo_events.cpython-311.pyc b/core/migrations/__pycache__/0002_seed_demo_events.cpython-311.pyc
new file mode 100644
index 0000000..cfe9de2
Binary files /dev/null and b/core/migrations/__pycache__/0002_seed_demo_events.cpython-311.pyc differ
diff --git a/core/models.py b/core/models.py
index 71a8362..dae7647 100644
--- a/core/models.py
+++ b/core/models.py
@@ -1,3 +1,88 @@
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}'
diff --git a/core/templates/base.html b/core/templates/base.html
index 1e7e5fb..e345ca1 100644
--- a/core/templates/base.html
+++ b/core/templates/base.html
@@ -1,25 +1,90 @@
+{% load static %}
-
- {% block title %}Knowledge Base{% endblock %}
- {% if project_description %}
-
-
-
- {% endif %}
+
+ {% block title %}{{ page_title|default:project_name }}{% endblock %}
+
+
{% if project_image_url %}
{% endif %}
- {% load static %}
+
+
+
+
{% block head %}{% endblock %}
+
+
+
+ {% if messages %}
+
+ {% for message in messages %}
+
+ {{ message }}
+
+ {% endfor %}
+
+ {% endif %}
-
{% block content %}{% endblock %}
-
+
+
+
+
diff --git a/core/templates/core/event_detail.html b/core/templates/core/event_detail.html
new file mode 100644
index 0000000..afb01b5
--- /dev/null
+++ b/core/templates/core/event_detail.html
@@ -0,0 +1,94 @@
+{% extends "base.html" %}
+
+{% block title %}{{ page_title }}{% endblock %}
+{% block meta_description %}{{ meta_description }}{% endblock %}
+
+{% block content %}
+
+
+
← Back to events
+
+
+
+
{{ event.start_at|date:"l, F j" }}
+
{{ event.title }}
+
{{ event.summary }}
+
+
+
About this event
+
{{ event.description|linebreaksbr }}
+
+
+
+
+
+
+
Reserve your seat
+
Registration form
+
+ {% if event.capacity and event.confirmed_registrations >= event.capacity %}
+
Waitlist
+ {% endif %}
+
+ Submit once per email address. We will confirm your status right away.
+
+
+ {% if event.capacity %}
+ {{ event.spots_remaining }} spots remaining before waitlist.
+ {% else %}
+ Open attendance with no fixed seat limit.
+ {% endif %}
+
+
+
+
+
+
+ {% if related_events %}
+
+
+
+
More events
+
Keep exploring the calendar.
+
+
+
+ {% for related in related_events %}
+
+
+
+ {{ related.start_at|date:"M d" }}
+ Upcoming
+
+ {{ related.title }}
+ {{ related.summary }}
+ View event
+
+
+ {% endfor %}
+
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/core/templates/core/index.html b/core/templates/core/index.html
index faec813..c2411c9 100644
--- a/core/templates/core/index.html
+++ b/core/templates/core/index.html
@@ -1,145 +1,172 @@
{% extends "base.html" %}
-{% block title %}{{ project_name }}{% endblock %}
-
-{% block head %}
-
-
-
-
-{% endblock %}
+{% block title %}{{ page_title }}{% endblock %}
+{% block meta_description %}{{ meta_description }}{% endblock %}
{% block content %}
-
-
Analyzing your requirements and generating your app…
-
-
Loading…
+
+
+
+
+
Single-team event operations
+
Publish beautiful event pages and collect registrations in minutes.
+
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.
+
+
+
+
+
{{ stats.upcoming_events }}
+
Upcoming events
+
+
+
+
+
{{ stats.confirmed_attendees }}
+
Confirmed attendees
+
+
+
+
+
{{ stats.waitlist_total }}
+
Waitlist entries
+
+
+
+
+
+
+
+
+
+
+
+
Featured next up
+ {% if featured_event %}
+
{{ featured_event.title }}
+ {% else %}
+
Ready for your first event
+ {% endif %}
+
+
Live
+
+ {% if featured_event %}
+
+ Date {{ featured_event.start_at|date:"M d, Y" }} · {{ featured_event.start_at|date:"g:i A" }}
+ Venue {{ featured_event.venue }}
+ Capacity {% if featured_event.capacity %}{{ featured_event.confirmed_registrations }}/{{ featured_event.capacity }} booked{% else %}Open attendance{% endif %}
+
+
Register for featured event
+ {% else %}
+
Create events in Django Admin and the landing page will automatically showcase them here.
+
Go to admin
+ {% endif %}
+
+
+
+
- AppWizzy AI is collecting your requirements and applying the first changes.
- This page will refresh automatically as the plan is implemented.
-
- Runtime: Django {{ django_version }} · Python {{ python_version }}
- — UTC {{ current_time|date:"Y-m-d H:i:s" }}
-
-
+
+
+
+
+
+
+
Event finder
+
Find the right session fast.
+
+
+
+
+
+
+
+
+
+
+
Upcoming events
+
Public listing with instant registration.
+
+
See organizer dashboard →
+
+ {% if events %}
+
+ {% for event in events %}
+
+
+
+ {{ event.start_at|date:"M d" }}
+ {% if event.capacity and event.confirmed_registrations >= event.capacity %}
+ Waitlist only
+ {% else %}
+ Open
+ {% endif %}
+
+ {{ event.title }}
+ {{ event.summary }}
+
+
Venue {{ event.venue }}
+
Starts {{ event.start_at|date:"M d, Y · g:i A" }}
+
Availability {% if event.capacity %}{{ event.confirmed_registrations }}/{{ event.capacity }} reserved{% else %}Open attendance{% endif %}
+
+
+
{{ event.waitlist_registrations }} on waitlist
+
View details
+
+
+
+ {% endfor %}
+
+ {% else %}
+
+
No matching events yet
+
{% 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 %}
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
01
+
Publish events
+
Create or edit event details in admin, then let the polished landing page surface them instantly.
+
+
+
+
+
02
+
Collect registrations
+
Attendees browse events, submit a secure form, and receive confirmation or waitlist handling with capacity checks.
+
+
+
+
+
03
+
Manage attendees
+
Monitor counts in the dashboard, review recent signups, and export attendee CSVs for operations.
+
+
+
+
+
-
- Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
-
-{% endblock %}
\ No newline at end of file
+{% endblock %}
diff --git a/core/templates/core/login.html b/core/templates/core/login.html
new file mode 100644
index 0000000..3c8b700
--- /dev/null
+++ b/core/templates/core/login.html
@@ -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 %}
+
+
+
+ Organizer access
+ Sign in to manage events.
+ Use your organizer account to open the protected dashboard, export attendee lists, and jump into Django Admin for event editing.
+
+
+
+
+{% endblock %}
diff --git a/core/templates/core/organizer_dashboard.html b/core/templates/core/organizer_dashboard.html
new file mode 100644
index 0000000..02291b3
--- /dev/null
+++ b/core/templates/core/organizer_dashboard.html
@@ -0,0 +1,115 @@
+{% extends "base.html" %}
+
+{% block title %}{{ page_title }}{% endblock %}
+{% block meta_description %}{{ meta_description }}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
Organizer dashboard
+
Keep every event, signup, and export in one place.
+
Create new events inside the dashboard, review registration volume, and keep a quick path into attendee records without leaving this workspace.
+
+
+
+
+
{{ dashboard_stats.upcoming_events }}
Upcoming events
+
{{ dashboard_stats.confirmed_attendees }}
Confirmed attendees
+
{{ dashboard_stats.waitlist_total }}
Waitlist total
+
+
+
+
+
+
+
Event roster
+
See registration volume at a glance.
+
+
+ {% if events %}
+
+ {% for event in events %}
+
+
+
+
+
{{ event.title }}
+
{{ event.start_at|date:"M d, Y · g:i A" }} · {{ event.venue }}
+
+
+
+
+
Confirmed {{ event.confirmed_registrations }}
+
Waitlist {{ event.waitlist_registrations }}
+
Total {{ event.total_registrations }}
+
+ {% if event.capacity %}Capacity {{ event.capacity }} seats.{% else %}Flexible attendance limit.{% endif %}
+
+
+ {% endfor %}
+
+ {% else %}
+
+
No events created yet
+
Start by creating your first event from the dashboard, then share the public event page for registrations.
+
Create first event
+
+ {% endif %}
+
+
+
+
+
+
Recent attendees
+
Latest registrations across events.
+
+
+ {% if recent_registrations %}
+
+
+
+
+
+ Name
+ Event
+ Status
+ Email
+ Registered
+
+
+
+ {% for registration in recent_registrations %}
+
+ {{ registration.full_name }}
+ {{ registration.event.title }}
+ {{ registration.get_status_display }}
+ {{ registration.email }}
+ {{ registration.created_at|date:"M d, g:i A" }}
+
+ {% endfor %}
+
+
+
+
+ {% else %}
+
+
No registrations yet
+
Once attendees submit the public form, their records will appear here instantly.
+
+ {% endif %}
+
+
+
+{% endblock %}
diff --git a/core/templates/core/organizer_event_form.html b/core/templates/core/organizer_event_form.html
new file mode 100644
index 0000000..a7bfa0e
--- /dev/null
+++ b/core/templates/core/organizer_event_form.html
@@ -0,0 +1,113 @@
+{% extends "base.html" %}
+
+{% block title %}{{ page_title }}{% endblock %}
+{% block meta_description %}{{ meta_description }}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
Organizer tools
+
{{ form_title }}
+
{{ form_intro }}
+
+
+
+
+
+
+
+
+
+
Event setup
+
{{ form_section_title }}
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/core/registration_success.html b/core/templates/core/registration_success.html
new file mode 100644
index 0000000..185b743
--- /dev/null
+++ b/core/templates/core/registration_success.html
@@ -0,0 +1,26 @@
+{% extends "base.html" %}
+
+{% block title %}{{ page_title }}{% endblock %}
+{% block meta_description %}{{ meta_description }}{% endblock %}
+
+{% block content %}
+
+
+
+ Registration received
+ {% if registration.status == 'waitlist' %}You are on the waitlist.{% else %}Your spot is secured.{% endif %}
+ {{ registration.full_name }}, your registration for {{ event.title }} 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 %}
+
+
Event {{ event.title }}
+
When {{ event.start_at|date:"M d, Y · g:i A" }}
+
Where {{ event.venue }}
+
Status {{ registration.get_status_display }}
+
+
+
+
+
+{% endblock %}
diff --git a/core/tests.py b/core/tests.py
index 7ce503c..4aab592 100644
--- a/core/tests.py
+++ b/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')
diff --git a/core/urls.py b/core/urls.py
index 6299e3d..d5d2696 100644
--- a/core/urls.py
+++ b/core/urls.py
@@ -1,7 +1,16 @@
+from django.contrib.auth import views as auth_views
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 = [
- 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/
/', event_detail, name='event_detail'),
+ path('events//registered//', 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//edit/', organizer_event_edit, name='organizer_event_edit'),
+ path('dashboard/events//attendees.csv', export_attendees_csv, name='export_attendees_csv'),
]
diff --git a/core/views.py b/core/views.py
index c9aed12..5a1e538 100644
--- a/core/views.py
+++ b/core/views.py
@@ -1,25 +1,251 @@
+import csv
+import logging
import os
import platform
-from django import get_version as django_version
-from django.shortcuts import render
+from django.contrib import messages
+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 .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):
- """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()
+ 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 = {
- "project_name": "New Style",
- "agent_brand": agent_brand,
- "django_version": django_version(),
- "python_version": platform.python_version(),
- "current_time": now,
- "host_name": host_name,
- "project_description": os.getenv("PROJECT_DESCRIPTION", ""),
- "project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
+ **_base_context(request),
+ 'page_title': f'{event.title} | Register with Northstar Events',
+ 'meta_description': event.summary,
+ 'event': event,
+ 'form': form,
+ 'related_events': Event.objects.filter(is_published=True, end_at__gte=timezone.now()).exclude(pk=event.pk)[:3],
}
- 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
diff --git a/static/css/custom.css b/static/css/custom.css
index 925f6ed..f6cf2e3 100644
--- a/static/css/custom.css
+++ b/static/css/custom.css
@@ -1,4 +1,501 @@
-/* Custom styles for the application */
-body {
- font-family: system-ui, -apple-system, sans-serif;
+:root {
+ --bg: #f6f8fb;
+ --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;
+ }
}
diff --git a/staticfiles/css/custom.css b/staticfiles/css/custom.css
index 108056f..f6cf2e3 100644
--- a/staticfiles/css/custom.css
+++ b/staticfiles/css/custom.css
@@ -1,21 +1,501 @@
-
: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);
+ --bg: #f6f8fb;
+ --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;
}
-body {
+
+* {
+ box-sizing: border-box;
+}
+
+html {
+ scroll-behavior: smooth;
+}
+
+body.app-shell {
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;
+ 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;
- 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;
+}
+
+.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;
+ }
+}