nordabiz/templates/index.html
Maciej Pienczyn f934df3bb4
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(homepage): restore events filtering — add events-row class, limit=2
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 19:30:04 +02:00

2024 lines
77 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 %}
/* Events Row - siatka 2x2 */
.events-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-md);
margin-bottom: var(--spacing-xl);
}
.events-row-label {
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 1px;
color: rgba(255,255,255,0.9);
margin-bottom: var(--spacing-xs);
}
/* 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-md);
color: white;
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
box-shadow: var(--shadow-md);
position: relative;
overflow: hidden;
text-decoration: none;
cursor: pointer;
transition: var(--transition);
flex: 1;
min-width: 0;
}
.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-top {
display: flex;
align-items: flex-start;
gap: var(--spacing-sm);
}
.event-banner-icon {
font-size: 2rem;
flex-shrink: 0;
}
.event-banner-content {
flex: 1;
min-width: 0;
}
.event-banner-title {
font-size: var(--font-size-base);
font-weight: 700;
margin-bottom: var(--spacing-xs);
}
.event-banner-meta {
font-size: var(--font-size-xs);
opacity: 0.9;
display: flex;
flex-wrap: wrap;
gap: var(--spacing-sm);
}
.event-banner-bottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-sm);
}
.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;
font-size: var(--font-size-xs);
}
.event-banner-action {
flex-shrink: 0;
}
.event-banner .btn-light {
background: white;
color: #2E4872;
border: none;
padding: var(--spacing-xs) var(--spacing-md);
font-weight: 600;
font-size: var(--font-size-sm);
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;
}
/* Events filter buttons */
.events-filter {
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
}
.events-filter-label {
font-size: var(--font-size-sm);
color: var(--text-muted);
font-weight: 500;
}
.events-filter-btn {
padding: var(--spacing-xs) var(--spacing-md);
border-radius: var(--radius-btn);
border: 1.5px solid #cbd5e1;
background: white;
color: var(--text-secondary);
font-size: var(--font-size-sm);
font-weight: 500;
cursor: pointer;
transition: var(--transition);
}
.events-filter-btn:hover {
border-color: var(--primary);
color: var(--primary);
}
.events-filter-btn.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.event-banner[data-event-type="external"] {
background: linear-gradient(135deg, #1a3a2a 0%, #2d5a3e 100%);
}
.event-banner[data-event-type="external"]:hover {
box-shadow: 0 10px 30px rgba(45, 90, 62, 0.25);
}
.event-banner[data-event-type="external"] .btn-light {
color: #2d5a3e;
}
.event-banner.hidden-by-filter {
display: none;
}
/* Homepage 2-column grid */
.homepage-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
@media (max-width: 768px) {
.homepage-grid {
grid-template-columns: 1fr;
}
.events-row {
grid-template-columns: 1fr;
}
.event-banner {
flex-direction: column;
text-align: center;
}
.event-banner-top {
flex-direction: column;
align-items: center;
}
.event-banner-meta {
justify-content: center;
}
.event-banner-bottom {
flex-direction: column;
}
.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.dark-bg {
background: #1a1a2e;
}
.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-relation {
display: inline-block;
font-size: 11px;
color: var(--text-secondary);
background: var(--background);
padding: 2px 8px;
border-radius: var(--radius);
margin-bottom: var(--spacing-xs);
}
.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;
}
}
/* What's New Widget */
.whats-new-widget {
display: flex;
align-items: flex-start;
gap: var(--spacing-md);
background: var(--surface);
border: 1px solid var(--border-color, #e5e7eb);
border-left: 4px solid var(--primary);
border-radius: var(--radius-lg);
padding: var(--spacing-md) var(--spacing-lg);
margin-bottom: var(--spacing-lg);
}
.whats-new-icon {
font-size: 1.5rem;
flex-shrink: 0;
margin-top: 2px;
}
.whats-new-items {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-top: 4px;
}
.whats-new-item {
padding: 3px 0;
}
.whats-new-link {
color: var(--primary);
font-weight: 500;
font-size: var(--font-size-sm);
white-space: nowrap;
flex-shrink: 0;
text-decoration: none;
margin-top: 2px;
}
.whats-new-link:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
.whats-new-widget {
flex-wrap: wrap;
gap: var(--spacing-xs);
padding: var(--spacing-sm) var(--spacing-md);
}
.whats-new-icon {
font-size: 1.2rem;
}
.whats-new-desc {
display: none;
}
.whats-new-extra {
display: none;
}
.whats-new-link {
width: 100%;
text-align: right;
margin-top: 0;
}
}
{% 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 %}
<!-- Co nowego na platformie -->
{% if latest_release %}
<div class="whats-new-widget">
<div class="whats-new-icon">&#10024;</div>
<div style="flex: 1; min-width: 0;">
<div style="display: flex; align-items: baseline; gap: var(--spacing-sm); flex-wrap: wrap;">
<span style="font-weight: 600; color: var(--text-primary);">Co nowego na platformie?</span>
<span style="font-size: var(--font-size-xs); color: var(--text-secondary);">{{ latest_release.version }} &middot; {{ latest_release.date }}</span>
</div>
<div class="whats-new-items">
{% set all_items = (latest_release.new or []) + (latest_release.improve or []) %}
{% set starred = [] %}
{% for item in all_items %}{% if item.startswith('★') %}{% if starred.append(item) %}{% endif %}{% endif %}{% endfor %}
{% set starred_links = latest_release.get('links', {}) if latest_release.get is defined else {} %}
{% if starred %}
{% for item in starred %}
{% set item_title = item|striptags|replace('★ ', '') %}
{% set item_title_short = item_title.split(' - ')[0]|trim %}
{% set item_desc = item_title.split(' - ')[1]|trim if ' - ' in item_title else '' %}
{% set link = starred_links.get(item_title_short, '') if starred_links.get is defined else '' %}
<div class="whats-new-item{% if loop.index > 2 %} whats-new-extra{% endif %}">
<strong>{{ item_title_short }}</strong><span class="whats-new-desc">{{ ' - ' + item_desc if item_desc else '' }}</span>
{% if link %}
<a href="{{ link }}" style="color: var(--primary); font-weight: 500; text-decoration: none; margin-left: 6px; font-size: var(--font-size-xs);" onmouseover="this.style.textDecoration='underline'" onmouseout="this.style.textDecoration='none'">Wypróbuj &rarr;</a>
{% endif %}
</div>
{% endfor %}
{% else %}
{% set highlight = (all_items[0] if all_items else '')|striptags %}
<div style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">{{ highlight }}</div>
{% endif %}
{% set total_changes = (latest_release.new|length if latest_release.new else 0) + (latest_release.improve|length if latest_release.improve else 0) + (latest_release.fix|length if latest_release.fix else 0) %}
{% set starred_count = starred|length %}
{% set hidden_starred = starred|length - 2 if starred|length > 2 else 0 %}
{% set extra_count = total_changes - starred_count + hidden_starred %}
{% if extra_count > 0 %}
<div style="margin-top: 4px;">
<a href="{{ url_for('release_notes') }}" style="color: var(--primary); font-weight: 500; text-decoration: none; font-size: var(--font-size-sm);" onmouseover="this.style.textDecoration='underline'" onmouseout="this.style.textDecoration='none'">+ {{ extra_count }} {{ 'inna zmiana' if extra_count == 1 else ('inne zmiany' if extra_count < 5 else 'innych zmian') }} &rarr;</a>
</div>
{% endif %}
</div>
</div>
<a href="{{ url_for('release_notes') }}" class="whats-new-link">Zobacz wszystko &rarr;</a>
</div>
{% endif %}
<!-- Events Filter -->
{% if upcoming_events %}
<div class="events-filter" data-animate="fadeIn">
<span class="events-filter-label">Pokaż:</span>
<button class="events-filter-btn active" data-filter="all" onclick="filterEvents('all', this)">Wszystkie</button>
<button class="events-filter-btn" data-filter="norda" onclick="filterEvents('norda', this)">🏢 Norda Biznes</button>
<button class="events-filter-btn" data-filter="external" onclick="filterEvents('external', this)">🌐 Zewnętrzne</button>
</div>
{% endif %}
<!-- Homepage Grid: Events + Forum + New Members -->
{% if upcoming_events or latest_forum_topic or latest_admitted %}
<div class="homepage-grid" data-animate="fadeIn">
<!-- LEFT COLUMN: 2 Events -->
<div class="events-row" style="display: flex; flex-direction: column; gap: var(--spacing-md);">
{% if upcoming_events %}
{% for ue in upcoming_events[:2] %}
{% set ev = ue.event %}
<a href="{{ url_for('calendar.calendar_event', event_id=ev.id) }}" class="event-banner" data-event-type="{{ 'external' if ev.is_external else 'norda' }}" style="margin: 0;">
<div class="event-banner-top">
<div class="event-banner-icon">📅</div>
<div class="event-banner-content">
{% if loop.first %}
<div class="events-row-label">Najbliższe wydarzenia Kto weźmie udział?</div>
{% endif %}
<div class="event-banner-title">
{{ ev.title }} →
{% if ev.is_external and ev.external_source %}
<span style="display:inline-block; background:rgba(255,255,255,0.2); color:#fff; font-size:10px; padding:2px 6px; border-radius:4px; font-weight:600; vertical-align:middle; margin-left:6px;">🌐 {{ ev.external_source }}</span>
{% endif %}
</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>
</div>
<div class="event-banner-bottom">
<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 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>
{% endif %}
</div>
</div>
</a>
{% endfor %}
{% endif %}
</div>
<!-- RIGHT COLUMN: Forum + New Members -->
<div style="display: flex; flex-direction: column; gap: var(--spacing-md);">
<!-- Latest Forum Topic -->
{% if latest_forum_topic %}
<a href="{{ url_for('forum.forum_topic', topic_id=latest_forum_topic.id) }}" style="text-decoration: none; display: block; background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--spacing-md); transition: all 0.2s; min-height: 120px;" onmouseover="this.style.borderColor='var(--primary)';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.08)'" onmouseout="this.style.borderColor='var(--border)';this.style.boxShadow='none'">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span style="font-size: 1.2rem;">💬</span>
<span style="font-size: var(--font-size-xs); font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px;">Najnowszy wpis na forum</span>
</div>
<div style="font-weight: 600; color: var(--text-primary); font-size: var(--font-size-base); line-height: 1.4; margin-bottom: 8px;">
{{ latest_forum_topic.title }}
</div>
<div style="font-size: var(--font-size-sm); color: var(--text-secondary);">
{{ latest_forum_topic.author.name if latest_forum_topic.author else 'Anonim' }} · {{ latest_forum_topic.created_at|local_time('%d.%m.%Y %H:%M') }}
{% if latest_forum_topic.reply_count is defined and latest_forum_topic.reply_count > 0 %}
· {{ latest_forum_topic.reply_count }} {{ 'odpowiedź' if latest_forum_topic.reply_count == 1 else ('odpowiedzi' if latest_forum_topic.reply_count < 5 else 'odpowiedzi') }}
{% endif %}
</div>
</a>
{% endif %}
<!-- New Members -->
{% if latest_admitted %}
<a href="{{ url_for('public.new_members') }}" style="text-decoration: none; display: block; background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--spacing-md); transition: all 0.2s; min-height: 120px;" onmouseover="this.style.borderColor='var(--primary)';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.08)'" onmouseout="this.style.borderColor='var(--border)';this.style.boxShadow='none'">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span style="font-size: 1.2rem;">🏢</span>
<span style="font-size: var(--font-size-xs); font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px;">Nowi członkowie Izby</span>
{% if last_meeting %}
<span style="font-size: var(--font-size-xs); color: var(--text-muted);">· Rada {{ last_meeting.meeting_date.strftime('%d.%m.%Y') }}</span>
{% endif %}
</div>
<div style="display: flex; flex-direction: column; gap: 6px;">
{% for company in latest_admitted[:4] %}
<div style="display: flex; align-items: center; gap: 8px;">
<span style="color: var(--success); font-size: 14px;"></span>
<span style="font-weight: 500; color: var(--text-primary); font-size: var(--font-size-sm);">
{{ company.name }}
</span>
{% if company.status != 'active' %}
<span style="font-size: var(--font-size-xs); color: var(--text-muted); font-style: italic;">· profil w trakcie uzupełniania</span>
{% endif %}
</div>
{% endfor %}
{% if latest_admitted|length > 4 %}
<div style="font-size: var(--font-size-sm); color: var(--text-muted);">+ {{ latest_admitted|length - 4 }} więcej</div>
{% endif %}
</div>
<div style="margin-top: 8px; font-size: var(--font-size-sm); color: var(--primary); font-weight: 500;">
Zobacz wszystkich nowych członków →
</div>
</a>
{% elif last_meeting %}
<!-- No companies admitted yet at last meeting, show placeholder -->
<div style="background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--spacing-md); min-height: 120px;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span style="font-size: 1.2rem;">🏢</span>
<span style="font-size: var(--font-size-xs); font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px;">Nowi członkowie Izby</span>
</div>
<div style="color: var(--text-muted); font-size: var(--font-size-sm);">Brak nowych firm przyjętych na ostatnim posiedzeniu Rady.</div>
</div>
{% endif %}
</div>
</div>
{% 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 — widoczny dla zalogowanych -->
{% if current_user.is_authenticated 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;">Zielony Okr&#281;g Przemys&#322;owy Kaszubia</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 %}
<a {% if fact.source_news and fact.source_news.url %}href="{{ fact.source_news.url }}" target="_blank" rel="noopener"{% else %}href="{{ url_for('public.zopk_index') }}"{% endif %}
style="background: rgba(255,255,255,0.12); border-radius: var(--radius); padding: var(--spacing-md); text-decoration: none; color: white; display: block; transition: background 0.2s, transform 0.2s; cursor: pointer;"
onmouseover="this.style.background='rgba(255,255,255,0.22)'; this.style.transform='translateY(-2px)';"
onmouseout="this.style.background='rgba(255,255,255,0.12)'; this.style.transform='none';">
<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 %}">
{% if fact.fact_type == 'investment' %}inwestycja{% elif fact.fact_type == 'decision' %}decyzja{% elif fact.fact_type == 'event' %}wydarzenie{% elif fact.fact_type == 'milestone' %}kamień milowy{% elif fact.fact_type == 'statistic' %}dane{% elif fact.fact_type == 'partnership' %}współpraca{% elif fact.fact_type == 'project' %}projekt{% else %}fakt{% endif %}
</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|local_time('%d.%m.%Y') if fact.source_news.published_at else '' }}
<span style="float: right; opacity: 0.8;">Czytaj &rarr;</span>
</div>
{% endif %}
</a>
{% endfor %}
</div>
<div style="text-align: center; margin-top: var(--spacing-md);">
<button id="zopkMoreBtn" onclick="loadMoreFacts()" style="background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.3); color: white; padding: 8px 20px; border-radius: var(--radius); cursor: pointer; font-size: var(--font-size-sm); transition: background 0.2s;"
onmouseover="this.style.background='rgba(255,255,255,0.25)';" onmouseout="this.style.background='rgba(255,255,255,0.15)';">
Pokaż więcej
</button>
</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 {{ 'dark-bg' if company.logo_dark_bg else '' }}">
<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>
{% if company_parent and company.id in company_parent %}
<span class="company-relation">marka {{ company_parent[company.id] }}</span>
{% elif company_children and company.id in company_children %}
<span class="company-relation">{{ company_children[company.id]|join(', ') }}</span>
{% endif %}
</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 %}
// Filter events by type
function filterEvents(type, btn) {
document.querySelectorAll('.events-filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
fetch('/api/upcoming-events?filter=' + type + '&limit=2')
.then(r => r.json())
.then(data => {
const row = document.querySelector('.events-row');
if (!data.events || data.events.length === 0) {
row.innerHTML = '<div style="text-align:center; padding:var(--spacing-xl); color:var(--text-muted);">Brak wydarzeń w tej kategorii</div>';
return;
}
row.innerHTML = data.events.slice(0, 2).map((ev, i) => {
let typeCls = ev.is_external ? 'external' : 'norda';
let sourceBadge = ev.is_external && ev.external_source
? '<span style="display:inline-block;background:rgba(255,255,255,0.2);color:#fff;font-size:10px;padding:2px 6px;border-radius:4px;font-weight:600;vertical-align:middle;margin-left:6px;">🌐 ' + ev.external_source + '</span>'
: '';
let accessBadge = '';
if (ev.access_level === 'admin_only') accessBadge = '<span style="display:inline-block;background:#ef4444;color:#fff;font-size:10px;padding:2px 6px;border-radius:4px;font-weight:600;vertical-align:middle;margin-left:6px;">UKRYTE</span>';
else if (ev.access_level === 'rada_only') accessBadge = '<span style="display:inline-block;background:#f59e0b;color:#92400e;font-size:10px;padding:2px 6px;border-radius:4px;font-weight:600;vertical-align:middle;margin-left:6px;">IZBA</span>';
let timePart = ev.time ? '<span>🕕 ' + ev.time + '</span>' : '';
let locPart = ev.location ? '<span>📍 ' + ev.location + '</span>' : '';
let cnt = ev.attendee_count;
let cntWord = cnt === 1 ? 'osoba' : (cnt >= 2 && cnt <= 4 ? 'osoby' : 'osób');
let actionHtml = '';
if (ev.user_registered) {
actionHtml = '<span class="btn-light btn-registered">✓ Jesteś zapisany/a</span>';
} else if (ev.user_can_attend) {
actionHtml = '<button type="button" class="btn-light" onclick="rsvpAndGo(event, ' + ev.id + ')">Zapisz się →</button>';
} else if (ev.access_level === 'rada_only') {
actionHtml = '<span class="btn-light" style="background:#fef3c7;color:#92400e;border:1px solid #fde68a;">🔒 Rada Izby</span>';
}
let label = i === 0 ? '<div class="events-row-label">Najbliższe wydarzenia Kto weźmie udział?</div>' : '';
return '<a href="' + ev.url + '" class="event-banner" data-event-type="' + typeCls + '">' +
'<div class="event-banner-top"><div class="event-banner-icon">📅</div><div class="event-banner-content">' +
label +
'<div class="event-banner-title">' + ev.title + ' →' + sourceBadge + accessBadge + '</div>' +
'<div class="event-banner-meta"><span>📆 ' + ev.date + ' (' + ev.day + ')</span>' + timePart + locPart + '</div>' +
'</div></div>' +
'<div class="event-banner-bottom">' +
'<div class="event-banner-attendees">👥 Zapisanych: ' + cnt + ' ' + cntWord + '</div>' +
'<div class="event-banner-action">' + actionHtml + '</div>' +
'</div></a>';
}).join('');
});
}
// 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();
}
});
// ZOPK "Czy wiesz, że..." — load more facts
let zopkOffset = 3;
async function loadMoreFacts() {
const btn = document.getElementById('zopkMoreBtn');
btn.textContent = 'Ładowanie...';
btn.disabled = true;
try {
const resp = await fetch('/api/zopk-facts?offset=' + zopkOffset);
const data = await resp.json();
if (data.facts && data.facts.length > 0) {
const grid = btn.closest('div[data-animate]').querySelector('[style*="display: grid"]');
const typeColors = {investment:'rgba(16,185,129,0.3)',event:'rgba(59,130,246,0.3)',decision:'rgba(245,158,11,0.3)',milestone:'rgba(139,92,246,0.3)'};
data.facts.forEach(f => {
const card = document.createElement('a');
card.href = f.source_url || '/zopk';
card.target = '_blank';
card.rel = 'noopener';
card.style.cssText = 'background:rgba(255,255,255,0.12);border-radius:var(--radius);padding:var(--spacing-md);text-decoration:none;color:white;display:block;transition:background 0.2s,transform 0.2s;cursor:pointer;opacity:0;';
card.onmouseover = function(){this.style.background='rgba(255,255,255,0.22)';this.style.transform='translateY(-2px)';};
card.onmouseout = function(){this.style.background='rgba(255,255,255,0.12)';this.style.transform='none';};
const color = typeColors[f.type] || 'rgba(255,255,255,0.2)';
card.innerHTML = '<span style="display:inline-block;padding:1px 8px;border-radius:4px;font-size:11px;font-weight:600;margin-bottom:var(--spacing-xs);background:'+color+';">'+f.type_label+'</span>'
+ '<p style="font-size:var(--font-size-sm);line-height:1.5;margin:0;opacity:0.95;">'+f.text+(f.text.length>=200?'...':'')+'</p>'
+ '<div style="font-size:11px;margin-top:var(--spacing-xs);opacity:0.7;">'+f.source_name+' &bull; '+f.source_date+'<span style="float:right;opacity:0.8;">Czytaj &rarr;</span></div>';
grid.appendChild(card);
requestAnimationFrame(() => { card.style.transition = 'opacity 0.4s'; card.style.opacity = '1'; });
});
zopkOffset += data.facts.length;
if (!data.has_more) {
btn.textContent = 'To wszystko';
btn.disabled = true;
btn.style.opacity = '0.5';
} else {
btn.textContent = 'Pokaż więcej';
btn.disabled = false;
}
} else {
btn.textContent = 'Brak więcej faktów';
btn.disabled = true;
btn.style.opacity = '0.5';
}
} catch(e) {
btn.textContent = 'Pokaż więcej';
btn.disabled = false;
}
}
{% endblock %}