
STYLMET — Centrum Balustrad
Strona firmy produkującej balustrady ze stali nierdzewnej i wypożyczalni sprzętu w Gorzowie Wielkopolskim.
Wdrożone i utrzymywane — stylmet.pl ·
- React
- TypeScript
- Vite
- SSG
- PHP
- Local SEO
Szybka, prerenderowana strona pod lokalne SEO: oferta, 132 realizacje, wypożyczalnia i wyceny.
Zrzuty ekranu
Pasek można przewijać w bok. Kliknięcie zrzutu powiększa go — dalej nawigacja strzałkami.Pełny opis
Nowa strona internetowa Centrum Balustrad STYLMET — warsztatu z Gorzowa Wielkopolskiego, który od 1993 roku robi balustrady, poręcze, ogrodzenia i konstrukcje ze stali nierdzewnej, a przy okazji wypożycza przyczepy, lawetę, podnośnik koszowy i rusztowanie. Zastąpiła stronę na WordPressie i Elementorze.
STRONA, KTÓRA MA DZWONIĆ
Klienci trafiają tu z lokalnego wyszukiwania i z wizytówki Google, najczęściej z telefonu. Dlatego numer telefonu jest w nagłówku na każdej stronie i przy każdej szerokości ekranu, a formularz wyceny jest jeden klik od każdego miejsca.
GOTOWY HTML DLA KAŻDEJ PODSTRONY
Wszystkie podstrony są generowane do statycznego HTML-a już przy budowaniu — z pełną treścią, danymi strukturalnymi i mapą strony. Wyszukiwarka nie musi uruchamiać JavaScriptu, a strona pojawia się szybko nawet na słabym zasięgu.
132 REALIZACJE ZE ZNAKIEM WODNYM
Galeria z filtrami i pełnoekranowym podglądem. Zdjęcia klienta przechodzą przez własny skrypt, który zmniejsza je do kilku rozmiarów i nakłada logo firmy.
WYPOŻYCZALNIA Z CENNIKIEM
Każda pozycja ma specyfikację, wymagane prawo jazdy, cennik netto i brutto oraz własną galerię. Ceny trafiają też do danych strukturalnych, więc Google może je pokazać w wynikach.
WYCENA ZE ZDJĘCIAMI
Formularz przyjmuje do trzech zdjęć albo PDF-ów i wysyła je mailem prosto do biura — bez reCAPTCHY i bez banera cookies.
DWA MOTYWY
Ciemny, z tłem ze stalowych profili, i jasny, ze szczotkowaną blachą. Oba zaprojektowane osobno, nie przez odwrócenie kolorów.
Technologie
Zakres prac: Realizacja w ramach GriffSoftware — implementacja frontendu, prerendering, endpoint formularzy w PHP, obróbka zdjęć, wdrożenie
- React 19
- TypeScript 6
- Vite 8
- Plain CSS + design tokens (dark / light)
- Hand-rolled router (~110 lines)
- PHP 8.1 enquiry endpoint (mail(), multipart attachments)
- Google Places API (live reviews)
- ESLint 10 + typescript-eslint
- tsc -b
- Prerender as a build gate
- Manual checks at 320–2560 px
- SSG prerender (renderToString) + hydration
- sharp image pipeline with watermarks
- JSON-LD, sitemap.xml, robots.txt
- Apache / LiteSpeed (.htaccess), dhosting.pl
Realizacja — architektura i technologie
8 min
Realizacja w jednym akapicie
STYLMET to rodzinny warsztat z Gorzowa Wielkopolskiego, który od ponad trzydziestu lat robi balustrady, poręcze, podjazdy i ogrodzenia ze stali nierdzewnej, a obok prowadzi małą wypożyczalnię: dwie przyczepy, lawetę, podnośnik koszowy 17,1 m i rusztowanie warszawskie. Poprzednia strona stała na WordPressie z Elementorem. Brief był krótki: strona ma przynosić lokalne zapytania — telefony i prośby o wycenę — od klientów prywatnych, wspólnot mieszkaniowych, firm budowlanych i instytucji w promieniu mniej więcej 40 km od Gorzowa. Wszystko, co opisuję niżej, wynika z tego jednego zdania. Od pierwszego commita do wdrożenia minęły niecałe dwa tygodnie, z kilkoma rundami poprawek od klienta po drodze.
Dwie zależności w runtime
Strona ma w dependencies dokładnie dwie paczki: react i react-dom. Nie ma React Routera — jest własny router w około 110 linijkach, który renderuje prawdziwe <a href> i przechwytuje tylko zwykłe kliknięcie lewym przyciskiem, więc linki działają bez JavaScriptu i są czytelne dla robotów. Nie ma biblioteki formularzy, walidacji, stanu globalnego ani klienta HTTP — w całej aplikacji jest jedno fetch. Ikony Phosphor są wklejane jako ścieżki SVG przez skrypt uruchamiany ręcznie, więc w paczce nie ma biblioteki ikon. To nie jest minimalizm dla zasady. Przy sześciu podstronach, które zmieniają się kilka razy w roku, każda zależność to kilobajty na telefonie klienta i kolejna rzecz do aktualizowania.
Prerendering zamiast SPA
npm run build robi cztery rzeczy po kolei: sprawdza typy, buduje paczkę dla przeglądarki, buduje drugą paczkę SSR dla Node i uruchamia prerender.mjs. Ten skrypt woła render(path) dla każdej trasy z siteConfig.routes, podstawia wynik do szablonu i zapisuje osobny index.html dla każdej podstrony, a na koniec generuje sitemap.xml i robots.txt. W przeglądarce hydrateRoot przejmuje gotowy HTML.
Każda podstrona trafia na serwer jako kompletny dokument — z <title>, opisem, canonicalem, Open Graph i jednym grafem JSON-LD (LocalBusiness, WebSite, WebPage, BreadcrumbList, a na wypożyczalni dodatkowo OfferCatalog z cenami sprzętu). Jedną rzecz celowo pominąłem: aggregateRating. Firma ma 5,0 w Google i strona to cytuje, ale oznaczanie w danych strukturalnych ocen zebranych na innej platformie to prosta droga do ręcznej kary od Google. Powód jest zapisany w komentarzu na górze pliku, żeby nikt później nie „naprawił” tego w dobrej wierze.
Stara strona miała sześć adresów w sitemapie. Cztery istnieją pod tymi samymi nazwami, a /galeria/ i /ogrodzenia-gorzow/ mają przekierowania 301 w .htaccess — pozycje w wyszukiwarce, na które firma pracowała latami, nie znikają razem z WordPressem.
Motyw, który rozstrzyga się przed Reactem
Projekt powstał jako ciemny — wszystkie makiety były ciemne — a jasny motyw jest jego osobno zaprojektowaną wersją dzienną, a nie odwróceniem kolorów. Wszystkie kolory, fonty, odstępy i cienie są w jednym pliku, colors.ts, który generuje custom properties dla obu motywów. W arkuszach CSS nie ma ani jednego literału koloru. Trik, dzięki któremu to działa: stopnie skali oznaczają odległość od tła, a nie jasność. 100 to zawsze najwyższy kontrast, 900 najbliżej tła, więc color: var(--color-neutral-300) jest tekstem drugorzędnym w obu motywach.
Motyw rozstrzyga mały skrypt w index.html, zanim React się załaduje: czyta localStorage i ustawia data-theme na <html>, więc prerenderowana strona od pierwszej klatki ma właściwe kolory. React odczytuje ten atrybut przez useSyncExternalStore. Dzięki temu w wygenerowanym HTML-u nie ma wartości, którą zna tylko przeglądarka, i hydracja się nie rozjeżdża.
Tło strony to zdjęcie stali: profile w motywie ciemnym i szczotkowana blacha w jasnym. Blachę rysuje osobny skrypt, bo zdjęcie z zawijasami po szlifowaniu, które było tam wcześniej, klient po prostu nie odczytywał jako metalu. Przezroczystość tła jest dobrana tak, żeby tekst drugorzędny zachował wymagany kontrast nawet na najjaśniejszym pikselu. Po zmianie pliku trzeba to zmierzyć od nowa.
132 zdjęcia i znak wodny, który nie znika przy kadrowaniu
Klient dostarczył kilkadziesiąt, a potem ponad sto zdjęć realizacji i poprosił o znak wodny. Skrypt process-images.mjs (sharp, uruchamiany ręcznie, nie w buildzie) czyta manifest ze slugami, kategoriami i tekstami alt, skaluje każde zdjęcie do kilku wariantów WebP i wypala logo w prawym dolnym rogu.
Problem pojawił się od razu: w galerii zdjęcia są pokazywane w całości, ale na kafelkach oferty i na stronie głównej są przycinane przez object-fit: cover — i wypalony róg po prostu znikał. Rozwiązanie to dwie drogi, których nie wolno mieszać. Galeria dostaje pliki z wypalonym znakiem. Wszystkie przycinane ramki dostają czyste kopie z katalogu kadr/ i klasę .watermarked, która rysuje ten sam znak w CSS w rogu ramki. Czyste kopie powstają tylko dla tych zdjęć, które faktycznie są użyte w kodzie, więc cały zbiór nigdy nie jest publikowany bez znaku. Wielkość znaku liczy calc() z wymiarów lightboxa, żeby na każdym ekranie wyglądał tak samo. Wyśrodkowany znak i przycinanie w stronę rogu zostały przetestowane i klient je odrzucił.
Drugi, mniej oczywisty szczegół: .htaccess cache'uje pliki .webp na rok jako immutable, a zdjęcie z nowym znakiem ma tę samą nazwę. Każda ścieżka do zdjęcia przechodzi więc przez photoSrc(), która dokleja ?v= z hashem całego zbioru. Bez tego stali klienci widzieliby stare piksele przez rok.
Formularz wyceny bez reCAPTCHY i bez banera cookies
Po stronie serwera jest jeden plik: wyslij-zapytanie.php. Przeglądarka wysyła multipart/form-data — pola jako JSON w jednej części (PHP psuje spacje w nazwach pól) i do trzech załączników. Endpoint robi wszystko, czego nie da się powierzyć przeglądarce: sprawdza Origin, limity rozmiaru, rozpoznaje typ pliku po pierwszych bajtach, a nie po nazwie czy Content-Type, poprawia rozszerzenie, ogranicza liczbę zapytań do pięciu na godzinę z jednego IP i czyści każdą wartość trafiającą do nagłówka maila z CR/LF — to klasyczna droga do dopisania komuś Bcc:.
Przed spamem chroni honeypot, a nie reCAPTCHA. To świadomy wybór: każdy skrypt zewnętrzny oznaczałby baner cookies, a baner na stronie, która ma skłonić do telefonu, to czysta strata. Z tego samego powodu na stronie kontaktu nie ma osadzonej mapy Google, tylko narysowany ręcznie plan ulic w SVG z linkiem do Map.
Jeden szczegół wyszedł dopiero przy sprawdzaniu DNS: rekord SPF domeny kończy się na -all i dopuszcza tylko serwery dhostingu. Formularz wysyłany funkcją mail() z innego hostingu udawałby nadawcę ze stylmet.pl, nie przeszedłby SPF i serwer pocztowy, na którym stoi skrzynka odbiorcy, miałby pełne prawo go odrzucić. Strona trafiła więc na ten sam hosting co poczta, a ta zależność jest opisana w dokumentacji projektu.
Uczciwość komunikatów jest tu regułą, a nie ozdobnikiem: panel po wysłaniu mówi dokładnie to, co się stało. Jeśli endpoint jest wyłączony, formularz otwiera program pocztowy z gotową wiadomością i nie twierdzi, że coś zostało wysłane.
Opinie z Google, które nie psują SEO
Sekcja opinii pobiera prawdziwe recenzje przez Google Places API, ale dopiero po hydracji. Pierwszy render — ten w prerenderowanym HTML-u — zawsze pokazuje zapisane w kodzie cytaty, więc robot widzi treść, a render po stronie serwera i klienta się zgadza. Każdy błąd (brak klucza, limit, sieć) jest logowany i zamienia się w null, a sekcja zostaje przy cytatach. Awaria usługi zewnętrznej nie jest problemem odwiedzającego.
Poprawki od klienta, czyli treść musi być prawdziwa
Kilka rund uwag od klienta zmieniło więcej niż kolory. Napis STYLMET przeszedł pod znak, bo tak wygląda logo w firmie, co podniosło nagłówek z 78 do 118 px. Wysokość nagłówka jest jedną zmienną, którą czytają pasek, menu, odstęp i scroll-padding-top. Menu na telefonie zamieniło się z przewijanego paska z makiety w pełne menu z inert, zarządzaniem focusem i blokadą przewijania. Wypożyczalnia ogrodzeń budowlanych zniknęła, bo nigdy nie ruszyła. A zdanie „na dzień, na weekend albo na całą budowę” poszło do poprawki, bo rusztowanie jest wynajmowane na minimum siedem dni. Takie drobne uwagi mają znaczenie i nie są bagatelizowane.
Co z tej realizacji wynika
Najważniejsza lekcja: na małej stronie firmowej to ograniczenia robią architekturę. Brak banera cookies wykluczył reCAPTCHĘ, mapę i analitykę. SPF zdecydował o hostingu. Roczny cache wymusił wersjonowanie zdjęć, a kadrowanie — dwie drogi znaku wodnego. Żadna z tych decyzji nie wynikała z mody na technologię, każda z konkretnego faktu o tej firmie i jej klientach. Rzecz, którą powtórzyłbym od razu w następnym projekcie: spisać te fakty w briefie w repozytorium, razem z uzasadnieniem każdego „tego celowo nie ma”. Dzięki temu kolejna runda poprawek nie cofa decyzji, które kosztowały najwięcej myślenia.
