Spotkania na serwerze: umawianie ogladania z druga strona
Deploy Polska Lokalnie / deploy (push) Successful in 12s

Nowa tabela meetings i /api/meetings. Termin proponuje jedna ze stron
rozmowy o ogloszeniu, druga potwierdza albo odrzuca; odwolac moze kazda
ze stron. Kalendarz konta pokazuje oba rodzaje wpisow - umowione
ogladania i wlasne notatki - i jest ten sam na kazdym urzadzeniu.
Powiadomienia: wpis w watku i dzwonek przy kazdej zmianie, e-mail
wylacznie przy potwierdzeniu terminu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 09:57:35 +02:00
parent e85cc70982
commit b9fecdea85
11 changed files with 1029 additions and 240 deletions
+270 -240
View File
@@ -271,14 +271,6 @@ const LIVE_LISTINGS_REFRESH_MS = 15000;
type NotificationCategory = 'listing' | 'messages' | 'system';
const meetingsStorageKey = 'polskalokalnie-account-meetings';
// Identyfikatory przykladowych spotkan, ktore wczesniejsza wersja zapisywala kazdemu w localStorage.
// Odfiltrowujemy je przy odczycie, zeby zniknely takze u osob, ktore juz je maja zapisane.
const LEGACY_DEMO_MEETING_IDS = new Set([
'meeting-1', 'meeting-2', 'meeting-3', 'meeting-4',
'meeting-test-now-1', 'meeting-test-week-ago-1', 'meeting-test-prev-month-1',
]);
type FavoriteTab = 'buy' | 'rent';
type FavoriteListing = {
@@ -6472,24 +6464,26 @@ function AccountSidebar() {
const messagesUnreadCount = useMessagesUnreadCount();
// Liczba ogloszen konta pochodzi z serwera (/listings/mine) - tak samo jak lista na /konto/moje-oferty.
const [allListingsCount, setAllListingsCount] = useState(0);
// Spotkania zaplanowane na dzisiaj (odwolane sie nie licza).
const todayMeetingsCount = (() => {
if (typeof window === 'undefined') {
return 0;
// Spotkania na dzisiaj (odwolane i odrzucone sie nie licza) - z konta, nie z przegladarki.
const [todayMeetingsCount, setTodayMeetingsCount] = useState(0);
useEffect(() => {
if (!user) {
setTodayMeetingsCount(0);
return;
}
try {
const raw = window.localStorage.getItem(meetingsStorageKey);
const parsed = raw ? JSON.parse(raw) : [];
if (!Array.isArray(parsed)) {
return 0;
}
const now = new Date();
const todayKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
return parsed.filter((item) => item && !LEGACY_DEMO_MEETING_IDS.has(item.id) && item.dateKey === todayKey && item.status !== 'cancelled').length;
} catch {
return 0;
}
})();
let cancelled = false;
const now = new Date();
const todayKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
apiFetch<ApiMeeting[]>('/meetings')
.then((items) => {
if (!cancelled) {
setTodayMeetingsCount(items.filter((item) => item.date === todayKey
&& item.status !== 'CANCELLED' && item.status !== 'DECLINED').length);
}
})
.catch(() => { if (!cancelled) { setTodayMeetingsCount(0); } });
return () => { cancelled = true; };
}, [user, location.pathname]);
// Zapisane wyszukiwania sa na koncie, wiec licznik bierzemy z serwera - tak jak liczbe ofert.
const [savedSearchCount, setSavedSearchCount] = useState(0);
useEffect(() => {
@@ -9095,22 +9089,54 @@ function AccountComparisonPage({ onOpenListing }: { onOpenListing: (id: number)
);
}
function AccountMeetingsPage() {
const { notify } = useNotifications();
type MeetingType = 'buy' | 'rent' | 'own';
type MeetingStatus = 'planned' | 'completed' | 'cancelled';
type MeetingItem = {
id: string;
title: string;
subtitle: string;
location: string;
dateKey: string;
start: string;
end: string;
type: MeetingType;
status: MeetingStatus;
};
// Spotkanie z API: ogladanie umowione z druga strona rozmowy albo wlasny wpis w kalendarzu.
type ApiMeeting = {
id: number;
conversationId: number | null;
listingId: number | null;
listingTitle: string | null;
title: string;
note: string | null;
location: string | null;
date: string;
start: string;
end: string;
status: 'PROPOSED' | 'CONFIRMED' | 'DECLINED' | 'CANCELLED';
proposedByMe: boolean;
privateNote: boolean;
otherName: string | null;
cancelledByMe: boolean;
};
// Serwer podaje godziny jako HH:mm:ss - w kalendarzu i polach formularza uzywamy HH:mm.
function meetingTime(value: string): string {
return value.slice(0, 5);
}
function meetingStatusLabel(meeting: ApiMeeting): string {
if (meeting.status === 'CONFIRMED') {
return meeting.privateNote ? 'Wpis własny' : 'Potwierdzone';
}
if (meeting.status === 'DECLINED') {
return 'Termin odrzucony';
}
if (meeting.status === 'CANCELLED') {
return meeting.cancelledByMe ? 'Odwołane przez Ciebie' : 'Odwołane';
}
return meeting.proposedByMe ? 'Czeka na potwierdzenie' : 'Do potwierdzenia';
}
function meetingStatusClass(meeting: ApiMeeting): string {
if (meeting.status === 'CONFIRMED') {
return 'status-completed';
}
if (meeting.status === 'DECLINED' || meeting.status === 'CANCELLED') {
return 'status-cancelled';
}
return 'status-planned';
}
function AccountMeetingsPage() {
const formatDateKey = (value: Date) => {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, '0');
@@ -9118,15 +9144,16 @@ function AccountMeetingsPage() {
return `${year}-${month}-${day}`;
};
const storageKey = 'polskalokalnie-account-meetings';
const [calendarMonth, setCalendarMonth] = useState(() => new Date(new Date().getFullYear(), new Date().getMonth(), 1));
const [selectedDateKey, setSelectedDateKey] = useState(() => formatDateKey(new Date()));
const [showBuyMeetings, setShowBuyMeetings] = useState(true);
const [showRentMeetings, setShowRentMeetings] = useState(true);
const [showSharedMeetings, setShowSharedMeetings] = useState(true);
const [showOwnMeetings, setShowOwnMeetings] = useState(true);
const [isCreateFormOpen, setIsCreateFormOpen] = useState(false);
const [editingMeetingId, setEditingMeetingId] = useState<string | null>(null);
const [editingMeetingId, setEditingMeetingId] = useState<number | null>(null);
const [formError, setFormError] = useState('');
const [busy, setBusy] = useState(false);
const [meetings, setMeetings] = useState<ApiMeeting[]>([]);
const [loading, setLoading] = useState(true);
const [newMeeting, setNewMeeting] = useState({
title: '',
subtitle: '',
@@ -9134,41 +9161,23 @@ function AccountMeetingsPage() {
dateKey: formatDateKey(new Date()),
start: '10:00',
end: '10:30',
type: 'buy' as MeetingType,
});
const [meetings, setMeetings] = useState<MeetingItem[]>(() => {
try {
const raw = window.localStorage.getItem(storageKey);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed
.filter((item): item is MeetingItem => (
item
&& typeof item.id === 'string'
&& !LEGACY_DEMO_MEETING_IDS.has(item.id)
&& typeof item.title === 'string'
&& typeof item.subtitle === 'string'
&& typeof item.location === 'string'
&& typeof item.dateKey === 'string'
&& typeof item.start === 'string'
&& typeof item.end === 'string'
&& (item.type === 'buy' || item.type === 'rent' || item.type === 'own')
&& (item.status === 'planned' || item.status === 'completed' || item.status === 'cancelled')
))
.sort((left, right) => `${left.dateKey}-${left.start}`.localeCompare(`${right.dateKey}-${right.start}`));
} catch {
return [];
}
});
const reload = useCallback(() => (
apiFetch<ApiMeeting[]>('/meetings')
.then((items) => { setMeetings(items); return items; })
.catch(() => { setMeetings([]); return [] as ApiMeeting[]; })
.finally(() => setLoading(false))
), []);
useEffect(() => {
window.localStorage.setItem(storageKey, JSON.stringify(meetings));
}, [meetings]);
let cancelled = false;
apiFetch<ApiMeeting[]>('/meetings')
.then((items) => { if (!cancelled) { setMeetings(items); } })
.catch(() => { if (!cancelled) { setMeetings([]); } })
.finally(() => { if (!cancelled) { setLoading(false); } });
return () => { cancelled = true; };
}, []);
const monthLabel = calendarMonth.toLocaleDateString('pl-PL', { month: 'long', year: 'numeric' });
const todayKey = formatDateKey(new Date());
@@ -9182,18 +9191,15 @@ function AccountMeetingsPage() {
});
const visibleSelectedMeetings = meetings
.filter((meeting) => meeting.dateKey === selectedDateKey)
.filter((meeting) => (
(meeting.type === 'buy' && showBuyMeetings)
|| (meeting.type === 'rent' && showRentMeetings)
|| (meeting.type === 'own' && showOwnMeetings)
))
.filter((meeting) => meeting.date === selectedDateKey)
.filter((meeting) => (meeting.privateNote ? showOwnMeetings : showSharedMeetings))
.sort((left, right) => left.start.localeCompare(right.start));
const selectedDayMeetings = meetings.filter((meeting) => meeting.date === selectedDateKey);
const selectedDayTotals = {
all: meetings.filter((meeting) => meeting.dateKey === selectedDateKey).length,
planned: meetings.filter((meeting) => meeting.dateKey === selectedDateKey && meeting.status === 'planned').length,
completed: meetings.filter((meeting) => meeting.dateKey === selectedDateKey && meeting.status === 'completed').length,
all: selectedDayMeetings.length,
waiting: selectedDayMeetings.filter((meeting) => meeting.status === 'PROPOSED').length,
confirmed: selectedDayMeetings.filter((meeting) => meeting.status === 'CONFIRMED').length,
};
const selectedDateLabel = (() => {
@@ -9206,15 +9212,7 @@ function AccountMeetingsPage() {
};
const resetFormForDate = (dateKey: string) => {
setNewMeeting({
title: '',
subtitle: '',
location: '',
dateKey,
start: '10:00',
end: '10:30',
type: 'buy',
});
setNewMeeting({ title: '', subtitle: '', location: '', dateKey, start: '10:00', end: '10:30' });
setEditingMeetingId(null);
setFormError('');
};
@@ -9236,65 +9234,64 @@ function AccountMeetingsPage() {
return (hours * 60) + minutes;
};
const hasTimeConflict = (candidateDateKey: string, candidateStart: string, candidateEnd: string, ignoreId?: string | null) => {
// Odwolane i odrzucone terminy nie blokuja godziny - miejsce w kalendarzu jest znowu wolne.
const hasTimeConflict = (candidateDateKey: string, candidateStart: string, candidateEnd: string, ignoreId: number | null) => {
const candidateStartMinutes = toMinutes(candidateStart);
const candidateEndMinutes = toMinutes(candidateEnd);
return meetings.some((meeting) => {
if (meeting.dateKey !== candidateDateKey || meeting.id === ignoreId) {
if (meeting.date !== candidateDateKey || meeting.id === ignoreId) {
return false;
}
const startMinutes = toMinutes(meeting.start);
const endMinutes = toMinutes(meeting.end);
return candidateStartMinutes < endMinutes && candidateEndMinutes > startMinutes;
if (meeting.status === 'CANCELLED' || meeting.status === 'DECLINED') {
return false;
}
return candidateStartMinutes < toMinutes(meetingTime(meeting.end))
&& candidateEndMinutes > toMinutes(meetingTime(meeting.start));
});
};
const setStatus = (id: string, status: MeetingStatus) => {
if (selectedDateIsPast) {
return;
}
const target = meetings.find((meeting) => meeting.id === id);
setMeetings((current) => current.map((meeting) => (meeting.id === id ? { ...meeting, status } : meeting)));
if (target && status === 'cancelled') {
notify({ category: 'system', icon: 'calendar', title: 'Spotkanie odwołane', body: `Spotkanie „${target.title}” zostało odwołane.`, link: 'meetings', dedupeKey: `meeting-${id}-cancelled` });
} else if (target && status === 'completed') {
notify({ category: 'system', icon: 'check', title: 'Spotkanie potwierdzone', body: `Spotkanie „${target.title}” zostało oznaczone jako zrealizowane.`, link: 'meetings', dedupeKey: `meeting-${id}-completed` });
}
const run = (action: Promise<unknown>, onError: string) => {
setBusy(true);
action
.then(() => reload())
.catch((error: Error) => setFormError(error.message || onError))
.finally(() => setBusy(false));
};
const removeMeeting = (id: string) => {
if (selectedDateIsPast) {
return;
}
setMeetings((current) => current.filter((meeting) => meeting.id !== id));
if (editingMeetingId === id) {
const confirmMeeting = (meeting: ApiMeeting) => {
run(apiFetch<ApiMeeting>(`/meetings/${meeting.id}/confirm`, { method: 'POST' }), 'Nie udało się potwierdzić terminu.');
};
const declineMeeting = (meeting: ApiMeeting) => {
run(apiFetch<ApiMeeting>(`/meetings/${meeting.id}/decline`, { method: 'POST' }), 'Nie udało się odrzucić terminu.');
};
const cancelMeeting = (meeting: ApiMeeting) => {
run(apiFetch<void>(`/meetings/${meeting.id}`, { method: 'DELETE' }), 'Nie udało się odwołać spotkania.');
if (editingMeetingId === meeting.id) {
setEditingMeetingId(null);
setIsCreateFormOpen(false);
}
};
const openEditMeeting = (meeting: MeetingItem) => {
if (isPastDateKey(meeting.dateKey)) {
return;
}
const openEditMeeting = (meeting: ApiMeeting) => {
setEditingMeetingId(meeting.id);
setNewMeeting({
title: meeting.title,
subtitle: meeting.subtitle,
location: meeting.location,
dateKey: meeting.dateKey,
start: meeting.start,
end: meeting.end,
type: meeting.type,
subtitle: meeting.note ?? '',
location: meeting.location ?? '',
dateKey: meeting.date,
start: meetingTime(meeting.start),
end: meetingTime(meeting.end),
});
setFormError('');
setSelectedDateKey(meeting.dateKey);
const selectedDate = new Date(meeting.dateKey);
setSelectedDateKey(meeting.date);
const selectedDate = new Date(meeting.date);
setCalendarMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
setIsCreateFormOpen(true);
};
const addMeeting = () => {
const saveMeeting = () => {
if (isPastDateKey(newMeeting.dateKey)) {
setFormError('Nie można dodawać ani edytować spotkań wstecz.');
return;
@@ -9312,66 +9309,31 @@ function AccountMeetingsPage() {
return;
}
if (editingMeetingId) {
setMeetings((current) => current.map((meeting) => (meeting.id === editingMeetingId
? {
...meeting,
title: newMeeting.title.trim(),
subtitle: newMeeting.subtitle.trim() || 'Spotkanie ręcznie dodane',
location: newMeeting.location.trim() || 'Do uzupełnienia',
dateKey: newMeeting.dateKey,
start: newMeeting.start,
end: newMeeting.end,
type: newMeeting.type,
}
: meeting)));
} else {
setMeetings((current) => [
...current,
{
id: `meeting-${Date.now()}`,
title: newMeeting.title.trim(),
subtitle: newMeeting.subtitle.trim() || 'Spotkanie ręcznie dodane',
location: newMeeting.location.trim() || 'Do uzupełnienia',
dateKey: newMeeting.dateKey,
start: newMeeting.start,
end: newMeeting.end,
type: newMeeting.type,
status: 'planned',
},
]);
}
const payload = {
title: newMeeting.title.trim(),
note: newMeeting.subtitle.trim(),
location: newMeeting.location.trim(),
date: newMeeting.dateKey,
start: newMeeting.start,
end: newMeeting.end,
};
const request = editingMeetingId
? apiFetch<ApiMeeting>(`/meetings/${editingMeetingId}`, { method: 'PUT', body: JSON.stringify(payload) })
: apiFetch<ApiMeeting>('/meetings', { method: 'POST', body: JSON.stringify(payload) });
if (!editingMeetingId) {
notify({ category: 'system', icon: 'calendar', title: 'Spotkanie zaplanowane', body: `„${newMeeting.title.trim()}” - ${newMeeting.dateKey}, godz. ${newMeeting.start}.`, link: 'meetings' });
}
setFormError('');
setSelectedDateKey(newMeeting.dateKey);
const selectedDate = new Date(newMeeting.dateKey);
setCalendarMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
resetFormForDate(newMeeting.dateKey);
setIsCreateFormOpen(false);
};
const markerClass = (type: MeetingType) => {
if (type === 'buy') {
return 'buy';
}
if (type === 'rent') {
return 'rent';
}
return 'own';
};
const statusLabel = (status: MeetingStatus) => {
if (status === 'completed') {
return 'Zrealizowane';
}
if (status === 'cancelled') {
return 'Odwołane';
}
return 'Zaplanowane';
setBusy(true);
request
.then(() => reload())
.then(() => {
setFormError('');
setSelectedDateKey(newMeeting.dateKey);
const selectedDate = new Date(newMeeting.dateKey);
setCalendarMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
resetFormForDate(newMeeting.dateKey);
setIsCreateFormOpen(false);
})
.catch((error: Error) => setFormError(error.message || 'Nie udało się zapisać spotkania.'))
.finally(() => setBusy(false));
};
useEffect(() => {
@@ -9392,7 +9354,7 @@ function AccountMeetingsPage() {
<div className="account-main account-settings-main meetings-main">
<header className="settings-head meetings-head">
<h1>Umówione spotkania</h1>
<p>Zarządzaj swoimi spotkaniami i przeglądaj nadchodzące terminy.</p>
<p>Oglądania umówione w rozmowach o ogłoszeniach oraz Twoje własne wpisy w kalendarzu.</p>
</header>
<div className="meetings-grid">
@@ -9408,8 +9370,7 @@ function AccountMeetingsPage() {
className="meetings-today-button"
onClick={() => {
const today = new Date();
const todayKey = formatDateKey(today);
setSelectedDateKey(todayKey);
setSelectedDateKey(formatDateKey(today));
setCalendarMonth(new Date(today.getFullYear(), today.getMonth(), 1));
}}
>
@@ -9426,7 +9387,7 @@ function AccountMeetingsPage() {
const isOutside = date.getMonth() !== calendarMonth.getMonth();
const isActive = key === selectedDateKey;
const isPast = isPastDateKey(key);
const hasMeeting = meetings.some((meeting) => meeting.dateKey === key);
const hasMeeting = meetings.some((meeting) => meeting.date === key && meeting.status !== 'CANCELLED');
return (
<button
@@ -9447,20 +9408,22 @@ function AccountMeetingsPage() {
</div>
<button type="button" className="meetings-add-button" onClick={openCreateForm} disabled={selectedDateIsPast}>
<Icon name="plus" /> Dodaj spotkanie
<Icon name="plus" /> Dodaj własny wpis
</button>
<section className="meetings-filters-card">
<h3>Filtry</h3>
<label><input type="checkbox" checked={showBuyMeetings && showRentMeetings && showOwnMeetings} onChange={(event) => {
setShowBuyMeetings(event.target.checked);
setShowRentMeetings(event.target.checked);
<label><input type="checkbox" checked={showSharedMeetings && showOwnMeetings} onChange={(event) => {
setShowSharedMeetings(event.target.checked);
setShowOwnMeetings(event.target.checked);
}} /> Pokaż wszystkie</label>
<label><input type="checkbox" checked={showBuyMeetings} onChange={(event) => setShowBuyMeetings(event.target.checked)} /> Spotkania z kupującymi</label>
<label><input type="checkbox" checked={showRentMeetings} onChange={(event) => setShowRentMeetings(event.target.checked)} /> Spotkania z wynajmującymi</label>
<label><input type="checkbox" checked={showOwnMeetings} onChange={(event) => setShowOwnMeetings(event.target.checked)} /> Spotkania własne</label>
<label><input type="checkbox" checked={showSharedMeetings} onChange={(event) => setShowSharedMeetings(event.target.checked)} /> Oglądania z drugą stroną</label>
<label><input type="checkbox" checked={showOwnMeetings} onChange={(event) => setShowOwnMeetings(event.target.checked)} /> Wpisy własne</label>
</section>
<p className="meetings-hint">
Termin oglądania proponujesz w rozmowie o ogłoszeniu - <Link to={ROUTES.messages}>przejdź do wiadomości</Link>.
</p>
</aside>
<section className="meetings-list-card">
@@ -9468,8 +9431,8 @@ function AccountMeetingsPage() {
<h2>{selectedDateLabel}</h2>
<div className="meetings-day-summary">
<span>Wszystkie: {selectedDayTotals.all}</span>
<span>Zaplanowane: {selectedDayTotals.planned}</span>
<span>Zrealizowane: {selectedDayTotals.completed}</span>
<span>Do potwierdzenia: {selectedDayTotals.waiting}</span>
<span>Potwierdzone: {selectedDayTotals.confirmed}</span>
</div>
{selectedDateIsPast && (
<p className="meetings-history-note">Dzień archiwalny: możesz wyłącznie przeglądać historię spotkań.</p>
@@ -9477,50 +9440,54 @@ function AccountMeetingsPage() {
</header>
<div className="meetings-list">
{visibleSelectedMeetings.length === 0 && <p className="meetings-empty">Brak spotkań dla wybranego dnia.</p>}
{visibleSelectedMeetings.map((meeting) => (
<article key={meeting.id}>
<div className="time">{meeting.start}<small>- {meeting.end}</small></div>
<div className="info">
<strong><i className={markerClass(meeting.type)} />{meeting.title}</strong>
<p>{meeting.subtitle}</p>
<small>{meeting.location}</small>
</div>
<div className="meeting-side-actions">
<em className={`status-${meeting.status}`}>{statusLabel(meeting.status)}</em>
{!selectedDateIsPast && (
<div className="meeting-row-actions">
{meeting.status !== 'completed' && (
<button type="button" onClick={() => setStatus(meeting.id, 'completed')} aria-label="Oznacz jako zrealizowane"><Icon name="check" /></button>
)}
{meeting.status !== 'planned' && (
<button type="button" onClick={() => setStatus(meeting.id, 'planned')} aria-label="Oznacz jako zaplanowane"><Icon name="clock" /></button>
)}
<button type="button" onClick={() => setStatus(meeting.id, 'cancelled')} aria-label="Oznacz jako odwołane"><Icon name="warning" /></button>
<button type="button" onClick={() => openEditMeeting(meeting)} aria-label="Edytuj spotkanie"><Icon name="gear" /></button>
<button type="button" onClick={() => removeMeeting(meeting.id)} aria-label="Usuń spotkanie"><Icon name="trash" /></button>
</div>
)}
</div>
</article>
))}
{loading && <p className="meetings-empty">Wczytujemy kalendarz…</p>}
{!loading && visibleSelectedMeetings.length === 0 && <p className="meetings-empty">Brak spotkań dla wybranego dnia.</p>}
{visibleSelectedMeetings.map((meeting) => {
const canDecide = !meeting.privateNote && meeting.status === 'PROPOSED' && !meeting.proposedByMe;
const canCancel = meeting.status === 'PROPOSED' || meeting.status === 'CONFIRMED';
return (
<article key={meeting.id}>
<div className="time">{meetingTime(meeting.start)}<small>- {meetingTime(meeting.end)}</small></div>
<div className="info">
<strong><i className={meeting.privateNote ? 'own' : 'buy'} />{meeting.title}</strong>
<p>{meeting.privateNote ? (meeting.note || 'Wpis własny') : `Z: ${meeting.otherName ?? 'druga strona'}${meeting.listingTitle ? ` · ${meeting.listingTitle}` : ''}`}</p>
<small>{meeting.location || 'Miejsce do uzgodnienia'}</small>
</div>
<div className="meeting-side-actions">
<em className={meetingStatusClass(meeting)}>{meetingStatusLabel(meeting)}</em>
{!selectedDateIsPast && (
<div className="meeting-row-actions">
{canDecide && (
<button type="button" disabled={busy} onClick={() => confirmMeeting(meeting)} aria-label="Potwierdź termin"><Icon name="check" /></button>
)}
{canDecide && (
<button type="button" disabled={busy} onClick={() => declineMeeting(meeting)} aria-label="Odrzuć termin"><Icon name="warning" /></button>
)}
{meeting.privateNote && (
<button type="button" disabled={busy} onClick={() => openEditMeeting(meeting)} aria-label="Edytuj wpis"><Icon name="gear" /></button>
)}
{meeting.conversationId != null && (
<Link to={conversationPath(meeting.conversationId)} aria-label="Otwórz rozmowę"><Icon name="message" /></Link>
)}
{canCancel && (
<button type="button" disabled={busy} onClick={() => cancelMeeting(meeting)} aria-label={meeting.privateNote ? 'Usuń wpis' : 'Odwołaj spotkanie'}><Icon name="trash" /></button>
)}
</div>
)}
</div>
</article>
);
})}
</div>
{isCreateFormOpen && (
<div className="meetings-create-form">
<h3>{editingMeetingId ? 'Edytuj spotkanie' : 'Dodaj spotkanie ręcznie'}</h3>
<h3>{editingMeetingId ? 'Edytuj wpis' : 'Dodaj własny wpis'}</h3>
<div className="fields">
<label>Nazwa spotkania<input type="text" value={newMeeting.title} onChange={(event) => setNewMeeting((current) => ({ ...current, title: event.target.value }))} placeholder="np. Spotkanie z kupującym" /></label>
<label>Opis<input type="text" value={newMeeting.subtitle} onChange={(event) => setNewMeeting((current) => ({ ...current, subtitle: event.target.value }))} placeholder="np. Mieszkanie 3-pokojowe, 62 m²" /></label>
<label>Miejsce<input type="text" value={newMeeting.location} onChange={(event) => setNewMeeting((current) => ({ ...current, location: event.target.value }))} placeholder="np. ul. Mokotowska 45, Warszawa" /></label>
<label>Nazwa spotkania<input type="text" value={newMeeting.title} onChange={(event) => setNewMeeting((current) => ({ ...current, title: event.target.value }))} placeholder="Np. oglądanie mieszkania" /></label>
<label>Opis<input type="text" value={newMeeting.subtitle} onChange={(event) => setNewMeeting((current) => ({ ...current, subtitle: event.target.value }))} placeholder="Np. notatka dla siebie" /></label>
<label>Miejsce<input type="text" value={newMeeting.location} onChange={(event) => setNewMeeting((current) => ({ ...current, location: event.target.value }))} placeholder="Np. ul. Długa 5" /></label>
<label>Data<input type="date" min={todayKey} value={newMeeting.dateKey} onChange={(event) => setNewMeeting((current) => ({ ...current, dateKey: event.target.value }))} /></label>
<label>Typ
<select value={newMeeting.type} onChange={(event) => setNewMeeting((current) => ({ ...current, type: event.target.value as MeetingType }))}>
<option value="buy">Spotkanie z kupującym</option>
<option value="rent">Spotkanie z wynajmującym</option>
<option value="own">Spotkanie własne</option>
</select>
</label>
<label>Godzina od<input type="time" value={newMeeting.start} onChange={(event) => setNewMeeting((current) => ({ ...current, start: event.target.value }))} /></label>
<label>Godzina do<input type="time" value={newMeeting.end} onChange={(event) => setNewMeeting((current) => ({ ...current, end: event.target.value }))} /></label>
</div>
@@ -9536,10 +9503,11 @@ function AccountMeetingsPage() {
>
Anuluj
</button>
<button type="button" className="primary" onClick={addMeeting}>{editingMeetingId ? 'Zapisz zmiany' : 'Zapisz spotkanie'}</button>
<button type="button" className="primary" disabled={busy} onClick={saveMeeting}>{editingMeetingId ? 'Zapisz zmiany' : 'Zapisz wpis'}</button>
</div>
</div>
)}
{!isCreateFormOpen && formError && <p className="meetings-error">{formError}</p>}
</section>
</div>
</div>
@@ -24927,6 +24895,13 @@ function MessagesPage({
const [activeFilter, setActiveFilter] = useState<ThreadFilter>('all');
const [isFiltersOpen, setIsFiltersOpen] = useState(false);
const [composerValue, setComposerValue] = useState('');
// Propozycja ogladania z poziomu rozmowy - termin trafia do kalendarza obu stron.
const meetingToday = new Date().toISOString().slice(0, 10);
const [meetingFormOpen, setMeetingFormOpen] = useState(false);
const [meetingForm, setMeetingForm] = useState({ date: meetingToday, start: '17:00', end: '17:30', location: '' });
const [meetingFeedback, setMeetingFeedback] = useState('');
const [meetingError, setMeetingError] = useState('');
const [meetingBusy, setMeetingBusy] = useState(false);
const [isSending, setIsSending] = useState(false);
const [moderationFeedback, setModerationFeedback] = useState('');
const [isBlockModalOpen, setIsBlockModalOpen] = useState(false);
@@ -25364,6 +25339,39 @@ function MessagesPage({
void postToActiveThread(value);
};
// Propozycja terminu: zapisuje sie w kalendarzu obu stron i zostawia wpis w watku rozmowy.
const proposeMeeting = () => {
const conversationId = activeThread.conversationId;
if (!conversationId || meetingBusy) {
return;
}
if (meetingForm.end <= meetingForm.start) {
setMeetingError('Godzina zakończenia musi być późniejsza niż rozpoczęcia.');
return;
}
setMeetingBusy(true);
setMeetingError('');
apiFetch<ApiMeeting>('/meetings', {
method: 'POST',
body: JSON.stringify({
conversationId,
title: activeThreadListing?.title ? `Oglądanie: ${activeThreadListing.title}` : 'Oglądanie nieruchomości',
location: meetingForm.location.trim(),
date: meetingForm.date,
start: meetingForm.start,
end: meetingForm.end,
}),
})
.then(() => apiFetch<ApiConversationDetail>(`/conversations/${conversationId}`))
.then((detail) => {
applyConversationDetail(detail);
setMeetingFormOpen(false);
setMeetingFeedback('Propozycja wysłana. Czekasz na potwierdzenie drugiej strony.');
})
.catch((error: Error) => setMeetingError(error.message || 'Nie udało się wysłać propozycji.'))
.finally(() => setMeetingBusy(false));
};
// Wizytowka to zwykla wiadomosc tekstowa ze znacznikiem - zostaje w historii rozmowy na serwerze.
const deliverBusinessCard = (card: BusinessCard) => {
void postToActiveThread(formatBusinessCard(card));
@@ -25864,6 +25872,28 @@ function MessagesPage({
}}
disabled={!activeThreadListing}
><span><Icon name="house" /> Zobacz ogłoszenie</span><Icon name="link" /></button>
{/* Termin ogladania uzgadniamy w rozmowie - druga strona potwierdza go w swoim kalendarzu. */}
{!isAdminThread && activeThread.conversationId && (
<>
<button type="button" onClick={() => { setMeetingFormOpen((open) => !open); setMeetingError(''); setMeetingFeedback(''); }}>
<span><Icon name="calendar" /> Zaproponuj oglądanie</span><Icon name="chevron" />
</button>
{meetingFormOpen && (
<div className="messages-meeting-form">
<label>Data<input type="date" min={meetingToday} value={meetingForm.date} onChange={(event) => setMeetingForm((current) => ({ ...current, date: event.target.value }))} /></label>
<div className="messages-meeting-times">
<label>Od<input type="time" value={meetingForm.start} onChange={(event) => setMeetingForm((current) => ({ ...current, start: event.target.value }))} /></label>
<label>Do<input type="time" value={meetingForm.end} onChange={(event) => setMeetingForm((current) => ({ ...current, end: event.target.value }))} /></label>
</div>
<label>Miejsce<input type="text" value={meetingForm.location} placeholder="Np. adres z ogłoszenia" onChange={(event) => setMeetingForm((current) => ({ ...current, location: event.target.value }))} /></label>
{meetingError && <p className="messages-meeting-error">{meetingError}</p>}
<button type="button" className="messages-meeting-send" disabled={meetingBusy} onClick={proposeMeeting}>Wyślij propozycję</button>
<small>Druga strona potwierdzi termin albo zaproponuje inny. Wszystkie terminy zobaczysz w <Link to={ROUTES.accountMeetings}>kalendarzu spotkań</Link>.</small>
</div>
)}
{meetingFeedback && <p className="messages-meeting-ok" role="status">{meetingFeedback}</p>}
</>
)}
{/* Rozmowy z obsluga serwisu nie blokujemy, nie zglaszamy ani nie usuwamy. */}
{!isAdminThread && (
<>
+77
View File
@@ -35233,3 +35233,80 @@ a.listing-detail-secondary {
font-weight: 900;
margin: 0 0 12px;
}
/* Propozycja ogladania w rozmowie i podpowiedz w kalendarzu spotkan. */
.messages-meeting-form {
background: #f7fafc;
border: 1px solid #dde5ee;
border-radius: 10px;
display: grid;
gap: 10px;
margin-bottom: 10px;
padding: 12px;
}
.messages-meeting-form label {
color: #4a5a6f;
display: grid;
font-size: 12px;
font-weight: 800;
gap: 4px;
}
.messages-meeting-form input {
border: 1px solid #d7e0eb;
border-radius: 8px;
font-size: 13px;
padding: 8px 10px;
}
.messages-meeting-times {
display: grid;
gap: 10px;
grid-template-columns: 1fr 1fr;
}
.messages-meeting-send {
background: #10a15e;
border: 0;
border-radius: 8px;
color: #ffffff;
cursor: pointer;
font-size: 13px;
font-weight: 900;
min-height: 40px;
}
.messages-meeting-form small {
color: #6b7f96;
font-size: 11px;
line-height: 1.4;
}
.messages-meeting-error,
.messages-meeting-ok {
font-size: 12px;
font-weight: 800;
margin: 0 0 8px;
}
.messages-meeting-error {
color: #c0392b;
}
.messages-meeting-ok {
color: #0b9f5c;
}
.meetings-hint {
color: #6b7f96;
font-size: 12px;
line-height: 1.5;
margin: 12px 0 0;
}
.meeting-row-actions a {
align-items: center;
color: #526171;
display: inline-flex;
}
+35
View File
@@ -531,3 +531,38 @@ test.describe('Zapisane wyszukiwania', () => {
await expect(page.locator('main')).not.toContainText('Gdynia');
});
});
// Spotkania sa na koncie, a ogladanie umawiaja obie strony rozmowy o ogloszeniu.
test.describe('Spotkania', () => {
test('wlasny wpis w kalendarzu przezywa odswiezenie', async ({ page }) => {
await login(page);
await goto(page, ROUTES.accountMeetings);
await page.getByRole('button', { name: /Dodaj własny wpis/ }).click();
const title = `Notatka ${Date.now()}`;
await page.locator('.meetings-create-form input[type=text]').first().fill(title);
await page.getByRole('button', { name: 'Zapisz wpis' }).click();
await expect(page.locator('.meetings-list')).toContainText(title);
await page.reload();
await expect(page.locator('.meetings-list')).toContainText(title);
await page.locator('button[aria-label="Usuń wpis"]').first().click();
await expect(page.locator('.meetings-list')).not.toContainText(title);
});
test('w rozmowie o ogloszeniu mozna zaproponowac ogladanie', async ({ page }) => {
await login(page);
await goto(page, ROUTES.buy);
await page.locator('.real-result-card').first().waitFor();
const href = await page.locator('.real-result-card .card-stretched-link').first().getAttribute('href');
await goto(page, href!);
await page.getByRole('button', { name: /Napisz wiadomość/ }).first().click();
await page.waitForFunction(() => window.location.pathname.startsWith('/wiadomosci/'));
await page.locator('.messages-composer input[type=text]').fill('Dzien dobry, czy oferta jest aktualna?');
await page.locator('.messages-composer .send').click();
await page.waitForFunction(() => /wiadomosci/.test(window.location.pathname) && window.location.pathname.split('/').length === 3);
await page.getByRole('button', { name: /Zaproponuj oglądanie/ }).click();
await expect(page.locator('.messages-meeting-form')).toBeVisible();
});
});