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
+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

+941 -522
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>
);
}
+12 -7
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>
<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>
<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