poprawki graficzne, zdjęcia, panel administarcyjny oraz układ stron

This commit is contained in:
2026-07-24 14:52:19 +02:00
parent 93e67bd27e
commit 966dd3f209
19 changed files with 1997 additions and 584 deletions
+7 -7
View File
@@ -53,21 +53,21 @@ Zasady:
- **Nie buduj nawigacji na stanie.** Zakazane jako mechanizm przełączania stron: `useState('view')`, `setView`, `activeView`, `selectedPage`, `selectedTab` i pochodne.
- **Nie przełączaj całych stron warunkowym renderowaniem** bez zmiany adresu (`{view === 'x' && <Page />}`).
- **Do nawigacji używaj** `Link`, `NavLink`, `useNavigate` oraz tras zadeklarowanych w `Routes`.
- **Identyfikatory obiektów trzymaj w parametrach URL**: `/oferta/:id`, `/profil/:id`, `/wiadomosci/:conversationId` nie w stanie Reacta.
- **Identyfikatory obiektów trzymaj w parametrach URL**: `/oferta/:id`, `/profil/:id`, `/wiadomosci/:conversationId` - nie w stanie Reacta.
- **Filtry, sortowanie, wyszukiwanie i paginację zapisuj w query string** wszędzie tam, gdzie użytkownik powinien móc skopiować lub odświeżyć widok.
- **Zakładki paneli twórz jako trasy zagnieżdżone**: `/konto/ustawienia`, `/admin/uzytkownicy`.
- **Trasy wymagające logowania zabezpieczaj `ProtectedRoute`** (`frontend/src/ProtectedRoute.tsx`). Pamiętaj, że to zabezpieczenie interfejsu autoryzację egzekwuje backend.
- **Trasy wymagające logowania zabezpieczaj `ProtectedRoute`** (`frontend/src/ProtectedRoute.tsx`). Pamiętaj, że to zabezpieczenie interfejsu - autoryzację egzekwuje backend.
- **Nie używaj `href="#"`** ani przycisków imitujących linki.
- **Każdy link musi działać** po odświeżeniu (F5), przy bezpośrednim wejściu z adresu, po użyciu Wstecz/Dalej i po otwarciu w nowej karcie.
Gdzie co leży:
- `frontend/src/routes.ts` centralna mapa adresów (`ROUTES`) i helpery (`listingPath`, `mapPath`, `adminTabPath`). Nowy widok zaczynaj od wpisu tutaj, nie od literału ścieżki w JSX.
- `frontend/src/ProtectedRoute.tsx` bramka autoryzacji, obsługuje stan `loading` z `AuthProvider`.
- `frontend/src/App.tsx` deklaracja `<Routes>`.
- `nginx/default.conf` `try_files $uri $uri/ /index.html` (SPA fallback, już skonfigurowany).
- `frontend/src/routes.ts` - centralna mapa adresów (`ROUTES`) i helpery (`listingPath`, `mapPath`, `adminTabPath`). Nowy widok zaczynaj od wpisu tutaj, nie od literału ścieżki w JSX.
- `frontend/src/ProtectedRoute.tsx` - bramka autoryzacji, obsługuje stan `loading` z `AuthProvider`.
- `frontend/src/App.tsx` - deklaracja `<Routes>`.
- `nginx/default.conf` - `try_files $uri $uri/ /index.html` (SPA fallback, już skonfigurowany).
Przed zakończeniem każdego zadania sprawdź, czy dodane widoki mają trasy i czy adres zmienia się podczas nawigacji. Jeżeli istniejący kod łamie ten standard, nie powielaj starego rozwiązania dostosuj go do React Routera.
Przed zakończeniem każdego zadania sprawdź, czy dodane widoki mają trasy i czy adres zmienia się podczas nawigacji. Jeżeli istniejący kod łamie ten standard, nie powielaj starego rozwiązania - dostosuj go do React Routera.
## Behavioral Guidelines To Reduce Common LLM Coding Mistakes
@@ -74,7 +74,7 @@ public class DataSeeder implements CommandLineRunner {
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.",
"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));
}
@@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
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.RequestParam;
@@ -58,6 +59,16 @@ public class ListingController {
.body(response);
}
// Edycja wlasnego ogloszenia (pelna aktualizacja pol; status bez zmian).
@PutMapping("/{id}")
public ListingDetailResponse update(
@PathVariable Long id,
@Valid @RequestBody ListingCreateRequest request,
Authentication authentication
) {
return listingService.updateOwn(id, authentication.getName(), request);
}
// Wstrzymanie/wznowienie wlasnego ogloszenia (status=PAUSED lub APPROVED).
@PatchMapping("/{id}/status")
public ListingResponse updateStatus(
@@ -100,6 +100,33 @@ public class ListingService {
request.district(), request.street(), request.contactName());
PropertyListing listing = new PropertyListing();
applyRequest(listing, request);
listing.setOwnerEmail(ownerEmail);
listing.setStatus(ListingStatus.PENDING);
return ListingDetailResponse.from(listingRepository.save(listing));
}
// Edycja wlasnego ogloszenia. Waliduje tresc filtrem, ale NIE zmienia statusu -
// zatwierdzone ogloszenie pozostaje widoczne od razu po zapisie zmian.
@Transactional
public ListingDetailResponse updateOwn(Long id, String ownerEmail, ListingCreateRequest request) {
PropertyListing listing = listingRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found"));
requireOwner(listing, ownerEmail);
textModerationService.validateOrThrow(
request.title(), request.description(), request.city(), request.address(),
request.district(), request.street(), request.contactName());
applyRequest(listing, request);
// ownerEmail i status pozostaja bez zmian.
return ListingDetailResponse.from(listingRepository.save(listing));
}
// Wspolne mapowanie pol requestu na encje (uzywane przez create i updateOwn).
private void applyRequest(PropertyListing listing, ListingCreateRequest request) {
listing.setTitle(request.title().trim());
listing.setDescription(request.description().trim());
listing.setOfferType(request.offerType());
@@ -140,11 +167,6 @@ public class ListingService {
listing.setPhotos(photos);
listing.setCoverPhoto(photos.isEmpty() ? null : photos.get(0));
listing.setVirtualTourUrl(normalizeVirtualTourUrl(request.virtualTourUrl()));
listing.setOwnerEmail(ownerEmail);
listing.setStatus(ListingStatus.PENDING);
return ListingDetailResponse.from(listingRepository.save(listing));
}
// --- Moderacja (admin) ---
@@ -29,7 +29,7 @@ public class PropertyListing {
@Column(nullable = false, length = 160)
private String title;
@Column(nullable = false, length = 3000)
@Column(nullable = false, columnDefinition = "text")
private String description;
@Enumerated(EnumType.STRING)
@@ -55,7 +55,7 @@ public class MailConfigController {
SendResult result = emailSender.send(
mailConfigService.getSmtp(),
request.recipient().trim(),
"Test konfiguracji SMTP Polska Lokalnie",
"Test konfiguracji SMTP - Polska Lokalnie",
"<p>To jest wiadomość testowa potwierdzająca poprawną konfigurację poczty SMTP.</p>");
return TestResultResponse.from(result);
}
@@ -65,7 +65,7 @@ public class MailConfigController {
SendResult result = smsSender.send(
mailConfigService.getSms(),
request.recipient().trim(),
"Test konfiguracji bramki SMS Polska Lokalnie");
"Test konfiguracji bramki SMS - Polska Lokalnie");
return TestResultResponse.from(result);
}
+2
View File
@@ -3,6 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/favicon.png" />
<title>Polska Lokalnie</title>
</head>
<body>
+8
View File
@@ -10,7 +10,15 @@
"test:e2e": "playwright test"
},
"dependencies": {
"@tiptap/extension-character-count": "^2.11.5",
"@tiptap/extension-link": "^2.11.5",
"@tiptap/extension-placeholder": "^2.11.5",
"@tiptap/extension-underline": "^2.11.5",
"@tiptap/pm": "^2.11.5",
"@tiptap/react": "^2.11.5",
"@tiptap/starter-kit": "^2.11.5",
"@types/pdfmake": "^0.3.3",
"dompurify": "^3.2.4",
"pdfmake": "^0.3.11",
"react": "18.3.1",
"react-dom": "18.3.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

+896 -477
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -9,7 +9,7 @@ import { ROUTES } from './routes';
* dociąga się asynchronicznie z /auth/me. Bez tego warunku bezpośrednie wejście na
* /konto wyrzucałoby zalogowanego użytkownika na logowanie, zanim profil dotrze.
*
* To zabezpieczenie interfejsu, nie kontrola dostępu do danych autoryzację
* To zabezpieczenie interfejsu, nie kontrola dostępu do danych - autoryzację
* egzekwuje backend przy każdym endpoincie.
*/
export function ProtectedRoute({ requireAdmin = false }: { requireAdmin?: boolean }) {
+159
View File
@@ -0,0 +1,159 @@
// Wspoldzielony edytor tekstu sformatowanego (WYSIWYG) dla calego serwisu.
// Standard: pola prozą uzywaja RichTextEditor (zapis HTML), a render tresci
// uzytkownika idzie WYLACZNIE przez SafeHtml (sanityzacja DOMPurify) - to jedyny
// punkt renderowania HTML od uzytkownika, co chroni przed XSS.
import { useEffect, type ReactNode } from 'react';
import { EditorContent, useEditor, type Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Underline from '@tiptap/extension-underline';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import CharacterCount from '@tiptap/extension-character-count';
import DOMPurify from 'dompurify';
// Dozwolone znaczniki i atrybuty przy renderowaniu tresci uzytkownika.
const ALLOWED_TAGS = ['p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'ul', 'ol', 'li', 'h3', 'a'];
const ALLOWED_ATTR = ['href', 'target', 'rel'];
export function sanitizeHtml(html: string): string {
return DOMPurify.sanitize(html ?? '', { ALLOWED_TAGS, ALLOWED_ATTR });
}
// Widoczny tekst (bez znacznikow) - do walidacji i licznika znakow.
export function stripHtml(html: string): string {
if (!html) {
return '';
}
const doc = new DOMParser().parseFromString(sanitizeHtml(html), 'text/html');
return (doc.body.textContent || '').replace(/ /g, ' ').trim();
}
// Czy tresc jest pusta (sam pusty akapit tez traktujemy jako brak tresci).
export function isRichTextEmpty(html: string): boolean {
return stripHtml(html).length === 0;
}
type SafeHtmlProps = {
html: string;
className?: string;
as?: 'div' | 'span';
};
// Jedyny dozwolony sposob renderowania HTML pochodzacego od uzytkownika.
export function SafeHtml({ html, className, as = 'div' }: SafeHtmlProps) {
const Tag = as;
return <Tag className={className} dangerouslySetInnerHTML={{ __html: sanitizeHtml(html) }} />;
}
type ToolbarButtonProps = {
onClick: () => void;
active?: boolean;
disabled?: boolean;
label: string;
title: string;
children: ReactNode;
};
function ToolbarButton({ onClick, active, disabled, label, title, children }: ToolbarButtonProps) {
return (
<button
type="button"
className={active ? 'active' : ''}
onMouseDown={(event) => event.preventDefault()}
onClick={onClick}
disabled={disabled}
aria-label={label}
aria-pressed={active}
title={title}
>
{children}
</button>
);
}
function Toolbar({ editor }: { editor: Editor }) {
return (
<div className="rich-editor-toolbar" role="toolbar" aria-label="Formatowanie tekstu">
<ToolbarButton label="Pogrubienie" title="Pogrubienie" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
<strong>B</strong>
</ToolbarButton>
<ToolbarButton label="Kursywa" title="Kursywa" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
<em>I</em>
</ToolbarButton>
<ToolbarButton label="Podkreslenie" title="Podkreslenie" active={editor.isActive('underline')} onClick={() => editor.chain().focus().toggleUnderline().run()}>
<span style={{ textDecoration: 'underline' }}>U</span>
</ToolbarButton>
<span className="rich-editor-sep" aria-hidden="true" />
<ToolbarButton label="Naglowek" title="Naglowek" active={editor.isActive('heading', { level: 3 })} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>
H
</ToolbarButton>
<ToolbarButton label="Lista punktowana" title="Lista punktowana" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
&bull; &#8212;
</ToolbarButton>
<ToolbarButton label="Lista numerowana" title="Lista numerowana" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
1.
</ToolbarButton>
<span className="rich-editor-sep" aria-hidden="true" />
<ToolbarButton label="Wyczysc formatowanie" title="Wyczysc formatowanie" onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}>
&#10005;
</ToolbarButton>
</div>
);
}
type RichTextEditorProps = {
value: string;
onChange: (html: string) => void;
placeholder?: string;
maxLength?: number;
ariaLabel?: string;
};
export function RichTextEditor({ value, onChange, placeholder, maxLength, ariaLabel }: RichTextEditorProps) {
const editor = useEditor({
extensions: [
StarterKit.configure({ heading: { levels: [3] } }),
Underline,
Link.configure({ openOnClick: false, autolink: true, HTMLAttributes: { rel: 'noopener noreferrer nofollow', target: '_blank' } }),
Placeholder.configure({ placeholder: placeholder ?? '' }),
...(maxLength ? [CharacterCount.configure({ limit: maxLength })] : [CharacterCount]),
],
content: value || '',
editorProps: {
attributes: {
role: 'textbox',
'aria-multiline': 'true',
...(ariaLabel ? { 'aria-label': ariaLabel } : {}),
},
},
onUpdate: ({ editor: current }) => {
const html = current.getHTML();
// Pusty edytor zwraca "<p></p>" - normalizujemy do pustego ciagu.
onChange(current.isEmpty ? '' : html);
},
});
// Synchronizacja z zewnetrzna wartoscia (prefill w trybie edycji) - tylko gdy sie rozjecha.
useEffect(() => {
if (!editor) {
return;
}
const incoming = value || '';
const currentEmpty = editor.isEmpty && incoming === '';
if (!currentEmpty && incoming !== editor.getHTML()) {
editor.commands.setContent(incoming, false);
}
}, [editor, value]);
const count = editor ? editor.storage.characterCount.characters() : stripHtml(value).length;
return (
<div className="rich-editor">
{editor && <Toolbar editor={editor} />}
<EditorContent editor={editor} className="rich-editor-area" />
{typeof maxLength === 'number' && (
<small className="rich-editor-count">{count}/{maxLength}</small>
)}
</div>
);
}
+11 -6
View File
@@ -5,6 +5,7 @@
*/
import { useCallback, useEffect, useState } from 'react';
import { apiFetch } from '../auth';
import { RichTextEditor } from '../RichText';
type IconComponent = (props: { name: string }) => JSX.Element | null;
@@ -289,14 +290,14 @@ function LeadsSection({ Icon }: { Icon: IconComponent }) {
<tbody>
{leads.map((lead) => (
<tr key={lead.id}>
<td>{lead.name || ''}</td>
<td>{lead.name || '-'}</td>
<td>
<div className="mkt-contact">
{lead.email && <span>{lead.email}</span>}
{lead.phone && <span className="mkt-muted">{lead.phone}</span>}
</div>
</td>
<td>{lead.city || ''}</td>
<td>{lead.city || '-'}</td>
<td><span className="mkt-chip">{SOURCE_LABEL[lead.source]}</span></td>
<td>{STATUS_LABEL[lead.status]}</td>
<td>
@@ -324,7 +325,7 @@ function LeadsSection({ Icon }: { Icon: IconComponent }) {
<div className="mkt-import">
<h3><Icon name="document" /> Import kontaktów (CSV)</h3>
<p className="mkt-muted">Kolumny: <code>nazwa;email;telefon;miasto</code> jeden kontakt w wierszu, nagłówek opcjonalny.</p>
<p className="mkt-muted">Kolumny: <code>nazwa;email;telefon;miasto</code> - jeden kontakt w wierszu, nagłówek opcjonalny.</p>
<textarea
rows={4}
value={csv}
@@ -430,7 +431,7 @@ function TargetGroupsSection({ Icon }: { Icon: IconComponent }) {
<div className="mkt-grid-2">
<div className="admin-card mkt-card">
<div className="admin-card-head"><h2>Nowa grupa docelowa</h2></div>
<p className="mkt-muted">Grupa to dynamiczny segment odbiorcy wyliczają się na bieżąco z kryteriów.</p>
<p className="mkt-muted">Grupa to dynamiczny segment - odbiorcy wyliczają się na bieżąco z kryteriów.</p>
<div className="mkt-form">
<label>Nazwa
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="np. Deweloperzy Warszawa" />
@@ -485,7 +486,7 @@ function TargetGroupsSection({ Icon }: { Icon: IconComponent }) {
<li key={g.id}>
<div>
<strong>{g.name}</strong>
{g.description && <span className="mkt-muted"> {g.description}</span>}
{g.description && <span className="mkt-muted"> - {g.description}</span>}
<div className="mkt-group-meta">
<span className="mkt-badge">{g.memberCount} odbiorców</span>
{g.sources.length > 0 && <span className="mkt-muted">{g.sources.map((s) => SOURCE_LABEL[s]).join(', ')}</span>}
@@ -624,7 +625,7 @@ export function AdminCampaignsView({ Icon }: { Icon: IconComponent }) {
</label>
<label>Grupa docelowa
<select value={form.targetGroupId} onChange={(e) => setForm({ ...form, targetGroupId: e.target.value })}>
<option value=""> wybierz </option>
<option value="">- wybierz -</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>{g.name} ({g.memberCount})</option>
))}
@@ -637,7 +638,11 @@ export function AdminCampaignsView({ Icon }: { Icon: IconComponent }) {
</label>
)}
<label>Treść <span className="mkt-muted">(użyj {'{{name}}'} dla personalizacji; stopka z rezygnacją dodawana automatycznie)</span>
{form.channel === 'EMAIL' ? (
<RichTextEditor value={form.body} onChange={(html) => setForm({ ...form, body: html })} ariaLabel="Treść kampanii e-mail" placeholder="Cześć {{name}}, ..." />
) : (
<textarea rows={5} value={form.body} onChange={(e) => setForm({ ...form, body: e.target.value })} placeholder="Cześć {{name}}, ..." />
)}
</label>
<div className="mkt-form-row">
<label>Dzienny limit
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+1 -1
View File
@@ -77,7 +77,7 @@ export function toDisplayNotifications(list: ServerNotification[]): DisplayNotif
...item,
description: item.body ?? '',
timeLabel,
// Widok grupuje wyłącznie na "Dzisiaj"/"Wczoraj" starsze trafiają do "Wczoraj".
// Widok grupuje wyłącznie na "Dzisiaj"/"Wczoraj" - starsze trafiają do "Wczoraj".
dayLabel: isToday ? 'Dzisiaj' : 'Wczoraj',
unread,
action: !unread && item.link ? 'arrow' : undefined,
+8 -2
View File
@@ -1,4 +1,4 @@
// Centralna mapa adresów aplikacji. Jedyne źródło prawdy dla nawigacji
// Centralna mapa adresów aplikacji. Jedyne źródło prawdy dla nawigacji -
// komponenty korzystają z ROUTES zamiast trzymać identyfikatory widoków w stanie.
export const ROUTES = {
home: '/',
@@ -11,6 +11,7 @@ export const ROUTES = {
negotiation: '/negocjacje',
comparison: '/konto/porownanie',
add: '/dodaj-ogloszenie',
edit: '/edytuj-ogloszenie/:id',
services: '/firmy-i-uslugi',
companies: '/firmy',
guides: '/poradniki',
@@ -42,7 +43,12 @@ export function listingPath(id: number): string {
return `/oferta/${id}`;
}
// Negocjacje dotyczą konkretnej oferty — bez identyfikatora pokazujemy pierwszą z listy.
// Dedykowana strona edycji wlasnego ogloszenia.
export function listingEditPath(id: number): string {
return `/edytuj-ogloszenie/${id}`;
}
// Negocjacje dotyczą konkretnej oferty - bez identyfikatora pokazujemy pierwszą z listy.
export function negotiationPath(offerId?: number | null): string {
return offerId ? `${ROUTES.negotiation}/${offerId}` : ROUTES.negotiation;
}
+816 -35
View File
File diff suppressed because it is too large Load Diff