poprawki graficzne, zdjęcia, panel administarcyjny oraz układ stron
This commit is contained in:
@@ -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 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 />}`).
|
- **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`.
|
- **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.
|
- **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`.
|
- **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.
|
- **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.
|
- **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:
|
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/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/ProtectedRoute.tsx` - bramka autoryzacji, obsługuje stan `loading` z `AuthProvider`.
|
||||||
- `frontend/src/App.tsx` — deklaracja `<Routes>`.
|
- `frontend/src/App.tsx` - deklaracja `<Routes>`.
|
||||||
- `nginx/default.conf` — `try_files $uri $uri/ /index.html` (SPA fallback, już skonfigurowany).
|
- `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
|
## 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",
|
OfferType.SALE, PropertyType.HOUSE, "Kraków", "ul. Podgórska 8",
|
||||||
new BigDecimal("1290000"), 145.0, 5, ListingStatus.PENDING));
|
new BigDecimal("1290000"), 145.0, 5, ListingStatus.PENDING));
|
||||||
listingRepository.save(demoListing(
|
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",
|
OfferType.RENT, PropertyType.APARTMENT, "Wrocław", "ul. Krupnicza 3",
|
||||||
new BigDecimal("2600"), 30.0, 1, ListingStatus.PENDING));
|
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.PatchMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
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.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
@@ -58,6 +59,16 @@ public class ListingController {
|
|||||||
.body(response);
|
.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).
|
// Wstrzymanie/wznowienie wlasnego ogloszenia (status=PAUSED lub APPROVED).
|
||||||
@PatchMapping("/{id}/status")
|
@PatchMapping("/{id}/status")
|
||||||
public ListingResponse updateStatus(
|
public ListingResponse updateStatus(
|
||||||
|
|||||||
@@ -100,6 +100,33 @@ public class ListingService {
|
|||||||
request.district(), request.street(), request.contactName());
|
request.district(), request.street(), request.contactName());
|
||||||
|
|
||||||
PropertyListing listing = new PropertyListing();
|
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.setTitle(request.title().trim());
|
||||||
listing.setDescription(request.description().trim());
|
listing.setDescription(request.description().trim());
|
||||||
listing.setOfferType(request.offerType());
|
listing.setOfferType(request.offerType());
|
||||||
@@ -140,11 +167,6 @@ public class ListingService {
|
|||||||
listing.setPhotos(photos);
|
listing.setPhotos(photos);
|
||||||
listing.setCoverPhoto(photos.isEmpty() ? null : photos.get(0));
|
listing.setCoverPhoto(photos.isEmpty() ? null : photos.get(0));
|
||||||
listing.setVirtualTourUrl(normalizeVirtualTourUrl(request.virtualTourUrl()));
|
listing.setVirtualTourUrl(normalizeVirtualTourUrl(request.virtualTourUrl()));
|
||||||
|
|
||||||
listing.setOwnerEmail(ownerEmail);
|
|
||||||
listing.setStatus(ListingStatus.PENDING);
|
|
||||||
|
|
||||||
return ListingDetailResponse.from(listingRepository.save(listing));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Moderacja (admin) ---
|
// --- Moderacja (admin) ---
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ public class PropertyListing {
|
|||||||
@Column(nullable = false, length = 160)
|
@Column(nullable = false, length = 160)
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
@Column(nullable = false, length = 3000)
|
@Column(nullable = false, columnDefinition = "text")
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ public class MailConfigController {
|
|||||||
SendResult result = emailSender.send(
|
SendResult result = emailSender.send(
|
||||||
mailConfigService.getSmtp(),
|
mailConfigService.getSmtp(),
|
||||||
request.recipient().trim(),
|
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>");
|
"<p>To jest wiadomość testowa potwierdzająca poprawną konfigurację poczty SMTP.</p>");
|
||||||
return TestResultResponse.from(result);
|
return TestResultResponse.from(result);
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,7 @@ public class MailConfigController {
|
|||||||
SendResult result = smsSender.send(
|
SendResult result = smsSender.send(
|
||||||
mailConfigService.getSms(),
|
mailConfigService.getSms(),
|
||||||
request.recipient().trim(),
|
request.recipient().trim(),
|
||||||
"Test konfiguracji bramki SMS – Polska Lokalnie");
|
"Test konfiguracji bramki SMS - Polska Lokalnie");
|
||||||
return TestResultResponse.from(result);
|
return TestResultResponse.from(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
<title>Polska Lokalnie</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -10,7 +10,15 @@
|
|||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"@types/pdfmake": "^0.3.3",
|
||||||
|
"dompurify": "^3.2.4",
|
||||||
"pdfmake": "^0.3.11",
|
"pdfmake": "^0.3.11",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
+941
-522
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ import { ROUTES } from './routes';
|
|||||||
* dociąga się asynchronicznie z /auth/me. Bez tego warunku bezpośrednie wejście na
|
* 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.
|
* /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.
|
* egzekwuje backend przy każdym endpoincie.
|
||||||
*/
|
*/
|
||||||
export function ProtectedRoute({ requireAdmin = false }: { requireAdmin?: boolean }) {
|
export function ProtectedRoute({ requireAdmin = false }: { requireAdmin?: boolean }) {
|
||||||
|
|||||||
@@ -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()}>
|
||||||
|
• —
|
||||||
|
</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()}>
|
||||||
|
✕
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { apiFetch } from '../auth';
|
import { apiFetch } from '../auth';
|
||||||
|
import { RichTextEditor } from '../RichText';
|
||||||
|
|
||||||
type IconComponent = (props: { name: string }) => JSX.Element | null;
|
type IconComponent = (props: { name: string }) => JSX.Element | null;
|
||||||
|
|
||||||
@@ -289,14 +290,14 @@ function LeadsSection({ Icon }: { Icon: IconComponent }) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{leads.map((lead) => (
|
{leads.map((lead) => (
|
||||||
<tr key={lead.id}>
|
<tr key={lead.id}>
|
||||||
<td>{lead.name || '—'}</td>
|
<td>{lead.name || '-'}</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="mkt-contact">
|
<div className="mkt-contact">
|
||||||
{lead.email && <span>{lead.email}</span>}
|
{lead.email && <span>{lead.email}</span>}
|
||||||
{lead.phone && <span className="mkt-muted">{lead.phone}</span>}
|
{lead.phone && <span className="mkt-muted">{lead.phone}</span>}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{lead.city || '—'}</td>
|
<td>{lead.city || '-'}</td>
|
||||||
<td><span className="mkt-chip">{SOURCE_LABEL[lead.source]}</span></td>
|
<td><span className="mkt-chip">{SOURCE_LABEL[lead.source]}</span></td>
|
||||||
<td>{STATUS_LABEL[lead.status]}</td>
|
<td>{STATUS_LABEL[lead.status]}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -324,7 +325,7 @@ function LeadsSection({ Icon }: { Icon: IconComponent }) {
|
|||||||
|
|
||||||
<div className="mkt-import">
|
<div className="mkt-import">
|
||||||
<h3><Icon name="document" /> Import kontaktów (CSV)</h3>
|
<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
|
<textarea
|
||||||
rows={4}
|
rows={4}
|
||||||
value={csv}
|
value={csv}
|
||||||
@@ -430,7 +431,7 @@ function TargetGroupsSection({ Icon }: { Icon: IconComponent }) {
|
|||||||
<div className="mkt-grid-2">
|
<div className="mkt-grid-2">
|
||||||
<div className="admin-card mkt-card">
|
<div className="admin-card mkt-card">
|
||||||
<div className="admin-card-head"><h2>Nowa grupa docelowa</h2></div>
|
<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">
|
<div className="mkt-form">
|
||||||
<label>Nazwa
|
<label>Nazwa
|
||||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="np. Deweloperzy Warszawa" />
|
<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}>
|
<li key={g.id}>
|
||||||
<div>
|
<div>
|
||||||
<strong>{g.name}</strong>
|
<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">
|
<div className="mkt-group-meta">
|
||||||
<span className="mkt-badge">{g.memberCount} odbiorców</span>
|
<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>}
|
{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>
|
||||||
<label>Grupa docelowa
|
<label>Grupa docelowa
|
||||||
<select value={form.targetGroupId} onChange={(e) => setForm({ ...form, targetGroupId: e.target.value })}>
|
<select value={form.targetGroupId} onChange={(e) => setForm({ ...form, targetGroupId: e.target.value })}>
|
||||||
<option value="">— wybierz —</option>
|
<option value="">- wybierz -</option>
|
||||||
{groups.map((g) => (
|
{groups.map((g) => (
|
||||||
<option key={g.id} value={g.id}>{g.name} ({g.memberCount})</option>
|
<option key={g.id} value={g.id}>{g.name} ({g.memberCount})</option>
|
||||||
))}
|
))}
|
||||||
@@ -637,7 +638,11 @@ export function AdminCampaignsView({ Icon }: { Icon: IconComponent }) {
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<label>Treść <span className="mkt-muted">(użyj {'{{name}}'} dla personalizacji; stopka z rezygnacją dodawana automatycznie)</span>
|
<label>Treść <span className="mkt-muted">(użyj {'{{name}}'} dla personalizacji; stopka z rezygnacją dodawana automatycznie)</span>
|
||||||
<textarea rows={5} value={form.body} onChange={(e) => setForm({ ...form, body: e.target.value })} placeholder="Cześć {{name}}, ..." />
|
{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>
|
</label>
|
||||||
<div className="mkt-form-row">
|
<div className="mkt-form-row">
|
||||||
<label>Dzienny limit
|
<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 |
@@ -77,7 +77,7 @@ export function toDisplayNotifications(list: ServerNotification[]): DisplayNotif
|
|||||||
...item,
|
...item,
|
||||||
description: item.body ?? '',
|
description: item.body ?? '',
|
||||||
timeLabel,
|
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',
|
dayLabel: isToday ? 'Dzisiaj' : 'Wczoraj',
|
||||||
unread,
|
unread,
|
||||||
action: !unread && item.link ? 'arrow' : undefined,
|
action: !unread && item.link ? 'arrow' : undefined,
|
||||||
|
|||||||
@@ -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.
|
// komponenty korzystają z ROUTES zamiast trzymać identyfikatory widoków w stanie.
|
||||||
export const ROUTES = {
|
export const ROUTES = {
|
||||||
home: '/',
|
home: '/',
|
||||||
@@ -11,6 +11,7 @@ export const ROUTES = {
|
|||||||
negotiation: '/negocjacje',
|
negotiation: '/negocjacje',
|
||||||
comparison: '/konto/porownanie',
|
comparison: '/konto/porownanie',
|
||||||
add: '/dodaj-ogloszenie',
|
add: '/dodaj-ogloszenie',
|
||||||
|
edit: '/edytuj-ogloszenie/:id',
|
||||||
services: '/firmy-i-uslugi',
|
services: '/firmy-i-uslugi',
|
||||||
companies: '/firmy',
|
companies: '/firmy',
|
||||||
guides: '/poradniki',
|
guides: '/poradniki',
|
||||||
@@ -42,7 +43,12 @@ export function listingPath(id: number): string {
|
|||||||
return `/oferta/${id}`;
|
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 {
|
export function negotiationPath(offerId?: number | null): string {
|
||||||
return offerId ? `${ROUTES.negotiation}/${offerId}` : ROUTES.negotiation;
|
return offerId ? `${ROUTES.negotiation}/${offerId}` : ROUTES.negotiation;
|
||||||
}
|
}
|
||||||
|
|||||||
+816
-35
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user