nordabiz/templates/calendar/event.html
Maciej Pienczyn 08a499ac40
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(calendar): add public. prefix to all url_for endpoints in event template
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:56:10 +01:00

692 lines
23 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_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.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.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 not event.is_past %}
{% if event.can_user_attend(current_user) %}
<div class="rsvp-section">
<div>
<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>
</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>
</div>
{% 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 and event.can_user_see_attendees(current_user) %}
<div class="attendees-section">
<h2>Uczestnicy ({{ event.attendee_count }})</h2>
<div class="attendees-list">
{% for attendee in event.attendees|sort(attribute='user.name') %}
<div class="attendee-badge">
{# Person badge - always clickable: person profile if person_id, user profile otherwise #}
{% 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>
{# Company badge - always link to company profile #}
{% 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>
{% 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();
if (data.success) {
if (data.action === 'added') {
btn.textContent = 'Wypisz się';
btn.classList.remove('btn-primary');
btn.classList.add('btn-secondary', 'attending');
showToast('Zapisano na wydarzenie!', 'success');
} else {
btn.textContent = 'Wezmę udział';
btn.classList.remove('btn-secondary', 'attending');
btn.classList.add('btn-primary');
showToast('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;
}
/* --- Add to Calendar functions --- */
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 }},
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 params = new URLSearchParams({
action: 'TEMPLATE',
text: _evt.title,
dates: start + '/' + (end || start),
location: _evt.location,
details: 'Szczegóły: ' + _evt.url,
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 speaker = {{ (event.speaker_name or '')|tojson }};
const desc = (speaker ? 'Prowadzący: ' + speaker + '\\n' : '') + 'Szczegóły: ' + _evt.url;
const ics = [
'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=Norda Biznes:mailto:biuro@norda-biznes.info',
'END:VEVENT',
'END:VCALENDAR',
].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 %}