feat(calendar): add external events support for KIG/ARP integration
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

External events from partner organizations (ARP, KIG, etc.) can now
be added to the calendar with distinct visual treatment:
- Grey badge "ZEWNĘTRZNE" and muted date box in list view
- Grey color in grid view with border accent
- "Jestem zainteresowany" instead of "Zapisz się" (no commitment)
- Prominent "Przejdź do rejestracji" button linking to external organizer
- "Zainteresowani" section instead of "Uczestnicy"
- Toggle filter "Pokaż zewnętrzne" with localStorage persistence
- Admin form checkbox to mark events as external

New fields: is_external, external_url, external_source on NordaEvent.
Migration: 086_external_events.sql

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-19 10:23:10 +01:00
parent a703991e62
commit 7a3955d0fa
7 changed files with 194 additions and 25 deletions

View File

@ -943,6 +943,8 @@ def admin_calendar_new():
if event_type == 'rada' and access_level != 'rada_only': if event_type == 'rada' and access_level != 'rada_only':
access_level = 'rada_only' access_level = 'rada_only'
is_external = request.form.get('is_external') == 'on'
event = NordaEvent( event = NordaEvent(
title=request.form.get('title', '').strip(), title=request.form.get('title', '').strip(),
description=sanitize_html(request.form.get('description', '').strip()), description=sanitize_html(request.form.get('description', '').strip()),
@ -951,10 +953,13 @@ def admin_calendar_new():
time_end=request.form.get('time_end') or None, time_end=request.form.get('time_end') or None,
location=request.form.get('location', '').strip() or None, location=request.form.get('location', '').strip() or None,
event_type=event_type, event_type=event_type,
max_attendees=request.form.get('max_attendees', type=int) or None, max_attendees=None if is_external else (request.form.get('max_attendees', type=int) or None),
access_level=access_level, access_level=access_level,
created_by=current_user.id, created_by=current_user.id,
source='manual' source='manual',
is_external=is_external,
external_url=request.form.get('external_url', '').strip() or None if is_external else None,
external_source=request.form.get('external_source', '').strip() or None if is_external else None,
) )
# Handle file attachment # Handle file attachment

View File

@ -328,19 +328,22 @@ def rsvp(event_id):
EventAttendee.user_id == current_user.id EventAttendee.user_id == current_user.id
).first() ).first()
is_ext = getattr(event, 'is_external', False) or False
msg_added = 'Oznaczono jako zainteresowany' if is_ext else 'Zapisano na wydarzenie'
msg_removed = 'Usunięto zainteresowanie' if is_ext else 'Wypisano z wydarzenia'
if existing: if existing:
# Wypisz
db.delete(existing) db.delete(existing)
db.commit() db.commit()
return jsonify({ return jsonify({
'success': True, 'success': True,
'action': 'removed', 'action': 'removed',
'message': 'Wypisano z wydarzenia', 'message': msg_removed,
'attendee_count': event.attendee_count 'attendee_count': event.attendee_count
}) })
else: else:
# Zapisz # Skip max_attendees check for external events
if event.max_attendees and event.attendee_count >= event.max_attendees: if not is_ext and event.max_attendees and event.attendee_count >= event.max_attendees:
return jsonify({'success': False, 'error': 'Brak wolnych miejsc'}), 400 return jsonify({'success': False, 'error': 'Brak wolnych miejsc'}), 400
attendee = EventAttendee( attendee = EventAttendee(
@ -353,7 +356,7 @@ def rsvp(event_id):
return jsonify({ return jsonify({
'success': True, 'success': True,
'action': 'added', 'action': 'added',
'message': 'Zapisano na wydarzenie', 'message': msg_added,
'attendee_count': event.attendee_count 'attendee_count': event.attendee_count
}) })
finally: finally:

View File

@ -2174,6 +2174,11 @@ class NordaEvent(Base):
organizer_name = Column(String(255), default='Norda Biznes') organizer_name = Column(String(255), default='Norda Biznes')
organizer_email = Column(String(255), default='biuro@norda-biznes.info') organizer_email = Column(String(255), default='biuro@norda-biznes.info')
# External event (ARP, KIG, etc.)
is_external = Column(Boolean, default=False)
external_url = Column(String(1000)) # Registration link at external organizer
external_source = Column(String(255)) # Source name (e.g. "Agencja Rozwoju Pomorza")
# Attachment # Attachment
attachment_filename = Column(String(255)) # Original filename attachment_filename = Column(String(255)) # Original filename
attachment_path = Column(String(1000)) # Server path (static/uploads/events/...) attachment_path = Column(String(1000)) # Server path (static/uploads/events/...)

View File

@ -0,0 +1,9 @@
-- Migration 086: Add external event fields to norda_events
-- For KIG integration: external events from ARP, KIG, partner organizations
ALTER TABLE norda_events ADD COLUMN IF NOT EXISTS is_external BOOLEAN DEFAULT FALSE;
ALTER TABLE norda_events ADD COLUMN IF NOT EXISTS external_url VARCHAR(1000);
ALTER TABLE norda_events ADD COLUMN IF NOT EXISTS external_source VARCHAR(255);
-- Grant permissions
GRANT ALL ON TABLE norda_events TO nordabiz_app;

View File

@ -106,6 +106,27 @@
<form method="POST" action="{{ url_for('admin.admin_calendar_new') }}" enctype="multipart/form-data"> <form method="POST" action="{{ url_for('admin.admin_calendar_new') }}" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group" style="background: var(--bg-secondary); padding: var(--spacing-md); border-radius: var(--radius); border: 1px solid var(--border);">
<label style="display: flex; align-items: center; gap: var(--spacing-sm); cursor: pointer; margin-bottom: 0;">
<input type="checkbox" id="is_external" name="is_external" style="width: auto;">
<span>Wydarzenie zewnętrzne</span>
</label>
<div class="form-hint">Zaznacz dla wydarzeń organizowanych przez podmioty zewnętrzne (ARP, KIG, urzędy). Użytkownicy zobaczą przycisk "Jestem zainteresowany" zamiast "Zapisz się".</div>
</div>
<div id="external-fields" style="display: none;">
<div class="form-group">
<label for="external_source">Organizator / Źródło *</label>
<input type="text" id="external_source" name="external_source" maxlength="255" placeholder="np. Agencja Rozwoju Pomorza">
</div>
<div class="form-group">
<label for="external_url">Link do rejestracji *</label>
<input type="url" id="external_url" name="external_url" placeholder="https://brokereksportowy.pl/...">
<div class="form-hint">Link do strony zewnętrznej, gdzie użytkownicy mogą się zarejestrować</div>
</div>
</div>
<div class="form-group"> <div class="form-group">
<label for="title">Tytul wydarzenia *</label> <label for="title">Tytul wydarzenia *</label>
<input type="text" id="title" name="title" required maxlength="255" placeholder="np. Spotkanie czlonkow Norda Biznes"> <input type="text" id="title" name="title" required maxlength="255" placeholder="np. Spotkanie czlonkow Norda Biznes">
@ -197,3 +218,21 @@
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %}
(function() {
const cb = document.getElementById('is_external');
const extFields = document.getElementById('external-fields');
const maxAtt = document.getElementById('max_attendees');
const maxAttGroup = maxAtt ? maxAtt.closest('.form-group') : null;
function toggle() {
const isExt = cb.checked;
extFields.style.display = isExt ? 'block' : 'none';
if (maxAttGroup) maxAttGroup.style.display = isExt ? 'none' : 'block';
}
cb.addEventListener('change', toggle);
toggle();
})();
{% endblock %}

View File

@ -383,13 +383,20 @@
</a> </a>
<div class="event-header"> <div class="event-header">
{% if event.is_featured %} {% 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"> <div class="event-logo">
<img src="{{ url_for('static', filename='img/norda-logo.svg') }}" alt="Norda Biznes"> <img src="{{ url_for('static', filename='img/norda-logo.svg') }}" alt="Norda Biznes">
</div> </div>
{% endif %} {% endif %}
<h1>{{ event.title }} <h1>{{ event.title }}
{% if event.access_level == 'admin_only' %} {% 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> <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' %} {% 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> <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>
@ -492,6 +499,18 @@
</div> </div>
{% endif %} {% 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 %} {% if event.image_url %}
<div style="margin-bottom: var(--spacing-xl); border-radius: var(--radius-lg); overflow: hidden;"> <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;"> <img src="{{ event.image_url }}" alt="{{ event.title }}" style="width: 100%; height: auto; display: block;">
@ -526,6 +545,15 @@
{% if not event.is_past %} {% if not event.is_past %}
{% if event.can_user_attend(current_user) %} {% if event.can_user_attend(current_user) %}
<div class="rsvp-section"> <div class="rsvp-section">
{% if event.is_external %}
<div>
<strong>Interesuje Cię to wydarzenie?</strong>
<p class="text-muted" style="margin: 0;">{{ event.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 %}Jestem zainteresowany{% endif %}
</button>
{% else %}
<div> <div>
<strong>Chcesz wziąć udział?</strong> <strong>Chcesz wziąć udział?</strong>
<p class="text-muted" style="margin: 0;">{{ event.attendee_count }} osób już się zapisało{% if event.max_attendees %} (limit: {{ event.max_attendees }}){% endif %}</p> <p class="text-muted" style="margin: 0;">{{ event.attendee_count }} osób już się zapisało{% if event.max_attendees %} (limit: {{ event.max_attendees }}){% endif %}</p>
@ -533,6 +561,7 @@
<button id="rsvp-btn" class="btn {% if user_attending %}btn-secondary attending{% else %}btn-primary{% endif %}" onclick="toggleRSVP()"> <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 %} {% if user_attending %}Wypisz się{% else %}Wezmę udział{% endif %}
</button> </button>
{% endif %}
</div> </div>
{% elif event.access_level == 'rada_only' %} {% elif event.access_level == 'rada_only' %}
<div class="rsvp-section" style="background: #fef3c7; border: 1px solid #fde68a;"> <div class="rsvp-section" style="background: #fef3c7; border: 1px solid #fde68a;">
@ -554,7 +583,7 @@
{% if event.attendees and event.can_user_see_attendees(current_user) %} {% if event.attendees and event.can_user_see_attendees(current_user) %}
<div class="attendees-section"> <div class="attendees-section">
<h2>Uczestnicy ({{ event.attendee_count }})</h2> <h2>{{ 'Zainteresowani' if event.is_external else 'Uczestnicy' }} ({{ event.attendee_count }})</h2>
<div class="attendees-list"> <div class="attendees-list">
{% for attendee in event.attendees|sort(attribute='user.name') %} {% for attendee in event.attendees|sort(attribute='user.name') %}
<div class="attendee-badge"> <div class="attendee-badge">
@ -624,17 +653,18 @@ async function toggleRSVP() {
}); });
const data = await response.json(); const data = await response.json();
const isExt = {{ 'true' if event.is_external else 'false' }};
if (data.success) { if (data.success) {
if (data.action === 'added') { if (data.action === 'added') {
btn.textContent = 'Wypisz się'; btn.textContent = isExt ? 'Nie interesuje mnie' : 'Wypisz się';
btn.classList.remove('btn-primary'); btn.classList.remove('btn-primary');
btn.classList.add('btn-secondary', 'attending'); btn.classList.add('btn-secondary', 'attending');
showToast('Zapisano na wydarzenie!', 'success'); showToast(isExt ? 'Oznaczono jako zainteresowany!' : 'Zapisano na wydarzenie!', 'success');
} else { } else {
btn.textContent = 'Wezmę udział'; btn.textContent = isExt ? 'Jestem zainteresowany' : 'Wezmę udział';
btn.classList.remove('btn-secondary', 'attending'); btn.classList.remove('btn-secondary', 'attending');
btn.classList.add('btn-primary'); btn.classList.add('btn-primary');
showToast('Wypisano z wydarzenia', 'info'); showToast(isExt ? 'Usunięto zainteresowanie' : 'Wypisano z wydarzenia', 'info');
} }
// Refresh page to update attendees list // Refresh page to update attendees list
setTimeout(() => location.reload(), 1000); setTimeout(() => location.reload(), 1000);

View File

@ -191,6 +191,12 @@
color: #7c3aed; color: #7c3aed;
} }
.calendar-event.external {
background: #f1f5f9;
color: #475569;
border-left: 2px solid #94a3b8;
}
/* Widok listy - istniejące style */ /* Widok listy - istniejące style */
.events-section { .events-section {
margin-bottom: var(--spacing-2xl); margin-bottom: var(--spacing-2xl);
@ -320,6 +326,34 @@
.badge-type.webinar { background: #dcfce7; color: #166534; } .badge-type.webinar { background: #dcfce7; color: #166534; }
.badge-type.networking { background: #fef3c7; color: #92400e; } .badge-type.networking { background: #fef3c7; color: #92400e; }
.badge-type.other { background: #f3e8ff; color: #7c3aed; } .badge-type.other { background: #f3e8ff; color: #7c3aed; }
.badge-type.external { background: #f1f5f9; color: #475569; }
.event-card.external-event {
border-left: 3px solid #94a3b8;
opacity: 0.92;
}
.event-card.external-event .event-date-box {
background: #64748b;
}
.external-source {
font-size: var(--font-size-xs);
color: var(--text-secondary);
font-style: italic;
}
.filter-toggle {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: var(--font-size-sm);
color: var(--text-secondary);
cursor: pointer;
user-select: none;
}
.filter-toggle input { width: auto; }
.rsvp-list-btn { min-width: 110px; transition: var(--transition); } .rsvp-list-btn { min-width: 110px; transition: var(--transition); }
.rsvp-list-btn.rsvp-attending { .rsvp-list-btn.rsvp-attending {
@ -398,6 +432,11 @@
</div> </div>
{% endif %} {% endif %}
<label class="filter-toggle">
<input type="checkbox" id="show-external" checked>
Pokaż zewnętrzne
</label>
{% if current_user.can_access_admin_panel() %} {% if current_user.can_access_admin_panel() %}
<a href="{{ url_for('admin.admin_calendar') }}" class="btn btn-secondary btn-sm">Zarządzaj</a> <a href="{{ url_for('admin.admin_calendar') }}" class="btn btn-secondary btn-sm">Zarządzaj</a>
{% endif %} {% endif %}
@ -428,8 +467,9 @@
{% if day in events_by_day %} {% if day in events_by_day %}
{% for event in events_by_day[day] %} {% for event in events_by_day[day] %}
<a href="{{ url_for('calendar.calendar_event', event_id=event.id) }}" <a href="{{ url_for('calendar.calendar_event', event_id=event.id) }}"
class="calendar-event {{ event.event_type }}" class="calendar-event {{ 'external' if event.is_external else event.event_type }}"
title="{{ event.title }}{% if event.time_start %} - {{ event.time_start.strftime('%H:%M') }}{% endif %}"> data-external="{{ 'true' if event.is_external else 'false' }}"
title="{{ event.title }}{% if event.time_start %} - {{ event.time_start.strftime('%H:%M') }}{% endif %}{% if event.is_external %} ({{ event.external_source }}){% endif %}">
{% if event.time_start %}{{ event.time_start.strftime('%H:%M') }} {% endif %}{{ event.title[:18] }}{% if event.title|length > 18 %}...{% endif %} {% if event.time_start %}{{ event.time_start.strftime('%H:%M') }} {% endif %}{{ event.title[:18] }}{% if event.title|length > 18 %}...{% endif %}
</a> </a>
{% endfor %} {% endfor %}
@ -447,6 +487,7 @@
<span><span class="badge-type networking">Networking</span></span> <span><span class="badge-type networking">Networking</span></span>
<span><span class="badge-type webinar">Webinar</span></span> <span><span class="badge-type webinar">Webinar</span></span>
<span><span class="badge-type other">Inne</span></span> <span><span class="badge-type other">Inne</span></span>
<span><span class="badge-type external">Zewnętrzne</span></span>
</div> </div>
{% else %} {% else %}
@ -458,17 +499,20 @@
{% if upcoming_events %} {% if upcoming_events %}
{% for event in upcoming_events %} {% for event in upcoming_events %}
<div class="event-card {% if event.is_featured %}featured{% endif %}"> <div class="event-card {% if event.is_featured %}featured{% endif %}{% if event.is_external %} external-event{% endif %}" data-external="{{ 'true' if event.is_external else 'false' }}">
<div class="event-date-box"> <div class="event-date-box">
<div class="day">{{ event.event_date.day }}</div> <div class="day">{{ event.event_date.day }}</div>
<div class="month">{{ pl_months.get(event.event_date.strftime('%b'), event.event_date.strftime('%b')) }}</div> <div class="month">{{ pl_months.get(event.event_date.strftime('%b'), event.event_date.strftime('%b')) }}</div>
</div> </div>
<div class="event-info"> <div class="event-info">
<div class="event-title"> <div class="event-title">
<a href="{{ url_for('calendar.calendar_event', event_id=event.id) }}">{{ event.title }}</a>{% if event.access_level == 'admin_only' %} <span style="display:inline-block;background:#ef4444;color:#fff;font-size:10px;padding:1px 5px;border-radius:3px;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:10px;padding:1px 5px;border-radius:3px;font-weight:600;vertical-align:middle;">IZBA</span>{% endif %} <a href="{{ url_for('calendar.calendar_event', event_id=event.id) }}">{{ event.title }}</a>{% if event.is_external %} <span style="display:inline-block;background:#94a3b8;color:#fff;font-size:10px;padding:1px 5px;border-radius:3px;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:10px;padding:1px 5px;border-radius:3px;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:10px;padding:1px 5px;border-radius:3px;font-weight:600;vertical-align:middle;">IZBA</span>{% endif %}
</div> </div>
{% if event.is_external and event.external_source %}
<div class="external-source">Źródło: {{ event.external_source }}</div>
{% endif %}
<div class="event-meta"> <div class="event-meta">
<span class="badge-type {{ event.event_type }}">{{ pl_types.get(event.event_type, event.event_type) }}</span> <span class="badge-type {{ 'external' if event.is_external else event.event_type }}">{{ 'Zewnętrzne' if event.is_external else pl_types.get(event.event_type, event.event_type) }}</span>
{% if event.time_start %} {% if event.time_start %}
<span>{{ event.time_start.strftime('%H:%M') }}{% if event.time_end %} - {{ event.time_end.strftime('%H:%M') }}{% endif %}</span> <span>{{ event.time_start.strftime('%H:%M') }}{% if event.time_end %} - {{ event.time_end.strftime('%H:%M') }}{% endif %}</span>
{% endif %} {% endif %}
@ -492,13 +536,23 @@
{% endif %} {% endif %}
</div> </div>
<div class="event-actions"> <div class="event-actions">
{% if event.is_external %}
<span class="attendee-count">{{ event.attendee_count }} zainteresowanych</span>
{% if event.can_user_attend(current_user) %}
{% set is_attending = event.attendees|selectattr('user_id','equalto',current_user.id)|list %}
<button class="btn btn-sm rsvp-list-btn {{ 'rsvp-attending' if is_attending else 'rsvp-not-attending' }}" data-event-id="{{ event.id }}" data-attending="{{ 'true' if is_attending else 'false' }}" data-external="true" onclick="toggleListRSVP(this)" {{ 'onmouseenter=this.textContent="Nie interesuje" onmouseleave=this.textContent="Zainteresowany "'|safe if is_attending }}>
{{ 'Zainteresowany ✓' if is_attending else 'Jestem zainteresowany' }}
</button>
{% endif %}
{% else %}
<span class="attendee-count">{{ event.attendee_count }} uczestników</span> <span class="attendee-count">{{ event.attendee_count }} uczestników</span>
{% if event.can_user_attend(current_user) %} {% if event.can_user_attend(current_user) %}
{% set is_attending = event.attendees|selectattr('user_id','equalto',current_user.id)|list %} {% set is_attending = event.attendees|selectattr('user_id','equalto',current_user.id)|list %}
<button class="btn btn-sm rsvp-list-btn {{ 'rsvp-attending' if is_attending else 'rsvp-not-attending' }}" data-event-id="{{ event.id }}" data-attending="{{ 'true' if is_attending else 'false' }}" onclick="toggleListRSVP(this)" {{ 'onmouseenter=this.textContent="Wypisz się" onmouseleave=this.textContent="Zapisano "'|safe if is_attending }}> <button class="btn btn-sm rsvp-list-btn {{ 'rsvp-attending' if is_attending else 'rsvp-not-attending' }}" data-event-id="{{ event.id }}" data-attending="{{ 'true' if is_attending else 'false' }}" data-external="false" onclick="toggleListRSVP(this)" {{ 'onmouseenter=this.textContent="Wypisz się" onmouseleave=this.textContent="Zapisano "'|safe if is_attending }}>
{{ 'Zapisano ✓' if is_attending else 'Zapisz się' }} {{ 'Zapisano ✓' if is_attending else 'Zapisz się' }}
</button> </button>
{% endif %} {% endif %}
{% endif %}
<a href="{{ url_for('calendar.calendar_event', event_id=event.id) }}" class="btn btn-outline btn-sm">Szczegóły</a> <a href="{{ url_for('calendar.calendar_event', event_id=event.id) }}" class="btn btn-outline btn-sm">Szczegóły</a>
</div> </div>
</div> </div>
@ -547,6 +601,7 @@
var csrfToken = '{{ csrf_token() }}'; var csrfToken = '{{ csrf_token() }}';
async function toggleListRSVP(btn) { async function toggleListRSVP(btn) {
var eventId = btn.dataset.eventId; var eventId = btn.dataset.eventId;
var isExt = btn.dataset.external === 'true';
btn.disabled = true; btn.disabled = true;
try { try {
var resp = await fetch('/kalendarz/' + eventId + '/rsvp', { var resp = await fetch('/kalendarz/' + eventId + '/rsvp', {
@ -555,15 +610,20 @@ async function toggleListRSVP(btn) {
}); });
var data = await resp.json(); var data = await resp.json();
if (data.success) { if (data.success) {
var addedText = isExt ? 'Zainteresowany ✓' : 'Zapisano ✓';
var removedText = isExt ? 'Jestem zainteresowany' : 'Zapisz się';
var hoverText = isExt ? 'Nie interesuje' : 'Wypisz się';
var countLabel = isExt ? ' zainteresowanych' : ' uczestników';
if (data.action === 'added') { if (data.action === 'added') {
btn.textContent = 'Zapisano ✓'; btn.textContent = addedText;
btn.classList.remove('rsvp-not-attending'); btn.classList.remove('rsvp-not-attending');
btn.classList.add('rsvp-attending'); btn.classList.add('rsvp-attending');
btn.dataset.attending = 'true'; btn.dataset.attending = 'true';
btn.onmouseenter = function() { this.textContent = 'Wypisz się'; }; btn.onmouseenter = function() { this.textContent = hoverText; };
btn.onmouseleave = function() { this.textContent = 'Zapisano ✓'; }; btn.onmouseleave = function() { this.textContent = addedText; };
} else { } else {
btn.textContent = 'Zapisz się'; btn.textContent = removedText;
btn.classList.remove('rsvp-attending'); btn.classList.remove('rsvp-attending');
btn.classList.add('rsvp-not-attending'); btn.classList.add('rsvp-not-attending');
btn.dataset.attending = 'false'; btn.dataset.attending = 'false';
@ -571,9 +631,27 @@ async function toggleListRSVP(btn) {
btn.onmouseleave = null; btn.onmouseleave = null;
} }
var countEl = btn.parentElement.querySelector('.attendee-count'); var countEl = btn.parentElement.querySelector('.attendee-count');
if (countEl) countEl.textContent = data.attendee_count + ' uczestników'; if (countEl) countEl.textContent = data.attendee_count + countLabel;
} }
} catch(e) {} } catch(e) {}
btn.disabled = false; btn.disabled = false;
} }
/* Filter toggle for external events */
(function() {
var cb = document.getElementById('show-external');
var stored = localStorage.getItem('nordabiz_show_external');
if (stored === 'false') cb.checked = false;
function applyFilter() {
var show = cb.checked;
localStorage.setItem('nordabiz_show_external', show);
document.querySelectorAll('[data-external="true"]').forEach(function(el) {
el.style.display = show ? '' : 'none';
});
}
cb.addEventListener('change', applyFilter);
applyFilter();
})();
{% endblock %} {% endblock %}