From b30a0b9b950a047dc64b36a76d5decfa9f7151b8 Mon Sep 17 00:00:00 2001 From: pascal Date: Mon, 10 Aug 2026 18:34:26 +0200 Subject: [PATCH] zminay w alertach cenowych --- .../notification/NotificationController.java | 18 + .../notification/NotificationRepository.java | 7 + .../notification/NotificationResponse.java | 4 + .../notification/NotificationService.java | 18 + frontend/package-lock.json | 851 +++++++++++++ frontend/src/App.tsx | 1094 +++++++++++++---- frontend/src/notifications.tsx | 24 +- frontend/src/routes.ts | 17 + frontend/src/styles.css | 1060 +++++++++++----- 9 files changed, 2577 insertions(+), 516 deletions(-) diff --git a/backend/src/main/java/pl/polskalokalnie/notification/NotificationController.java b/backend/src/main/java/pl/polskalokalnie/notification/NotificationController.java index 709ae73..9c4aafb 100644 --- a/backend/src/main/java/pl/polskalokalnie/notification/NotificationController.java +++ b/backend/src/main/java/pl/polskalokalnie/notification/NotificationController.java @@ -2,15 +2,19 @@ package pl.polskalokalnie.notification; import jakarta.validation.Valid; import java.util.List; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @RestController @@ -45,6 +49,20 @@ public class NotificationController { return ResponseEntity.noContent().build(); } + // Sprzatanie po skasowanym alercie: usuwa powiadomienia o kluczu zaczynajacym sie od prefiksu. + // Zakres to zawsze wlasne powiadomienia - odbiorce bierzemy z tokenu, nie z zadania. + @DeleteMapping + public ResponseEntity deleteByDedupePrefix( + @RequestParam("dedupePrefix") String dedupePrefix, + Authentication authentication + ) { + if (dedupePrefix == null || dedupePrefix.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj prefiks klucza powiadomienia"); + } + notificationService.deleteByDedupePrefix(authentication.getName(), dedupePrefix); + return ResponseEntity.noContent().build(); + } + // Zdarzenia liczone po stronie klienta (alerty cenowe, spotkania, ulubione, weryfikacja telefonu, // dopasowane oferty). Powiadomienie zawsze trafia do zalogowanego uzytkownika (serwer ustawia odbiorce). @PostMapping diff --git a/backend/src/main/java/pl/polskalokalnie/notification/NotificationRepository.java b/backend/src/main/java/pl/polskalokalnie/notification/NotificationRepository.java index 018a293..21eb12e 100644 --- a/backend/src/main/java/pl/polskalokalnie/notification/NotificationRepository.java +++ b/backend/src/main/java/pl/polskalokalnie/notification/NotificationRepository.java @@ -17,4 +17,11 @@ public interface NotificationRepository extends JpaRepository=18" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@remirror/core-constants": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", + "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -399,6 +423,449 @@ "tslib": "^2.8.0" } }, + "node_modules/@tiptap/core": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz", + "integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.27.2.tgz", + "integrity": "sha512-oIGZgiAeA4tG3YxbTDfrmENL4/CIwGuP3THtHsNhwRqwsl9SfMk58Ucopi2GXTQSdYXpRJ0ahE6nPqB5D6j/Zw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.27.2.tgz", + "integrity": "sha512-bR7J5IwjCGQ0s3CIxyMvOCnMFMzIvsc5OVZKscTN5UkXzFsaY6muUAIqtKxayBUucjtUskm5qZowJITCeCb1/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.27.2.tgz", + "integrity": "sha512-VkwlCOcr0abTBGzjPXklJ92FCowG7InU8+Od9FyApdLNmn0utRYGRhw0Zno6VgE9EYr1JY4BRnuSa5f9wlR72w==", + "license": "MIT", + "dependencies": { + "tippy.js": "^6.3.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.2.tgz", + "integrity": "sha512-gmFuKi97u5f8uFc/GQs+zmezjiulZmFiDYTh3trVoLRoc2SAHOjGEB7qxdx7dsqmMN7gwiAWAEVurLKIi1lnnw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-character-count": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-character-count/-/extension-character-count-2.27.2.tgz", + "integrity": "sha512-EcQRIvbLbMDDzo7uFqXYgh1CfgedS9sYX4BllktY2OlXLPdNpwo9t8WMK/a7soESNv0Le3WZ5pNvnNhv7Z2YdA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.27.2.tgz", + "integrity": "sha512-7X9AgwqiIGXoZX7uvdHQsGsjILnN/JaEVtqfXZnPECzKGaWHeK/Ao4sYvIIIffsyZJA8k5DC7ny2/0sAgr2TuA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz", + "integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz", + "integrity": "sha512-CFhAYsPnyYnosDC4639sCJnBUnYH4Cat9qH5NZWHVvdgtDwu8GZgZn2eSzaKSYXWH1vJ9DSlCK+7UyC3SNXIBA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.27.2.tgz", + "integrity": "sha512-oEu/OrktNoQXq1x29NnH/GOIzQZm8ieTQl3FK27nxfBPA89cNoH4mFEUmBL5/OFIENIjiYG3qWpg6voIqzswNw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.27.2.tgz", + "integrity": "sha512-GUN6gPIGXS7ngRJOwdSmtBRBDt9Kt9CM/9pSwKebhLJ+honFoNA+Y6IpVyDvvDMdVNgBchiJLs6qA5H97gAePQ==", + "license": "MIT", + "dependencies": { + "tippy.js": "^6.3.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.27.2.tgz", + "integrity": "sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.27.2.tgz", + "integrity": "sha512-kSRVGKlCYK6AGR0h8xRkk0WOFGXHIIndod3GKgWU49APuIGDiXd8sziXsSlniUsWmqgDmDXcNnSzPcV7AQ8YNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.27.2.tgz", + "integrity": "sha512-iM3yeRWuuQR/IRQ1djwNooJGfn9Jts9zF43qZIUf+U2NY8IlvdNsk2wTOdBgh6E0CamrStPxYGuln3ZS4fuglw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-history": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.2.tgz", + "integrity": "sha512-+hSyqERoFNTWPiZx4/FCyZ/0eFqB9fuMdTB4AC/q9iwu3RNWAQtlsJg5230bf/qmyO6bZxRUc0k8p4hrV6ybAw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.27.2.tgz", + "integrity": "sha512-WGWUSgX+jCsbtf9Y9OCUUgRZYuwjVoieW5n6mAUohJ9/6gc6sGIOrUpBShf+HHo6WD+gtQjRd+PssmX3NPWMpg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz", + "integrity": "sha512-1OFsw2SZqfaqx5Fa5v90iNlPRcqyt+lVSjBwTDzuPxTPFY4Q0mL89mKgkq2gVHYNCiaRkXvFLDxaSvBWbmthgg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.27.2.tgz", + "integrity": "sha512-bnP61qkr0Kj9Cgnop1hxn2zbOCBzNtmawxr92bVTOE31fJv6FhtCnQiD6tuPQVGMYhcmAj7eihtvuEMFfqEPcQ==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz", + "integrity": "sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.2.tgz", + "integrity": "sha512-M7A4tLGJcLPYdLC4CI2Gwl8LOrENQW59u3cMVa+KkwG1hzSJyPsbDpa1DI6oXPC2WtYiTf22zrbq3gVvH+KA2w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.27.2.tgz", + "integrity": "sha512-elYVn2wHJJ+zB9LESENWOAfI4TNT0jqEN34sMA/hCtA4im1ZG2DdLHwkHIshj/c4H0dzQhmsS/YmNC5Vbqab/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-placeholder": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.27.2.tgz", + "integrity": "sha512-IjsgSVYJRjpAKmIoapU0E2R4E2FPY3kpvU7/1i7PUYisylqejSJxmtJPGYw0FOMQY9oxnEEvfZHMBA610tqKpg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.27.2.tgz", + "integrity": "sha512-HHIjhafLhS2lHgfAsCwC1okqMsQzR4/mkGDm4M583Yftyjri1TNA7lzhzXWRFWiiMfJxKtdjHjUAQaHuteRTZw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz", + "integrity": "sha512-Xk7nYcigljAY0GO9hAQpZ65ZCxqOqaAlTPDFcKerXmlkQZP/8ndx95OgUb1Xf63kmPOh3xypurGS2is3v0MXSA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text-style": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz", + "integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-2.27.2.tgz", + "integrity": "sha512-gPOsbAcw1S07ezpAISwoO8f0RxpjcSH7VsHEFDVuXm4ODE32nhvSinvHQjv2icRLOXev+bnA7oIBu7Oy859gWQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/pm": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz", + "integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-collab": "^1.3.1", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.2", + "prosemirror-markdown": "^1.13.1", + "prosemirror-menu": "^1.2.4", + "prosemirror-model": "^1.23.0", + "prosemirror-schema-basic": "^1.2.3", + "prosemirror-schema-list": "^1.4.1", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-trailing-node": "^3.0.0", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.37.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.27.2.tgz", + "integrity": "sha512-0EAs8Cpkfbvben1PZ34JN2Nd79Dhioynm2jML27DBbf1VWPk+FFWFGTMLUT0bu+Np5iVxio8fqV9t0mc4D6thA==", + "license": "MIT", + "dependencies": { + "@tiptap/extension-bubble-menu": "^2.27.2", + "@tiptap/extension-floating-menu": "^2.27.2", + "@types/use-sync-external-store": "^0.0.6", + "fast-deep-equal": "^3", + "use-sync-external-store": "^1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.27.2.tgz", + "integrity": "sha512-bb0gJvPoDuyRUQ/iuN52j1//EtWWttw+RXAv1uJxfR0uKf8X7uAqzaOOgwjknoCIDC97+1YHwpGdnRjpDkOBxw==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^2.27.2", + "@tiptap/extension-blockquote": "^2.27.2", + "@tiptap/extension-bold": "^2.27.2", + "@tiptap/extension-bullet-list": "^2.27.2", + "@tiptap/extension-code": "^2.27.2", + "@tiptap/extension-code-block": "^2.27.2", + "@tiptap/extension-document": "^2.27.2", + "@tiptap/extension-dropcursor": "^2.27.2", + "@tiptap/extension-gapcursor": "^2.27.2", + "@tiptap/extension-hard-break": "^2.27.2", + "@tiptap/extension-heading": "^2.27.2", + "@tiptap/extension-history": "^2.27.2", + "@tiptap/extension-horizontal-rule": "^2.27.2", + "@tiptap/extension-italic": "^2.27.2", + "@tiptap/extension-list-item": "^2.27.2", + "@tiptap/extension-ordered-list": "^2.27.2", + "@tiptap/extension-paragraph": "^2.27.2", + "@tiptap/extension-strike": "^2.27.2", + "@tiptap/extension-text": "^2.27.2", + "@tiptap/extension-text-style": "^2.27.2", + "@tiptap/pm": "^2.27.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -410,6 +877,28 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.9.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", @@ -466,6 +955,19 @@ "@types/react": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", @@ -492,6 +994,12 @@ } } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/base64-js": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", @@ -567,6 +1075,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -590,6 +1104,39 @@ "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", "license": "MIT" }, + "node_modules/dompurify": { + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -929,6 +1476,31 @@ "unicode-trie": "^2.0.0" } }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -941,6 +1513,39 @@ "loose-envify": "cli.js" } }, + "node_modules/markdown-it": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -960,6 +1565,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/pdfkit": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz", @@ -1092,6 +1703,210 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prosemirror-changeset": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-collab": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", + "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz", + "integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz", + "integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-markdown": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.5.tgz", + "integrity": "sha512-ac8trNQ01ybKDRTcfUc56LZufG3oYyU4N25qSXgp8dS0U4JtzzCj7oQlKu5v09VSmS5IseYoQ2yDkTbo7f7D8Q==", + "license": "MIT", + "dependencies": { + "@types/markdown-it": "^14.0.0", + "markdown-it": "^14.0.0", + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-menu": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.2.tgz", + "integrity": "sha512-6VgUJTYod0nMBlCaYJGhXGLu7Gt4AvcwcOq0YfJCY/6Uh+3S7UsWhpy6rJFCBFOmonq1hD8KyWOtZhkppd4YPg==", + "license": "MIT", + "dependencies": { + "crelt": "^1.0.0", + "prosemirror-commands": "^1.0.0", + "prosemirror-history": "^1.0.0", + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.11", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz", + "integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-basic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", + "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-trailing-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", + "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", + "license": "MIT", + "dependencies": { + "@remirror/core-constants": "3.0.0", + "escape-string-regexp": "^4.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.22.1", + "prosemirror-state": "^1.4.2", + "prosemirror-view": "^1.33.8" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.42.2", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.2.tgz", + "integrity": "sha512-Pdg0l5kXm8aLDquFAnQFTCITg0q44sLqBlHlpsVLD9segdOao8TOfQdAhCrCXyVgPSRr6UDDROOIWA3bIrN9YQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.8", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -1195,6 +2010,12 @@ "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/sax": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", @@ -1252,6 +2073,15 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tippy.js": { + "version": "6.3.7", + "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", + "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==", + "license": "MIT", + "dependencies": { + "@popperjs/core": "^2.9.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1272,6 +2102,12 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", @@ -1324,6 +2160,15 @@ "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", "license": "MIT" }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "8.0.16", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", @@ -1402,6 +2247,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/xmldoc": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/xmldoc/-/xmldoc-2.0.3.tgz", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ac9dba0..8d5d3e4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import { Fragment, type ChangeEvent as ReactChangeEvent, type ClipboardEvent as ReactClipboardEvent, type Dispatch, type DragEvent as ReactDragEvent, type FormEvent as ReactFormEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type SetStateAction, type WheelEvent as ReactWheelEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import './styles.css'; import { Link, NavLink, Outlet, Route, Routes, useLocation, useNavigate, useNavigationType, useParams, useSearchParams } from 'react-router-dom'; -import { ROUTES, listingPath, listingEditPath, mapPath, negotiationPath, companiesPath, adminTabPath, ADMIN_PATH_TO_TAB, type AdminTabKey } from './routes'; +import { ROUTES, listingPath, listingEditPath, mapPath, negotiationPath, priceHistoryPath, companiesPath, adminTabPath, ADMIN_PATH_TO_TAB, type AdminTabKey } from './routes'; import { ProtectedRoute } from './ProtectedRoute'; import { apiFetch, useAuth } from './auth'; import type { AuthUser, ContactPreference, PreferredLanguage, RegisterPending } from './auth'; @@ -459,6 +459,9 @@ type CityPriceAlertSubscription = { propertyType: HistoryPropertyType; marketType: HistoryMarketType; createdAt: string; + // Przelacznik powiadomien dla alertu. Pole opcjonalne - subskrypcje zapisane przed jego + // dodaniem nie maja go w localStorage, a brak wartosci traktujemy jako "wlaczone". + notificationsEnabled?: boolean; }; type ListingPriceAlertSubscription = { @@ -467,6 +470,8 @@ type ListingPriceAlertSubscription = { location: string; offerType: ApiOfferType; createdAt: string; + // Jak w alertach lokalizacji: brak wartosci traktujemy jako "powiadomienia wlaczone". + notificationsEnabled?: boolean; }; type SavedSearchMode = 'buy' | 'rent'; @@ -497,7 +502,6 @@ const CITY_PRICE_ALERTS_STORAGE_SUFFIX = 'city-price-alert-subscriptions'; const LISTING_PRICE_ALERTS_STORAGE_SUFFIX = 'listing-price-alert-subscriptions'; const CITY_PRICE_ALERT_SNAPSHOTS_STORAGE_SUFFIX = 'city-price-alert-snapshots'; const CITY_PRICE_ALERT_EVENTS_STORAGE_SUFFIX = 'city-price-alert-events'; -const CITY_PRICE_ALERT_LAST_RUN_STORAGE_SUFFIX = 'city-price-alert-last-run'; const SAVED_SEARCHES_STORAGE_SUFFIX = 'saved-searches'; const PRICE_ALERT_EVENTS_LIMIT = 40; const SAVED_SEARCHES_LIMIT = 60; @@ -574,12 +578,17 @@ function cityAlertMarketFilter(marketType: HistoryMarketType): 'wtorny' | 'pierw return null; } -function calculateCityAlertPerM2(subscription: CityPriceAlertSubscription, listings: ApiListingSummary[]): number { +// Ogloszenia pasujace do kryteriow alertu (miasto + typ nieruchomosci + rynek). +// Wspolne dla sredniej ceny za m² i dla wyboru najlepszej biezacej oferty. +function filterCityAlertListings( + subscription: CityPriceAlertSubscription, + listings: ApiListingSummary[], +): ApiListingSummary[] { const targetPropertyType = cityAlertPropertyTypeToApi(subscription.propertyType); const targetMarket = cityAlertMarketFilter(subscription.marketType); const cityQuery = subscription.city.trim() || subscription.label.split(',')[0]?.trim() || ''; - const filtered = listings.filter((item) => { + return listings.filter((item) => { if (cityQuery && !cityStartsWith(item.city, cityQuery)) { return false; } @@ -591,6 +600,24 @@ function calculateCityAlertPerM2(subscription: CityPriceAlertSubscription, listi } return item.area > 0 && item.price > 0; }); +} + +// Najlepsza oferta dla alertu: najnizsza cena za m² sposrod pasujacych ogloszen. +function findBestCityAlertOffer( + subscription: CityPriceAlertSubscription, + listings: ApiListingSummary[], +): ApiListingSummary | null { + const filtered = filterCityAlertListings(subscription, listings); + if (filtered.length === 0) { + return null; + } + return filtered.reduce((best, item) => ( + (item.price / item.area) < (best.price / best.area) ? item : best + )); +} + +function calculateCityAlertPerM2(subscription: CityPriceAlertSubscription, listings: ApiListingSummary[]): number { + const filtered = filterCityAlertListings(subscription, listings); if (filtered.length === 0) { const estimate = estimateHistoryLocationPrice(subscription.label); @@ -2807,22 +2834,22 @@ function App() { let cancelled = false; let running = false; - const runDailyCityAlertsUpdate = async () => { + // Sprawdzanie stawki za m² dla obserwowanych lokalizacji. Wczesniej bylo zamkniete + // bramka "raz na dobe" (lastRunKey === todayKey), przez co po zapisaniu pierwszego + // punktu odniesienia zadna zmiana ceny tego samego dnia nie byla juz wykrywana - + // alerty lokalizacji praktycznie nie dzialaly. Teraz chodzi cyklicznie, tak jak + // monitoring cen pojedynczych ogloszen. Zdarzenie i tak powstaje wylacznie wtedy, + // gdy srednia faktycznie sie zmieni, wiec czestsze sprawdzanie nie generuje szumu. + const runCityAlertsUpdate = async () => { if (cancelled || running) { return; } const todayKey = currentLocalDayKey(); - const lastRunKey = readUserJsonStorage(user, CITY_PRICE_ALERT_LAST_RUN_STORAGE_SUFFIX, null); - if (lastRunKey === todayKey) { - return; - } - running = true; try { const subscriptions = readUserJsonStorage(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, []); if (subscriptions.length === 0) { - writeUserJsonStorage(user, CITY_PRICE_ALERT_LAST_RUN_STORAGE_SUFFIX, todayKey); return; } @@ -2865,8 +2892,24 @@ function App() { if (incomingEvents.length > 0) { const mergedEvents = mergeCityPriceAlertEvents(incomingEvents, previousEvents); writeUserJsonStorage(user, CITY_PRICE_ALERT_EVENTS_STORAGE_SUFFIX, mergedEvents); + + // Powiadomienie tylko o spadku sredniej stawki - tak samo jak przy obserwowanym + // ogloszeniu. Klucz ma prefiks "city-price--", dzieki czemu + // usuniecie alertu potrafi posprzatac powiazane powiadomienia. + incomingEvents.forEach((event) => { + if (event.currentPerM2 < event.previousPerM2) { + const diff = event.previousPerM2 - event.currentPerM2; + notificationsCenter.notify({ + category: 'listing', + icon: 'coin', + title: 'Spadek cen w lokalizacji', + body: `Średnia cena za m² w „${event.label}” spadła o ${diff.toLocaleString('pl-PL')} zł.`, + link: 'priceAlerts', + dedupeKey: `city-price-${event.subscriptionKey}-${event.currentPerM2}`, + }); + } + }); } - writeUserJsonStorage(user, CITY_PRICE_ALERT_LAST_RUN_STORAGE_SUFFIX, todayKey); } catch { // Keep silent; updater retries on next interval. } finally { @@ -2874,9 +2917,9 @@ function App() { } }; - void runDailyCityAlertsUpdate(); + void runCityAlertsUpdate(); const intervalId = window.setInterval(() => { - void runDailyCityAlertsUpdate(); + void runCityAlertsUpdate(); }, 60000); return () => { @@ -3247,6 +3290,39 @@ function App() { }); }; + // Usuniecie i przelaczanie powiadomien z poziomu /konto/alerty-cenowe. Stan subskrypcji + // ogloszen zyje tutaj (efekt nizej zapisuje go do localStorage), wiec strona alertow + // modyfikuje go przez te funkcje zamiast pisac po magazynie na wlasna reke. + // Usuniecie alertu kasuje caly slad po nim: subskrypcje, zarejestrowane zmiany ceny, + // punkt odniesienia oraz powiadomienia w centrum - tak, jakby alertu nigdy nie bylo. + const removeListingPriceAlert = (listingId: number) => { + setListingPriceAlertSubscriptionsState((current) => current.filter((item) => item.listingId !== listingId)); + + if (user) { + const events = readUserJsonStorage(user, FAVORITE_PRICE_ALERTS_STORAGE_SUFFIX, []); + writeUserJsonStorage( + user, + FAVORITE_PRICE_ALERTS_STORAGE_SUFFIX, + events.filter((event) => event.listingId !== listingId), + ); + + const snapshots = readUserJsonStorage>(user, FAVORITE_PRICE_SNAPSHOTS_STORAGE_SUFFIX, {}); + delete snapshots[String(listingId)]; + writeUserJsonStorage(user, FAVORITE_PRICE_SNAPSHOTS_STORAGE_SUFFIX, snapshots); + } + + // Powiadomienia o zmianie ceny tego ogloszenia maja klucz "price--". + notificationsCenter.dismissByDedupePrefix(`price-${listingId}-`); + }; + + const toggleListingPriceAlertNotifications = (listingId: number) => { + setListingPriceAlertSubscriptionsState((current) => current.map((item) => ( + item.listingId === listingId + ? { ...item, notificationsEnabled: item.notificationsEnabled === false } + : item + ))); + }; + const toggleSummaryFavorite = async (listing: ApiListingSummary) => { const listingKey = favoriteListingKey(listing.id); const exists = favoriteListingsState.some((item) => item.id === listingKey); @@ -3555,7 +3631,18 @@ function App() { /> )} /> - } /> + + )} + /> } /> } /> } /> @@ -5627,8 +5714,11 @@ function AccountSidebar() { const favoritesCount = user ? readUserJsonStorage(user, FAVORITE_LISTINGS_STORAGE_SUFFIX, []).length : 0; + // Licznik obejmuje oba rodzaje alertow - lokalizacji i pojedynczych ogloszen - + // dokladnie tak, jak lista na /konto/alerty-cenowe. const priceAlertsCount = user ? readUserJsonStorage(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, []).length + + readUserJsonStorage(user, LISTING_PRICE_ALERTS_STORAGE_SUFFIX, []).length : 0; useEffect(() => { @@ -6725,22 +6815,366 @@ function AccountHelpContactPage({ ); } +// Okresy wykresu trendu ceny za m² na dole strony alertów. +const PRICE_ALERT_TREND_PERIODS = [ + { key: '6', label: '6 miesięcy', months: 6 }, + { key: '12', label: '12 miesięcy', months: 12 }, + { key: '24', label: '24 miesiące', months: 24 }, +] as const; + +type PriceAlertTrendPeriod = (typeof PRICE_ALERT_TREND_PERIODS)[number]['key']; + +// Okno czasowe, z ktorego liczymy spadki/wzrosty w pasku podsumowania. +const PRICE_ALERT_STATS_DAYS = 30; + +// Ile zdarzen pokazujemy w kolumnie "Najnowsze powiadomienia". +const PRICE_ALERT_FEED_LIMIT = 4; + +// Czas wzgledny zdarzenia cenowego ("2 godz. temu"). +function priceAlertRelativeTime(iso: string): string { + const time = new Date(iso).getTime(); + if (Number.isNaN(time)) { + return ''; + } + const minutes = Math.max(0, Math.round((Date.now() - time) / 60000)); + if (minutes < 1) { + return 'przed chwilą'; + } + if (minutes < 60) { + return `${minutes} min temu`; + } + const hours = Math.round(minutes / 60); + if (hours < 24) { + return `${hours} godz. temu`; + } + const days = Math.round(hours / 24); + return days === 1 ? 'wczoraj' : `${days} dni temu`; +} + +// Procentowa zmiana miedzy dwiema cenami; brak punktu odniesienia = 0%. +function priceAlertChangePercent(previous: number, current: number): number { + return previous > 0 ? ((current - previous) / previous) * 100 : 0; +} + +// Procent w polskim zapisie, ze znakiem (np. "-2,4%"). +function formatAlertPercent(value: number, withSign = true): string { + const sign = withSign && value > 0 ? '+' : ''; + return `${sign}${value.toFixed(1).replace('.', ',')}%`; +} + +// Etykiety osi X wykresu - kolejne miesiace wstecz od biezacego. +function priceAlertTrendLabels(months: number): string[] { + const today = historyToday(); + return Array.from({ length: months }, (_, index) => { + const date = new Date(today.getFullYear(), today.getMonth() - (months - 1 - index), 1); + return `${HISTORY_YEAR_MONTHS[date.getMonth()]} ${`${date.getFullYear()}`.slice(2)}`; + }); +} + +// Wspolny model wiersza alertu - ten sam uklad obsluguje alerty ogloszen i lokalizacji. +// Geometria wykresu ceny za m² - wspolna dla wszystkich kart alertow lokalizacji. +function buildPriceAlertChart(series: number[]) { + const width = 640; + const height = 200; + const padding = { top: 14, right: 8, bottom: 10, left: 8 }; + const min = Math.min(...series); + const max = Math.max(...series); + const span = max - min || 1; + const innerWidth = width - padding.left - padding.right; + const innerHeight = height - padding.top - padding.bottom; + const points = series.map((value, index) => ({ + x: padding.left + (series.length === 1 + ? innerWidth / 2 + : (index / (series.length - 1)) * innerWidth), + y: padding.top + innerHeight - (((value - min) / span) * innerHeight), + })); + const line = points + .map((point, index) => `${index === 0 ? 'M' : 'L'}${point.x.toFixed(1)} ${point.y.toFixed(1)}`) + .join(' '); + const first = points[0]; + const last = points[points.length - 1]; + return { + width, + height, + line, + area: `${line} L${last.x.toFixed(1)} ${height} L${first.x.toFixed(1)} ${height} Z`, + ticks: [3, 2, 1, 0].map((step) => Math.round(min + ((span * step) / 3))), + }; +} + +// Wspolny model wiersza alertu ogloszenia. +type PriceAlertRowModel = { + id: string; + createdAt: string; + thumb: string | null; + title: string; + meta: string; + subMeta: string; + hasChange: boolean; + changePercent: number; + changeLabel: string; + offerHeading: string; + offerPrice: string | null; + offerEmptyLabel: string; + offerLines: string[]; + notificationsEnabled: boolean; + ctaTo: string; + ctaLabel: string; + onToggle: () => void; + onRemove: () => void; +}; + +function PriceAlertRow({ + row, + menuOpen, + onMenuChange, +}: { + row: PriceAlertRowModel; + menuOpen: boolean; + onMenuChange: (id: string | null) => void; +}) { + return ( +
  • +
    + {!row.thumb && } +
    + +
    + {row.title} + {row.meta} + {row.subMeta} +
    + +
    + Ostatnia zmiana ceny + {row.hasChange ? ( + + + {' '}{formatAlertPercent(Math.abs(row.changePercent), false)} + + ) : ( + + )} + {row.changeLabel} +
    + +
    + {row.offerHeading} + {row.offerPrice ? ( + {row.offerPrice} + ) : ( + {row.offerEmptyLabel} + )} + {row.offerLines.map((line) => {line})} +
    + +
    + Powiadomienia + + {row.notificationsEnabled ? 'e-mail, push' : 'wyłączone'} +
    + +
    + + {row.ctaLabel} + +
    + + {/* Bez odsylacza do historii cen - ta dotyczy lokalizacji, nie pojedynczej oferty. */} + {menuOpen && ( +
    + +
    + )} +
    +
    +
  • + ); +} + +type PriceAlertLocationCardModel = { + key: string; + label: string; + meta: string; + historyTo: string; + chart: ReturnType; + currentPerM2: number; + changePercent: number; + // Czy zmiana pochodzi z realnie zarejestrowanego zdarzenia, czy z szacowanego trendu. + changeIsReal: boolean; + changeCaption: string; + notificationsEnabled: boolean; + onToggle: () => void; + onRemove: () => void; +}; + +// Alert lokalizacji jako kompaktowy kafelek z wykresem ceny za m² - wprost widac, +// czy stawka w danym miejscu spada, czy rosnie. Male karty ukladaja sie w siatke, +// wiec na ekranie miesci sie ich duzo wiecej niz pelnowymiarowych wykresow. +function PriceAlertLocationCard({ + card, + labels, + months, + menuOpen, + onMenuChange, +}: { + card: PriceAlertLocationCardModel; + labels: string[]; + months: number; + menuOpen: boolean; + onMenuChange: (id: string | null) => void; +}) { + // Identyfikator gradientu musi byc unikalny - na stronie jest wiele wykresow. + const fillId = `priceAlertsChartFill-${card.key}`; + const menuId = `location:${card.key}`; + + return ( +
    +
    +
    +

    {card.label}

    +

    {card.meta}

    +
    +
    + + {menuOpen && ( +
    + {/* Historia cen otwiera sie od razu na lokalizacji z tego alertu. */} + Zobacz historię cen + +
    + )} +
    +
    + +
    + {formatM2(card.currentPerM2)} + + + {' '}{formatAlertPercent(card.changePercent)} + +
    + + + + + + + + + + + + + + + {/* Zmiana zarejestrowana przez alert; do czasu pierwszej - szacunek z trendu. */} +
    + + {card.changeIsReal ? ( + <> + + {' '}Cena {card.changePercent <= 0 ? 'spadła' : 'wzrosła'} o {formatAlertPercent(Math.abs(card.changePercent), false)} + + ) : ( + 'Czeka na pierwszą zmianę' + )} + + {card.changeCaption} +
    + +
    + + {card.notificationsEnabled ? 'e-mail, push' : 'Powiadomienia wyłączone'} +
    +
    + ); +} + function AccountPriceAlertsPage({ user, onOpenListing, + listingAlerts, + onRemoveListingAlert, + onToggleListingAlertNotifications, }: { user: AuthUser | null; onOpenListing: (id: number) => void; + listingAlerts: ListingPriceAlertSubscription[]; + onRemoveListingAlert: (listingId: number) => void; + onToggleListingAlertNotifications: (listingId: number) => void; }) { - const navigate = useNavigate(); + const { dismissByDedupePrefix } = useNotifications(); const [cityAlerts, setCityAlerts] = useState([]); const [priceEvents, setPriceEvents] = useState([]); const [cityPriceEvents, setCityPriceEvents] = useState([]); + const [listings, setListings] = useState([]); + const [trendPeriod, setTrendPeriod] = useState('12'); + const [openMenuKey, setOpenMenuKey] = useState(null); + // Alerty lokalizacji i zdarzenia cenowe dopisuje w tle glowny komponent App, wiec widok + // co 10 s siega po aktualny stan zapisany w localStorage uzytkownika. Alerty pojedynczych + // ogloszen przychodza propsem - ich zrodlem prawdy jest stan w App. useEffect(() => { if (!user) { setCityAlerts([]); setPriceEvents([]); + setCityPriceEvents([]); return; } @@ -6755,261 +7189,411 @@ function AccountPriceAlertsPage({ return () => window.clearInterval(intervalId); }, [user]); - const latestEvent = priceEvents[0] ?? null; - const latestCityEvent = cityPriceEvents[0] ?? null; - const latestEventType: 'listing' | 'city' | null = (() => { - if (latestEvent && latestCityEvent) { - return new Date(latestEvent.changedAt).getTime() >= new Date(latestCityEvent.changedAt).getTime() - ? 'listing' - : 'city'; - } - if (latestEvent) { - return 'listing'; - } - if (latestCityEvent) { - return 'city'; - } - return null; - })(); - const cityInsights = useMemo(() => cityAlerts.map((subscription, index) => { - const market = HISTORY_PRICE_MARKETS.find((item) => item.key === subscription.key) ?? findHistoryMarket(subscription.label); - const district = historyShortMarketLabel(market ?? resolveHistoryMarket(subscription.label)); - const rawPrice = market?.apartmentM2 ?? estimateHistoryLocationPrice(subscription.label).price; - const price = Math.round(rawPrice * HISTORY_PROPERTY_MULTIPLIERS[subscription.propertyType] * HISTORY_MARKET_MULTIPLIERS[subscription.marketType]); - const delta = (market?.trend24 ?? (0.03 + ((index % 4) * 0.008))) * 100; - return { - key: subscription.key, - district, - price, - delta, - barWidth: Math.max(28, 78 - (index * 9)), + // Ogloszenia z API daja aktualna cene i zdjecie dla alertow zalozonych na ofertach. + useEffect(() => { + let cancelled = false; + apiFetch('/listings') + .then((data) => { + if (!cancelled) { + setListings(data); + } + }) + .catch(() => { + if (!cancelled) { + setListings([]); + } + }); + return () => { + cancelled = true; }; - }), [cityAlerts]); + }, []); - const cityAveragePrice = cityInsights.length > 0 - ? Math.round(cityInsights.reduce((sum, item) => sum + item.price, 0) / cityInsights.length) - : 0; - const cityAverageDelta = cityInsights.length > 0 - ? cityInsights.reduce((sum, item) => sum + item.delta, 0) / cityInsights.length - : 0; - - const activeAlerts = cityAlerts.map((subscription) => ({ - title: subscription.label, - meta: `${subscription.propertyType} • ${subscription.marketType}`, - key: subscription.key, - })); - - const removeAlert = (key: string) => { - if (!user) { + // Zamkniecie menu kliknieciem poza nim. + useEffect(() => { + if (!openMenuKey) { return; } - setCityAlerts((current) => { - const next = current.filter((item) => item.key !== key); + const close = () => setOpenMenuKey(null); + window.addEventListener('click', close); + return () => window.removeEventListener('click', close); + }, [openMenuKey]); + + const persistCityAlerts = (next: CityPriceAlertSubscription[]) => { + if (user) { writeUserJsonStorage(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, next); - return next; - }); + } + setCityAlerts(next); }; - const activeListingEvent = latestEventType === 'listing' ? latestEvent : null; - const activeCityEvent = latestEventType === 'city' ? latestCityEvent : null; - const hasPriceDrop = activeListingEvent - ? activeListingEvent.currentPrice < activeListingEvent.previousPrice - : activeCityEvent - ? activeCityEvent.currentPerM2 < activeCityEvent.previousPerM2 - : false; - const latestDelta = activeListingEvent - ? activeListingEvent.currentPrice - activeListingEvent.previousPrice - : activeCityEvent - ? activeCityEvent.currentPerM2 - activeCityEvent.previousPerM2 - : 0; - const latestDeltaPercent = (activeListingEvent && activeListingEvent.previousPrice > 0) - ? (Math.abs(latestDelta) / activeListingEvent.previousPrice) * 100 - : (activeCityEvent && activeCityEvent.previousPerM2 > 0) - ? (Math.abs(latestDelta) / activeCityEvent.previousPerM2) * 100 + // Jak przy alertach ogloszen: kasujemy tez zarejestrowane zmiany i punkt odniesienia, + // zeby po usunieciu alertu nie zostal po nim slad w historii zmian. + const removeCityAlert = (key: string) => { + persistCityAlerts(cityAlerts.filter((item) => item.key !== key)); + + if (user) { + const remainingEvents = cityPriceEvents.filter((event) => event.subscriptionKey !== key); + writeUserJsonStorage(user, CITY_PRICE_ALERT_EVENTS_STORAGE_SUFFIX, remainingEvents); + setCityPriceEvents(remainingEvents); + + // Klucz snapshotu to "||", wiec zdejmujemy wszystkie warianty tej lokalizacji. + const snapshots = readUserJsonStorage>( + user, + CITY_PRICE_ALERT_SNAPSHOTS_STORAGE_SUFFIX, + {}, + ); + Object.keys(snapshots) + .filter((snapshotKey) => snapshotKey === key || snapshotKey.startsWith(`${key}|`)) + .forEach((snapshotKey) => delete snapshots[snapshotKey]); + writeUserJsonStorage(user, CITY_PRICE_ALERT_SNAPSHOTS_STORAGE_SUFFIX, snapshots); + } + + // Powiadomienia o spadkach w tej lokalizacji maja klucz "city-price--". + dismissByDedupePrefix(`city-price-${key}-`); + setOpenMenuKey(null); + }; + + const toggleCityAlertNotifications = (key: string) => { + persistCityAlerts(cityAlerts.map((item) => ( + item.key === key ? { ...item, notificationsEnabled: item.notificationsEnabled === false } : item + ))); + }; + + const chartMonths = PRICE_ALERT_TREND_PERIODS.find((item) => item.key === trendPeriod)?.months ?? 12; + const chartLabels = useMemo(() => priceAlertTrendLabels(chartMonths), [chartMonths]); + + // Alerty lokalizacji sledza srednia cene ofertowa mieszkan NA SPRZEDAZ - taki sam + // zbior, na jakim pracuje monitoring w tle (/listings?offerType=SALE). + const saleListings = useMemo(() => listings.filter((item) => item.offerType === 'SALE'), [listings]); + + // --- Alerty lokalizacji (dodane w historii cen) jako karty z wykresem --- + const locationCards: PriceAlertLocationCardModel[] = useMemo(() => cityAlerts + .map((subscription) => { + const market = HISTORY_PRICE_MARKETS.find((item) => item.key === subscription.key) + ?? resolveHistoryMarket(subscription.label); + // Stawka liczona z realnych ogloszen na sprzedaz - dokladnie ta sama, ktora + // porownuje monitoring w tle, wiec naglowek karty zgadza sie z zarejestrowana zmiana. + const currentPerM2 = calculateCityAlertPerM2(subscription, saleListings); + const modelSeries = generateHistorySeries( + market, + subscription.propertyType, + subscription.marketType, + '2026', + chartMonths, + ); + // Przeskalowanie modelu tak, by konczyl sie na realnej stawce - inaczej wykres + // pokazywalby inne pieniadze niz liczba nad nim. + const anchor = modelSeries[modelSeries.length - 1] || 1; + const series = modelSeries.map((value) => Math.max(1, Math.round((value / anchor) * currentPerM2))); + const previousPerM2 = series.length > 1 ? series[series.length - 2] : currentPerM2; + + const lastEvent = cityPriceEvents.find((event) => event.subscriptionKey === subscription.key) ?? null; + const eventAmount = lastEvent ? lastEvent.currentPerM2 - lastEvent.previousPerM2 : 0; + + return { + key: subscription.key, + // Nazwa wybrana przez uzytkownika w historii cen. Dopasowany rynek sluzy tylko + // do wyliczenia serii - przy nazwie spoza slownika potrafi wskazac inna dzielnice. + label: subscription.label, + meta: `${subscription.propertyType} • ${subscription.marketType}`, + historyTo: priceHistoryPath(subscription.label, subscription.propertyType, subscription.marketType), + chart: buildPriceAlertChart(series), + currentPerM2, + // Gdy alert zarejestrowal juz zmiane - pokazujemy ja, bo to fakt. Zanim to nastapi + // pokazujemy szacowana zmiane miesiac do miesiaca, zeby kafelek nie byl pusty. + changePercent: lastEvent + ? priceAlertChangePercent(lastEvent.previousPerM2, lastEvent.currentPerM2) + : priceAlertChangePercent(previousPerM2, currentPerM2), + changeIsReal: Boolean(lastEvent), + changeCaption: lastEvent + ? `${formatM2(Math.abs(eventAmount))} ${eventAmount <= 0 ? 'taniej' : 'drożej'} • ${priceAlertRelativeTime(lastEvent.changedAt)}` + : 'Szacowana zmiana miesiąc do miesiąca', + notificationsEnabled: subscription.notificationsEnabled !== false, + onToggle: () => toggleCityAlertNotifications(subscription.key), + onRemove: () => removeCityAlert(subscription.key), + }; + }), [cityAlerts, cityPriceEvents, chartMonths, saleListings]); + + // --- Alerty pojedynczych ogloszen ("Utworz alert ceny" na ofercie) --- + const listingRowModels: PriceAlertRowModel[] = useMemo(() => listingAlerts + .map((subscription) => { + const listing = listings.find((item) => item.id === subscription.listingId) ?? null; + const lastEvent = priceEvents.find((event) => event.listingId === subscription.listingId) ?? null; + const changeAmount = lastEvent ? lastEvent.currentPrice - lastEvent.previousPrice : 0; + return { + id: `listing:${subscription.listingId}`, + createdAt: subscription.createdAt, + thumb: listing?.coverPhoto ?? null, + title: listing?.title ?? subscription.title, + // Dane z API maja pierwszenstwo - ogloszenie moglo zmienic miasto albo typ oferty + // po zalozeniu alertu. Zapis w subskrypcji sluzy tylko jako awaryjny opis. + meta: listing + ? `${listing.offerType === 'RENT' ? 'Wynajem' : 'Sprzedaż'} • ${[listing.city, listing.district].filter(Boolean).join(', ')}` + : `${subscription.offerType === 'RENT' ? 'Wynajem' : 'Sprzedaż'} • ${subscription.location}`, + subMeta: listing + ? `Cena za m²: ${formatM2(listing.price / Math.max(1, listing.area))}` + : 'Ogłoszenie nie jest teraz dostępne', + hasChange: Boolean(lastEvent), + changePercent: lastEvent ? priceAlertChangePercent(lastEvent.previousPrice, lastEvent.currentPrice) : 0, + changeLabel: lastEvent + ? `${formatPln(Math.abs(changeAmount))} ${changeAmount <= 0 ? 'taniej' : 'drożej'}` + : 'Czeka na pierwszą zmianę', + offerHeading: 'Aktualna cena', + offerPrice: listing ? formatPln(listing.price) : null, + offerEmptyLabel: 'Brak danych', + offerLines: listing + ? [ + `${Math.round(listing.area)} m² • ${roomsLabel(listing.rooms)}`, + listingRelativeDate(listing.createdAt), + ] + : ['Oferta została zdjęta lub wstrzymana'], + notificationsEnabled: subscription.notificationsEnabled !== false, + ctaTo: listingPath(subscription.listingId), + ctaLabel: 'Zobacz ofertę', + onToggle: () => onToggleListingAlertNotifications(subscription.listingId), + onRemove: () => { + onRemoveListingAlert(subscription.listingId); + // Magazyn sprzata App; tutaj zdejmujemy zdarzenia od razu, zeby feed + // nie pokazywal ich jeszcze przez chwile do najblizszego odswiezenia. + setPriceEvents((current) => current.filter((event) => event.listingId !== subscription.listingId)); + setOpenMenuKey(null); + }, + }; + }) + .sort((left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime()), + [listingAlerts, listings, priceEvents, onRemoveListingAlert, onToggleListingAlertNotifications]); + + const alertsCount = cityAlerts.length + listingAlerts.length; + + // Zmiany cen z ostatnich 30 dni - wspolne zrodlo dla kafli podsumowania. + const recentChanges = useMemo(() => { + const cutoff = Date.now() - (PRICE_ALERT_STATS_DAYS * 24 * 60 * 60 * 1000); + const withinWindow = (iso: string) => { + const time = new Date(iso).getTime(); + return !Number.isNaN(time) && time >= cutoff; + }; + return [ + ...priceEvents + .filter((event) => withinWindow(event.changedAt)) + .map((event) => priceAlertChangePercent(event.previousPrice, event.currentPrice)), + ...cityPriceEvents + .filter((event) => withinWindow(event.changedAt)) + .map((event) => priceAlertChangePercent(event.previousPerM2, event.currentPerM2)), + ]; + }, [priceEvents, cityPriceEvents]); + + const dropCount = recentChanges.filter((value) => value < 0).length; + const riseCount = recentChanges.filter((value) => value > 0).length; + const averageChange = recentChanges.length > 0 + ? recentChanges.reduce((sum, value) => sum + value, 0) / recentChanges.length : 0; + const notificationFeed = useMemo(() => ([ + ...priceEvents.map((event) => ({ + id: event.id, + changedAt: event.changedAt, + percent: priceAlertChangePercent(event.previousPrice, event.currentPrice), + title: event.title, + listingId: event.listingId as number | null, + })), + ...cityPriceEvents.map((event) => ({ + id: event.id, + changedAt: event.changedAt, + percent: priceAlertChangePercent(event.previousPerM2, event.currentPerM2), + title: event.label, + listingId: null as number | null, + })), + ]) + .sort((left, right) => new Date(right.changedAt).getTime() - new Date(left.changedAt).getTime()) + .slice(0, PRICE_ALERT_FEED_LIMIT), [priceEvents, cityPriceEvents]); + return ( -
    +
    - Strona główna Powiadomienia i alerty + Strona główna Alerty cenowe
    -

    Powiadomienia i alerty

    -

    Śledź zmiany cen w wybranych lokalizacjach i otrzymuj powiadomienia, które pomogą Ci podjąć lepszą decyzję.

    +

    + Alerty cenowe + {alertsCount} +

    +

    Śledź zmiany cen mieszkań i bądź na bieżąco. Otrzymuj powiadomienia, gdy ceny spadną lub wzrosną.

    -
    -
    -
    - Alert cenowy -

    - {latestEventType - ? (hasPriceDrop ? 'Cena spadła!' : 'Cena wzrosła!') - : 'Brak nowych zmian cen'} -

    -

    - {activeListingEvent - ? `${activeListingEvent.offerType === 'RENT' ? 'Wynajem' : 'Kupno'}: ${activeListingEvent.title}` - : activeCityEvent - ? `${activeCityEvent.label} • ${activeCityEvent.propertyType} • ${activeCityEvent.marketType}` - : 'Dodaj oferty do ulubionych lub alerty lokalizacji, aby śledzić zmiany cen.'} -

    - - - {' '} - {activeListingEvent - ? activeListingEvent.location - : activeCityEvent - ? [activeCityEvent.city, activeCityEvent.region].filter(Boolean).join(', ') - : 'Wybierz mieszkania lub lokalizacje, które Cię interesują'} - -
    - - {activeListingEvent - ? formatPln(activeListingEvent.currentPrice) - : activeCityEvent - ? formatM2(activeCityEvent.currentPerM2) - : '0 zł'} +
    +
    + +
    + Alerty aktywne + {alertsCount} + {alertsCount > 0 ? 'Wszystkie działają poprawnie' : 'Nie masz jeszcze alertów'} +
    +
    +
    + +
    + Spadki cen ({PRICE_ALERT_STATS_DAYS} dni) + {dropCount} + Zmian w dół względem poprzedniej ceny +
    +
    +
    + +
    + Wzrosty cen ({PRICE_ALERT_STATS_DAYS} dni) + {riseCount} + Zmian w górę względem poprzedniej ceny +
    +
    +
    + +
    + Średnia zmiana cen + 0 ? 'is-rise' : undefined}> + {recentChanges.length > 0 ? formatAlertPercent(averageChange) : '0,0%'} - - {activeListingEvent - ? formatPln(activeListingEvent.previousPrice) - : activeCityEvent - ? formatM2(activeCityEvent.previousPerM2) - : '0 zł'} - - - {latestEventType - ? `${latestDelta <= 0 ? 'Spadek' : 'Wzrost'} o ${activeListingEvent ? formatPln(Math.abs(latestDelta)) : formatM2(Math.abs(latestDelta))} (${latestDeltaPercent.toFixed(1).replace('.', ',')}%)` - : 'Brak porównań'} - + W skali ostatnich {PRICE_ALERT_STATS_DAYS} dni
    -
    - {activeListingEvent ? ( - <> - Powierzchnia: {activeListingEvent.area} - - Cena za m²: - {formatM2(Math.round(activeListingEvent.currentPrice / Math.max(1, Number(activeListingEvent.area.replace(/[^\d,]/g, '').replace(',', '.')) || 1)))} - - - - ) : ( - <> - Typ: {activeCityEvent ? activeCityEvent.propertyType : '-'} - Rynek: {activeCityEvent ? activeCityEvent.marketType : '-'} - - )} -
    -
    - -
    - - +
    -
    -
    -
    -

    Średnia zmiana ceny za m² w alertach

    -

    {cityInsights.length > 0 ? `Na podstawie ${cityInsights.length} aktywnych lokalizacji` : 'Dodaj lokalizację, aby aktywować wykres'}

    -
    -
    - {cityInsights.length > 0 ? formatM2(cityAveragePrice) : '0 zł/m²'} - {cityInsights.length > 0 ? `${cityAverageDelta >= 0 ? '+' : ''}${cityAverageDelta.toFixed(1).replace('.', ',')}%` : '0,0%'} + {/* Alerty zalozone przyciskiem "Utworz alert ceny" na konkretnym ogloszeniu. */} +
    +
    +
    +

    Twoje alerty

    +

    Obserwowane ogłoszenia — powiadomimy Cię, gdy zmieni się ich cena

    - -
    + + Dodaj z ogłoszenia + + -
    -
    -

    Zmiana ceny za m² w dzielnicach

    -

    Ostatnie 12 miesięcy

    -
    -
    - {cityInsights.length > 0 ? cityInsights.slice(0, 5).map((item) => ( -
    - {item.district} - - {formatM2(item.price)} - {item.delta >= 0 ? '+' : ''}{item.delta.toFixed(1).replace('.', ',')}% -
    - )) : ( -
    - Brak danych - - 0 zł/m² - 0,0% -
    - )} + {listingRowModels.length > 0 ? ( +
      + {listingRowModels.map((row) => ( + + ))} +
    + ) : ( +
    + +
    + Nie obserwujesz jeszcze żadnego ogłoszenia +

    Wejdź na dowolną ofertę i kliknij „Utwórz alert ceny”, a damy Ci znać, gdy jej cena się zmieni.

    +
    + Przeglądaj ogłoszenia
    - Dodaj kolejne lokalizacje -
    + )} +
    -
    +
    + {/* Alerty lokalizacji z historii cen - kazdy jako wykres ceny za m². */} +
    -

    Twoje alerty

    -
    -
    - {activeAlerts.length > 0 ? activeAlerts.map((item) => ( - + ))}
    - Usuń - - )) : ( - -
    - Brak aktywnych alertów - Przejdź do historii cen i dodaj pierwszą lokalizację. -
    - Dodaj + )} + + Dodaj lokalizację - )} -
    - Zarządzaj alertami -
    +
    + + + {locationCards.length > 0 ? ( +
    + {locationCards.map((card) => ( + + ))} +
    + ) : ( +
    + +
    + Nie śledzisz jeszcze żadnej lokalizacji +

    Wybierz miasto lub dzielnicę w historii cen, a pokażemy tu wykres ceny za m² i damy znać, gdy stawka drgnie.

    +
    + Przejdź do historii cen +
    + )} +
    + +
    @@ -17450,6 +18034,7 @@ function historyShortMarketLabel(market: PriceHistoryMarket) { function PriceHistoryPage({ user }: { user: AuthUser | null }) { const navigate = useNavigate(); + const [historyParams] = useSearchParams(); const [locationInput, setLocationInput] = useState(''); const [selectedMarket, setSelectedMarket] = useState(null); const [propertyType, setPropertyType] = useState('Mieszkanie'); @@ -17607,6 +18192,25 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) { applyMarket(resolveHistoryMarket(place.label, place.sublabel)); }; + // Wejscie z adresu z parametrami (np. "Zobacz historie cen" przy alercie lokalizacji) + // otwiera stronę od razu na wybranym miejscu, zamiast na pustym formularzu. + useEffect(() => { + const label = historyParams.get('lokalizacja'); + if (!label) { + return; + } + const typeParam = historyParams.get('typ'); + if (typeParam && typeParam in HISTORY_PROPERTY_MULTIPLIERS) { + setPropertyType(typeParam as HistoryPropertyType); + } + const marketParam = historyParams.get('rynek'); + if (marketParam && marketParam in HISTORY_MARKET_MULTIPLIERS) { + setMarketType(marketParam as HistoryMarketType); + } + applyMarket(resolveHistoryMarket(label)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [historyParams]); + const applyLocation = () => { if (locationQuery.length < 2) { skipNextFetch.current = true; @@ -22918,6 +23522,14 @@ function Icon({ name }: { name: string }) { ); + // Odpowiednik 'trend' dla spadkow - osie zostaja na miejscu, opada sama linia. + case 'trend-down': + return ( + + ); case 'target': return ( i ma wlasny styl. */ +.price-alerts-row-main > span { + color: #7f90a6; + display: block; + font-size: 11px; + font-weight: 700; + margin-top: 3px; +} + +.price-alerts-row-cell small { + color: #8b9bb0; + display: block; + font-size: 10px; + font-weight: 800; + margin-bottom: 5px; +} + +.price-alerts-row-cell strong { + align-items: center; + color: #16283f; + display: flex; + font-size: 17px; + font-weight: 900; + gap: 5px; +} + +.price-alerts-row-cell strong svg { + height: 13px; + width: 13px; +} + +.price-alerts-row-cell strong.is-drop { color: #12915a; } +.price-alerts-row-cell strong.is-rise { color: #d8455e; } +.price-alerts-row-cell strong.is-idle { color: #a3b1c2; font-size: 15px; } + +.price-alerts-row-cell span { + color: #7f90a6; + display: block; + font-size: 11px; + font-weight: 700; + margin-top: 3px; +} + +/* Przelacznik powiadomien */ + +.price-alerts-switch { + background: #d7dfe9; + border: 0; + border-radius: 999px; + cursor: pointer; + display: block; + height: 22px; + padding: 0; + position: relative; + transition: background 0.18s ease; + width: 40px; +} + +.price-alerts-switch.is-on { + background: #16a765; +} + +.price-alerts-switch i { + background: #ffffff; + border-radius: 50%; + box-shadow: 0 1px 3px rgba(15, 30, 50, 0.28); + height: 18px; + left: 2px; + position: absolute; + top: 2px; + transition: transform 0.18s ease; + width: 18px; +} + +.price-alerts-switch.is-on i { + transform: translateX(18px); +} + +.price-alerts-switch:focus-visible { + outline: 2px solid #16a765; + outline-offset: 2px; +} + +.price-alerts-row-actions { + align-items: center; + display: flex; + gap: 6px; +} + +.price-alerts-row-cta { + align-items: center; + background: #ffffff; + border: 1px solid #b9e0cb; + border-radius: 9px; + color: #12915a; + display: inline-flex; + font-size: 12px; + font-weight: 850; + gap: 7px; + padding: 10px 15px; + text-decoration: none; + transition: background 0.16s ease; + white-space: nowrap; +} + +.price-alerts-row-cta:hover { + background: #f1fbf5; +} + +.price-alerts-row-cta svg { + height: 12px; + width: 12px; +} + +.price-alerts-row-menu { + position: relative; +} + +.price-alerts-row-menu > button { + background: transparent; border: 0; border-radius: 8px; + color: #0b1220; + cursor: pointer; + height: 32px; + width: 28px; +} + +.price-alerts-row-menu > button:hover { + background: #eef2f7; + color: #000000; +} + +/* Kropki rysowane sa obrysem (stroke), wiec o ich grubosci decyduje stroke-width, + nie sam kolor - bez pogrubienia nawet czarne pozostawaly ledwo widoczne. */ +.price-alerts-row-menu > button svg { + height: 16px; + stroke-width: 3.4; + width: 16px; +} + +.price-alerts-row-menu-panel { + background: #ffffff; + border: 1px solid #e2e9f1; + border-radius: 10px; + box-shadow: 0 14px 34px rgba(18, 40, 70, 0.14); + display: grid; + min-width: 176px; + overflow: hidden; + position: absolute; + right: 0; + top: calc(100% + 6px); + z-index: 20; +} + +.price-alerts-row-menu-panel a, +.price-alerts-row-menu-panel button { + background: transparent; + border: 0; + color: #33465e; + cursor: pointer; + font-size: 12px; + font-weight: 750; + padding: 10px 13px; + text-align: left; + text-decoration: none; +} + +.price-alerts-row-menu-panel a:hover, +.price-alerts-row-menu-panel button:hover { + background: #f5f8fb; +} + +.price-alerts-row-menu-panel button { + border-top: 1px solid #eef2f7; + color: #d8455e; +} + +/* Pusty stan listy */ + +.price-alerts-empty { + align-items: center; + background: #ffffff; + border: 1px dashed #d6e1ec; + border-radius: 14px; + display: grid; + gap: 16px; + grid-template-columns: auto minmax(0, 1fr) auto; + padding: 26px 22px; +} + +.price-alerts-empty > span { + align-items: center; + background: #e4f7ed; + border-radius: 50%; + color: #13a163; + display: inline-flex; + height: 46px; + justify-content: center; + width: 46px; +} + +.price-alerts-empty strong { + color: #16283f; + font-size: 15px; + font-weight: 900; +} + +.price-alerts-empty p { + color: #7f90a6; + font-size: 12px; + font-weight: 650; + margin: 4px 0 0; +} + +.price-alerts-empty > a { + align-items: center; + background: #16a765; + border-radius: 9px; color: #ffffff; display: inline-flex; font-size: 12px; - font-weight: 900; + font-weight: 850; gap: 7px; - height: 35px; - justify-content: center; + padding: 11px 18px; + text-decoration: none; + white-space: nowrap; } -.price-alerts-grid { +.price-alerts-empty > a svg { + height: 12px; + width: 12px; +} + +/* --- Dolna sekcja: wykres + feed --- */ + +.price-alerts-bottom { display: grid; - gap: 12px; - grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + grid-template-columns: minmax(0, 1.85fr) minmax(280px, 1fr); + margin-top: 22px; } -.price-alerts-card { +.price-alerts-feed { background: #ffffff; - border: 1px solid #dfe8f1; - border-radius: 12px; - display: grid; - gap: 10px; - padding: 14px; + border: 1px solid #e6ecf3; + border-radius: 16px; + padding: 18px 20px; } -.price-alerts-card header h3 { - color: #182a45; - font-size: 18px; +/* Lewa kolumna dolnej sekcji: naglowek + karty lokalizacji, jedna pod druga. */ +.price-alerts-locations { + display: grid; + gap: 14px; +} + +.price-alerts-locations > header { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: space-between; +} + +.price-alerts-locations > header h2 { + color: #16283f; + font-size: 17px; + font-weight: 900; margin: 0; } -.price-alerts-card header p { - color: #6b7d93; +.price-alerts-locations > header p { + color: #8b9bb0; font-size: 11px; - font-weight: 800; - margin: 5px 0 0; + font-weight: 700; + margin: 4px 0 0; } -.price-alerts-kpi { +.price-alerts-locations-tools { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +/* Siatka kompaktowych kafelkow - im szersza kolumna, tym wiecej wykresow w rzedzie. */ +.price-alerts-locations-grid { + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fill, minmax(228px, 1fr)); +} + +.price-alerts-location-card { + background: #ffffff; + border: 1px solid #e6ecf3; + border-radius: 12px; + padding: 11px 12px; +} + +.price-alerts-location-card > header { + align-items: start; + display: flex; + gap: 6px; + justify-content: space-between; + margin-bottom: 8px; +} + +.price-alerts-location-card h3 { + color: #16283f; + font-size: 13px; + font-weight: 900; + margin: 0; +} + +.price-alerts-location-card header p { + color: #8b9bb0; + font-size: 10px; + font-weight: 750; + margin: 2px 0 0; +} + +.price-alerts-location-figure { align-items: baseline; display: flex; - gap: 12px; + flex-wrap: wrap; + gap: 6px; } -.price-alerts-kpi strong { - color: #162a44; - font-size: 30px; - line-height: 1; -} - -.price-alerts-kpi span { - color: #14a35f; - font-size: 18px; +.price-alerts-location-figure strong { + color: #16283f; + font-size: 17px; font-weight: 900; } -.price-alerts-chart { - height: 132px; +.price-alerts-location-figure span { + align-items: center; + display: inline-flex; + font-size: 12px; + font-weight: 900; + gap: 3px; +} + +.price-alerts-location-figure span svg { + height: 11px; + width: 11px; +} + +.price-alerts-location-figure span.is-drop { color: #12915a; } +.price-alerts-location-figure span.is-rise { color: #d8455e; } + +.price-alerts-mini-chart { + display: block; + height: 52px; + margin-top: 6px; width: 100%; } +.price-alerts-mini-axis { + color: #a3b1c2; + display: flex; + font-size: 9px; + font-weight: 750; + gap: 6px; + justify-content: space-between; + margin-top: 3px; +} + +.price-alerts-mini-axis span:nth-child(2) { + color: #c2ccd8; +} + +.price-alerts-period { + background: #f2f6fa; + border-radius: 9px; + display: inline-flex; + gap: 2px; + padding: 3px; +} + +.price-alerts-period button { + background: transparent; + border: 0; + border-radius: 7px; + color: #6c7f96; + cursor: pointer; + font-size: 11px; + font-weight: 800; + padding: 7px 12px; + white-space: nowrap; +} + +.price-alerts-period button.is-active { + background: #ffffff; + box-shadow: 0 2px 6px rgba(18, 40, 70, 0.1); + color: #16283f; +} + .price-alerts-chart-line { fill: none; - stroke: #26b36a; + stroke: #16a765; stroke-linecap: round; stroke-linejoin: round; - stroke-width: 3; + stroke-width: 2.4; + vector-effect: non-scaling-stroke; } -.price-alerts-districts { - display: grid; - gap: 9px; -} - -.price-alerts-districts div { - align-items: center; - display: grid; - gap: 8px; - grid-template-columns: 88px minmax(90px, 1fr) auto auto; -} - -.price-alerts-districts strong { - color: #233852; - font-size: 12px; -} - -.price-alerts-district-bar { - background: #edf1f6; - border-radius: 999px; - display: block; - height: 8px; - overflow: hidden; -} - -.price-alerts-district-bar i { - background: linear-gradient(90deg, #25b269 0%, #5ac987 100%); - border-radius: 999px; - display: block; - height: 100%; -} - -.price-alerts-districts em { - color: #2d435e; - font-size: 12px; - font-style: normal; - font-weight: 820; -} - -.price-alerts-districts b { - color: #15a35f; - font-size: 12px; -} - -.price-alerts-link { - background: transparent; - border: 0; - color: #18a661; - font-size: 12px; - font-weight: 900; - justify-self: start; - padding: 0; -} - -.price-alerts-active-list { - align-content: start; -} - -.price-alerts-active-items { - display: grid; - gap: 8px; -} - -.price-alerts-active-items button, .price-alerts-active-items a { - align-items: center; - background: #fbfcfe; - border: 1px solid #e2eaf3; +/* Zarejestrowana zmiana ceny za m² - to ona wyzwala powiadomienie dla alertu. */ +.price-alerts-event { + border: 1px solid #e6ecf3; border-radius: 9px; - color: #21364f; - display: grid; - gap: 8px; - grid-template-columns: minmax(0, 1fr) auto; - padding: 9px; - text-align: left; + margin-top: 9px; + padding: 8px 9px; } -.price-alerts-active-items strong { - color: #1d3049; - display: block; - font-size: 12px; -} +.price-alerts-event.is-drop { background: #f1fbf5; border-color: #d6efe1; } +.price-alerts-event.is-rise { background: #fdf1f3; border-color: #f6d9df; } +.price-alerts-event.is-idle { background: #f7f9fc; } -.price-alerts-active-items small { - color: #6d7f96; +.price-alerts-event small { + color: #8b9bb0; display: block; font-size: 10px; - font-weight: 750; + font-weight: 800; + margin-bottom: 4px; +} + +.price-alerts-event strong { + align-items: center; + display: flex; + font-size: 12px; + font-weight: 900; + gap: 4px; + margin: 0; +} + +.price-alerts-event svg { + height: 11px; + width: 11px; +} + +.price-alerts-event.is-drop strong { color: #10794c; } +.price-alerts-event.is-rise strong { color: #c53f56; } +.price-alerts-event.is-idle strong { color: #7f90a6; font-size: 12px; } + +.price-alerts-event span { + color: #7f90a6; + display: block; + font-size: 10px; + font-weight: 700; margin-top: 2px; } -.price-alerts-active-items span { - background: #e9f8ef; - border-radius: 999px; - color: #119659; - font-size: 10px; - font-weight: 900; - padding: 2px 8px; +/* Przelacznik powiadomien w karcie lokalizacji. */ +.price-alerts-location-actions { + align-items: center; + border-top: 1px solid #eef2f7; + display: flex; + gap: 8px; + margin-top: 9px; + padding-top: 9px; } -.price-alerts-manage { - background: #f5f8fc; - border: 1px solid #dce6f1; - border-radius: 8px; - color: #334a64; - font-size: 12px; +.price-alerts-location-actions span { + color: #6c7f96; + font-size: 10px; + font-weight: 750; +} + +/* Przelacznik w kafelku jest mniejszy niz w wierszach ogloszen. */ +.price-alerts-location-actions .price-alerts-switch { + height: 18px; + width: 33px; +} + +.price-alerts-location-actions .price-alerts-switch i { + height: 14px; + width: 14px; +} + +.price-alerts-location-actions .price-alerts-switch.is-on i { + transform: translateX(15px); +} + +/* --- Feed powiadomien --- */ + +.price-alerts-feed { + align-content: start; + display: grid; +} + +.price-alerts-feed > header { + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 12px; +} + +.price-alerts-feed h2 { + color: #16283f; + font-size: 16px; font-weight: 900; - height: 35px; + margin: 0; +} + +.price-alerts-feed > header a { + color: #12915a; + font-size: 11px; + font-weight: 850; + text-decoration: none; +} + +.price-alerts-feed ul { + display: grid; + gap: 4px; + list-style: none; + margin: 0; + padding: 0; +} + +.price-alerts-feed li > button, +.price-alerts-feed li > div { + align-items: center; + background: transparent; + border: 0; + border-radius: 10px; + display: grid; + gap: 11px; + grid-template-columns: auto minmax(0, 1fr) auto; + padding: 9px 8px; + text-align: left; + width: 100%; +} + +.price-alerts-feed li > button { + cursor: pointer; +} + +.price-alerts-feed li > button:hover { + background: #f5f8fb; +} + +.price-alerts-feed-icon { + align-items: center; + border-radius: 50%; + display: inline-flex; + height: 30px; + justify-content: center; + width: 30px; +} + +.price-alerts-feed-icon svg { + height: 13px; + width: 13px; +} + +.price-alerts-feed-icon.is-drop { background: #e4f7ed; color: #13a163; } +.price-alerts-feed-icon.is-rise { background: #fdecef; color: #e04b62; } + +.price-alerts-feed-text strong { + color: #16283f; + display: block; + font-size: 12px; + font-weight: 850; +} + +.price-alerts-feed-text > span { + color: #8b9bb0; + display: block; + font-size: 11px; + font-weight: 700; + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.price-alerts-feed em { + color: #a3b1c2; + font-size: 10px; + font-style: normal; + font-weight: 750; + white-space: nowrap; +} + +.price-alerts-feed-empty { + color: #8b9bb0; + font-size: 12px; + font-weight: 650; + line-height: 1.55; + margin: 0; +} + +/* --- Responsywnosc --- */ + +@media (max-width: 1500px) { + .price-alerts-row { + grid-template-columns: 88px minmax(140px, 1.2fr) minmax(110px, 0.9fr) minmax(120px, 1fr) auto; + } + + .price-alerts-row-switch { + display: none; + } + + .price-alerts-row-thumb { + width: 88px; + } } @media (max-width: 1300px) { - .price-alerts-grid { + .price-alerts-bottom { grid-template-columns: 1fr; } - .price-alerts-hero { + .price-alerts-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + row-gap: 18px; + } +} + +@media (max-width: 900px) { + .price-alerts-row { + grid-template-columns: 72px minmax(0, 1fr); + row-gap: 12px; + } + + .price-alerts-row-thumb { + height: 56px; + width: 72px; + } + + .price-alerts-row-cell, + .price-alerts-row-actions { + grid-column: 1 / -1; + } + + .price-alerts-row-actions { + justify-content: space-between; + } + + .price-alerts-empty { grid-template-columns: 1fr; + text-align: center; } - .price-alerts-heading h1 { - font-size: 31px; + .price-alerts-empty > span { + justify-self: center; } - .price-alerts-settings { - justify-self: start; + .price-alerts-empty > a { + justify-content: center; } } @media (max-width: 740px) { - .price-alerts-pricing strong { - font-size: 30px; - } - - .price-alerts-pricing em { - font-size: 15px; - } - - .price-alerts-pricing span { - font-size: 12px; - } - - .price-alerts-districts div { + .price-alerts-heading { grid-template-columns: 1fr; } + + .price-alerts-stats { + grid-template-columns: 1fr; + padding: 16px; + } + + .price-alerts-list-section > header { + align-items: start; + flex-direction: column; + gap: 10px; + } + + .price-alerts-locations > header { + flex-direction: column; + } + + .price-alerts-period { + width: 100%; + } + + .price-alerts-period button { + flex: 1; + } } .settings-head h1 {