firmy i usługi plus ustawiaenia
This commit is contained in:
+1223
-355
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,6 @@ export type UserSettings = {
|
||||
currency: Currency;
|
||||
areaUnit: AreaUnit;
|
||||
directOffersOnly: boolean;
|
||||
hideInactiveOffers: boolean;
|
||||
saveSearchesOnHome: boolean;
|
||||
notifySavedSearches: boolean;
|
||||
notifyPriceAlerts: boolean;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user