nordabiz/templates/index.html
Maciej Pienczyn 72ba8e05f1
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): Show 3 upcoming events on homepage + WhatsApp data import
- Display up to 3 next events with RSVP status instead of just one
- Add import script for WhatsApp Norda group data (Feb 2026):
  events, company updates, Alter Energy, Croatia announcement

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 16:29:07 +01:00

1591 lines
54 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 %}Katalog firm - Norda Biznes Partner{% endblock %}
{% block extra_css %}
/* Event Banner - Ankieta "Kto weźmie udział?" (niebieski primary) */
.event-banner {
background: linear-gradient(135deg, #1e3050 0%, #2E4872 100%);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
color: white;
display: flex;
align-items: center;
gap: var(--spacing-lg);
box-shadow: var(--shadow-md);
position: relative;
overflow: hidden;
text-decoration: none;
cursor: pointer;
transition: var(--transition);
}
.event-banner:hover {
transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(46, 72, 114, 0.25);
filter: brightness(1.05);
}
.event-banner::before {
content: '';
position: absolute;
top: -50%;
right: -10%;
width: 200px;
height: 200px;
background: rgba(255,255,255,0.1);
border-radius: 50%;
}
.event-banner-icon {
font-size: 2.5rem;
flex-shrink: 0;
}
.event-banner-content {
flex: 1;
min-width: 0;
}
.event-banner-label {
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 1px;
opacity: 0.9;
margin-bottom: var(--spacing-xs);
}
.event-banner-title {
font-size: var(--font-size-lg);
font-weight: 700;
margin-bottom: var(--spacing-xs);
}
.event-banner-meta {
font-size: var(--font-size-sm);
opacity: 0.9;
display: flex;
flex-wrap: wrap;
gap: var(--spacing-md);
}
.event-banner-attendees {
display: flex;
align-items: center;
gap: var(--spacing-xs);
background: rgba(255,255,255,0.2);
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius);
font-weight: 600;
margin-top: var(--spacing-sm);
}
.event-banner-action {
flex-shrink: 0;
}
.event-banner .btn-light {
background: white;
color: #2E4872;
border: none;
padding: var(--spacing-sm) var(--spacing-lg);
font-weight: 600;
font-size: var(--font-size-base);
border-radius: var(--radius-btn);
text-decoration: none;
display: inline-block;
cursor: pointer;
transition: var(--transition);
}
.event-banner .btn-light:disabled {
opacity: 0.7;
cursor: wait;
}
.event-banner .btn-light:hover {
background: #EDF0F5;
transform: translateY(-1px);
}
.event-banner .btn-registered {
background: #166534;
color: white;
}
.event-banner .btn-registered:hover {
background: #15803d;
}
@media (max-width: 640px) {
.event-banner {
flex-direction: column;
text-align: center;
}
.event-banner-meta {
justify-content: center;
}
.event-banner-attendees {
justify-content: center;
}
}
/* NordaGPT Chat Banner (niebieski primary) */
.chat-banner {
background: linear-gradient(135deg, #1e3050 0%, #2E4872 100%);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
color: white;
display: flex;
align-items: center;
gap: var(--spacing-lg);
box-shadow: var(--shadow-md);
position: relative;
overflow: hidden;
transition: var(--transition);
cursor: pointer;
}
.chat-banner:hover {
transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(46, 72, 114, 0.25);
filter: brightness(1.05);
}
/* Chat minimized state - banner pulsing to indicate active session */
.chat-banner.chat-active {
animation: chatPulse 2s ease-in-out infinite;
border: 2px solid rgba(255,255,255,0.5);
}
.chat-banner.chat-active .chat-banner-btn {
background: #10b981;
color: white;
}
@keyframes chatPulse {
0%, 100% { box-shadow: var(--shadow-md), 0 0 0 0 rgba(46, 72, 114, 0.4); }
50% { box-shadow: var(--shadow-lg), 0 0 0 8px rgba(46, 72, 114, 0); }
}
.chat-banner::before {
content: '';
position: absolute;
top: -50%;
right: -10%;
width: 200px;
height: 200px;
background: rgba(255,255,255,0.1);
border-radius: 50%;
}
.chat-banner-icon {
font-size: 2.5rem;
flex-shrink: 0;
}
.chat-banner-content {
flex: 1;
min-width: 0;
}
.chat-banner-label {
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 1px;
opacity: 0.9;
margin-bottom: var(--spacing-xs);
}
.chat-banner-title {
font-size: var(--font-size-lg);
font-weight: 700;
margin-bottom: var(--spacing-sm);
}
.chat-banner-input-wrapper {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
.chat-banner-input {
flex: 1;
padding: var(--spacing-sm) var(--spacing-md);
border: none;
border-radius: var(--radius);
font-size: var(--font-size-sm);
background: rgba(255,255,255,0.95);
color: var(--text-primary);
cursor: pointer;
}
.chat-banner-input:focus {
outline: none;
background: white;
}
.chat-banner-input::placeholder {
color: var(--text-secondary);
}
.chat-banner-btn {
background: white;
color: #2E4872;
border: none;
padding: var(--spacing-sm) var(--spacing-md);
font-weight: 600;
font-size: var(--font-size-sm);
border-radius: var(--radius-btn);
cursor: pointer;
transition: var(--transition);
white-space: nowrap;
}
.chat-banner-btn:hover {
background: #EDF0F5;
transform: translateY(-1px);
}
/* NordaGPT Fullscreen Modal */
.nordagpt-modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
animation: fadeIn 0.2s ease;
}
.nordagpt-modal.active {
display: flex;
}
.nordagpt-modal.minimized {
display: none;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.nordagpt-container {
position: absolute;
top: 20px;
left: 20px;
right: 20px;
bottom: 20px;
background: white;
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.nordagpt-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-md) var(--spacing-lg);
background: linear-gradient(135deg, #1e3050 0%, #2E4872 100%);
color: white;
}
.nordagpt-header-left {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.nordagpt-header h2 {
font-size: var(--font-size-lg);
font-weight: 600;
margin: 0;
}
.nordagpt-header-badge {
background: rgba(255,255,255,0.2);
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
}
.nordagpt-header-actions {
display: flex;
gap: var(--spacing-xs);
}
.nordagpt-header-btn {
background: rgba(255,255,255,0.2);
border: none;
color: white;
width: 32px;
height: 32px;
border-radius: var(--radius);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition);
}
.nordagpt-header-btn:hover {
background: rgba(255,255,255,0.3);
}
.nordagpt-messages {
flex: 1;
overflow-y: auto;
padding: var(--spacing-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.nordagpt-message {
display: flex;
gap: var(--spacing-sm);
max-width: 85%;
}
.nordagpt-message.user {
align-self: flex-end;
flex-direction: row-reverse;
}
.nordagpt-message-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: var(--font-size-sm);
flex-shrink: 0;
}
.nordagpt-message.assistant .nordagpt-message-avatar {
background: linear-gradient(135deg, #1e3050 0%, #2E4872 100%);
color: white;
}
.nordagpt-message.user .nordagpt-message-avatar {
background: var(--primary);
color: white;
}
.nordagpt-message-content {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-lg);
line-height: 1.5;
}
.nordagpt-message.assistant .nordagpt-message-content {
background: var(--background);
color: var(--text-primary);
border-bottom-left-radius: var(--radius-sm);
}
.nordagpt-message.user .nordagpt-message-content {
background: var(--primary);
color: white;
border-bottom-right-radius: var(--radius-sm);
}
.nordagpt-message-content a {
color: inherit;
text-decoration: underline;
}
/* AI response list styles */
.nordagpt-message-content .ai-list {
margin: var(--spacing-xs) 0;
padding-left: var(--spacing-lg);
}
.nordagpt-message-content .ai-list li {
margin-bottom: var(--spacing-xs);
line-height: 1.4;
}
.nordagpt-message-content ol.ai-list {
list-style-type: decimal;
}
.nordagpt-message-content ul.ai-list {
list-style-type: disc;
}
.nordagpt-message-content strong {
font-weight: 600;
}
.nordagpt-input-area {
padding: var(--spacing-md) var(--spacing-lg);
border-top: 1px solid var(--border);
display: flex;
gap: var(--spacing-sm);
}
.nordagpt-input {
flex: 1;
padding: var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
font-size: var(--font-size-base);
resize: none;
}
.nordagpt-input:focus {
outline: none;
border-color: #2E4872;
box-shadow: 0 0 0 3px rgba(46, 72, 114, 0.1);
}
.nordagpt-send-btn {
background: linear-gradient(135deg, #1e3050 0%, #2E4872 100%);
color: white;
border: none;
padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--radius-btn);
font-weight: 600;
cursor: pointer;
transition: var(--transition);
}
.nordagpt-send-btn:hover {
filter: brightness(1.1);
}
.nordagpt-send-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.nordagpt-typing {
display: flex;
gap: 4px;
padding: var(--spacing-sm);
}
.nordagpt-typing span {
width: 8px;
height: 8px;
background: #2E4872;
border-radius: 50%;
animation: typing 1.4s infinite;
}
.nordagpt-typing span:nth-child(2) { animation-delay: 0.2s; }
.nordagpt-typing span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-4px); opacity: 1; }
}
@media (max-width: 640px) {
.chat-banner {
flex-direction: column;
text-align: center;
}
.chat-banner-input-wrapper {
width: 100%;
flex-direction: column;
}
.chat-banner-input {
width: 100%;
}
.nordagpt-container {
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 0;
}
.nordagpt-message {
max-width: 95%;
}
}
/* Search Bar */
.search-section {
margin-bottom: var(--spacing-2xl);
}
.search-bar {
display: flex;
gap: var(--spacing-md);
max-width: 800px;
margin: 0 auto;
}
.search-input {
flex: 1;
padding: var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-base);
}
.search-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
/* Category Filter */
.category-filter {
display: flex;
gap: var(--spacing-sm);
flex-wrap: wrap;
justify-content: center;
margin-bottom: var(--spacing-xl);
}
.category-badge {
padding: var(--spacing-sm) var(--spacing-md);
background-color: var(--background);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
color: var(--text-secondary);
text-decoration: none;
font-size: var(--font-size-sm);
font-weight: 500;
transition: var(--transition);
cursor: pointer;
}
.category-badge:hover,
.category-badge.active {
background-color: var(--primary);
color: white;
border-color: var(--primary);
}
/* Category hierarchy - two rows */
.category-filter-wrapper {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
margin-bottom: var(--spacing-xl);
}
.category-filter-main {
display: flex;
gap: var(--spacing-sm);
flex-wrap: wrap;
justify-content: center;
}
.category-filter-sub {
display: none;
gap: var(--spacing-sm);
flex-wrap: wrap;
justify-content: center;
padding: var(--spacing-md);
background: var(--bg-secondary);
border-radius: var(--radius-lg);
}
.category-filter-sub.visible {
display: flex;
}
.category-badge.category-main {
font-weight: 600;
background-color: var(--surface);
color: var(--text-primary);
border-color: var(--primary);
}
.category-badge.category-main:hover {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark, #1d4ed8) 100%);
color: white;
border-color: var(--primary);
}
.category-badge.category-main.active {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark, #1d4ed8) 100%);
color: white;
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3);
}
.category-badge.category-sub {
font-size: var(--font-size-sm);
padding: var(--spacing-xs) var(--spacing-md);
background-color: var(--surface);
border-color: var(--border);
}
.category-badge.category-sub:hover,
.category-badge.category-sub.active {
background-color: var(--primary);
color: white;
border-color: var(--primary);
}
/* Kategoria "Do uzupełnienia" - żółty styl, z prawej strony */
.category-badge.category-todo {
margin-left: auto;
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
color: #78350f;
border-color: #f59e0b;
font-weight: 600;
}
.category-badge.category-todo:hover {
filter: brightness(1.1);
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.4);
}
.category-badge.category-todo.active {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.5);
}
/* Company Grid */
.companies-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-2xl);
}
.company-card {
background-color: var(--surface);
border-radius: 0;
padding: var(--spacing-lg);
border: 1px solid #E4E4E4;
transition: var(--transition);
display: flex;
flex-direction: column;
height: 100%;
}
.company-card:hover {
border-color: var(--primary);
box-shadow: 0 10px 30px rgba(46, 72, 114, 0.15);
}
.company-logo {
width: 100%;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: var(--spacing-md);
background: var(--background);
border-radius: var(--radius-md);
overflow: hidden;
}
.company-logo img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.company-header {
margin-bottom: var(--spacing-md);
}
.company-category {
display: inline-block;
padding: var(--spacing-xs) var(--spacing-sm);
background-color: #EDF0F5;
color: #464646;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
border-radius: 4px;
margin-bottom: var(--spacing-sm);
}
.company-name {
font-size: var(--font-size-xl);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
text-decoration: none;
display: block;
}
.company-name:hover {
color: var(--primary);
}
.company-description {
color: var(--text-secondary);
font-size: var(--font-size-sm);
line-height: 1.6;
margin-bottom: var(--spacing-md);
flex: 1;
}
.company-contact {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
font-size: var(--font-size-sm);
color: var(--text-secondary);
padding-top: var(--spacing-md);
border-top: 1px solid var(--border);
}
.company-contact-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.company-contact a {
color: var(--primary);
text-decoration: none;
}
.company-contact a:hover {
text-decoration: underline;
}
.empty-state {
text-align: center;
padding: var(--spacing-2xl);
color: var(--text-secondary);
}
.empty-state svg {
opacity: 0.3;
margin-bottom: var(--spacing-md);
}
/* Loading State */
.loading {
text-align: center;
padding: var(--spacing-2xl);
color: var(--text-secondary);
}
@media (max-width: 768px) {
.companies-grid {
grid-template-columns: 1fr;
}
.search-bar {
flex-direction: column;
}
}
{% endblock %}
{% block content %}
<!-- Header - różny dla członków i nie-członków -->
{% if current_user.is_authenticated and not current_user.is_norda_member and not current_user.company_id %}
{% if pending_application %}
<!-- Banner dla użytkownika z deklaracją w toku -->
<a href="{{ url_for('membership.status') }}" class="membership-header" data-animate="fadeIn" style="display: flex; align-items: center; gap: var(--spacing-xl); padding: var(--spacing-xl); background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); border-radius: var(--radius-lg); margin-bottom: var(--spacing-xl); text-decoration: none; color: white;">
<div style="font-size: 3rem;">📋</div>
<div style="flex: 1;">
<h1 style="font-size: var(--font-size-2xl); margin-bottom: var(--spacing-xs); font-weight: 700;">
Deklaracja członkowska w toku
</h1>
<p style="font-size: var(--font-size-md); opacity: 0.9; margin: 0;">
{% if pending_application.status == 'submitted' or pending_application.status == 'under_review' %}
Twoja deklaracja dla firmy "{{ pending_application.company_name }}" oczekuje na rozpatrzenie
{% elif pending_application.status == 'pending_user_approval' %}
Administrator zaproponował zmiany - sprawdź i zaakceptuj
{% elif pending_application.status == 'changes_requested' %}
Wymagane są poprawki w Twojej deklaracji
{% else %}
Kontynuuj wypełnianie deklaracji dla firmy "{{ pending_application.company_name or 'Twoja firma' }}"
{% endif %}
</p>
</div>
<div style="background: white; color: #1d4ed8; padding: var(--spacing-md) var(--spacing-xl); border-radius: var(--radius); font-weight: 700; font-size: var(--font-size-lg); white-space: nowrap;">
{% if pending_application.status == 'draft' %}Kontynuuj →{% else %}Sprawdź status →{% endif %}
</div>
</a>
<div style="background: #dbeafe; border: 1px solid #93c5fd; padding: var(--spacing-md); border-radius: var(--radius); margin-bottom: var(--spacing-xl); display: flex; align-items: center; gap: var(--spacing-sm);">
<span style="font-size: 1.2rem;"></span>
<span style="color: #1e40af;">Przeglądasz listę firm Izby NORDA. Pełny dostęp do szczegółów firm otrzymasz po zatwierdzeniu deklaracji.</span>
</div>
{% else %}
<!-- Banner CTA dla nie-członków NORDA bez deklaracji -->
<a href="{{ url_for('membership.apply') }}" class="membership-header" data-animate="fadeIn" style="display: flex; align-items: center; gap: var(--spacing-xl); padding: var(--spacing-xl); background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%); border-radius: var(--radius-lg); margin-bottom: var(--spacing-xl); text-decoration: none; color: white;">
<div style="font-size: 3rem;">🤝</div>
<div style="flex: 1;">
<h1 style="font-size: var(--font-size-2xl); margin-bottom: var(--spacing-xs); font-weight: 700;">
Dołącz do Izby Przedsiębiorców NORDA
</h1>
<p style="font-size: var(--font-size-md); opacity: 0.9; margin: 0;">
Złóż deklarację członkowską i zyskaj pełny dostęp do katalog {{ total_companies }} firm, wydarzeń i funkcji portalu
</p>
</div>
<div style="background: white; color: #16a34a; padding: var(--spacing-md) var(--spacing-xl); border-radius: var(--radius); font-weight: 700; font-size: var(--font-size-lg); white-space: nowrap;">
Złóż deklarację →
</div>
</a>
<div style="background: #fef3c7; border: 1px solid #fde68a; padding: var(--spacing-md); border-radius: var(--radius); margin-bottom: var(--spacing-xl); display: flex; align-items: center; gap: var(--spacing-sm);">
<span style="font-size: 1.2rem;">🔒</span>
<span style="color: #92400e;">Przeglądasz listę firm Izby NORDA. Aby zobaczyć szczegóły każdej firmy, złóż deklarację członkowską.</span>
</div>
{% endif %}
{% else %}
<!-- Standardowy nagłówek dla członków -->
<div style="background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%); color: white; padding: var(--spacing-xl); border-radius: var(--radius-lg); margin-bottom: var(--spacing-xl); text-align: center;">
<h1 style="font-size: var(--font-size-3xl); margin-bottom: var(--spacing-sm); font-weight: 700;">
Katalog firm Norda Biznes
</h1>
<p style="font-size: var(--font-size-lg); opacity: 0.9;">
{{ COMPANY_COUNT }} podmiotów gospodarczych • 4 kategorie • 17 podkategorii
</p>
</div>
{% endif %}
<!-- Event Banners - Najbliższe wydarzenia -->
{% if upcoming_events %}
{% for ue in upcoming_events %}
{% set ev = ue.event %}
<a href="{{ url_for('calendar.calendar_event', event_id=ev.id) }}" class="event-banner" data-animate="fadeIn"{% if not loop.first %} style="margin-top: -8px;"{% endif %}>
<div class="event-banner-icon">📅</div>
<div class="event-banner-content">
{% if loop.first %}
<div class="event-banner-label">Najbliższe wydarzenia Kto weźmie udział?</div>
{% endif %}
<div class="event-banner-title">{{ ev.title }} →</div>
<div class="event-banner-meta">
<span>📆 {{ ev.event_date.strftime('%d.%m.%Y') }} ({{ ['Pon', 'Wt', 'Śr', 'Czw', 'Pt', 'Sob', 'Nd'][ev.event_date.weekday()] }})</span>
{% if ev.time_start %}
<span>🕕 {{ ev.time_start.strftime('%H:%M') }}</span>
{% endif %}
{% if ev.location %}
<span>📍 {{ ev.location[:30] }}{% if ev.location|length > 30 %}...{% endif %}</span>
{% endif %}
</div>
<div class="event-banner-attendees">
👥 Zapisanych: {{ ev.attendee_count }} {% if ev.attendee_count == 1 %}osoba{% elif ev.attendee_count in [2,3,4] %}osoby{% else %}osób{% endif %}
</div>
</div>
<div class="event-banner-action">
{% if ue.user_registered %}
<span class="btn-light btn-registered">✓ Jesteś zapisany/a</span>
{% elif ue.user_can_attend %}
<button type="button" class="btn-light" onclick="rsvpAndGo(event, {{ ev.id }})">Zapisz się →</button>
{% elif ev.access_level == 'rada_only' %}
<span class="btn-light" style="background: #fef3c7; color: #92400e; border: 1px solid #fde68a;">🔒 Rada Izby</span>
{% endif %}
</div>
</a>
{% endfor %}
{% endif %}
<!-- NordaGPT Chat Banner -->
{% if current_user.is_authenticated %}
<a href="{{ url_for('chat') }}" class="chat-banner" id="chatBanner" style="cursor: pointer; text-decoration: none;" data-animate="fadeIn">
<div class="chat-banner-icon"><img src="{{ url_for('static', filename='img/nordagpt-icon.svg') }}" alt="NordaGPT" style="width: 48px; height: 48px;"></div>
<div class="chat-banner-content">
<div class="chat-banner-label">NordaGPT - Asystent AI Norda Biznes</div>
<div class="chat-banner-title" id="chatBannerTitle">Zapytaj o firmy, usługi, wydarzenia...</div>
<div class="chat-banner-input-wrapper">
<span class="chat-banner-input">Np. Kto oferuje usługi IT? Kiedy następne spotkanie?</span>
<span class="chat-banner-btn">Rozpocznij chat →</span>
</div>
</div>
</a>
{% endif %}
<!-- NordaGPT Fullscreen Modal -->
<div class="nordagpt-modal" id="nordagptModal">
<div class="nordagpt-container">
<div class="nordagpt-header">
<div class="nordagpt-header-left">
<img src="{{ url_for('static', filename='img/nordagpt-icon.svg') }}" alt="NordaGPT" style="width: 28px; height: 28px;">
<h2>NordaGPT</h2>
<span class="nordagpt-header-badge">Gemini 3</span>
</div>
<div class="nordagpt-header-actions">
<button class="nordagpt-header-btn" onclick="minimizeNordaGPT()" title="Minimalizuj">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 8h8"/>
</svg>
</button>
<button class="nordagpt-header-btn" onclick="closeNordaGPT()" title="Zamknij">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 4l8 8M12 4l-8 8"/>
</svg>
</button>
</div>
</div>
<div class="nordagpt-messages" id="nordagptMessages">
<div class="nordagpt-message assistant">
<div class="nordagpt-message-avatar">AI</div>
<div class="nordagpt-message-content">
Cześć! Jestem NordaGPT - asystentem AI Norda Biznes. Mogę pomóc Ci znaleźć firmy, usługi, sprawdzić kalendarz wydarzeń, rekomendacje i wiele więcej. O co chcesz zapytać?
</div>
</div>
</div>
<div class="nordagpt-input-area">
<input type="text" class="nordagpt-input" id="nordagptInput"
placeholder="Napisz wiadomość..."
onkeypress="if(event.key==='Enter')sendNordaGPTMessage()">
<button class="nordagpt-send-btn" id="nordagptSendBtn" onclick="sendNordaGPTMessage()">
Wyślij
</button>
</div>
</div>
</div>
<!-- ZOPK Knowledge Widget — admin only -->
{% if current_user.is_authenticated and current_user.is_admin and zopk_facts %}
<div style="background: linear-gradient(135deg, #059669 0%, #047857 50%, #065f46 100%); border-radius: var(--radius-lg); padding: var(--spacing-lg); margin-bottom: var(--spacing-xl); color: white;" data-animate="fadeIn">
<div style="display: flex; align-items: center; gap: var(--spacing-sm); margin-bottom: var(--spacing-md);">
<span style="font-size: 1.5rem;">💡</span>
<h3 style="margin: 0; font-size: var(--font-size-lg); font-weight: 700;">Czy wiesz, że... <span style="font-weight: 400; font-size: var(--font-size-sm); opacity: 0.8;">(Baza Wiedzy ZOPK)</span></h3>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: var(--spacing-md);">
{% for fact in zopk_facts %}
<div style="background: rgba(255,255,255,0.12); border-radius: var(--radius); padding: var(--spacing-md);">
<span style="display: inline-block; padding: 1px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; margin-bottom: var(--spacing-xs);
{% if fact.fact_type == 'investment' %}background: rgba(16,185,129,0.3);
{% elif fact.fact_type == 'event' %}background: rgba(59,130,246,0.3);
{% elif fact.fact_type == 'decision' %}background: rgba(245,158,11,0.3);
{% elif fact.fact_type == 'milestone' %}background: rgba(139,92,246,0.3);
{% else %}background: rgba(255,255,255,0.2);{% endif %}">
{{ fact.fact_type or 'fakt' }}
</span>
<p style="font-size: var(--font-size-sm); line-height: 1.5; margin: 0; opacity: 0.95;">
{{ fact.full_text[:200] }}{% if fact.full_text|length > 200 %}...{% endif %}
</p>
{% if fact.source_news %}
<div style="font-size: 11px; margin-top: var(--spacing-xs); opacity: 0.7;">
{{ fact.source_news.source_name or fact.source_news.source_domain }} &bull; {{ fact.source_news.published_at.strftime('%d.%m.%Y') if fact.source_news.published_at else '' }}
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Search Section -->
<div class="search-section" data-animate="fadeIn">
<form action="{{ url_for('search') }}" method="GET" class="search-bar">
<input
type="search"
name="q"
class="search-input"
placeholder="Szukaj firm po nazwie, usłudze lub słowie kluczowym..."
aria-label="Search companies"
>
<button type="submit" class="btn btn-primary">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="9" cy="9" r="7"/>
<path d="M14 14l5 5"/>
</svg>
Szukaj
</button>
</form>
</div>
<!-- Category Filter - Hierarchical -->
{% if main_categories %}
<div class="category-filter-wrapper">
<!-- Row 1: Main categories -->
<div class="category-filter-main">
<button class="category-badge active" onclick="filterCategory('all')">
Wszystkie ({{ total_companies }})
</button>
{# Collect categories with counts and sort by count descending #}
{% set cat_counts = [] %}
{% for main_cat in main_categories %}
{% set main_count = companies|selectattr('category_id', 'equalto', main_cat.id)|list|length %}
{% set sub_count = namespace(val=0) %}
{% for sub in main_cat.subcategories %}
{% set sub_count.val = sub_count.val + companies|selectattr('category_id', 'equalto', sub.id)|list|length %}
{% endfor %}
{% set total_count = main_count + sub_count.val %}
{% if total_count > 0 %}
{% set _ = cat_counts.append({'cat': main_cat, 'count': total_count}) %}
{% endif %}
{% endfor %}
{# Renderuj normalne kategorie (bez "do-uzupelnienia") #}
{% for item in cat_counts|sort(attribute='count', reverse=true) %}
{% if item.cat.slug != 'do-uzupelnienia' %}
<button class="category-badge category-main" onclick="selectMainCategory('{{ item.cat.slug }}')" data-main-slug="{{ item.cat.slug }}">
{{ item.cat.name }} ({{ item.count }})
</button>
{% endif %}
{% endfor %}
{# Kategoria "Do uzupełnienia" osobno, z prawej strony (żółta) #}
{% for item in cat_counts if item.cat.slug == 'do-uzupelnienia' %}
<button class="category-badge category-todo" onclick="selectMainCategory('{{ item.cat.slug }}')" data-main-slug="{{ item.cat.slug }}">
{{ item.cat.name }} ({{ item.count }})
</button>
{% endfor %}
</div>
<!-- Row 2: Subcategories (hidden by default, shown when main category selected) -->
{% for main_cat in main_categories %}
<div class="category-filter-sub" id="subcats-{{ main_cat.slug }}" data-parent="{{ main_cat.slug }}">
{# Zbierz podkategorie z licznikami i posortuj malejąco #}
{% set sub_counts = [] %}
{% for sub in main_cat.subcategories %}
{% set count = companies|selectattr('category_id', 'equalto', sub.id)|list|length %}
{% if count > 0 %}
{% set _ = sub_counts.append({'sub': sub, 'count': count}) %}
{% endif %}
{% endfor %}
{% for item in sub_counts|sort(attribute='count', reverse=true) %}
<button class="category-badge category-sub" onclick="filterSubCategory('{{ item.sub.slug }}', '{{ main_cat.slug }}')" data-parent="{{ main_cat.slug }}">
{{ item.sub.name }} ({{ item.count }})
</button>
{% endfor %}
</div>
{% endfor %}
</div>
{% elif categories %}
<!-- Fallback dla starej struktury -->
<div class="category-filter">
<button class="category-badge active" onclick="filterCategory('all')">
Wszystkie ({{ total_companies }})
</button>
{% for category in categories %}
{% set count = companies|selectattr('category_id', 'equalto', category.id)|list|length %}
{% if count > 0 %}
<button class="category-badge" onclick="filterCategory('{{ category.slug }}')">
{{ category.name }} ({{ count }})
</button>
{% endif %}
{% endfor %}
</div>
{% endif %}
<!-- Companies Grid -->
{% if companies %}
<div class="companies-grid" id="companiesGrid">
{% for company in companies %}
<div class="company-card" data-category="{{ company.category.slug if company.category else 'brak' }}" data-animate="fadeInUp" data-animate-delay="{{ (loop.index0 % 6) + 1 }}">
<a href="{{ url_for('company_detail', company_id=company.id) }}" class="company-logo">
<img src="{{ url_for('static', filename='img/companies/' ~ company.slug ~ '.webp') }}"
alt="{{ company.name }}"
onerror="if(!this.dataset.triedSvg){this.dataset.triedSvg='1';this.src=this.src.replace('.webp','.svg')}else{this.parentElement.style.display='none'}">
</a>
<div class="company-header">
{% if company.category %}
<span class="company-category">{{ company.category.name }}</span>
{% endif %}
<a href="{{ url_for('company_detail', company_id=company.id) }}" class="company-name">
{{ company.name }}
</a>
</div>
<div class="company-description">
{{ company.description_short|truncate(150) if company.description_short else 'Brak opisu' }}
</div>
<div class="company-contact">
{% if company.website %}
<div class="company-contact-item">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="8" cy="8" r="7"/>
<path d="M1 8h14M8 1a11 11 0 0 1 0 14 11 11 0 0 1 0-14"/>
</svg>
<a href="{{ company.website }}" target="_blank" rel="noopener noreferrer">
{{ company.website|replace('https://', '')|replace('http://', '')|replace('www.', '')|truncate(30) }}
</a>
</div>
{% endif %}
{% if company.phone %}
<div class="company-contact-item">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 2a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H3z"/>
<path d="M6 6l4 4M10 6l-4 4"/>
</svg>
<a href="tel:{{ company.phone }}">{{ company.phone }}</a>
</div>
{% endif %}
{% if company.address_city %}
<div class="company-contact-item">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M8 2l6 6-6 6-6-6 6-6z"/>
</svg>
{{ company.address_city }}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<svg width="120" height="120" viewBox="0 0 120 120" fill="none">
<circle cx="60" cy="60" r="50" stroke="currentColor" stroke-width="4"/>
<path d="M40 50h40M40 70h40" stroke="currentColor" stroke-width="4" stroke-linecap="round"/>
</svg>
<h2>Brak firm w katalogu</h2>
<p>Nie znaleziono żadnych firm w systemie.</p>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
// RSVP and redirect to event
async function rsvpAndGo(e, eventId) {
e.preventDefault();
e.stopPropagation();
const btn = e.target;
btn.disabled = true;
btn.textContent = 'Zapisuję...';
try {
const response = await fetch('/kalendarz/' + eventId + '/rsvp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
}
});
const data = await response.json();
if (data.success) {
// Update counter visually before redirect
const counter = document.querySelector('.event-banner-attendees');
if (counter && data.action === 'added') {
const newCount = data.attendee_count;
let suffix = 'osób';
if (newCount === 1) suffix = 'osoba';
else if (newCount >= 2 && newCount <= 4) suffix = 'osoby';
counter.innerHTML = '👥 Zapisanych: ' + newCount + ' ' + suffix;
}
btn.textContent = '✓ Zapisano!';
// Redirect after short delay
setTimeout(() => {
window.location.href = '/kalendarz/' + eventId;
}, 500);
} else {
btn.textContent = 'Błąd';
setTimeout(() => { btn.textContent = 'Zapisz się →'; btn.disabled = false; }, 2000);
}
} catch (error) {
btn.textContent = 'Błąd sieci';
setTimeout(() => { btn.textContent = 'Zapisz się →'; btn.disabled = false; }, 2000);
}
}
// Category filter - show all
function filterCategory(slug) {
const cards = document.querySelectorAll('.company-card');
// Hide all subcategory rows
document.querySelectorAll('.category-filter-sub').forEach(row => {
row.classList.remove('visible');
});
// Remove active from all badges
document.querySelectorAll('.category-badge').forEach(badge => {
badge.classList.remove('active');
});
// Activate "Wszystkie" button
const allBtn = document.querySelector('.category-badge[onclick*="filterCategory(\'all\')"]');
if (allBtn) allBtn.classList.add('active');
// Show all cards
cards.forEach(card => {
card.style.display = 'flex';
});
}
// Select main category - show subcategories and filter
function selectMainCategory(mainSlug) {
const cards = document.querySelectorAll('.company-card');
// Hide all subcategory rows, show only selected
document.querySelectorAll('.category-filter-sub').forEach(row => {
row.classList.remove('visible');
});
const subRow = document.getElementById('subcats-' + mainSlug);
if (subRow) subRow.classList.add('visible');
// Update active badges
document.querySelectorAll('.category-badge').forEach(badge => {
badge.classList.remove('active');
});
const mainBtn = document.querySelector('.category-badge.category-main[data-main-slug="' + mainSlug + '"]');
if (mainBtn) mainBtn.classList.add('active');
// Get all valid slugs (main + subcategories)
const validSlugs = [mainSlug];
document.querySelectorAll('.category-badge.category-sub[data-parent="' + mainSlug + '"]').forEach(badge => {
const onclick = badge.getAttribute('onclick');
if (onclick) {
const match = onclick.match(/filterSubCategory\('([^']+)'/);
if (match) validSlugs.push(match[1]);
}
});
// Filter cards
cards.forEach(card => {
const cardCategory = card.getAttribute('data-category');
card.style.display = validSlugs.includes(cardCategory) ? 'flex' : 'none';
});
}
// Filter by subcategory
function filterSubCategory(subSlug, parentSlug) {
const cards = document.querySelectorAll('.company-card');
// Keep subcategory row visible
document.querySelectorAll('.category-filter-sub').forEach(row => {
row.classList.remove('visible');
});
const subRow = document.getElementById('subcats-' + parentSlug);
if (subRow) subRow.classList.add('visible');
// Update active badges - main stays highlighted, sub is active
document.querySelectorAll('.category-badge').forEach(badge => {
badge.classList.remove('active');
});
const mainBtn = document.querySelector('.category-badge.category-main[data-main-slug="' + parentSlug + '"]');
if (mainBtn) mainBtn.classList.add('active');
// Find and activate the sub badge
document.querySelectorAll('.category-badge.category-sub[data-parent="' + parentSlug + '"]').forEach(badge => {
const onclick = badge.getAttribute('onclick');
if (onclick && onclick.includes("'" + subSlug + "'")) {
badge.classList.add('active');
}
});
// Filter cards - only show matching subcategory
cards.forEach(card => {
const cardCategory = card.getAttribute('data-category');
card.style.display = cardCategory === subSlug ? 'flex' : 'none';
});
}
// Smooth scroll to companies on search
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('q')) {
document.getElementById('companiesGrid')?.scrollIntoView({ behavior: 'smooth' });
}
// ========================================
// NordaGPT Chat Functions
// ========================================
let nordaGPTConversationId = null;
let nordaGPTIsMinimized = false;
function openNordaGPT() {
document.getElementById('nordagptModal').classList.add('active');
document.getElementById('nordagptModal').classList.remove('minimized');
document.getElementById('nordagptInput').focus();
document.body.style.overflow = 'hidden';
nordaGPTIsMinimized = false;
// Remove active indicator from banner
const banner = document.getElementById('chatBanner');
if (banner) {
banner.classList.remove('chat-active');
}
}
function minimizeNordaGPT() {
document.getElementById('nordagptModal').classList.remove('active');
document.getElementById('nordagptModal').classList.add('minimized');
document.body.style.overflow = '';
nordaGPTIsMinimized = true;
// Show banner with active indicator
const banner = document.getElementById('chatBanner');
const title = document.getElementById('chatBannerTitle');
const btn = banner?.querySelector('.chat-banner-btn');
if (banner) {
banner.classList.add('chat-active');
}
if (title) {
title.textContent = '💬 Chat aktywny - kliknij aby kontynuować';
}
if (btn) {
btn.textContent = 'Wznów chat →';
}
}
function closeNordaGPT() {
document.getElementById('nordagptModal').classList.remove('active');
document.getElementById('nordagptModal').classList.remove('minimized');
document.body.style.overflow = '';
nordaGPTIsMinimized = false;
// Reset banner to initial state
const banner = document.getElementById('chatBanner');
const title = document.getElementById('chatBannerTitle');
const btn = banner?.querySelector('.chat-banner-btn');
if (banner) {
banner.classList.remove('chat-active');
}
if (title) {
title.textContent = 'Zapytaj o firmy, usługi, wydarzenia...';
}
if (btn) {
btn.textContent = 'Rozpocznij chat →';
}
}
// Convert URLs, emails, markdown to HTML (linkify + formatting)
function linkifyNordaGPT(text) {
let escaped = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
// Use placeholders to protect converted elements
const placeholders = [];
function addPlaceholder(html) {
const placeholder = '__PH_' + placeholders.length + '__';
placeholders.push(html);
return placeholder;
}
// 1. Markdown links first
const markdownLinkRegex = /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/gi;
escaped = escaped.replace(markdownLinkRegex, function(match, linkText, url) {
return addPlaceholder('<a href="' + url + '" target="_blank">' + linkText + '</a>');
});
// 2. Plain URLs
const urlRegex = /(https?:\/\/[^\s<]+|www\.[^\s<]+)/gi;
escaped = escaped.replace(urlRegex, function(url) {
let cleanUrl = url.replace(/[.,;:!?)\]]+$/, '');
const trailingPunct = url.slice(cleanUrl.length);
const href = cleanUrl.startsWith('www.') ? 'https://' + cleanUrl : cleanUrl;
return addPlaceholder('<a href="' + href + '" target="_blank">' + cleanUrl + '</a>') + trailingPunct;
});
// 3. Emails
const emailRegex = /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/gi;
escaped = escaped.replace(emailRegex, function(email) {
let cleanEmail = email.replace(/[.,;:!?)\]]+$/, '');
const trailingPunct = email.slice(cleanEmail.length);
return addPlaceholder('<a href="mailto:' + cleanEmail + '">' + cleanEmail + '</a>') + trailingPunct;
});
// 4. Convert **bold** to <strong>
escaped = escaped.replace(/\*\*([^*]+)\*\*/g, function(match, boldText) {
return addPlaceholder('<strong>' + boldText + '</strong>');
});
// 5. Process lines for lists and newlines
const lines = escaped.split('\n');
const processedLines = [];
let inList = false;
let listType = null;
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
const trimmedLine = line.trim();
// Check for numbered list (1. 2. 3. etc.)
const numberedMatch = trimmedLine.match(/^(\d+)\.\s+(.*)$/);
// Check for bullet list (- or * at start)
const bulletMatch = trimmedLine.match(/^[-*]\s+(.*)$/);
if (numberedMatch) {
if (!inList || listType !== 'ol') {
if (inList) processedLines.push(listType === 'ol' ? '</ol>' : '</ul>');
processedLines.push('<ol class="ai-list">');
inList = true;
listType = 'ol';
}
processedLines.push('<li>' + numberedMatch[2] + '</li>');
} else if (bulletMatch) {
if (!inList || listType !== 'ul') {
if (inList) processedLines.push(listType === 'ol' ? '</ol>' : '</ul>');
processedLines.push('<ul class="ai-list">');
inList = true;
listType = 'ul';
}
processedLines.push('<li>' + bulletMatch[1] + '</li>');
} else {
if (inList && trimmedLine !== '') {
processedLines.push(listType === 'ol' ? '</ol>' : '</ul>');
inList = false;
listType = null;
}
if (trimmedLine === '') {
if (!inList) processedLines.push('<br>');
} else {
processedLines.push(line);
if (i < lines.length - 1) processedLines.push('<br>');
}
}
}
if (inList) {
processedLines.push(listType === 'ol' ? '</ol>' : '</ul>');
}
escaped = processedLines.join('\n');
// 6. Restore all placeholders
placeholders.forEach(function(html, i) {
escaped = escaped.replace('__PH_' + i + '__', html);
});
// Clean up multiple consecutive <br> tags
escaped = escaped.replace(/(<br>\s*){3,}/g, '<br><br>');
return escaped;
}
function addNordaGPTMessage(role, content) {
const messagesDiv = document.getElementById('nordagptMessages');
const messageDiv = document.createElement('div');
messageDiv.className = 'nordagpt-message ' + role;
const avatar = document.createElement('div');
avatar.className = 'nordagpt-message-avatar';
avatar.textContent = role === 'user' ? 'U' : 'AI';
const contentDiv = document.createElement('div');
contentDiv.className = 'nordagpt-message-content';
if (role === 'assistant') {
contentDiv.innerHTML = linkifyNordaGPT(content);
} else {
contentDiv.textContent = content;
}
messageDiv.appendChild(avatar);
messageDiv.appendChild(contentDiv);
messagesDiv.appendChild(messageDiv);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
function showNordaGPTTyping() {
const messagesDiv = document.getElementById('nordagptMessages');
const typingDiv = document.createElement('div');
typingDiv.className = 'nordagpt-message assistant';
typingDiv.id = 'nordagptTyping';
typingDiv.innerHTML = `
<div class="nordagpt-message-avatar">AI</div>
<div class="nordagpt-message-content">
<div class="nordagpt-typing"><span></span><span></span><span></span></div>
</div>
`;
messagesDiv.appendChild(typingDiv);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
function hideNordaGPTTyping() {
const typing = document.getElementById('nordagptTyping');
if (typing) typing.remove();
}
async function sendNordaGPTMessage() {
const input = document.getElementById('nordagptInput');
const sendBtn = document.getElementById('nordagptSendBtn');
const message = input.value.trim();
if (!message) return;
// Add user message
addNordaGPTMessage('user', message);
input.value = '';
sendBtn.disabled = true;
// Show typing indicator
showNordaGPTTyping();
try {
// Step 1: Start conversation if we don't have one
if (!nordaGPTConversationId) {
const startResponse = await fetch('/api/chat/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({
title: 'NordaGPT - ' + new Date().toLocaleDateString('pl-PL')
})
});
const startData = await startResponse.json();
if (startData.success) {
nordaGPTConversationId = startData.conversation_id;
} else {
throw new Error(startData.error || 'Nie udało się rozpocząć rozmowy');
}
}
// Step 2: Send message to conversation
const response = await fetch('/api/chat/' + nordaGPTConversationId + '/message', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({
message: message
})
});
const data = await response.json();
hideNordaGPTTyping();
if (data.success && data.message) {
addNordaGPTMessage('assistant', data.message);
} else if (data.error) {
addNordaGPTMessage('assistant', 'Przepraszam, wystąpił błąd: ' + data.error);
}
} catch (error) {
hideNordaGPTTyping();
console.error('NordaGPT error:', error);
addNordaGPTMessage('assistant', 'Przepraszam, nie mogę teraz odpowiedzieć. Spróbuj ponownie później.');
}
sendBtn.disabled = false;
input.focus();
}
// Close modal on Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const modal = document.getElementById('nordagptModal');
if (modal.classList.contains('active')) {
minimizeNordaGPT();
}
}
});
// Close modal when clicking outside
document.getElementById('nordagptModal')?.addEventListener('click', function(e) {
if (e.target === this) {
minimizeNordaGPT();
}
});
{% endblock %}