firmy i usługi plus ustawiaenia

This commit is contained in:
2026-08-19 17:02:55 +02:00
parent c52bbabec0
commit f9f030ba14
21 changed files with 3438 additions and 426 deletions
+1223 -355
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -24,7 +24,6 @@ export type UserSettings = {
currency: Currency;
areaUnit: AreaUnit;
directOffersOnly: boolean;
hideInactiveOffers: boolean;
saveSearchesOnHome: boolean;
notifySavedSearches: boolean;
notifyPriceAlerts: boolean;
+194
View File
@@ -0,0 +1,194 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { apiFetch, useAuth } from './auth';
import type { AreaUnit, Currency, UserSettings } from './auth';
/**
* Jednostki prezentacji cen i powierzchni.
*
* Baza trzyma ceny w zlotowkach, a powierzchnie w metrach kwadratowych - to sa wartosci zrodlowe i
* nigdy ich nie zmieniamy. Uzytkownik wybiera w /konto/ustawienia jedynie sposob WYSWIETLANIA, a
* cala konwersja dzieje sie tutaj, tuz przed pokazaniem liczby. Dzieki temu zmiana waluty nie
* dotyka danych i da sie ja cofnac bez zadnych skutkow ubocznych.
*
* Swiadomie NIE przeliczamy: platnosci za promowanie ofert (AutoPay realnie pobiera zlotowki),
* panelu administratora (raportowanie wewnetrzne) oraz pol, w ktorych uzytkownik wpisuje cene przy
* dodawaniu oferty - wpis zostaje w PLN/m2, zeby nie zapisac w bazie kwoty po kursie z jednego dnia.
*
* Obok jednostek trzymamy tu takze pozostale preferencje z tego samego zapytania o ustawienia konta
* (`/me/settings`), ktore decyduja o tym, CO uzytkownik widzi: filtr ofert bezposrednich i sekcje
* "Ostatnio przegladane". Dzieki temu ustawienia sa pobierane raz, a nie osobno w kazdym widoku.
*/
export type FxRates = {
base: string;
rates: Record<string, number>;
effectiveDate: string;
source: string;
};
/** 1 m2 = 10.763910417 ft2 (definicja stopy miedzynarodowej). */
const FT2_PER_M2 = 10.763910417;
const CURRENCY_FALLBACK: Record<Currency, number> = { PLN: 1, EUR: 4.3, USD: 3.95 };
export const AREA_SUFFIX: Record<AreaUnit, string> = { M2: 'm²', FT2: 'ft²' };
type DisplayContextValue = {
currency: Currency;
areaUnit: AreaUnit;
/** Skrot jednostki powierzchni do budowania wlasnych etykiet, np. "cena za m²". */
areaSuffix: string;
fx: FxRates | null;
/** Kwota w PLN przeliczona na wybrana walute (bez formatowania). */
convertPrice: (pln: number) => number;
/** Powierzchnia w m2 przeliczona na wybrana jednostke (bez formatowania). */
convertArea: (squareMeters: number) => number;
/**
* Konwersja odwrotna: liczba wpisana przez uzytkownika w jego jednostce -> metry kwadratowe.
* Potrzebna w kalkulatorach, ktore licza na bazach cen za m2, a pole formularza pokazuje ft².
*/
toSquareMeters: (valueInUserUnit: number) => number;
/** Cena z jednostka, np. "749 000 zł" albo "173 111 €". */
formatPrice: (pln: number) => string;
/** Cena miesieczna, np. "3 200 zł / miesiąc". */
formatMonthlyPrice: (pln: number) => string;
/** Cena za jednostke powierzchni, np. "12 081 zł/m²" albo "259 $/ft²". */
formatPricePerArea: (plnPerSquareMeter: number) => string;
/** Sama powierzchnia, np. "62 m²" albo "667 ft²". */
formatArea: (squareMeters: number) => string;
/** Liczba bez jednostki, w wybranej walucie - do miejsc, ktore dokladaja wlasny sufiks. */
formatPriceNumber: (pln: number) => string;
/** Sama liczba stawki za jednostke powierzchni - do zakresow typu "12 081 - 15 533". */
formatPricePerAreaNumber: (plnPerSquareMeter: number) => string;
/** Liczba bez jednostki, w wybranej jednostce powierzchni. */
formatAreaNumber: (squareMeters: number) => string;
/** Pokazuj wylacznie oferty od osob prywatnych (konta PERSONAL), z pominieciem firm. */
directOffersOnly: boolean;
/** Czy strona glowna ma pokazywac sekcje "Ostatnio przegladane". */
showRecentlyViewedOnHome: boolean;
/** Przyjmuje ustawienia zapisane w /konto/ustawienia, zeby widok zmienil sie od razu. */
applySettings: (settings: DisplaySettings) => void;
};
/** Fragment ustawien konta, ktory realnie steruje prezentacja i doborem ofert. */
type DisplaySettings = Pick<UserSettings, 'currency' | 'areaUnit' | 'directOffersOnly' | 'saveSearchesOnHome'>;
const DisplayContext = createContext<DisplayContextValue | null>(null);
function currencyFormatter(currency: Currency, fractionDigits: number): Intl.NumberFormat {
return new Intl.NumberFormat('pl-PL', {
style: 'currency',
currency,
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
});
}
// Intl wstawia twarda spacje (U+00A0) i waskie spacje (U+202F); reszta aplikacji uzywa zwyklych.
function normalizeSpaces(value: string): string {
return value.replace(/[  ]/g, ' ');
}
export function DisplayProvider({ children }: { children: ReactNode }) {
const { user, loadSettings } = useAuth();
const [currency, setCurrency] = useState<Currency>('PLN');
const [areaUnit, setAreaUnit] = useState<AreaUnit>('M2');
const [directOffersOnly, setDirectOffersOnly] = useState(false);
const [showRecentlyViewedOnHome, setShowRecentlyViewedOnHome] = useState(true);
const [fx, setFx] = useState<FxRates | null>(null);
// Kursy sa te same dla wszystkich, wiec pobieramy je raz na wejscie do aplikacji.
useEffect(() => {
let active = true;
apiFetch<FxRates>('/fx/rates')
.then((rates) => { if (active) setFx(rates); })
.catch(() => { if (active) setFx(null); });
return () => { active = false; };
}, []);
// Preferencje sa wlasnoscia konta - goscie widza wartosci bazowe (PLN, m²).
useEffect(() => {
if (!user) {
setCurrency('PLN');
setAreaUnit('M2');
setDirectOffersOnly(false);
setShowRecentlyViewedOnHome(true);
return;
}
let active = true;
loadSettings()
.then((settings) => {
if (!active) return;
applyTo(settings);
})
.catch(() => undefined);
return () => { active = false; };
// applyTo jest stabilne (same settery useState), wiec nie trzymamy go w zaleznosciach.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, loadSettings]);
function applyTo(settings: DisplaySettings) {
setCurrency(settings.currency ?? 'PLN');
setAreaUnit(settings.areaUnit ?? 'M2');
setDirectOffersOnly(settings.directOffersOnly ?? false);
setShowRecentlyViewedOnHome(settings.saveSearchesOnHome ?? true);
}
const applySettings = useCallback((settings: DisplaySettings) => {
applyTo(settings);
}, []);
const value = useMemo<DisplayContextValue>(() => {
const rate = fx?.rates?.[currency] ?? CURRENCY_FALLBACK[currency];
const safeRate = Number.isFinite(rate) && rate > 0 ? rate : 1;
const convertPrice = (pln: number) => (currency === 'PLN' ? pln : pln / safeRate);
const convertArea = (squareMeters: number) => (areaUnit === 'M2' ? squareMeters : squareMeters * FT2_PER_M2);
// Ceny ofert sa duze, wiec grosze tylko zaszumialyby karte - zaokraglamy do pelnej jednostki.
const price = currencyFormatter(currency, 0);
const plain = new Intl.NumberFormat('pl-PL', { maximumFractionDigits: 0 });
const formatPrice = (pln: number) => normalizeSpaces(price.format(Math.round(convertPrice(pln))));
const formatPriceNumber = (pln: number) => normalizeSpaces(plain.format(Math.round(convertPrice(pln))));
const formatAreaNumber = (squareMeters: number) => normalizeSpaces(plain.format(Math.round(convertArea(squareMeters))));
return {
currency,
areaUnit,
areaSuffix: AREA_SUFFIX[areaUnit],
fx,
convertPrice,
convertArea,
toSquareMeters: (valueInUserUnit: number) => (areaUnit === 'M2' ? valueInUserUnit : valueInUserUnit / FT2_PER_M2),
formatPrice,
formatMonthlyPrice: (pln: number) => `${formatPrice(pln)} / miesiąc`,
// Cena za jednostke to iloraz dwoch przeliczonych wielkosci, wiec dzielimy takze przez wspolczynnik powierzchni.
formatPricePerArea: (plnPerSquareMeter: number) => {
const perUnit = convertPrice(plnPerSquareMeter) / (areaUnit === 'M2' ? 1 : FT2_PER_M2);
return `${normalizeSpaces(price.format(Math.round(perUnit)))}/${AREA_SUFFIX[areaUnit]}`;
},
formatArea: (squareMeters: number) => `${formatAreaNumber(squareMeters)} ${AREA_SUFFIX[areaUnit]}`,
directOffersOnly,
showRecentlyViewedOnHome,
formatPriceNumber,
formatPricePerAreaNumber: (plnPerSquareMeter: number) => {
const perUnit = convertPrice(plnPerSquareMeter) / (areaUnit === 'M2' ? 1 : FT2_PER_M2);
return normalizeSpaces(plain.format(Math.round(perUnit)));
},
formatAreaNumber,
applySettings,
};
}, [currency, areaUnit, directOffersOnly, showRecentlyViewedOnHome, fx, applySettings]);
return <DisplayContext.Provider value={value}>{children}</DisplayContext.Provider>;
}
export function useDisplay(): DisplayContextValue {
const context = useContext(DisplayContext);
if (!context) {
throw new Error('useDisplay musi być użyte wewnątrz DisplayProvider');
}
return context;
}
+4 -1
View File
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import { AuthProvider } from './auth';
import { DisplayProvider } from './display';
import { NotificationsProvider } from './notifications';
ReactDOM.createRoot(document.getElementById('root')!).render(
@@ -10,7 +11,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<BrowserRouter>
<AuthProvider>
<NotificationsProvider>
<App />
<DisplayProvider>
<App />
</DisplayProvider>
</NotificationsProvider>
</AuthProvider>
</BrowserRouter>
+8
View File
@@ -14,6 +14,7 @@ export const ROUTES = {
edit: '/edytuj-ogloszenie/:id',
services: '/firmy-i-uslugi',
companies: '/firmy',
advertising: '/reklama',
guides: '/poradniki',
guideBuying: '/poradniki/kupno-mieszkania',
creditCalculator: '/kalkulator-zdolnosci',
@@ -107,6 +108,12 @@ export function companiesPath(categorySlug?: string): string {
return categorySlug ? `${ROUTES.companies}?kategoria=${encodeURIComponent(categorySlug)}` : ROUTES.companies;
}
// Strona "Jeszcze nie mamy tej usługi" z formularzem reklamowym. Kategoria trafia do query
// stringu, żeby po wejściu z kafelka pole "Rodzaj usługi" było już wypełnione.
export function advertisingPath(categorySlug?: string): string {
return categorySlug ? `${ROUTES.advertising}?kategoria=${encodeURIComponent(categorySlug)}` : ROUTES.advertising;
}
// Zakładki panelu administratora jako podścieżki /admin/*.
export const ADMIN_TAB_PATHS = {
dashboard: '',
@@ -114,6 +121,7 @@ export const ADMIN_TAB_PATHS = {
users: 'uzytkownicy',
messages: 'wiadomosci',
support: 'pomoc-i-kontakt',
advertisingEnquiries: 'zapytania-o-reklame',
reports: 'zgloszenia',
payments: 'platnosci',
stats: 'statystyki',
+1323 -41
View File
File diff suppressed because it is too large Load Diff
+30 -25
View File
@@ -73,52 +73,57 @@ test.describe('Trasy publiczne', () => {
});
test.describe('Lista firm i uslug (/firmy)', () => {
test('wejscie na /firmy pokazuje pelna liste', async ({ page }) => {
// Do serwisu nie dolaczyla jeszcze zadna firma. Dopoki tak jest, /firmy pokazuje SAMO zaproszenie -
// bez naglowka, licznika, chipow kategorii i wyszukiwarki (pokazywalyby zera i filtrowaly pustke).
// Gdy pojawi sie pierwsza firma, gorna czesc wraca i te testy trzeba rozszerzyc o karty.
test('wejscie na /firmy pokazuje samo zaproszenie', async ({ page }) => {
await goto(page, ROUTES.companies);
await expect(page.locator('.companies-head h1')).toHaveText('Wszystkie firmy i usługi');
const cards = await page.locator('.directory-card').count();
expect(cards).toBeGreaterThan(10);
await expect(page.locator('.companies-none')).toBeVisible();
await expect(page.locator('.directory-card')).toHaveCount(0);
await expect(page.locator('.companies-head')).toHaveCount(0);
await expect(page.locator('.companies-filters')).toHaveCount(0);
await expect(page.locator('.service-mini-search')).toHaveCount(0);
});
test('filtr kategorii dziala przez query string', async ({ page }) => {
test('zaproszenie przejmuje role naglowka H1', async ({ page }) => {
await goto(page, ROUTES.companies);
const headings = page.locator('.companies-page h1');
await expect(headings).toHaveCount(1);
await expect(headings).toContainText('Nie mamy jeszcze żadnych firm');
});
test('kategoria z query stringu trafia do komunikatu', async ({ page }) => {
await goto(page, `${ROUTES.companies}?kategoria=remonty`);
await expect(page.locator('.companies-head h1')).toHaveText('Remont i wykończenie');
await expect(page.locator('.companies-none')).toContainText('Remont i wykończenie');
const filtered = await page.locator('.directory-card').count();
await goto(page, ROUTES.companies);
const all = await page.locator('.directory-card').count();
expect(filtered).toBeGreaterThan(0);
expect(filtered).toBeLessThan(all);
await expect(page.locator('.companies-none')).toContainText('w naszej bazie');
});
test('nieznana kategoria nie wywraca widoku - pokazuje pelna liste', async ({ page }) => {
test('nieznana kategoria nie wywraca widoku', async ({ page }) => {
await goto(page, `${ROUTES.companies}?kategoria=nie-ma-takiej`);
await expect(page.locator('.companies-head h1')).toHaveText('Wszystkie firmy i usługi');
await expect(page.locator('.companies-none')).toBeVisible();
await expect(page.locator('.companies-none')).toContainText('w naszej bazie');
});
test('klik w chip kategorii zmienia adres i wynik', async ({ page }) => {
await goto(page, ROUTES.companies);
await page.locator('.companies-chip', { hasText: 'Finanse' }).first().click();
await page.waitForFunction(() => window.location.search.includes('kategoria=finanse'));
await expect(page.locator('.companies-head h1')).toHaveText('Finanse');
});
test('"Zobacz firmy" prowadzi do listy z wybrana kategoria', async ({ page }) => {
// Kategorii usług jeszcze nie obsługujemy, więc kafelek prowadzi na stronę zgłoszenia
// reklamowego (/reklama), a nie do listy firm - z kategorią przeniesioną w query stringu.
test('"Zobacz firmy" prowadzi na strone reklamy z wybrana kategoria', async ({ page }) => {
await goto(page, ROUTES.services);
const link = page.locator('.service-category-card', { hasText: 'Remont i wykończenie' }).getByRole('link');
await expect(link).toHaveAttribute('href', '/firmy?kategoria=remonty');
await expect(link).toHaveAttribute('href', '/reklama?kategoria=remonty');
await link.click();
await page.waitForFunction(() => window.location.pathname === '/firmy');
await expect(page.locator('.companies-head h1')).toHaveText('Remont i wykończenie');
await page.waitForFunction(() => window.location.pathname === '/reklama');
await expect(page.locator('.advertising-input.select select')).toHaveValue('Remont i wykończenie');
});
test('"Zobacz wszystkie" prowadzi do pelnej listy', async ({ page }) => {
test('"Zobacz wszystkie" prowadzi na /firmy', async ({ page }) => {
await goto(page, ROUTES.services);
const link = page.locator('.services-section .section-head', { hasText: 'Polecane firmy' }).getByRole('link');
await expect(link).toHaveAttribute('href', ROUTES.companies);
await link.click();
await page.waitForFunction(() => window.location.pathname === '/firmy');
await expect(page.locator('.companies-head h1')).toHaveText('Wszystkie firmy i usługi');
await expect(page.locator('.companies-none')).toBeVisible();
});
test('Wstecz i Dalej dzialaja na filtrze kategorii', async ({ page }) => {