Initial commit

This commit is contained in:
2026-07-09 00:14:45 +02:00
commit 0c2ec3fc1c
100 changed files with 47687 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.vite/
*.log
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="pl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Polska Lokalnie</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1296
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "polskalokalnie-frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc && vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"@types/pdfmake": "^0.3.3",
"pdfmake": "^0.3.11",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "6.0.2",
"typescript": "5.6.3",
"vite": "8.0.16"
}
}
+18542
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+193
View File
@@ -0,0 +1,193 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
export type Role = 'USER' | 'ADMIN';
export type AuthProviderName = 'LOCAL' | 'GOOGLE' | 'FACEBOOK';
export type AccountType = 'PERSONAL' | 'COMPANY';
export type ContactPreference = 'EMAIL' | 'PHONE' | 'EMAIL_AND_PHONE';
export type PreferredLanguage = 'PL' | 'EN' | 'UK' | 'DE';
export type AuthUser = {
id: number;
email: string;
fullName: string;
role: Role;
provider: AuthProviderName;
accountType: AccountType;
phone: string | null;
address: string | null;
contactPreference: ContactPreference;
preferredLanguage: PreferredLanguage;
nip: string | null;
birthDate: string | null;
verified: boolean;
blocked: boolean;
createdAt: string;
};
type AuthResponse = { token: string; user: AuthUser };
export type RegisterDetails = {
accountType: AccountType;
phone?: string;
nip?: string;
};
type AuthContextValue = {
user: AuthUser | null;
token: string | null;
loading: boolean;
login: (email: string, password: string) => Promise<AuthUser>;
register: (email: string, password: string, fullName: string, details?: RegisterDetails) => Promise<AuthUser>;
socialLogin: (provider: Exclude<AuthProviderName, 'LOCAL'>) => Promise<AuthUser>;
updateProfile: (fullName: string, phone?: string, birthDate?: string, address?: string, contactPreference?: ContactPreference, preferredLanguage?: PreferredLanguage) => Promise<AuthUser>;
logout: () => void;
};
const TOKEN_KEY = 'polskalokalnie-auth-token';
const API_BASE = '/api';
export function getToken(): string | null {
return window.localStorage.getItem(TOKEN_KEY);
}
/**
* Cienki wrapper na fetch: dokleja token Bearer i zamienia bledy backendu (pole "message")
* na wyjatek z czytelnym komunikatem po polsku.
*/
export async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
const headers = new Headers(options.headers);
if (options.body && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (!response.ok) {
let message = 'Wystąpił błąd. Spróbuj ponownie.';
try {
const data = await response.json();
if (data && typeof data.message === 'string' && data.message.trim()) {
message = data.message;
}
} catch {
// brak/niepoprawne cialo JSON - zostaje komunikat domyslny
}
const error = new Error(message) as Error & { status?: number };
error.status = response.status;
throw error;
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(() => getToken());
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState<boolean>(() => Boolean(getToken()));
// Po odswiezeniu strony: majac token w localStorage, pobierz aktualny profil.
useEffect(() => {
if (!token) {
setUser(null);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
apiFetch<AuthUser>('/auth/me')
.then((me) => {
if (!cancelled) {
setUser(me);
setLoading(false);
}
})
.catch(() => {
if (!cancelled) {
window.localStorage.removeItem(TOKEN_KEY);
setToken(null);
setUser(null);
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [token]);
const applyAuth = useCallback((auth: AuthResponse) => {
window.localStorage.setItem(TOKEN_KEY, auth.token);
setToken(auth.token);
setUser(auth.user);
return auth.user;
}, []);
const login = useCallback(
async (email: string, password: string) =>
applyAuth(await apiFetch<AuthResponse>('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
})),
[applyAuth],
);
const register = useCallback(
async (email: string, password: string, fullName: string, details?: RegisterDetails) =>
applyAuth(await apiFetch<AuthResponse>('/auth/register', {
method: 'POST',
body: JSON.stringify({ email, password, fullName, ...details }),
})),
[applyAuth],
);
const socialLogin = useCallback(
async (provider: Exclude<AuthProviderName, 'LOCAL'>) =>
applyAuth(await apiFetch<AuthResponse>('/auth/social', {
method: 'POST',
body: JSON.stringify({ provider }),
})),
[applyAuth],
);
const updateProfile = useCallback(
async (fullName: string, phone?: string, birthDate?: string, address?: string, contactPreference?: ContactPreference, preferredLanguage?: PreferredLanguage) => {
const updated = await apiFetch<AuthUser>('/auth/me', {
method: 'PUT',
body: JSON.stringify({ fullName, phone, birthDate, address, contactPreference, preferredLanguage }),
});
setUser(updated);
return updated;
},
[],
);
const logout = useCallback(() => {
window.localStorage.removeItem(TOKEN_KEY);
setToken(null);
setUser(null);
}, []);
const value = useMemo<AuthContextValue>(
() => ({ user, token, loading, login, register, socialLogin, updateProfile, logout }),
[user, token, loading, login, register, socialLogin, updateProfile, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error('useAuth must be used within an AuthProvider');
}
return ctx;
}
+251
View File
@@ -0,0 +1,251 @@
export type UiLanguage = 'PL' | 'EN' | 'UK' | 'DE';
const en: Record<string, string> = {
'Kupuję': 'Buy',
'Wynajmuję': 'Rent',
'Jak sprzedawać': 'How to sell',
'Wycena mieszkania': 'Property valuation',
'Firmy i usługi': 'Companies and services',
'Poradniki': 'Guides',
'Dane i ustawienia': 'Data and settings',
'Edytuj profil': 'Edit profile',
'Dodatkowe informacje': 'Additional information',
'Adres zamieszkania': 'Residential address',
'Preferencje kontaktu': 'Contact preferences',
'Język komunikacji': 'Communication language',
'Nie podano': 'Not provided',
'Polski': 'Polish',
'Zapisz zmiany': 'Save changes',
'Dodaj zdjęcie': 'Add photo',
'Zmień zdjęcie': 'Change photo',
'Usuń zdjęcie': 'Remove photo',
'Personalizacja profilu': 'Profile personalization',
'Widoczność profilu': 'Profile visibility',
'Publiczna': 'Public',
'Prywatna': 'Private',
'Tylko kontakty': 'Contacts only',
'Profil publiczny': 'Public profile',
'Zobacz profil': 'View profile',
'Usunięcie konta': 'Account deletion',
'Usuń konto': 'Delete account',
'Porównanie mieszkań': 'Apartment comparison',
'Porównaj do 4 mieszkań i wybierz najlepszą ofertę dla siebie.': 'Compare up to 4 apartments and choose the best option for you.',
'Dodaj mieszkania do porównania': 'Add apartments to compare',
'Możesz porównać do 4 ofert jednocześnie.': 'You can compare up to 4 listings at once.',
'Szukaj mieszkań': 'Search apartments',
'Wszystkie typy': 'All types',
'Mieszkanie': 'Apartment',
'Dom': 'House',
'Działka': 'Plot',
'Lokal użytkowy': 'Commercial unit',
'Pokój': 'Room',
'Dodaj': 'Add',
'Podsumowanie porównania': 'Comparison summary',
'Cena': 'Price',
'Cena całkowita': 'Total price',
'Cena za m²': 'Price per m²',
'Przelicznik ceny do metrażu': 'Price-to-area ratio',
'Lokalizacja': 'Location',
'Lokalizacja ogłoszenia': 'Listing location',
'Metraż': 'Area',
'Powierzchnia użytkowa': 'Usable area',
'Liczba pokoi': 'Number of rooms',
'Ilość pomieszczeń': 'Number of spaces',
'Piętro': 'Floor',
'Poziom mieszkania': 'Apartment floor',
'Stan': 'Condition',
'Stan techniczny': 'Technical condition',
'Rok budowy': 'Year built',
'Rok oddania do użytku': 'Year commissioned',
'Czynsz': 'Rent',
'Miesięczny czynsz': 'Monthly rent',
'Parking': 'Parking',
'Miejsce parkingowe': 'Parking space',
'Balkon / Taras': 'Balcony / Terrace',
'Dodatkowa przestrzeń': 'Additional space',
'Dostępność': 'Availability',
'Termin dostępności': 'Availability date',
'Ocena ogólna': 'Overall score',
'Na podstawie kluczowych czynników': 'Based on key factors',
'Brak wybranych ogłoszeń': 'No selected listings',
'Dodaj pierwsze mieszkanie, aby rozpocząć porównanie.': 'Add your first apartment to start comparison.',
'Zobacz ogłoszenie': 'View listing',
'Wczytuję ogłoszenia...': 'Loading listings...',
'Udostępnij porównanie': 'Share comparison',
'Wyczyść wszystko': 'Clear all',
'Strona główna': 'Home page',
'Porównywarka mieszkań': 'Apartment comparison tool',
'Sprzedaż': 'Sale',
'Wynajem': 'Rent',
'Dodaj min. 2 oferty do porównania': 'Add at least 2 listings to compare',
'Świetna': 'Excellent',
'Bardzo dobra': 'Very good',
'Dobra': 'Good',
'Do poprawy': 'Needs improvement',
'Od zaraz': 'Immediately',
'Do uzgodnienia': 'To be agreed',
'Brak': 'None',
};
const uk: Record<string, string> = {
'Kupuję': 'Купую',
'Wynajmuję': 'Орендую',
'Jak sprzedawać': 'Як продавати',
'Wycena mieszkania': 'Оцінка нерухомості',
'Firmy i usługi': 'Компанії та послуги',
'Poradniki': 'Порадники',
'Dane i ustawienia': 'Дані та налаштування',
'Edytuj profil': 'Редагувати профіль',
'Dodatkowe informacje': 'Додаткова інформація',
'Adres zamieszkania': 'Адреса проживання',
'Preferencje kontaktu': 'Параметри контакту',
'Język komunikacji': 'Мова спілкування',
'Nie podano': 'Не вказано',
'Polski': 'Польська',
'Zapisz zmiany': 'Зберегти зміни',
'Dodaj zdjęcie': 'Додати фото',
'Zmień zdjęcie': 'Змінити фото',
'Usuń zdjęcie': 'Видалити фото',
'Personalizacja profilu': 'Персоналізація профілю',
'Widoczność profilu': 'Видимість профілю',
'Publiczna': 'Публічний',
'Prywatna': 'Приватний',
'Tylko kontakty': 'Лише контакти',
'Profil publiczny': 'Публічний профіль',
'Zobacz profil': 'Переглянути профіль',
'Usunięcie konta': 'Видалення облікового запису',
'Usuń konto': 'Видалити обліковий запис',
'Porównanie mieszkań': 'Порівняння квартир',
'Porównaj do 4 mieszkań i wybierz najlepszą ofertę dla siebie.': 'Порівняйте до 4 квартир і оберіть найкращу пропозицію для себе.',
'Dodaj mieszkania do porównania': 'Додайте квартири для порівняння',
'Możesz porównać do 4 ofert jednocześnie.': 'Ви можете порівняти до 4 оголошень одночасно.',
'Szukaj mieszkań': 'Пошук квартир',
'Wszystkie typy': 'Усі типи',
'Mieszkanie': 'Квартира',
'Dom': 'Будинок',
'Działka': 'Ділянка',
'Lokal użytkowy': 'Комерційне приміщення',
'Pokój': 'Кімната',
'Dodaj': 'Додати',
'Podsumowanie porównania': 'Підсумок порівняння',
'Cena': 'Ціна',
'Cena całkowita': 'Загальна ціна',
'Cena za m²': 'Ціна за м²',
'Lokalizacja': 'Локація',
'Metraż': 'Площа',
'Liczba pokoi': 'Кількість кімнат',
'Piętro': 'Поверх',
'Stan': 'Стан',
'Rok budowy': 'Рік побудови',
'Czynsz': 'Орендна плата',
'Parking': 'Паркування',
'Balkon / Taras': 'Балкон / Тераса',
'Dostępność': 'Доступність',
'Ocena ogólna': 'Загальна оцінка',
'Brak wybranych ogłoszeń': 'Немає вибраних оголошень',
'Zobacz ogłoszenie': 'Переглянути оголошення',
'Udostępnij porównanie': 'Поділитися порівнянням',
'Wyczyść wszystko': 'Очистити все',
'Strona główna': 'Головна сторінка',
'Porównywarka mieszkań': 'Порівняння квартир',
'Sprzedaż': 'Продаж',
'Wynajem': 'Оренда',
'Brak': 'Немає',
};
const de: Record<string, string> = {
'Kupuję': 'Ich kaufe',
'Wynajmuję': 'Ich miete',
'Jak sprzedawać': 'Wie verkauft man',
'Wycena mieszkania': 'Immobilienbewertung',
'Firmy i usługi': 'Firmen und Dienstleistungen',
'Poradniki': 'Ratgeber',
'Dane i ustawienia': 'Daten und Einstellungen',
'Edytuj profil': 'Profil bearbeiten',
'Dodatkowe informacje': 'Zusätzliche Informationen',
'Adres zamieszkania': 'Wohnadresse',
'Preferencje kontaktu': 'Kontaktpräferenzen',
'Język komunikacji': 'Kommunikationssprache',
'Nie podano': 'Nicht angegeben',
'Polski': 'Polnisch',
'Zapisz zmiany': 'Änderungen speichern',
'Dodaj zdjęcie': 'Foto hinzufügen',
'Zmień zdjęcie': 'Foto ändern',
'Usuń zdjęcie': 'Foto entfernen',
'Personalizacja profilu': 'Profilpersonalisierung',
'Widoczność profilu': 'Profilsichtbarkeit',
'Publiczna': 'Öffentlich',
'Prywatna': 'Privat',
'Tylko kontakty': 'Nur Kontakte',
'Profil publiczny': 'Öffentliches Profil',
'Zobacz profil': 'Profil ansehen',
'Usunięcie konta': 'Konto löschen',
'Usuń konto': 'Konto entfernen',
'Porównanie mieszkań': 'Wohnungsvergleich',
'Porównaj do 4 mieszkań i wybierz najlepszą ofertę dla siebie.': 'Vergleichen Sie bis zu 4 Wohnungen und wählen Sie das beste Angebot.',
'Dodaj mieszkania do porównania': 'Wohnungen zum Vergleich hinzufügen',
'Możesz porównać do 4 ofert jednocześnie.': 'Sie können bis zu 4 Angebote gleichzeitig vergleichen.',
'Szukaj mieszkań': 'Wohnungen suchen',
'Wszystkie typy': 'Alle Typen',
'Mieszkanie': 'Wohnung',
'Dom': 'Haus',
'Działka': 'Grundstück',
'Lokal użytkowy': 'Gewerbeobjekt',
'Pokój': 'Zimmer',
'Dodaj': 'Hinzufügen',
'Podsumowanie porównania': 'Vergleichszusammenfassung',
'Cena': 'Preis',
'Cena całkowita': 'Gesamtpreis',
'Cena za m²': 'Preis pro m²',
'Lokalizacja': 'Standort',
'Metraż': 'Fläche',
'Liczba pokoi': 'Zimmeranzahl',
'Piętro': 'Etage',
'Stan': 'Zustand',
'Rok budowy': 'Baujahr',
'Czynsz': 'Miete',
'Parking': 'Parken',
'Balkon / Taras': 'Balkon / Terrasse',
'Dostępność': 'Verfügbarkeit',
'Ocena ogólna': 'Gesamtbewertung',
'Brak wybranych ogłoszeń': 'Keine ausgewählten Anzeigen',
'Zobacz ogłoszenie': 'Anzeige ansehen',
'Udostępnij porównanie': 'Vergleich teilen',
'Wyczyść wszystko': 'Alles löschen',
'Strona główna': 'Startseite',
'Porównywarka mieszkań': 'Wohnungsvergleich',
'Sprzedaż': 'Verkauf',
'Wynajem': 'Miete',
'Brak': 'Keine',
};
const dictionaries: Record<Exclude<UiLanguage, 'PL'>, Record<string, string>> = {
EN: en,
UK: uk,
DE: de,
};
function normalizeKey(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
export function getManualTranslation(language: UiLanguage, source: string): string | null {
if (language === 'PL') {
return source;
}
const dictionary = dictionaries[language];
if (!dictionary) {
return null;
}
const exact = dictionary[source];
if (exact) {
return exact;
}
const normalizedSource = normalizeKey(source);
if (!normalizedSource) {
return null;
}
const normalizedHit = dictionary[normalizedSource];
return normalizedHit ?? null;
}
+12
View File
@@ -0,0 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { AuthProvider } from './auth';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<AuthProvider>
<App />
</AuthProvider>
</React.StrictMode>,
);
+9
View File
@@ -0,0 +1,9 @@
declare module 'pdfmake/build/fonts/Roboto.js' {
type PdfMakeFontContainer = {
vfs: Record<string, { data: string; encoding?: string } | string>;
fonts: Record<string, Record<string, string>>;
};
const fontContainer: PdfMakeFontContainer;
export default fontContainer;
}
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
+35
View File
@@ -0,0 +1,35 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
strictPort: true,
allowedHosts: true,
hmr: {
overlay: false,
},
watch: {
usePolling: true,
interval: 120,
awaitWriteFinish: {
stabilityThreshold: 260,
pollInterval: 80,
},
},
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
},
},
},
preview: {
host: '0.0.0.0',
port: 4173,
strictPort: true,
allowedHosts: true,
},
});