Compare commits

..

No commits in common. "ai-dev" and "master" have entirely different histories.

28 changed files with 186 additions and 2367 deletions

View File

@ -150,13 +150,9 @@ STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'staticfiles' STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [ STATICFILES_DIRS = [
directory BASE_DIR / 'static',
for directory in [ BASE_DIR / 'assets',
BASE_DIR / 'static', BASE_DIR / 'node_modules',
BASE_DIR / 'assets',
BASE_DIR / 'node_modules',
]
if directory.exists()
] ]
# Email # Email
@ -184,7 +180,3 @@ 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'

View File

@ -1,18 +1,3 @@
from django.contrib import admin from django.contrib import admin
from .models import Event, Registration # Register your models here.
@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')

View File

@ -1,114 +0,0 @@
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

View File

@ -1,54 +0,0 @@
# 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')],
},
),
]

View File

@ -1,104 +0,0 @@
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),
]

View File

@ -1,88 +1,3 @@
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}'

View File

@ -1,90 +1,25 @@
{% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <title>{% block title %}Knowledge Base{% endblock %}</title>
<title>{% block title %}{{ page_title|default:project_name }}{% endblock %}</title> {% if project_description %}
<meta name="description" content="{% block meta_description %}{{ meta_description|default:project_description }}{% endblock %}"> <meta name="description" content="{{ project_description }}">
<meta name="author" content="Northstar Events"> <meta property="og:description" content="{{ project_description }}">
<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 %}
<link rel="preconnect" href="https://fonts.googleapis.com"> {% load static %}
<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 %}
<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> </body>
</html> </html>

View File

@ -1,94 +0,0 @@
{% 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 %}

View File

@ -1,172 +1,145 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %} {% block title %}{{ project_name }}{% 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>
<section class="hero-section section-space"> <div class="card">
<div class="container position-relative"> <h1>Analyzing your requirements and generating your app…</h1>
<div class="row align-items-center g-5"> <div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<div class="col-lg-7"> <span class="sr-only">Loading…</span>
<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 class="hero-stats row g-3 mt-4">
<div class="col-sm-4">
<div class="stat-card">
<div class="stat-value">{{ stats.upcoming_events }}</div>
<div class="stat-label">Upcoming events</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> </div>
</section> <p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
<section class="section-space section-search"> <p class="runtime">
<div class="container"> Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
<div class="search-panel card-surface"> — UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
<div> </p>
<p class="section-kicker mb-2">Event finder</p> </div>
<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>
{% endblock %} <footer>
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
</footer>
{% endblock %}

View File

@ -1,35 +0,0 @@
{% 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 %}

View File

@ -1,115 +0,0 @@
{% 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 %}

View File

@ -1,113 +0,0 @@
{% 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 %}

View File

@ -1,26 +0,0 @@
{% 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 %}

View File

@ -1,117 +1,3 @@
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.test import TestCase from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from .models import Event, Registration # Create your tests here.
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')

View File

@ -1,16 +1,7 @@
from django.contrib.auth import views as auth_views
from django.urls import path from django.urls import path
from .views import event_detail, export_attendees_csv, home, organizer_dashboard, organizer_event_create, organizer_event_edit, registration_success from .views import home
urlpatterns = [ urlpatterns = [
path('login/', auth_views.LoginView.as_view(template_name='core/login.html', redirect_authenticated_user=True), name='login'), path("", home, name="home"),
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'),
] ]

View File

@ -1,251 +1,25 @@
import csv
import logging
import os import os
import platform import platform
from django.contrib import messages from django import get_version as django_version
from django.contrib.auth.decorators import login_required from django.shortcuts import render
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 = {
**_base_context(request), "project_name": "New Style",
'page_title': f'{event.title} | Register with Northstar Events', "agent_brand": agent_brand,
'meta_description': event.summary, "django_version": django_version(),
'event': event, "python_version": platform.python_version(),
'form': form, "current_time": now,
'related_events': Event.objects.filter(is_published=True, end_at__gte=timezone.now()).exclude(pk=event.pk)[:3], "host_name": host_name,
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
} }
return render(request, 'core/event_detail.html', context) return render(request, "core/index.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

View File

@ -1,501 +1,4 @@
:root { /* Custom styles for the application */
--bg: #f6f8fb; body {
--surface: rgba(255, 255, 255, 0.88); font-family: system-ui, -apple-system, sans-serif;
--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;
}
} }

View File

@ -1,501 +1,21 @@
:root { :root {
--bg: #f6f8fb; --bg-color-start: #6a11cb;
--surface: rgba(255, 255, 255, 0.88); --bg-color-end: #2575fc;
--surface-strong: #ffffff; --text-color: #ffffff;
--surface-muted: #eef3f8; --card-bg-color: rgba(255, 255, 255, 0.01);
--border: rgba(27, 47, 74, 0.1); --card-border-color: rgba(255, 255, 255, 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;
color: var(--text); background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
background: color: var(--text-color);
radial-gradient(circle at top left, rgba(15, 143, 122, 0.14), transparent 28%), display: flex;
radial-gradient(circle at 80% 10%, rgba(255, 132, 87, 0.16), transparent 22%), justify-content: center;
linear-gradient(180deg, #fbfdff 0%, #f4f7fb 100%); align-items: center;
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;
}
}