Strony artykułów, sitemap, GA4, polityka prywatności

- Klikalne kafelki i slideshow → /blog/[slug]
- Strona artykułu: cover fullwidth z parallax, tytuł nad obrazkiem
- Schema.org BlogPosting na stronach postów
- Dynamiczny sitemap.xml (artykuły + produkty z Directus, revalidate=0)
- Google Analytics GA4 (G-7067BL7TDN)
- Polityka prywatności /polityka-prywatnosci
- CookieBanner z linkiem do polityki
- Footer: działający link do polityki prywatności

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paweł Domański
2026-07-05 19:49:19 +02:00
parent eb09da9e4d
commit 8d4dc8a6f1
12 changed files with 382 additions and 18 deletions
+8 -9
View File
@@ -1,21 +1,20 @@
{ {
"name": "bento-website", "name": "bento-website",
"private": true, "private": true,
"version": "0.0.0", "version": "0.1.0",
"type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "next dev",
"build": "vite build", "build": "next build",
"preview": "vite preview" "start": "next start"
}, },
"dependencies": { "dependencies": {
"next": "^14.2.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0" "react-dom": "^18.2.0",
"react-markdown": "^10.1.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.66", "@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22", "@types/react-dom": "^18.2.22"
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.2.0"
} }
} }
+76
View File
@@ -0,0 +1,76 @@
import { notFound } from 'next/navigation'
import { fetchArticle } from '../../../lib/directus'
const DIRECTUS_URL = 'https://cms.dexterlab.pl'
const BASE_URL = 'https://labai.pro'
function blogPostingSchema(article) {
return {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
'@id': `${BASE_URL}/blog/${article.Slug}`,
headline: article.Title,
url: `${BASE_URL}/blog/${article.Slug}`,
description: article.Excerpt || undefined,
datePublished: article.date_published || article.date_created,
dateModified: article.date_updated || article.date_published || article.date_created,
image: article.Cover_Image ? `${DIRECTUS_URL}/assets/${article.Cover_Image}` : undefined,
author: article.Author
? { '@type': 'Person', name: article.Author }
: { '@type': 'Organization', name: 'Laboratorium AI' },
publisher: {
'@type': 'Organization',
'@id': `${BASE_URL}/#organization`,
name: 'Laboratorium AI',
},
mainEntityOfPage: { '@type': 'WebPage', '@id': `${BASE_URL}/blog/${article.Slug}` },
}
}
export const revalidate = 0
export async function generateMetadata({ params }) {
const article = await fetchArticle(params.slug)
if (!article) return {}
return {
title: `${article.Title} — Laboratorium AI`,
description: article.Excerpt || article.Title,
openGraph: {
title: article.Title,
images: article.Cover_Image ? [`${DIRECTUS_URL}/assets/${article.Cover_Image}`] : [],
},
}
}
export default async function ArticlePage({ params }) {
const article = await fetchArticle(params.slug)
if (!article) notFound()
return (
<article className="article-page">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(blogPostingSchema(article)) }}
/>
<div
className="article-cover-parallax"
style={article.Cover_Image ? { backgroundImage: `url(${DIRECTUS_URL}/assets/${article.Cover_Image})` } : {}}
>
<div className="article-header">
<div className="article-meta-row">
{article.Badge && <span className={`bento-badge badge-${article.Badge.toLowerCase()}`}>{article.Badge}</span>}
{article.Category && <span className="article-category">{article.Category}</span>}
</div>
<h1>{article.Title}</h1>
</div>
</div>
{article.Content && (
<div className="article-body">
<div className="article-content">
<div dangerouslySetInnerHTML={{ __html: article.Content }} />
</div>
</div>
)}
</article>
)
}
+2
View File
@@ -34,6 +34,8 @@ export default function RootLayout({ children }) {
<Footer /> <Footer />
<CookieBanner /> <CookieBanner />
<Script src="https://stat.dexterlab.pl/api/script.js" data-site-id="e7d7e9823a23" strategy="afterInteractive" /> <Script src="https://stat.dexterlab.pl/api/script.js" data-site-id="e7d7e9823a23" strategy="afterInteractive" />
<Script src="https://www.googletagmanager.com/gtag/js?id=G-7067BL7TDN" strategy="afterInteractive" />
<Script id="ga4" strategy="afterInteractive">{`window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag('js',new Date());gtag('config','G-7067BL7TDN');`}</Script>
<Script id="libredesk-config" strategy="beforeInteractive">{`window.LibredeskSettings = { baseURL: 'https://pomoc.dexterlab.pl', inboxID: 'bffd72d7-a528-42ca-bfed-377670d49364' };`}</Script> <Script id="libredesk-config" strategy="beforeInteractive">{`window.LibredeskSettings = { baseURL: 'https://pomoc.dexterlab.pl', inboxID: 'bffd72d7-a528-42ca-bfed-377670d49364' };`}</Script>
<Script src="https://pomoc.dexterlab.pl/widget.js" strategy="afterInteractive" /> <Script src="https://pomoc.dexterlab.pl/widget.js" strategy="afterInteractive" />
</body> </body>
+1
View File
@@ -49,6 +49,7 @@ export default async function HomePage() {
bgImage={article.bgImage} bgImage={article.bgImage}
hero={article.hero} hero={article.hero}
style={{ gridColumn: article.gridColumn, gridRow: article.gridRow }} style={{ gridColumn: article.gridColumn, gridRow: article.gridRow }}
href={article.slug ? `/blog/${article.slug}` : undefined}
/> />
))} ))}
</> </>
+57
View File
@@ -0,0 +1,57 @@
export const metadata = {
title: 'Polityka Prywatności — Laboratorium AI',
description: 'Informacje o przetwarzaniu danych osobowych i plikach cookie w serwisie Laboratorium AI.',
}
export default function PolitykaPrywatnosci() {
return (
<div className="article-page">
<div className="article-body" style={{ paddingTop: '6rem' }}>
<h1 style={{ fontSize: '2.5rem', fontWeight: 800, marginBottom: '2rem' }}>Polityka Prywatności</h1>
<div className="article-content">
<p>Ostatnia aktualizacja: 5 lipca 2026</p>
<h2>1. Administrator danych</h2>
<p>Administratorem danych osobowych jest DexterLab, prowadzący serwis <strong>labai.pro</strong>. Kontakt: <a href="mailto:dexterteam@gmail.com">dexterteam@gmail.com</a></p>
<h2>2. Jakie dane zbieramy</h2>
<p>Zbieramy wyłącznie dane anonimowe lub podane dobrowolnie:</p>
<ul>
<li>Dane analityczne (anonimowe) adres IP w formie skróconej, przeglądarka, strony odwiedzane</li>
<li>Dane podane w formularzu kontaktowym imię, adres e-mail, treść wiadomości</li>
<li>Dane podane przy zapisie do newslettera adres e-mail</li>
</ul>
<h2>3. Pliki cookie</h2>
<p>Używamy następujących plików cookie:</p>
<ul>
<li><strong>Niezbędne</strong> zapamiętanie zgody na cookie (<code>dexos-cookie-consent</code>)</li>
<li><strong>Analityczne</strong> Google Analytics 4 (identyfikator: G-7067BL7TDN) oraz Umami Analytics. Dane anonimizowane przed wysłaniem.</li>
<li><strong>Funkcjonalne</strong> Libredesk (widget czatu), aktywowany wyłącznie po Twojej interakcji</li>
</ul>
<p>Możesz odrzucić cookie analityczne przy pierwszym wejściu na stronę lub usunąć je z ustawień przeglądarki.</p>
<h2>4. Podstawa prawna przetwarzania</h2>
<ul>
<li>Zgoda (art. 6 ust. 1 lit. a RODO) cookie analityczne i marketingowe</li>
<li>Prawnie uzasadniony interes (art. 6 ust. 1 lit. f RODO) bezpieczeństwo serwisu, cookie niezbędne</li>
<li>Wykonanie umowy / działania przedumowne formularz kontaktowy, newsletter</li>
</ul>
<h2>5. Przekazywanie danych</h2>
<p>Dane analityczne przekazywane do Google LLC (USA) na podstawie standardowych klauzul umownych. Google Analytics jest skonfigurowany z anonimizacją IP. Więcej: <a href="https://policies.google.com/privacy" target="_blank" rel="noopener">policies.google.com/privacy</a></p>
<h2>6. Twoje prawa</h2>
<p>Przysługuje Ci prawo do: dostępu do danych, sprostowania, usunięcia, ograniczenia przetwarzania, przenoszenia danych, wniesienia sprzeciwu oraz skargi do Prezesa UODO (<a href="https://uodo.gov.pl" target="_blank" rel="noopener">uodo.gov.pl</a>).</p>
<h2>7. Okres przechowywania</h2>
<p>Dane analityczne przechowywane przez 14 miesięcy. Dane z formularza kontaktowego do czasu zakończenia korespondencji, nie dłużej niż 3 lata.</p>
<h2>8. Kontakt</h2>
<p>W sprawach dotyczących prywatności pisz na: <a href="mailto:dexterteam@gmail.com">dexterteam@gmail.com</a></p>
</div>
</div>
</div>
)
}
+50
View File
@@ -0,0 +1,50 @@
export const revalidate = 0
const BASE_URL = 'https://labai.pro'
const DIRECTUS = 'https://cms.dexterlab.pl'
async function fetchSlugs() {
try {
const res = await fetch(
`${DIRECTUS}/items/LabAI?filter[status][_eq]=published&fields=Slug,date_updated&limit=-1`
)
return res.ok ? (await res.json()).data ?? [] : []
} catch { return [] }
}
async function fetchProducts() {
try {
const res = await fetch(
`${DIRECTUS}/items/products?filter[site][_eq]=labai.pro&filter[status][_eq]=published&fields=id,date_updated&limit=-1`
)
return res.ok ? (await res.json()).data ?? [] : []
} catch { return [] }
}
export default async function sitemap() {
const [articles, products] = await Promise.all([fetchSlugs(), fetchProducts()])
const static_pages = [
{ url: `${BASE_URL}/`, lastModified: new Date(), changeFrequency: 'daily', priority: 1 },
{ url: `${BASE_URL}/produkty`, lastModified: new Date(), changeFrequency: 'weekly', priority: 0.8 },
{ url: `${BASE_URL}/kontakt`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.5 },
]
const article_pages = articles
.filter(a => a.Slug)
.map(a => ({
url: `${BASE_URL}/blog/${a.Slug}`,
lastModified: a.date_updated ? new Date(a.date_updated) : new Date(),
changeFrequency: 'weekly',
priority: 0.7,
}))
const product_pages = products.map(p => ({
url: `${BASE_URL}/produkty#${p.id}`,
lastModified: p.date_updated ? new Date(p.date_updated) : new Date(),
changeFrequency: 'monthly',
priority: 0.6,
}))
return [...static_pages, ...article_pages, ...product_pages]
}
+12 -4
View File
@@ -1,11 +1,12 @@
import React from 'react'; import React from 'react';
import Link from 'next/link';
const BentoCard = ({ type, badge, title, category, readTime, bgImage, hero = false, style = {} }) => { const BentoCard = ({ type, badge, title, category, readTime, bgImage, hero = false, style = {}, href }) => {
return ( const inner = (
<article <article
className={`bento-card${hero ? ' hero-article' : ''}`} className={`bento-card${hero ? ' hero-article' : ''}`}
style={style} style={href ? {} : style}
tabIndex={0} tabIndex={href ? -1 : 0}
aria-label={`${category}: ${title}`} aria-label={`${category}: ${title}`}
> >
<div <div
@@ -32,6 +33,13 @@ const BentoCard = ({ type, badge, title, category, readTime, bgImage, hero = fal
</div> </div>
</article> </article>
); );
if (href) return (
<Link href={href} style={style} className="bento-card-link">
{inner}
</Link>
);
return inner;
}; };
export default BentoCard; export default BentoCard;
+10 -2
View File
@@ -1,4 +1,6 @@
import React, { useEffect, useState } from 'react'; 'use client'
import { useEffect, useState } from 'react';
import Link from 'next/link';
const BentoSlideshow = ({ slides, style = {} }) => { const BentoSlideshow = ({ slides, style = {} }) => {
const [current, setCurrent] = useState(0); const [current, setCurrent] = useState(0);
@@ -40,7 +42,13 @@ const BentoSlideshow = ({ slides, style = {} }) => {
aria-hidden={index !== current} aria-hidden={index !== current}
> >
<div className={`bento-badge badge-${slide.type}`}>{slide.badge}</div> <div className={`bento-badge badge-${slide.type}`}>{slide.badge}</div>
<h2 className="article-title slideshow-title">{slide.title}</h2> {slide.slug ? (
<Link href={`/blog/${slide.slug}`} className="slideshow-title-link">
<h2 className="article-title slideshow-title">{slide.title}</h2>
</Link>
) : (
<h2 className="article-title slideshow-title">{slide.title}</h2>
)}
<div className="article-meta"> <div className="article-meta">
<span>{slide.category}</span> <span>{slide.category}</span>
<span className="dot" aria-hidden="true" /> <span className="dot" aria-hidden="true" />
+6 -3
View File
@@ -1,9 +1,12 @@
import React, { useState } from 'react'; 'use client'
import { useState, useEffect } from 'react';
import Link from 'next/link';
const STORAGE_KEY = 'dexos-cookie-consent'; const STORAGE_KEY = 'dexos-cookie-consent';
const CookieBanner = () => { const CookieBanner = () => {
const [visible, setVisible] = useState(() => !localStorage.getItem(STORAGE_KEY)); const [visible, setVisible] = useState(false);
useEffect(() => { setVisible(!localStorage.getItem(STORAGE_KEY)); }, []);
const handleConsent = (accepted) => { const handleConsent = (accepted) => {
localStorage.setItem(STORAGE_KEY, accepted ? 'accepted' : 'rejected'); localStorage.setItem(STORAGE_KEY, accepted ? 'accepted' : 'rejected');
@@ -16,7 +19,7 @@ const CookieBanner = () => {
<div className="cookie-banner" role="dialog" aria-modal="true" aria-label="Zgoda na pliki cookie"> <div className="cookie-banner" role="dialog" aria-modal="true" aria-label="Zgoda na pliki cookie">
<div className="cookie-content"> <div className="cookie-content">
<h4>Ciasteczka</h4> <h4>Ciasteczka</h4>
<p>Używamy plików cookie, aby dostarczać spersonalizowane treści i analizować ruch na stronie.</p> <p>Używamy plików cookie do analizy ruchu i personalizacji treści. <Link href="/polityka-prywatnosci" style={{ color: 'var(--primary)' }}>Polityka prywatności</Link></p>
<div className="cookie-actions"> <div className="cookie-actions">
<button className="button-secondary" onClick={() => handleConsent(false)}> <button className="button-secondary" onClick={() => handleConsent(false)}>
Odrzuć Odrzuć
+48
View File
@@ -0,0 +1,48 @@
const Footer = () => (
<footer className="main-footer">
<div className="footer-container">
<div className="footer-brand">
<div className="nav-logo">
<span className="pulse-dot" aria-hidden="true"></span>
DEX_OS
</div>
<p>Zbudowane przez deweloperów sztucznej inteligencji, dla agentów przyszłości.</p>
<div className="footer-social" aria-label="Social media">
<a href="https://linkedin.com/company/dexterlab" rel="me noopener" target="_blank" aria-label="LinkedIn" className="footer-social__link">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
</a>
<a href="https://x.com/dexterlab_pl" rel="me noopener" target="_blank" aria-label="X (Twitter)" className="footer-social__link">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.748l7.73-8.835L1.254 2.25H8.08l4.261 5.636 5.903-5.636zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
</a>
<a href="https://facebook.com/dexterlab" rel="me noopener" target="_blank" aria-label="Facebook" className="footer-social__link">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
</a>
</div>
</div>
<div className="footer-links">
<div className="footer-col">
<h4>Platforma</h4>
<a href="#">Architektura</a>
<a href="#">Agent Swarms</a>
<a href="#">Bezpieczeństwo</a>
</div>
<div className="footer-col">
<h4>Firma</h4>
<a href="#">O nas</a>
<a href="#">Kariera</a>
<a href="/kontakt">Kontakt</a>
</div>
<div className="footer-col">
<h4>Zasady</h4>
<a href="#">Regulamin</a>
<a href="/polityka-prywatnosci">Polityka Prywatności</a>
</div>
</div>
</div>
<div className="footer-bottom">
<p>&copy; {new Date().getFullYear()} DEX Operating System. Wszelkie prawa zastrzeżone.</p>
</div>
</footer>
)
export default Footer
+99
View File
@@ -1580,3 +1580,102 @@ h1, h2, h3, h4, h5, h6 {
text-align: center; text-align: center;
} }
/* ── Strona artykułu ── */
.slideshow-title-link {
text-decoration: none;
color: inherit;
cursor: pointer;
}
.bento-card-link {
display: block;
text-decoration: none;
color: inherit;
cursor: pointer;
}
.bento-card-link .bento-card {
width: 100%;
height: 100%;
}
.article-page {
max-width: 100%;
margin: 0;
padding: 0;
}
.article-meta-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.article-meta-row .bento-badge {
position: static;
}
.article-category {
color: var(--text-muted);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.article-cover-parallax {
position: relative;
width: 100%;
height: 520px;
background-size: cover;
background-position: center;
background-attachment: fixed;
display: flex;
align-items: flex-end;
}
.article-header {
width: 100%;
padding: 2rem 1.5rem;
background: rgba(12, 12, 20, 0.8);
backdrop-filter: blur(4px);
}
.article-header > div,
.article-header h1 {
max-width: 780px;
margin-left: auto;
margin-right: auto;
}
.article-header h1 {
font-size: 3rem;
font-weight: 800;
color: var(--text-main);
margin-top: 0.75rem;
margin-bottom: 0;
line-height: 1.15;
}
.article-body {
max-width: 780px;
margin: 0 auto;
padding: 3rem 1.5rem 5rem;
}
.article-content {
color: var(--text-muted);
line-height: 1.8;
font-size: 1.05rem;
}
.article-content h2,
.article-content h3 {
color: var(--text-main);
margin: 2rem 0 0.75rem;
}
.article-content p { margin-bottom: 1.25rem; }
.article-content img { max-width: 100%; border-radius: 8px; margin: 1.5rem 0; }
.article-content a { color: var(--primary); }
+13
View File
@@ -12,6 +12,17 @@ export async function fetchLaboratoriumData() {
return result.data return result.data
} }
export async function fetchArticle(slug) {
const url = `${DIRECTUS_URL}/items/${COLLECTION}?filter[Slug][_eq]=${slug}&filter[status][_eq]=published&limit=1`
const headers = process.env.DIRECTUS_TOKEN
? { Authorization: `Bearer ${process.env.DIRECTUS_TOKEN}` }
: {}
const response = await fetch(url, { headers })
if (!response.ok) return null
const result = await response.json()
return result.data?.[0] ?? null
}
export function mapDirectusToContent(items) { export function mapDirectusToContent(items) {
if (!items || items.length === 0) return null if (!items || items.length === 0) return null
@@ -25,6 +36,7 @@ export function mapDirectusToContent(items) {
category: item.Category || 'AI', category: item.Category || 'AI',
readTime: '5 min read', readTime: '5 min read',
bgImage: item.Cover_Image ? `${DIRECTUS_URL}/assets/${item.Cover_Image}` : '', bgImage: item.Cover_Image ? `${DIRECTUS_URL}/assets/${item.Cover_Image}` : '',
slug: item.Slug || null,
})) }))
const slideshowTitles = new Set(slideshow.map(s => s.title)) const slideshowTitles = new Set(slideshow.map(s => s.title))
@@ -39,6 +51,7 @@ export function mapDirectusToContent(items) {
category: item.Category || 'AI', category: item.Category || 'AI',
readTime: '5 min read', readTime: '5 min read',
bgImage: item.Cover_Image ? `${DIRECTUS_URL}/assets/${item.Cover_Image}` : '', bgImage: item.Cover_Image ? `${DIRECTUS_URL}/assets/${item.Cover_Image}` : '',
slug: item.Slug || item.slug || null,
gridColumn: getGridColumn(index), gridColumn: getGridColumn(index),
gridRow: getGridRow(index), gridRow: getGridRow(index),
hero: index === 0, hero: index === 0,