nordabiz/templates/calendar/event.html
Maciej Pienczyn e0fbd8ce9a
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
fix(ux): extend nordaConfirm to support callback mode for AJAX actions
nordaConfirm now accepts both (form, message, options) for form submission
and (message, callback, options) for AJAX-based confirmations like guest deletion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:26:58 +02:00

968 lines
37 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>
.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>
<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">Data</div>
<div class="info-value">{{ event.event_date.strftime('%d.%m.%Y') }}</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>
</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 %}
</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ą
</button>
{% endif %}
<div id="guest-form" style="display: none; margin-top: 12px; padding: 16px; background: var(--surface); border-radius: var(--radius); border: 1px solid var(--border);">
<input type="hidden" id="guest-edit-id" value="">
<div style="display: flex; flex-direction: column; gap: 10px;">
<div>
<label for="guest-first-name" style="font-size: 0.85em; color: var(--text-secondary);">Imię</label>
<input type="text" id="guest-first-name" maxlength="100" class="form-control" style="margin-top: 2px;">
</div>
<div>
<label for="guest-last-name" style="font-size: 0.85em; color: var(--text-secondary);">Nazwisko</label>
<input type="text" id="guest-last-name" maxlength="100" class="form-control" style="margin-top: 2px;">
</div>
<div>
<label for="guest-org" style="font-size: 0.85em; color: var(--text-secondary);">Firma / organizacja</label>
<input type="text" id="guest-org" maxlength="255" class="form-control" style="margin-top: 2px;">
</div>
<div style="display: flex; gap: 8px;">
<button id="guest-submit-btn" onclick="submitGuest()" class="btn btn-primary" style="font-size: 0.9em;">Dodaj</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: 0;"></p>
</div>
</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.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 --- */
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';
form.style.display = 'block';
btn.style.display = 'none';
document.getElementById('guest-first-name').focus();
} else {
cancelGuestForm();
}
}
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 })
});
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") }}',
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 end = _evt.end ? (_evt.date + '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 end = _evt.end ? (_evt.date + 'T' + _evt.end + '00') : (_evt.date + '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 %}