diff --git a/CLAUDE.md b/CLAUDE.md
index 4fcbdfb..29dab25 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -25,6 +25,7 @@ SoftSPM is a Polish software house (**25+ years in IT**) that has served the wat
- **Offer card order is fixed**: aplikacje pod klucz, konsulting, szkolenia, webinaria.
- The **ecosystem is a headline theme**, not a footnote. `index.html#ekosystem` carries it with a diagram and three "loop" cards.
- **[wodociagowiec.pl](https://wodociagowiec.pl/)** - trade portal: articles, cases, regulatory analysis, reader Q&A. Reader questions become webinar topics.
+ - **[akademiawodociagowca.pl](https://akademiawodociagowca.pl/)** - the **course platform**, added 2026-09-03. Live webinars, on-demand courses, tests, certificates, one company account. `index.html#platforma` (section 05) is its marketing home; it also feeds the schedule tables via its public API. Section 05 carries the "knowledge propagation" argument: taught first-hand, knowledge stays in-house, questions feed the programme.
- **[gieldakomunalna.pl](https://gieldakomunalna.pl/)** - a **new, free marketplace** for the municipal sector (owner brief, 2026-08-31). Two jobs, and copy must reflect both: (1) users **list, buy, sell and swap free of charge** the stock **sitting idle in their warehouses** - project surplus, equipment replaced in upgrades, spares for discontinued machines; (2) it **brings together manufacturers and contractors** in a genuinely **niche, specialist** trade where there may be only a handful nationwide. It is **not** a tender/RFQ portal - do not describe it as "zapytania ofertowe", "miejsce wymiany ofert" or a procurement channel.
- Content language is **Polish**, plus an English mirror of the homepage.
@@ -36,7 +37,7 @@ SoftSPM is a Polish software house (**25+ years in IT**) that has served the wat
| Page | Sections |
|---|---|
-| [index.html](index.html) | Hero · By the numbers (spec sheet) · Offer · Subject tracks · Upcoming dates · Course platform · **Ecosystem** · Testimonial · Newsletter |
+| [index.html](index.html) | Hero · By the numbers (spec sheet) · Offer · Subject tracks · Upcoming dates · **Akademia Wodociągowca** (05) · Turnkey software · **Ecosystem** · Testimonial · Newsletter |
| [szkolenia.html](szkolenia.html) | Hero · Training format (spec sheet) · Course catalogue (tracks 01-08 with prices, plus an RFQ workshop) · Schedule · Annual webinar package · Signup form |
| [konsulting.html](konsulting.html) | Hero · 7 service areas (02.7 is `.cell-span` full-width) · 3-step process · Deliverables + projects (spec sheet) · Team · Testimonial · Contact form |
| [index-en.html](index-en.html) | English mirror of the homepage - same eight tracks, same ecosystem loop cards; keep it in step when the Polish homepage changes |
@@ -47,6 +48,8 @@ The **eight** subject tracks on `index.html#tematy`, in fixed order: 01 water la
A **light wireframe/blueprint** aesthetic: light technical ground, a single steel-blue accent, square corners with registration-cross ("+") marks, cards drawn as line art with no fill, photos duotoned in the accent. Numbers and parameters are presented as **technical spec sheets** — a table with a title block carrying a drawing code, revision, and sheet number.
+Motion: entrance animations are opt-in via `data-reveal` + [assets/reveal.js](assets/reveal.js) (IntersectionObserver, counters in `.stats`). The hiding styles only apply once the script adds `.js-reveal` to ``, so content stays visible without JavaScript, and everything is disabled under `prefers-reduced-motion`. `assets/img/akademia.svg` animates itself: line-drawing via `pathLength="1"` plus dashoffset, a flowing arrow dash and a pulsing LIVE dot, all declared inside the file so they run from an ` `, with the same reduced-motion guard.
+
Rule: **one accent color only.** No extra decorative colors; hover/pressed states come from the tonal 100–900 ramp.
| Role | Value |
@@ -90,9 +93,8 @@ The spec lists these as unconfirmed — do not present them as fact, and flag th
- Stats in "Doświadczenie w liczbach" (480+ trained, 24 webinars/year, 60+ advisory projects) — sample values.
- Testimonials and quotes — samples, to be replaced with real references.
-- Course platform name and URL (the "Przejdź do platformy" links point at `#`).
- Course prices and the annual webinar package price.
-- **Training dates are deliberately absent.** The schedule tables carry a `.tag-soon` "Wkrótce" / "Soon" chip instead of a date, and the CTA is "Powiadom mnie" / "Notify me" — the site collects interest rather than taking bookings. Do not reintroduce concrete dates until real ones are confirmed; if they are, restore the `Data`/`Date` column heading and the "Zapisz się" CTAs together with them.
+- **The static schedule rows are the fallback, not the plan.** The `.tag-soon` "Wkrótce" / "Soon" rows in `index.html` (8) and `szkolenia.html` (11) show only when the Akademia API returns nothing or is unreachable — see **Schedule** below. Keep them plausible and on-topic; they are what visitors see when the platform has no open dates.
- Team member names — the consulting page lists teams, not people, pending real bios.
- Nothing else. The forms are live — see **Forms** below.
@@ -106,6 +108,12 @@ Known real details: SoftSPM · Ławy, 97-400 Bełchatów · info@softspm.com.
The folder also holds AI-generated photos from the previous direction (SCADA, control rooms, dashboards, blog imagery). **The built pages no longer reference any of them** — they belong to the dropped pump/monitoring theme. [images/README.md](images/README.md) is inherited from an even earlier "AquaNexis" branding and is stale; verify a file exists before referencing it.
+## Schedule
+
+The `04 · Harmonogram` tables in `index.html` and `szkolenia.html` are filled at runtime by [assets/schedule.js](assets/schedule.js) from [api/schedule.php](api/schedule.php), which proxies the **Akademia Wodociągowca** public API v1 (`/webinars` + `/courses`, `X-API-Key` header, docs at `akademiawodociagowca/docs/api_public_v1.html`). Key stays server-side, response cached 5 min. Setup is in [api/README.md](api/README.md).
+
+When live items arrive the script swaps the rows, the `Termin` → `Data` column heading, the section heading, the intro and the sheet note (`data-schedule-*` hooks mark all of them). When the API is empty, unreachable or unconfigured it does nothing, so the static rows in the HTML stay — never leave that table without plausible static rows.
+
## Forms
All four forms — newsletter (`index.html`), contact (`index-en.html`), training signup (`szkolenia.html`), consulting (`konsulting.html`) — post JSON to [api/send.php](api/send.php), which relays to `api.softspm.pl/send_email_api.php` ([doc/manual_send_email.html](doc/manual_send_email.html)). **Never call that API from the browser**: it authenticates with a Bearer key, and a key in client-side JS is a public key. The proxy keeps it server-side and pins the recipient to server config, so the endpoint cannot be used to mail arbitrary addresses.
diff --git a/api/README.md b/api/README.md
index 5f7d0c2..5ec9036 100644
--- a/api/README.md
+++ b/api/README.md
@@ -43,6 +43,30 @@ return [
];
```
+### Akademia Wodociągowca (harmonogram w sekcji 04)
+
+Tabela terminów na stronie głównej i na `szkolenia.html` pobiera webinary
+i kursy z publicznego API Akademii. Dopisz klucz wygenerowany w panelu
+Akademii (Ustawienia -> Klucze API - pokazywany tylko raz):
+
+```php
+// wariant ze stałymi
+define('SOFTSPM_AKADEMIA_API_KEY', 'tutaj_klucz_z_panelu_akademii');
+
+// wariant z tablicą
+'softspm_akademia_api_key' => 'tutaj_klucz_z_panelu_akademii',
+```
+
+Adres API domyślnie to `https://akademiawodociagowca.pl/api/public/v1`.
+Zmieniasz go tylko wtedy, gdy instancja stoi pod innym adresem:
+
+```php
+define('SOFTSPM_AKADEMIA_API_URL', 'https://inna-domena/api/public/v1');
+```
+
+Bez tego klucza nic się nie psuje: `api/schedule.php` zwraca
+`source: "error"`, a w tabeli zostają statyczne wiersze wpisane w HTML.
+
### Wpisy opcjonalne
```php
@@ -145,7 +169,44 @@ szablon endpointu nie zamienia `\n` na ` `. `send_email_api.php` jest wasz,
więc najczystszą poprawką jest tam jedno `nl2br()` na sanityzowanej treści -
nic po stronie tej strony nie wymaga wtedy zmian.
-## 5. Zabezpieczenia przed botami
+## 5. Harmonogram z Akademii Wodociągowca
+
+[schedule.php](schedule.php) pobiera `GET /webinars` i `GET /courses` z API
+Akademii (dokumentacja: `akademiawodociagowca/docs/api_public_v1.html`), scala
+je w jedną listę i oddaje stronie jako JSON. Klucz `X-API-Key` zostaje na
+serwerze - punkt 9 dokumentacji Akademii wprost tego wymaga.
+
+- **Cache:** odpowiedź leży w `api/storage/cache/schedule.json` przez
+ `akademia_cache_ttl` (domyślnie 300 s, zgodnie z zaleceniem 1-5 minut).
+ Pusty wynik i błąd trzymane są krócej, maksymalnie minutę.
+- **Kolejność:** webinary z terminem rosnąco, potem kursy na żądanie.
+- **Linki:** akceptujemy wyłącznie adresy prowadzące do domeny Akademii -
+ gdyby API zwróciło cokolwiek innego, link jest pomijany.
+- **Limit:** `?limit=` (1-40). Strona główna prosi o 8 pozycji,
+ `szkolenia.html` o 12.
+
+Odpowiedź ma pole `source`:
+
+| `source` | Znaczenie | Co widzi użytkownik |
+|---|---|---|
+| `api` | Są pozycje | Tabela, nagłówki i notatka podmienione na dane z Akademii; kolumna wraca z „Termin" na „Data" |
+| `empty` | API odpowiedziało pustą tablicą | Statyczne wiersze z HTML-a zostają nietknięte |
+| `error` | Brak klucza albo API nie odpowiada | Jak wyżej |
+
+Statyczne wiersze to pełnoprawny plan awaryjny: działają bez JavaScriptu,
+bez klucza i przy niedostępnym API. Strona nigdy nie pokazuje pustej tabeli.
+Są w `index.html` (8 pozycji) i `szkolenia.html` (11 pozycji) - jeśli chcesz
+zmienić te tematy zastępcze, edytujesz je wprost w HTML.
+
+Test:
+
+```bash
+curl -s "https://softspm.com/api/schedule.php?limit=5" | head -c 400
+```
+
+Oczekiwane: `{"ok":true,"source":"api",...}` z tablicą `items`.
+
+## 6. Zabezpieczenia przed botami
Suwak sam z siebie niczego nie gwarantuje - jest elementem interfejsu, a nie
kryptografią. Blokady, które faktycznie odsiewają automaty, działają na
@@ -166,7 +227,7 @@ Jeśli mimo to zacznie przechodzić spam, następnym krokiem jest Cloudflare
Turnstile albo hCaptcha - obie działają na tej samej zasadzie co obecny token,
więc podmiana dotknie tylko `send.php` i `assets/forms.js`.
-## 6. Test po wdrożeniu
+## 7. Test po wdrożeniu
```bash
# 1. Token - powinien zwrócić {"ok":true,"token":"t.szkolenia...."}
diff --git a/api/config.php b/api/config.php
index 31245d2..e7355ff 100644
--- a/api/config.php
+++ b/api/config.php
@@ -26,6 +26,14 @@ $config = [
// Dowolny losowy ciąg, min. 32 znaki. Zmiana unieważnia otwarte formularze.
'token_secret' => '',
+ // --- Akademia Wodociągowca: publiczne API v1 (webinary i kursy) --------
+ // Dokumentacja: akademiawodociagowca/docs/api_public_v1.html
+ // Klucz z panelu Akademii: Ustawienia -> Klucze API. Pokazywany raz.
+ 'akademia_api_url' => 'https://akademiawodociagowca.pl/api/public/v1',
+ 'akademia_api_key' => '',
+ // Bufor odpowiedzi w sekundach. Dokumentacja Akademii zaleca 1-5 minut.
+ 'akademia_cache_ttl' => 300,
+
// --- Adresat zgłoszeń --------------------------------------------------
// Ustawiany wyłącznie po stronie serwera. Nigdy nie pochodzi z formularza -
// inaczej endpoint stałby się otwartym przekaźnikiem spamu.
@@ -68,8 +76,10 @@ $config = [
// --- 1. Zmienne środowiskowe ------------------------------------------------
$fromEnv = [
- 'api_key' => getenv('SOFTSPM_MAIL_API_KEY'),
- 'token_secret' => getenv('SOFTSPM_FORM_SECRET'),
+ 'api_key' => getenv('SOFTSPM_MAIL_API_KEY'),
+ 'token_secret' => getenv('SOFTSPM_FORM_SECRET'),
+ 'akademia_api_key' => getenv('SOFTSPM_AKADEMIA_API_KEY'),
+ 'akademia_api_url' => getenv('SOFTSPM_AKADEMIA_API_URL'),
];
foreach ($fromEnv as $key => $value) {
if (is_string($value) && $value !== '') {
@@ -133,6 +143,19 @@ if (is_file($sharedPath) && is_readable($sharedPath)) {
}
}
+ // Klucz do publicznego API Akademii Wodociągowca.
+ if ($config['akademia_api_key'] === '') {
+ $value = $pick('SOFTSPM_AKADEMIA_API_KEY', 'softspm_akademia_api_key');
+ if (is_string($value)) {
+ $config['akademia_api_key'] = $value;
+ }
+ }
+ // Adres API Akademii - tylko gdy instancja stoi pod innym adresem.
+ $value = $pick('SOFTSPM_AKADEMIA_API_URL', 'softspm_akademia_api_url');
+ if (is_string($value)) {
+ $config['akademia_api_url'] = $value;
+ }
+
// Adresat zgłoszeń - jeden dla wszystkich formularzy.
$value = $pick('SOFTSPM_FORM_RECIPIENT', 'softspm_form_recipient');
if (is_string($value)) {
diff --git a/api/schedule.php b/api/schedule.php
new file mode 100644
index 0000000..abe713e
--- /dev/null
+++ b/api/schedule.php
@@ -0,0 +1,266 @@
+ {"ok":true,"source":"api|empty|error","items":[...]}
+ *
+ * source = "api" - są pozycje, strona podmienia tabelę
+ * source = "empty" - API odpowiedziało pustą tablicą
+ * source = "error" - API niedostępne albo brak klucza
+ * W obu ostatnich przypadkach strona zostawia statyczne wiersze z HTML-a.
+ */
+
+$config = require __DIR__ . '/config.php';
+
+header('Content-Type: application/json; charset=utf-8');
+header('X-Content-Type-Options: nosniff');
+
+function sched_respond(int $status, array $payload): void
+{
+ http_response_code($status);
+ echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ exit;
+}
+
+function sched_storage(array $config): string
+{
+ $root = rtrim((string) $config['storage_dir'], '/\\');
+ $dir = $root . '/cache';
+ if (!is_dir($dir)) {
+ @mkdir($dir, 0770, true);
+ }
+ $guard = $root . '/.htaccess';
+ if (!is_file($guard)) {
+ @file_put_contents($guard, "Require all denied\n\nDeny from all\n \n");
+ }
+
+ return $dir;
+}
+
+/** @return array{status:int, body:string} */
+function sched_get(string $url, string $key, int $timeout): array
+{
+ $headers = ['Accept: application/json', 'X-API-Key: ' . $key];
+
+ if (function_exists('curl_init')) {
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_HTTPHEADER => $headers,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_CONNECTTIMEOUT => 6,
+ CURLOPT_TIMEOUT => $timeout,
+ ]);
+ $body = curl_exec($ch);
+ $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+ curl_close($ch);
+
+ return ['status' => $status, 'body' => is_string($body) ? $body : ''];
+ }
+
+ $context = stream_context_create([
+ 'http' => [
+ 'method' => 'GET',
+ 'header' => implode("\r\n", $headers),
+ 'timeout' => $timeout,
+ 'ignore_errors' => true,
+ ],
+ ]);
+ $body = @file_get_contents($url, false, $context);
+ $status = 0;
+ foreach ($http_response_header ?? [] as $line) {
+ if (preg_match('#^HTTP/\S+\s+(\d{3})#', $line, $m) === 1) {
+ $status = (int) $m[1];
+ }
+ }
+
+ return ['status' => $status, 'body' => is_string($body) ? $body : ''];
+}
+
+function sched_text($value, int $max = 300): string
+{
+ if (!is_string($value)) {
+ return '';
+ }
+ $value = preg_replace('/[\x00-\x1F\x7F]/u', ' ', $value) ?? '';
+ $value = trim(preg_replace('/\s{2,}/u', ' ', $value) ?? '');
+
+ return mb_substr($value, 0, $max);
+}
+
+/** Link trafia na stronę, więc musi prowadzić do Akademii, nie gdziekolwiek. */
+function sched_url($value, array $allowedHosts): ?string
+{
+ if (!is_string($value) || $value === '') {
+ return null;
+ }
+ $parts = parse_url($value);
+ if (!is_array($parts) || !isset($parts['scheme'], $parts['host'])) {
+ return null;
+ }
+ if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
+ return null;
+ }
+ $host = strtolower($parts['host']);
+ foreach ($allowedHosts as $allowed) {
+ $allowed = strtolower((string) $allowed);
+ if ($allowed === '') {
+ continue;
+ }
+ if ($host === $allowed || substr($host, -strlen('.' . $allowed)) === '.' . $allowed) {
+ return $value;
+ }
+ }
+
+ return null;
+}
+
+// ---------------------------------------------------------------------------
+if (strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
+ sched_respond(405, ['ok' => false, 'source' => 'error', 'error' => 'method_not_allowed', 'items' => []]);
+}
+
+$limit = isset($_GET['limit']) ? (int) $_GET['limit'] : 12;
+$limit = max(1, min(40, $limit));
+
+$base = rtrim((string) $config['akademia_api_url'], '/');
+$key = (string) $config['akademia_api_key'];
+
+if ($base === '' || $key === '') {
+ sched_respond(200, ['ok' => true, 'source' => 'error', 'error' => 'not_configured', 'items' => []]);
+}
+
+$linkHosts = [];
+$hostFromBase = parse_url($base, PHP_URL_HOST);
+if (is_string($hostFromBase) && $hostFromBase !== '') {
+ $linkHosts[] = preg_replace('/^www\./', '', $hostFromBase);
+}
+
+// --- Cache ------------------------------------------------------------------
+// Dokumentacja Akademii zaleca buforowanie 1-5 minut; dane zmieniają się rzadko.
+$ttl = (int) $config['akademia_cache_ttl'];
+$dir = sched_storage($config);
+$cache = $dir . '/schedule.json';
+
+if ($ttl > 0 && is_file($cache) && (time() - (int) @filemtime($cache)) < $ttl) {
+ $raw = @file_get_contents($cache);
+ if (is_string($raw) && $raw !== '') {
+ $cached = json_decode($raw, true);
+ if (is_array($cached) && isset($cached['items']) && is_array($cached['items'])) {
+ $cached['items'] = array_slice($cached['items'], 0, $limit);
+ $cached['cached'] = true;
+ sched_respond(200, $cached);
+ }
+ }
+}
+
+// --- Pobranie danych --------------------------------------------------------
+$items = [];
+$failed = 0;
+
+$webinars = sched_get($base . '/webinars', $key, (int) $config['timeout']);
+if ($webinars['status'] === 200) {
+ $decoded = json_decode($webinars['body'], true);
+ if (is_array($decoded)) {
+ foreach ($decoded as $row) {
+ if (!is_array($row)) {
+ continue;
+ }
+ $title = sched_text($row['title'] ?? '', 160);
+ $start = sched_text($row['startsAt'] ?? '', 40);
+ if ($title === '' || $start === '') {
+ continue;
+ }
+ $capacity = isset($row['capacity']) && is_numeric($row['capacity']) ? (int) $row['capacity'] : null;
+ $registered = isset($row['registeredCount']) && is_numeric($row['registeredCount']) ? (int) $row['registeredCount'] : 0;
+
+ $items[] = [
+ 'kind' => 'webinar',
+ 'title' => $title,
+ 'startsAt' => $start,
+ 'endsAt' => sched_text($row['endsAt'] ?? '', 40),
+ 'timezone' => sched_text($row['timezone'] ?? '', 64),
+ 'url' => sched_url($row['registrationUrl'] ?? null, $linkHosts),
+ 'open' => !isset($row['registrationOpen']) || (bool) $row['registrationOpen'],
+ 'soldOut' => $capacity !== null && $registered >= $capacity,
+ 'seatsLeft' => $capacity !== null ? max(0, $capacity - $registered) : null,
+ ];
+ }
+ }
+} else {
+ $failed++;
+}
+
+$courses = sched_get($base . '/courses', $key, (int) $config['timeout']);
+if ($courses['status'] === 200) {
+ $decoded = json_decode($courses['body'], true);
+ if (is_array($decoded)) {
+ foreach ($decoded as $row) {
+ if (!is_array($row)) {
+ continue;
+ }
+ $title = sched_text($row['title'] ?? '', 160);
+ if ($title === '') {
+ continue;
+ }
+ $level = strtoupper(sched_text($row['level'] ?? '', 20));
+ $items[] = [
+ 'kind' => 'course',
+ 'title' => $title,
+ 'subtitle' => sched_text($row['subtitle'] ?? '', 160),
+ 'category' => sched_text($row['category'] ?? '', 80),
+ 'level' => in_array($level, ['BASIC', 'INTERMEDIATE', 'ADVANCED'], true) ? $level : null,
+ 'minutes' => isset($row['durationMinutes']) && is_numeric($row['durationMinutes']) ? (int) $row['durationMinutes'] : null,
+ 'free' => !empty($row['free']),
+ 'url' => sched_url($row['courseUrl'] ?? null, $linkHosts),
+ ];
+ }
+ }
+} else {
+ $failed++;
+}
+
+// Webinary z terminem idą pierwsze i rosnąco, kursy na żądanie na końcu.
+usort($items, static function (array $a, array $b): int {
+ $aDated = $a['kind'] === 'webinar';
+ $bDated = $b['kind'] === 'webinar';
+ if ($aDated !== $bDated) {
+ return $aDated ? -1 : 1;
+ }
+ if ($aDated) {
+ return strcmp((string) $a['startsAt'], (string) $b['startsAt']);
+ }
+
+ return 0;
+});
+
+$source = 'api';
+if ($items === []) {
+ $source = $failed > 0 ? 'error' : 'empty';
+}
+
+$payload = [
+ 'ok' => true,
+ 'source' => $source,
+ 'fetchedAt' => gmdate('c'),
+ 'items' => $items,
+];
+
+if ($ttl > 0) {
+ @file_put_contents($cache, json_encode($payload, JSON_UNESCAPED_UNICODE), LOCK_EX);
+ // Pusty wynik i błąd trzymamy krócej, żeby świeże pozycje pojawiły się szybko.
+ if ($source !== 'api') {
+ $short = min($ttl, 60);
+ @touch($cache, time() - ($ttl - $short));
+ }
+}
+
+$payload['items'] = array_slice($payload['items'], 0, $limit);
+sched_respond(200, $payload);
diff --git a/assets/img/akademia.svg b/assets/img/akademia.svg
new file mode 100644
index 0000000..ce81fb8
--- /dev/null
+++ b/assets/img/akademia.svg
@@ -0,0 +1,130 @@
+
+Akademia Wodociągowca - obieg kompetencji: praktyka wdrożeniowa i doradcza zasila platformę kursową, a wyszkolone zespoły wracają z pytaniami z rynku
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ WDROŻENIA U KLIENTÓW
+ PROJEKTY DORADCZE
+ PYTANIA CZYTELNIKÓW
+
+CO ROBIMY
+
+
+
+
+
+
+
+
+
+
+
+
+AKADEMIA WODOCIĄGOWCA
+
+
+
+
+
+ LIVE
+
+
+
+
+
+
+
+
+WEBINARIA · KURSY · CERTYFIKATY
+
+
+
+WIEDZA
+
+
+
+ZESPOŁY W FIRMACH
+
+
+
+
+
+
+
+
+
+
+
+
+
+ CERTYFIKAT
+
+
+
+
+
+PYTANIA WRACAJĄ DO PROGRAMU
+
+
+
+
+
+
+
+
+
diff --git a/assets/img/en/akademia.svg b/assets/img/en/akademia.svg
new file mode 100644
index 0000000..03e382e
--- /dev/null
+++ b/assets/img/en/akademia.svg
@@ -0,0 +1,130 @@
+
+Akademia Wodociagowca - the competence loop: our delivery and advisory practice feeds the course platform, and trained teams come back with questions from the market
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ CLIENT ROLLOUTS
+ ADVISORY PROJECTS
+ READER QUESTIONS
+
+WHAT WE DO
+
+
+
+
+
+
+
+
+
+
+
+
+AKADEMIA WODOCIAGOWCA
+
+
+
+
+
+ LIVE
+
+
+
+
+
+
+
+
+WEBINARS · COURSES · CERTIFICATES
+
+
+
+KNOW-HOW
+
+
+
+TEAMS AT UTILITIES
+
+
+
+
+
+
+
+
+
+
+
+
+
+ CERTIFICATE
+
+
+
+
+
+QUESTIONS FEED THE PROGRAMME
+
+
+
+
+
+
+
+
+
diff --git a/assets/reveal.js b/assets/reveal.js
new file mode 100644
index 0000000..9d05c47
--- /dev/null
+++ b/assets/reveal.js
@@ -0,0 +1,55 @@
+/* ============================================================================
+ SoftSPM - wejście bloków i liczniki
+ Delikatne pojawianie się sekcji przy przewijaniu oraz naliczanie liczb
+ w pasku Akademii. Wszystko wyłącza się przy prefers-reduced-motion,
+ a bez JavaScriptu treść i tak jest widoczna - styl startowy nakładamy
+ dopiero po dodaniu klasy js-reveal.
+ ========================================================================= */
+(function () {
+ 'use strict';
+
+ var supported = 'IntersectionObserver' in window && 'classList' in document.documentElement;
+ var reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+ if (!supported || reduced) {
+ return;
+ }
+
+ document.documentElement.classList.add('js-reveal');
+
+ function countUp(node) {
+ var target = parseInt(node.getAttribute('data-count'), 10);
+ if (isNaN(target)) { return; }
+ var suffix = node.getAttribute('data-suffix') || '';
+ var duration = 900;
+ var started = null;
+
+ function step(now) {
+ if (started === null) { started = now; }
+ var progress = Math.min(1, (now - started) / duration);
+ // wyhamowanie na końcu - liczba dobija do wartości, nie przeskakuje
+ var eased = 1 - Math.pow(1 - progress, 3);
+ node.textContent = Math.round(target * eased) + suffix;
+ if (progress < 1) { requestAnimationFrame(step); }
+ }
+
+ node.textContent = '0' + suffix;
+ requestAnimationFrame(step);
+ }
+
+ var observer = new IntersectionObserver(function (entries) {
+ for (var i = 0; i < entries.length; i++) {
+ var entry = entries[i];
+ if (!entry.isIntersecting) { continue; }
+ var node = entry.target;
+ node.classList.add('is-in');
+ observer.unobserve(node);
+
+ var counters = node.querySelectorAll('[data-count]');
+ for (var c = 0; c < counters.length; c++) { countUp(counters[c]); }
+ }
+ }, { rootMargin: '0px 0px -12% 0px', threshold: 0.12 });
+
+ var blocks = document.querySelectorAll('[data-reveal]');
+ for (var i = 0; i < blocks.length; i++) { observer.observe(blocks[i]); }
+})();
diff --git a/assets/schedule.js b/assets/schedule.js
new file mode 100644
index 0000000..9420a05
--- /dev/null
+++ b/assets/schedule.js
@@ -0,0 +1,208 @@
+/* ============================================================================
+ SoftSPM - harmonogram z Akademii Wodociągowca
+ Podmienia wiersze tabeli terminów danymi z api/schedule.php.
+ Gdy API nie odpowiada albo nie ma pozycji, statyczne wiersze z HTML-a
+ zostają nietknięte - strona nigdy nie pokazuje pustej tabeli.
+ ========================================================================= */
+(function () {
+ 'use strict';
+
+ var DICT = {
+ pl: {
+ dateHead: 'Data',
+ webinar: 'Webinar',
+ training: 'Szkolenie',
+ online: 'Online',
+ elearning: 'Online · e-learning',
+ onDemand: 'Na żądanie',
+ signUp: 'Zapisz się →',
+ viewCourse: 'Zobacz kurs →',
+ soldOut: 'Brak miejsc',
+ soon: 'Zapisy wkrótce',
+ note: 'Terminy pobierane na bieżąco z platformy kursowej Akademia Wodociągowca. Zapisy prowadzi Akademia.',
+ plateTitle: 'Terminy i kursy otwarte',
+ heading: 'Najbliższe szkolenia i webinaria',
+ intro: 'Poniżej pozycje otwarte w tej chwili na naszej platformie kursowej. Zapisy prowadzi Akademia Wodociągowca.',
+ locale: 'pl-PL',
+ levels: { BASIC: 'podstawowy', INTERMEDIATE: 'średni', ADVANCED: 'zaawansowany' }
+ },
+ en: {
+ dateHead: 'Date',
+ webinar: 'Webinar',
+ training: 'Training',
+ online: 'Online',
+ elearning: 'Online · e-learning',
+ onDemand: 'On demand',
+ signUp: 'Sign up →',
+ viewCourse: 'View course →',
+ soldOut: 'Fully booked',
+ soon: 'Registration soon',
+ note: 'Dates are pulled live from our Akademia Wodociagowca course platform, which also handles registration.',
+ plateTitle: 'Open dates and courses',
+ heading: 'Upcoming training and webinars',
+ intro: 'Everything currently open on our course platform. Registration is handled by Akademia Wodociagowca.',
+ locale: 'en-GB',
+ levels: { BASIC: 'basic', INTERMEDIATE: 'intermediate', ADVANCED: 'advanced' }
+ }
+ };
+
+ var lang = (document.documentElement.getAttribute('lang') || 'pl').slice(0, 2).toLowerCase();
+ var t = DICT[lang] || DICT.pl;
+
+ function el(tag, className, text) {
+ var node = document.createElement(tag);
+ if (className) { node.className = className; }
+ if (text !== undefined && text !== null) { node.textContent = text; }
+ return node;
+ }
+
+ function formatDate(iso, timezone) {
+ var date = new Date(iso);
+ if (isNaN(date.getTime())) { return null; }
+ var zone = timezone || 'Europe/Warsaw';
+ var opts = { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: zone };
+ var timeOpts = { hour: '2-digit', minute: '2-digit', timeZone: zone };
+ try {
+ return {
+ day: new Intl.DateTimeFormat(t.locale, opts).format(date),
+ hour: new Intl.DateTimeFormat(t.locale, timeOpts).format(date)
+ };
+ } catch (e) {
+ // Nieznana strefa albo stary silnik - pokazujemy datę bez strefy.
+ return { day: date.toLocaleDateString(t.locale), hour: date.toLocaleTimeString(t.locale) };
+ }
+ }
+
+ function actionCell(item) {
+ var cell = el('td', 's-act');
+ if (item.kind === 'webinar' && item.soldOut) {
+ cell.appendChild(el('span', 'text-muted', t.soldOut));
+ return cell;
+ }
+ if (item.kind === 'webinar' && item.open === false) {
+ cell.appendChild(el('span', 'text-muted', t.soon));
+ return cell;
+ }
+ if (!item.url) {
+ cell.appendChild(el('span', 'text-muted', item.kind === 'webinar' ? t.soon : ''));
+ return cell;
+ }
+ var link = el('a', 'sign', item.kind === 'webinar' ? t.signUp : t.viewCourse);
+ link.href = item.url;
+ link.target = '_blank';
+ link.rel = 'noopener';
+ cell.appendChild(link);
+ return cell;
+ }
+
+ function buildRow(item) {
+ var row = document.createElement('tr');
+
+ var date = el('td', 's-date');
+ if (item.kind === 'webinar') {
+ var parts = formatDate(item.startsAt, item.timezone);
+ if (parts) {
+ date.appendChild(document.createTextNode(parts.day));
+ date.appendChild(el('span', 's-hour', parts.hour));
+ } else {
+ date.appendChild(el('span', 'tag tag-soon', t.onDemand));
+ }
+ } else {
+ date.appendChild(el('span', 'tag tag-soon', t.onDemand));
+ }
+ row.appendChild(date);
+
+ // textContent, nie innerHTML - tytuły przychodzą z innego systemu.
+ row.appendChild(el('td', 's-topic', item.title));
+
+ var form = el('td', 's-form');
+ form.appendChild(el(
+ 'span',
+ item.kind === 'webinar' ? 'tag tag-outline' : 'tag tag-accent',
+ item.kind === 'webinar' ? t.webinar : t.training
+ ));
+ row.appendChild(form);
+
+ var place = item.kind === 'webinar' ? t.online : t.elearning;
+ if (item.kind === 'course' && item.level && t.levels[item.level]) {
+ place = t.elearning + ' · ' + t.levels[item.level];
+ }
+ row.appendChild(el('td', 's-place', place));
+
+ row.appendChild(actionCell(item));
+ return row;
+ }
+
+ function render(table, items) {
+ var body = table.querySelector('tbody');
+ if (!body) { return; }
+
+ var fresh = document.createDocumentFragment();
+ for (var i = 0; i < items.length; i++) {
+ fresh.appendChild(buildRow(items[i]));
+ }
+ body.innerHTML = '';
+ body.appendChild(fresh);
+
+ // Mamy realne daty, więc nagłówek kolumny wraca z "Termin" na "Data".
+ var head = table.querySelector('[data-schedule-datehead]');
+ if (head) { head.textContent = t.dateHead; }
+
+ // Przy realnych terminach nagłówki mówiące "ustalamy terminy" przestają
+ // być prawdziwe, więc lecą razem z wierszami.
+ var section = table.closest ? table.closest('section') : null;
+ if (section) {
+ var heading = section.querySelector('[data-schedule-heading]');
+ if (heading) { heading.textContent = t.heading; }
+ var intro = section.querySelector('[data-schedule-intro]');
+ if (intro) { intro.textContent = t.intro; }
+ }
+
+ var plate = table.closest ? table.closest('.plate') : null;
+ var plateTitle = plate ? plate.querySelector('[data-schedule-title]') : null;
+ if (plateTitle) { plateTitle.textContent = t.plateTitle; }
+
+ var note = plate ? plate.querySelector('[data-schedule-note]') : null;
+ if (note) {
+ note.textContent = t.note + ' ';
+ var link = el('a', null, 'akademiawodociagowca.pl');
+ link.href = 'https://akademiawodociagowca.pl/';
+ link.target = '_blank';
+ link.rel = 'noopener';
+ note.appendChild(link);
+ }
+ table.setAttribute('data-schedule-state', 'live');
+ }
+
+ function boot() {
+ var tables = document.querySelectorAll('table[data-schedule]');
+ if (!tables.length) { return; }
+
+ var limit = 0;
+ for (var i = 0; i < tables.length; i++) {
+ limit = Math.max(limit, parseInt(tables[i].getAttribute('data-schedule-limit'), 10) || 12);
+ }
+
+ fetch('api/schedule.php?limit=' + limit, {
+ credentials: 'same-origin',
+ headers: { 'Accept': 'application/json' }
+ })
+ .then(function (response) { return response.json(); })
+ .then(function (data) {
+ if (!data || data.source !== 'api' || !data.items || !data.items.length) { return; }
+ for (var i = 0; i < tables.length; i++) {
+ var own = parseInt(tables[i].getAttribute('data-schedule-limit'), 10) || data.items.length;
+ render(tables[i], data.items.slice(0, own));
+ }
+ })
+ .catch(function () {
+ // Cisza jest właściwą reakcją: w tabeli zostają statyczne wiersze.
+ });
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', boot);
+ } else {
+ boot();
+ }
+})();
diff --git a/assets/styles.css b/assets/styles.css
index cb89b3d..518d313 100644
--- a/assets/styles.css
+++ b/assets/styles.css
@@ -272,6 +272,11 @@ textarea.input { min-height: 110px; resize: vertical; }
.s-prop { font-weight: 500; }
.s-rem { font-size: 14px; color: color-mix(in srgb, var(--color-text) 68%, transparent); }
.s-date { white-space: nowrap; font-weight: 500; width: 1%; }
+/* godzina webinaru pod datą - pobierana z Akademii Wodociągowca */
+.s-hour {
+ display: block; font-weight: 400; font-size: 12.5px;
+ color: color-mix(in srgb, var(--color-text) 55%, transparent);
+}
.s-topic { font-family: var(--font-heading); font-weight: 600; font-size: 18px; }
.s-act { text-align: right; white-space: nowrap; }
.sign {
@@ -475,6 +480,35 @@ textarea.input { min-height: 110px; resize: vertical; }
color: var(--color-accent-700);
}
+/* -- pasek liczb (Akademia) --------------------------------------------- */
+.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: calc(1.5 * var(--leading)); }
+@media (max-width: 820px) { .stats { grid-template-columns: repeat(2, 1fr); } }
+.stat { padding-top: var(--space-4); border-top: 2px solid var(--color-accent); }
+.stat-num {
+ display: block;
+ font-family: var(--font-heading); font-weight: 700;
+ font-size: clamp(38px, 4.4vw, 52px); line-height: 1;
+ color: var(--color-accent-700);
+ font-feature-settings: "tnum" 1;
+}
+.stat-label {
+ display: block; margin-top: var(--space-3);
+ font-size: 13.5px; line-height: var(--half);
+ color: color-mix(in srgb, var(--color-text) 62%, transparent);
+}
+
+/* -- wejście bloków przy przewijaniu -----------------------------------
+ Styl startowy nakładamy dopiero, gdy skrypt zgłosi gotowość (klasa
+ js-reveal na ). Bez JavaScriptu treść jest widoczna od razu. */
+.js-reveal [data-reveal] {
+ opacity: 0; transform: translateY(16px);
+ transition: opacity .55s ease-out, transform .55s ease-out;
+}
+.js-reveal [data-reveal].is-in { opacity: 1; transform: none; }
+@media (prefers-reduced-motion: reduce) {
+ .js-reveal [data-reveal] { opacity: 1; transform: none; transition: none; }
+}
+
/* -- newsletter / CTA --------------------------------------------------- */
.cta-band {
position: relative; padding: calc(2 * var(--leading)) var(--space-8);
@@ -608,6 +642,8 @@ textarea.input { min-height: 110px; resize: vertical; }
}
.table.sched td { border: 0; padding: 0; }
.table.sched .s-date { order: 1; font-size: 14px; color: var(--color-accent-700); }
+ /* w karcie mobilnej data i godzina mieszczą się w jednej linii */
+ .table.sched .s-hour { display: inline; font-size: 13px; margin-left: var(--space-2); }
.table.sched .s-form { order: 2; text-align: right; }
.table.sched .s-topic { grid-column: 1 / -1; order: 3; }
.table.sched .s-place { order: 4; font-size: 13.5px; color: color-mix(in srgb, var(--color-text) 60%, transparent); }
diff --git a/index-en.html b/index-en.html
index f1d35cf..043b1c7 100644
--- a/index-en.html
+++ b/index-en.html
@@ -4,7 +4,7 @@
SoftSPM - training, consulting and custom software for the water sector
-
+
@@ -16,7 +16,7 @@
Offer
Topics
Dates
- Platform
+ Academy
Software
Ecosystem
PL
@@ -30,8 +30,8 @@
-
Water sector know-how since 2014.
-
Training, webinars, consulting and turnkey software for municipal water and wastewater utilities. We teach what we do ourselves - we have been building software for over 25 years, and doing it for the water sector since 2014. We run the Wodociagowiec.pl portal and Gielda Komunalna , a free marketplace where the sector buys, sells and swaps equipment.
+
Water sector know-how that stays in-house.
+
Turnkey software , consulting, training and webinars for municipal water and wastewater utilities. Over 25 years in IT, in the water sector since 2014. We do not leave a slide deck to gather dust: what stays behind is a working system, a list of decisions to take, or competence that keeps working once we are gone.
See the programme
Book a consultation
@@ -71,7 +71,7 @@
03 Specialists trained 480+ Classroom and online
04 Webinars per year 24 On our own course platform
05 Advisory projects 60+ Tariffs, organisation, procurement, digitisation
-
06 Ecosystem reach 2 services Wodociagowiec.pl trade portal and Gielda Komunalna marketplace
+
06 Ecosystem reach 3 services Wodociagowiec.pl, Gielda Komunalna and Akademia Wodociagowca
Sample figures - to be confirmed before publication.
@@ -270,14 +270,69 @@
-
+