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 %} + + {% 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 }}

+
+
Venue{{ event.venue }}
+
Time{{ event.start_at|date:"g:i A" }} – {{ event.end_at|date:"g:i A" }}
+
Status{% if event.registration_is_open %}Registration open{% else %}Registration closed{% endif %}
+
Capacity{% if event.capacity %}{{ event.confirmed_registrations }}/{{ event.capacity }} booked{% else %}Flexible{% endif %}
+
+
+

About this event

+

{{ event.description|linebreaksbr }}

+
+
+
+ +
+
+
+ + {% 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" }} -

-
+ + + + +
+
+
+
+

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.

+
+
+
+
+
- -{% 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.

+
+ {% csrf_token %} + {% if form.non_field_errors %} +
{{ form.non_field_errors }}
+ {% endif %} +
+ + +
+
+ + +
+ {% if next %}{% endif %} +
+ + Open Django Admin +
+
+
+
+
+{% 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 %} +
+
+ + + + + + + + + + + + {% for registration in recent_registrations %} + + + + + + + + {% endfor %} + +
NameEventStatusEmailRegistered
{{ registration.full_name }}{{ registration.event.title }}{{ registration.get_status_display }}{{ registration.email }}{{ registration.created_at|date:"M d, g:i A" }}
+
+
+ {% 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 }}

+
+
+ +
+ {% csrf_token %} + {% if form.non_field_errors %} +
{{ form.non_field_errors }}
+ {% endif %} + +
+
+
+ + {{ form.title }} + {% if form.title.errors %}
{{ form.title.errors|join:', ' }}
{% endif %} +
+
+ + {{ form.summary }} + {% if form.summary.errors %}
{{ form.summary.errors|join:', ' }}
{% endif %} +
+
+ + {{ form.description }} + {% if form.description.errors %}
{{ form.description.errors|join:', ' }}
{% endif %} +
+
+ + {{ form.venue }} + {% if form.venue.errors %}
{{ form.venue.errors|join:', ' }}
{% endif %} +
+
+ +
+
+
+

Schedule & access

+
+ + {{ form.start_at }} + {% if form.start_at.errors %}
{{ form.start_at.errors|join:', ' }}
{% endif %} +
+
+ + {{ form.end_at }} + {% if form.end_at.errors %}
{{ form.end_at.errors|join:', ' }}
{% endif %} +
+
+ + {{ form.capacity }} +
Leave blank for open attendance.
+ {% if form.capacity.errors %}
{{ form.capacity.errors|join:', ' }}
{% endif %} +
+
+ + {{ form.registration_opens }} + {% if form.registration_opens.errors %}
{{ form.registration_opens.errors|join:', ' }}
{% endif %} +
+
+ + {{ form.registration_closes }} + {% if form.registration_closes.errors %}
{{ form.registration_closes.errors|join:', ' }}
{% endif %} +
+
+ {{ form.is_published }} + + {% if form.is_published.errors %}
{{ form.is_published.errors|join:', ' }}
{% endif %} +
+
+
+
+
+ +
+ + Cancel +
+
+
+
+
+
+{% 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; + } +}