commit 0c2ec3fc1c7a2316fc4fb7387cff6e64f325e69a Author: pascal Date: Thu Jul 9 00:14:45 2026 +0200 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ca8cbcc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +frontend/node_modules/ +frontend/dist/ +backend/target/ +.git/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..60fcbcc --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +POSTGRES_DB=polskalokalnie +POSTGRES_USER=polskalokalnie +POSTGRES_PASSWORD=change_me_for_local_dev + +SPRING_JPA_HIBERNATE_DDL_AUTO=update + +# Local network binding. +# Keep database and backend local; expose only the web proxy to the LAN. +WEB_BIND_ADDRESS=0.0.0.0 +BACKEND_BIND_ADDRESS=127.0.0.1 +POSTGRES_BIND_ADDRESS=127.0.0.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c3c1758 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +.env +.DS_Store +Thumbs.db + +# Java / Spring Boot +backend/target/ +*.class + +# Node / React +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ + +# IDE +.idea/ +.vscode/ +*.iml + +# Docker / logs +*.log +docker-data/ diff --git a/.tmp/login-screenshot.png b/.tmp/login-screenshot.png new file mode 100644 index 0000000..21bd80e Binary files /dev/null and b/.tmp/login-screenshot.png differ diff --git a/.tmp/login-wide-screenshot.png b/.tmp/login-wide-screenshot.png new file mode 100644 index 0000000..31217ec Binary files /dev/null and b/.tmp/login-wide-screenshot.png differ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..dae2a0d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,110 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +This repository is currently a clean workspace with no application source tree checked in. Keep new code organized by responsibility: + +- `src/` for application code and reusable modules. +- `tests/` for automated tests that mirror the `src/` layout. +- `assets/` for static images, fonts, fixtures, or other non-code resources. +- `docs/` for architecture notes, setup guides, and operational runbooks. + +Prefer small, focused modules. Place shared helpers near the code that uses them first; promote them to a common module only when multiple features need them. + +## Build, Test, and Development Commands + +No build system or package manifest is currently present. When tooling is added, document the canonical commands in the README and keep them stable. Recommended command names: + +- `npm run dev`, `pnpm dev`, or equivalent: start the local development server. +- `npm test`, `pnpm test`, or equivalent: run the full test suite. +- `npm run lint` / `npm run format`: check and format code style. +- `npm run build`: produce a production-ready build. + +Avoid adding ad hoc scripts that only work on one machine. Prefer cross-platform commands or document platform-specific requirements clearly. + +## Coding Style & Naming Conventions + +Follow the formatter and linter configured by the first language/toolchain added to the project. Until then, use 2-space indentation for web projects, 4-space indentation for Python, and meaningful names over abbreviations. + +Use clear file names that describe purpose, for example `user-service.ts`, `map-view.tsx`, or `test_user_service.py`. Keep public APIs explicit and avoid large catch-all utility files. + +## Testing Guidelines + +Add tests alongside each new feature or bug fix. Test files should mirror the implementation name, such as `user-service.test.ts` for `src/user-service.ts` or `test_user_service.py` for `src/user_service.py`. + +Prefer fast unit tests for business logic and a smaller number of integration tests for workflows. Include regression tests for any fixed bug. + +## Commit & Pull Request Guidelines + +Git history is not available in this workspace, so use a simple imperative commit style: `Add project scaffold`, `Fix login validation`, `Update map assets`. + +Pull requests should include a short summary, testing performed, linked issues when applicable, and screenshots or screen recordings for user-facing UI changes. Keep PRs focused; split unrelated changes into separate submissions. + +## Security & Configuration Tips + +Do not commit secrets, local credentials, generated build output, or machine-specific configuration. Store required environment variables in an ignored `.env` file and document safe example values in `.env.example`. + +## Behavioral Guidelines To Reduce Common LLM Coding Mistakes + +Tradeoff: These guidelines bias toward caution over speed. For trivial tasks, use judgment. + +### 1. Think Before Coding + +Don't assume. Don't hide confusion. Surface tradeoffs. + +Before implementing: + +- State assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them instead of choosing silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop, name what is confusing, and ask. + +### 2. Simplicity First + +Minimum code that solves the problem. Nothing speculative. + +- No features beyond what was asked. +- No abstractions for single-use code. +- No flexibility or configurability that was not requested. +- No error handling for impossible scenarios. +- If code is much longer than needed, rewrite it to be simpler. + +Ask: Would a senior engineer say this is overcomplicated? If yes, simplify. + +### 3. Surgical Changes + +Touch only what you must. Clean up only your own mess. + +When editing existing code: + +- Do not improve adjacent code, comments, or formatting unless needed for the task. +- Do not refactor code that is not broken. +- Match existing style, even if you would do it differently. +- If unrelated dead code is noticed, mention it but do not delete it. + +When changes create orphans: + +- Remove imports, variables, or functions made unused by your own changes. +- Do not remove pre-existing dead code unless asked. + +Test: Every changed line should trace directly to the request. + +### 4. Goal-Driven Execution + +Define success criteria. Loop until verified. + +Transform tasks into verifiable goals: + +- Add validation -> write tests for invalid inputs, then make them pass. +- Fix a bug -> write a test that reproduces it, then make it pass. +- Refactor X -> ensure tests pass before and after. + +For multi-step tasks, state a brief plan: + +1. [Step] -> verify: [check] +2. [Step] -> verify: [check] +3. [Step] -> verify: [check] + +Strong success criteria enable independent execution. Weak criteria require frequent clarification. + +These guidelines are working if there are fewer unnecessary diff changes, fewer rewrites caused by overcomplication, and clarifying questions appear before implementation mistakes. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8ece2ee --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# Polska Lokalnie + +Aplikacja do ogloszen sprzedazy i wynajmu mieszkan, domow oraz apartamentow. + +## Stack technologiczny + +- Backend: Java 21, Spring Boot, Spring Web, Spring Data JPA, Bean Validation +- Frontend: React, TypeScript, Vite +- Baza danych: PostgreSQL +- Reverse proxy i statyczny frontend: Nginx +- Uruchamianie: Docker Compose + +## Uruchomienie w Dockerze + +Skopiuj konfiguracje srodowiskowa i uruchom caly stack: + +```bash +cp .env.example .env +docker compose up --build +``` + +W PowerShell mozesz uzyc: + +```powershell +Copy-Item .env.example .env +docker compose up --build +``` + +Aplikacja bedzie dostepna pod adresem: + +- Frontend: http://localhost +- API: http://localhost/api/listings +- PostgreSQL: localhost:5432 + +## Dostep w sieci lokalnej + +Aplikacja jest przygotowana do pracy z innych urzadzen w tej samej sieci Wi-Fi/LAN. + +Najpierw sprawdz adres IP komputera, na ktorym uruchamiasz projekt: + +```powershell +ipconfig +``` + +Znajdz aktywna karte sieciowa i pole `IPv4 Address`, na przyklad `192.168.1.25`. + +### Docker Compose + +Uruchom caly stack: + +```powershell +docker compose up --build +``` + +Na drugim urzadzeniu w tej samej sieci wejdz w przegladarce na: + +```text +http://192.168.1.25 +``` + +Port `80` jest wystawiany na siec lokalna przez Nginx. Backend i baza danych domyslnie sa dostepne tylko lokalnie na komputerze hosta, a API jest osiagalne przez Nginx pod `/api`. + +### Tryb developerski + +Uruchom backend lokalnie: + +```powershell +cd backend +mvn spring-boot:run +``` + +Uruchom frontend lokalnie: + +```powershell +cd frontend +npm run dev +``` + +Na drugim urzadzeniu wejdz na: + +```text +http://192.168.1.25:5173 +``` + +Vite nasluchuje na `0.0.0.0`, a zapytania `/api` sa proxy do backendu na `127.0.0.1:8080`. + +## Uruchomienie lokalne bez Dockera + +Backend: + +```bash +cd backend +mvn spring-boot:run +``` + +Frontend: + +```bash +cd frontend +npm install +npm run dev +``` + +Vite przekierowuje zapytania `/api` do backendu na `http://localhost:8080`. + +## Struktura projektu + +```text +backend/ Spring Boot API +frontend/ React + Vite +nginx/ konfiguracja Nginx i obraz web +docker-compose.yml +``` diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..4cb4fee --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,3 @@ +target/ +.mvn/ +*.log diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..d672440 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,16 @@ +FROM maven:3.9-eclipse-temurin-21 AS build +WORKDIR /app + +COPY pom.xml . +RUN mvn -q -DskipTests dependency:go-offline + +COPY src ./src +RUN mvn -q -DskipTests package + +FROM eclipse-temurin:21-jre +WORKDIR /app + +COPY --from=build /app/target/*.jar app.jar + +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..dfb18f1 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "backend", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..c0bc397 --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,83 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.5 + + + + pl.polskalokalnie + backend + 0.0.1-SNAPSHOT + polskalokalnie-backend + API ogloszen nieruchomosci Polska Lokalnie + + + 21 + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-security + + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + runtime + + + org.postgresql + postgresql + runtime + + + com.h2database + h2 + test + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/backend/src/main/java/pl/polskalokalnie/PolskaLokalnieApplication.java b/backend/src/main/java/pl/polskalokalnie/PolskaLokalnieApplication.java new file mode 100644 index 0000000..39c5476 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/PolskaLokalnieApplication.java @@ -0,0 +1,15 @@ +package pl.polskalokalnie; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration; + +// Uwierzytelnianie realizuje wlasny filtr JWT, wiec wylaczamy domyslnego +// uzytkownika Spring Security (i mylacy log "Using generated security password"). +@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class) +public class PolskaLokalnieApplication { + + public static void main(String[] args) { + SpringApplication.run(PolskaLokalnieApplication.class, args); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/admin/AddForbiddenWordRequest.java b/backend/src/main/java/pl/polskalokalnie/admin/AddForbiddenWordRequest.java new file mode 100644 index 0000000..0fa281c --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/admin/AddForbiddenWordRequest.java @@ -0,0 +1,11 @@ +package pl.polskalokalnie.admin; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record AddForbiddenWordRequest( + @NotBlank(message = "Słowo nie może być puste") + @Size(max = 120, message = "Słowo jest zbyt długie") + String word +) { +} \ No newline at end of file diff --git a/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java b/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java new file mode 100644 index 0000000..3985db1 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java @@ -0,0 +1,208 @@ +package pl.polskalokalnie.admin; + +import java.util.Comparator; +import java.util.List; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; +import pl.polskalokalnie.moderation.ForbiddenWordResponse; +import pl.polskalokalnie.moderation.TextModerationService; +import pl.polskalokalnie.auth.dto.UserResponse; +import pl.polskalokalnie.listing.ListingResponse; +import pl.polskalokalnie.listing.ListingService; +import pl.polskalokalnie.listing.ListingStatus; +import pl.polskalokalnie.message.MessageResponse; +import pl.polskalokalnie.message.MessageService; +import pl.polskalokalnie.message.SendMessageRequest; +import pl.polskalokalnie.report.ListingReportResponse; +import pl.polskalokalnie.report.ListingReportService; +import pl.polskalokalnie.report.ResolveListingReportRequest; +import pl.polskalokalnie.user.AppUser; +import pl.polskalokalnie.user.BlockedEmail; +import pl.polskalokalnie.user.BlockedEmailRepository; +import pl.polskalokalnie.user.Role; +import pl.polskalokalnie.user.UserRepository; + +@RestController +@RequestMapping("/api/admin") +public class AdminController { + + private final UserRepository userRepository; + private final BlockedEmailRepository blockedEmailRepository; + private final ListingService listingService; + private final ListingReportService listingReportService; + private final MessageService messageService; + private final TextModerationService textModerationService; + + public AdminController(UserRepository userRepository, BlockedEmailRepository blockedEmailRepository, + ListingService listingService, ListingReportService listingReportService, + MessageService messageService, + TextModerationService textModerationService) { + this.userRepository = userRepository; + this.blockedEmailRepository = blockedEmailRepository; + this.listingService = listingService; + this.listingReportService = listingReportService; + this.messageService = messageService; + this.textModerationService = textModerationService; + } + + // --- Uzytkownicy --- + + @GetMapping("/users") + public List users() { + return userRepository.findAll().stream() + .sorted(Comparator.comparing(AppUser::getCreatedAt).reversed()) + .map(UserResponse::from) + .toList(); + } + + @PostMapping("/users/{id}/block") + public UserResponse block(@PathVariable Long id) { + return setBlocked(id, true); + } + + @PostMapping("/users/{id}/unblock") + public UserResponse unblock(@PathVariable Long id) { + return setBlocked(id, false); + } + + @PostMapping("/users/{id}/verify") + public UserResponse verify(@PathVariable Long id) { + AppUser user = requireUser(id); + user.setVerified(true); + return UserResponse.from(userRepository.save(user)); + } + + @DeleteMapping("/users/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteUser(@PathVariable Long id) { + AppUser user = requireUser(id); + if (user.getRole() == Role.ADMIN) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można usunąć konta administratora"); + } + userRepository.delete(user); + } + + // Odrzucenie konta podczas weryfikacji: e-mail trafia na trwala liste zablokowanych + // adresow, wiec nie da sie nim ponownie zalozyc konta. + @PostMapping("/users/{id}/reject") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void rejectUser(@PathVariable Long id) { + AppUser user = requireUser(id); + if (user.getRole() == Role.ADMIN) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można odrzucić konta administratora"); + } + if (!blockedEmailRepository.existsByEmailIgnoreCase(user.getEmail())) { + BlockedEmail blockedEmail = new BlockedEmail(); + blockedEmail.setEmail(user.getEmail()); + blockedEmailRepository.save(blockedEmail); + } + userRepository.delete(user); + } + + private UserResponse setBlocked(Long id, boolean blocked) { + AppUser user = requireUser(id); + if (user.getRole() == Role.ADMIN) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można zablokować konta administratora"); + } + user.setBlocked(blocked); + return UserResponse.from(userRepository.save(user)); + } + + private AppUser requireUser(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje")); + } + + // --- Wiadomosci do uzytkownika --- + + @GetMapping("/users/{id}/messages") + public List userMessages(@PathVariable Long id, Authentication authentication) { + AppUser user = requireUser(id); + AppUser admin = requireAdmin(authentication); + return messageService.getConversation(admin.getId(), user.getId()); + } + + @PostMapping("/users/{id}/messages") + public MessageResponse sendMessageToUser(@PathVariable Long id, Authentication authentication, + @Valid @RequestBody SendMessageRequest request) { + AppUser user = requireUser(id); + AppUser admin = requireAdmin(authentication); + return messageService.send(admin.getId(), user.getId(), request.content()); + } + + private AppUser requireAdmin(Authentication authentication) { + return userRepository.findByEmailIgnoreCase(authentication.getName()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED)); + } + + // --- Moderacja ogloszen --- + + @GetMapping("/listings") + public List listings() { + return listingService.findAllForModeration(); + } + + @PostMapping("/listings/{id}/approve") + public ListingResponse approve(@PathVariable Long id) { + return listingService.changeStatus(id, ListingStatus.APPROVED); + } + + @PostMapping("/listings/{id}/reject") + public ListingResponse reject(@PathVariable Long id) { + return listingService.changeStatus(id, ListingStatus.REJECTED); + } + + @DeleteMapping("/listings/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteListing(@PathVariable Long id) { + listingService.delete(id); + } + + @GetMapping("/reports") + public List reports() { + return listingReportService.findAllForAdmin(); + } + + @PostMapping("/reports/{id}/resolve") + public ListingReportResponse resolveReport( + @PathVariable Long id, + Authentication authentication, + @RequestBody(required = false) ResolveListingReportRequest request + ) { + return listingReportService.resolve(id, authentication.getName(), request == null ? null : request.note()); + } + + @PostMapping("/reports/{id}/delete-listing") + public ListingReportResponse deleteListingFromReport(@PathVariable Long id, Authentication authentication) { + return listingReportService.deleteListingAndResolve(id, authentication.getName()); + } + + // --- Slowa zabronione --- + + @GetMapping("/forbidden-words") + public List forbiddenWords() { + return textModerationService.listForbiddenWords(); + } + + @PostMapping("/forbidden-words") + @ResponseStatus(HttpStatus.CREATED) + public List addForbiddenWord(@Valid @RequestBody AddForbiddenWordRequest request) { + return textModerationService.addForbiddenWord(request.word()); + } + + @DeleteMapping("/forbidden-words/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteForbiddenWord(@PathVariable Long id) { + textModerationService.removeForbiddenWord(id); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/auth/AuthController.java b/backend/src/main/java/pl/polskalokalnie/auth/AuthController.java new file mode 100644 index 0000000..e7a321d --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/auth/AuthController.java @@ -0,0 +1,54 @@ +package pl.polskalokalnie.auth; + +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import pl.polskalokalnie.auth.dto.AuthResponse; +import pl.polskalokalnie.auth.dto.LoginRequest; +import pl.polskalokalnie.auth.dto.RegisterRequest; +import pl.polskalokalnie.auth.dto.SocialLoginRequest; +import pl.polskalokalnie.auth.dto.UpdateProfileRequest; +import pl.polskalokalnie.auth.dto.UserResponse; + +@RestController +@RequestMapping("/api/auth") +public class AuthController { + + private final AuthService authService; + + public AuthController(AuthService authService) { + this.authService = authService; + } + + @PostMapping("/register") + public ResponseEntity register(@Valid @RequestBody RegisterRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(authService.register(request)); + } + + @PostMapping("/login") + public AuthResponse login(@Valid @RequestBody LoginRequest request) { + return authService.login(request); + } + + @PostMapping("/social") + public AuthResponse social(@Valid @RequestBody SocialLoginRequest request) { + return authService.socialLogin(request); + } + + @GetMapping("/me") + public UserResponse me(Authentication authentication) { + return authService.currentUser(authentication.getName()); + } + + @PutMapping("/me") + public UserResponse updateMe(Authentication authentication, @Valid @RequestBody UpdateProfileRequest request) { + return authService.updateProfile(authentication.getName(), request); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java b/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java new file mode 100644 index 0000000..69341f0 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java @@ -0,0 +1,180 @@ +package pl.polskalokalnie.auth; + +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.Locale; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import pl.polskalokalnie.moderation.TextModerationService; +import pl.polskalokalnie.auth.dto.AuthResponse; +import pl.polskalokalnie.auth.dto.LoginRequest; +import pl.polskalokalnie.auth.dto.RegisterRequest; +import pl.polskalokalnie.auth.dto.SocialLoginRequest; +import pl.polskalokalnie.auth.dto.UpdateProfileRequest; +import pl.polskalokalnie.auth.dto.UserResponse; +import pl.polskalokalnie.user.AccountType; +import pl.polskalokalnie.user.AppUser; +import pl.polskalokalnie.user.AuthProvider; +import pl.polskalokalnie.user.BlockedEmailRepository; +import pl.polskalokalnie.user.ContactPreference; +import pl.polskalokalnie.user.PreferredLanguage; +import pl.polskalokalnie.user.Role; +import pl.polskalokalnie.user.UserRepository; + +@Service +public class AuthService { + + private final UserRepository userRepository; + private final BlockedEmailRepository blockedEmailRepository; + private final PasswordEncoder passwordEncoder; + private final JwtService jwtService; + private final TextModerationService textModerationService; + + public AuthService( + UserRepository userRepository, + BlockedEmailRepository blockedEmailRepository, + PasswordEncoder passwordEncoder, + JwtService jwtService, + TextModerationService textModerationService + ) { + this.userRepository = userRepository; + this.blockedEmailRepository = blockedEmailRepository; + this.passwordEncoder = passwordEncoder; + this.jwtService = jwtService; + this.textModerationService = textModerationService; + } + + public AuthResponse register(RegisterRequest request) { + String email = normalizeEmail(request.email()); + if (blockedEmailRepository.existsByEmailIgnoreCase(email)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Ten adres e-mail został zablokowany i nie można go już użyć do rejestracji"); + } + if (userRepository.existsByEmailIgnoreCase(email)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Konto z tym adresem e-mail już istnieje"); + } + + textModerationService.validateOrThrow(request.fullName(), request.phone(), request.nip()); + + AppUser user = new AppUser(); + user.setEmail(email); + user.setFullName(request.fullName().trim()); + user.setPasswordHash(passwordEncoder.encode(request.password())); + user.setRole(Role.USER); + user.setProvider(AuthProvider.LOCAL); + user.setAccountType(request.accountType() != null ? request.accountType() : AccountType.PERSONAL); + user.setPhone(request.phone() != null && !request.phone().isBlank() ? request.phone().trim() : null); + user.setNip(request.nip() != null && !request.nip().isBlank() ? request.nip().trim() : null); + // Konta zalozone samodzielnie czekaja na weryfikacje danych przez administratora. + user.setVerified(false); + + return buildAuthResponse(userRepository.save(user)); + } + + public AuthResponse login(LoginRequest request) { + AppUser user = userRepository.findByEmailIgnoreCase(normalizeEmail(request.email())) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Nieprawidłowy e-mail lub hasło")); + + if (user.getPasswordHash() == null + || !passwordEncoder.matches(request.password(), user.getPasswordHash())) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Nieprawidłowy e-mail lub hasło"); + } + if (user.isBlocked()) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Konto zostało zablokowane"); + } + + return buildAuthResponse(user); + } + + /** + * Symulowane logowanie spoleczne: znajduje lub tworzy konto powiazane z dostawca. + * Nie ma tu prawdziwego OAuth - dane przychodza z frontendu jako demo. + */ + public AuthResponse socialLogin(SocialLoginRequest request) { + AuthProvider provider = request.provider(); + String email = normalizeEmail( + request.email() != null && !request.email().isBlank() + ? request.email() + : defaultSocialEmail(provider)); + String name = request.fullName() != null && !request.fullName().isBlank() + ? request.fullName().trim() + : defaultSocialName(provider); + + textModerationService.validateOrThrow(name); + + AppUser user = userRepository.findByEmailIgnoreCase(email).orElseGet(() -> { + if (blockedEmailRepository.existsByEmailIgnoreCase(email)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Ten adres e-mail został zablokowany i nie można go już użyć do rejestracji"); + } + AppUser created = new AppUser(); + created.setEmail(email); + created.setFullName(name); + created.setRole(Role.USER); + created.setProvider(provider); + // Logowanie spoleczne nie wymaga rekopisania danych, wiec konto jest od razu zweryfikowane. + created.setVerified(true); + return userRepository.save(created); + }); + + if (user.isBlocked()) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Konto zostało zablokowane"); + } + + return buildAuthResponse(user); + } + + public UserResponse currentUser(String email) { + return userRepository.findByEmailIgnoreCase(email) + .map(UserResponse::from) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła")); + } + + public UserResponse updateProfile(String email, UpdateProfileRequest request) { + AppUser user = userRepository.findByEmailIgnoreCase(email) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła")); + + textModerationService.validateOrThrow(request.fullName(), request.phone(), request.birthDate(), request.address()); + + user.setFullName(request.fullName().trim()); + user.setPhone(request.phone() != null && !request.phone().isBlank() ? request.phone().trim() : null); + user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null); + user.setContactPreference(request.contactPreference() != null ? request.contactPreference() : ContactPreference.EMAIL_AND_PHONE); + user.setPreferredLanguage(request.preferredLanguage() != null ? request.preferredLanguage() : PreferredLanguage.PL); + if (request.birthDate() != null && !request.birthDate().isBlank()) { + try { + user.setBirthDate(LocalDate.parse(request.birthDate().trim())); + } catch (DateTimeParseException ex) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy format daty urodzenia"); + } + } else { + user.setBirthDate(null); + } + + return UserResponse.from(userRepository.save(user)); + } + + private AuthResponse buildAuthResponse(AppUser user) { + return new AuthResponse(jwtService.generateToken(user), UserResponse.from(user)); + } + + private String normalizeEmail(String email) { + return email.trim().toLowerCase(Locale.ROOT); + } + + private String defaultSocialEmail(AuthProvider provider) { + return switch (provider) { + case GOOGLE -> "demo.google@gmail.com"; + case FACEBOOK -> "demo.facebook@facebook.com"; + case LOCAL -> "demo.local@mieszko.pl"; + }; + } + + private String defaultSocialName(AuthProvider provider) { + return switch (provider) { + case GOOGLE -> "Użytkownik Google"; + case FACEBOOK -> "Użytkownik Facebook"; + case LOCAL -> "Użytkownik"; + }; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/auth/JwtAuthFilter.java b/backend/src/main/java/pl/polskalokalnie/auth/JwtAuthFilter.java new file mode 100644 index 0000000..6bb9446 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/auth/JwtAuthFilter.java @@ -0,0 +1,51 @@ +package pl.polskalokalnie.auth; + +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + + public JwtAuthFilter(JwtService jwtService) { + this.jwtService = jwtService; + } + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + String header = request.getHeader("Authorization"); + if (header != null && header.startsWith("Bearer ")) { + String token = header.substring(7); + try { + Claims claims = jwtService.parse(token); + String email = claims.getSubject(); + String role = claims.get("role", String.class); + var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role)); + var authentication = new UsernamePasswordAuthenticationToken(email, null, authorities); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (Exception ex) { + // Nieprawidlowy lub wygasly token: zadanie leci dalej jako nieuwierzytelnione. + SecurityContextHolder.clearContext(); + } + } + filterChain.doFilter(request, response); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/auth/JwtService.java b/backend/src/main/java/pl/polskalokalnie/auth/JwtService.java new file mode 100644 index 0000000..3aacb39 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/auth/JwtService.java @@ -0,0 +1,48 @@ +package pl.polskalokalnie.auth; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; +import javax.crypto.SecretKey; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import pl.polskalokalnie.user.AppUser; + +@Service +public class JwtService { + + private final SecretKey key; + private final long expirationSeconds; + + public JwtService( + @Value("${app.jwt.secret}") String secret, + @Value("${app.jwt.expiration-seconds:86400}") long expirationSeconds + ) { + this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.expirationSeconds = expirationSeconds; + } + + public String generateToken(AppUser user) { + Instant now = Instant.now(); + return Jwts.builder() + .subject(user.getEmail()) + .claim("role", user.getRole().name()) + .claim("name", user.getFullName()) + .claim("uid", user.getId()) + .issuedAt(Date.from(now)) + .expiration(Date.from(now.plusSeconds(expirationSeconds))) + .signWith(key) + .compact(); + } + + public Claims parse(String token) { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java b/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java new file mode 100644 index 0000000..12f9e3b --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java @@ -0,0 +1,51 @@ +package pl.polskalokalnie.auth; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +public class SecurityConfig { + + private final JwtAuthFilter jwtAuthFilter; + + public SecurityConfig(JwtAuthFilter jwtAuthFilter) { + this.jwtAuthFilter = jwtAuthFilter; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .cors(Customizer.withDefaults()) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers("/error").permitAll() + .requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/social").permitAll() + .requestMatchers("/api/i18n/translate").permitAll() + .requestMatchers("/api/auth/me").authenticated() + .requestMatchers(HttpMethod.GET, "/api/listings/mine").authenticated() + .requestMatchers(HttpMethod.GET, "/api/listings", "/api/listings/**").permitAll() + .requestMatchers("/api/admin/**").hasRole("ADMIN") + .requestMatchers(HttpMethod.POST, "/api/listings").authenticated() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/auth/dto/AuthResponse.java b/backend/src/main/java/pl/polskalokalnie/auth/dto/AuthResponse.java new file mode 100644 index 0000000..4f72351 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/auth/dto/AuthResponse.java @@ -0,0 +1,7 @@ +package pl.polskalokalnie.auth.dto; + +public record AuthResponse( + String token, + UserResponse user +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/auth/dto/LoginRequest.java b/backend/src/main/java/pl/polskalokalnie/auth/dto/LoginRequest.java new file mode 100644 index 0000000..c6fbdfb --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/auth/dto/LoginRequest.java @@ -0,0 +1,10 @@ +package pl.polskalokalnie.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @NotBlank @Email String email, + @NotBlank String password +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/auth/dto/RegisterRequest.java b/backend/src/main/java/pl/polskalokalnie/auth/dto/RegisterRequest.java new file mode 100644 index 0000000..a99f7b1 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/auth/dto/RegisterRequest.java @@ -0,0 +1,16 @@ +package pl.polskalokalnie.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import pl.polskalokalnie.user.AccountType; + +public record RegisterRequest( + @NotBlank @Email String email, + @NotBlank @Size(min = 6, max = 72) String password, + @NotBlank String fullName, + AccountType accountType, + String phone, + String nip +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/auth/dto/SocialLoginRequest.java b/backend/src/main/java/pl/polskalokalnie/auth/dto/SocialLoginRequest.java new file mode 100644 index 0000000..a617062 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/auth/dto/SocialLoginRequest.java @@ -0,0 +1,15 @@ +package pl.polskalokalnie.auth.dto; + +import jakarta.validation.constraints.NotNull; +import pl.polskalokalnie.user.AuthProvider; + +/** + * Symulowane logowanie spoleczne. W wersji produkcyjnej email/name pochodzilyby + * z tokenu dostawcy OAuth (Google/Facebook); tutaj przychodza z frontendu jako dane demo. + */ +public record SocialLoginRequest( + @NotNull AuthProvider provider, + String email, + String fullName +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/auth/dto/UpdateProfileRequest.java b/backend/src/main/java/pl/polskalokalnie/auth/dto/UpdateProfileRequest.java new file mode 100644 index 0000000..b23bf48 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/auth/dto/UpdateProfileRequest.java @@ -0,0 +1,15 @@ +package pl.polskalokalnie.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import pl.polskalokalnie.user.ContactPreference; +import pl.polskalokalnie.user.PreferredLanguage; + +public record UpdateProfileRequest( + @NotBlank String fullName, + String phone, + String birthDate, + String address, + ContactPreference contactPreference, + PreferredLanguage preferredLanguage +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/auth/dto/UserResponse.java b/backend/src/main/java/pl/polskalokalnie/auth/dto/UserResponse.java new file mode 100644 index 0000000..8b2f2fa --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/auth/dto/UserResponse.java @@ -0,0 +1,48 @@ +package pl.polskalokalnie.auth.dto; + +import java.time.Instant; +import java.time.LocalDate; +import pl.polskalokalnie.user.AccountType; +import pl.polskalokalnie.user.AppUser; +import pl.polskalokalnie.user.AuthProvider; +import pl.polskalokalnie.user.ContactPreference; +import pl.polskalokalnie.user.PreferredLanguage; +import pl.polskalokalnie.user.Role; + +public record UserResponse( + Long id, + String email, + String fullName, + Role role, + AuthProvider provider, + AccountType accountType, + String phone, + String address, + ContactPreference contactPreference, + PreferredLanguage preferredLanguage, + String nip, + LocalDate birthDate, + boolean verified, + boolean blocked, + Instant createdAt +) { + public static UserResponse from(AppUser user) { + return new UserResponse( + user.getId(), + user.getEmail(), + user.getFullName(), + user.getRole(), + user.getProvider(), + user.getAccountType(), + user.getPhone(), + user.getAddress(), + user.getContactPreference() != null ? user.getContactPreference() : ContactPreference.EMAIL_AND_PHONE, + user.getPreferredLanguage() != null ? user.getPreferredLanguage() : PreferredLanguage.PL, + user.getNip(), + user.getBirthDate(), + user.isVerified(), + user.isBlocked(), + user.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/config/DataSeeder.java b/backend/src/main/java/pl/polskalokalnie/config/DataSeeder.java new file mode 100644 index 0000000..235deb3 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/config/DataSeeder.java @@ -0,0 +1,100 @@ +package pl.polskalokalnie.config; + +import java.math.BigDecimal; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.CommandLineRunner; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import pl.polskalokalnie.listing.ListingRepository; +import pl.polskalokalnie.listing.ListingStatus; +import pl.polskalokalnie.listing.OfferType; +import pl.polskalokalnie.listing.PropertyListing; +import pl.polskalokalnie.listing.PropertyType; +import pl.polskalokalnie.user.AppUser; +import pl.polskalokalnie.user.AuthProvider; +import pl.polskalokalnie.user.Role; +import pl.polskalokalnie.user.UserRepository; + +@Component +public class DataSeeder implements CommandLineRunner { + + private final UserRepository userRepository; + private final ListingRepository listingRepository; + private final PasswordEncoder passwordEncoder; + private final String adminEmail; + private final String adminPassword; + private final String adminName; + + public DataSeeder( + UserRepository userRepository, + ListingRepository listingRepository, + PasswordEncoder passwordEncoder, + @Value("${app.admin.email:admin@mieszko.pl}") String adminEmail, + @Value("${app.admin.password:Admin123!}") String adminPassword, + @Value("${app.admin.name:Administrator Mieszko}") String adminName + ) { + this.userRepository = userRepository; + this.listingRepository = listingRepository; + this.passwordEncoder = passwordEncoder; + this.adminEmail = adminEmail; + this.adminPassword = adminPassword; + this.adminName = adminName; + } + + @Override + public void run(String... args) { + seedAdmin(); + seedDemoListings(); + } + + private void seedAdmin() { + if (userRepository.existsByEmailIgnoreCase(adminEmail)) { + return; + } + AppUser admin = new AppUser(); + admin.setEmail(adminEmail.toLowerCase()); + admin.setFullName(adminName); + admin.setPasswordHash(passwordEncoder.encode(adminPassword)); + admin.setRole(Role.ADMIN); + admin.setProvider(AuthProvider.LOCAL); + admin.setVerified(true); + userRepository.save(admin); + } + + private void seedDemoListings() { + if (listingRepository.count() > 0) { + return; + } + listingRepository.save(demoListing( + "Słoneczne 3 pokoje na Mokotowie", "Rozkładowe mieszkanie po remoncie, blisko metra.", + OfferType.SALE, PropertyType.APARTMENT, "Warszawa", "ul. Puławska 120", + new BigDecimal("749000"), 62.0, 3, ListingStatus.APPROVED)); + listingRepository.save(demoListing( + "Dom z ogrodem pod Krakowem", "Wolnostojący dom, działka 600 m², cicha okolica.", + OfferType.SALE, PropertyType.HOUSE, "Kraków", "ul. Podgórska 8", + new BigDecimal("1290000"), 145.0, 5, ListingStatus.PENDING)); + listingRepository.save(demoListing( + "Kawalerka do wynajęcia — Wrocław", "Umeblowana kawalerka w centrum, dostępna od zaraz.", + OfferType.RENT, PropertyType.APARTMENT, "Wrocław", "ul. Krupnicza 3", + new BigDecimal("2600"), 30.0, 1, ListingStatus.PENDING)); + } + + private PropertyListing demoListing( + String title, String description, OfferType offerType, PropertyType propertyType, + String city, String address, BigDecimal price, Double area, Integer rooms, ListingStatus status + ) { + PropertyListing listing = new PropertyListing(); + listing.setTitle(title); + listing.setDescription(description); + listing.setOfferType(offerType); + listing.setPropertyType(propertyType); + listing.setCity(city); + listing.setAddress(address); + listing.setPrice(price); + listing.setArea(area); + listing.setRooms(rooms); + listing.setOwnerEmail("demo.user@mieszko.pl"); + listing.setStatus(status); + return listing; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java b/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java new file mode 100644 index 0000000..705aaa7 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java @@ -0,0 +1,75 @@ +package pl.polskalokalnie.config; + +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; + +@Configuration +public class SchemaFixer { + + @Bean + ApplicationRunner ensureViewsCountColumn(JdbcTemplate jdbcTemplate) { + return args -> { + jdbcTemplate.execute( + "ALTER TABLE property_listings ADD COLUMN IF NOT EXISTS views_count BIGINT NOT NULL DEFAULT 0" + ); + jdbcTemplate.execute( + "ALTER TABLE app_users ADD COLUMN IF NOT EXISTS address VARCHAR(255)" + ); + jdbcTemplate.execute( + "ALTER TABLE app_users ADD COLUMN IF NOT EXISTS contact_preference VARCHAR(20)" + ); + jdbcTemplate.execute( + "UPDATE app_users SET contact_preference = 'EMAIL_AND_PHONE' WHERE contact_preference IS NULL" + ); + jdbcTemplate.execute( + "ALTER TABLE app_users ALTER COLUMN contact_preference SET DEFAULT 'EMAIL_AND_PHONE'" + ); + jdbcTemplate.execute( + "ALTER TABLE app_users ALTER COLUMN contact_preference SET NOT NULL" + ); + jdbcTemplate.execute( + "ALTER TABLE app_users ADD COLUMN IF NOT EXISTS preferred_language VARCHAR(10)" + ); + jdbcTemplate.execute( + "UPDATE app_users SET preferred_language = 'PL' WHERE preferred_language IS NULL" + ); + jdbcTemplate.execute( + "ALTER TABLE app_users ALTER COLUMN preferred_language SET DEFAULT 'PL'" + ); + jdbcTemplate.execute( + "ALTER TABLE app_users ALTER COLUMN preferred_language SET NOT NULL" + ); + jdbcTemplate.execute( + "ALTER TABLE IF EXISTS listing_report_attachments ADD COLUMN IF NOT EXISTS file_type VARCHAR(120)" + ); + jdbcTemplate.execute( + "ALTER TABLE IF EXISTS listing_report_attachments ADD COLUMN IF NOT EXISTS data_url TEXT" + ); + + String dataUrlType = jdbcTemplate.query( + "SELECT data_type FROM information_schema.columns WHERE table_name = 'listing_report_attachments' AND column_name = 'data_url'", + rs -> rs.next() ? rs.getString(1) : null + ); + + if ("oid".equalsIgnoreCase(dataUrlType)) { + jdbcTemplate.execute("ALTER TABLE listing_report_attachments ADD COLUMN IF NOT EXISTS data_url_text TEXT"); + jdbcTemplate.execute( + "UPDATE listing_report_attachments " + + "SET data_url_text = CASE WHEN data_url IS NULL THEN NULL ELSE convert_from(lo_get(data_url), 'UTF8') END" + ); + jdbcTemplate.execute( + "DO $$ " + + "BEGIN " + + " IF EXISTS (SELECT 1 FROM listing_report_attachments WHERE data_url IS NOT NULL) THEN " + + " PERFORM lo_unlink(data_url) FROM listing_report_attachments WHERE data_url IS NOT NULL; " + + " END IF; " + + "END $$" + ); + jdbcTemplate.execute("ALTER TABLE listing_report_attachments DROP COLUMN data_url"); + jdbcTemplate.execute("ALTER TABLE listing_report_attachments RENAME COLUMN data_url_text TO data_url"); + } + }; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/config/WebConfig.java b/backend/src/main/java/pl/polskalokalnie/config/WebConfig.java new file mode 100644 index 0000000..48a09c6 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/config/WebConfig.java @@ -0,0 +1,38 @@ +package pl.polskalokalnie.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/api/**") + .allowedOriginPatterns( + "http://localhost:[*]", + "http://127.0.0.1:[*]", + "http://192.168.*.*:[*]", + "http://10.*.*.*:[*]", + "http://172.16.*.*:[*]", + "http://172.17.*.*:[*]", + "http://172.18.*.*:[*]", + "http://172.19.*.*:[*]", + "http://172.20.*.*:[*]", + "http://172.21.*.*:[*]", + "http://172.22.*.*:[*]", + "http://172.23.*.*:[*]", + "http://172.24.*.*:[*]", + "http://172.25.*.*:[*]", + "http://172.26.*.*:[*]", + "http://172.27.*.*:[*]", + "http://172.28.*.*:[*]", + "http://172.29.*.*:[*]", + "http://172.30.*.*:[*]", + "http://172.31.*.*:[*]", + "http://*.local:[*]") + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowedHeaders("*"); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/i18n/RuntimeTranslationService.java b/backend/src/main/java/pl/polskalokalnie/i18n/RuntimeTranslationService.java new file mode 100644 index 0000000..f6c86b8 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/i18n/RuntimeTranslationService.java @@ -0,0 +1,132 @@ +package pl.polskalokalnie.i18n; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import pl.polskalokalnie.user.PreferredLanguage; + +@Service +public class RuntimeTranslationService { + + private static final int MAX_TEXTS_PER_REQUEST = 180; + private static final int MAX_TEXT_LENGTH = 400; + + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(6)) + .build(); + private final ObjectMapper objectMapper = new ObjectMapper(); + private final Map cache = new ConcurrentHashMap<>(); + + public Map translateBatch(PreferredLanguage language, List texts) { + if (texts == null || texts.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak tekstów do tłumaczenia"); + } + if (texts.size() > MAX_TEXTS_PER_REQUEST) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Zbyt wiele tekstów do tłumaczenia w jednym żądaniu"); + } + + if (language == PreferredLanguage.PL) { + Map passthrough = new LinkedHashMap<>(); + texts.forEach((text) -> passthrough.put(text, text)); + return passthrough; + } + + String targetCode = toGoogleLang(language); + Map results = new LinkedHashMap<>(); + List toTranslate = new ArrayList<>(); + + for (String text : texts) { + String safeText = text != null ? text : ""; + if (safeText.length() > MAX_TEXT_LENGTH) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Jeden z tekstów jest zbyt długi do tłumaczenia"); + } + + String cacheKey = targetCode + "|" + safeText; + String cached = cache.get(cacheKey); + if (cached != null) { + results.put(safeText, cached); + } else { + toTranslate.add(safeText); + } + } + + for (String text : toTranslate) { + String translated = translateSingle(text, targetCode); + cache.put(targetCode + "|" + text, translated); + results.put(text, translated); + } + + return results; + } + + private String translateSingle(String source, String targetCode) { + if (source.isBlank()) { + return source; + } + + String encoded = URLEncoder.encode(source, StandardCharsets.UTF_8); + String url = "https://translate.googleapis.com/translate_a/single?client=gtx&sl=pl&tl=" + + targetCode + "&dt=t&q=" + encoded; + + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) + .GET() + .timeout(Duration.ofSeconds(10)) + .header("Accept", "application/json") + .header("User-Agent", "PolskaLokalnie/1.0") + .build(); + + try { + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Usługa tłumaczeń jest chwilowo niedostępna"); + } + return parseTranslatedText(response.body(), source); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Nie udało się pobrać tłumaczenia", ex); + } catch (IOException ex) { + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Nie udało się pobrać tłumaczenia", ex); + } + } + + private String parseTranslatedText(String payload, String fallback) throws IOException { + JsonNode root = objectMapper.readTree(payload); + JsonNode topSegments = root.path(0); + if (!topSegments.isArray() || topSegments.isEmpty()) { + return fallback; + } + StringBuilder translated = new StringBuilder(); + for (JsonNode segment : topSegments) { + JsonNode part = segment.path(0); + if (part.isTextual()) { + translated.append(part.asText()); + } + } + String value = translated.toString(); + return value.isBlank() ? fallback : value; + } + + private String toGoogleLang(PreferredLanguage language) { + return switch (language) { + case EN -> "en"; + case UK -> "uk"; + case DE -> "de"; + case PL -> "pl"; + }; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/i18n/TranslateRequest.java b/backend/src/main/java/pl/polskalokalnie/i18n/TranslateRequest.java new file mode 100644 index 0000000..c1a4f56 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/i18n/TranslateRequest.java @@ -0,0 +1,12 @@ +package pl.polskalokalnie.i18n; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import pl.polskalokalnie.user.PreferredLanguage; + +public record TranslateRequest( + @NotNull PreferredLanguage language, + @NotEmpty List texts +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/i18n/TranslateResponse.java b/backend/src/main/java/pl/polskalokalnie/i18n/TranslateResponse.java new file mode 100644 index 0000000..f553d78 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/i18n/TranslateResponse.java @@ -0,0 +1,10 @@ +package pl.polskalokalnie.i18n; + +import java.util.Map; +import pl.polskalokalnie.user.PreferredLanguage; + +public record TranslateResponse( + PreferredLanguage language, + Map translations +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/i18n/TranslationController.java b/backend/src/main/java/pl/polskalokalnie/i18n/TranslationController.java new file mode 100644 index 0000000..75544af --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/i18n/TranslationController.java @@ -0,0 +1,31 @@ +package pl.polskalokalnie.i18n; + +import jakarta.validation.Valid; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/i18n") +public class TranslationController { + + private final RuntimeTranslationService runtimeTranslationService; + + public TranslationController(RuntimeTranslationService runtimeTranslationService) { + this.runtimeTranslationService = runtimeTranslationService; + } + + @PostMapping("/translate") + public TranslateResponse translate(@Valid @RequestBody TranslateRequest request) { + List normalizedTexts = request.texts().stream() + .map(text -> text == null ? "" : text) + .distinct() + .collect(Collectors.toList()); + Map translations = runtimeTranslationService.translateBatch(request.language(), normalizedTexts); + return new TranslateResponse(request.language(), translations); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java new file mode 100644 index 0000000..26c8a4e --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java @@ -0,0 +1,58 @@ +package pl.polskalokalnie.listing; + +import jakarta.validation.Valid; +import java.net.URI; +import java.util.List; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/listings") +public class ListingController { + + private final ListingService listingService; + + public ListingController(ListingService listingService) { + this.listingService = listingService; + } + + @GetMapping + public List search( + @RequestParam(required = false) String city, + @RequestParam(required = false) OfferType offerType, + @RequestParam(required = false) PropertyType propertyType + ) { + return listingService.search(city, offerType, propertyType); + } + + @GetMapping("/mine") + public List mine(Authentication authentication) { + return listingService.findMine(authentication.getName()); + } + + @GetMapping("/{id}") + public ListingDetailResponse getById( + @PathVariable Long id, + @RequestParam(defaultValue = "false") boolean incrementView + ) { + return listingService.getById(id, incrementView); + } + + @PostMapping + public ResponseEntity create( + @Valid @RequestBody ListingCreateRequest request, + Authentication authentication + ) { + ListingDetailResponse response = listingService.create(request, authentication.getName()); + return ResponseEntity + .created(URI.create("/api/listings/" + response.id())) + .body(response); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingCreateRequest.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingCreateRequest.java new file mode 100644 index 0000000..2f28404 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingCreateRequest.java @@ -0,0 +1,47 @@ +package pl.polskalokalnie.listing; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.math.BigDecimal; +import java.util.List; + +public record ListingCreateRequest( + @NotBlank String title, + @NotBlank String description, + @NotNull OfferType offerType, + @NotNull PropertyType propertyType, + @NotBlank String city, + String address, + @NotNull @Positive BigDecimal price, + @NotNull @Positive Double area, + @NotNull @Min(1) Integer rooms, + // --- pola opcjonalne --- + String market, + String district, + String street, + String building, + String floor, + String buildingFloors, + Integer yearBuilt, + String condition, + String ownership, + String contactName, + String contactPhone, + String contactEmail, + BigDecimal rentExtra, + String availableFrom, + String furnishing, + String buildingType, + String buildingMaterial, + String windows, + String exposure, + String roomHeight, + Double lat, + Double lng, + List media, + List amenities, + List photos +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java new file mode 100644 index 0000000..49420d4 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java @@ -0,0 +1,95 @@ +package pl.polskalokalnie.listing; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +/** + * Pelna reprezentacja ogloszenia dla widoku szczegolow - zawiera wszystkie pola z formularza + * dodawania oraz cala galerie zdjec (data URL). + */ +public record ListingDetailResponse( + Long id, + String title, + String description, + OfferType offerType, + PropertyType propertyType, + String city, + String district, + String street, + String building, + String address, + BigDecimal price, + Double area, + Integer rooms, + String floor, + String buildingFloors, + String market, + Integer yearBuilt, + String condition, + String ownership, + String contactName, + String contactPhone, + String contactEmail, + BigDecimal rentExtra, + String availableFrom, + String furnishing, + String buildingType, + String buildingMaterial, + String windows, + String exposure, + String roomHeight, + Double lat, + Double lng, + List media, + List amenities, + List photos, + String ownerEmail, + ListingStatus status, + Instant createdAt, + Long viewsCount +) { + public static ListingDetailResponse from(PropertyListing listing) { + return new ListingDetailResponse( + listing.getId(), + listing.getTitle(), + listing.getDescription(), + listing.getOfferType(), + listing.getPropertyType(), + listing.getCity(), + listing.getDistrict(), + listing.getStreet(), + listing.getBuilding(), + listing.getAddress(), + listing.getPrice(), + listing.getArea(), + listing.getRooms(), + listing.getFloor(), + listing.getBuildingFloors(), + listing.getMarket(), + listing.getYearBuilt(), + listing.getCondition(), + listing.getOwnership(), + listing.getContactName(), + listing.getContactPhone(), + listing.getContactEmail(), + listing.getRentExtra(), + listing.getAvailableFrom(), + listing.getFurnishing(), + listing.getBuildingType(), + listing.getBuildingMaterial(), + listing.getWindows(), + listing.getExposure(), + listing.getRoomHeight(), + listing.getLat(), + listing.getLng(), + List.copyOf(listing.getMedia()), + List.copyOf(listing.getAmenities()), + List.copyOf(listing.getPhotos()), + listing.getOwnerEmail(), + listing.getStatus(), + listing.getCreatedAt(), + listing.getViewsCount() + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingRepository.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingRepository.java new file mode 100644 index 0000000..796ba56 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingRepository.java @@ -0,0 +1,6 @@ +package pl.polskalokalnie.listing; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ListingRepository extends JpaRepository { +} diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingResponse.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingResponse.java new file mode 100644 index 0000000..7b30a4d --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingResponse.java @@ -0,0 +1,54 @@ +package pl.polskalokalnie.listing; + +import java.math.BigDecimal; +import java.time.Instant; + +/** + * Lekka reprezentacja ogloszenia do list (Kupuje/Wynajmuje, moderacja, moje ogloszenia). + * Nie zawiera pelnej galerii ani pol szczegolowych - od tego jest {@link ListingDetailResponse}. + */ +public record ListingResponse( + Long id, + String title, + String description, + OfferType offerType, + PropertyType propertyType, + String city, + String district, + String address, + BigDecimal price, + Double area, + Integer rooms, + String floor, + String buildingFloors, + String market, + Integer yearBuilt, + String coverPhoto, + String ownerEmail, + ListingStatus status, + Instant createdAt +) { + public static ListingResponse from(PropertyListing listing) { + return new ListingResponse( + listing.getId(), + listing.getTitle(), + listing.getDescription(), + listing.getOfferType(), + listing.getPropertyType(), + listing.getCity(), + listing.getDistrict(), + listing.getAddress(), + listing.getPrice(), + listing.getArea(), + listing.getRooms(), + listing.getFloor(), + listing.getBuildingFloors(), + listing.getMarket(), + listing.getYearBuilt(), + listing.getCoverPhoto(), + listing.getOwnerEmail(), + listing.getStatus(), + listing.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java new file mode 100644 index 0000000..7d91867 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java @@ -0,0 +1,168 @@ +package pl.polskalokalnie.listing; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import pl.polskalokalnie.moderation.TextModerationService; + +@Service +public class ListingService { + + private static final int MAX_PHOTOS = 8; + + private final ListingRepository listingRepository; + private final TextModerationService textModerationService; + + public ListingService(ListingRepository listingRepository, TextModerationService textModerationService) { + this.listingRepository = listingRepository; + this.textModerationService = textModerationService; + } + + public List search(String city, OfferType offerType, PropertyType propertyType) { + return listingRepository.findAll().stream() + .filter(listing -> listing.getStatus() == ListingStatus.APPROVED) + .filter(listing -> city == null || listing.getCity().equalsIgnoreCase(city.trim())) + .filter(listing -> offerType == null || listing.getOfferType() == offerType) + .filter(listing -> propertyType == null || listing.getPropertyType() == propertyType) + .sorted(Comparator.comparing(PropertyListing::getCreatedAt).reversed()) + .map(ListingResponse::from) + .toList(); + } + + @Transactional + public ListingDetailResponse getById(Long id, boolean incrementView) { + PropertyListing listing = listingRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found")); + + if (incrementView) { + listing.setViewsCount((listing.getViewsCount() == null ? 0L : listing.getViewsCount()) + 1L); + listing = listingRepository.save(listing); + } + + return ListingDetailResponse.from(listing); + } + + public List findMine(String ownerEmail) { + return listingRepository.findAll().stream() + .filter(listing -> ownerEmail != null && ownerEmail.equalsIgnoreCase(listing.getOwnerEmail())) + .sorted(Comparator.comparing(PropertyListing::getCreatedAt).reversed()) + .map(ListingResponse::from) + .toList(); + } + + @Transactional + public ListingDetailResponse create(ListingCreateRequest request, String ownerEmail) { + textModerationService.validateOrThrow( + request.title(), request.description(), request.city(), request.address(), + request.district(), request.street(), request.contactName()); + + PropertyListing listing = new PropertyListing(); + listing.setTitle(request.title().trim()); + listing.setDescription(request.description().trim()); + listing.setOfferType(request.offerType()); + listing.setPropertyType(request.propertyType()); + listing.setCity(request.city().trim()); + listing.setAddress(trimToNull(request.address())); + listing.setPrice(request.price()); + listing.setArea(request.area()); + listing.setRooms(request.rooms()); + + listing.setMarket(trimToNull(request.market())); + listing.setDistrict(trimToNull(request.district())); + listing.setStreet(trimToNull(request.street())); + listing.setBuilding(trimToNull(request.building())); + listing.setFloor(trimToNull(request.floor())); + listing.setBuildingFloors(trimToNull(request.buildingFloors())); + listing.setYearBuilt(request.yearBuilt()); + listing.setCondition(trimToNull(request.condition())); + listing.setOwnership(trimToNull(request.ownership())); + listing.setContactName(trimToNull(request.contactName())); + listing.setContactPhone(trimToNull(request.contactPhone())); + listing.setContactEmail(trimToNull(request.contactEmail())); + listing.setRentExtra(request.rentExtra()); + listing.setAvailableFrom(trimToNull(request.availableFrom())); + listing.setFurnishing(trimToNull(request.furnishing())); + listing.setBuildingType(trimToNull(request.buildingType())); + listing.setBuildingMaterial(trimToNull(request.buildingMaterial())); + listing.setWindows(trimToNull(request.windows())); + listing.setExposure(trimToNull(request.exposure())); + listing.setRoomHeight(trimToNull(request.roomHeight())); + listing.setLat(request.lat()); + listing.setLng(request.lng()); + + listing.setMedia(cleanList(request.media())); + listing.setAmenities(cleanList(request.amenities())); + + List photos = limitPhotos(request.photos()); + listing.setPhotos(photos); + listing.setCoverPhoto(photos.isEmpty() ? null : photos.get(0)); + + listing.setOwnerEmail(ownerEmail); + listing.setStatus(ListingStatus.PENDING); + + return ListingDetailResponse.from(listingRepository.save(listing)); + } + + // --- Moderacja (admin) --- + + public List findAllForModeration() { + return listingRepository.findAll().stream() + .sorted(Comparator.comparing(PropertyListing::getCreatedAt).reversed()) + .map(ListingResponse::from) + .toList(); + } + + public ListingResponse changeStatus(Long id, ListingStatus status) { + PropertyListing listing = listingRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found")); + listing.setStatus(status); + return ListingResponse.from(listingRepository.save(listing)); + } + + public void delete(Long id) { + if (!listingRepository.existsById(id)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found"); + } + listingRepository.deleteById(id); + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static List cleanList(List values) { + List cleaned = new ArrayList<>(); + if (values != null) { + for (String value : values) { + String trimmed = trimToNull(value); + if (trimmed != null && !cleaned.contains(trimmed)) { + cleaned.add(trimmed); + } + } + } + return cleaned; + } + + private static List limitPhotos(List photos) { + List result = new ArrayList<>(); + if (photos != null) { + for (String photo : photos) { + if (photo != null && !photo.isBlank()) { + result.add(photo); + if (result.size() >= MAX_PHOTOS) { + break; + } + } + } + } + return result; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingStatus.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingStatus.java new file mode 100644 index 0000000..8a0c0d5 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingStatus.java @@ -0,0 +1,7 @@ +package pl.polskalokalnie.listing; + +public enum ListingStatus { + PENDING, + APPROVED, + REJECTED +} diff --git a/backend/src/main/java/pl/polskalokalnie/listing/OfferType.java b/backend/src/main/java/pl/polskalokalnie/listing/OfferType.java new file mode 100644 index 0000000..462ee88 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/listing/OfferType.java @@ -0,0 +1,6 @@ +package pl.polskalokalnie.listing; + +public enum OfferType { + SALE, + RENT +} diff --git a/backend/src/main/java/pl/polskalokalnie/listing/PropertyListing.java b/backend/src/main/java/pl/polskalokalnie/listing/PropertyListing.java new file mode 100644 index 0000000..74b5392 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/listing/PropertyListing.java @@ -0,0 +1,476 @@ +package pl.polskalokalnie.listing; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OrderColumn; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "property_listings") +public class PropertyListing { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 160) + private String title; + + @Column(nullable = false, length = 3000) + private String description; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private OfferType offerType; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private PropertyType propertyType; + + @Column(nullable = false, length = 120) + private String city; + + @Column(length = 220) + private String address; + + @Column(nullable = false, precision = 14, scale = 2) + private BigDecimal price; + + @Column(nullable = false) + private Double area; + + @Column(nullable = false) + private Integer rooms; + + // --- Dodatkowe pola z formularza dodawania ogloszenia (wszystkie opcjonalne) --- + + @Column(length = 30) + private String market; + + @Column(length = 120) + private String district; + + @Column(length = 160) + private String street; + + @Column(length = 30) + private String building; + + @Column(length = 20) + private String floor; + + @Column(length = 20) + private String buildingFloors; + + private Integer yearBuilt; + + @Column(length = 40) + private String condition; + + @Column(length = 40) + private String ownership; + + @Column(length = 120) + private String contactName; + + @Column(length = 40) + private String contactPhone; + + @Column(length = 180) + private String contactEmail; + + // Dodatkowy czynsz (dla wynajmu) + @Column(precision = 14, scale = 2) + private BigDecimal rentExtra; + + @Column(length = 40) + private String availableFrom; + + @Column(length = 40) + private String furnishing; + + @Column(length = 40) + private String buildingType; + + @Column(length = 40) + private String buildingMaterial; + + @Column(length = 40) + private String windows; + + @Column(length = 40) + private String exposure; + + @Column(length = 20) + private String roomHeight; + + private Double lat; + + private Double lng; + + @ElementCollection + @CollectionTable(name = "listing_media", joinColumns = @JoinColumn(name = "listing_id")) + @Column(name = "value", length = 60) + private List media = new ArrayList<>(); + + @ElementCollection + @CollectionTable(name = "listing_amenities", joinColumns = @JoinColumn(name = "listing_id")) + @Column(name = "value", length = 60) + private List amenities = new ArrayList<>(); + + // Zdjecia trzymane jako data URL (base64). Osobna tabela z kolumna TEXT. + @ElementCollection + @CollectionTable(name = "listing_photos", joinColumns = @JoinColumn(name = "listing_id")) + @OrderColumn(name = "position") + @Column(name = "photo", columnDefinition = "text") + private List photos = new ArrayList<>(); + + // Pierwsze zdjecie skopiowane jako okladka, zeby listy nie musialy ladowac calej galerii. + @Column(columnDefinition = "text") + private String coverPhoto; + + @Column(length = 180) + private String ownerEmail; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private ListingStatus status = ListingStatus.PENDING; + + @Column(nullable = false) + private Long viewsCount = 0L; + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + void setCreatedAtOnInsert() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } + + public Long getId() { + return id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public OfferType getOfferType() { + return offerType; + } + + public void setOfferType(OfferType offerType) { + this.offerType = offerType; + } + + public PropertyType getPropertyType() { + return propertyType; + } + + public void setPropertyType(PropertyType propertyType) { + this.propertyType = propertyType; + } + + public String getCity() { + return city; + } + + public void setCity(String city) { + this.city = city; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + public BigDecimal getPrice() { + return price; + } + + public void setPrice(BigDecimal price) { + this.price = price; + } + + public Double getArea() { + return area; + } + + public void setArea(Double area) { + this.area = area; + } + + public Integer getRooms() { + return rooms; + } + + public void setRooms(Integer rooms) { + this.rooms = rooms; + } + + public String getMarket() { + return market; + } + + public void setMarket(String market) { + this.market = market; + } + + public String getDistrict() { + return district; + } + + public void setDistrict(String district) { + this.district = district; + } + + public String getStreet() { + return street; + } + + public void setStreet(String street) { + this.street = street; + } + + public String getBuilding() { + return building; + } + + public void setBuilding(String building) { + this.building = building; + } + + public String getFloor() { + return floor; + } + + public void setFloor(String floor) { + this.floor = floor; + } + + public String getBuildingFloors() { + return buildingFloors; + } + + public void setBuildingFloors(String buildingFloors) { + this.buildingFloors = buildingFloors; + } + + public Integer getYearBuilt() { + return yearBuilt; + } + + public void setYearBuilt(Integer yearBuilt) { + this.yearBuilt = yearBuilt; + } + + public String getCondition() { + return condition; + } + + public void setCondition(String condition) { + this.condition = condition; + } + + public String getOwnership() { + return ownership; + } + + public void setOwnership(String ownership) { + this.ownership = ownership; + } + + public String getContactName() { + return contactName; + } + + public void setContactName(String contactName) { + this.contactName = contactName; + } + + public String getContactPhone() { + return contactPhone; + } + + public void setContactPhone(String contactPhone) { + this.contactPhone = contactPhone; + } + + public String getContactEmail() { + return contactEmail; + } + + public void setContactEmail(String contactEmail) { + this.contactEmail = contactEmail; + } + + public BigDecimal getRentExtra() { + return rentExtra; + } + + public void setRentExtra(BigDecimal rentExtra) { + this.rentExtra = rentExtra; + } + + public String getAvailableFrom() { + return availableFrom; + } + + public void setAvailableFrom(String availableFrom) { + this.availableFrom = availableFrom; + } + + public String getFurnishing() { + return furnishing; + } + + public void setFurnishing(String furnishing) { + this.furnishing = furnishing; + } + + public String getBuildingType() { + return buildingType; + } + + public void setBuildingType(String buildingType) { + this.buildingType = buildingType; + } + + public String getBuildingMaterial() { + return buildingMaterial; + } + + public void setBuildingMaterial(String buildingMaterial) { + this.buildingMaterial = buildingMaterial; + } + + public String getWindows() { + return windows; + } + + public void setWindows(String windows) { + this.windows = windows; + } + + public String getExposure() { + return exposure; + } + + public void setExposure(String exposure) { + this.exposure = exposure; + } + + public String getRoomHeight() { + return roomHeight; + } + + public void setRoomHeight(String roomHeight) { + this.roomHeight = roomHeight; + } + + public Double getLat() { + return lat; + } + + public void setLat(Double lat) { + this.lat = lat; + } + + public Double getLng() { + return lng; + } + + public void setLng(Double lng) { + this.lng = lng; + } + + public List getMedia() { + return media; + } + + public void setMedia(List media) { + this.media = media; + } + + public List getAmenities() { + return amenities; + } + + public void setAmenities(List amenities) { + this.amenities = amenities; + } + + public List getPhotos() { + return photos; + } + + public void setPhotos(List photos) { + this.photos = photos; + } + + public String getCoverPhoto() { + return coverPhoto; + } + + public void setCoverPhoto(String coverPhoto) { + this.coverPhoto = coverPhoto; + } + + public String getOwnerEmail() { + return ownerEmail; + } + + public void setOwnerEmail(String ownerEmail) { + this.ownerEmail = ownerEmail; + } + + public ListingStatus getStatus() { + return status; + } + + public void setStatus(ListingStatus status) { + this.status = status; + } + + public Long getViewsCount() { + return viewsCount; + } + + public void setViewsCount(Long viewsCount) { + this.viewsCount = viewsCount == null ? 0L : Math.max(0L, viewsCount); + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/listing/PropertyType.java b/backend/src/main/java/pl/polskalokalnie/listing/PropertyType.java new file mode 100644 index 0000000..93c97b4 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/listing/PropertyType.java @@ -0,0 +1,6 @@ +package pl.polskalokalnie.listing; + +public enum PropertyType { + APARTMENT, + HOUSE +} diff --git a/backend/src/main/java/pl/polskalokalnie/message/Message.java b/backend/src/main/java/pl/polskalokalnie/message/Message.java new file mode 100644 index 0000000..396e917 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/message/Message.java @@ -0,0 +1,81 @@ +package pl.polskalokalnie.message; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Instant; + +@Entity +@Table(name = "messages") +public class Message { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long senderId; + + @Column(nullable = false) + private Long recipientId; + + @Column(nullable = false, length = 2000) + private String content; + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + @Column(nullable = false) + private boolean read = false; + + @PrePersist + void onCreate() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } + + public Long getId() { + return id; + } + + public Long getSenderId() { + return senderId; + } + + public void setSenderId(Long senderId) { + this.senderId = senderId; + } + + public Long getRecipientId() { + return recipientId; + } + + public void setRecipientId(Long recipientId) { + this.recipientId = recipientId; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public boolean isRead() { + return read; + } + + public void setRead(boolean read) { + this.read = read; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/message/MessageController.java b/backend/src/main/java/pl/polskalokalnie/message/MessageController.java new file mode 100644 index 0000000..0913d00 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/message/MessageController.java @@ -0,0 +1,53 @@ +package pl.polskalokalnie.message; + +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; +import pl.polskalokalnie.user.AppUser; +import pl.polskalokalnie.user.UserRepository; + +@RestController +@RequestMapping("/api/messages") +public class MessageController { + + private final MessageService messageService; + private final UserRepository userRepository; + + public MessageController(MessageService messageService, UserRepository userRepository) { + this.messageService = messageService; + this.userRepository = userRepository; + } + + @GetMapping("/admin") + public List conversationWithAdmin(Authentication authentication) { + AppUser me = currentUser(authentication); + AppUser admin = messageService.resolveAdmin(); + return messageService.getConversation(me.getId(), admin.getId()); + } + + @PostMapping("/admin") + public MessageResponse sendToAdmin(Authentication authentication, @Valid @RequestBody SendMessageRequest request) { + AppUser me = currentUser(authentication); + AppUser admin = messageService.resolveAdmin(); + return messageService.send(me.getId(), admin.getId(), request.content()); + } + + @GetMapping("/unread-count") + public UnreadCountResponse unreadCount(Authentication authentication) { + AppUser me = currentUser(authentication); + AppUser admin = messageService.resolveAdmin(); + return new UnreadCountResponse(messageService.countUnreadFrom(me.getId(), admin.getId())); + } + + private AppUser currentUser(Authentication authentication) { + return userRepository.findByEmailIgnoreCase(authentication.getName()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED)); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/message/MessageRepository.java b/backend/src/main/java/pl/polskalokalnie/message/MessageRepository.java new file mode 100644 index 0000000..13412bc --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/message/MessageRepository.java @@ -0,0 +1,22 @@ +package pl.polskalokalnie.message; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +public interface MessageRepository extends JpaRepository { + + @Query("select m from Message m where (m.senderId = :first and m.recipientId = :second) " + + "or (m.senderId = :second and m.recipientId = :first) order by m.createdAt asc") + List findConversation(@Param("first") Long first, @Param("second") Long second); + + long countByRecipientIdAndSenderIdAndReadFalse(Long recipientId, Long senderId); + + @Modifying + @Transactional + @Query("update Message m set m.read = true where m.recipientId = :recipientId and m.senderId = :senderId and m.read = false") + void markConversationRead(@Param("recipientId") Long recipientId, @Param("senderId") Long senderId); +} diff --git a/backend/src/main/java/pl/polskalokalnie/message/MessageResponse.java b/backend/src/main/java/pl/polskalokalnie/message/MessageResponse.java new file mode 100644 index 0000000..baafbfe --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/message/MessageResponse.java @@ -0,0 +1,23 @@ +package pl.polskalokalnie.message; + +import java.time.Instant; + +public record MessageResponse( + Long id, + Long senderId, + Long recipientId, + String content, + Instant createdAt, + boolean mine +) { + public static MessageResponse from(Message message, Long currentUserId) { + return new MessageResponse( + message.getId(), + message.getSenderId(), + message.getRecipientId(), + message.getContent(), + message.getCreatedAt(), + message.getSenderId().equals(currentUserId) + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/message/MessageService.java b/backend/src/main/java/pl/polskalokalnie/message/MessageService.java new file mode 100644 index 0000000..1faefe8 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/message/MessageService.java @@ -0,0 +1,55 @@ +package pl.polskalokalnie.message; + +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import pl.polskalokalnie.moderation.TextModerationService; +import pl.polskalokalnie.user.AppUser; +import pl.polskalokalnie.user.Role; +import pl.polskalokalnie.user.UserRepository; + +@Service +public class MessageService { + + private final MessageRepository messageRepository; + private final UserRepository userRepository; + private final TextModerationService textModerationService; + + public MessageService( + MessageRepository messageRepository, + UserRepository userRepository, + TextModerationService textModerationService + ) { + this.messageRepository = messageRepository; + this.userRepository = userRepository; + this.textModerationService = textModerationService; + } + + public AppUser resolveAdmin() { + return userRepository.findFirstByRole(Role.ADMIN) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Brak konta administratora")); + } + + public List getConversation(Long currentUserId, Long otherUserId) { + messageRepository.markConversationRead(currentUserId, otherUserId); + return messageRepository.findConversation(currentUserId, otherUserId).stream() + .map(message -> MessageResponse.from(message, currentUserId)) + .toList(); + } + + public MessageResponse send(Long senderId, Long recipientId, String content) { + textModerationService.validateOrThrow(content); + + Message message = new Message(); + message.setSenderId(senderId); + message.setRecipientId(recipientId); + message.setContent(content.trim()); + Message saved = messageRepository.save(message); + return MessageResponse.from(saved, senderId); + } + + public long countUnreadFrom(Long recipientId, Long senderId) { + return messageRepository.countByRecipientIdAndSenderIdAndReadFalse(recipientId, senderId); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/message/SendMessageRequest.java b/backend/src/main/java/pl/polskalokalnie/message/SendMessageRequest.java new file mode 100644 index 0000000..6115b3f --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/message/SendMessageRequest.java @@ -0,0 +1,11 @@ +package pl.polskalokalnie.message; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record SendMessageRequest( + @NotBlank(message = "Wiadomość nie może być pusta") + @Size(max = 2000, message = "Wiadomość jest zbyt długa") + String content +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/message/UnreadCountResponse.java b/backend/src/main/java/pl/polskalokalnie/message/UnreadCountResponse.java new file mode 100644 index 0000000..ddbcadf --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/message/UnreadCountResponse.java @@ -0,0 +1,4 @@ +package pl.polskalokalnie.message; + +public record UnreadCountResponse(long count) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/moderation/ForbiddenWord.java b/backend/src/main/java/pl/polskalokalnie/moderation/ForbiddenWord.java new file mode 100644 index 0000000..af499cd --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/moderation/ForbiddenWord.java @@ -0,0 +1,48 @@ +package pl.polskalokalnie.moderation; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Instant; + +@Entity +@Table(name = "forbidden_words") +public class ForbiddenWord { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 120) + private String word; + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + void onCreate() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } + + public Long getId() { + return id; + } + + public String getWord() { + return word; + } + + public void setWord(String word) { + this.word = word; + } + + public Instant getCreatedAt() { + return createdAt; + } +} \ No newline at end of file diff --git a/backend/src/main/java/pl/polskalokalnie/moderation/ForbiddenWordRepository.java b/backend/src/main/java/pl/polskalokalnie/moderation/ForbiddenWordRepository.java new file mode 100644 index 0000000..b313669 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/moderation/ForbiddenWordRepository.java @@ -0,0 +1,8 @@ +package pl.polskalokalnie.moderation; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ForbiddenWordRepository extends JpaRepository { + + boolean existsByWordIgnoreCase(String word); +} \ No newline at end of file diff --git a/backend/src/main/java/pl/polskalokalnie/moderation/ForbiddenWordResponse.java b/backend/src/main/java/pl/polskalokalnie/moderation/ForbiddenWordResponse.java new file mode 100644 index 0000000..1ebb265 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/moderation/ForbiddenWordResponse.java @@ -0,0 +1,8 @@ +package pl.polskalokalnie.moderation; + +public record ForbiddenWordResponse(Long id, String word) { + + public static ForbiddenWordResponse from(ForbiddenWord forbiddenWord) { + return new ForbiddenWordResponse(forbiddenWord.getId(), forbiddenWord.getWord()); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/moderation/ForbiddenWordSeeder.java b/backend/src/main/java/pl/polskalokalnie/moderation/ForbiddenWordSeeder.java new file mode 100644 index 0000000..76f2113 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/moderation/ForbiddenWordSeeder.java @@ -0,0 +1,80 @@ +package pl.polskalokalnie.moderation; + +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.boot.CommandLineRunner; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import pl.polskalokalnie.settings.AppSetting; +import pl.polskalokalnie.settings.AppSettingRepository; + +/** + * Wsiewa domyslny slownik wulgaryzmow/wyzwisk do tabeli forbidden_words dokladnie raz w zyciu bazy. + * Dzieki temu baza jest jedynym zrodlem prawdy dla filtra tresci: brakujace slowa domyslne sa + * dodawane niezaleznie od tego, ile slow admin juz mial, a pozniejsze usuniecia sa TRWALE + * (po jednorazowym seedzie flaga jest ustawiona i slownik nie odradza sie przy restarcie). + */ +@Component +@Order(0) +public class ForbiddenWordSeeder implements CommandLineRunner { + + private static final String SEED_FLAG_KEY = "forbidden_words_seeded"; + + private static final List DEFAULT_WORDS = List.of( + "kurwa", "kurwy", "kurwie", "kurwo", "kurwą", "kurwami", "kurwić", "kurwic", "kurwica", + "kurwisko", "kurwiarz", "skurwysyn", "skurwysyna", "skurwysynu", "skurwysyny", "skurwiel", + "skurwiele", "wkurwia", "wkurwiać", "wkurwiony", "pierdol", "pierdolić", "pierdolic", + "pierdolę", "pierdole", "pierdolisz", "pierdolą", "pierdolony", "pierdolona", "pierdolone", + "popierdolony", "rozpierdol", "rozpierdolić", "rozpierdalać", "wpierdol", "wpierdolić", + "wpierdalać", "spierdalaj", "spierdalać", "spierdolić", "wypierdalaj", "wypierdalać", + "wypierdolić", "zapierdalać", "zapierdala", "zapierdol", "odpierdolić", "przypierdalać", + "chuj", "chuja", "chujowi", "chujem", "chuje", "chujek", "chujnia", "chujowy", "chujowa", + "chujowe", "chujowo", "huj", "hujek", "hujnia", "hujowy", "pizda", "pizdy", "pizdzie", + "pizdą", "pizdo", "pizduś", "pizdowaty", "pizdnąć", "cipa", "cipka", "cipki", "cipę", + "cipie", "cipą", "jebać", "jebac", "jebie", "jebię", "jebiesz", "jebią", "jebany", + "jebana", "jebane", "jebani", "jebnięty", "jebnąć", "jebnij", "dojebać", "odjebać", + "przejebać", "wyjebać", "wyjebany", "wyjebane", "zajebać", "zajebisty", "zajebista", + "zajebiste", "zajebiście", "najebany", "pojeb", "pojebany", "pojebana", "pojebane", "fiut", + "fiuta", "fiuty", "kutas", "kutasa", "kutasy", "kutafon", "pała", "pały", "pałę", + "szmata", "szmato", "szmaty", "dziwka", "dziwki", "dziwek", "suka", "suki", "suko", + "sukinsyn", "gówno", "gowno", "gówniany", "gowniany", "gówniarz", "gowniarz", "gówniak", + "gnojek", "gnoj", "debil", "debilu", "debile", "idiota", "idioci", "idiotka", "kretyn", + "kretyni", "imbecyl", "imbecyle", "przygłup", "przyglup", "głupek", "glupek", "frajer", + "frajerze", "lamus", "palant", "pajac", "baran", "osioł", "idiotyczny", "ścierwo", "scierwo" + ); + + private final ForbiddenWordRepository forbiddenWordRepository; + private final AppSettingRepository appSettingRepository; + + public ForbiddenWordSeeder( + ForbiddenWordRepository forbiddenWordRepository, + AppSettingRepository appSettingRepository + ) { + this.forbiddenWordRepository = forbiddenWordRepository; + this.appSettingRepository = appSettingRepository; + } + + @Override + public void run(String... args) { + if (appSettingRepository.existsById(SEED_FLAG_KEY)) { + return; + } + + Set existing = forbiddenWordRepository.findAll().stream() + .map(word -> word.getWord().toLowerCase(Locale.ROOT)) + .collect(Collectors.toSet()); + + DEFAULT_WORDS.stream() + .distinct() + .filter(word -> !existing.contains(word.toLowerCase(Locale.ROOT))) + .forEach(word -> { + ForbiddenWord forbiddenWord = new ForbiddenWord(); + forbiddenWord.setWord(word); + forbiddenWordRepository.save(forbiddenWord); + }); + + appSettingRepository.save(new AppSetting(SEED_FLAG_KEY, "true")); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/moderation/ModerationCheckRequest.java b/backend/src/main/java/pl/polskalokalnie/moderation/ModerationCheckRequest.java new file mode 100644 index 0000000..e6b99ec --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/moderation/ModerationCheckRequest.java @@ -0,0 +1,4 @@ +package pl.polskalokalnie.moderation; + +public record ModerationCheckRequest(String text) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/moderation/ModerationController.java b/backend/src/main/java/pl/polskalokalnie/moderation/ModerationController.java new file mode 100644 index 0000000..2912401 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/moderation/ModerationController.java @@ -0,0 +1,30 @@ +package pl.polskalokalnie.moderation; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * Lekki endpoint dla frontendu: pozwala sprawdzic dowolne pole tekstowe przez ten sam globalny + * filtr tresci co reszta backendu (np. czaty renderowane po stronie klienta). Zwraca 204, gdy + * tekst jest czysty, albo 400 z komunikatem, gdy zawiera niedozwolone slownictwo. + */ +@RestController +@RequestMapping("/api/moderation") +public class ModerationController { + + private final TextModerationService textModerationService; + + public ModerationController(TextModerationService textModerationService) { + this.textModerationService = textModerationService; + } + + @PostMapping("/check") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void check(@RequestBody ModerationCheckRequest request) { + textModerationService.validateOrThrow(request.text()); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/moderation/TextModerationService.java b/backend/src/main/java/pl/polskalokalnie/moderation/TextModerationService.java new file mode 100644 index 0000000..b845bbd --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/moderation/TextModerationService.java @@ -0,0 +1,151 @@ +package pl.polskalokalnie.moderation; + +import java.text.Normalizer; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +/** + * Globalny filtr tresci. Jedynym zrodlem slow zakazanych jest tabela forbidden_words + * (domyslny slownik wsiewa {@link ForbiddenWordSeeder}). Kazde pole tekstowe przechodzace + * przez {@link #validateOrThrow} jest normalizowane tak, by wykryc celowe modyfikacje: + * rozna wielkosc liter, polskie znaki, spacje/kropki/myslniki/podkreslenia, zamiana liter na + * cyfry (leet) oraz wielokrotne powtarzanie liter (np. "kuuurwa", "k.u_r-w4"). + */ +@Service +public class TextModerationService { + + private static final Locale POLISH_LOCALE = Locale.forLanguageTag("pl-PL"); + + private static final String BLOCK_MESSAGE = + "Twoja wiadomość zawiera niedozwolone słownictwo. Usuń je i spróbuj ponownie."; + + private final ForbiddenWordRepository forbiddenWordRepository; + + public TextModerationService(ForbiddenWordRepository forbiddenWordRepository) { + this.forbiddenWordRepository = forbiddenWordRepository; + } + + public void validateOrThrow(String... values) { + if (values == null) { + return; + } + for (String value : values) { + if (containsProhibitedContent(value)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, BLOCK_MESSAGE); + } + } + } + + public boolean containsProhibitedContent(String value) { + if (value == null || value.isBlank()) { + return false; + } + String normalized = normalize(value); + if (normalized.isBlank()) { + return false; + } + for (String banned : allNormalizedWords()) { + if (normalized.contains(banned)) { + return true; + } + } + return false; + } + + public List listForbiddenWords() { + return forbiddenWordRepository.findAll().stream() + .sorted(Comparator.comparing(ForbiddenWord::getWord, String.CASE_INSENSITIVE_ORDER)) + .map(ForbiddenWordResponse::from) + .toList(); + } + + public List addForbiddenWord(String rawWord) { + String cleaned = rawWord == null ? "" : rawWord.trim().toLowerCase(POLISH_LOCALE); + if (cleaned.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Słowo nie może być puste"); + } + + String normalized = normalize(cleaned); + if (normalized.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowe słowo"); + } + + if (forbiddenWordRepository.existsByWordIgnoreCase(cleaned) || allNormalizedWords().contains(normalized)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "To słowo jest już na liście zakazanych"); + } + + ForbiddenWord forbiddenWord = new ForbiddenWord(); + forbiddenWord.setWord(cleaned); + forbiddenWordRepository.save(forbiddenWord); + return listForbiddenWords(); + } + + public void removeForbiddenWord(Long id) { + if (!forbiddenWordRepository.existsById(id)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Słowo nie istnieje na liście"); + } + forbiddenWordRepository.deleteById(id); + } + + private Set allNormalizedWords() { + Set all = new LinkedHashSet<>(); + forbiddenWordRepository.findAll().stream() + .map(ForbiddenWord::getWord) + .map(TextModerationService::normalize) + .filter(word -> !word.isBlank()) + .forEach(all::add); + return all; + } + + private static String normalize(String value) { + String lower = value.toLowerCase(Locale.ROOT); + String deaccented = Normalizer.normalize(lower, Normalizer.Form.NFD) + .replaceAll("\\p{M}+", ""); + + StringBuilder lettersOnly = new StringBuilder(deaccented.length()); + for (int i = 0; i < deaccented.length(); i++) { + char c = mapLeet(deaccented.charAt(i)); + if (Character.isLetter(c)) { + lettersOnly.append(c); + } + } + + return collapseRepeatingLetters(lettersOnly.toString()); + } + + private static char mapLeet(char c) { + return switch (c) { + case '0' -> 'o'; + case '1' -> 'i'; + case '3' -> 'e'; + case '4' -> 'a'; + case '5' -> 's'; + case '7' -> 't'; + default -> c; + }; + } + + private static String collapseRepeatingLetters(String value) { + if (value.isEmpty()) { + return value; + } + + StringBuilder collapsed = new StringBuilder(value.length()); + char prev = value.charAt(0); + collapsed.append(prev); + for (int i = 1; i < value.length(); i++) { + char current = value.charAt(i); + if (current != prev) { + collapsed.append(current); + prev = current; + } + } + return collapsed.toString(); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/report/CreateListingReportRequest.java b/backend/src/main/java/pl/polskalokalnie/report/CreateListingReportRequest.java new file mode 100644 index 0000000..730916e --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/report/CreateListingReportRequest.java @@ -0,0 +1,15 @@ +package pl.polskalokalnie.report; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record CreateListingReportRequest( + @NotNull Long listingId, + @NotBlank String reasonId, + @NotBlank String reasonTitle, + String details, + List attachmentNames, + List attachmentFiles +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/report/ListingReport.java b/backend/src/main/java/pl/polskalokalnie/report/ListingReport.java new file mode 100644 index 0000000..7f6520c --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/report/ListingReport.java @@ -0,0 +1,211 @@ +package pl.polskalokalnie.report; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "listing_reports") +public class ListingReport { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long listingId; + + @Column(nullable = false, length = 180) + private String listingTitle; + + @Column(nullable = false, length = 120) + private String listingCity; + + @Column(length = 180) + private String listingOwnerEmail; + + @Column(nullable = false, length = 80) + private String reasonId; + + @Column(nullable = false, length = 180) + private String reasonTitle; + + @Column(length = 1000) + private String details; + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "listing_report_attachments", joinColumns = @JoinColumn(name = "report_id")) + private List attachments = new ArrayList<>(); + + @Column(nullable = false, length = 180) + private String reporterEmail; + + @Column(length = 120) + private String reporterName; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private ListingReportStatus status = ListingReportStatus.OPEN; + + @Column(nullable = false) + private boolean listingDeleted = false; + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + private Instant resolvedAt; + + @Column(length = 180) + private String resolvedByEmail; + + @Column(length = 1000) + private String resolutionNote; + + @PrePersist + void onCreate() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } + + public Long getId() { + return id; + } + + public Long getListingId() { + return listingId; + } + + public void setListingId(Long listingId) { + this.listingId = listingId; + } + + public String getListingTitle() { + return listingTitle; + } + + public void setListingTitle(String listingTitle) { + this.listingTitle = listingTitle; + } + + public String getListingCity() { + return listingCity; + } + + public void setListingCity(String listingCity) { + this.listingCity = listingCity; + } + + public String getListingOwnerEmail() { + return listingOwnerEmail; + } + + public void setListingOwnerEmail(String listingOwnerEmail) { + this.listingOwnerEmail = listingOwnerEmail; + } + + public String getReasonId() { + return reasonId; + } + + public void setReasonId(String reasonId) { + this.reasonId = reasonId; + } + + public String getReasonTitle() { + return reasonTitle; + } + + public void setReasonTitle(String reasonTitle) { + this.reasonTitle = reasonTitle; + } + + public String getDetails() { + return details; + } + + public void setDetails(String details) { + this.details = details; + } + + public List getAttachments() { + return attachments; + } + + public void setAttachments(List attachments) { + this.attachments = attachments == null ? new ArrayList<>() : new ArrayList<>(attachments); + } + + public String getReporterEmail() { + return reporterEmail; + } + + public void setReporterEmail(String reporterEmail) { + this.reporterEmail = reporterEmail; + } + + public String getReporterName() { + return reporterName; + } + + public void setReporterName(String reporterName) { + this.reporterName = reporterName; + } + + public ListingReportStatus getStatus() { + return status; + } + + public void setStatus(ListingReportStatus status) { + this.status = status; + } + + public boolean isListingDeleted() { + return listingDeleted; + } + + public void setListingDeleted(boolean listingDeleted) { + this.listingDeleted = listingDeleted; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getResolvedAt() { + return resolvedAt; + } + + public void setResolvedAt(Instant resolvedAt) { + this.resolvedAt = resolvedAt; + } + + public String getResolvedByEmail() { + return resolvedByEmail; + } + + public void setResolvedByEmail(String resolvedByEmail) { + this.resolvedByEmail = resolvedByEmail; + } + + public String getResolutionNote() { + return resolutionNote; + } + + public void setResolutionNote(String resolutionNote) { + this.resolutionNote = resolutionNote; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/report/ListingReportAttachment.java b/backend/src/main/java/pl/polskalokalnie/report/ListingReportAttachment.java new file mode 100644 index 0000000..17d594e --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/report/ListingReportAttachment.java @@ -0,0 +1,41 @@ +package pl.polskalokalnie.report; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +@Embeddable +public class ListingReportAttachment { + + @Column(name = "file_name", nullable = false, length = 255) + private String fileName; + + @Column(name = "file_type", length = 120) + private String fileType; + + @Column(name = "data_url", columnDefinition = "TEXT") + private String dataUrl; + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public String getFileType() { + return fileType; + } + + public void setFileType(String fileType) { + this.fileType = fileType; + } + + public String getDataUrl() { + return dataUrl; + } + + public void setDataUrl(String dataUrl) { + this.dataUrl = dataUrl; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/report/ListingReportAttachmentPayload.java b/backend/src/main/java/pl/polskalokalnie/report/ListingReportAttachmentPayload.java new file mode 100644 index 0000000..7ffe4f8 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/report/ListingReportAttachmentPayload.java @@ -0,0 +1,8 @@ +package pl.polskalokalnie.report; + +public record ListingReportAttachmentPayload( + String fileName, + String fileType, + String dataUrl +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/report/ListingReportAttachmentResponse.java b/backend/src/main/java/pl/polskalokalnie/report/ListingReportAttachmentResponse.java new file mode 100644 index 0000000..2334407 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/report/ListingReportAttachmentResponse.java @@ -0,0 +1,15 @@ +package pl.polskalokalnie.report; + +public record ListingReportAttachmentResponse( + String fileName, + String fileType, + String dataUrl +) { + public static ListingReportAttachmentResponse from(ListingReportAttachment attachment) { + return new ListingReportAttachmentResponse( + attachment.getFileName(), + attachment.getFileType(), + attachment.getDataUrl() + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/report/ListingReportController.java b/backend/src/main/java/pl/polskalokalnie/report/ListingReportController.java new file mode 100644 index 0000000..ba715ed --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/report/ListingReportController.java @@ -0,0 +1,27 @@ +package pl.polskalokalnie.report; + +import jakarta.validation.Valid; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/reports") +public class ListingReportController { + + private final ListingReportService listingReportService; + + public ListingReportController(ListingReportService listingReportService) { + this.listingReportService = listingReportService; + } + + @PostMapping("/listings") + public ListingReportResponse createListingReport( + @Valid @RequestBody CreateListingReportRequest request, + Authentication authentication + ) { + return listingReportService.create(request, authentication.getName()); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/report/ListingReportRepository.java b/backend/src/main/java/pl/polskalokalnie/report/ListingReportRepository.java new file mode 100644 index 0000000..4fcd102 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/report/ListingReportRepository.java @@ -0,0 +1,9 @@ +package pl.polskalokalnie.report; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ListingReportRepository extends JpaRepository { + + List findAllByOrderByCreatedAtDesc(); +} diff --git a/backend/src/main/java/pl/polskalokalnie/report/ListingReportResponse.java b/backend/src/main/java/pl/polskalokalnie/report/ListingReportResponse.java new file mode 100644 index 0000000..f47129d --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/report/ListingReportResponse.java @@ -0,0 +1,48 @@ +package pl.polskalokalnie.report; + +import java.time.Instant; +import java.util.List; + +public record ListingReportResponse( + Long id, + Long listingId, + String listingTitle, + String listingCity, + String listingOwnerEmail, + String reasonId, + String reasonTitle, + String details, + List attachmentNames, + List attachments, + String reporterEmail, + String reporterName, + ListingReportStatus status, + boolean listingDeleted, + Instant createdAt, + Instant resolvedAt, + String resolvedByEmail, + String resolutionNote +) { + public static ListingReportResponse from(ListingReport report) { + return new ListingReportResponse( + report.getId(), + report.getListingId(), + report.getListingTitle(), + report.getListingCity(), + report.getListingOwnerEmail(), + report.getReasonId(), + report.getReasonTitle(), + report.getDetails(), + report.getAttachments().stream().map(ListingReportAttachment::getFileName).toList(), + report.getAttachments().stream().map(ListingReportAttachmentResponse::from).toList(), + report.getReporterEmail(), + report.getReporterName(), + report.getStatus(), + report.isListingDeleted(), + report.getCreatedAt(), + report.getResolvedAt(), + report.getResolvedByEmail(), + report.getResolutionNote() + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/report/ListingReportService.java b/backend/src/main/java/pl/polskalokalnie/report/ListingReportService.java new file mode 100644 index 0000000..d6fdb24 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/report/ListingReportService.java @@ -0,0 +1,167 @@ +package pl.polskalokalnie.report; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import pl.polskalokalnie.listing.ListingRepository; +import pl.polskalokalnie.listing.PropertyListing; +import pl.polskalokalnie.user.AppUser; +import pl.polskalokalnie.user.UserRepository; + +@Service +public class ListingReportService { + + private static final int MAX_ATTACHMENTS = 5; + private static final int MAX_FILE_NAME_LENGTH = 255; + private static final int MAX_FILE_TYPE_LENGTH = 120; + private static final int MAX_DATA_URL_LENGTH = 16_000_000; + + private final ListingReportRepository listingReportRepository; + private final ListingRepository listingRepository; + private final UserRepository userRepository; + + public ListingReportService( + ListingReportRepository listingReportRepository, + ListingRepository listingRepository, + UserRepository userRepository + ) { + this.listingReportRepository = listingReportRepository; + this.listingRepository = listingRepository; + this.userRepository = userRepository; + } + + @Transactional + public ListingReportResponse create(CreateListingReportRequest request, String reporterEmail) { + PropertyListing listing = listingRepository.findById(request.listingId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found")); + + ListingReport report = new ListingReport(); + report.setListingId(listing.getId()); + report.setListingTitle(listing.getTitle()); + report.setListingCity(listing.getCity()); + report.setListingOwnerEmail(listing.getOwnerEmail()); + report.setReasonId(request.reasonId().trim()); + report.setReasonTitle(request.reasonTitle().trim()); + report.setDetails(trimToNull(request.details())); + report.setAttachments(cleanAttachments(request.attachmentNames(), request.attachmentFiles())); + report.setReporterEmail(reporterEmail); + report.setReporterName(userRepository.findByEmailIgnoreCase(reporterEmail).map(AppUser::getFullName).orElse(null)); + report.setStatus(ListingReportStatus.OPEN); + + return ListingReportResponse.from(listingReportRepository.save(report)); + } + + public List findAllForAdmin() { + return listingReportRepository.findAllByOrderByCreatedAtDesc().stream() + .map(ListingReportResponse::from) + .toList(); + } + + @Transactional + public ListingReportResponse resolve(Long reportId, String adminEmail, String note) { + ListingReport report = requireReport(reportId); + report.setStatus(ListingReportStatus.RESOLVED); + report.setResolvedAt(Instant.now()); + report.setResolvedByEmail(adminEmail); + report.setResolutionNote(trimToNull(note)); + return ListingReportResponse.from(listingReportRepository.save(report)); + } + + @Transactional + public ListingReportResponse deleteListingAndResolve(Long reportId, String adminEmail) { + ListingReport report = requireReport(reportId); + Long listingId = report.getListingId(); + + if (listingId != null && listingRepository.existsById(listingId)) { + listingRepository.deleteById(listingId); + } + + report.setListingDeleted(true); + report.setStatus(ListingReportStatus.RESOLVED); + report.setResolvedAt(Instant.now()); + report.setResolvedByEmail(adminEmail); + if (report.getResolutionNote() == null || report.getResolutionNote().isBlank()) { + report.setResolutionNote("Ogłoszenie usunięte przez administratora po zgłoszeniu."); + } + return ListingReportResponse.from(listingReportRepository.save(report)); + } + + private ListingReport requireReport(Long id) { + return listingReportRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Report not found")); + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static List cleanAttachments( + List names, + List files + ) { + List cleaned = new ArrayList<>(); + + if (files != null) { + for (ListingReportAttachmentPayload payload : files) { + if (payload == null) { + continue; + } + String fileName = truncate(trimToNull(payload.fileName()), MAX_FILE_NAME_LENGTH); + if (fileName == null) { + continue; + } + ListingReportAttachment attachment = new ListingReportAttachment(); + attachment.setFileName(fileName); + attachment.setFileType(truncate(trimToNull(payload.fileType()), MAX_FILE_TYPE_LENGTH)); + String dataUrl = trimToNull(payload.dataUrl()); + if (dataUrl != null && dataUrl.length() > MAX_DATA_URL_LENGTH) { + dataUrl = null; + } + attachment.setDataUrl(dataUrl); + cleaned.add(attachment); + if (cleaned.size() >= MAX_ATTACHMENTS) { + return cleaned; + } + } + } + + if (names != null) { + for (String name : names) { + String trimmed = truncate(trimToNull(name), MAX_FILE_NAME_LENGTH); + if (trimmed == null) { + continue; + } + boolean exists = cleaned.stream().anyMatch(item -> trimmed.equalsIgnoreCase(item.getFileName())); + if (exists) { + continue; + } + ListingReportAttachment attachment = new ListingReportAttachment(); + attachment.setFileName(trimmed); + cleaned.add(attachment); + if (cleaned.size() >= MAX_ATTACHMENTS) { + break; + } + } + } + + return cleaned; + } + + private static String truncate(String value, int maxLength) { + if (value == null) { + return null; + } + if (value.length() <= maxLength) { + return value; + } + return value.substring(0, maxLength); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/report/ListingReportStatus.java b/backend/src/main/java/pl/polskalokalnie/report/ListingReportStatus.java new file mode 100644 index 0000000..f7952d1 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/report/ListingReportStatus.java @@ -0,0 +1,6 @@ +package pl.polskalokalnie.report; + +public enum ListingReportStatus { + OPEN, + RESOLVED +} diff --git a/backend/src/main/java/pl/polskalokalnie/report/ResolveListingReportRequest.java b/backend/src/main/java/pl/polskalokalnie/report/ResolveListingReportRequest.java new file mode 100644 index 0000000..7d4910a --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/report/ResolveListingReportRequest.java @@ -0,0 +1,6 @@ +package pl.polskalokalnie.report; + +public record ResolveListingReportRequest( + String note +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/settings/AppSetting.java b/backend/src/main/java/pl/polskalokalnie/settings/AppSetting.java new file mode 100644 index 0000000..fa230f2 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/settings/AppSetting.java @@ -0,0 +1,42 @@ +package pl.polskalokalnie.settings; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +/** + * Prosty magazyn flag konfiguracyjnych klucz-wartosc. Uzywany m.in. do oznaczenia, ze domyslny + * slownik slow zakazanych zostal juz raz wsiany do bazy (dzieki czemu usuniecia sa trwale). + */ +@Entity +@Table(name = "app_settings") +public class AppSetting { + + @Id + @Column(name = "setting_key", length = 100) + private String settingKey; + + @Column(name = "setting_value", nullable = false, length = 255) + private String settingValue; + + protected AppSetting() { + } + + public AppSetting(String settingKey, String settingValue) { + this.settingKey = settingKey; + this.settingValue = settingValue; + } + + public String getSettingKey() { + return settingKey; + } + + public String getSettingValue() { + return settingValue; + } + + public void setSettingValue(String settingValue) { + this.settingValue = settingValue; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/settings/AppSettingRepository.java b/backend/src/main/java/pl/polskalokalnie/settings/AppSettingRepository.java new file mode 100644 index 0000000..6521dc6 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/settings/AppSettingRepository.java @@ -0,0 +1,6 @@ +package pl.polskalokalnie.settings; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AppSettingRepository extends JpaRepository { +} diff --git a/backend/src/main/java/pl/polskalokalnie/user/AccountType.java b/backend/src/main/java/pl/polskalokalnie/user/AccountType.java new file mode 100644 index 0000000..6f321bf --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/user/AccountType.java @@ -0,0 +1,6 @@ +package pl.polskalokalnie.user; + +public enum AccountType { + PERSONAL, + COMPANY +} diff --git a/backend/src/main/java/pl/polskalokalnie/user/AppUser.java b/backend/src/main/java/pl/polskalokalnie/user/AppUser.java new file mode 100644 index 0000000..fb24b89 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/user/AppUser.java @@ -0,0 +1,205 @@ +package pl.polskalokalnie.user; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Instant; +import java.time.LocalDate; +import org.hibernate.annotations.ColumnDefault; + +@Entity +@Table(name = "app_users") +public class AppUser { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 180) + private String email; + + @Column(length = 120) + private String fullName; + + // Nullable: konta zakladane przez logowanie spoleczne nie maja lokalnego hasla. + @Column(length = 100) + private String passwordHash; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private Role role = Role.USER; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private AuthProvider provider = AuthProvider.LOCAL; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @ColumnDefault("'PERSONAL'") + private AccountType accountType = AccountType.PERSONAL; + + @Column(length = 30) + private String phone; + + @Column(length = 255) + private String address; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @ColumnDefault("'EMAIL_AND_PHONE'") + private ContactPreference contactPreference = ContactPreference.EMAIL_AND_PHONE; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + @ColumnDefault("'PL'") + private PreferredLanguage preferredLanguage = PreferredLanguage.PL; + + @Column(length = 20) + private String nip; + + private LocalDate birthDate; + + // Konta zakladane przez rejestracje lokalna czekaja na zatwierdzenie przez administratora. + @Column(nullable = false) + @ColumnDefault("false") + private boolean verified = false; + + @Column(nullable = false) + private boolean blocked = false; + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + void onCreate() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } + + public Long getId() { + return id; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getFullName() { + return fullName; + } + + public void setFullName(String fullName) { + this.fullName = fullName; + } + + public String getPasswordHash() { + return passwordHash; + } + + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + + public Role getRole() { + return role; + } + + public void setRole(Role role) { + this.role = role; + } + + public AuthProvider getProvider() { + return provider; + } + + public void setProvider(AuthProvider provider) { + this.provider = provider; + } + + public AccountType getAccountType() { + return accountType; + } + + public void setAccountType(AccountType accountType) { + this.accountType = accountType; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + public ContactPreference getContactPreference() { + return contactPreference; + } + + public void setContactPreference(ContactPreference contactPreference) { + this.contactPreference = contactPreference; + } + + public PreferredLanguage getPreferredLanguage() { + return preferredLanguage; + } + + public void setPreferredLanguage(PreferredLanguage preferredLanguage) { + this.preferredLanguage = preferredLanguage; + } + + public String getNip() { + return nip; + } + + public void setNip(String nip) { + this.nip = nip; + } + + public LocalDate getBirthDate() { + return birthDate; + } + + public void setBirthDate(LocalDate birthDate) { + this.birthDate = birthDate; + } + + public boolean isVerified() { + return verified; + } + + public void setVerified(boolean verified) { + this.verified = verified; + } + + public boolean isBlocked() { + return blocked; + } + + public void setBlocked(boolean blocked) { + this.blocked = blocked; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/user/AuthProvider.java b/backend/src/main/java/pl/polskalokalnie/user/AuthProvider.java new file mode 100644 index 0000000..e726b40 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/user/AuthProvider.java @@ -0,0 +1,7 @@ +package pl.polskalokalnie.user; + +public enum AuthProvider { + LOCAL, + GOOGLE, + FACEBOOK +} diff --git a/backend/src/main/java/pl/polskalokalnie/user/BlockedEmail.java b/backend/src/main/java/pl/polskalokalnie/user/BlockedEmail.java new file mode 100644 index 0000000..b7f3fb1 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/user/BlockedEmail.java @@ -0,0 +1,49 @@ +package pl.polskalokalnie.user; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Instant; + +// Adresy e-mail odrzuconych podczas weryfikacji kont - trwale zablokowane przed ponowna rejestracja. +@Entity +@Table(name = "blocked_emails") +public class BlockedEmail { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 180) + private String email; + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + void onCreate() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } + + public Long getId() { + return id; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/user/BlockedEmailRepository.java b/backend/src/main/java/pl/polskalokalnie/user/BlockedEmailRepository.java new file mode 100644 index 0000000..0fed654 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/user/BlockedEmailRepository.java @@ -0,0 +1,8 @@ +package pl.polskalokalnie.user; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BlockedEmailRepository extends JpaRepository { + + boolean existsByEmailIgnoreCase(String email); +} diff --git a/backend/src/main/java/pl/polskalokalnie/user/ContactPreference.java b/backend/src/main/java/pl/polskalokalnie/user/ContactPreference.java new file mode 100644 index 0000000..068a09c --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/user/ContactPreference.java @@ -0,0 +1,7 @@ +package pl.polskalokalnie.user; + +public enum ContactPreference { + EMAIL, + PHONE, + EMAIL_AND_PHONE +} diff --git a/backend/src/main/java/pl/polskalokalnie/user/PreferredLanguage.java b/backend/src/main/java/pl/polskalokalnie/user/PreferredLanguage.java new file mode 100644 index 0000000..093313b --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/user/PreferredLanguage.java @@ -0,0 +1,8 @@ +package pl.polskalokalnie.user; + +public enum PreferredLanguage { + PL, + EN, + UK, + DE +} diff --git a/backend/src/main/java/pl/polskalokalnie/user/Role.java b/backend/src/main/java/pl/polskalokalnie/user/Role.java new file mode 100644 index 0000000..f221aab --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/user/Role.java @@ -0,0 +1,6 @@ +package pl.polskalokalnie.user; + +public enum Role { + USER, + ADMIN +} diff --git a/backend/src/main/java/pl/polskalokalnie/user/UserRepository.java b/backend/src/main/java/pl/polskalokalnie/user/UserRepository.java new file mode 100644 index 0000000..9f7712a --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/user/UserRepository.java @@ -0,0 +1,13 @@ +package pl.polskalokalnie.user; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { + + Optional findByEmailIgnoreCase(String email); + + boolean existsByEmailIgnoreCase(String email); + + Optional findFirstByRole(Role role); +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..3ffcf1f --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,29 @@ +server: + port: ${SERVER_PORT:8080} + error: + include-message: always + +spring: + application: + name: polskalokalnie-backend + datasource: + url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/polskalokalnie} + username: ${SPRING_DATASOURCE_USERNAME:polskalokalnie} + password: ${SPRING_DATASOURCE_PASSWORD:polskalokalnie_dev} + jpa: + hibernate: + ddl-auto: ${SPRING_JPA_HIBERNATE_DDL_AUTO:update} + open-in-view: false + properties: + hibernate: + format_sql: true + +app: + jwt: + # W produkcji ustaw APP_JWT_SECRET (min. 32 znaki) przez zmienna srodowiskowa. + secret: ${APP_JWT_SECRET:local-dev-secret-change-me-please-32chars-min} + expiration-seconds: ${APP_JWT_EXPIRATION_SECONDS:86400} + admin: + email: ${APP_ADMIN_EMAIL:admin@mieszko.pl} + password: ${APP_ADMIN_PASSWORD:Admin123!} + name: ${APP_ADMIN_NAME:Administrator Mieszko} diff --git a/backend/src/test/java/pl/polskalokalnie/PolskaLokalnieApplicationTests.java b/backend/src/test/java/pl/polskalokalnie/PolskaLokalnieApplicationTests.java new file mode 100644 index 0000000..c9d56e1 --- /dev/null +++ b/backend/src/test/java/pl/polskalokalnie/PolskaLokalnieApplicationTests.java @@ -0,0 +1,12 @@ +package pl.polskalokalnie; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class PolskaLokalnieApplicationTests { + + @Test + void contextLoads() { + } +} diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml new file mode 100644 index 0000000..010d940 --- /dev/null +++ b/backend/src/test/resources/application.yml @@ -0,0 +1,18 @@ +spring: + datasource: + url: jdbc:h2:mem:polskalokalnie;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE + username: sa + password: + driver-class-name: org.h2.Driver + jpa: + hibernate: + ddl-auto: create-drop + +app: + jwt: + secret: test-secret-key-for-junit-context-32chars-min + expiration-seconds: 3600 + admin: + email: admin@mieszko.pl + password: Admin123! + name: Administrator Mieszko diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a79e1e2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-polskalokalnie} + POSTGRES_USER: ${POSTGRES_USER:-polskalokalnie} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-polskalokalnie_dev} + ports: + - "${POSTGRES_BIND_ADDRESS:-127.0.0.1}:5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-polskalokalnie} -d ${POSTGRES_DB:-polskalokalnie}"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB:-polskalokalnie} + SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-polskalokalnie} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-polskalokalnie_dev} + SPRING_JPA_HIBERNATE_DDL_AUTO: ${SPRING_JPA_HIBERNATE_DDL_AUTO:-update} + depends_on: + db: + condition: service_healthy + ports: + - "${BACKEND_BIND_ADDRESS:-127.0.0.1}:8080:8080" + + web: + build: + context: . + dockerfile: nginx/Dockerfile + depends_on: + - backend + ports: + - "${WEB_BIND_ADDRESS:-0.0.0.0}:80:80" + +volumes: + postgres-data: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..4117859 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.vite/ +*.log diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a7821a3 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Polska Lokalnie + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..417c18f --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1296 @@ +{ + "name": "polskalokalnie-frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "polskalokalnie-frontend", + "version": "0.0.1", + "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" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/pdfkit": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz", + "integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/pdfmake": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@types/pdfmake/-/pdfmake-0.3.3.tgz", + "integrity": "sha512-ufbceB4Q3dKpmFUMKEbZvJcfLi8+C5b1+rTFVG6urtkS3FHwaTQH6NCh/iEEDINlApCxH/W3/rsq99pP5E9/JA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/pdfkit": "*" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.12", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz", + "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/brotli/node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, + "node_modules/browserify-zlib/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-md5": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", + "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pdfkit": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz", + "integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "^1.0.0", + "@noble/hashes": "^1.6.0", + "fontkit": "^2.0.4", + "js-md5": "^0.8.3", + "linebreak": "^1.1.0", + "png-js": "^1.1.0" + } + }, + "node_modules/pdfmake": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/pdfmake/-/pdfmake-0.3.11.tgz", + "integrity": "sha512-Uc49J9hUMyuqJk+U+PxlpBpPr96A4HOOfesGx609EPr2ue82+5/Smq/KTAkEqh0/jUGSi1fumvqZ5yAWijJTJg==", + "license": "MIT", + "dependencies": { + "linebreak": "^1.1.0", + "pdfkit": "^0.19.1", + "xmldoc": "^2.0.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/png-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-properties/node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/xmldoc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/xmldoc/-/xmldoc-2.0.3.tgz", + "integrity": "sha512-6gRk4NY/Jvg67xn7OzJuxLRsGgiXBaPUQplVJ/9l99uIugxh4FTOewYz5ic8WScj7Xx/2WvhENiQKwkK9RpE4w==", + "license": "MIT", + "dependencies": { + "sax": "^1.4.3" + }, + "engines": { + "node": ">=12.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..755e641 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..80f8e97 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,18542 @@ +import { Fragment, type ChangeEvent as ReactChangeEvent, type ClipboardEvent as ReactClipboardEvent, type Dispatch, type DragEvent as ReactDragEvent, type FormEvent as ReactFormEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type SetStateAction, type WheelEvent as ReactWheelEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import './styles.css'; +import { apiFetch, useAuth } from './auth'; +import type { AuthUser, ContactPreference, PreferredLanguage } from './auth'; +import { getManualTranslation } from './i18nOverrides'; + +const heroImage = new URL('./assets/hero-interior.png', import.meta.url).href; +const cityImage = new URL('./assets/city-panorama.png', import.meta.url).href; +const loginRoomImage = new URL('./assets/login-room.png', import.meta.url).href; +const SMS_VERIFICATION_CODE = '123456'; + +type View = 'home' | 'buy' | 'rent' | 'sell' | 'valuation' | 'priceHistory' | 'districtRanking' | 'negotiation' | 'comparison' | 'add' | 'services' | 'guides' | 'map' | 'login' | 'account' | 'accountMeetings' | 'accountListings' | 'accountSettings' | 'accountSecurity' | 'accountProfileEdit' | 'accountPublicProfile' | 'notifications' | 'favorites' | 'messages' | 'admin' | 'listingDetail'; + +type MessageThreadListing = { + listingId: number; + title: string; + price: number; + city: string; + district: string | null; + area: number; + rooms: number; + floor: string | null; + buildingFloors: string | null; + thumbnail: string | null; +}; + +type MessageThreadTarget = { + id: string; + name: string; + status: string; + listing: MessageThreadListing; +}; + +export type ApiOfferType = 'SALE' | 'RENT'; +export type ApiPropertyType = 'APARTMENT' | 'HOUSE'; +export type ApiListingStatus = 'PENDING' | 'APPROVED' | 'REJECTED'; + +// Lekka reprezentacja z list (Kupuje/Wynajmuje, moje ogloszenia) - bez pelnej galerii. +export type ApiListingSummary = { + id: number; + title: string; + description: string; + offerType: ApiOfferType; + propertyType: ApiPropertyType; + city: string; + district: string | null; + address: string | null; + price: number; + area: number; + rooms: number; + floor: string | null; + buildingFloors: string | null; + market: string | null; + yearBuilt: number | null; + coverPhoto: string | null; + ownerEmail: string | null; + status: ApiListingStatus; + createdAt: string; +}; + +// Pelne ogloszenie dla widoku szczegolow. +export type ApiListingDetail = { + id: number; + title: string; + description: string; + offerType: ApiOfferType; + propertyType: ApiPropertyType; + city: string; + district: string | null; + street: string | null; + building: string | null; + address: string | null; + price: number; + area: number; + rooms: number; + floor: string | null; + buildingFloors: string | null; + market: string | null; + yearBuilt: number | null; + condition: string | null; + ownership: string | null; + contactName: string | null; + contactPhone: string | null; + contactEmail: string | null; + rentExtra: number | null; + availableFrom: string | null; + furnishing: string | null; + buildingType: string | null; + buildingMaterial: string | null; + windows: string | null; + exposure: string | null; + roomHeight: string | null; + lat: number | null; + lng: number | null; + media: string[]; + amenities: string[]; + photos: string[]; + ownerEmail: string | null; + status: ApiListingStatus; + createdAt: string; + viewsCount?: number; +}; + +type LiveListingSummary = ApiListingSummary & { + lat: number | null; + lng: number | null; +}; + +const LIVE_LISTINGS_REFRESH_MS = 15000; + +const PROTECTED_VIEWS = new Set([ + 'add', 'admin', 'account', 'accountMeetings', 'accountListings', 'accountSettings', + 'accountSecurity', 'accountProfileEdit', 'accountPublicProfile', 'notifications', 'favorites', 'messages', 'comparison', +]); + +type NotificationCategory = 'listing' | 'messages' | 'system'; + +type UserNotification = { + id: string; + title: string; + description: string; + timeLabel: string; + dayLabel: 'Dzisiaj' | 'Wczoraj'; + category: NotificationCategory; + icon: string; + unread?: boolean; + action?: 'arrow'; +}; + +const userNotifications: UserNotification[] = [ + { + id: 'new-message', + title: 'Nowa wiadomość', + description: 'Masz nową wiadomość od użytkownika Anna Nowak', + timeLabel: '11:24', + dayLabel: 'Dzisiaj', + category: 'messages', + icon: 'message', + unread: true, + }, + { + id: 'saved-listing', + title: 'Ogłoszenie zapisane', + description: 'Mieszkanie 2-pokojowe na Mokotowie zostało zapisane do ulubionych.', + timeLabel: '10:15', + dayLabel: 'Dzisiaj', + category: 'listing', + icon: 'heart', + unread: true, + }, + { + id: 'matching-listings', + title: 'Nowe ogłoszenia dopasowane do Ciebie', + description: 'Zobacz 3 nowe oferty mieszkań, które mogą Cię zainteresować.', + timeLabel: '09:43', + dayLabel: 'Dzisiaj', + category: 'listing', + icon: 'bell', + action: 'arrow', + }, + { + id: 'price-drop', + title: 'Zmiana ceny', + description: 'Cena mieszkania „Apartament z widokiem” spadła o 20 000 zł.', + timeLabel: '09:12', + dayLabel: 'Dzisiaj', + category: 'listing', + icon: 'coin', + action: 'arrow', + }, + { + id: 'phone-verified', + title: 'Numer telefonu zweryfikowany', + description: 'Twój numer telefonu został pomyślnie zweryfikowany.', + timeLabel: '08:30', + dayLabel: 'Dzisiaj', + category: 'system', + icon: 'check', + unread: true, + }, + { + id: 'support-reply', + title: 'Wiadomość od obsługi Mieszko', + description: 'Dziękujemy za kontakt. Odpowiemy najszybciej jak to możliwe.', + timeLabel: 'Wczoraj, 18:20', + dayLabel: 'Wczoraj', + category: 'messages', + icon: 'mail', + action: 'arrow', + }, + { + id: 'listing-views', + title: 'Twoje ogłoszenie ma nowe wyświetlenia', + description: 'Twoje ogłoszenie „Kawalerka w centrum” zostało wyświetlone 23 razy.', + timeLabel: 'Wczoraj, 16:05', + dayLabel: 'Wczoraj', + category: 'listing', + icon: 'eye', + unread: true, + }, + { + id: 'new-review', + title: 'Nowa opinia', + description: 'Katarzyna Zielińska wystawiła Ci pozytywną opinię.', + timeLabel: 'Wczoraj, 12:47', + dayLabel: 'Wczoraj', + category: 'system', + icon: 'star', + action: 'arrow', + }, +]; + +const meetingsStorageKey = 'polskalokalnie-account-meetings'; +const listingsStorageKey = 'polskalokalnie-account-listings'; + +function getMeetingReminderNotifications(referenceDate = new Date()): UserNotification[] { + if (typeof window === 'undefined') { + return []; + } + + type StoredMeeting = { + id: string; + title: string; + location: string; + dateKey: string; + start: string; + status?: string; + }; + + try { + const raw = window.localStorage.getItem(meetingsStorageKey); + if (!raw) { + return []; + } + + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + + const nowMs = referenceDate.getTime(); + const oneDayMs = 24 * 60 * 60 * 1000; + + return parsed + .filter((item): item is StoredMeeting => ( + item + && typeof item.id === 'string' + && typeof item.title === 'string' + && typeof item.location === 'string' + && typeof item.dateKey === 'string' + && typeof item.start === 'string' + )) + .filter((meeting) => (meeting.status ?? 'planned') === 'planned') + .map((meeting) => { + const meetingDateTime = new Date(`${meeting.dateKey}T${meeting.start}:00`); + const meetingMs = meetingDateTime.getTime(); + const reminderStartMs = meetingMs - oneDayMs; + const isInReminderWindow = nowMs >= reminderStartMs && nowMs < meetingMs; + return { meeting, meetingDateTime, isInReminderWindow }; + }) + .filter((row) => row.isInReminderWindow) + .sort((left, right) => left.meetingDateTime.getTime() - right.meetingDateTime.getTime()) + .map(({ meeting, meetingDateTime }) => ({ + id: `meeting-reminder-${meeting.id}`, + title: 'Przypomnienie o spotkaniu', + description: `${meeting.title} o ${meeting.start} (${meeting.location}).`, + timeLabel: meetingDateTime.toLocaleTimeString('pl-PL', { hour: '2-digit', minute: '2-digit' }), + dayLabel: 'Dzisiaj' as const, + category: 'system' as const, + icon: 'calendar', + unread: true, + action: 'arrow' as const, + })); + } catch { + return []; + } +} + +function getListingResumeNotifications(referenceDate = new Date()): UserNotification[] { + if (typeof window === 'undefined') { + return []; + } + + type StoredListing = { + id: string; + title: string; + status?: string; + pausedUntil?: string | null; + }; + + try { + const raw = window.localStorage.getItem(listingsStorageKey); + if (!raw) { + return []; + } + + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + + const nowMs = referenceDate.getTime(); + const oneDayMs = 24 * 60 * 60 * 1000; + + return parsed + .filter((item): item is StoredListing => ( + item + && typeof item.id === 'string' + && typeof item.title === 'string' + && typeof item.status === 'string' + )) + .filter((item) => item.status === 'paused' && typeof item.pausedUntil === 'string' && item.pausedUntil.length > 0) + .map((item) => { + const resumeDateTime = new Date(item.pausedUntil as string); + const resumeMs = resumeDateTime.getTime(); + const reminderStartMs = resumeMs - oneDayMs; + const isInReminderWindow = nowMs >= reminderStartMs && nowMs < resumeMs; + return { item, resumeDateTime, isInReminderWindow }; + }) + .filter((row) => row.isInReminderWindow) + .sort((left, right) => left.resumeDateTime.getTime() - right.resumeDateTime.getTime()) + .map(({ item, resumeDateTime }) => ({ + id: `listing-resume-${item.id}`, + title: 'Jutro ogłoszenie zostanie przywrócone', + description: `${item.title} zostanie automatycznie wznowione ${resumeDateTime.toLocaleDateString('pl-PL')} o ${resumeDateTime.toLocaleTimeString('pl-PL', { hour: '2-digit', minute: '2-digit' })}.`, + timeLabel: resumeDateTime.toLocaleTimeString('pl-PL', { hour: '2-digit', minute: '2-digit' }), + dayLabel: 'Dzisiaj' as const, + category: 'listing' as const, + icon: 'bell', + unread: true, + action: 'arrow' as const, + })); + } catch { + return []; + } +} + +function getAllNotifications(referenceDate = new Date()): UserNotification[] { + return [ + ...getListingResumeNotifications(referenceDate), + ...getMeetingReminderNotifications(referenceDate), + ...userNotifications, + ]; +} + +type FavoriteTab = 'buy' | 'rent'; + +type FavoriteListing = { + id: string; + listingId: number; + mode: FavoriteTab; + image: string; + price: string; + title: string; + location: string; + area: string; + rooms: string; + floor: string; + year: string; + added: string; + photos: number; + badge: string; +}; + +const favoriteListings: FavoriteListing[] = []; + +function favoriteListingKey(listingId: number): string { + return `listing-${listingId}`; +} + +function favoriteTabFromOfferType(offerType: ApiOfferType): FavoriteTab { + return offerType === 'RENT' ? 'rent' : 'buy'; +} + +function toFavoriteListingFromSummary(listing: ApiListingSummary): FavoriteListing { + const mode = favoriteTabFromOfferType(listing.offerType); + const price = listing.offerType === 'RENT' ? `${formatPln(listing.price)} / mies.` : formatPln(listing.price); + const location = [listing.city, listing.district].filter(Boolean).join(', '); + const floor = listingFloorText(listing.floor, listing.buildingFloors); + + return { + id: favoriteListingKey(listing.id), + listingId: listing.id, + mode, + image: listing.coverPhoto ?? heroImage, + price, + title: listing.title, + location: location || listing.city, + area: `${`${listing.area}`.replace('.', ',')} m²`, + rooms: listingRoomsText(listing.rooms), + floor: floor ? `${floor} piętro` : 'Brak danych o piętrze', + year: listing.yearBuilt ? `${listing.yearBuilt} rok` : 'Brak danych o roku budowy', + added: listingRelativeDate(listing.createdAt) || 'Dodane niedawno', + photos: 1, + badge: mode === 'rent' ? 'Na wynajem' : 'Na sprzedaż', + }; +} + +function toFavoriteListing(listing: ApiListingDetail): FavoriteListing { + const mode = favoriteTabFromOfferType(listing.offerType); + const price = listing.offerType === 'RENT' ? `${formatPln(listing.price)} / mies.` : formatPln(listing.price); + const location = [listing.city, listing.district].filter(Boolean).join(', '); + const floor = listingFloorText(listing.floor, listing.buildingFloors); + + return { + id: favoriteListingKey(listing.id), + listingId: listing.id, + mode, + image: listing.photos[0] ?? heroImage, + price, + title: listing.title, + location: location || listing.city, + area: `${`${listing.area}`.replace('.', ',')} m²`, + rooms: listingRoomsText(listing.rooms), + floor: floor ? `${floor} piętro` : 'Brak danych o piętrze', + year: listing.yearBuilt ? `${listing.yearBuilt} rok` : 'Brak danych o roku budowy', + added: listingRelativeDate(listing.createdAt) || 'Dodane niedawno', + photos: Math.max(listing.photos.length, 1), + badge: mode === 'rent' ? 'Na wynajem' : 'Na sprzedaż', + }; +} + +function buildMessageThreadTargetFromListing(listing: ApiListingDetail, user: AuthUser | null): MessageThreadTarget { + const sellerName = listing.contactName?.trim() || 'Administracja Mieszko'; + const isOwner = Boolean(user && listing.ownerEmail && user.email.toLowerCase() === listing.ownerEmail.toLowerCase()); + const sellerRole = isOwner ? 'To Twoje ogłoszenie' : 'Oficjalny kontakt zespołem Mieszko'; + const threadIdBase = listing.ownerEmail?.trim() || sellerName; + const threadId = `listing-${listing.id}-${threadIdBase.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; + + return { + id: threadId, + name: sellerName, + status: sellerRole, + listing: { + listingId: listing.id, + title: listing.title, + price: listing.price, + city: listing.city, + district: listing.district, + area: listing.area, + rooms: listing.rooms, + floor: listing.floor, + buildingFloors: listing.buildingFloors, + thumbnail: listing.photos[0] ?? heroImage, + }, + }; +} + +const benefits = [ + { + icon: 'coin', + title: '0 zł', + text: 'za ogłoszenie', + }, + { + icon: 'chart', + title: 'Tysiące kupujących', + text: 'każdego dnia', + }, + { + icon: 'shuffle', + title: 'AI dopasowuje', + text: 'kupujących', + }, + { + icon: 'shield', + title: 'Pełna widoczność', + text: 'w całej Polsce', + }, +]; + +const buyQuickFilters = [ + { + icon: 'flame', + title: 'Okazje cenowe', + text: 'Najlepsze oferty', + }, + { + icon: 'shield', + title: 'Bez pośredników', + text: 'Oferty prywatne', + }, + { + icon: 'spark', + title: 'Nowe dzisiaj', + text: 'Dodane w ciągu 24h', + }, +]; + +const insightCards = [ + { + icon: 'gear', + title: 'AI wycena mieszkania', + text: 'Sprawdź ile warte jest Twoje mieszkanie w 2 minuty', + action: 'Wycena za darmo', + view: 'valuation' as const, + }, + { + icon: 'trend', + title: 'Historia cen', + text: 'Sprawdź jak zmieniały się ceny w okolicy', + action: 'Zobacz wykresy', + view: 'priceHistory' as const, + }, + { + icon: 'house', + title: 'Ranking dzielnic', + text: 'Porównaj dzielnice pod kątem życia i inwestycji', + action: 'Zobacz ranking', + view: 'districtRanking' as const, + }, + { + icon: 'coin', + title: 'Ile możesz utargować?', + text: 'AI podpowie realistyczną cenę transakcyjną', + action: 'Sprawdź teraz', + view: 'negotiation' as const, + }, +]; + +type DistrictRankingMetric = 'overall' | 'price' | 'popularity' | 'rating'; +type DistrictPropertyType = 'flat' | 'house' | 'commercial'; +type DistrictMarketType = 'all' | 'secondary' | 'primary'; +type DistrictPeriod = '12' | '6' | '2026'; + +type DistrictRankingSource = { + district: string; + city: keyof typeof VALUATION_PRICE_PER_SQM_BY_CITY; + lat: number; + lng: number; + price: number; + monthlyChange: number; + demand: number; + liquidity: number; + residentScore: number; + investmentScore: number; +}; + +const districtRankingSources: DistrictRankingSource[] = [ + { district: 'Młyniska', city: 'Gdańsk', lat: 54.3792, lng: 18.6568, price: 20842, monthlyChange: 1.6, demand: 82, liquidity: 76, residentScore: 72, investmentScore: 89 }, + { district: 'Karlikowo', city: 'Sopot', lat: 54.4411, lng: 18.5652, price: 20678, monthlyChange: 4.46, demand: 91, liquidity: 82, residentScore: 88, investmentScore: 95 }, + { district: 'Śródmieście', city: 'Warszawa', lat: 52.235, lng: 21.0122, price: 20625, monthlyChange: 2.62, demand: 98, liquidity: 86, residentScore: 82, investmentScore: 98 }, + { district: 'Zacisze - Zalesie - Szczytniki', city: 'Wrocław', lat: 51.1133, lng: 17.098, price: 20475, monthlyChange: 2.07, demand: 78, liquidity: 74, residentScore: 95, investmentScore: 86 }, + { district: 'Orłowo', city: 'Gdynia', lat: 54.4793, lng: 18.5588, price: 19874, monthlyChange: 1.68, demand: 85, liquidity: 82, residentScore: 96, investmentScore: 88 }, + { district: 'Dolny Sopot', city: 'Sopot', lat: 54.445, lng: 18.5683, price: 19544, monthlyChange: 4.5, demand: 93, liquidity: 79, residentScore: 95, investmentScore: 92 }, + { district: 'Świniary', city: 'Wrocław', lat: 51.1678, lng: 16.9355, price: 19170, monthlyChange: 2.07, demand: 62, liquidity: 60, residentScore: 73, investmentScore: 63 }, + { district: 'Żoliborz', city: 'Warszawa', lat: 52.2682, lng: 20.9858, price: 18782, monthlyChange: 8, demand: 91, liquidity: 89, residentScore: 94, investmentScore: 90 }, + { district: 'Dzielnica I Stare Miasto', city: 'Kraków', lat: 50.0619, lng: 19.9373, price: 18685, monthlyChange: 3.11, demand: 96, liquidity: 68, residentScore: 86, investmentScore: 94 }, + { district: 'Śródmieście', city: 'Gdańsk', lat: 54.3519, lng: 18.6466, price: 18132, monthlyChange: 1.6, demand: 95, liquidity: 86, residentScore: 84, investmentScore: 92 }, + { district: 'Dzielnica VII Zwierzyniec', city: 'Kraków', lat: 50.0562, lng: 19.8985, price: 18087, monthlyChange: 3.11, demand: 84, liquidity: 78, residentScore: 96, investmentScore: 86 }, + { district: 'Wola', city: 'Warszawa', lat: 52.2338, lng: 20.9616, price: 17985, monthlyChange: 2.62, demand: 97, liquidity: 92, residentScore: 80, investmentScore: 97 }, + { district: 'Górny Sopot', city: 'Sopot', lat: 54.4378, lng: 18.5492, price: 17208, monthlyChange: 4.5, demand: 82, liquidity: 74, residentScore: 88, investmentScore: 81 }, + { district: 'Letnica', city: 'Gdańsk', lat: 54.3962, lng: 18.6386, price: 16813, monthlyChange: 1.6, demand: 88, liquidity: 84, residentScore: 76, investmentScore: 93 }, + { district: 'Dzielnica II Grzegórzki', city: 'Kraków', lat: 50.0606, lng: 19.9688, price: 16699, monthlyChange: 3.11, demand: 87, liquidity: 83, residentScore: 86, investmentScore: 90 }, + { district: 'Kamienna Góra', city: 'Gdynia', lat: 54.5066, lng: 18.5487, price: 16595, monthlyChange: 3.35, demand: 76, liquidity: 72, residentScore: 93, investmentScore: 78 }, + { district: 'Świemirowo', city: 'Sopot', lat: 54.4302, lng: 18.5451, price: 16530, monthlyChange: 12.85, demand: 74, liquidity: 62, residentScore: 81, investmentScore: 83 }, + { district: 'Śródmieście', city: 'Gdynia', lat: 54.5211, lng: 18.5392, price: 16269, monthlyChange: 3.35, demand: 94, liquidity: 80, residentScore: 84, investmentScore: 82 }, + { district: 'Strachocin - Swojczyce - Wojnów', city: 'Wrocław', lat: 51.1152, lng: 17.1477, price: 13669, monthlyChange: 2.07, demand: 70, liquidity: 72, residentScore: 87, investmentScore: 75 }, + { district: 'Ławica', city: 'Poznań', lat: 52.4229, lng: 16.7921, price: 13302, monthlyChange: 1.53, demand: 72, liquidity: 70, residentScore: 88, investmentScore: 74 }, +]; + +const DISTRICT_PROPERTY_MULTIPLIERS: Record = { + flat: 1, + house: 0.86, + commercial: 1.12, +}; + +const DISTRICT_MARKET_MULTIPLIERS: Record = { + all: 1, + secondary: 0.97, + primary: 1.08, +}; + +const DISTRICT_PERIOD_MULTIPLIERS: Record = { + '12': 1, + '6': 0.58, + '2026': 0.46, +}; + +const DISTRICT_POPULARITY_PERIOD_ADJUSTMENT: Record = { + '12': 0, + '6': -2.5, + '2026': 1.5, +}; + +const DISTRICT_RANKING_LABELS: Record = { + overall: 'Ogólny ranking', + price: 'Średnia cena za m²', + popularity: 'Popularność', + rating: 'Ocena mieszkańców', +}; + +function districtModelRow(source: DistrictRankingSource, propertyType: DistrictPropertyType, marketType: DistrictMarketType, period: DistrictPeriod) { + const price = Math.round((source.price * DISTRICT_PROPERTY_MULTIPLIERS[propertyType] * DISTRICT_MARKET_MULTIPLIERS[marketType]) / 10) * 10; + const cityBenchmark = VALUATION_PRICE_PER_SQM_BY_CITY[source.city]; + const pricePremiumVsCity = cityBenchmark > 0 ? ((price - cityBenchmark) / cityBenchmark) * 100 : 0; + const marketHeatIndex = clampNumber(50 + source.monthlyChange * 5.2 + pricePremiumVsCity * 0.44 + source.liquidity * 0.12, 35, 100); + const popularity = Math.round(clampNumber( + source.demand * 0.44 + source.liquidity * 0.31 + marketHeatIndex * 0.25 + DISTRICT_POPULARITY_PERIOD_ADJUSTMENT[period], + 40, + 99, + )); + const rating = Number((source.residentScore / 20).toFixed(1)); + const affordability = clampNumber(100 - ((price - 6500) / 17000) * 48, 45, 100); + const changeValue = clampNumber((source.monthlyChange * 2.2 + source.demand * 0.025 + source.investmentScore * 0.018 + (marketType === 'primary' ? 0.8 : marketType === 'secondary' ? -0.2 : 0)) * DISTRICT_PERIOD_MULTIPLIERS[period], 1.2, 13.8); + const score = Math.round(clampNumber(popularity * 0.28 + source.investmentScore * 0.27 + source.residentScore * 0.25 + affordability * 0.12 + source.liquidity * 0.08, 45, 99)); + + return { + ...source, + price, + changeValue, + change: `+${changeValue.toFixed(1).replace('.', ',')}%`, + popularity, + rating, + ratingText: rating.toFixed(1).replace('.', ','), + score, + }; +} + +const listings = [ + { + location: 'Kraków, Zabłocie', + meta: 'Mieszkanie · 3 pokoje · 62 m²', + price: '749 000 zł', + perMeter: '12 081 zł/m²', + badge: 'Nowe', + }, + { + location: 'Warszawa, Mokotów', + meta: 'Mieszkanie · 2 pokoje · 45 m²', + price: '679 000 zł', + perMeter: '15 089 zł/m²', + badge: 'Premium', + }, + { + location: 'Wrocław, Kępa Mieszczańska', + meta: 'Mieszkanie · 4 pokoje · 78 m²', + price: '899 000 zł', + perMeter: '11 526 zł/m²', + badge: 'Nowe', + }, + { + location: 'Gdańsk, Wrzeszcz', + meta: 'Mieszkanie · 3 pokoje · 56 m²', + price: '619 000 zł', + perMeter: '11 054 zł/m²', + badge: '', + }, +]; + +const cities = [ + { name: 'Warszawa', count: '12 582 ogłoszenia', position: '48% 55%' }, + { name: 'Kraków', count: '8 150 ogłoszeń', position: '54% 54%' }, + { name: 'Wrocław', count: '6 843 ogłoszenia', position: '42% 58%' }, + { name: 'Gdańsk', count: '5 231 ogłoszeń', position: '62% 52%' }, +]; + +const rentQuickFilters = [ + { + icon: 'sun', + title: 'Nowe dzisiaj', + text: 'Dodane w ciągu 24h', + }, + { + icon: 'student', + title: 'Dla studentów', + text: 'Blisko uczelni', + }, + { + icon: 'paw', + title: 'Zwierzęta mile widziane', + text: 'Przyjazne zwierzętom', + }, + { + icon: 'car', + title: 'Z miejscem parkingowym', + text: 'Parking dostępny', + }, +]; + +type BuyOffer = { + title: string; + propertyType: PropertySearchType; + city: string; + district: string; + rooms: number; + area: number; + priceValue: number; + market: 'wtorny' | 'pierwotny'; + year: number; + date: string; + dateOrder: number; + score: string; + saving: string; + tags: string[]; + position: string; + image: 'hero' | 'city'; +}; + +type PropertySearchType = 'Mieszkanie' | 'Dom' | 'Działka' | 'Lokal użytkowy' | 'Pokój'; + +const buyListings: BuyOffer[] = [ + { + title: 'Mieszkanie 3-pokojowe', + propertyType: 'Mieszkanie', + city: 'Warszawa', + district: 'Warszawa, Mokotów, Stegny', + rooms: 3, + area: 62, + priceValue: 749000, + market: 'wtorny', + year: 1980, + date: 'Dodane dzisiaj', + dateOrder: 0, + score: 'Ocena oferty 8.7/10', + saving: 'Możliwe do negocjacji: ok. 35 000 zł', + tags: ['Okazja', 'Bez prowizji'], + position: '47% 58%', + image: 'hero', + }, + { + title: 'Mieszkanie 2-pokojowe', + propertyType: 'Mieszkanie', + city: 'Warszawa', + district: 'Warszawa, Praga-Południe, Grochów', + rooms: 2, + area: 45, + priceValue: 699000, + market: 'pierwotny', + year: 2023, + date: 'Dodane dzisiaj', + dateOrder: 0, + score: 'Ocena oferty 8.2/10', + saving: 'Możliwe do negocjacji: ok. 20 000 zł', + tags: ['Nowe', 'Bez prowizji'], + position: '86% 55%', + image: 'city', + }, + { + title: 'Mieszkanie 3-pokojowe', + propertyType: 'Mieszkanie', + city: 'Warszawa', + district: 'Warszawa, Bemowo, Górczewska', + rooms: 3, + area: 55, + priceValue: 699000, + market: 'wtorny', + year: 2010, + date: 'Dodane wczoraj', + dateOrder: 1, + score: 'Ocena oferty 7.9/10', + saving: 'Możliwe do negocjacji: ok. 25 000 zł', + tags: ['Bez prowizji'], + position: '58% 62%', + image: 'hero', + }, + { + title: 'Dom 4-pokojowy', + propertyType: 'Dom', + city: 'Warszawa', + district: 'Warszawa, Bielany, Chomiczówka', + rooms: 4, + area: 72, + priceValue: 789000, + market: 'wtorny', + year: 1995, + date: 'Dodane 2 dni temu', + dateOrder: 2, + score: 'Ocena oferty 8.1/10', + saving: 'Cena niższa od podobnych ofert', + tags: ['Okazja', 'Bez prowizji'], + position: '33% 58%', + image: 'hero', + }, + { + title: 'Mieszkanie 2-pokojowe', + propertyType: 'Mieszkanie', + city: 'Kraków', + district: 'Kraków, Podgórze, Zabłocie', + rooms: 2, + area: 48, + priceValue: 679000, + market: 'pierwotny', + year: 2024, + date: 'Dodane dzisiaj', + dateOrder: 0, + score: 'Ocena oferty 8.5/10', + saving: 'Cena rynkowa dla lokalizacji', + tags: ['Nowe', 'Bez prowizji'], + position: '52% 55%', + image: 'city', + }, + { + title: 'Mieszkanie 3-pokojowe', + propertyType: 'Mieszkanie', + city: 'Kraków', + district: 'Kraków, Krowodrza, Bronowice', + rooms: 3, + area: 64, + priceValue: 845000, + market: 'wtorny', + year: 2008, + date: 'Dodane wczoraj', + dateOrder: 1, + score: 'Ocena oferty 8.0/10', + saving: 'Możliwe do negocjacji: ok. 30 000 zł', + tags: ['Bez prowizji'], + position: '44% 60%', + image: 'hero', + }, + { + title: 'Dom z ogrodem', + propertyType: 'Dom', + city: 'Wrocław', + district: 'Wrocław, Krzyki, Borek', + rooms: 4, + area: 78, + priceValue: 899000, + market: 'wtorny', + year: 2001, + date: 'Dodane dzisiaj', + dateOrder: 0, + score: 'Ocena oferty 8.3/10', + saving: 'Możliwe do negocjacji: ok. 40 000 zł', + tags: ['Okazja', 'Bez prowizji'], + position: '60% 50%', + image: 'hero', + }, + { + title: 'Mieszkanie 2-pokojowe', + propertyType: 'Mieszkanie', + city: 'Wrocław', + district: 'Wrocław, Stare Miasto, Kępa Mieszczańska', + rooms: 2, + area: 41, + priceValue: 569000, + market: 'pierwotny', + year: 2025, + date: 'Dodane 2 dni temu', + dateOrder: 2, + score: 'Ocena oferty 8.6/10', + saving: 'Nowa inwestycja, cena deweloperska', + tags: ['Nowe', 'Bez prowizji'], + position: '38% 54%', + image: 'city', + }, + { + title: 'Mieszkanie 3-pokojowe', + propertyType: 'Mieszkanie', + city: 'Gdańsk', + district: 'Gdańsk, Wrzeszcz, Strzyża', + rooms: 3, + area: 58, + priceValue: 749000, + market: 'wtorny', + year: 2015, + date: 'Dodane dzisiaj', + dateOrder: 0, + score: 'Ocena oferty 8.4/10', + saving: 'Cena niższa od podobnych ofert', + tags: ['Okazja', 'Bez prowizji'], + position: '66% 48%', + image: 'hero', + }, + { + title: 'Pokój inwestycyjny', + propertyType: 'Pokój', + city: 'Gdańsk', + district: 'Gdańsk, Śródmieście, Wyspa Spichrzów', + rooms: 1, + area: 29, + priceValue: 459000, + market: 'pierwotny', + year: 2024, + date: 'Dodane wczoraj', + dateOrder: 1, + score: 'Ocena oferty 8.1/10', + saving: 'Idealne pod inwestycję', + tags: ['Nowe', 'Bez prowizji'], + position: '72% 52%', + image: 'city', + }, + { + title: 'Działka budowlana', + propertyType: 'Działka', + city: 'Poznań', + district: 'Poznań, Jeżyce, Sołacz', + rooms: 3, + area: 61, + priceValue: 689000, + market: 'wtorny', + year: 2012, + date: 'Dodane 3 dni temu', + dateOrder: 3, + score: 'Ocena oferty 7.8/10', + saving: 'Możliwe do negocjacji: ok. 22 000 zł', + tags: ['Bez prowizji'], + position: '50% 58%', + image: 'hero', + }, + { + title: 'Lokal użytkowy', + propertyType: 'Lokal użytkowy', + city: 'Łódź', + district: 'Łódź, Śródmieście, Nowe Centrum', + rooms: 2, + area: 44, + priceValue: 379000, + market: 'wtorny', + year: 2018, + date: 'Dodane dzisiaj', + dateOrder: 0, + score: 'Ocena oferty 8.0/10', + saving: 'Bardzo dobra cena za m²', + tags: ['Okazja', 'Bez prowizji'], + position: '54% 56%', + image: 'city', + }, +]; + +const POLISH_CITIES = [ + 'Warszawa', 'Kraków', 'Łódź', 'Wrocław', 'Poznań', 'Gdańsk', 'Szczecin', 'Bydgoszcz', + 'Lublin', 'Białystok', 'Katowice', 'Gdynia', 'Częstochowa', 'Radom', 'Sosnowiec', + 'Toruń', 'Kielce', 'Rzeszów', 'Gliwice', 'Zabrze', 'Olsztyn', 'Bielsko-Biała', + 'Bytom', 'Zielona Góra', 'Rybnik', 'Ruda Śląska', 'Opole', 'Tychy', 'Gorzów Wielkopolski', + 'Dąbrowa Górnicza', 'Elbląg', 'Płock', 'Wałbrzych', 'Włocławek', 'Tarnów', 'Chorzów', + 'Koszalin', 'Kalisz', 'Legnica', 'Grudziądz', 'Słupsk', 'Jaworzno', 'Jastrzębie-Zdrój', + 'Nowy Sącz', 'Jelenia Góra', 'Siedlce', 'Mysłowice', 'Konin', 'Piła', 'Piotrków Trybunalski', + 'Inowrocław', 'Lubin', 'Ostrów Wielkopolski', 'Suwałki', 'Stargard', 'Gniezno', 'Ostrowiec Świętokrzyski', + 'Siemianowice Śląskie', 'Głogów', 'Pabianice', 'Leszno', 'Żory', 'Zamość', 'Pruszków', + 'Łomża', 'Ełk', 'Tomaszów Mazowiecki', 'Chełm', 'Mielec', 'Kędzierzyn-Koźle', 'Przemyśl', + 'Stalowa Wola', 'Tczew', 'Biała Podlaska', 'Bełchatów', 'Świdnica', 'Będzin', 'Zgierz', + 'Piekary Śląskie', 'Racibórz', 'Legionowo', 'Ostrołęka', 'Świnoujście', 'Starachowice', + 'Zawiercie', 'Wejherowo', 'Puławy', 'Wodzisław Śląski', 'Skierniewice', 'Tarnowskie Góry', + 'Starogard Gdański', 'Rumia', 'Kutno', 'Nysa', 'Sieradz', 'Radomsko', 'Otwock', 'Skarżysko-Kamienna', + 'Dębica', 'Ciechanów', 'Kalisz', 'Mińsk Mazowiecki', 'Krosno', 'Piaseczno', 'Sopot', + 'Świętochłowice', 'Nowy Targ', 'Zakopane', 'Oświęcim', 'Bochnia', 'Brzeg', 'Malbork', + 'Kołobrzeg', 'Sanok', 'Jarosław', 'Lubliniec', 'Augustów', 'Władysławowo', 'Gostyń', + 'Wągrowiec', 'Żywiec', 'Cieszyn', 'Mława', 'Kraśnik', 'Świdnik', 'Grójec', 'Wieliczka', + 'Józefów', 'Marki', 'Ząbki', 'Łuków', 'Hrubieszów', 'Biłgoraj', 'Sandomierz', 'Police', + 'Knurów', 'Czeladź', 'Bolesławiec', 'Oleśnica', 'Kłodzko', 'Dzierżoniów', 'Nowa Sól', + 'Żary', 'Kościerzyna', 'Chojnice', 'Kwidzyn', 'Lębork', 'Iława', 'Giżycko', 'Działdowo', + 'Szczecinek', 'Wałcz', 'Białogard', 'Goleniów', 'Gryfino', 'Międzyrzecz', 'Krotoszyn', + 'Jarocin', 'Środa Wielkopolska', 'Turek', 'Koło', 'Września', 'Śrem', 'Rawicz', 'Kościan', +]; + +const ROOM_WORDS: Record = { 1: 'pokój', 2: 'pokoje', 3: 'pokoje', 4: 'pokoje', 5: 'pokoi' }; + +const POPULAR_CITIES = ['Warszawa', 'Kraków', 'Wrocław', 'Gdańsk', 'Poznań', 'Łódź']; + +const DEFAULT_RECENT = ['Warszawa Mokotów', 'Kraków Zabłocie', 'Wrocław Krzyki']; + +type SortKey = 'default' | 'new' | 'old' | 'cheap' | 'expensive' | 'small' | 'big'; + +const SORT_OPTIONS: { value: SortKey; label: string }[] = [ + { value: 'default', label: 'Domyślnie' }, + { value: 'new', label: 'Data dodania: najnowsze' }, + { value: 'old', label: 'Data dodania: najstarsze' }, + { value: 'cheap', label: 'Cena: od najniższej' }, + { value: 'expensive', label: 'Cena: od najwyższej' }, + { value: 'small', label: 'Powierzchnia: od najmniejszej' }, + { value: 'big', label: 'Powierzchnia: od największej' }, +]; + +const PRICE_PRESETS: { label: string; value: number }[] = [ + { label: 'do 500k', value: 500000 }, + { label: 'do 700k', value: 700000 }, + { label: 'do 1 mln', value: 1000000 }, + { label: 'do 1,5 mln', value: 1500000 }, + { label: 'do 2 mln', value: 2000000 }, +]; + +const RENT_PRICE_PRESETS: { label: string; value: number }[] = [ + { label: 'do 2 tys.', value: 2000 }, + { label: 'do 3 tys.', value: 3000 }, + { label: 'do 4 tys.', value: 4000 }, + { label: 'do 6 tys.', value: 6000 }, + { label: 'do 8 tys.', value: 8000 }, +]; + +const MORE_FILTERS: { key: string; label: string; options: string[] }[] = [ + { key: 'pietro', label: 'Piętro', options: ['Dowolne', 'Parter', '1–3 piętro', '4–10 piętro', 'Powyżej 10'] }, + { key: 'balkon', label: 'Balkon / Taras', options: ['Dowolne', 'Balkon', 'Taras', 'Loggia', 'Ogródek'] }, + { key: 'stan', label: 'Stan mieszkania', options: ['Dowolne', 'Do wykończenia', 'Do odświeżenia', 'Dobry', 'Po remoncie', 'Wysoki standard'] }, + { key: 'winda', label: 'Winda', options: ['Dowolne', 'Tak', 'Nie'] }, + { key: 'ogrzewanie', label: 'Ogrzewanie', options: ['Dowolne', 'Miejskie', 'Gazowe', 'Elektryczne', 'Pompa ciepła'] }, + { key: 'parking', label: 'Miejsce parkingowe', options: ['Dowolne', 'Garaż', 'Hala garażowa', 'Miejsce naziemne', 'Brak'] }, + { key: 'czynsz', label: 'Czynsz', options: ['Dowolny', 'do 400 zł', 'do 600 zł', 'do 800 zł', 'do 1000 zł'] }, + { key: 'rok', label: 'Rok budowy', options: ['Dowolny', 'Do 1970', '1971–2000', '2001–2015', 'Po 2015'] }, +]; + +const PRICE_SLIDER_MAX = 2000000; +const PRICE_SLIDER_STEP = 25000; +const RENT_PRICE_SLIDER_MAX = 15000; +const RENT_PRICE_SLIDER_STEP = 100; + +const AREA_PRESETS: { label: string; min: number; max: number }[] = [ + { label: 'do 35 m²', min: 0, max: 35 }, + { label: '35–50 m²', min: 35, max: 50 }, + { label: '50–70 m²', min: 50, max: 70 }, + { label: '70–90 m²', min: 70, max: 90 }, + { label: 'pow. 90 m²', min: 90, max: 0 }, +]; + +const AREA_SLIDER_MAX = 150; +const AREA_SLIDER_STEP = 5; + +const ROOM_OPTIONS = [1, 2, 3, 4, 5]; + +function roomsLabel(rooms: number): string { + return `${rooms} ${ROOM_WORDS[rooms] ?? 'pokoi'}`; +} + +function formatPln(value: number): string { + return `${value.toLocaleString('pl-PL').replace(/\u00a0/g, ' ')} zł`; +} + +/** + * Konwertuje ulotny blob/URL zdjecia na skompresowany data URL (JPEG), przeskalowany do maks. + * `maxDim` px po dluzszym boku - dzieki temu zdjecia da sie utrwalic w bazie bez rozdmuchiwania jej. + */ +async function imageUrlToDataUrl(url: string, maxDim = 1280, quality = 0.72): Promise { + try { + const blob = await (await fetch(url)).blob(); + const bitmap = await createImageBitmap(blob); + const scale = Math.min(1, maxDim / Math.max(bitmap.width, bitmap.height)); + const width = Math.max(1, Math.round(bitmap.width * scale)); + const height = Math.max(1, Math.round(bitmap.height * scale)); + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) { + return null; + } + ctx.drawImage(bitmap, 0, 0, width, height); + bitmap.close(); + return canvas.toDataURL('image/jpeg', quality); + } catch { + return null; + } +} + +/** + * Bezpieczny generator ID. crypto.randomUUID istnieje tylko w bezpiecznym kontekscie (HTTPS/localhost); + * przy dostepie po IP w sieci lokalnej jest undefined i rzucalby wyjatek, wywalajac apke do bialego ekranu. + */ +function safeRandomId(): string { + const webCrypto = typeof crypto !== 'undefined' ? crypto : undefined; + if (webCrypto && typeof webCrypto.randomUUID === 'function') { + return webCrypto.randomUUID(); + } + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +function offerMeta(offer: BuyOffer): string { + const perMeter = Math.round(offer.priceValue / offer.area); + const roomPart = offer.propertyType === 'Działka' ? offer.propertyType : roomsLabel(offer.rooms); + return `${roomPart} • ${offer.area} m² • ${formatPln(perMeter).replace(' zł', '')} zł/m² • ${offer.year} r.`; +} + +type NegotiationChance = { + label: 'Wysoka' | 'Średnia' | 'Niska'; + text: string; +}; + +type NegotiationModel = { + offerPerMeter: number; + minPrice: number; + maxPrice: number; + minPerMeter: number; + maxPerMeter: number; + negotiationSpan: number; + expectedSaving: number; + chance: NegotiationChance; + marketAvgPerMeter: number; + marketDeltaPct: number; + conditionDiscountPct: number; + marketDays: number; + seasonNegotiationBonusPct: number; +}; + +type NegotiationTransaction = { + street: string; + meta: string; + price: string; + sqm: string; + date: string; + image: 'hero' | 'city'; +}; + +function roundPrice(value: number): number { + return Math.round(value / 100) * 100; +} + +function negotiationChanceFromScore(score: number): NegotiationChance { + if (score >= 76) { + return { + label: 'Wysoka', + text: 'Sprzedający prawdopodobnie zaakceptuje ofertę w tym zakresie cenowym.', + }; + } + if (score >= 52) { + return { + label: 'Średnia', + text: 'Oferta jest realna, ale sprzedający może oczekiwać mocniejszego uzasadnienia ceny.', + }; + } + return { + label: 'Niska', + text: 'Ten zakres może być zbyt agresywny. Rozważ podniesienie oferty startowej.', + }; +} + +function buildNegotiationModel(offer: BuyOffer): NegotiationModel { + const offerPerMeter = offer.priceValue / offer.area; + const cityBase = VALUATION_PRICE_PER_SQM_BY_CITY[offer.city] ?? offerPerMeter * 0.94; + const premiumToCity = cityBase > 0 ? (offerPerMeter - cityBase) / cityBase : 0; + const age = Math.max(0, 2026 - offer.year); + const marketFactor = offer.market === 'wtorny' ? 0.017 : 0.007; + const ageFactor = clampNumber(age / 145, 0, 0.07); + const opportunityFactor = offer.tags.includes('Okazja') ? 0.013 : 0; + const premiumFactor = Math.max(0, premiumToCity) * 0.5; + const baselineDiscount = clampNumber(0.026 + marketFactor + ageFactor + opportunityFactor + premiumFactor, 0.02, 0.165); + const spreadDiscount = clampNumber(0.014 + Math.max(0, premiumToCity) * 0.16 + age / 620, 0.012, 0.052); + const rawMax = roundPrice(offer.priceValue * (1 - baselineDiscount)); + const rawMin = roundPrice(offer.priceValue * (1 - baselineDiscount - spreadDiscount)); + const floorMin = roundPrice(offer.priceValue * 0.75); + const minPrice = Math.max(floorMin, Math.min(rawMin, rawMax - 5000)); + const maxPrice = Math.max(minPrice + 5000, Math.min(rawMax, offer.priceValue - 2000)); + const minPerMeter = Math.round(minPrice / offer.area); + const maxPerMeter = Math.round(maxPrice / offer.area); + const negotiationSpan = maxPrice - minPrice; + const expectedSaving = offer.priceValue - Math.round((minPrice + maxPrice) / 2); + const chanceScore = clampNumber( + 61 + + (offer.market === 'wtorny' ? 11 : -4) + + (offer.tags.includes('Okazja') ? 7 : 0) + + Math.max(0, premiumToCity) * 62 + + Math.min(0, premiumToCity) * 22 + + Math.min(age / 2.7, 14), + 24, + 96, + ); + + return { + offerPerMeter: Math.round(offerPerMeter), + minPrice, + maxPrice, + minPerMeter, + maxPerMeter, + negotiationSpan, + expectedSaving, + chance: negotiationChanceFromScore(chanceScore), + marketAvgPerMeter: Math.round(clampNumber((minPerMeter * 0.55 + maxPerMeter * 0.45), 1000, 40000)), + marketDeltaPct: Number((clampNumber((offerPerMeter - maxPerMeter) / offerPerMeter, -0.03, 0.18) * 100).toFixed(1)), + conditionDiscountPct: Number((clampNumber((age / 2.2) * 0.35 + (offer.market === 'wtorny' ? 2.2 : 1.1), 1.2, 8.4)).toFixed(1)), + marketDays: Math.round(clampNumber(38 + age * 1.05 + (offer.market === 'wtorny' ? 11 : 5), 28, 124)), + seasonNegotiationBonusPct: Number((clampNumber(1.2 + (offer.market === 'wtorny' ? 1 : 0.5) + Math.max(0, premiumToCity) * 3.5, 0.8, 5.6)).toFixed(1)), + }; +} + +function buildComparableTransactions(activeOffer: BuyOffer, source: BuyOffer[]): NegotiationTransaction[] { + const sameCityAndType = source + .filter((offer) => offer !== activeOffer && offer.city === activeOffer.city && offer.propertyType === activeOffer.propertyType) + .sort((left, right) => Math.abs(left.area - activeOffer.area) - Math.abs(right.area - activeOffer.area)); + const sameCity = source + .filter((offer) => offer !== activeOffer && offer.city === activeOffer.city) + .sort((left, right) => Math.abs(left.area - activeOffer.area) - Math.abs(right.area - activeOffer.area)); + const fallback = source + .filter((offer) => offer !== activeOffer) + .sort((left, right) => Math.abs(left.area - activeOffer.area) - Math.abs(right.area - activeOffer.area)); + const merged = [...sameCityAndType, ...sameCity, ...fallback]; + const unique: BuyOffer[] = []; + const keys = new Set(); + for (const offer of merged) { + const key = `${offer.city}|${offer.district}|${offer.title}|${offer.priceValue}`; + if (keys.has(key)) { + continue; + } + keys.add(key); + unique.push(offer); + if (unique.length >= 3) { + break; + } + } + return unique.map((offer) => { + const perMeter = Math.round(offer.priceValue / offer.area); + return { + street: offer.district, + meta: `${offer.area} m² · ${roomsLabel(offer.rooms)} · ${offer.year} r.`, + price: formatPln(offer.priceValue), + sqm: `${formatPln(perMeter).replace(' zł', '')} zł/m²`, + date: offer.date, + image: offer.image, + }; + }); +} + +type RentOffer = { + title: string; + propertyType: PropertySearchType; + city: string; + district: string; + rooms: number; + area: number; + priceValue: number; + market: 'wtorny' | 'pierwotny'; + floor: string; + buildingFloors: string; + date: string; + dateOrder: number; + tags: string[]; + amenities: string[]; + position: string; + forStudents: boolean; + petsAllowed: boolean; + parkingIncluded: boolean; +}; + +const rentListings: RentOffer[] = [ + { + title: 'Mieszkanie 2-pokojowe', + propertyType: 'Mieszkanie', + city: 'Warszawa', + district: 'Warszawa, Mokotów, Służew', + rooms: 2, + area: 42, + priceValue: 3300, + market: 'wtorny', + floor: '3', + buildingFloors: '6', + date: 'Dodane dzisiaj', + dateOrder: 0, + tags: ['Nowe', 'Bez prowizji'], + amenities: ['Umeblowane', 'Balkon', 'Winda'], + position: '47% 58%', + forStudents: true, + petsAllowed: false, + parkingIncluded: false, + }, + { + title: 'Mieszkanie 1-pokojowe', + propertyType: 'Mieszkanie', + city: 'Warszawa', + district: 'Warszawa, Praga-Południe, Grochów', + rooms: 1, + area: 28, + priceValue: 2200, + market: 'wtorny', + floor: '2', + buildingFloors: '4', + date: 'Dodane dzisiaj', + dateOrder: 0, + tags: ['Bez prowizji'], + amenities: ['Umeblowane', 'Winda'], + position: '86% 55%', + forStudents: true, + petsAllowed: false, + parkingIncluded: false, + }, + { + title: 'Mieszkanie 3-pokojowe', + propertyType: 'Mieszkanie', + city: 'Warszawa', + district: 'Warszawa, Bemowo, Górce', + rooms: 3, + area: 55, + priceValue: 3800, + market: 'pierwotny', + floor: '1', + buildingFloors: '5', + date: 'Dodane wczoraj', + dateOrder: 1, + tags: ['Bez prowizji'], + amenities: ['Umeblowane', 'Balkon', 'Parking'], + position: '58% 62%', + forStudents: false, + petsAllowed: true, + parkingIncluded: true, + }, + { + title: 'Mieszkanie 2-pokojowe', + propertyType: 'Mieszkanie', + city: 'Warszawa', + district: 'Warszawa, Wola, Nowolipki', + rooms: 2, + area: 38, + priceValue: 2900, + market: 'wtorny', + floor: '4', + buildingFloors: '7', + date: 'Dodane wczoraj', + dateOrder: 1, + tags: ['Bez prowizji'], + amenities: ['Umeblowane', 'Winda'], + position: '33% 58%', + forStudents: false, + petsAllowed: true, + parkingIncluded: false, + }, +]; + +const serviceCategories = [ + { + icon: 'credit-card', + title: 'Finanse', + text: 'Kredyty hipoteczne, doradcy, ubezpieczenia', + }, + { + icon: 'scale', + title: 'Prawo i dokumenty', + text: 'Notariusze, prawnicy, rzeczoznawcy', + }, + { + icon: 'paint', + title: 'Remont i wykończenie', + text: 'Firmy remontowe, fachowcy, projekty wnętrz', + }, + { + icon: 'truck', + title: 'Przeprowadzka', + text: 'Firmy przeprowadzkowe, magazyny, transport', + }, + { + icon: 'sofa', + title: 'Wyposażenie wnętrz', + text: 'Meble, AGD, kuchnie na wymiar, oświetlenie', + }, + { + icon: 'camera', + title: 'Sprzedaż nieruchomości', + text: 'Fotografia, home staging, wirtualne spacery 3D', + }, + { + icon: 'city', + title: 'Deweloperzy i inwestycje', + text: 'Nowe inwestycje, deweloperzy, biura sprzedaży', + }, + { + icon: 'dots', + title: 'Inne usługi', + text: 'Usługi dodatkowe związane z nieruchomościami', + }, +]; + +const serviceCompanies = [ + { + icon: 'house', + title: 'Finanse Expert', + rating: '4.9 (128 opinii)', + text: 'Kredyty hipoteczne', + city: 'Warszawa, cała Polska', + }, + { + icon: 'hammer', + title: 'Remont Masters', + rating: '4.8 (96 opinii)', + text: 'Remonty i wykończenia', + city: 'Kraków, małopolskie', + }, + { + icon: 'letter', + title: 'Notariusz Nowak', + rating: '4.9 (74 opinie)', + text: 'Kancelarie notarialne', + city: 'Wrocław, dolnośląskie', + }, + { + icon: 'truck', + title: 'SuperPrzeprowadzki', + rating: '4.7 (111 opinii)', + text: 'Przeprowadzki', + city: 'Poznań, wielkopolskie', + }, +]; + +const serviceDirectory = [ + { + title: 'Remont Masters', + rating: '4.8 (96 opinii)', + text: 'Kompleksowe remonty mieszkań i domów', + city: 'Kraków, małopolskie', + image: heroImage, + position: '72% 62%', + }, + { + title: 'Perfect House', + rating: '4.7 (64 opinie)', + text: 'Wykończenia wnętrz pod klucz', + city: 'Warszawa, mazowieckie', + image: heroImage, + position: '92% 50%', + }, + { + title: 'Home Finish', + rating: '4.6 (52 opinie)', + text: 'Remonty, wykończenia, projekty wnętrz', + city: 'Wrocław, dolnośląskie', + image: heroImage, + position: '48% 58%', + }, +]; + +const serviceSteps = [ + { + icon: 'group', + title: 'Wybierz usługę', + text: 'Wybierz kategorię, która Cię interesuje', + }, + { + icon: 'search', + title: 'Porównaj firmy', + text: 'Sprawdź opinie, ceny i portfolio firm', + }, + { + icon: 'message', + title: 'Skontaktuj się', + text: 'Napisz lub zadzwoń bezpośrednio do firmy', + }, + { + icon: 'paint', + title: 'Zrealizuj usługę', + text: 'Ciesz się profesjonalnie wykonaną usługą', + }, +]; + +const sellSteps = [ + { + icon: 'document', + title: 'Dodaj ogłoszenie', + text: 'Wypełnij formularz i dodaj zdjęcia', + }, + { + icon: 'calculator', + title: 'My wycenimy się resztą', + text: 'Twoje ogłoszenie trafi do tysięcy kupujących', + }, + { + icon: 'message', + title: 'Otrzymujesz zapytania', + text: 'Kupujący kontaktują się bezpośrednio z Tobą', + }, + { + icon: 'scale', + title: 'Negocjujesz warunki', + text: 'Rozmawiasz i ustalasz szczegóły sprzedaży', + }, + { + icon: 'money', + title: 'Sprzedajesz', + text: 'Finalizujesz transakcję i odbierasz pieniądze', + }, +]; + +const sellReasons = [ + { + icon: 'check', + title: 'Całkowicie za darmo', + text: '0 zł prowizji, 0 zł opłat', + }, + { + icon: 'target', + title: 'Kupujący z całej Polski', + text: 'Docieramy do tysięcy osób każdego dnia', + }, + { + icon: 'spark', + title: 'Sprzedaż nawet w 7 dni', + text: 'Szybka sprzedaż dzięki dużej bazie kupujących', + }, + { + icon: 'shield', + title: 'Pełna kontrola', + text: 'To Ty decydujesz z kim rozmawiasz i na jakich warunkach', + }, + { + icon: 'warning', + title: 'Wsparcie na każdym etapie', + text: 'Pomocny asystent AI prowadzi przez cały proces', + }, +]; + +const sellChannels = [ + { + icon: 'logo', + title: 'mieszko.pl', + text: 'Nasza strona', + }, + { + icon: 'search', + title: 'Google', + text: 'Reklama Google', + }, + { + icon: 'message', + title: 'Facebook', + text: 'Marketplace i posty', + }, + { + icon: 'group', + title: 'Grupy lokalne', + text: 'Ponad 500 grup', + }, + { + icon: 'mail', + title: 'Newsletter', + text: 'Tysiące subskrybentów', + }, +]; + +const sellFeatures = [ + { + icon: 'star', + title: 'Wyróżnione ogłoszenie', + text: 'Twoje ogłoszenie na szczycie listy', + }, + { + icon: 'camera', + title: 'Galeria zdjęć i video', + text: 'Dodaj do 30 zdjęć i film', + }, + { + icon: 'tour', + title: 'Wirtualny spacer 360°', + text: 'Pokaż mieszkanie w 3D', + }, + { + icon: 'chart', + title: 'Statystyki oglądalności', + text: 'Sprawdzaj zainteresowanie', + }, + { + icon: 'bell', + title: 'Powiadomienia o nowych', + text: 'Bądź pierwszy w kontaktach', + }, +]; + +const sellTestimonials = [ + { + name: 'Anna, Kraków', + result: 'Sprzedane w 6 dni', + text: 'Dodałam ogłoszenie w poniedziałek, w sobotę miałam już kupca! Zero prowizji, świetny kontakt.', + }, + { + name: 'Marek, Warszawa', + result: 'Sprzedane w 4 dni', + text: 'Najlepszy portal do sprzedaży mieszkań. Dużo zapytań i poważnych kupujących.', + }, + { + name: 'Kasia, Wrocław', + result: 'Sprzedane w 7 dni', + text: 'Prosty formularz, szybka publikacja i bardzo wszystkie zero kosztów. Polecam!', + }, +]; + +const addProgressSteps = ['Szczegóły', 'Lokalizacja', 'Zdjęcia', 'Dodatkowe informacje', 'Podgląd ogłoszenia']; + +const listingTips = [ + { + icon: 'document', + title: 'Napisz szczegółowy opis', + text: 'Im więcej informacji, tym większe zainteresowanie.', + }, + { + icon: 'money', + title: 'Ustal dokładną cenę', + text: 'Sprawdź ceny podobnych mieszkań w okolicy.', + }, + { + icon: 'lock', + title: 'Bądź w kontakcie', + text: 'Szybkie odpowiedzi zwiększają szanse na sprzedaż.', + }, +]; + +const extraInfoTips = [ + { + icon: 'document', + title: 'Im więcej informacji, tym lepiej', + text: 'Szczegółowy opis zwiększa zaufanie i zainteresowanie ofertą.', + }, + { + icon: 'shield', + title: 'Sprawdź dokładnie dane', + text: 'Upewnij się, że wszystkie informacje są poprawne.', + }, + { + icon: 'star', + title: 'Wyróżnij atuty nieruchomości', + text: 'Dodatkowe udogodnienia mogą przyciągnąć kupujących.', + }, +]; + +const mediaOptions = [ + { icon: 'flame', label: 'Ogrzewanie miejskie', checked: true }, + { icon: 'flame', label: 'Ogrzewanie gazowe' }, + { icon: 'house', label: 'Ogrzewanie elektryczne' }, + { icon: 'building', label: 'Ogrzewanie podłogowe' }, + { icon: 'sun', label: 'Woda miejska', checked: true }, + { icon: 'link', label: 'Kanalizacja', checked: true }, + { icon: 'link', label: 'Gospodarka ściekowa' }, + { icon: 'spark', label: 'Prąd', checked: true }, + { icon: 'flame', label: 'Gaz' }, + { icon: 'wifi', label: 'Internet', checked: true }, + { icon: 'wifi', label: 'Światłowód' }, + { icon: 'screen', label: 'Telewizja kablowa' }, + { icon: 'shield', label: 'Alarm' }, + { icon: 'phone', label: 'Domofon' }, + { icon: 'screen', label: 'Wideodomofon' }, + { icon: 'spark', label: 'Smart Home' }, + { icon: 'sun', label: 'Fotowoltaika' }, + { icon: 'snow', label: 'Pompa ciepła' }, + { icon: 'building', label: 'Rekuperacja' }, + { icon: 'snow', label: 'Klimatyzacja' }, +]; + +const RENT_BILLING_OPTIONS_HEATING = ['Wliczone w cenę', 'Według zużycia', 'Stała opłata']; +const RENT_BILLING_OPTIONS_WATER = ['Wliczona w cenę', 'Według zużycia', 'Stała opłata']; +const RENT_BILLING_OPTIONS_ENERGY = ['Wliczony w cenę', 'Według zużycia', 'Stała opłata']; +const RENT_BILLING_OPTIONS_INTERNET = ['Wliczony w cenę', 'Dodatkowo płatny (kwota)']; +const RENT_BILLING_OPTIONS_TV = ['Wliczona w cenę', 'Dodatkowo płatna (kwota)']; +const RENT_BILLING_OPTIONS_PARKING = ['W cenie', 'Dodatkowo płatny (kwota/mies.)']; + +function billingRequiresAmount(mode: string): boolean { + return mode === 'Stała opłata' || mode.includes('(kwota'); +} + +function getRentBillingOptions(label: string): string[] | null { + if (label.startsWith('Ogrzewanie')) { + return RENT_BILLING_OPTIONS_HEATING; + } + if (label === 'Woda miejska') { + return RENT_BILLING_OPTIONS_WATER; + } + if (label === 'Prąd' || label === 'Gaz') { + return RENT_BILLING_OPTIONS_ENERGY; + } + if (label === 'Internet') { + return RENT_BILLING_OPTIONS_INTERNET; + } + if (label === 'Telewizja kablowa') { + return RENT_BILLING_OPTIONS_TV; + } + return null; +} + +const amenityOptions = [ + { icon: 'building', label: 'Balkon', checked: true }, + { icon: 'building', label: 'Taras' }, + { icon: 'wifi', label: 'Ogródek' }, + { icon: 'city', label: 'Winda', checked: true }, + { icon: 'car', label: 'Parking', checked: true }, + { icon: 'car', label: 'Garaż' }, + { icon: 'building', label: 'Strych' }, + { icon: 'shield', label: 'Ogrodzenie' }, + { icon: 'student', label: 'Dla studentów' }, + { icon: 'paw', label: 'Zwierzęta mile widziane' }, + { icon: 'building', label: 'Piwnica', checked: true }, + { icon: 'building', label: 'Komórka lokatorska' }, + { icon: 'document', label: 'Dostęp dla niepełnosprawnych' }, + { icon: 'eye', label: 'Monitoring' }, + { icon: 'shield', label: 'Ochrona' }, +]; + +const houseAmenityOptions = [ + { icon: 'flame', label: 'Kominek' }, + { icon: 'sun', label: 'Basen' }, + { icon: 'sun', label: 'Jacuzzi' }, + { icon: 'snow', label: 'Sauna' }, + { icon: 'sun', label: 'Ogród zimowy' }, + { icon: 'house', label: 'Altana' }, + { icon: 'car', label: 'Automatyczna brama' }, + { icon: 'shield', label: 'Rolety zewnętrzne' }, + { icon: 'eye', label: 'Monitoring zewnętrzny' }, + { icon: 'shield', label: 'System alarmowy' }, + { icon: 'sun', label: 'Panele fotowoltaiczne' }, + { icon: 'snow', label: 'Pompa ciepła' }, + { icon: 'spark', label: 'Magazynek energii' }, + { icon: 'car', label: 'Ładowarka do samochodu elektrycznego' }, + { icon: 'flame', label: 'Grill murowany' }, + { icon: 'sun', label: 'Oczko wodne' }, + { icon: 'student', label: 'Plac zabaw' }, + { icon: 'target', label: 'Boisko' }, +]; + +const extraInfoSelectOptions = { + rentalPeriod: ['Wybierz', 'Krótkoterminowy', 'Długoterminowy', 'Bez określonego czasu'], + furnishing: ['Wybierz', 'Tak', 'Częściowo', 'Nie'], + buildingType: ['Wybierz', 'Blok', 'Kamienica', 'Apartamentowiec', 'Dom wielorodzinny', 'Loft'], + houseType: ['Wybierz', 'Wolnostojący', 'Bliźniak', 'Szeregowy', 'Segment skrajny', 'Segment środkowy', 'Dworek', 'Rezydencja'], + buildingMaterial: ['Wybierz', 'Cegła', 'Pustak', 'Beton', 'Drewno', 'Keramzyt', 'Silikat', 'Prefabrykat', 'Inny'], + windows: ['Wybierz', 'PCV', 'Drewniane', 'Aluminiowe'], + exposure: ['Wybierz', 'Północ', 'Południe', 'Wschód', 'Zachód', 'Północ-południe', 'Wschód-zachód', 'Wielostronna'], + houseFloors: ['Wybierz', 'Parterowy', '1', '2', '3+'], + roofType: ['Wybierz', 'Dwuspadowy', 'Czterospadowy', 'Płaski', 'Wielospadowy'], + roofCover: ['Wybierz', 'Dachówka', 'Blachodachówka', 'Blacha', 'Papa', 'Gont', 'Inne'], + garageDetails: ['Wybierz', '1 stanowisko', '2 stanowiska', '3+', 'W bryle budynku', 'Wolnostojący'], + basement: ['Wybierz', 'Tak', 'Częściowe', 'Nie'], + attic: ['Wybierz', 'Użytkowy', 'Nieużytkowy', 'Brak'], + fencing: ['Wybierz', 'Tak', 'Częściowe', 'Nie'], + accessRoad: ['Wybierz', 'Asfalt', 'Kostka', 'Utwardzony', 'Szutrowy', 'Polny'], + sewageType: ['Wybierz', 'Kanalizacja', 'Szambo', 'Przydomowa oczyszczalnia'], +}; + +const listingDetailsSelectOptions = { + transaction: ['Sprzedaż', 'Wynajem'], + propertyType: ['Mieszkanie', 'Dom', 'Działka', 'Lokal użytkowy', 'Pokój'], + market: ['Wtórny', 'Pierwotny'], + houseType: ['Wybierz', 'Wolnostojący', 'Bliźniak', 'Szeregowy', 'Segment skrajny', 'Segment środkowy', 'Rezydencja', 'Dworek'], + houseFloors: ['Wybierz', 'Parterowy', '1', '2', '3+'], + houseGarage: ['Wybierz', 'Brak', 'Jednostanowiskowy', 'Dwustanowiskowy', 'Trzystanowiskowy+', 'W bryle budynku', 'Wolnostojący'], + houseBasement: ['Wybierz', 'Tak', 'Nie', 'Częściowe'], + houseAttic: ['Wybierz', 'Użytkowy', 'Nieużytkowy', 'Brak'], + rooms: ['Wybierz', '1', '2', '3', '4', '5', '6+'], + floor: ['Wybierz', 'Parter', '1', '2', '3', '4', '5', '6', '7', '8+', 'Poddasze'], + buildingFloors: ['Wybierz', '1', '2', '3', '4', '5', '6', '7', '8+'], + condition: ['Wybierz', 'Do wprowadzenia', 'Po remoncie', 'Do odświeżenia', 'Do remontu', 'Deweloperski'], + ownership: ['Wybierz', 'Pełna własność', 'Spółdzielcze własnościowe', 'Użytkowanie wieczyste', 'Udział'], +}; + +const INITIAL_LISTING_DETAILS = { + transaction: 'Sprzedaż', + propertyType: 'Mieszkanie', + market: 'Wtórny', + title: '', + description: '', + price: '', + area: '', + rooms: 'Wybierz', + houseType: 'Wybierz', + houseGarage: 'Wybierz', + houseBasement: 'Wybierz', + houseAttic: 'Wybierz', + floor: 'Wybierz', + buildingFloors: 'Wybierz', + houseFloors: 'Wybierz', + yearBuilt: '', + condition: 'Wybierz', + ownership: 'Wybierz', + contactName: '', + contactPhone: '', + contactEmail: '', +}; + +const INITIAL_EXTRA_INFO = { + rent: '', + deposit: '', + rentalPeriod: 'Wybierz', + mediaBilling: {} as Record, + mediaBillingAmounts: {} as Record, + availableFrom: '', + plotArea: '', + furnishing: 'Wybierz', + buildingType: 'Wybierz', + buildingMaterial: 'Wybierz', + roofType: 'Wybierz', + roofCover: 'Wybierz', + garageDetails: 'Wybierz', + basement: 'Wybierz', + attic: 'Wybierz', + fencing: 'Wybierz', + accessRoad: 'Wybierz', + sewageType: 'Wybierz', + windows: 'Wybierz', + exposure: 'Wybierz', + roomHeight: '', +}; + +const EDIT_LISTING_DRAFT_STORAGE_KEY = 'polskalokalnie-edit-listing-draft'; + +const addFooterBenefits = [ + { + icon: 'lock', + title: 'Szyfrowane dane', + text: 'Zabezpieczamy 100% przesyłanych informacji.', + }, + { + icon: 'group', + title: 'Tysiące kupujących', + text: 'Twoje ogłoszenie zobaczą zainteresowani.', + }, + { + icon: 'clock', + title: 'Prosta obsługa', + text: 'Dodaj ogłoszenie w mniej niż 5 minut.', + }, + { + icon: 'shield', + title: 'Zero kosztów', + text: 'Jesteśmy za darmo. Od początku do końca.', + }, +]; + +const guideTopics = [ + { + icon: 'house', + title: 'Kupno mieszkania', + text: 'Poradniki dla kupujących', + count: '23 artykuły', + }, + { + icon: 'percent', + title: 'Kredyty hipoteczne', + text: 'Finansowanie zakupu', + count: '18 artykułów', + }, + { + icon: 'key', + title: 'Wynajem', + text: 'Porady dla najemców i wynajmujących', + count: '16 artykułów', + }, + { + icon: 'chart', + title: 'Inwestowanie', + text: 'Analizy i strategie inwestycyjne', + count: '15 artykułów', + }, + { + icon: 'paint', + title: 'Remont i wykończenie', + text: 'Praktyczne porady i inspiracje', + count: '21 artykułów', + }, + { + icon: 'scale', + title: 'Prawo i podatki', + text: 'Prawo nieruchomości i podatki', + count: '17 artykułów', + }, +]; + +const guideArticles = [ + { + category: 'KREDYTY HIPOTECZNE', + title: 'Jak obliczyć zdolność kredytową w 2026 roku?', + text: 'Praktyczny przewodnik krok po kroku z aktualnymi przykładami.', + readTime: '8 min czytania', + views: '12 450', + image: cityImage, + position: '24% 58%', + }, + { + category: 'KUPNO MIESZKANIA', + title: 'Na co zwrócić uwagę przy odbiorze mieszkania?', + text: 'Sprawdź listę rzeczy, które warto skontrolować przed podpisaniem protokołu.', + readTime: '10 min czytania', + views: '9 876', + image: heroImage, + position: '47% 58%', + }, + { + category: 'WYNAJEM', + title: 'Umowa najmu okazjonalnego - wzór i omówienie', + text: 'Kompletny wzór umowy z objaśnieniami i praktycznymi wskazówkami.', + readTime: '6 min czytania', + views: '7 532', + image: heroImage, + position: '74% 55%', + }, + { + category: 'INWESTOWANIE', + title: 'Gdzie warto kupić mieszkanie inwestycyjne w 2026?', + text: 'Analiza miast i dzielnic z największym potencjałem wzrostu.', + readTime: '12 min czytania', + views: '11 302', + image: cityImage, + position: '82% 56%', + }, +]; + +const quickGuides = [ + { + icon: 'scale', + title: 'Jak sprawdzić księgę wieczystą?', + text: 'Poradnik krok po kroku', + }, + { + icon: 'percent', + title: 'Podatek od sprzedaży mieszkania', + text: 'Kiedy trzeba zapłacić?', + }, + { + icon: 'paint', + title: 'Ile kosztuje remont mieszkania?', + text: 'Aktualne koszty robocizny i materiałów', + }, + { + icon: 'shield', + title: 'Jak bezpiecznie wynająć mieszkanie?', + text: 'Porady dla wynajmujących', + }, + { + icon: 'chart', + title: 'PCC przy zakupie mieszkania', + text: 'Stawki i zwolnienia w 2026 roku', + }, +]; + +const guideRelatedOffers = [ + { + title: 'Mieszkania w Krakowie', + count: '2 450 ofert', + image: cityImage, + position: '88% 56%', + }, + { + title: 'Mieszkania w Warszawie', + count: '5 120 ofert', + image: cityImage, + position: '18% 48%', + }, + { + title: 'Nowe mieszkania', + count: '3 280 ofert', + image: cityImage, + position: '74% 52%', + }, + { + title: 'Okazje cenowe', + count: '1 230 ofert', + image: heroImage, + position: '47% 58%', + }, +]; + +const mapMarkers = [ + ['649 tys.', '72%', '20%'], + ['590 tys.', '50%', '33%'], + ['669 tys.', '82%', '32%'], + ['799 tys.', '18%', '45%'], + ['699 tys.', '18%', '61%'], + ['699 tys.', '60%', '59%'], + ['790 tys.', '84%', '60%'], + ['390 tys.', '61%', '73%'], + ['760 tys.', '42%', '86%'], + ['900 tys.', '72%', '86%'], + ['360 tys.', '82%', '77%'], +]; + +const rentMapMarkers = [ + ['2 600 zł', '30%', '19%'], + ['3 300 zł', '68%', '19%'], + ['2 800 zł', '88%', '25%'], + ['2 200 zł', '49%', '33%'], + ['3 800 zł', '18%', '43%'], + ['2 400 zł', '15%', '62%'], + ['3 900 zł', '62%', '65%'], + ['2 900 zł', '78%', '68%'], + ['2 900 zł', '28%', '78%'], + ['2 300 zł', '75%', '88%'], +]; + +type LatLng = { lat: number; lng: number }; + +const POLAND_CENTER: LatLng = { lat: 52.1, lng: 19.3 }; + +const CITY_COORDS: Record = { + Warszawa: { lat: 52.2297, lng: 21.0122 }, + Kraków: { lat: 50.0647, lng: 19.945 }, + Łódź: { lat: 51.7592, lng: 19.456 }, + Wrocław: { lat: 51.1079, lng: 17.0385 }, + Poznań: { lat: 52.4064, lng: 16.9252 }, + Gdańsk: { lat: 54.352, lng: 18.6466 }, + Szczecin: { lat: 53.4285, lng: 14.5528 }, + Bydgoszcz: { lat: 53.1235, lng: 18.0084 }, + Lublin: { lat: 51.2465, lng: 22.5684 }, + Białystok: { lat: 53.1325, lng: 23.1688 }, + Katowice: { lat: 50.2649, lng: 19.0238 }, + Gdynia: { lat: 54.5189, lng: 18.5305 }, + Częstochowa: { lat: 50.7971, lng: 19.12 }, + Radom: { lat: 51.4027, lng: 21.1471 }, + Sosnowiec: { lat: 50.2862, lng: 19.104 }, + Toruń: { lat: 53.0138, lng: 18.5984 }, + Kielce: { lat: 50.8661, lng: 20.6286 }, + Rzeszów: { lat: 50.0413, lng: 21.999 }, + Gliwice: { lat: 50.2945, lng: 18.6714 }, + Zabrze: { lat: 50.3249, lng: 18.7857 }, + Olsztyn: { lat: 53.7784, lng: 20.4801 }, + 'Bielsko-Biała': { lat: 49.8224, lng: 19.0444 }, + Bytom: { lat: 50.3483, lng: 18.9157 }, + 'Zielona Góra': { lat: 51.9356, lng: 15.5062 }, + Rybnik: { lat: 50.0971, lng: 18.5416 }, + Opole: { lat: 50.6751, lng: 17.9213 }, + Tychy: { lat: 50.131, lng: 18.9866 }, + 'Gorzów Wielkopolski': { lat: 52.7368, lng: 15.2288 }, + Płock: { lat: 52.5468, lng: 19.7064 }, + Tarnów: { lat: 50.0121, lng: 20.9858 }, + Koszalin: { lat: 54.1943, lng: 16.1714 }, + Kalisz: { lat: 51.7611, lng: 18.0911 }, + Legnica: { lat: 51.207, lng: 16.1619 }, + Słupsk: { lat: 54.4641, lng: 17.0287 }, + Elbląg: { lat: 54.1561, lng: 19.4045 }, + Wałbrzych: { lat: 50.7714, lng: 16.2845 }, + 'Nowy Sącz': { lat: 49.6175, lng: 20.7153 }, + 'Jelenia Góra': { lat: 50.9044, lng: 15.7197 }, + Sopot: { lat: 54.4418, lng: 18.5601 }, + Zakopane: { lat: 49.2992, lng: 19.9496 }, + Przemyśl: { lat: 49.7838, lng: 22.7677 }, +}; + +const POLAND_BOUNDS = { minLat: 48.8, maxLat: 54.9, minLng: 14.0, maxLng: 24.2 }; + +function projectToWorld(lat: number, lng: number, zoom: number) { + const scale = 256 * 2 ** zoom; + const x = ((lng + 180) / 360) * scale; + const sinLat = Math.sin((lat * Math.PI) / 180); + const y = (0.5 - Math.log((1 + sinLat) / (1 - sinLat)) / (4 * Math.PI)) * scale; + return { x, y }; +} + +function unprojectFromWorld(x: number, y: number, zoom: number): LatLng { + const scale = 256 * 2 ** zoom; + const lng = (x / scale) * 360 - 180; + const n = Math.PI - (2 * Math.PI * y) / scale; + const lat = (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))); + return { lat, lng }; +} + +function clampCenter(center: LatLng): LatLng { + return { + lat: Math.min(POLAND_BOUNDS.maxLat, Math.max(POLAND_BOUNDS.minLat, center.lat)), + lng: Math.min(POLAND_BOUNDS.maxLng, Math.max(POLAND_BOUNDS.minLng, center.lng)), + }; +} + +function normalizeSearchText(value: string): string { + return value.trim().toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, ''); +} + +function cityStartsWith(cityName: string, query: string): boolean { + const normalizedQuery = normalizeSearchText(query); + if (!normalizedQuery) { + return true; + } + return normalizeSearchText(cityName).startsWith(normalizedQuery); +} + +function findKnownCityKey(name: string): string | null { + const normalized = normalizeSearchText(name); + if (!normalized) { + return null; + } + return Object.keys(CITY_COORDS).find((cityName) => normalizeSearchText(cityName) === normalized) ?? null; +} + +function useResolvedCityCenter(cityName: string, fallbackCenter: LatLng): LatLng { + const [center, setCenter] = useState(fallbackCenter); + + useEffect(() => { + const trimmed = cityName.trim(); + if (!trimmed) { + setCenter(fallbackCenter); + return; + } + + const knownCity = findKnownCityKey(trimmed); + if (knownCity) { + setCenter(CITY_COORDS[knownCity]); + return; + } + + let cancelled = false; + const controller = new AbortController(); + fetchPlaceSuggestions(trimmed, controller.signal) + .then((places) => { + if (cancelled || places.length === 0) { + return; + } + setCenter(clampCenter({ lat: places[0].lat, lng: places[0].lng })); + }) + .catch(() => undefined); + + return () => { + cancelled = true; + controller.abort(); + }; + }, [cityName, fallbackCenter.lat, fallbackCenter.lng]); + + return center; +} + +function listingLatLng(offer: { city: string }, index: number): LatLng { + const base = CITY_COORDS[offer.city] ?? POLAND_CENTER; + const radius = 0.035; + return { + lat: base.lat + Math.sin(index * 1.7) * radius, + lng: base.lng + Math.cos(index * 2.3) * radius * 1.4, + }; +} + +type PlaceSuggestion = { + id: number; + label: string; + sublabel: string; + lat: number; + lng: number; +}; + +const PLACE_TYPE_LABELS: Record = { + city: 'miasto', + town: 'miasto', + village: 'wieś', + hamlet: 'przysiółek', + administrative: 'gmina', + municipality: 'gmina', + suburb: 'dzielnica', + neighbourhood: 'osiedle', + quarter: 'dzielnica', + county: 'powiat', + state: 'województwo', + locality: 'miejscowość', +}; + +type NominatimResult = { + place_id: number; + lat: string; + lon: string; + display_name: string; + type: string; + class: string; + address?: Record; +}; + +function describePlace(item: NominatimResult): PlaceSuggestion { + const address = item.address ?? {}; + const label = + address.city ?? address.town ?? address.village ?? address.hamlet + ?? address.municipality ?? item.display_name.split(',')[0]; + const typeLabel = PLACE_TYPE_LABELS[item.type] ?? PLACE_TYPE_LABELS[item.class] ?? 'miejscowość'; + const county = address.county ? address.county.replace(/^powiat\s+/i, '') : ''; + const state = address.state ? address.state.replace(/^województwo\s+/i, '') : ''; + const sublabel = [typeLabel, county, state].filter(Boolean).join(', '); + return { + id: item.place_id, + label, + sublabel, + lat: Number(item.lat), + lng: Number(item.lon), + }; +} + +async function fetchPlaceSuggestions(query: string, signal: AbortSignal): Promise { + const url = new URL('https://nominatim.openstreetmap.org/search'); + url.searchParams.set('q', query); + url.searchParams.set('format', 'jsonv2'); + url.searchParams.set('countrycodes', 'pl'); + url.searchParams.set('addressdetails', '1'); + url.searchParams.set('accept-language', 'pl'); + url.searchParams.set('limit', '8'); + const response = await fetch(url.toString(), { + signal, + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error('Nie udało się pobrać podpowiedzi'); + } + const data = (await response.json()) as NominatimResult[]; + const seen = new Set(); + const places: PlaceSuggestion[] = []; + for (const item of data) { + const place = describePlace(item); + const key = `${place.label}|${place.sublabel}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + places.push(place); + } + return places; +} + +type ListingLocation = { + city: string; + district: string; + estate: string; + street: string; + building: string; + unit: string; +}; + +type ListingPhoto = { + id: string; + name: string; + url: string; +}; + +const INITIAL_LISTING_LOCATION: ListingLocation = { + city: '', + district: '', + estate: '', + street: '', + building: '', + unit: '', +}; + +const INITIAL_LISTING_COORDS: LatLng = { lat: 50.0491, lng: 19.9617 }; + +function locationSearchQuery(location: ListingLocation) { + return [location.street, location.building, location.estate, location.district, location.city, 'Polska'] + .filter((part) => part.trim()) + .join(', '); +} + +function locationZoom(location: ListingLocation) { + if (location.street && location.building) { + return 17; + } + if (location.street || location.district || location.estate) { + return 15; + } + return 12; +} + +async function geocodeListingLocation(location: ListingLocation, signal: AbortSignal): Promise { + const query = locationSearchQuery(location); + if (!location.city.trim() || query.length < 4) { + return null; + } + const url = new URL('https://nominatim.openstreetmap.org/search'); + url.searchParams.set('q', query); + url.searchParams.set('format', 'jsonv2'); + url.searchParams.set('countrycodes', 'pl'); + url.searchParams.set('addressdetails', '1'); + url.searchParams.set('accept-language', 'pl'); + url.searchParams.set('limit', '1'); + const response = await fetch(url.toString(), { signal, headers: { Accept: 'application/json' } }); + if (!response.ok) { + throw new Error('Nie udało się odszukać adresu'); + } + const [result] = (await response.json()) as NominatimResult[]; + if (!result) { + return null; + } + return { lat: Number(result.lat), lng: Number(result.lon) }; +} + +async function reverseGeocodeListingLocation(point: LatLng, signal: AbortSignal): Promise> { + const url = new URL('https://nominatim.openstreetmap.org/reverse'); + url.searchParams.set('lat', String(point.lat)); + url.searchParams.set('lon', String(point.lng)); + url.searchParams.set('format', 'jsonv2'); + url.searchParams.set('addressdetails', '1'); + url.searchParams.set('accept-language', 'pl'); + url.searchParams.set('zoom', '18'); + const response = await fetch(url.toString(), { signal, headers: { Accept: 'application/json' } }); + if (!response.ok) { + throw new Error('Nie udało się pobrać lokalizacji z mapy'); + } + const data = (await response.json()) as NominatimResult; + const address = data.address ?? {}; + const road = address.road ?? address.pedestrian ?? address.footway ?? address.path ?? address.cycleway ?? address.street ?? ''; + const locality = address.village ?? address.hamlet ?? address.locality ?? ''; + const street = road && !/^\d+[a-z]?$/i.test(road) ? road : locality; + return { + city: address.city ?? address.town ?? address.municipality ?? address.county?.replace(/^powiat\s+/i, '') ?? address.village ?? '', + district: address.city_district ?? address.suburb ?? address.quarter ?? address.borough ?? address.district ?? '', + estate: address.neighbourhood ?? address.residential ?? address.hamlet ?? '', + street, + building: address.house_number ?? '', + }; +} + +const avatars = ['A', 'M', 'K', 'P', 'J']; + +function getDisplayName(user: AuthUser | null): string { + return user?.fullName?.trim() || user?.email || 'Użytkownik'; +} + +function getFirstName(fullName: string): string { + const trimmed = fullName.trim(); + return trimmed.split(/\s+/)[0] || trimmed; +} + +function getLastName(fullName: string): string { + const parts = fullName.trim().split(/\s+/).filter(Boolean); + return parts.slice(1).join(' '); +} + +function getInitials(fullName: string): string { + const parts = fullName.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) { + return '??'; + } + if (parts.length === 1) { + return parts[0].slice(0, 2).toUpperCase(); + } + return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); +} + +function contactPreferenceLabel(preference: ContactPreference | null | undefined): string { + if (preference === 'EMAIL') { + return 'E-mail'; + } + if (preference === 'PHONE') { + return 'Telefon'; + } + return 'E-mail, Telefon'; +} + +function preferredLanguageLabel(language: PreferredLanguage | null | undefined): string { + if (language === 'EN') { + return 'Angielski'; + } + if (language === 'UK') { + return 'Ukraiński'; + } + if (language === 'DE') { + return 'Niemiecki'; + } + return 'Polski'; +} + +function App() { + const { user, logout } = useAuth(); + const [view, setView] = useState(() => (window.location.hash === '#login' ? 'login' : 'home')); + const [mapCity, setMapCity] = useState(''); + const [mapOfferType, setMapOfferType] = useState('SALE'); + const [selectedNegotiationOffer, setSelectedNegotiationOffer] = useState(buyListings[0] ?? null); + const [selectedListingId, setSelectedListingId] = useState(null); + const [messageThreadTarget, setMessageThreadTarget] = useState(null); + const [favoriteListingsState, setFavoriteListingsState] = useState(favoriteListings); + const [favoritesPreferredTab, setFavoritesPreferredTab] = useState('buy'); + const [unreadFavoriteIds, setUnreadFavoriteIds] = useState>(new Set()); + const hydratedFavoriteIdsRef = useRef>(new Set()); + const translatedOriginalTextRef = useRef>(new Map()); + const translatedOriginalAttrRef = useRef>>(new Map()); + const translationObserverRef = useRef(null); + const translationTimerRef = useRef(null); + const translationInFlightRef = useRef(false); + const filters = useFilterState(); + + const clearLoginHash = () => { + if (window.location.hash === '#login') { + window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}`); + } + }; + + // Nawigacja z bramka autoryzacji: widoki chronione wymagaja zalogowania, + // panel admina wymaga roli ADMIN. + const navigate = (nextView: View) => { + let target = nextView; + if (target === 'admin' && user?.role !== 'ADMIN') { + target = user ? 'account' : 'login'; + } else if (PROTECTED_VIEWS.has(target) && !user) { + target = 'login'; + } + + setView(target); + if (target === 'login') { + window.location.hash = 'login'; + return; + } + clearLoginHash(); + }; + + // Po udanym logowaniu kierujemy admina do panelu, uzytkownika do konta + // (pomijamy bramke navigate, bo stan user moze byc jeszcze swiezo ustawiany). + const handleAuthenticated = (loggedUser: AuthUser) => { + setView(loggedUser.role === 'ADMIN' ? 'admin' : 'account'); + clearLoginHash(); + }; + + const handleLogout = () => { + logout(); + setView('home'); + clearLoginHash(); + }; + + useEffect(() => { + const onHashChange = () => { + if (window.location.hash === '#login') { + setView('login'); + } + }; + + window.addEventListener('hashchange', onHashChange); + return () => window.removeEventListener('hashchange', onHashChange); + }, []); + + useEffect(() => { + if (view === 'favorites') { + setUnreadFavoriteIds(new Set()); + } + }, [view]); + + useEffect(() => { + const preferred = user?.preferredLanguage ?? 'PL'; + const htmlLang = preferred === 'EN' ? 'en' : preferred === 'UK' ? 'uk' : preferred === 'DE' ? 'de' : 'pl'; + document.documentElement.lang = htmlLang; + }, [user?.preferredLanguage]); + + useEffect(() => { + const targetLanguage = user?.preferredLanguage ?? 'PL'; + const textNodeSources = translatedOriginalTextRef.current; + const attrSources = translatedOriginalAttrRef.current; + + const restorePolish = () => { + textNodeSources.forEach((source, node) => { + if (node.isConnected) { + node.textContent = source; + } + }); + attrSources.forEach((sourceAttrs, element) => { + if (!element.isConnected) { + return; + } + Object.entries(sourceAttrs).forEach(([attr, value]) => { + element.setAttribute(attr, value); + }); + }); + }; + + const shouldTranslateSource = (value: string) => { + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + // Skip pure numbers and separators. + if (!/[a-zA-Z\u00C0-\u024F\u0400-\u04FF\u0104\u0105\u0106\u0107\u0118\u0119\u0141\u0142\u0143\u0144\u00D3\u00F3\u015A\u015B\u0179\u017A\u017B\u017C]/.test(trimmed)) { + return false; + } + return true; + }; + + const collectTargets = () => { + const textTargets: Array<{ node: Text; source: string }> = []; + const attrTargets: Array<{ element: Element; attr: string; source: string }> = []; + + const root = document.querySelector('.site-shell') ?? document.body; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let current = walker.nextNode(); + while (current) { + const textNode = current as Text; + const parent = textNode.parentElement; + if (parent && !['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA'].includes(parent.tagName)) { + const source = textNodeSources.get(textNode) ?? textNode.textContent ?? ''; + if (!textNodeSources.has(textNode)) { + textNodeSources.set(textNode, source); + } + if (shouldTranslateSource(source)) { + textTargets.push({ node: textNode, source }); + } + } + current = walker.nextNode(); + } + + root.querySelectorAll('[placeholder], [title], [aria-label]').forEach((element) => { + const tracked = attrSources.get(element) ?? {}; + ['placeholder', 'title', 'aria-label'].forEach((attr) => { + const raw = element.getAttribute(attr); + if (!raw) { + return; + } + const source = tracked[attr] ?? raw; + if (!tracked[attr]) { + tracked[attr] = source; + } + if (shouldTranslateSource(source)) { + attrTargets.push({ element, attr, source }); + } + }); + if (Object.keys(tracked).length > 0) { + attrSources.set(element, tracked); + } + }); + + return { textTargets, attrTargets }; + }; + + const translateVisibleContent = async () => { + if (targetLanguage === 'PL') { + restorePolish(); + return; + } + if (translationInFlightRef.current) { + return; + } + + const { textTargets, attrTargets } = collectTargets(); + const sources = Array.from(new Set([ + ...textTargets.map((item) => item.source), + ...attrTargets.map((item) => item.source), + ])); + + if (sources.length === 0) { + return; + } + + translationInFlightRef.current = true; + try { + const mergedTranslations: Record = {}; + const missingForApi: string[] = []; + + sources.forEach((source) => { + const manual = getManualTranslation(targetLanguage, source); + if (manual != null) { + mergedTranslations[source] = manual; + } else if (source.length <= 380) { + missingForApi.push(source); + } else { + mergedTranslations[source] = source; + } + }); + + if (missingForApi.length > 0) { + const fallbackChunks: string[][] = []; + for (let index = 0; index < missingForApi.length; index += 150) { + fallbackChunks.push(missingForApi.slice(index, index + 150)); + } + + for (const chunk of fallbackChunks) { + const response = await apiFetch<{ language: PreferredLanguage; translations: Record }>('/i18n/translate', { + method: 'POST', + body: JSON.stringify({ language: targetLanguage, texts: chunk }), + }); + Object.assign(mergedTranslations, response.translations); + } + } + + textTargets.forEach(({ node, source }) => { + if (!node.isConnected) { + return; + } + const translated = mergedTranslations[source]; + if (translated && node.textContent !== translated) { + node.textContent = translated; + } + }); + + attrTargets.forEach(({ element, attr, source }) => { + if (!element.isConnected) { + return; + } + const translated = mergedTranslations[source]; + if (translated) { + element.setAttribute(attr, translated); + } + }); + } catch { + // If translation fails, leave original text untouched. + } finally { + translationInFlightRef.current = false; + } + }; + + const queueTranslate = () => { + if (translationTimerRef.current != null) { + window.clearTimeout(translationTimerRef.current); + } + translationTimerRef.current = window.setTimeout(() => { + void translateVisibleContent(); + }, 80); + }; + + translationObserverRef.current?.disconnect(); + if (targetLanguage === 'PL') { + restorePolish(); + return; + } + + queueTranslate(); + const observer = new MutationObserver(() => { + queueTranslate(); + }); + observer.observe(document.body, { + childList: true, + subtree: true, + characterData: true, + attributes: true, + attributeFilter: ['placeholder', 'title', 'aria-label'], + }); + translationObserverRef.current = observer; + + return () => { + observer.disconnect(); + translationObserverRef.current = null; + if (translationTimerRef.current != null) { + window.clearTimeout(translationTimerRef.current); + translationTimerRef.current = null; + } + }; + }, [user?.preferredLanguage, view]); + + const openMap = (name: string, source: 'buy' | 'rent' = 'buy') => { + setMapCity(name); + setMapOfferType(source === 'rent' ? 'RENT' : 'SALE'); + navigate('map'); + }; + const openNegotiation = (offer: BuyOffer) => { + setSelectedNegotiationOffer(offer); + navigate('negotiation'); + }; + const openListing = (id: number) => { + setSelectedListingId(id); + navigate('listingDetail'); + }; + + const openMessagesWithTarget = (target: MessageThreadTarget) => { + setMessageThreadTarget(target); + navigate('messages'); + }; + + const isListingFavorite = (listingId: number) => ( + favoriteListingsState.some((item) => item.id === favoriteListingKey(listingId)) + ); + + const toggleListingFavorite = (listing: ApiListingDetail) => { + const listingKey = favoriteListingKey(listing.id); + setFavoriteListingsState((current) => { + const exists = current.some((item) => item.id === listingKey); + if (exists) { + setUnreadFavoriteIds((prev) => { + if (!prev.has(listingKey)) { + return prev; + } + const next = new Set(prev); + next.delete(listingKey); + return next; + }); + return current.filter((item) => item.id !== listingKey); + } + setFavoritesPreferredTab(favoriteTabFromOfferType(listing.offerType)); + setUnreadFavoriteIds((prev) => { + const next = new Set(prev); + next.add(listingKey); + return next; + }); + return [toFavoriteListing(listing), ...current]; + }); + }; + + const toggleSummaryFavorite = async (listing: ApiListingSummary) => { + const listingKey = favoriteListingKey(listing.id); + const exists = favoriteListingsState.some((item) => item.id === listingKey); + if (exists) { + setUnreadFavoriteIds((prev) => { + if (!prev.has(listingKey)) { + return prev; + } + const next = new Set(prev); + next.delete(listingKey); + return next; + }); + setFavoriteListingsState((current) => current.filter((item) => item.id !== listingKey)); + return; + } + + setFavoritesPreferredTab(favoriteTabFromOfferType(listing.offerType)); + + try { + const fullListing = await apiFetch(`/listings/${listing.id}`); + const nextFavorite = toFavoriteListing(fullListing); + setFavoriteListingsState((current) => { + if (current.some((item) => item.id === listingKey)) { + return current; + } + setUnreadFavoriteIds((prev) => { + const next = new Set(prev); + next.add(listingKey); + return next; + }); + return [nextFavorite, ...current]; + }); + } catch { + const fallbackFavorite = toFavoriteListingFromSummary(listing); + setFavoriteListingsState((current) => { + if (current.some((item) => item.id === listingKey)) { + return current; + } + setUnreadFavoriteIds((prev) => { + const next = new Set(prev); + next.add(listingKey); + return next; + }); + return [fallbackFavorite, ...current]; + }); + } + }; + + useEffect(() => { + const toHydrate = favoriteListingsState + .filter((item) => item.photos <= 1 && !hydratedFavoriteIdsRef.current.has(item.listingId)) + .map((item) => item.listingId); + + if (toHydrate.length === 0) { + return; + } + + toHydrate.forEach((listingId) => hydratedFavoriteIdsRef.current.add(listingId)); + let cancelled = false; + + Promise.all( + toHydrate.map(async (listingId) => { + try { + return await apiFetch(`/listings/${listingId}`); + } catch { + return null; + } + }), + ).then((resolved) => { + if (cancelled) { + return; + } + + const hydratedById = new Map(); + resolved.forEach((listing) => { + if (listing) { + hydratedById.set(listing.id, toFavoriteListing(listing)); + } + }); + + if (hydratedById.size === 0) { + return; + } + + setFavoriteListingsState((current) => current.map((item) => { + const hydrated = hydratedById.get(item.listingId); + if (!hydrated) { + return item; + } + return { + ...item, + ...hydrated, + id: item.id, + listingId: item.listingId, + }; + })); + }); + + return () => { + cancelled = true; + }; + }, [favoriteListingsState]); + + return ( +
+
0} /> +
+ {view === 'login' && } + {view === 'admin' && } + {view === 'account' && } + {view === 'comparison' && } + {view === 'accountMeetings' && } + {view === 'accountListings' && } + {view === 'accountSettings' && } + {view === 'accountSecurity' && } + {view === 'accountProfileEdit' && } + {view === 'accountPublicProfile' && } + {view === 'notifications' && } + {view === 'messages' && ( + setMessageThreadTarget(null)} + /> + )} + {view === 'favorites' && ( + + )} + {view === 'buy' && ( + + )} + {view === 'rent' && ( + + )} + {view === 'sell' && } + {view === 'valuation' && } + {view === 'priceHistory' && } + {view === 'districtRanking' && } + {view === 'negotiation' && ( + + )} + {view === 'add' && } + {view === 'listingDetail' && ( + + )} + {view === 'services' && } + {view === 'guides' && } + {view === 'map' && } + {view === 'home' && } +
+
+ ); +} + +type AdminListing = { + id: number; + title: string; + city: string; + offerType: 'SALE' | 'RENT'; + propertyType: 'APARTMENT' | 'HOUSE'; + price: number; + area: number; + rooms: number; + ownerEmail: string | null; + status: 'PENDING' | 'APPROVED' | 'REJECTED'; + createdAt: string; +}; + +type AdminListingReportAttachment = { + fileName: string; + fileType: string | null; + dataUrl: string | null; +}; + +type AdminListingReport = { + id: number; + listingId: number; + listingTitle: string; + listingCity: string; + listingOwnerEmail: string | null; + reasonId: string; + reasonTitle: string; + details: string | null; + attachmentNames: string[]; + attachments?: AdminListingReportAttachment[]; + reporterEmail: string; + reporterName: string | null; + status: 'OPEN' | 'RESOLVED'; + listingDeleted: boolean; + createdAt: string; + resolvedAt: string | null; + resolvedByEmail: string | null; + resolutionNote: string | null; +}; + +type ForbiddenWord = { + id: number; + word: string; +}; + +const ADMIN_STATUS_LABELS: Record = { + PENDING: 'Oczekuje', + APPROVED: 'Zatwierdzone', + REJECTED: 'Odrzucone', +}; + +const ADMIN_REPORT_STATUS_LABELS: Record = { + OPEN: 'Otwarte', + RESOLVED: 'Rozpatrzone', +}; + +const ADMIN_OFFER_LABELS: Record = { + SALE: 'Sprzedaż', + RENT: 'Wynajem', +}; + +const ADMIN_PROPERTY_LABELS: Record = { + APARTMENT: 'Mieszkanie', + HOUSE: 'Dom', +}; + +function formatAdminDate(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + return '—'; + } + return date.toLocaleDateString('pl-PL', { day: '2-digit', month: '2-digit', year: 'numeric' }); +} + +function accountStorageKey(suffix: string, user: AuthUser | null): string { + return `accountProfile:${user?.id ?? 'guest'}:${suffix}`; +} + +type AdminTab = + | 'dashboard' | 'listings' | 'users' | 'messages' | 'reports' | 'payments' + | 'stats' | 'settings' | 'moderation' | 'categories' | 'locations' | 'promotions' | 'forbiddenWords'; + +const ADMIN_TAB_META: Record = { + dashboard: { title: 'Panel administratora', subtitle: 'Zarządzaj ogłoszeniami i użytkownikami serwisu.' }, + listings: { title: 'Ogłoszenia', subtitle: 'Przeglądaj, zatwierdzaj i usuwaj ogłoszenia użytkowników.' }, + users: { title: 'Użytkownicy', subtitle: 'Zarządzaj kontami zarejestrowanych użytkowników.' }, + messages: { title: 'Wiadomości', subtitle: 'Podgląd wiadomości wymienianych między użytkownikami.' }, + reports: { title: 'Zgłoszenia', subtitle: 'Zgłoszenia dotyczące ogłoszeń i użytkowników wymagające uwagi.' }, + payments: { title: 'Płatności', subtitle: 'Historia płatności i promocji ogłoszeń.' }, + stats: { title: 'Statystyki', subtitle: 'Statystyki aktywności i ruchu w serwisie.' }, + settings: { title: 'Ustawienia serwisu', subtitle: 'Globalna konfiguracja serwisu Mieszko.' }, + moderation: { title: 'Weryfikacja ogłoszeń', subtitle: 'Kolejka ogłoszeń oczekujących na moderację.' }, + categories: { title: 'Kategorie', subtitle: 'Zarządzaj kategoriami i typami nieruchomości.' }, + locations: { title: 'Lokalizacje', subtitle: 'Zarządzaj obsługiwanymi miastami i dzielnicami.' }, + promotions: { title: 'Banery i promocje', subtitle: 'Zarządzaj banerami reklamowymi i promocjami.' }, + forbiddenWords: { title: 'Zakazane słowa', subtitle: 'Lista wszystkich niedozwolonych słów używanych przez globalny filtr treści.' }, +}; + +const ADMIN_NAV_ITEMS: { id: AdminTab; icon: string; label: string }[] = [ + { id: 'dashboard', icon: 'house', label: 'Panel główny' }, + { id: 'listings', icon: 'document', label: 'Ogłoszenia' }, + { id: 'users', icon: 'user', label: 'Użytkownicy' }, + { id: 'messages', icon: 'message', label: 'Wiadomości' }, + { id: 'reports', icon: 'warning', label: 'Zgłoszenia' }, + { id: 'payments', icon: 'credit-card', label: 'Płatności' }, + { id: 'stats', icon: 'chart', label: 'Statystyki' }, + { id: 'settings', icon: 'gear', label: 'Ustawienia serwisu' }, +]; + +const ADMIN_TOOL_ITEMS: { id: AdminTab; icon: string; label: string }[] = [ + { id: 'moderation', icon: 'check', label: 'Weryfikacja ogłoszeń' }, + { id: 'forbiddenWords', icon: 'shield', label: 'Zakazane słowa' }, + { id: 'categories', icon: 'list', label: 'Kategorie' }, + { id: 'locations', icon: 'pin', label: 'Lokalizacje' }, + { id: 'promotions', icon: 'spark', label: 'Banery i promocje' }, +]; + +function AdminPlaceholderCard({ icon, label }: { icon: string; label: string }) { + return ( +
+ + {label} +

Ta sekcja jest w przygotowaniu i pojawi się w kolejnej aktualizacji panelu.

+
+ ); +} + +type AdminMessage = { + id: number; + senderId: number; + recipientId: number; + content: string; + createdAt: string; + mine: boolean; +}; + +function AdminMessageModal({ user, onClose }: { user: AuthUser; onClose: () => void }) { + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [draft, setDraft] = useState(''); + const [sending, setSending] = useState(false); + const listRef = useRef(null); + + const loadMessages = useCallback(async (silent = false) => { + if (!silent) { + setLoading(true); + } + setError(null); + try { + const data = await apiFetch(`/admin/users/${user.id}/messages`); + setMessages(data); + } catch (err) { + if (!silent) { + setError(err instanceof Error ? err.message : 'Nie udało się pobrać wiadomości.'); + } + } finally { + if (!silent) { + setLoading(false); + } + } + }, [user.id]); + + useEffect(() => { + loadMessages(); + }, [loadMessages]); + + useEffect(() => { + const intervalId = window.setInterval(() => { + loadMessages(true); + }, 4000); + return () => window.clearInterval(intervalId); + }, [loadMessages]); + + useEffect(() => { + if (listRef.current) { + listRef.current.scrollTop = listRef.current.scrollHeight; + } + }, [messages]); + + const handleSend = async () => { + const content = draft.trim(); + if (!content || sending) { + return; + } + setSending(true); + setError(null); + try { + const sent = await apiFetch(`/admin/users/${user.id}/messages`, { + method: 'POST', + body: JSON.stringify({ content }), + }); + setMessages((current) => [...current, sent]); + setDraft(''); + } catch (err) { + setError(err instanceof Error ? err.message : 'Nie udało się wysłać wiadomości.'); + } finally { + setSending(false); + } + }; + + return ( +
+
+ +

Wiadomość do: {user.fullName || user.email}

+

{user.email}

+ + + {error &&

{error}

} + +
+ {loading ? ( +

Wczytywanie wiadomości...

+ ) : messages.length === 0 ? ( +

Brak wiadomości. Napisz pierwszą wiadomość do tego użytkownika.

+ ) : ( + messages.map((message) => ( +
+

{message.content}

+ {new Date(message.createdAt).toLocaleString('pl-PL', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })} +
+ )) + )} +
+ +
+