nordabiz/templates/calendar/event.html
Maciej Pienczyn eaac876ec2
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
feat(calendar): multi-day events + **bold** w opisach wydarzeń
- norda_events: kolumna event_date_end (NULLABLE, check constraint >= event_date)
- NordaEvent: property is_multi_day, date_range_display; is_past uwzględnia koniec
- Admin (new/edit): pole "Data zakończenia" w formularzu
- Calendar grid: wydarzenie wielodniowe wyświetla się na każdym dniu zakresu
- Upcoming/past filter: używa COALESCE(end, date) — 2-dniowe zostaje w Upcoming
  do swojego ostatniego dnia
- event.html: "Termin" + zakres dla wielodniowych; ICS/Google end date z dateEnd
- Lekki markdown dla opisów: tylko **bold** → <strong> (audyt: tylko event #60)

Zero wpływu na 42 istniejące wydarzenia (NULL == stare zachowanie).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:52:31 +02:00

1110 lines
46 KiB
HTML
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block title %}{{ event.title }} - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.guest-type-btn.active { background: var(--primary) !important; color: white !important; }
.guest-type-btn:not(.active):hover { background: var(--border) !important; }
.guest-form-group { display: flex; flex-direction: column; }
.guest-form-label { font-size: 0.8em; font-weight: 500; color: var(--text-secondary); margin-bottom: 4px; }
.guest-form-input {
width: 100%; padding: 8px 12px;
border: 1px solid var(--border); border-radius: var(--radius);
font-size: 0.9em; transition: var(--transition);
background: var(--background);
}
.guest-form-input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); }
@media (max-width: 480px) {
#guest-form div[style*="grid-template-columns"] { grid-template-columns: 1fr !important; }
}
.event-header {
margin-bottom: var(--spacing-xl);
}
.event-header h1 {
font-size: var(--font-size-3xl);
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.event-detail {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
box-shadow: var(--shadow);
margin-bottom: var(--spacing-xl);
}
.event-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.info-item {
display: flex;
align-items: flex-start;
gap: var(--spacing-sm);
}
.info-item svg {
width: 20px;
height: 20px;
color: var(--primary);
flex-shrink: 0;
margin-top: 2px;
}
.info-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.info-value {
font-weight: 500;
color: var(--text-primary);
}
.event-description {
margin-bottom: var(--spacing-xl);
line-height: 1.7;
color: var(--text-primary);
font-size: 1.05rem;
}
.event-description p {
margin-bottom: var(--spacing-md);
}
.event-description p:last-child {
margin-bottom: 0;
}
.event-description h3 {
font-size: var(--font-size-lg);
margin: var(--spacing-lg) 0 var(--spacing-sm);
color: var(--text-primary);
}
.event-description ul {
margin: 0 0 var(--spacing-md) var(--spacing-lg);
padding: 0;
}
.event-description li {
margin-bottom: var(--spacing-xs);
}
.event-callout {
display: flex;
align-items: flex-start;
gap: var(--spacing-md);
padding: var(--spacing-lg);
background: linear-gradient(135deg, #f0fdf4, #ecfdf5);
border: 1px solid #bbf7d0;
border-radius: var(--radius-lg);
margin-top: var(--spacing-lg);
white-space: normal;
}
.event-callout svg {
width: 22px;
height: 22px;
color: #16a34a;
flex-shrink: 0;
margin-top: 2px;
}
.event-callout strong {
color: #15803d;
}
.event-callout small {
display: block;
margin-top: var(--spacing-xs);
color: var(--text-secondary);
}
.rsvp-section {
display: flex;
align-items: center;
gap: var(--spacing-lg);
padding: var(--spacing-lg);
background: var(--background);
border-radius: var(--radius);
}
.attendees-section {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
box-shadow: var(--shadow);
}
.attendees-section h2 {
font-size: var(--font-size-xl);
margin-bottom: var(--spacing-lg);
}
.attendees-list {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.attendee-badge {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-xs);
background: var(--background);
border-radius: var(--radius);
font-size: var(--font-size-sm);
}
/* Unverified person - blue */
.attendee-name {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
background: #dbeafe;
color: #1e40af;
border-radius: var(--radius);
font-weight: 500;
font-size: var(--font-size-sm);
text-decoration: none;
transition: var(--transition);
}
/* Verified person - green */
.attendee-name.verified {
background: #dcfce7;
color: #166534;
}
a.attendee-name:hover {
background: #bfdbfe;
transform: translateY(-1px);
}
a.attendee-name.verified:hover {
background: #bbf7d0;
}
.attendee-name svg {
width: 14px;
height: 14px;
}
.attendee-company {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
background: #fee2e2;
color: #991b1b;
border-radius: var(--radius);
font-weight: 500;
font-size: var(--font-size-sm);
text-decoration: none;
transition: var(--transition);
}
.attendee-company:hover {
background: #fecaca;
transform: translateY(-1px);
}
.attendee-company svg {
width: 14px;
height: 14px;
}
.back-link {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--text-secondary);
text-decoration: none;
margin-bottom: var(--spacing-lg);
}
.back-link:hover {
color: var(--primary);
}
#rsvp-btn.attending {
background: var(--success);
}
.event-logo {
display: flex;
align-items: center;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.event-logo img {
height: 48px;
width: auto;
}
/* Pill badge links — spójne z NordaGPT chat */
.person-link, .company-link {
display: inline-block;
padding: 2px 10px;
margin: 1px 2px;
border-radius: 12px;
text-decoration: none;
font-weight: 600;
font-size: 0.95em;
transition: var(--transition);
}
.person-link {
background: #ecfdf5;
color: #047857;
}
.person-link:hover {
background: #d1fae5;
color: #065f46;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(4, 120, 87, 0.2);
}
.company-link {
background: #fff7ed;
color: #c2410c;
}
.company-link:hover {
background: #ffedd5;
color: #9a3412;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(194, 65, 12, 0.2);
}
.speaker-link {
color: var(--primary);
text-decoration: none;
font-weight: 500;
}
.speaker-link:hover {
text-decoration: underline;
}
.calendar-add {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-md) var(--spacing-lg);
background: linear-gradient(135deg, #f0f9ff, #eff6ff);
border: 1px solid #bfdbfe;
border-radius: var(--radius-lg);
margin-bottom: var(--spacing-xl);
}
.calendar-add-icon {
width: 40px;
height: 40px;
background: var(--primary);
border-radius: var(--radius);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.calendar-add-icon svg {
width: 20px;
height: 20px;
color: white;
}
.calendar-add-label {
font-size: var(--font-size-sm);
font-weight: 600;
color: #1e40af;
}
.calendar-add-buttons {
display: flex;
gap: var(--spacing-sm);
margin-left: auto;
}
.calendar-add-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
text-decoration: none;
transition: var(--transition);
border: none;
cursor: pointer;
color: white;
}
.calendar-add-btn.google {
background: #4285f4;
}
.calendar-add-btn.google:hover {
background: #3367d6;
}
.calendar-add-btn.outlook {
background: #0078d4;
}
.calendar-add-btn.outlook:hover {
background: #106ebe;
}
.calendar-add-btn svg {
width: 16px;
height: 16px;
}
@media (max-width: 640px) {
.calendar-add {
flex-direction: column;
align-items: stretch;
gap: var(--spacing-sm);
}
.calendar-add-buttons {
margin-left: 0;
}
.calendar-add-icon {
display: none;
}
}
</style>
{% endblock %}
{% block content %}
<a href="{{ url_for('calendar.calendar_index') }}" class="back-link">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Powrót do kalendarza
</a>
{% if current_user.is_authenticated and current_user.can_access_admin_panel() %}
<div class="admin-event-bar" style="display: flex; gap: 8px; flex-wrap: wrap; padding: 10px 16px; background: #f0f4ff; border: 1px solid #c7d2fe; border-radius: var(--radius-lg); margin-bottom: var(--spacing-lg);">
<a href="{{ url_for('admin.admin_calendar_edit', event_id=event.id) }}" class="btn btn-primary" style="font-size: 0.85em;">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" style="vertical-align: -2px; margin-right: 4px;">
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
Edytuj wydarzenie
</a>
{% if event.is_paid %}
<a href="{{ url_for('admin.admin_event_payments', event_id=event.id) }}" class="btn btn-outline" style="font-size: 0.85em; background: white;">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" style="vertical-align: -2px; margin-right: 4px;">
<path d="M12 1v22M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/>
</svg>
Zarządzaj płatnościami
</a>
{% endif %}
</div>
{% endif %}
<div class="event-header">
{% if event.is_external %}
<div style="display: flex; align-items: center; gap: var(--spacing-sm); padding: var(--spacing-sm) var(--spacing-md); background: #f1f5f9; border: 1px solid #cbd5e1; border-radius: var(--radius); margin-bottom: var(--spacing-md); font-size: var(--font-size-sm); color: #475569;">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Wydarzenie zewnętrzne{% if event.external_source %} &middot; {{ event.external_source }}{% endif %}
</div>
{% elif event.is_featured %}
<div class="event-logo">
<img src="{{ url_for('static', filename='img/norda-logo.svg') }}" alt="Norda Biznes">
</div>
{% endif %}
<h1>{{ event.title }}
{% if event.is_external %}
<span style="display:inline-block;background:#94a3b8;color:#fff;font-size:12px;padding:2px 8px;border-radius:4px;font-weight:600;vertical-align:middle;">ZEWNĘTRZNE</span>
{% elif event.access_level == 'admin_only' %}
<span style="display:inline-block;background:#ef4444;color:#fff;font-size:12px;padding:2px 8px;border-radius:4px;font-weight:600;vertical-align:middle;">UKRYTE</span>
{% elif event.access_level == 'rada_only' %}
<span style="display:inline-block;background:#f59e0b;color:#92400e;font-size:12px;padding:2px 8px;border-radius:4px;font-weight:600;vertical-align:middle;">IZBA</span>
{% endif %}
</h1>
</div>
<div class="event-detail">
<div class="event-info-grid">
<div class="info-item">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
<div>
<div class="info-label">{% if event.is_multi_day %}Termin{% else %}Data{% endif %}</div>
<div class="info-value">{{ event.date_range_display }}</div>
</div>
</div>
{% if event.time_start %}
<div class="info-item">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
<div>
<div class="info-label">Godzina</div>
<div class="info-value">{{ event.time_start.strftime('%H:%M') }}{% if event.time_end %} - {{ event.time_end.strftime('%H:%M') }}{% endif %}</div>
</div>
</div>
{% endif %}
{% if event.location %}
<div class="info-item">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
<circle cx="12" cy="10" r="3"></circle>
</svg>
<div>
<div class="info-label">Miejsce</div>
<div class="info-value">
{% if event.location_url %}
<a href="{{ event.location_url }}" target="_blank" style="color:var(--primary);">{{ event.location }}</a>
{% elif event.location and event.location not in ['Do ustalenia', 'Online'] and 'do ustalenia' not in event.location|lower %}
<a href="https://www.google.com/maps/search/{{ event.location|urlencode }}" target="_blank" style="color:var(--primary);">{{ event.location }}</a>
{% else %}
{{ event.location }}
{% endif %}
</div>
</div>
</div>
{% endif %}
{% if event.speaker_name %}
<div class="info-item">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
<div>
<div class="info-label">Prelegent</div>
<div class="info-value">
{% if speaker_user_id %}
<a href="{{ url_for('public.user_profile', user_id=speaker_user_id) }}" class="person-link">{{ event.speaker_name }}</a>
{% elif speaker_company_slug %}
<a href="{{ url_for('public.company_detail_by_slug', slug=speaker_company_slug) }}" class="company-link">{{ event.speaker_name }}</a>
{% else %}
{{ event.speaker_name }}
{% endif %}
</div>
</div>
</div>
{% endif %}
</div>
{% if event.time_start and not event.is_past %}
<div class="calendar-add">
<div class="calendar-add-icon">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
</div>
<div class="calendar-add-label">Dodaj do kalendarza</div>
<div class="calendar-add-buttons">
<a href="#" class="calendar-add-btn google" onclick="addToGoogleCalendar(); return false;">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.94-.49-7-3.85-7-7.93s3.05-7.44 7-7.93v2.02C8.16 6.57 6 9.03 6 12s2.16 5.43 5 5.91v2.02zm2 0V17.9c2.84-.48 5-2.94 5-5.9s-2.16-5.43-5-5.91V4.07c3.94.49 7 3.85 7 7.93s-3.06 7.44-7 7.93z"/></svg>
Google
</a>
<a href="#" class="calendar-add-btn outlook" onclick="downloadICS(); return false;">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 4h-1V2h-2v2H8V2H6v2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V10h14v10zm0-12H5V6h14v2z"/></svg>
Outlook / ICS
</a>
</div>
</div>
{% endif %}
{% if event.is_external and event.external_url %}
<div style="padding: var(--spacing-lg); background: linear-gradient(135deg, #eff6ff, #f0f9ff); border: 1px solid #93c5fd; border-radius: var(--radius-lg); margin-bottom: var(--spacing-xl); text-align: center;">
<div style="font-weight: 600; color: #1e40af; margin-bottom: var(--spacing-sm);">Rejestracja u organizatora</div>
<a href="{{ event.external_url }}" target="_blank" class="btn btn-primary" style="font-size: var(--font-size-lg); padding: var(--spacing-sm) var(--spacing-xl);">
Przejdź do rejestracji &rarr;
</a>
{% if event.external_source %}
<div style="margin-top: var(--spacing-sm); font-size: var(--font-size-sm); color: var(--text-secondary);">{{ event.external_source }}</div>
{% endif %}
</div>
{% endif %}
{% if event.image_url %}
<div style="margin-bottom: var(--spacing-xl); border-radius: var(--radius-lg); overflow: hidden;">
<img src="{{ event.image_url }}" alt="{{ event.title }}" style="width: 100%; height: auto; display: block;">
</div>
{% endif %}
{% if enriched_description %}
<div class="event-description">
{{ enriched_description }}
</div>
{% elif event.description %}
<div class="event-description">
{{ event.description|safe }}
</div>
{% endif %}
{% if event.attachment_filename %}
<div style="display: flex; align-items: center; gap: var(--spacing-md); padding: var(--spacing-lg); background: linear-gradient(135deg, #fef3c7, #fefce8); border: 1px solid #fde68a; border-radius: var(--radius-lg); margin-bottom: var(--spacing-xl);">
<svg width="24" height="24" fill="none" stroke="#92400e" stroke-width="2" viewBox="0 0 24 24" style="flex-shrink: 0;">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
</svg>
<div style="flex: 1;">
<div style="font-weight: 600; color: #92400e;">Załącznik</div>
<a href="{{ url_for('static', filename=event.attachment_path.replace('static/', '')) }}" target="_blank" style="color: #b45309; text-decoration: underline;">{{ event.attachment_filename }}</a>
</div>
</div>
{% endif %}
{% if not event.is_past %}
{% if event.can_user_attend(current_user) %}
<div class="rsvp-section">
{% if event.is_external %}
<div>
<strong>Interesuje Cię to wydarzenie?</strong>
<p class="text-muted" style="margin: 0;">{{ event.total_attendee_count }} osób zainteresowanych z Izby</p>
</div>
<button id="rsvp-btn" class="btn {% if user_attending %}btn-secondary attending{% else %}btn-primary{% endif %}" onclick="toggleRSVP()">
{% if user_attending %}Nie interesuje mnie{% else %}Zainteresowany{% endif %}
</button>
{% else %}
<div>
<strong>Chcesz wziąć udział?</strong>
<p class="text-muted" style="margin: 0;">{{ event.total_attendee_count }} osób już się zapisało{% if event.max_attendees %} (limit: {{ event.max_attendees }}){% endif %}</p>
{% if event.is_paid and not user_attending %}
<p style="margin: 4px 0 0; font-size: 0.85em; color: var(--warning-text, #92400e); background: #fef3c7; padding: 6px 10px; border-radius: var(--radius-sm); display: inline-block;">
Wydarzenie płatne: <strong>{{ "%.0f"|format(event.price_member) }} zł</strong> (członkowie) / <strong>{{ "%.0f"|format(event.price_guest) }} zł</strong> (goście)
</p>
{% endif %}
</div>
<button id="rsvp-btn" class="btn {% if user_attending %}btn-secondary attending{% else %}btn-primary{% endif %}" onclick="toggleRSVP()">
{% if user_attending %}Wypisz się{% else %}Wezmę udział{% endif %}
</button>
{% endif %}
{% if event.is_paid and user_attending %}
{% set my_amount = (user_attending.payment_amount or 0)|float %}
{% set guests_amount = user_guests|map(attribute='payment_amount')|select('ne', none)|map('float')|sum if user_guests else 0 %}
{% set total_to_pay = my_amount + guests_amount %}
<div style="margin-top: 8px; width: 100%;">
{% if user_attending.payment_status == 'paid' and not user_guests %}
<span style="background: #dcfce7; color: #166534; padding: 6px 12px; border-radius: var(--radius-sm); font-size: 0.85em; font-weight: 600; display: inline-block;">
✓ Płatność potwierdzona
</span>
{% elif user_attending.payment_status == 'exempt' and not user_guests %}
<span style="background: #f3f4f6; color: #6b7280; padding: 6px 12px; border-radius: var(--radius-sm); font-size: 0.85em; font-weight: 600; display: inline-block;">
Zwolniony z opłaty
</span>
{% else %}
<div style="background: #fef3c7; color: #92400e; padding: 10px 14px; border-radius: var(--radius-sm); font-size: 0.85em; display: inline-block;">
<strong>Do wpłaty łącznie: {{ "%.0f"|format(total_to_pay) }} zł</strong>
<div style="font-size: 0.9em; margin-top: 4px; color: #a16207;">
Ty: {{ "%.0f"|format(my_amount) }} zł
{% if user_guests %}
+ {{ user_guests|length }} {{ 'osoba' if user_guests|length == 1 else 'osoby' if user_guests|length < 5 else 'osób' }}: {{ "%.0f"|format(guests_amount) }}
{% endif %}
</div>
</div>
{% endif %}
</div>
{% endif %}
</div>
{# --- Guest management section --- #}
{% if not event.is_external %}
<div class="guest-section" style="margin-top: 16px;">
<div id="user-guests-list">
{% if user_guests %}
<p style="margin: 0 0 8px; font-weight: 600; font-size: 0.9em; color: var(--text-secondary);">
Twoi goście ({{ user_guests|length }}/5):
</p>
{% for guest in user_guests %}
<div class="guest-item" data-guest-id="{{ guest.id }}" style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--surface); border-radius: var(--radius); margin-bottom: 4px; font-size: 0.9em;">
<span class="guest-display" style="flex: 1;">
{{ guest.display_name }}{% if guest.organization %} <span style="color: var(--text-secondary);">({{ guest.organization }})</span>{% endif %}
</span>
<button onclick="editGuest({{ guest.id }}, '{{ guest.first_name|default('', true)|e }}', '{{ guest.last_name|default('', true)|e }}', '{{ guest.organization|default('', true)|e }}')" style="background: none; border: none; cursor: pointer; color: var(--primary); font-size: 0.85em; padding: 2px 6px;">edytuj</button>
<button onclick="deleteGuest({{ guest.id }})" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1.1em; padding: 2px 6px;" title="Usuń gościa">&times;</button>
</div>
{% endfor %}
{% endif %}
</div>
{% if user_guests|length < 5 %}
<button id="add-guest-btn" onclick="toggleGuestForm()" class="btn btn-outline" style="margin-top: 8px; font-size: 0.9em;">
+ Dodaj osobę towarzyszącą / gościa
</button>
{% endif %}
<div id="guest-form" style="display: none; margin-top: 12px; padding: 20px; background: var(--surface); border-radius: var(--radius-lg); border: 1px solid var(--border); box-shadow: var(--shadow);">
<input type="hidden" id="guest-edit-id" value="">
<input type="hidden" id="guest-type" value="external">
{% if event.is_paid and current_user.company_id %}
<div id="guest-type-selector" style="display: flex; gap: 0; margin-bottom: 16px; border-radius: var(--radius); overflow: hidden; border: 1px solid var(--border);">
<button type="button" class="guest-type-btn active" data-type="member" onclick="selectGuestType('member')" style="flex: 1; padding: 10px 16px; border: none; cursor: pointer; font-size: 0.85em; font-weight: 600; transition: var(--transition); background: var(--primary); color: white;">
Osoba z firmy{% if event.price_member %} ({{ "%.0f"|format(event.price_member) }} zł){% endif %}
</button>
<button type="button" class="guest-type-btn" data-type="external" onclick="selectGuestType('external')" style="flex: 1; padding: 10px 16px; border: none; border-left: 1px solid var(--border); cursor: pointer; font-size: 0.85em; font-weight: 600; transition: var(--transition); background: var(--background); color: var(--text-secondary);">
Gość spoza Izby{% if event.price_guest %} ({{ "%.0f"|format(event.price_guest) }} zł){% endif %}
</button>
</div>
<div id="colleague-picker" class="guest-form-group" style="margin-bottom: 12px;">
<label for="colleague-select" class="guest-form-label">Wybierz osobę z firmy</label>
<select id="colleague-select" class="form-control guest-form-input" onchange="onColleagueSelect()">
<option value="">— wpisz dane ręcznie —</option>
</select>
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
<div class="guest-form-group">
<label for="guest-first-name" class="guest-form-label">Imię</label>
<input type="text" id="guest-first-name" maxlength="100" class="form-control guest-form-input" placeholder="np. Jan">
</div>
<div class="guest-form-group">
<label for="guest-last-name" class="guest-form-label">Nazwisko</label>
<input type="text" id="guest-last-name" maxlength="100" class="form-control guest-form-input" placeholder="np. Kowalski">
</div>
</div>
<div class="guest-form-group" style="margin-top: 12px;">
<label for="guest-org" class="guest-form-label">Firma / organizacja</label>
<input type="text" id="guest-org" maxlength="255" class="form-control guest-form-input" placeholder="np. Nazwa firmy Sp. z o.o.">
</div>
<div style="display: flex; gap: 8px; margin-top: 16px;">
<button id="guest-submit-btn" onclick="submitGuest()" class="btn btn-primary" style="font-size: 0.9em;">Dodaj osobę</button>
<button onclick="cancelGuestForm()" class="btn btn-outline" style="font-size: 0.9em;">Anuluj</button>
</div>
<p id="guest-form-error" style="display: none; color: var(--error); font-size: 0.85em; margin: 8px 0 0;"></p>
</div>
</div>
{% endif %}
{% elif event.access_level == 'rada_only' %}
<div class="rsvp-section" style="background: #fef3c7; border: 1px solid #fde68a;">
<svg width="24" height="24" fill="none" stroke="#92400e" stroke-width="2" viewBox="0 0 24 24" style="flex-shrink: 0;">
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
<div>
<strong style="color: #92400e;">Wydarzenie tylko dla Rady Izby</strong>
<p class="text-muted" style="margin: 0; color: #a16207;">Zapisy są dostępne wyłącznie dla członków Rady Izby NORDA.</p>
</div>
</div>
{% endif %}
{% else %}
<div class="rsvp-section" style="background: var(--border);">
<p class="text-muted" style="margin: 0;">To wydarzenie już się odbyło.{% if event.can_user_see_attendees(current_user) %} {{ event.total_attendee_count }} osób brało udział.{% endif %}</p>
</div>
{% endif %}
</div>
{% if (event.attendees or event.guests) and event.can_user_see_attendees(current_user) %}
<div class="attendees-section">
<h2>{{ 'Zainteresowani' if event.is_external else 'Uczestnicy' }} ({{ event.total_attendee_count }})</h2>
<div class="attendees-list">
{# --- Regular attendees with their guests --- #}
{% set ns = namespace(shown_hosts=[]) %}
{% for attendee in event.attendees|sort(attribute='user.name') %}
<div class="attendee-badge">
{% if attendee.user.person_id %}
<a href="{{ url_for('public.person_detail', person_id=attendee.user.person_id) }}" class="attendee-name verified">
{% else %}
<a href="{{ url_for('public.user_profile', user_id=attendee.user.id) }}" class="attendee-name verified">
{% endif %}
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
{{ attendee.user.name or attendee.user.email.split('@')[0] }}
</a>
{% if attendee.user.company %}
<a href="{{ url_for('public.company_detail_by_slug', slug=attendee.user.company.slug) }}" class="attendee-company">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 21V5a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v5m-4 0h4"></path>
</svg>
{{ attendee.user.company.name }}
</a>
{% endif %}
</div>
{# Guests of this attendee #}
{% for guest in event.guests if guest.host_user_id == attendee.user.id %}
<div class="attendee-badge" style="margin-left: 28px; font-size: 0.9em;">
<span class="attendee-name" style="color: var(--text-secondary);">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" style="width: 14px; height: 14px;">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
gość: {{ guest.display_name }}{% if guest.organization %} ({{ guest.organization }}){% endif %}
</span>
</div>
{% endfor %}
{% if ns.shown_hosts.append(attendee.user.id) %}{% endif %}
{% endfor %}
{# --- Hosts who are NOT attending but have guests --- #}
{% for guest in event.guests %}
{% if guest.host_user_id not in ns.shown_hosts %}
{% if ns.shown_hosts.append(guest.host_user_id) %}{% endif %}
<div class="attendee-badge" style="opacity: 0.7;">
<span class="attendee-name" style="color: var(--text-secondary);">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
{{ guest.host.name or 'Użytkownik' }} <em style="font-size: 0.85em;">(nie uczestniczy)</em>
</span>
</div>
{% for g in event.guests if g.host_user_id == guest.host_user_id %}
<div class="attendee-badge" style="margin-left: 28px; font-size: 0.9em;">
<span class="attendee-name" style="color: var(--text-secondary);">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" style="width: 14px; height: 14px;">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
gość: {{ g.display_name }}{% if g.organization %} ({{ g.organization }}){% endif %}
</span>
</div>
{% endfor %}
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
<div id="toastContainer" style="position: fixed; top: 80px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 10px;"></div>
<style>
.toast { padding: 12px 20px; border-radius: var(--radius); background: var(--surface); border-left: 4px solid var(--primary); box-shadow: 0 4px 12px rgba(0,0,0,0.15); display: flex; align-items: center; gap: 10px; animation: toastIn 0.3s ease; }
.toast.success { border-left-color: var(--success); }
.toast.error { border-left-color: var(--error); }
@keyframes toastIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes toastOut { from { opacity: 1; } to { opacity: 0; } }
</style>
{% endblock %}
{% block extra_js %}
const csrfToken = '{{ csrf_token() }}';
function showToast(message, type = 'info', duration = 4000) {
const container = document.getElementById('toastContainer');
const icons = { success: '✓', error: '✕', warning: '⚠', info: '' };
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `<span style="font-size:1.2em">${icons[type]||''}</span><span>${message}</span>`;
container.appendChild(toast);
setTimeout(() => { toast.style.animation = 'toastOut 0.3s ease forwards'; setTimeout(() => toast.remove(), 300); }, duration);
}
async function toggleRSVP() {
const btn = document.getElementById('rsvp-btn');
btn.disabled = true;
try {
const response = await fetch('{{ url_for("calendar.calendar_rsvp", event_id=event.id) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
const isExt = {{ 'true' if event.is_external else 'false' }};
if (data.success) {
if (data.action === 'added') {
btn.textContent = isExt ? 'Nie interesuje mnie' : 'Wypisz się';
btn.classList.remove('btn-primary');
btn.classList.add('btn-secondary', 'attending');
showToast(isExt ? 'Oznaczono jako zainteresowany!' : 'Zapisano na wydarzenie!', 'success');
} else {
btn.textContent = isExt ? 'Zainteresowany' : 'Wezmę udział';
btn.classList.remove('btn-secondary', 'attending');
btn.classList.add('btn-primary');
showToast(isExt ? 'Usunięto zainteresowanie' : 'Wypisano z wydarzenia', 'info');
}
// Refresh page to update attendees list
setTimeout(() => location.reload(), 1000);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia', 'error');
}
btn.disabled = false;
}
/* --- Guest management --- */
let colleaguesLoaded = false;
function toggleGuestForm() {
const form = document.getElementById('guest-form');
const btn = document.getElementById('add-guest-btn');
if (form.style.display === 'none') {
document.getElementById('guest-edit-id').value = '';
document.getElementById('guest-first-name').value = '';
document.getElementById('guest-last-name').value = '';
document.getElementById('guest-org').value = '';
document.getElementById('guest-submit-btn').textContent = 'Dodaj';
document.getElementById('guest-form-error').style.display = 'none';
// Reset guest type to member if paid event with company
const typeSelector = document.getElementById('guest-type-selector');
if (typeSelector) {
selectGuestType('member');
loadColleagues();
}
const cs = document.getElementById('colleague-select');
if (cs) cs.value = '';
form.style.display = 'block';
btn.style.display = 'none';
document.getElementById('guest-first-name').focus();
} else {
cancelGuestForm();
}
}
function selectGuestType(type) {
document.getElementById('guest-type').value = type;
document.querySelectorAll('.guest-type-btn').forEach(b => {
b.classList.toggle('active', b.dataset.type === type);
});
const picker = document.getElementById('colleague-picker');
if (picker) {
picker.style.display = type === 'member' ? 'block' : 'none';
}
}
async function loadColleagues() {
if (colleaguesLoaded) return;
const select = document.getElementById('colleague-select');
if (!select) return;
try {
const resp = await fetch('/kalendarz/{{ event.id }}/company-colleagues');
const data = await resp.json();
data.forEach(c => {
const opt = document.createElement('option');
opt.value = JSON.stringify(c);
opt.textContent = c.name + (c.already_registered ? ' (już zapisany/a)' : '');
if (c.already_registered) opt.disabled = true;
select.appendChild(opt);
});
colleaguesLoaded = true;
} catch(e) {}
}
function onColleagueSelect() {
const select = document.getElementById('colleague-select');
if (!select.value) return;
try {
const c = JSON.parse(select.value);
document.getElementById('guest-first-name').value = c.first_name || '';
document.getElementById('guest-last-name').value = c.last_name || '';
document.getElementById('guest-org').value = '{{ (active_company.name if active_company else (current_user.company.name if current_user.company else ""))|e }}';
} catch(e) {}
}
function cancelGuestForm() {
document.getElementById('guest-form').style.display = 'none';
const btn = document.getElementById('add-guest-btn');
if (btn) btn.style.display = '';
}
function editGuest(guestId, firstName, lastName, org) {
document.getElementById('guest-edit-id').value = guestId;
document.getElementById('guest-first-name').value = firstName;
document.getElementById('guest-last-name').value = lastName;
document.getElementById('guest-org').value = org;
document.getElementById('guest-submit-btn').textContent = 'Zapisz';
document.getElementById('guest-form-error').style.display = 'none';
document.getElementById('guest-form').style.display = 'block';
const btn = document.getElementById('add-guest-btn');
if (btn) btn.style.display = 'none';
document.getElementById('guest-first-name').focus();
}
async function submitGuest() {
const editId = document.getElementById('guest-edit-id').value;
const firstName = document.getElementById('guest-first-name').value.trim();
const lastName = document.getElementById('guest-last-name').value.trim();
const org = document.getElementById('guest-org').value.trim();
const errEl = document.getElementById('guest-form-error');
if (!firstName && !lastName && !org) {
errEl.textContent = 'Podaj przynajmniej imię, nazwisko lub firmę';
errEl.style.display = 'block';
return;
}
errEl.style.display = 'none';
const eventId = {{ event.id }};
const url = editId
? `/kalendarz/${eventId}/guests/${editId}`
: `/kalendarz/${eventId}/guests`;
const method = editId ? 'PATCH' : 'POST';
try {
const resp = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({ first_name: firstName, last_name: lastName, organization: org, guest_type: document.getElementById('guest-type').value || 'external' })
});
const data = await resp.json();
if (data.success) {
showToast(editId ? 'Dane gościa zaktualizowane' : 'Dodano osobę towarzyszącą', 'success');
setTimeout(() => location.reload(), 800);
} else {
errEl.textContent = data.error || 'Wystąpił błąd';
errEl.style.display = 'block';
}
} catch (e) {
errEl.textContent = 'Błąd połączenia';
errEl.style.display = 'block';
}
}
async function deleteGuest(guestId) {
nordaConfirm('Czy na pewno chcesz usunąć tę osobę towarzyszącą?', function() {
doDeleteGuest(guestId);
});
}
async function doDeleteGuest(guestId) {
try {
const resp = await fetch(`/kalendarz/{{ event.id }}/guests/${guestId}`, {
method: 'DELETE',
headers: { 'X-CSRFToken': csrfToken }
});
const data = await resp.json();
if (data.success) {
showToast('Usunięto osobę towarzyszącą', 'info');
setTimeout(() => location.reload(), 800);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (e) {
showToast('Błąd połączenia', 'error');
}
}
/* --- Add to Calendar functions --- */
function stripHtml(html) {
const tmp = document.createElement('div');
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || '';
}
function foldIcsLine(line) {
// ICS spec: lines max 75 octets, continuation lines start with space
const parts = [];
while (line.length > 75) {
parts.push(line.substring(0, 75));
line = ' ' + line.substring(75);
}
parts.push(line);
return parts.join('\r\n');
}
const _evt = {
title: {{ event.title|tojson }},
date: '{{ event.event_date.strftime("%Y%m%d") }}',
dateEnd: '{{ event.event_date_end.strftime("%Y%m%d") if event.event_date_end else "" }}',
start: '{{ event.time_start.strftime("%H%M") if event.time_start else "0000" }}',
end: '{{ event.time_end.strftime("%H%M") if event.time_end else "" }}',
location: {{ (event.location or '')|tojson }},
speaker: {{ (event.speaker_name or '')|tojson }},
description: {{ (event.description or '')|tojson }},
organizerName: {{ (event.external_source if event.is_external and event.external_source else event.organizer_name or 'Norda Biznes')|tojson }},
organizerEmail: {{ (event.organizer_email or 'biuro@norda-biznes.info')|tojson }},
url: window.location.href,
};
function addToGoogleCalendar() {
const start = _evt.date + 'T' + _evt.start + '00';
const endDate = _evt.dateEnd || _evt.date;
const end = _evt.end ? (endDate + 'T' + _evt.end + '00') : '';
const details = [
_evt.speaker ? 'Prowadzący: ' + _evt.speaker : '',
_evt.description,
'',
'Szczegóły: ' + _evt.url,
].filter(Boolean).join('\n');
const params = new URLSearchParams({
action: 'TEMPLATE',
text: _evt.title,
dates: start + '/' + (end || start),
location: _evt.location,
details: details,
ctz: 'Europe/Warsaw',
});
window.open('https://calendar.google.com/calendar/render?' + params, '_blank');
}
function downloadICS() {
const start = _evt.date + 'T' + _evt.start + '00';
const endDate = _evt.dateEnd || _evt.date;
const end = _evt.end ? (endDate + 'T' + _evt.end + '00') : (endDate + 'T' + _evt.start + '00');
const uid = 'nordabiz-event-{{ event.id }}@nordabiznes.pl';
const now = new Date().toISOString().replace(/[-:]/g,'').split('.')[0] + 'Z';
const plainDesc = stripHtml(_evt.description);
const descParts = [
_evt.speaker ? 'Prowadzący: ' + _evt.speaker : '',
plainDesc,
'',
'Szczegóły: ' + _evt.url,
].filter(Boolean);
const desc = descParts.join('\\n');
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//NordaBiznes//PL',
'BEGIN:VEVENT',
'UID:' + uid,
'DTSTAMP:' + now,
'DTSTART;TZID=Europe/Warsaw:' + start,
'DTEND;TZID=Europe/Warsaw:' + end,
'SUMMARY:' + _evt.title,
'LOCATION:' + _evt.location,
'DESCRIPTION:' + desc,
'URL:' + _evt.url,
'ORGANIZER;CN=' + _evt.organizerName + ':mailto:' + _evt.organizerEmail,
'BEGIN:VALARM',
'TRIGGER:-P1D',
'ACTION:DISPLAY',
'DESCRIPTION:Jutro: ' + _evt.title,
'END:VALARM',
'END:VEVENT',
'END:VCALENDAR',
];
const ics = lines.map(foldIcsLine).join('\r\n');
const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = _evt.title.substring(0, 50).replace(/[^a-zA-Z0-9ąćęłńóśźżĄĆĘŁŃÓŚŹŻ ]/g, '') + '.ics';
a.click();
URL.revokeObjectURL(a.href);
}
{% endblock %}