From b47199c57c153bf7e6248714acad509d0e31fa4f Mon Sep 17 00:00:00 2001 From: kouloumos Date: Sun, 15 Feb 2026 16:02:47 +0200 Subject: [PATCH 01/20] docs: add consultations implementation guide Documents the consultation feature as actually implemented, covering the regulation JSON schema, dual-view frontend, comment system, geo-editor, and business rules. --- docs/guides/consultations.md | 195 +++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 docs/guides/consultations.md diff --git a/docs/guides/consultations.md b/docs/guides/consultations.md new file mode 100644 index 00000000..5e4b0fff --- /dev/null +++ b/docs/guides/consultations.md @@ -0,0 +1,195 @@ +# Public Consultations + +## Concept + +A regulation viewer and public feedback platform that enables municipalities to publish consultations on regulatory texts. Citizens can read structured regulation documents, explore geographic areas on an interactive map, leave comments on specific articles or locations, and upvote other comments. The system is driven by a JSON regulation file that defines chapters, articles, and geosets with geographic geometries. + +## Architectural Overview + +The consultation feature operates as a JSON-driven, dual-view interface: + +1. **Regulation JSON**: Each consultation points to a remote JSON file (`jsonUrl`) that defines the entire regulation structure — chapters, articles, geographic areas, cross-references, and definitions. The schema is defined in [`json-schemas/regulation.schema.json`](../../json-schemas/regulation.schema.json). +2. **Database Layer**: Prisma stores consultation metadata (name, end date, active status), comments, and upvotes. Comments are entity-scoped — tied to a specific chapter, article, geoset, or geometry by `entityType` + `entityId`. +3. **Frontend Layer**: A `ConsultationViewer` client component orchestrates two views — a Document View (chapters/articles with markdown content) and a Map View (Mapbox-powered geographic visualization). A floating action button toggles between them. +4. **Comment System**: Authenticated users can leave HTML-rich comments on any entity. Comments support upvoting and trigger email notifications to the municipality's contact address. +5. **Admin Geo-Editor**: Administrators can draw missing geometries directly on the map when regulation text defines areas textually but lacks GeoJSON coordinates. Edits are stored in localStorage and exported as a complete updated regulation JSON. + +The consultation feature is gated per-city via the `consultationsEnabled` flag on the City model. + +## Regulation JSON Structure + +The regulation JSON file is the core data source for each consultation. It follows a schema defined in [`json-schemas/regulation.schema.json`](../../json-schemas/regulation.schema.json). + +**Root properties:** +- `title`, `summary` — regulation metadata (summary supports markdown with `{REF:id}` and `{DEF:id}` references) +- `contactEmail`, `ccEmails` — where citizen feedback emails are sent +- `sources` — array of source documents (`{title, url, description?}`) +- `definitions` — dictionary of terms that can be referenced via `{DEF:id}` in markdown +- `defaultVisibleGeosets` — which geosets are visible on the map by default +- `regulation` — array of `Chapter` and `GeoSet` items (the main content) + +**Chapter** (`type: "chapter"`): +- `num`, `id`, `title`, `summary`, `preludeBody` (intro markdown before articles) +- `articles[]` — each with `num`, `id`, `title`, `summary`, `body` (markdown) + +**GeoSet** (`type: "geoset"`): +- `id`, `name`, `description`, `color` (hex) +- `geometries[]` — individual geographic shapes + +**Geometry** types: +- `point` — single location with GeoJSON Point +- `circle` — point with radius +- `polygon` — area boundary with GeoJSON Polygon +- `derived` — computed from other geosets via `buffer` (zone around source) or `difference` (subtract geosets from base) operations + +**Cross-Reference System:** +Markdown content can include `{REF:id}` to link to any chapter, article, geoset, or geometry. When clicked, the viewer navigates to the referenced entity (switching views if needed). `{DEF:id}` links to term definitions shown inline. + +## Sequence Diagram + +```mermaid +sequenceDiagram + participant Citizen as Citizen + participant Frontend as React Frontend + participant API as Next.js API + participant DB as PostgreSQL + participant JSON as Regulation JSON (remote) + participant Email as Resend Email + + Note over Citizen, Email: Viewing a Consultation + Citizen->>Frontend: Opens /[cityId]/consultation/[id] + Frontend->>DB: getConsultationById(cityId, id) + DB-->>Frontend: Consultation metadata + comments + Frontend->>JSON: Fetch regulation JSON from jsonUrl + JSON-->>Frontend: Full regulation data + Frontend->>Frontend: Render ConsultationViewer (document + map) + + Note over Citizen, Email: Navigating Between Views + Citizen->>Frontend: Clicks floating toggle button + Frontend->>Frontend: Switch between Document View and Map View + Citizen->>Frontend: Clicks {REF:id} link in article text + Frontend->>Frontend: Navigate to referenced entity (auto-switch view if needed) + + Note over Citizen, Email: Commenting + Citizen->>Frontend: Writes comment on an article + Frontend->>API: POST /api/consultations/[id]/comments + API->>DB: Verify consultation is active + API->>JSON: Fetch regulation JSON, validate entity exists + API->>DB: Create ConsultationComment record + API->>Email: Send notification to municipality contactEmail + Email-->>API: Email sent + API-->>Frontend: Comment created + Frontend->>Frontend: Update comment list + + Note over Citizen, Email: Upvoting + Citizen->>Frontend: Clicks upvote on a comment + Frontend->>API: POST /api/consultations/comments/[commentId]/upvote + API->>DB: Toggle ConsultationCommentUpvote (upsert/delete) + API-->>Frontend: Updated upvote count +``` + +## Key Component Pointers + +* **Data Models**: + * `Consultation`: [`prisma/schema.prisma`](../../prisma/schema.prisma) (id, name, jsonUrl, endDate, isActive, cityId) + * `ConsultationComment`: [`prisma/schema.prisma`](../../prisma/schema.prisma) (entity-scoped via entityType + entityId) + * `ConsultationCommentUpvote`: [`prisma/schema.prisma`](../../prisma/schema.prisma) (unique constraint on userId + commentId) + * `City.consultationsEnabled`: Feature flag gating the consultations tab + +* **JSON Schema**: + * Regulation schema: [`json-schemas/regulation.schema.json`](../../json-schemas/regulation.schema.json) (JSON Schema Draft 7 defining chapters, articles, geosets, geometries, references, definitions) + +* **Database Functions**: + * `getConsultationsForCity()`: [`src/lib/db/consultations.ts`](../../src/lib/db/consultations.ts) (active consultations only, ordered by end date) + * `getAllConsultationsForCity()`: [`src/lib/db/consultations.ts`](../../src/lib/db/consultations.ts) (all consultations including inactive, used on listing page) + * `getConsultationById()`: [`src/lib/db/consultations.ts`](../../src/lib/db/consultations.ts) (single consultation with computed active status) + * `addConsultationComment()`: [`src/lib/db/consultations.ts`](../../src/lib/db/consultations.ts) (validates entity exists in regulation JSON, sends email) + * `toggleCommentUpvote()`: [`src/lib/db/consultations.ts`](../../src/lib/db/consultations.ts) (toggle on/off, returns new count) + * `deleteConsultationComment()`: [`src/lib/db/consultations.ts`](../../src/lib/db/consultations.ts) (owner-only, cascades to upvotes) + * `isConsultationActive()`: [`src/lib/db/consultations.ts`](../../src/lib/db/consultations.ts) (checks isActive flag AND end date with timezone awareness) + +* **API Endpoints**: + * `GET/POST /api/consultations/[id]/comments`: [`src/app/api/consultations/[id]/comments/route.ts`](../../src/app/api/consultations/%5Bid%5D/comments/route.ts) (list and create comments) + * `POST /api/consultations/comments/[commentId]/upvote`: [`src/app/api/consultations/comments/[commentId]/upvote/route.ts`](../../src/app/api/consultations/comments/%5BcommentId%5D/upvote/route.ts) (toggle upvote) + * `DELETE /api/consultations/comments/[commentId]/delete`: [`src/app/api/consultations/comments/[commentId]/delete/route.ts`](../../src/app/api/consultations/comments/%5BcommentId%5D/delete/route.ts) (owner-only deletion) + +* **Pages**: + * Consultations listing: [`src/app/[locale]/(city)/[cityId]/(other)/(tabs)/consultations/page.tsx`](../../src/app/%5Blocale%5D/(city)/%5BcityId%5D/(other)/(tabs)/consultations/page.tsx) (all consultations for a city) + * Consultation detail: [`src/app/[locale]/(city)/[cityId]/consultation/[id]/page.tsx`](../../src/app/%5Blocale%5D/(city)/%5BcityId%5D/consultation/%5Bid%5D/page.tsx) (viewer with document + map) + * Comments print view: [`src/app/[locale]/(city)/[cityId]/consultation/[id]/comments/page.tsx`](../../src/app/%5Blocale%5D/(city)/%5BcityId%5D/consultation/%5Bid%5D/comments/page.tsx) (print-friendly comment summary) + * Layout: [`src/app/[locale]/(city)/[cityId]/consultation/[id]/layout.tsx`](../../src/app/%5Blocale%5D/(city)/%5BcityId%5D/consultation/%5Bid%5D/layout.tsx) (header, footer, feature-flag check) + +* **Frontend Components** (all under `src/components/consultations/`): + * `ConsultationViewer`: Master orchestrator — manages view state (document/map), URL hash navigation, chapter expansion, reference click handling + * `ConsultationHeader`: Title, status badge (Active/Inactive), end date, comment count + * `ConsultationDocument`: Renders chapters/articles with expand/collapse, AI summary cards, sources list + * `ChapterView` / `ArticleView`: Individual chapter and article renderers with comment counts, permalinks, collapsible content + * `MarkdownContent`: Renders markdown with `{REF:id}` and `{DEF:id}` pattern handling as interactive links + * `ConsultationMap`: Mapbox map with geoset rendering, layer controls, detail panel, derived geometry computation (buffer/difference) + * `LayerControlsPanel` / `LayerControlsButton`: Sidebar for toggling geoset/geometry visibility with checkbox tree UI + * `DetailPanel`: Side sheet showing selected geoset/geometry info with description, textual definition, and comments + * `GeoSetItem` / `GeometryItem`: Tree items in layer controls with checkboxes, color swatches, and comment counts + * `CommentSection`: Rich text editor (ReactQuill), authentication check, comment display with upvotes and delete + * `CommentsOverviewSheet`: Modal listing all comments with sort options (recent/likes), entity type badges, navigation + * `AISummaryCard`: Collapsible card for AI-generated summaries on chapters/articles + * `SourcesList`: Regulation source documents and contact information + * `PermalinkButton`: Copy-to-clipboard link for any entity + * `DocumentNavigation`: Sticky sidebar with chapter/article outline + * `ViewToggleButton`: Floating button to switch between document and map views + * `EditingToolsPanel`: Admin drawing tools for map geometry editing + * `PrintButton`: Triggers native print dialog on comments page + +* **City-Level Component**: + * `CityConsultations`: [`src/components/cities/CityConsultations.tsx`](../../src/components/cities/CityConsultations.tsx) (card grid listing for city consultations tab) + +* **Types**: + * `RegulationData`, `Chapter`, `Article`, `GeoSet`, `Geometry`, etc.: [`src/components/consultations/types.ts`](../../src/components/consultations/types.ts) + +* **Email**: + * Template: [`src/lib/email/templates/consultation-comment.tsx`](../../src/lib/email/templates/consultation-comment.tsx) (React Email HTML template with entity permalink) + * Sender: [`src/lib/email/consultation.ts`](../../src/lib/email/consultation.ts) (sends via Resend to contactEmail + ccEmails) + +## Business Rules & Assumptions + +### Feature Gating +1. Consultations are only visible for cities where `consultationsEnabled` is `true` +2. The consultation listing page and detail page both check this flag + +### Active Status +1. A consultation is active when **both** `isActive` is `true` in the database **and** `endDate` has not passed +2. End date comparison is timezone-aware using the city's timezone (via `date-fns-tz`) +3. Inactive consultations are visible on the listing page but comments are disabled + +### Comments +1. Only authenticated users can create comments +2. Comments are entity-scoped: each comment targets a specific `entityType` (CHAPTER, ARTICLE, GEOSET, GEOMETRY) and `entityId` +3. Before saving, the API fetches the regulation JSON and validates the target entity actually exists +4. Comment body is validated: non-empty, max 5000 characters +5. HTML in comments is sanitized to allow only safe tags (`p`, `strong`, `em`, `a`, `ul`, `ol`, `li`) +6. Comments can only be deleted by their author +7. Upvotes use a unique constraint (`userId`, `commentId`) for toggle behavior +8. Each new comment triggers an email notification to the municipality (`contactEmail` from the regulation JSON, CC'd to `ccEmails`) + +### Regulation JSON +1. The regulation JSON is fetched from a remote URL stored in `Consultation.jsonUrl` +2. It is fetched at page load on the detail page and cached for entity validation in comment creation +3. The schema supports both static geometries (with GeoJSON coordinates) and derived geometries (computed via buffer/difference operations) +4. Geometries may have a `textualDefinition` but null `geojson` — the admin geo-editor addresses this gap + +### Map & Geo-Editor +1. The map uses Mapbox GL with custom styling for different geosets (each has a `color`) +2. `defaultVisibleGeosets` in the regulation JSON controls initial map layer visibility +3. Derived geometries are computed client-side using Turf.js operations +4. The admin geo-editor stores drawn geometries in browser `localStorage` until exported +5. Export produces a complete updated `regulation.json` merging local edits with original data +6. Only super-administrators can access editing mode + +### Navigation +1. URL hash anchors (`#chapter-1`, `#article-3`, `#geoset-prohibited_areas`) enable deep linking to specific entities +2. `{REF:id}` links in markdown content navigate to the referenced entity, switching between document and map views as needed +3. The comments print page orders comments by document structure (chapters/articles first, then geosets/geometries) + +### Multi-Tenancy +1. All consultation data is city-scoped — queries always filter by `cityId` +2. Comments store both `consultationId` and `cityId` for multi-tenant isolation +3. Database indexes optimize queries on `(cityId, isActive)` and `(consultationId, entityType, entityId)` From 3ac54b4336e2aa4cc8e51f5d94507d81166c5958 Mon Sep 17 00:00:00 2001 From: kouloumos Date: Sun, 15 Feb 2026 16:02:59 +0200 Subject: [PATCH 02/20] feat: add geocoding script for regulation addresses Geocodes point geometries in regulation JSON files using Google Geocoding API. Finds points with textualDefinition but no geojson coordinates, geocodes each address scoped to Athens, and fills in the geojson field. Supports --dry-run, --force, and --delay options. --- scripts/geocode-regulation-addresses.ts | 321 ++++++++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 scripts/geocode-regulation-addresses.ts diff --git a/scripts/geocode-regulation-addresses.ts b/scripts/geocode-regulation-addresses.ts new file mode 100644 index 00000000..e7bc2a64 --- /dev/null +++ b/scripts/geocode-regulation-addresses.ts @@ -0,0 +1,321 @@ +#!/usr/bin/env tsx + +/** + * Geocode addresses in a regulation JSON file using Google Geocoding API. + * + * Finds all point geometries with a textualDefinition but no geojson coordinates, + * geocodes each address scoped to Athens, and fills in the geojson field. + * + * Usage: + * npx tsx scripts/geocode-regulation-addresses.ts + * + * Options: + * --dry-run Preview what would be geocoded without making API calls + * --force Re-geocode even if geojson already exists + * --delay=N Delay between API calls in ms (default: 200) + */ + +import fs from 'fs'; +import path from 'path'; +import axios from 'axios'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY; + +// Athens center coordinates for biasing geocoding results +const ATHENS_CENTER = { lat: 37.9838, lng: 23.7275 }; +// Bounding box for Athens municipality (SW corner, NE corner) +const ATHENS_BOUNDS = { + south: 37.94, + west: 23.68, + north: 38.02, + east: 23.79, +}; + +interface GeoJSONPoint { + type: 'Point'; + coordinates: [number, number]; // [lng, lat] +} + +interface Geometry { + type: string; + name: string; + id: string; + description?: string; + textualDefinition?: string; + geojson: GeoJSONPoint | null; +} + +interface GeoSet { + type: 'geoset'; + id: string; + name: string; + geometries: Geometry[]; +} + +interface RegulationData { + regulation: Array; + [key: string]: unknown; +} + +interface GeocodingResult { + geometryId: string; + address: string; + status: 'success' | 'zero_results' | 'error'; + coordinates?: [number, number]; + formattedAddress?: string; + error?: string; +} + +async function geocodeAddress( + address: string, + geosetName: string, +): Promise<{ + coordinates: [number, number]; + formattedAddress: string; +} | null> { + // Build a search query scoped to Athens + // Append "Αθήνα" (Athens) to help the geocoder scope results + const query = `${address}, ${geosetName}, Αθήνα, Ελλάδα`; + + const response = await axios.get( + 'https://maps.googleapis.com/maps/api/geocode/json', + { + params: { + address: query, + key: GOOGLE_API_KEY, + language: 'el', + region: 'gr', + // Bias results toward Athens + bounds: `${ATHENS_BOUNDS.south},${ATHENS_BOUNDS.west}|${ATHENS_BOUNDS.north},${ATHENS_BOUNDS.east}`, + }, + timeout: 10000, + }, + ); + + if (response.data.status === 'ZERO_RESULTS') { + return null; + } + + if (response.data.status !== 'OK') { + throw new Error( + `Google Geocoding API error: ${response.data.status} - ${response.data.error_message || 'Unknown error'}`, + ); + } + + const result = response.data.results[0]; + if (!result?.geometry?.location) { + return null; + } + + const { lat, lng } = result.geometry.location; + + // Verify the result is within Athens bounds (with some margin) + const margin = 0.02; // ~2km margin + if ( + lat < ATHENS_BOUNDS.south - margin || + lat > ATHENS_BOUNDS.north + margin || + lng < ATHENS_BOUNDS.west - margin || + lng > ATHENS_BOUNDS.east + margin + ) { + console.warn( + ` ⚠ Result outside Athens bounds: ${result.formatted_address} (${lat}, ${lng})`, + ); + return null; + } + + return { + coordinates: [lng, lat], // GeoJSON uses [lng, lat] + formattedAddress: result.formatted_address, + }; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function geocodeRegulation( + filePath: string, + options: { dryRun: boolean; force: boolean; delayMs: number }, +): Promise { + const absolutePath = path.resolve(filePath); + if (!fs.existsSync(absolutePath)) { + console.error(`Error: File not found at ${absolutePath}`); + process.exit(1); + } + + const regulationData: RegulationData = JSON.parse( + fs.readFileSync(absolutePath, 'utf-8'), + ); + + // Collect all geometries that need geocoding + const toGeocode: Array<{ + geometry: Geometry; + geosetName: string; + geosetId: string; + }> = []; + + for (const item of regulationData.regulation) { + if (item.type !== 'geoset') continue; + const geoset = item as GeoSet; + + for (const geometry of geoset.geometries) { + if (geometry.type !== 'point') continue; + + // Skip if already has coordinates (unless --force) + if (geometry.geojson && !options.force) continue; + + // Need either textualDefinition or name to geocode + const address = geometry.textualDefinition || geometry.name; + if (!address) continue; + + toGeocode.push({ + geometry, + geosetName: geoset.name, + geosetId: geoset.id, + }); + } + } + + console.log(`\nFound ${toGeocode.length} geometries to geocode.\n`); + + if (toGeocode.length === 0) { + console.log('Nothing to geocode. All point geometries already have coordinates.'); + return; + } + + if (options.dryRun) { + console.log('DRY RUN - Would geocode these addresses:\n'); + for (const { geometry, geosetName } of toGeocode) { + const address = geometry.textualDefinition || geometry.name; + console.log(` [${geosetName}] ${geometry.id}: ${address}`); + } + return; + } + + // Geocode each address + const results: GeocodingResult[] = []; + let successCount = 0; + let failCount = 0; + + for (let i = 0; i < toGeocode.length; i++) { + const { geometry, geosetName } = toGeocode[i]; + const address = geometry.textualDefinition || geometry.name; + + process.stdout.write( + `[${i + 1}/${toGeocode.length}] ${geosetName} > ${address}...`, + ); + + try { + const result = await geocodeAddress(address, geosetName); + + if (result) { + geometry.geojson = { + type: 'Point', + coordinates: result.coordinates, + }; + successCount++; + console.log(` ✓ ${result.formattedAddress}`); + results.push({ + geometryId: geometry.id, + address, + status: 'success', + coordinates: result.coordinates, + formattedAddress: result.formattedAddress, + }); + } else { + failCount++; + console.log(' ✗ ZERO_RESULTS'); + results.push({ + geometryId: geometry.id, + address, + status: 'zero_results', + }); + } + } catch (error) { + failCount++; + const errorMsg = + error instanceof Error ? error.message : 'Unknown error'; + console.log(` ✗ ERROR: ${errorMsg}`); + results.push({ + geometryId: geometry.id, + address, + status: 'error', + error: errorMsg, + }); + } + + // Rate limiting + if (i < toGeocode.length - 1) { + await delay(options.delayMs); + } + } + + // Save updated regulation JSON + fs.writeFileSync(absolutePath, JSON.stringify(regulationData, null, 2)); + + // Print summary + console.log('\n' + '='.repeat(60)); + console.log('GEOCODING SUMMARY'); + console.log('='.repeat(60)); + console.log(`Total: ${toGeocode.length}`); + console.log(`Success: ${successCount}`); + console.log(`Failed: ${failCount}`); + console.log(`Updated file: ${absolutePath}`); + + // List failures for manual review + const failures = results.filter((r) => r.status !== 'success'); + if (failures.length > 0) { + console.log(`\nFailed addresses (${failures.length}) — use admin geo-editor to fix:`); + for (const f of failures) { + console.log(` - ${f.geometryId}: ${f.address} (${f.status}${f.error ? ': ' + f.error : ''})`); + } + + // Save failures report + const reportPath = absolutePath.replace('.json', '-geocode-failures.json'); + fs.writeFileSync(reportPath, JSON.stringify(failures, null, 2)); + console.log(`\nFailure report saved to: ${reportPath}`); + } +} + +// --- CLI --- +function main() { + const args = process.argv.slice(2); + const flags = args.filter((a) => a.startsWith('--')); + const positional = args.filter((a) => !a.startsWith('--')); + + if (positional.length !== 1) { + console.log( + 'Usage: npx tsx scripts/geocode-regulation-addresses.ts [options] ', + ); + console.log('\nOptions:'); + console.log(' --dry-run Preview what would be geocoded without making API calls'); + console.log(' --force Re-geocode even if geojson already exists'); + console.log(' --delay=N Delay between API calls in ms (default: 200)'); + process.exit(1); + } + + if (!GOOGLE_API_KEY) { + console.error('Error: GOOGLE_API_KEY environment variable is required'); + console.error('Set it in your .env file or export it in your shell.'); + process.exit(1); + } + + const dryRun = flags.includes('--dry-run'); + const force = flags.includes('--force'); + const delayFlag = flags.find((f) => f.startsWith('--delay=')); + const delayMs = delayFlag ? parseInt(delayFlag.split('=')[1], 10) : 200; + + geocodeRegulation(positional[0], { dryRun, force, delayMs }) + .then(() => { + console.log('\nDone.'); + }) + .catch((error) => { + console.error('\nFatal error:', error); + process.exit(1); + }); +} + +main(); From 009825305f078317487b570be668586b3c78ec05 Mon Sep 17 00:00:00 2001 From: kouloumos Date: Sun, 15 Feb 2026 16:03:08 +0200 Subject: [PATCH 03/20] feat: add cooking oil regulation generation script --- scripts/generate-cooking-oil-regulation.ts | 441 +++++++++++++++++++++ 1 file changed, 441 insertions(+) create mode 100644 scripts/generate-cooking-oil-regulation.ts diff --git a/scripts/generate-cooking-oil-regulation.ts b/scripts/generate-cooking-oil-regulation.ts new file mode 100644 index 00000000..8c25f5af --- /dev/null +++ b/scripts/generate-cooking-oil-regulation.ts @@ -0,0 +1,441 @@ +#!/usr/bin/env tsx + +/** + * Generates the regulation JSON for the Athens cooking oil collection bin consultation. + * Data extracted from the municipal PDF document. + * + * Usage: npx tsx scripts/generate-cooking-oil-regulation.ts + */ + +import fs from 'fs'; +import path from 'path'; + +// Addresses per Δημοτική Κοινότητα, extracted from the PDF +const communities: Array<{ num: number; color: string; addresses: string[] }> = [ + { + num: 1, + color: '#E63946', + addresses: [ + 'ΣΚΟΥΦΑ 55-57', + 'Π. ΙΩΑΚΕΙΜ 42', + 'ΠΛΟΥΤΑΡΧΟΥ 13', + 'ΚΑΡΝΕΑΔΟΥ 29 & ΠΛΟΥΤΑΡΧΟΥ', + 'ΣΚΟΥΦΑ & ΓΡΙΒΕΩΝ', + 'ΣΠΕΥΣΙΠΠΟΥ & ΧΑΡΙΤΟΣ', + 'ΗΡΟΔΟΤΟΥ & ΛΕΒΕΝΤΗ', + 'ΞΕΝΟΚΡΑΤΟΥΣ ΕΝ. 17-19', + 'ΜΗΤΡΟΠΟΛΕΩΣ 48', + 'ΚΟΛΟΚΟΤΡΩΝΗ & ΒΑΣΙΛΙΚΗΣ', + 'ΑΘΗΝΑΣ 16 (ΜΑΣΟΥΤΗΣ)', + 'ΑΙΟΛΟΥ & ΑΝΔΡΙΑΝΟΥ', + 'ΠΛ. ΚΑΝΙΓΓΟΣ', + 'ΙΠΠΟΚΡΑΤΟΥΣ & ΔΙΔΟΤΟΥ', + 'ΜΠΕΝΑΚΗ & ΒΑΛΤΕΤΣΙΟΥ', + 'ΠΛ. ΚΑΡΑΒΕΛΑ', + 'ΧΑΤΖ. ΜΕΞΗ & ΣΙΣΙΝΗ', + 'ΔΙΟΧΑΡΟΥΣ & ΟΥΜΠΛΙΑΝΗΣ', + 'ΕΡΜΟΥ & ΑΡΙΩΝΟΣ', + 'ΠΛΑΤΕΙΑ ΚΟΥΜΟΥΝΔΟΥΡΟΥ (ΕΝΑΝΤΙ ΕΥΡΙΠΙΔΟΥ)', + 'ΔΗΛΙΓΙΑΝΝΗ 1-3 (ΕΝΑΝΤΙ ΠΛΑΤΕΙΑΣ ΚΑΡΑΪΣΚΑΚΗ)', + 'ΠΛΑΤΩΜΑ WYNDHAM GRAND HOTEL (ΑΧΙΛΛΕΩΣ)', + 'ΠΛΑΤΕΙΑ ΗΡΩΩΝ', + 'ΒΕΙΚΟΥ & ΔΡΑΚΟΥ', + 'ΔΗΜΗΤΡΑΚΟΠΟΥΛΟΥ & ΔΡΑΚΟΥ', + 'ΦΑΛΗΡΟΥ & ΔΡΑΚΟΥ', + 'ΒΕΙΚΟΥ & ΟΛΥΜΠΙΟΥ', + 'ΔΗΜΗΤΡΑΚΟΠΟΥΛΟΥ & ΟΛΥΜΠΙΟΥ', + 'ΒΕΙΚΟΥ 1', + 'ΦΑΡΩΝ & ΑΓΙΟΥ ΠΑΥΛΟΥ', + 'ΑΧΑΡΝΩΝ & ΛΙΟΣΙΩΝ', + 'ΑΧΑΡΝΩΝ & ΑΒΕΡΩΦ', + 'ΑΧΑΡΝΩΝ 39', + 'ΚΑΡΟΛΟΥ 26', + 'ΟΔΟΣ ΑΣΚΛΗΠΙΟΥ & ΤΣΙΜΙΣΚΗ', + 'ΟΔΟΣ ΔΕΙΝΟΚΡΑΤΟΥΣ (Ν.Ν.Α.)', + 'ΒΑΣ. ΑΛΕΞΑΝΔΡΟΥ & ΒΡΑΣΙΔΑ', + 'ΜΑΡΑΣΛΗ & ΠΑΤΕΡΑ', + ], + }, + { + num: 2, + color: '#457B9D', + addresses: [ + 'ΒΟΛΤΑΙΡΟΥ & ΝΤΕΚΑΡΤ', + 'ΚΑΣΟΜΟΥΛΗ & ΜΑΣΤΡΑΧΑ', + 'ΚΑΣΟΜΟΥΛΗ 104', + 'ΚΑΣΟΜΟΥΛΗ & Ρ. ΠΥΩ', + 'ΧΕΛΝΤΡΑΙΧ & ΦΡΕΙΔ. ΣΜΙΘ (LIDL)', + 'ΑΣΠΡΟΓΕΡΑΚΑ & ΡΟΥΜΠΕΣΗ', + 'ΦΡΑΝΤΖΗ & ΘΑΡΥΠΟΥ', + 'ΥΜΗΤΤΟΥ & ΙΦΙΚΡΑΤΟΥΣ', + 'ΠΛ. ΠΛΥΤΑ (ΑΡΥΒΒΟΥ)', + 'ΠΡΟΦ. ΗΛΙΑΣ', + 'ΥΜΗΤΤΟΥ & ΚΑΦΑΝΤΑΡΗ', + 'ΜΕΛΑΝΙΠΠΙΔΟΥ & ΣΑΡΩΓΛΟΥ', + 'ΑΝΑΠΑΥΣΕΩΣ & ΑΡΔΗΤΤΟΥ', + 'ΠΛ. ΒΑΡΝΑΒΑ & ΜΕΛΙΣΣΟΥ', + 'ΑΡΧΙΜΗΔΟΥΣ 14', + 'ΑΜΥΝΤΑ & ΑΡΡΙΑΝΟΥ', + 'ΙΦΙΚΡΑΤΟΥΣ 7 & ΥΜΗΤΤΟΥ', + 'ΕΥΤΥΧΙΔΟΥ 32', + 'ΒΑΣ. ΑΛΕΞΑΝΔΡΟΥ & ΒΡΑΣΙΔΑ', + 'ΠΛ. ΑΜΦΙΚΡΑΤΟΥΣ', + 'ΥΜΗΤΤΟΥ & ΧΡΕΜΩΝΙΔΟΥ', + 'ΠΛ. ΒΑΡΝΑΒΑ', + 'ΠΛ. ΠΡΟΦ. ΗΛΙΑ', + 'ΤΙΜΟΣΘΕΝΟΥΣ & ΣΠΙΝΘΑΡΟΥ', + 'ΙΠΠΑΡΧΟΥ & ΣΩΣΤΡΑΤΟΥ', + 'ΒΡΕΣΘΕΝΗΣ & ΙΓΓΛΕΣΗ', + 'ΡΗΓΙΛΛΗΣ-ΛΕΣΧΗ ΑΞΙΩΜΑΤΙΚΩΝ', + ], + }, + { + num: 3, + color: '#2A9D8F', + addresses: [ + 'ΠΕΛΛΗΣ & ΚΟΡΥΤΣΑΣ', + 'ΜΕΛΕΝΙΚΟΥ & ΠΡΟΦΗΤΟΥ ΔΑΝΙΗΛ', + 'ΑΙΓΑΛΕΩ & ΑΓΙΟΥ ΠΟΛΥΚΑΡΠΟΥ (ΤΡΙΓΩΝΑΚΙ)', + 'ΠΛΑΤΕΙΑ ΔΟΥΡΟΥΤΗ', + 'ΠΛΑΤΑΙΩΝ 51 (MY MARKET)', + 'ΑΓΙΩΝ ΑΣΩΜΑΤΩΝ ΠΛΑΤΕΙΑ (ΔΙΠΛΑ ΣΤΟΥΣ ΒΥΘΙΖΟΜΕΝΟΥΣ)', + 'ΠΛΑΤΕΙΑ ΚΕΡΑΜΕΙΚΟΥ', + 'ΣΤΡΑΤΟΝΙΚΗΣ & ΕΛΑΣΙΔΩΝ', + 'ΠΛΑΤΕΙΑ ΗΟΥΣ (2 ΚΑΔΟΥΣ)', + 'ΠΛΑΤΕΙΑ ΑΓΙΑΣ ΑΙΚΑΤΕΡΙΝΗΣ ΕΠΙ ΤΗΣ ΗΟΥΣ (2 ΚΑΔΟΥΣ)', + 'ΘΕΣΣΑΛΟΝΙΚΗΣ ΣΤΟ ΣΤΑΘΜΟ ΝΕΦΕΛΗΣ 1 (2 ΚΑΔΟΥΣ)', + 'ΠΛΑΤΕΙΑ ΜΕΡΚΟΥΡΗ', + 'ΚΑΛΛΙΣΘΕΝΟΥΣ 60', + 'ΤΡΩΩΝ & ΤΡΙΤΩΝΟΣ', + 'ΝΗΛΕΩΣ & ΗΡΑΚΛΕΙΔΩΝ', + 'ΤΡΙΩΝ ΙΕΡΑΡΧΩΝ ΕΝ 168', + 'ΗΡΑΚΛΕΙΔΩΝ 11-13', + 'ΕΡΙΣΥΧΘΩΝΟΣ & ΗΡΑΚΛΕΙΔΩΝ', + 'ΑΧΑΙΩΝ & ΚΑΛΛΙΣΘΕΝΟΥΣ', + 'ΛΑΓΚΑΔΑ & ΣΠΥΡΟΥ ΠΑΤΣΗ', + 'ΚΑΣΤΟΡΙΑΣ & ΚΑΣΣΑΝΔΡΑΣ', + 'ΣΠΥΡΟΥ ΠΑΤΣΗ & ΚΟΡΥΤΣΑΣ', + 'ΣΕΡΑΦΕΙΟ', + ], + }, + { + num: 4, + color: '#E9C46A', + addresses: [ + 'ΙΩΑΝΝΙΝΩΝ & ΔΡΑΜΑΣ', + 'ΛΕΝΟΡΜΑΝ 129 (Σ. ΜΑΡΚΕΤ ΓΑΛΑΞΙΑΣ)', + 'ΛΕΝΟΡΜΑΝ 226 (Σ. Μ. ΣΚΛΑΒΕΝΙΤΗΣ)', + 'ΤΗΛΕΦΑΝΟΥΣ 8', + 'ΛΕΝΟΡΜΑΝ & ΚΛΕΟΒΙΔΟΣ (ΔΙΠΛΑ ΣΤΟΝ ΚΑΔΟ ΡΟΥΧΩΝ)', + 'ΙΦΙΓΕΝΕΙΑΣ & ΛΕΑΝΔΡΟΥ', + 'ΚΡΕΟΝΤΟΣ & ΕΛΛΗΣΠΟΝΤΟΥ', + 'ΑΝΤΙΓΟΝΗΣ 201 & ΚΡΕΟΝΤΟΣ', + 'ΑΥΛΩΝΟΣ & ΑΘΑΝΑΤΩΝ', + 'ΣΕΠΟΛΙΩΝ ΕΝΑΝΤΙ 76 (ΑΓΙΟΣ ΜΕΛΕΤΗΣ)', + 'ΑΥΛΩΝΟΣ ΕΝΑΝΤΙ 142 (Σ. ΜΑΡΚΕΤ ΓΑΛΑΞΙΑΣ)', + 'ΔΡΑΜΑΣ & ΠΡΟΠΟΝΤΙΔΟΣ (ΠΛΑΤΕΙΑ ΟΡΘΟΔΟΞΙΑΣ)', + 'ΠΑΡΑΣΚΕΥΟΠΟΥΛΟΥ & Μ. ΚΟΡΑΚΑ (ΣΧΟΛΕΙΑ)', + 'ΛΙΟΣΙΩΝ 197', + 'ΠΥΡΛΑ & ΠΡΕΤΕΝΤΕΡΗ', + 'ΣΤΡ. ΔΑΓΚΛΗ 81 & ΣΤΡ. ΚΑΛΛΑΡΗ', + 'Λ. ΙΩΝΙΑΣ & ΣΕΡΑΦΗ', + 'Λ. ΙΩΝΙΑΣ & ΚΡΙΤΟΒΟΥΛΙΔΟΥ', + 'ΣΕΡΑΦΗ & ΧΟΡΤΑΤΖΗ (ΑΓΙΟΣ ΜΑΡΚΟΣ)', + 'ΔΕΜΕΡΤΖΗ & ΠΑΠΑΝΑΣΤΑΣΙΟΥ (Σ. ΜΑΡΚΕΤ ΑΒ)', + 'ΠΛΑΤΩΝΟΣ & ΠΑΛΑΜΗΔΙΟΥ', + 'ΠΛΑΤΕΙΑ ΑΓΙΟΥ ΓΕΩΡΓΙΟΥ (ΠΛΑΤΩΝΟΣ)', + 'ΤΗΛΕΦΑΝΟΥΣ & ΜΟΝΑΣΤΗΡΙΟΥ (ΨΗΦΙΑΚΟ ΜΟΥΣΕΙΟ)', + 'ΖΗΝΟΔΩΡΟΥ 3 & ΑΜΦΙΑΡΑΟΥ (ΚΙΒΩΤΟΣ)', + 'ΑΣΤΡΟΥΣ & ΠΑΛΑΜΗΔΙΟΥ (Σ. ΜΑΡΚΕΤ ΓΑΛΑΞΙΑΣ)', + 'ΠΕΖΟΔΡΟΜΟΣ ΚΕΡΑΤΣΙΝΙΟΥ (ΣΧΟΛΕΙΟ)', + 'ΑΛΕΞΑΝΔΡΕΙΑΣ ΕΝΑΝΤΙ 87 & ΜΑΡΑΘΩΝΟΜΑΧΩΝ (ΑΓΙΟΣ ΤΡΥΦΩΝΑΣ)', + 'ΚΑΛΑΜΑ & ΓΡΑΜΜΟΥ', + 'ΔΩΔΩΝΗΣ & ΑΘΑΝΑΤΩΝ (SUPER MARKET)', + 'ΙΩΝΙΑΣ & ΨΑΡΟΥΔΑΚΗ', + 'ΑΣΤΡΟΥΣ & ΑΙΜΩΝΟΣ', + ], + }, + { + num: 5, + color: '#F4A261', + addresses: [ + 'ΑΧΑΡΝΩΝ & ΙΩΝΙΑΣ', + 'ΑΧΑΡΝΩΝ & ΒΙΚΕΛΑ', + 'ΑΧΑΡΝΩΝ & ΙΑΚΩΒΙΔΟΥ', + 'ΧΑΛΚΙΔΟΣ & ΙΩΝΙΑΣ', + 'ΤΣΟΥΝΤΑ & ΚΟΥΡΤΙΔΟΥ', + 'ΔΟΞΑΡΑ & ΤΥΡΙΝΘΟΣ', + 'ΚΟΥΡΤΙΔΟΥ & ΟΘ. ΣΤΑΘΑΤΟΥ', + 'ΣΙΒΟΡΩΝ ΕΝ 91', + 'ΜΑΡΚΟΡΑ 24-26', + 'ΚΑΛΟΣΓΟΥΡΟΥ & ΠΛΑΤΕΙΑ ΠΑΠΑΔΙΑΜΑΝΤΗ', + 'ΑΜΥΚΛΩΝ 7', + 'ΓΕΩΡΓΟΥΛΙΑ 10-12', + 'ΚΡΩΜΝΗΣ 2-4', + 'ΑΓΙΑΣ ΛΑΥΡΑΣ 13-15', + 'ΠΑΤΗΣΙΩΝ 240', + 'ΠΑΤΗΣΙΩΝ 340', + 'ΠΑΤΗΣΙΩΝ 380', + 'Λ. ΗΡΑΚΛΕΙΟΥ 32', + 'ΑΓΙΟΥ ΛΟΥΚΑ 41', + 'ΙΑΚΩΒΑΤΩΝ 11', + 'ΙΑΚΩΒΑΤΩΝ & Φ. ΠΟΛΙΤΗ', + 'ΝΙΚΟΠΟΛΕΩΣ 32', + 'ΧΡ. ΣΜΥΡΝΗΣ & Λ. ΙΩΝΙΑΣ', + 'ΠΑΤΗΣΙΩΝ 347', + 'ΠΑΤΗΣΙΩΝ 316', + 'ΠΑΤΗΣΙΩΝ 354', + 'ΠΑΤΗΣΙΩΝ 351', + 'ΠΑΤΗΣΙΩΝ 339', + 'ΑΧΑΡΝΩΝ 421-423', + 'ΤΡΑΛΛΕΩΝ 74', + ], + }, + { + num: 6, + color: '#264653', + addresses: [ + 'ΑΧΑΡΝΩΝ & ΑΓΙΟΥ ΝΙΚΟΛΑΟΥ', + 'ΠΛΑΤΕΙΑ ΑΜΕΡΙΚΗΣ', + 'ΠΛΑΤΕΙΑ ΚΑΜΠΑΝΗ 1', + 'ΑΧΑΡΝΩΝ 167', + 'ΠΛΑΤΕΙΑ ΑΤΤΙΚΗΣ', + 'ΠΛΑΤΕΙΑ ΒΙΚΤΩΡΙΑΣ', + 'ΑΓΙΟΣ ΠΑΝΤΕΛΕΗΜΟΝΑΣ', + 'ΦΩΚΙΩΝΟΣ ΝΕΓΡΗ & ΣΚΥΡΟΥ', + 'ΦΩΚΙΩΝΟΣ ΝΕΓΡΗ & ΕΠΤΑΝΗΣΟΥ', + 'ΙΘΑΚΗΣ 10', + 'ΔΟΙΡΑΝΗΣ 1 & ΕΥΕΛΠΙΔΩΝ', + 'ΚΥΨΕΛΗΣ & ΖΑΚΥΝΘΟΥ', + 'ΔΡΟΣΟΠΟΥΛΟΥ & ΦΩΚΙΩΝΟΣ ΝΕΓΡΗ', + 'ΚΑΥΚΑΣΟΥ & ΚΡΙΣΣΗΣ', + 'ΑΜΟΡΓΟΥ & ΑΓΙΑΣ ΖΩΝΗΣ', + 'ΜΗΘΥΜΝΗΣ & ΑΓΙΑΣ ΖΩΝΗΣ', + 'ΚΑΛΛΙΦΡΟΝΑ & ΑΓΙΑΣ ΖΩΝΗΣ', + 'ΤΖΟΥΜΑΓΙΑΣ ΕΝΑΝΤΙ 15', + 'ΚΥΨΕΛΗΣ & ΡΗΝΕΙΑΣ', + 'ΑΧΑΡΝΩΝ 203', + 'ΦΩΚΙΩΝΟΣ ΝΕΓΡΗ & ΠΛ. ΚΥΨΕΛΗΣ', + 'ΑΧΑΡΝΩΝ & ΛΑΡΝΑΚΟΣ', + 'ΒΕΛΒΕΝΔΟΥ & ΠΛ. ΚΥΨΕΛΗΣ', + 'ΑΧΑΡΝΩΝ 320', + 'ΑΧΑΡΝΩΝ & ΚΥΜΗΣ', + ], + }, + { + num: 7, + color: '#6A4C93', + addresses: [ + 'ΠΛΑΤΕΙΑ ΓΚΥΖΗ & ΛΙΑΚΑΤΑΙΩΝ', + 'ΔΗΜΗΤΣΑΝΑΣ & ΚΟΡΩΝΙΑΣ', + 'Λ. ΑΛΕΞΑΝΔΡΑΣ & ΣΟΥΤΣΟΥ', + 'ΒΑΛΤΙΝΩΝ & ΚΑΛΛΙΓΑ', + 'ΛΟΥΚΑΡΕΩΣ & ΚΑΛΒΟΥ', + 'ΜΗΤΡΟΠΕΤΡΟΒΑ ΕΝΑΝΤΙ 38-42', + 'ΒΑΦΕΙΟΔΩΡΙΟΥ & ΒΙΘΥΝΙΑΣ', + 'ΒΕΡΝΑΡΔΑΚΗ & ΣΟΛΙΩΤΗ', + 'ΤΡΙΓΩΝΟ ΠΑΜΟΡΜΟΥ & ΡΙΑΝΚΟΥΡ', + 'ΠΛΑΤΕΙΑ ΑΓ. ΔΗΜΗΤΡΙΟΥ', + 'ΠΛΑΤΕΙΑ ΧΑΙΡΟΠΟΥΛΟΥ', + 'ΠΛΑΤΕΙΑ ΕΠΩΝ', + 'ΠΑΓΚΑ ΕΝ 24', + 'ΜΠΑΚΟΥ & ΧΑΤΖΟΠΟΥΛΟΥ', + 'ΛΑΜΨΑ & ΤΡΙΦΥΛΛΙΑΣ', + 'ΠΗΝΕΛΟΠΗΣ ΔΕΛΤΑ (ΠΛΕΥΡΑ ΠΛΑΤΕΙΑΣ)', + 'ΑΔΡΙΑΝΕΙΟΥ & ΣΙΚΕΛΙΑΝΟΥ', + 'ΕΡΥΘΡΟΥ ΣΤΑΥΡΟΥ 16', + 'ΜΥΛΟΠΟΤΑΜΟΥ & ΤΣΟΤΑΚΟΥ', + 'ΠΕΡΙΚΛΗ ΣΤΑΥΡΟΥ ΕΝΑΝΤΙ 9 (ΠΕΖ/ΜΙΟ ΠΛΑΤΕΙΑΣ)', + 'ΧΑΛΚΗΔΟΝΟΣ & ΜΠΟΝΑΝΟΥ', + 'ΛΕΒΑΔΕΙΑΣ 2', + 'Μ. ΑΣΙΑΣ 33', + 'ΔΗΜΗΤΡΙΟΥ ΣΟΥΤΣΟΥ 33', + 'ΚΟΡΙΝΘΙΑΣ & ΚΟΡΩΝΕΙΑΣ', + 'Λ. ΑΛΕΞΑΝΔΡΑΣ & ΛΟΥΚΑΡΕΩΣ', + 'Λ. ΑΛΕΞΑΝΔΡΑΣ & ΒΟΥΡΝΑΖΟΥ', + 'ΒΟΥΡΝΑΖΟΥ 3', + 'ΛΟΥΚΑΡΕΩΣ & ΚΑΛΒΟΥ (ΟΠΙΣΘΕΝ ΚΑΔΩΝ Λ. ΑΛΕΞΑΝΔΡΑΣ)', + 'Λ. ΑΛΕΞΑΝΔΡΑΣ 83', + 'ΚΟΥΜΑΝΟΥΔΗ 2 & Λ. ΑΛΕΞΑΝΔΡΑΣ', + 'ΘΕΡΙΑΝΟΥ 14 & ΜΟΥΣΤΟΞΥΔΗ', + 'ΟΙΧΑΛΙΑΣ 1 & ΠΛ. ΓΚΥΖΗ', + 'ΠΑΝΟΡΜΟΥ 33 & ΚΕΔΡΗΝΟΥ', + 'ΚΕΔΡΗΝΟΥ & ΔΗΜΗΤΣΑΝΑΣ', + 'ΜΟΜΦΕΡΑΤΟΥ & ΔΡΟΣΗ', + ], + }, +]; + +function toId(address: string, communityNum: number, index: number): string { + // Generate a clean, unique ID from the address + const base = address + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') // strip diacritics + .replace(/[^A-Za-z0-9\s]/g, '') // remove non-alphanumeric + .trim() + .replace(/\s+/g, '_') + .toLowerCase() + .substring(0, 40); + return `dk${communityNum}_${String(index + 1).padStart(2, '0')}_${base || 'point'}`; +} + +function generateRegulation() { + const regulation: any = { + title: + 'Χωροθέτηση θέσεων τοποθέτησης κάδων συλλογής βρώσιμων φυτικών ελαίων και λιπών (τηγανελαίων) στις 7 Δημοτικές Κοινότητες του Δήμου Αθηναίων', + summary: + 'Πρόταση για την εγκατάσταση δικτύου **230 ειδικών κάδων** συλλογής τηγανελαίων στις 7 Δημοτικές Κοινότητες του Δήμου Αθηναίων. Σε κάθε θέση θα τοποθετηθούν 1-2 κάδοι. Δείτε τις προτεινόμενες τοποθεσίες στον χάρτη: {REF:dk_1}, {REF:dk_2}, {REF:dk_3}, {REF:dk_4}, {REF:dk_5}, {REF:dk_6}, {REF:dk_7}.', + contactEmail: 't.enallaktikis.diax.apovliton@athens.gr', + sources: [ + { + title: 'Εισηγητικό Χωροθέτησης Θέσεων', + url: 'https://www.cityofathens.gr/wp-content/uploads/2026/02/eisigitiko-chorothetisi-theseon-topothetisis-kadon-syllogis-vrosimon-fytikon-elaion.pdf', + description: + 'Το πλήρες εισηγητικό έγγραφο με τις προτεινόμενες θέσεις ανά Δημοτική Κοινότητα', + }, + { + title: 'Δημοσίευση Διαβούλευσης', + url: 'https://www.cityofathens.gr/diavoyleysi-gia-tin-chorothetisi-these/', + description: 'Η σελίδα της δημόσιας διαβούλευσης στον ιστότοπο του Δήμου Αθηναίων', + }, + ], + defaultVisibleGeosets: [ + 'dk_1', + 'dk_2', + 'dk_3', + 'dk_4', + 'dk_5', + 'dk_6', + 'dk_7', + ], + regulation: [] as any[], + }; + + // Chapter 1: Proposal overview + regulation.regulation.push({ + type: 'chapter', + num: 1, + id: 'eisigitiko', + title: 'Εισηγητικό', + summary: + 'Πρόταση της Δ/νσης Καθαριότητας - Ανακύκλωσης για χωροθέτηση 230 θέσεων κάδων συλλογής τηγανελαίων.', + articles: [ + { + num: 1, + id: 'nomiko-plaisio', + title: 'Νομικό Πλαίσιο & Αποφάσεις', + summary: + 'Αποφάσεις Δημοτικών Κοινοτήτων και Δημοτικού Συμβουλίου που αποτελούν τη βάση της πρότασης.', + body: `Η πρόταση βασίζεται στις ακόλουθες αποφάσεις: + +1. Απόφαση 167/03.02.2025 της **1ης Δημοτικής Κοινότητας** (ΑΔΑ: 9ΠΩ5Ω6Μ-ΠΦΦ) +2. Απόφαση 23/06.02.2025 της **2ης Δημοτικής Κοινότητας** (ΑΔΑ: 9ΕΓΧΩ6Μ-ΥΥΙ) +3. Απόφαση 42/18.02.2025 της **3ης Δημοτικής Κοινότητας** (ΑΔΑ: Ψ3ΘΧΩΞΜ-99Γ) +4. Απόφαση 20/17.02.2025 της **4ης Δημοτικής Κοινότητας** (ΑΔΑ: 91Γ3Ω6Μ-ΧΥΑ) +5. Απόφαση 22/27.02.2025 της **5ης Δημοτικής Κοινότητας** (ΑΔΑ: 9ΧΛΗΩ6Μ-ΤΨ7) +6. Απόφαση 30/12.03.2025 της **6ης Δημοτικής Κοινότητας** (ΑΔΑ: 9Θ69Ω6Μ-Δ6Μ) +7. Απόφαση 22/20.02.2025 της **7ης Δημοτικής Κοινότητας** (ΑΔΑ: ΡΡΤΕΩ6Μ-ΝΜ6) +8. Απόφαση 92677/08.02.2024 της Εκτιμητικής Επιτροπής (άρθρο 186 παρ. 5 Ν.3463/2006) +9. Απόφαση 694/25.11.2024 του Δημοτικού Συμβουλίου (ΑΔΑ: ΨΔΖΩ6Μ-43Ω) για τη διεξαγωγή πρόχειρου προφορικού πλειοδοτικού διαγωνισμού +10. Νόμοι: Ν.4555/2018 «ΚΛΕΙΣΘΕΝΗΣ Ι», Ν.3852/2010 «Καλλικράτης», Ν.5056/2023`, + }, + { + num: 2, + id: 'protasi', + title: 'Πρόταση Χωροθέτησης', + summary: + 'Πρόταση για εγκατάσταση 230 κάδων τηγανελαίων σε 7 Δημοτικές Κοινότητες.', + body: `Η Δ/νση Καθαριότητας – Ανακύκλωσης αποστέλλει προς έγκριση **230 θέσεις** χωροθέτησης κάδων συλλογής βρώσιμων φυτικών ελαίων και λιπών (τηγανελαίων) στις 7 Δημοτικές Κοινότητες. + +Η αρχική απόφαση (694/25.11.2024) προέβλεπε δίκτυο **150 κάδων**. Ωστόσο, για την καλύτερη εξυπηρέτηση και αποφυγή συνεχών τροποποιήσεων, εισηγείται η χωροθέτηση **230 θέσεων**. + +Σε κάθε θέση θα τοποθετηθούν **1 έως 2 κάδοι**. + +Οι προτεινόμενες θέσεις ανά Δημοτική Κοινότητα: +- {REF:dk_1}: 38 θέσεις +- {REF:dk_2}: 27 θέσεις +- {REF:dk_3}: 23 θέσεις +- {REF:dk_4}: 31 θέσεις +- {REF:dk_5}: 30 θέσεις +- {REF:dk_6}: 25 θέσεις +- {REF:dk_7}: 36 θέσεις + +Η ροή τηγανελαίων είναι συνεχώς εξελισσόμενη. Αποφεύγεται η ταφή τους σε ΧΥΤΑ, προωθείται η περιβαλλοντική ευαισθητοποίηση, και ο Δήμος εναρμονίζεται με το εγκεκριμένο ΕΣΔΑ και το τοπικό Σχέδιο Διαχείρισης Αποβλήτων.`, + }, + { + num: 3, + id: 'diavoulefsi', + title: 'Όροι Διαβούλευσης', + summary: + 'Χρονικό πλαίσιο και τρόπος υποβολής παρατηρήσεων.', + body: `Η διαβούλευση διαρκεί **7 ημέρες** (11-18 Φεβρουαρίου 2026). + +**Προθεσμία υποβολής:** Τετάρτη 18 Φεβρουαρίου 2026, ώρα 09:00. + +**Τρόποι υποβολής προτάσεων:** +- **Email:** t.enallaktikis.diax.apovliton@athens.gr +- **Αυτοπροσώπως:** Δημοτική Επιτροπή, Λιοσίων 22, 6ος Όροφος, τηλ. 210 5277432 + +**Αρμόδια Υπηρεσία:** Δ/νση Καθαριότητας - Ανακύκλωσης, Τμήμα Εναλλακτικής Διαχείρισης Αποβλήτων, Ιερά Οδός 151, τηλ. 210 3402446 + +Μετά το πέρας της διαβούλευσης, η Δ/νση Καθαριότητας - Ανακύκλωσης θα επεξεργαστεί τα σχόλια και θα οριστικοποιήσει την πρόταση.`, + }, + ], + }); + + // Generate geosets for each community + for (const community of communities) { + const ordinal = getGreekOrdinal(community.num); + const geoset: any = { + type: 'geoset', + id: `dk_${community.num}`, + name: `${ordinal} Δημοτική Κοινότητα`, + description: `Θέσεις τοποθέτησης κάδων συλλογής τηγανελαίων στην ${ordinal} Δημοτική Κοινότητα (${community.addresses.length} θέσεις)`, + color: community.color, + geometries: community.addresses.map((address, i) => ({ + type: 'point', + name: address, + id: toId(address, community.num, i), + textualDefinition: `${address}, ${ordinal} Δημοτική Κοινότητα, Αθήνα`, + geojson: null, // To be filled by geocoding script + })), + }; + regulation.regulation.push(geoset); + } + + // Validate counts + let totalPoints = 0; + for (const community of communities) { + totalPoints += community.addresses.length; + console.log( + `${getGreekOrdinal(community.num)} Δ.Κ.: ${community.addresses.length} θέσεις`, + ); + } + console.log(`\nΣύνολο: ${totalPoints} θέσεις`); + + return regulation; +} + +function getGreekOrdinal(num: number): string { + const ordinals: Record = { + 1: '1η', + 2: '2η', + 3: '3η', + 4: '4η', + 5: '5η', + 6: '6η', + 7: '7η', + }; + return ordinals[num] || `${num}η`; +} + +// --- Main --- +const regulation = generateRegulation(); + +const outputPath = path.join( + process.cwd(), + 'public', + 'regulation-cooking-oil.json', +); +fs.writeFileSync(outputPath, JSON.stringify(regulation, null, 2)); +console.log(`\nSaved to: ${outputPath}`); From c4bf0c9eb9a9be17bdc640d719c83ab221b5fc43 Mon Sep 17 00:00:00 2001 From: kouloumos Date: Sun, 15 Feb 2026 16:03:18 +0200 Subject: [PATCH 04/20] feat: add admin page for managing consultations Adds CRUD API routes and an admin UI for creating and managing consultations. Includes city selector (filtered to consultationsEnabled cities), regulation JSON URL input, end date picker, active toggle, and a table listing all consultations with comment counts. --- .../(other)/admin/consultations/page.tsx | 283 ++++++++++++++++++ src/app/api/admin/consultations/[id]/route.ts | 52 ++++ src/app/api/admin/consultations/route.ts | 72 +++++ src/components/admin/sidebar.tsx | 7 +- 4 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 src/app/[locale]/(other)/admin/consultations/page.tsx create mode 100644 src/app/api/admin/consultations/[id]/route.ts create mode 100644 src/app/api/admin/consultations/route.ts diff --git a/src/app/[locale]/(other)/admin/consultations/page.tsx b/src/app/[locale]/(other)/admin/consultations/page.tsx new file mode 100644 index 00000000..b1cceb53 --- /dev/null +++ b/src/app/[locale]/(other)/admin/consultations/page.tsx @@ -0,0 +1,283 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +type CityOption = { id: string; name: string }; + +type ConsultationRow = { + id: string; + name: string; + jsonUrl: string; + endDate: string; + isActive: boolean; + createdAt: string; + city: { id: string; name: string }; + _count: { comments: number }; +}; + +export default function AdminConsultationsPage() { + const [consultations, setConsultations] = useState([]); + const [cities, setCities] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function fetchConsultations() { + try { + const res = await fetch('/api/admin/consultations'); + if (!res.ok) throw new Error('Failed to fetch consultations'); + const data = await res.json(); + setConsultations(data); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + } + + async function fetchCities() { + try { + const res = await fetch('/api/admin/entities'); + if (!res.ok) throw new Error('Failed to fetch cities'); + const data = await res.json(); + setCities(data.filter((e: { type: string }) => e.type === 'city')); + } catch (err) { + console.error(err); + } + } + + useEffect(() => { + fetchConsultations(); + fetchCities(); + }, []); + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setIsSubmitting(true); + + const form = e.currentTarget; + const formData = new FormData(form); + const name = String(formData.get('name') || '').trim(); + const jsonUrl = String(formData.get('jsonUrl') || '').trim(); + const endDate = String(formData.get('endDate') || '').trim(); + const cityId = String(formData.get('cityId') || '').trim(); + + if (!name || !jsonUrl || !endDate || !cityId) { + setError('All fields are required'); + setIsSubmitting(false); + return; + } + + try { + const res = await fetch('/api/admin/consultations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, jsonUrl, endDate, cityId }) + }); + + if (!res.ok) { + const data = await res.json(); + setError(data.error || 'Failed to create consultation'); + return; + } + + form.reset(); + fetchConsultations(); + } finally { + setIsSubmitting(false); + } + } + + async function handleToggle(id: string, isActive: boolean) { + const res = await fetch(`/api/admin/consultations/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isActive }) + }); + + if (!res.ok) { + const data = await res.json(); + alert(`Error: ${data.error || 'Failed to update consultation'}`); + } + fetchConsultations(); + } + + async function handleDelete(id: string) { + if (!confirm('Are you sure you want to delete this consultation? All comments will also be deleted.')) return; + + const res = await fetch(`/api/admin/consultations/${id}`, { method: 'DELETE' }); + if (!res.ok) { + const data = await res.json(); + alert(`Error: ${data.error || 'Failed to delete consultation'}`); + return; + } + + fetchConsultations(); + } + + if (loading) { + return
Loading...
; + } + + return ( +
+
+

Consultations

+

+ Manage public consultations for regulation documents +

+
+ + {/* Create Form */} + + + Create Consultation + + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+ +
+
+ {error && ( +
+ {error} +
+ )} +
+
+ + {/* Consultations Table */} + + + All Consultations + + + {consultations.length === 0 ? ( +

No consultations yet.

+ ) : ( + + + + Name + City + End Date + Comments + Active + Actions + + + + {consultations.map((c) => ( + + +
+ {c.name} + + {c.jsonUrl} + +
+
+ {c.city.name} + + {new Date(c.endDate).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} + + {c._count.comments} + + handleToggle(c.id, e.target.checked)} + /> + + +
+ + +
+
+
+ ))} +
+
+ )} +
+
+
+ ); +} diff --git a/src/app/api/admin/consultations/[id]/route.ts b/src/app/api/admin/consultations/[id]/route.ts new file mode 100644 index 00000000..d5970d7a --- /dev/null +++ b/src/app/api/admin/consultations/[id]/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from 'next/server'; +import prisma from '@/lib/db/prisma'; +import { withUserAuthorizedToEdit } from '@/lib/auth'; + +export async function PUT(req: NextRequest, { params }: { params: { id: string } }) { + await withUserAuthorizedToEdit({}); + const id = params.id; + const body = await req.json(); + const { name, jsonUrl, endDate, isActive } = body as { + name?: string; + jsonUrl?: string; + endDate?: string; + isActive?: boolean; + }; + + try { + const updated = await prisma.consultation.update({ + where: { id }, + data: { + ...(name !== undefined && { name }), + ...(jsonUrl !== undefined && { jsonUrl }), + ...(endDate !== undefined && { endDate: new Date(endDate) }), + ...(isActive !== undefined && { isActive }), + }, + include: { + city: { + select: { id: true, name: true } + } + } + }); + return NextResponse.json(updated); + } catch (error) { + if ((error as { code?: string })?.code === 'P2025') { + return NextResponse.json({ error: 'Consultation not found' }, { status: 404 }); + } + return NextResponse.json({ error: 'Failed to update consultation' }, { status: 500 }); + } +} + +export async function DELETE(_req: NextRequest, { params }: { params: { id: string } }) { + await withUserAuthorizedToEdit({}); + const id = params.id; + try { + await prisma.consultation.delete({ where: { id } }); + return NextResponse.json({ ok: true }); + } catch (error) { + if ((error as { code?: string })?.code === 'P2025') { + return NextResponse.json({ error: 'Consultation not found' }, { status: 404 }); + } + return NextResponse.json({ error: 'Failed to delete consultation' }, { status: 500 }); + } +} diff --git a/src/app/api/admin/consultations/route.ts b/src/app/api/admin/consultations/route.ts new file mode 100644 index 00000000..c5187d19 --- /dev/null +++ b/src/app/api/admin/consultations/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from 'next/server'; +import prisma from '@/lib/db/prisma'; +import { withUserAuthorizedToEdit } from '@/lib/auth'; + +export async function GET() { + await withUserAuthorizedToEdit({}); + const items = await prisma.consultation.findMany({ + orderBy: { createdAt: 'desc' }, + include: { + city: { + select: { id: true, name: true } + }, + _count: { + select: { comments: true } + } + } + }); + return NextResponse.json(items); +} + +export async function POST(req: NextRequest) { + await withUserAuthorizedToEdit({}); + const body = await req.json(); + const { name, jsonUrl, endDate, isActive, cityId } = body as { + name: string; + jsonUrl: string; + endDate: string; + isActive?: boolean; + cityId: string; + }; + + if (!name || !jsonUrl || !endDate || !cityId) { + return NextResponse.json( + { error: 'name, jsonUrl, endDate, and cityId are required' }, + { status: 400 } + ); + } + + // Validate the city exists and has consultations enabled + const city = await prisma.city.findUnique({ + where: { id: cityId }, + select: { id: true, consultationsEnabled: true } + }); + + if (!city) { + return NextResponse.json({ error: 'City not found' }, { status: 404 }); + } + + if (!city.consultationsEnabled) { + return NextResponse.json( + { error: 'Consultations are not enabled for this city. Enable them first in city settings.' }, + { status: 400 } + ); + } + + const created = await prisma.consultation.create({ + data: { + name, + jsonUrl, + endDate: new Date(endDate), + isActive: isActive ?? true, + cityId + }, + include: { + city: { + select: { id: true, name: true } + } + } + }); + + return NextResponse.json(created, { status: 201 }); +} diff --git a/src/components/admin/sidebar.tsx b/src/components/admin/sidebar.tsx index 31b4587c..503735fe 100644 --- a/src/components/admin/sidebar.tsx +++ b/src/components/admin/sidebar.tsx @@ -1,4 +1,4 @@ -import { LayoutDashboard, Users, FileText, Settings, Files, Rocket, UserRound, List, RefreshCw, Search, Bell, QrCode, ClipboardCheck } from "lucide-react"; +import { LayoutDashboard, Users, FileText, Settings, Files, Rocket, UserRound, List, RefreshCw, Search, Bell, QrCode, ClipboardCheck, MessageSquareText } from "lucide-react"; import Link from "next/link"; import { Sidebar, @@ -54,6 +54,11 @@ const menuItems = [ icon: List, url: "/admin/tasks" }, + { + title: "Consultations", + icon: MessageSquareText, + url: "/admin/consultations", + }, { title: "QR Campaigns", icon: QrCode, From a126471d2bdbef110c83ff3ef7fb86e15f50979d Mon Sep 17 00:00:00 2001 From: kouloumos Date: Sun, 15 Feb 2026 16:03:27 +0200 Subject: [PATCH 05/20] fix: resolve relative URLs when fetching regulation JSON server-side Server-side fetch cannot resolve relative paths like /regulation.json. Prepend NEXTAUTH_URL to relative jsonUrl values in both the consultation page and the comment validation helper. --- .../consultation/[id]/comments/page.tsx | 20 ++----------------- .../[cityId]/consultation/[id]/page.tsx | 4 +++- src/lib/db/consultations.ts | 9 +++++---- 3 files changed, 10 insertions(+), 23 deletions(-) diff --git a/src/app/[locale]/(city)/[cityId]/consultation/[id]/comments/page.tsx b/src/app/[locale]/(city)/[cityId]/consultation/[id]/comments/page.tsx index d5341658..44b928f4 100644 --- a/src/app/[locale]/(city)/[cityId]/consultation/[id]/comments/page.tsx +++ b/src/app/[locale]/(city)/[cityId]/consultation/[id]/comments/page.tsx @@ -1,6 +1,6 @@ import { Metadata } from "next"; import { getCityCached } from "@/lib/cache"; -import { getConsultationById, getConsultationComments } from "@/lib/db/consultations"; +import { getConsultationById, getConsultationComments, fetchRegulationData } from "@/lib/db/consultations"; import { notFound } from "next/navigation"; import { RegulationData } from "@/components/consultations/types"; import { auth } from "@/auth"; @@ -13,22 +13,6 @@ interface PageProps { params: { cityId: string; id: string }; } -async function fetchRegulationData(jsonUrl: string): Promise { - try { - const response = await fetch(jsonUrl, { cache: 'no-store' }); - - if (!response.ok) { - console.error(`Failed to fetch regulation data: ${response.status}`); - return null; - } - - return await response.json(); - } catch (error) { - console.error('Error fetching regulation data:', error); - return null; - } -} - export async function generateMetadata({ params }: PageProps): Promise { const [consultation, city] = await Promise.all([ getConsultationById(params.cityId, params.id), @@ -274,7 +258,7 @@ export default async function CommentsPage({ params }: PageProps) { } // Check if consultations are enabled for this city - if (!(city as any).consultationsEnabled) { + if (!city.consultationsEnabled) { notFound(); } diff --git a/src/app/[locale]/(city)/[cityId]/consultation/[id]/page.tsx b/src/app/[locale]/(city)/[cityId]/consultation/[id]/page.tsx index c2487a0c..0b0bc67c 100644 --- a/src/app/[locale]/(city)/[cityId]/consultation/[id]/page.tsx +++ b/src/app/[locale]/(city)/[cityId]/consultation/[id]/page.tsx @@ -13,7 +13,9 @@ interface PageProps { async function fetchRegulationData(jsonUrl: string): Promise { try { - const response = await fetch(jsonUrl, { cache: 'no-store' }); + // Resolve relative URLs (e.g. /regulation.json) against the app's base URL + const url = jsonUrl.startsWith('http') ? jsonUrl : `${env.NEXTAUTH_URL}${jsonUrl}`; + const response = await fetch(url, { cache: 'no-store' }); if (!response.ok) { console.error(`Failed to fetch regulation data: ${response.status}`); diff --git a/src/lib/db/consultations.ts b/src/lib/db/consultations.ts index f79f8038..7723a7bd 100644 --- a/src/lib/db/consultations.ts +++ b/src/lib/db/consultations.ts @@ -195,11 +195,12 @@ async function validateEntityExists( } } -// Helper function to fetch regulation data from URL -async function fetchRegulationData(jsonUrl: string): Promise { +// Fetch regulation data from URL (exported for use in page components) +export async function fetchRegulationData(jsonUrl: string): Promise { try { - // Handle relative URLs by prepending the base URL - const url = jsonUrl.startsWith('http') ? jsonUrl : jsonUrl; + // Resolve relative URLs (e.g. /regulation.json) against the app's base URL + const { env } = await import('@/env.mjs'); + const url = jsonUrl.startsWith('http') ? jsonUrl : `${env.NEXTAUTH_URL}${jsonUrl}`; const response = await fetch(url, { cache: 'no-store' }); if (!response.ok) { From b89bd7ff3c9ca959a747bdfa1cb054ab7f267412 Mon Sep 17 00:00:00 2001 From: kouloumos Date: Sun, 15 Feb 2026 16:03:57 +0200 Subject: [PATCH 06/20] docs: add scripts and tooling section to consultations guide Documents the regulation JSON production pipeline: PDF-to-JSON conversion, coordinate transformation, address geocoding, and consultation-specific generators. Includes a comparison table showing which scripts were used for each consultation. --- docs/guides/consultations.md | 47 ++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/docs/guides/consultations.md b/docs/guides/consultations.md index 5e4b0fff..2e24cc08 100644 --- a/docs/guides/consultations.md +++ b/docs/guides/consultations.md @@ -149,6 +149,53 @@ sequenceDiagram * Template: [`src/lib/email/templates/consultation-comment.tsx`](../../src/lib/email/templates/consultation-comment.tsx) (React Email HTML template with entity permalink) * Sender: [`src/lib/email/consultation.ts`](../../src/lib/email/consultation.ts) (sends via Resend to contactEmail + ccEmails) +## Scripts & Tooling + +Regulation JSON files are produced through a pipeline of scripts. Each consultation may use a different subset depending on the source material. + +### PDF-to-JSON Conversion + +[`scripts/convert-regulation-pdf.ts`](../../scripts/convert-regulation-pdf.ts) + +Converts a regulation PDF into a structured regulation JSON file using Claude AI. The script extracts text from the PDF, sends it to the Anthropic API with the regulation JSON schema as guidance, and validates the output against `json-schemas/regulation.schema.json`. Best suited for text-heavy regulations with chapters and articles. + +**Used by**: Scooter regulation — the source PDF contained the full legal text, chapter structure, and coordinate data embedded in textual definitions. + +### Coordinate Transformation + +[`scripts/transform-regulation-coordinates.ts`](../../scripts/transform-regulation-coordinates.ts) + +Transforms coordinates embedded in regulation JSON from GGRS87 (Greek Grid) projection to WGS84 (standard GeoJSON). Parses `textualDefinition` and `description` fields for coordinate patterns like `X: 123456, Y: 789012`, converts them using proj4, and writes GeoJSON Point geometries back into the file. + +**Used by**: Scooter regulation — the source PDF contained GGRS87 coordinates that needed transformation to WGS84 for Mapbox rendering. + +### Address Geocoding + +[`scripts/geocode-regulation-addresses.ts`](../../scripts/geocode-regulation-addresses.ts) + +Geocodes point geometries that have a `textualDefinition` (street address) but no `geojson` coordinates. Uses the Google Geocoding API scoped to Athens with bounds biasing. Validates that results fall within Athens municipality bounds. Produces a failures report for addresses that need manual coordinate entry via the admin geo-editor. + +**Options**: `--dry-run` (preview without API calls), `--force` (re-geocode existing), `--delay=N` (rate limiting in ms). + +**Used by**: Cooking oil regulation — the source PDF contained 210 street addresses/intersections that needed geocoding to map coordinates. + +### Consultation-Specific Generators + +Some consultations require a custom generator script when the source material isn't a structured PDF suitable for AI extraction (e.g., tabular address lists, data scraped from documents). + +[`scripts/generate-cooking-oil-regulation.ts`](../../scripts/generate-cooking-oil-regulation.ts) + +Generates the complete regulation JSON for the Athens cooking oil collection bin consultation. Contains all 210 addresses across 7 Municipal Communities (Δημοτικές Κοινότητες) extracted manually from the source PDF. Each community becomes a geoset with a distinct color, and each address becomes a point geometry. + +### Typical Pipeline + +| Step | Scooter Regulation | Cooking Oil Regulation | +|------|-------------------|----------------------| +| 1. Extract structure | `convert-regulation-pdf.ts` (AI) | `generate-cooking-oil-regulation.ts` (manual) | +| 2. Resolve coordinates | `transform-regulation-coordinates.ts` (GGRS87→WGS84) | `geocode-regulation-addresses.ts` (address→lat/lng) | +| 3. Fix failures | Admin geo-editor | Admin geo-editor (4 addresses) | +| 4. Create DB record | Prisma seed | Admin consultations page | + ## Business Rules & Assumptions ### Feature Gating From dc407b2ca1ae7acb1a7b3bbbea35ffa76e19c5ee Mon Sep 17 00:00:00 2001 From: kouloumos Date: Sun, 15 Feb 2026 16:35:03 +0200 Subject: [PATCH 07/20] fix: scroll to top when navigating to map view via references Reference clicks and comment navigation switch to map view but didn't scroll to top, leaving the full-screen map hidden above the viewport. --- src/components/consultations/ConsultationViewer.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/consultations/ConsultationViewer.tsx b/src/components/consultations/ConsultationViewer.tsx index 094b0543..1ef33648 100644 --- a/src/components/consultations/ConsultationViewer.tsx +++ b/src/components/consultations/ConsultationViewer.tsx @@ -258,6 +258,8 @@ export default function ConsultationViewer({ params.set('view', 'map'); const newUrl = `${window.location.pathname}?${params.toString()}#${comment.entityId}`; router.push(newUrl, { scroll: false }); + // Scroll to top so the full-screen map is visible + window.scrollTo(0, 0); } }; @@ -307,6 +309,8 @@ export default function ConsultationViewer({ params.set('view', 'map'); const newUrl = `${window.location.pathname}?${params.toString()}#${referenceId}`; router.push(newUrl, { scroll: false }); + // Scroll to top so the full-screen map is visible + window.scrollTo(0, 0); } }; From 1b936ffd66c6b68a3c5b030141ccf5f0febddf0d Mon Sep 17 00:00:00 2001 From: kouloumos Date: Sun, 15 Feb 2026 17:02:08 +0200 Subject: [PATCH 08/20] feat: add "apply searched location" button to geo-editor When editing a point geometry, searching for an address now shows a button to directly use that location as the point's coordinates. Saves to localStorage like manual map clicks, with toast confirmation. --- .../consultations/ConsultationMap.tsx | 19 +++++ .../consultations/EditingToolsPanel.tsx | 72 +++++++++++++++++-- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/src/components/consultations/ConsultationMap.tsx b/src/components/consultations/ConsultationMap.tsx index 8cb3f26d..a6d2a921 100644 --- a/src/components/consultations/ConsultationMap.tsx +++ b/src/components/consultations/ConsultationMap.tsx @@ -629,6 +629,24 @@ export default function ConsultationMap({ console.log('📍 Updated selected locations:', locations.map(l => l.text)); }, []); + // Apply a searched location's coordinates as the current geometry's point + const handleApplyLocationToGeometry = useCallback((coordinates: [number, number]) => { + if (!selectedGeometryForEdit) return; + try { + const saved = JSON.parse(localStorage.getItem('opencouncil-edited-geometries') || '{}'); + saved[selectedGeometryForEdit] = { + type: 'Point', + coordinates: coordinates + }; + localStorage.setItem('opencouncil-edited-geometries', JSON.stringify(saved)); + setSavedGeometries({ ...saved }); + window.dispatchEvent(new CustomEvent('opencouncil-storage-change')); + console.log(`📍 Applied location to geometry ${selectedGeometryForEdit}:`, coordinates); + } catch (error) { + console.error('Error applying location to geometry:', error); + } + }, [selectedGeometryForEdit]); + // Function to handle deleting saved geometry const handleDeleteSavedGeometry = (geometryId: string) => { try { @@ -684,6 +702,7 @@ export default function ConsultationMap({ onSetDrawingMode={setDrawingMode} onNavigateToLocation={handleNavigateToLocation} onSelectedLocationsChange={handleSelectedLocationsChange} + onApplyLocationToGeometry={handleApplyLocationToGeometry} onClose={() => handleSelectGeometryForEdit(null)} /> )} diff --git a/src/components/consultations/EditingToolsPanel.tsx b/src/components/consultations/EditingToolsPanel.tsx index 0a2f503f..bd4d170d 100644 --- a/src/components/consultations/EditingToolsPanel.tsx +++ b/src/components/consultations/EditingToolsPanel.tsx @@ -1,9 +1,11 @@ +import { useState } from "react"; import { Button } from "@/components/ui/button"; -import { X, MapPin, Pentagon } from "lucide-react"; +import { X, MapPin, Pentagon, Check } from "lucide-react"; import { LocationNavigator } from './LocationNavigator'; import { CityWithGeometry } from '@/lib/db/cities'; import { Geometry } from "./types"; import { Location } from '@/lib/types/onboarding'; +import { useToast } from "@/hooks/use-toast"; type DrawingMode = 'point' | 'polygon'; @@ -15,6 +17,7 @@ interface EditingToolsPanelProps { onSetDrawingMode: (mode: DrawingMode) => void; onNavigateToLocation: (coordinates: [number, number]) => void; onSelectedLocationsChange?: (locations: Location[]) => void; + onApplyLocationToGeometry?: (coordinates: [number, number]) => void; onClose: () => void; } @@ -26,8 +29,44 @@ export default function EditingToolsPanel({ onSetDrawingMode, onNavigateToLocation, onSelectedLocationsChange, + onApplyLocationToGeometry, onClose }: EditingToolsPanelProps) { + const { toast } = useToast(); + const [lastSearchedLocation, setLastSearchedLocation] = useState(null); + const [applied, setApplied] = useState(false); + + const handleNavigateToLocation = (coordinates: [number, number]) => { + onNavigateToLocation(coordinates); + setApplied(false); + }; + + const handleSelectedLocationsChange = (locations: Location[]) => { + onSelectedLocationsChange?.(locations); + // Track the most recently added location + if (locations.length > 0) { + setLastSearchedLocation(locations[locations.length - 1]); + setApplied(false); + } else { + setLastSearchedLocation(null); + setApplied(false); + } + }; + + const handleApply = () => { + if (lastSearchedLocation && onApplyLocationToGeometry) { + onApplyLocationToGeometry(lastSearchedLocation.coordinates); + setApplied(true); + toast({ + title: "Η τοποθεσία εφαρμόστηκε", + description: lastSearchedLocation.text, + }); + } + }; + + const isPointGeometry = selectedGeometry?.type === 'point'; + const showApplyButton = isPointGeometry && lastSearchedLocation && onApplyLocationToGeometry; + return (
{/* Header */} @@ -76,8 +115,8 @@ export default function EditingToolsPanel({
- {drawingMode === 'point' - ? 'Κάντε κλικ στον χάρτη για να τοποθετήσετε ένα σημείο.' + {drawingMode === 'point' + ? 'Κάντε κλικ στον χάρτη για να τοποθετήσετε ένα σημείο.' : 'Κάντε κλικ στον χάρτη για να σχεδιάσετε μια περιοχή. Διπλό κλικ για να ολοκληρώσετε.' }
@@ -103,12 +142,33 @@ export default function EditingToolsPanel({ + {showApplyButton && ( + + )} )} ); -} \ No newline at end of file +} From 734d088698e92660696599465156b6b0048269a5 Mon Sep 17 00:00:00 2001 From: kouloumos Date: Sun, 15 Feb 2026 17:23:06 +0200 Subject: [PATCH 09/20] feat: zoom to geometry on click and hash navigation in map view Clicking a point or polygon on the map now zooms to it. Hash-based navigation (e.g. #dk_3) zooms to the geoset's boundary polygon. Fixed zoom not firing when transitioning from document to map view by tracking map initialization state so pending zooms execute once the map is ready. --- .../consultations/ConsultationMap.tsx | 62 ++++++++++--------- src/components/map/map.tsx | 6 +- 2 files changed, 37 insertions(+), 31 deletions(-) diff --git a/src/components/consultations/ConsultationMap.tsx b/src/components/consultations/ConsultationMap.tsx index a6d2a921..bb2df4e0 100644 --- a/src/components/consultations/ConsultationMap.tsx +++ b/src/components/consultations/ConsultationMap.tsx @@ -341,12 +341,30 @@ export default function ConsultationMap({ } }, []); + // Find the zoomable GeoJSON for a geometry by id + const findGeometryGeoJSON = useCallback((geometryId: string): GeoJSON.Geometry | null => { + const geometry = geoSets.flatMap(gs => gs.geometries).find(g => g.id === geometryId); + if (!geometry) return null; + + if (savedGeometries[geometry.id]) return savedGeometries[geometry.id]; + if (geometry.type !== 'derived' && 'geojson' in geometry && geometry.geojson) return geometry.geojson; + if (geometry.type === 'derived') return computeDerivedGeometry(geometry, geoSets); + return null; + }, [geoSets, savedGeometries]); + const openDetailFromId = useCallback((id: string) => { // Check if it's a geoset const geoSet = geoSets.find(gs => gs.id === id); if (geoSet) { setDetailType('geoset'); setDetailId(id); + + // Zoom to the geoset's boundary polygon if it exists + const boundaryGeometry = geoSet.geometries.find(g => g.type === 'polygon'); + if (boundaryGeometry) { + const geoJSON = findGeometryGeoJSON(boundaryGeometry.id); + if (geoJSON) setZoomGeometry(geoJSON); + } return; } @@ -355,12 +373,16 @@ export default function ConsultationMap({ if (geometry) { setDetailType('geometry'); setDetailId(id); + + // Zoom to the geometry + const geoJSON = findGeometryGeoJSON(id); + if (geoJSON) setZoomGeometry(geoJSON); return; } // If not found, close detail closeDetail(); - }, [geoSets, closeDetail]); + }, [geoSets, closeDetail, findGeometryGeoJSON]); // Handle URL hash changes to open detail panels useEffect(() => { @@ -400,6 +422,11 @@ export default function ConsultationMap({ const handleMapFeatureClick = (feature: GeoJSON.Feature) => { if (feature.properties?.id) { openGeometryDetail(feature.properties.id); + + // Zoom to the clicked feature's geometry + if (feature.geometry) { + setZoomGeometry(feature.geometry); + } } }; @@ -563,31 +590,11 @@ export default function ConsultationMap({ // Function to handle geometry selection for editing with auto-zoom const handleSelectGeometryForEdit = (geometryId: string | null) => { setSelectedGeometryForEdit(geometryId); - + if (geometryId) { - // Find the geometry to zoom to - const geometry = geoSets.flatMap(gs => gs.geometries).find(g => g.id === geometryId); - if (geometry) { - let geoJSON: GeoJSON.Geometry | null = null; - - // Check for saved geometry first - if (savedGeometries[geometry.id]) { - geoJSON = savedGeometries[geometry.id]; - } - // Otherwise use original geometry - else if (geometry.type !== 'derived' && 'geojson' in geometry && geometry.geojson) { - geoJSON = geometry.geojson; - } - // Handle derived geometries - else if (geometry.type === 'derived') { - geoJSON = computeDerivedGeometry(geometry, geoSets); - } - - // Store geometry for zooming - if (geoJSON) { - setZoomGeometry(geoJSON); - console.log('🎯 Selected geometry for editing and zoom:', geometryId); - } + const geoJSON = findGeometryGeoJSON(geometryId); + if (geoJSON) { + setZoomGeometry(geoJSON); } } }; @@ -671,10 +678,7 @@ export default function ConsultationMap({ } }; - const zoomToGeometry = useMemo(() => { - if (!selectedGeometryForEdit) return null; - return zoomGeometry; - }, [selectedGeometryForEdit, zoomGeometry]); + const zoomToGeometry = zoomGeometry; return (
diff --git a/src/components/map/map.tsx b/src/components/map/map.tsx index 968b6594..9431b99a 100644 --- a/src/components/map/map.tsx +++ b/src/components/map/map.tsx @@ -1,5 +1,5 @@ "use client" -import { useRef, useEffect, useCallback, useMemo, memo } from 'react' +import { useRef, useEffect, useCallback, useMemo, memo, useState } from 'react' import mapboxgl from 'mapbox-gl' import MapboxDraw from '@mapbox/mapbox-gl-draw' import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css' @@ -69,6 +69,7 @@ const Map = memo(function Map({ const animationFrame = useRef(null) const featuresRef = useRef(features) const isInitialized = useRef(false) + const [mapReady, setMapReady] = useState(false) const draw = useRef(null) const hoverTimeout = useRef(null) const currentHoveredFeature = useRef(null) @@ -387,6 +388,7 @@ const Map = memo(function Map({ // Wait for map to load before initializing features map.current.on('load', () => { isInitialized.current = true; + setMapReady(true); if (animateRotation) { animationFrame.current = requestAnimationFrame(rotateCamera); @@ -974,7 +976,7 @@ const Map = memo(function Map({ // Perform the zoom performZoom(zoomToGeometry); } - }, [zoomToGeometry]); + }, [zoomToGeometry, mapReady]); return (
From f9ba9ddc7faedd70179adc584d8d991952a7e16d Mon Sep 17 00:00:00 2001 From: kouloumos Date: Sun, 15 Feb 2026 19:07:55 +0200 Subject: [PATCH 10/20] feat: add S3 upload and inline URL editing to admin consultations Admin dashboard now supports uploading regulation JSON files directly to S3 and editing JSON URLs inline in the consultations table. Also documents the regulation JSON hosting workflow. --- docs/guides/consultations.md | 39 +++- .../(other)/admin/consultations/page.tsx | 203 ++++++++++++++++-- 2 files changed, 225 insertions(+), 17 deletions(-) diff --git a/docs/guides/consultations.md b/docs/guides/consultations.md index 2e24cc08..34d16af8 100644 --- a/docs/guides/consultations.md +++ b/docs/guides/consultations.md @@ -25,6 +25,7 @@ The regulation JSON file is the core data source for each consultation. It follo - `contactEmail`, `ccEmails` — where citizen feedback emails are sent - `sources` — array of source documents (`{title, url, description?}`) - `definitions` — dictionary of terms that can be referenced via `{DEF:id}` in markdown +- `defaultView` — initial view mode (`"map"` or `"document"`, defaults to `"document"`) - `defaultVisibleGeosets` — which geosets are visible on the map by default - `regulation` — array of `Chapter` and `GeoSet` items (the main content) @@ -194,7 +195,43 @@ Generates the complete regulation JSON for the Athens cooking oil collection bin | 1. Extract structure | `convert-regulation-pdf.ts` (AI) | `generate-cooking-oil-regulation.ts` (manual) | | 2. Resolve coordinates | `transform-regulation-coordinates.ts` (GGRS87→WGS84) | `geocode-regulation-addresses.ts` (address→lat/lng) | | 3. Fix failures | Admin geo-editor | Admin geo-editor (4 addresses) | -| 4. Create DB record | Prisma seed | Admin consultations page | +| 4. Upload JSON to S3 | Admin dashboard upload | Admin dashboard upload | +| 5. Create DB record | Prisma seed | Admin consultations page | + +## Hosting Regulation JSON Files + +Regulation JSON files must be hosted at a publicly accessible URL. The URL is stored in `Consultation.jsonUrl` and fetched by the frontend at page load and by the API during comment validation. + +### Uploading via Admin Dashboard + +The admin consultations page (`/admin/consultations`) supports uploading and managing regulation JSON files: + +1. **New consultation**: In the "Create Consultation" form, either paste a URL directly into the "Regulation JSON URL" field, or click the upload button (↑) to upload a `.json` file to S3. The upload returns a public URL that auto-fills the field. + +2. **Update existing consultation**: In the consultations table, hover over the JSON URL column and click the pencil icon to enter edit mode. You can either paste a new URL or click the upload button to replace the file on S3. + +### Storage on DigitalOcean Spaces (S3) + +Files are uploaded via the `/api/upload` endpoint which: +- Requires authentication (admin or authorized editor) +- Generates a random UUID filename (preserving the `.json` extension) +- Stores files under the `uploads/` prefix in the configured DO Spaces bucket +- Sets `public-read` ACL so the URL is publicly accessible +- Returns the full public URL (e.g., `https://{bucket}.{region}.digitaloceanspaces.com/uploads/{uuid}.json`) + +### Workflow for a New Consultation + +1. Generate the regulation JSON using the appropriate script (see [Scripts & Tooling](#scripts--tooling)) +2. Validate it against `json-schemas/regulation.schema.json` +3. Go to `/admin/consultations` and create a new consultation: + - Upload the JSON file (or paste an already-hosted URL) + - Select the city, set name and end date +4. After creation, use the admin geo-editor on the consultation map to draw any missing geometries +5. Export the updated JSON from the geo-editor and re-upload it via the admin table's edit button + +### Local Development + +For local development, you can place regulation JSON files in the `public/` directory and use relative URLs (e.g., `/regulation-cooking-oil.json`). However, for production and shared environments, always use S3-hosted URLs so the files are accessible regardless of the deployment. ## Business Rules & Assumptions diff --git a/src/app/[locale]/(other)/admin/consultations/page.tsx b/src/app/[locale]/(other)/admin/consultations/page.tsx index b1cceb53..73e426f5 100644 --- a/src/app/[locale]/(other)/admin/consultations/page.tsx +++ b/src/app/[locale]/(other)/admin/consultations/page.tsx @@ -1,11 +1,12 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Upload, ExternalLink, Pencil, Check, X } from 'lucide-react'; type CityOption = { id: string; name: string }; @@ -26,6 +27,11 @@ export default function AdminConsultationsPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [editingUrlId, setEditingUrlId] = useState(null); + const [editingUrlValue, setEditingUrlValue] = useState(''); + const fileInputRef = useRef(null); + const jsonUrlInputRef = useRef(null); async function fetchConsultations() { try { @@ -56,6 +62,50 @@ export default function AdminConsultationsPage() { fetchCities(); }, []); + async function uploadJsonFile(file: File): Promise { + const formData = new FormData(); + formData.append('file', file); + + const res = await fetch('/api/upload', { + method: 'POST', + body: formData, + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Upload failed'); + } + + const data = await res.json(); + return data.url; + } + + async function handleFileUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + + if (!file.name.endsWith('.json')) { + setError('Please select a JSON file'); + return; + } + + setIsUploading(true); + setError(null); + + try { + const url = await uploadJsonFile(file); + if (url && jsonUrlInputRef.current) { + jsonUrlInputRef.current.value = url; + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Upload failed'); + } finally { + setIsUploading(false); + // Reset file input + if (fileInputRef.current) fileInputRef.current.value = ''; + } + } + async function handleCreate(e: React.FormEvent) { e.preventDefault(); setError(null); @@ -108,6 +158,36 @@ export default function AdminConsultationsPage() { fetchConsultations(); } + async function handleUpdateUrl(id: string, newUrl: string) { + const res = await fetch(`/api/admin/consultations/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonUrl: newUrl }) + }); + + if (!res.ok) { + const data = await res.json(); + alert(`Error: ${data.error || 'Failed to update URL'}`); + } else { + setEditingUrlId(null); + fetchConsultations(); + } + } + + async function handleUploadAndUpdateUrl(id: string, file: File) { + setIsUploading(true); + try { + const url = await uploadJsonFile(file); + if (url) { + await handleUpdateUrl(id, url); + } + } catch (err) { + alert(err instanceof Error ? err.message : 'Upload failed'); + } finally { + setIsUploading(false); + } + } + async function handleDelete(id: string) { if (!confirm('Are you sure you want to delete this consultation? All comments will also be deleted.')) return; @@ -173,13 +253,36 @@ export default function AdminConsultationsPage() {
- +
+ + + +
+

+ Upload a .json file to S3, or paste a URL directly. +

@@ -191,8 +294,8 @@ export default function AdminConsultationsPage() { disabled={isSubmitting} />
-
@@ -218,6 +321,7 @@ export default function AdminConsultationsPage() { Name City + JSON URL End Date Comments Active @@ -228,14 +332,81 @@ export default function AdminConsultationsPage() { {consultations.map((c) => ( -
- {c.name} - - {c.jsonUrl} - -
+ {c.name}
{c.city.name} + + {editingUrlId === c.id ? ( +
+ setEditingUrlValue(e.target.value)} + className="h-7 text-xs" + /> + + + { + const file = e.target.files?.[0]; + if (file) handleUploadAndUpdateUrl(c.id, file); + }} + /> + +
+ ) : ( +
+ + {c.jsonUrl} + + + +
+ )} +
{new Date(c.endDate).toLocaleDateString('en-US', { year: 'numeric', From ade8f840f6fe767a5a0820aca923860210dfa9dd Mon Sep 17 00:00:00 2001 From: kouloumos Date: Mon, 16 Feb 2026 08:49:12 +0200 Subject: [PATCH 11/20] feat: add community picker with address search and welcome dialog to map view Redesign the map sidebar into a dual-mode panel: a simplified community picker (normal mode) with address search via LocationSelector, and the full layer controls (editing mode) for admins. Citizens can search their address to find nearby collection points within 500m, shown as colored pins on the map. A welcome dialog presents the regulation summary on first load with navigation options. Also: extract shared types (CurrentUser, GeoSetData, SEARCH_COLORS) to types.ts, deduplicate geometry list item rendering in DetailPanel, add GeometryCollection support to bounds calculation, improve map labels (point addresses at zoom, polygon names that fade), and update docs. --- docs/guides/consultations.md | 28 +- .../[cityId]/consultation/[id]/page.tsx | 25 +- src/components/consultations/ArticleView.tsx | 8 +- src/components/consultations/ChapterView.tsx | 8 +- .../consultations/ConsultationDocument.tsx | 8 +- .../consultations/ConsultationMap.tsx | 225 ++++++++--- .../consultations/ConsultationViewer.tsx | 153 +++++++- src/components/consultations/DetailPanel.tsx | 250 +++++++++--- src/components/consultations/GeoSetItem.tsx | 39 +- src/components/consultations/GeometryItem.tsx | 36 +- .../consultations/LayerControlsPanel.tsx | 358 +++++++++++++----- src/components/consultations/types.ts | 23 +- src/components/map/map.tsx | 224 +++++------ .../onboarding/selectors/LocationSelector.tsx | 8 +- src/lib/utils.ts | 32 +- 15 files changed, 972 insertions(+), 453 deletions(-) diff --git a/docs/guides/consultations.md b/docs/guides/consultations.md index 34d16af8..ca4d48ab 100644 --- a/docs/guides/consultations.md +++ b/docs/guides/consultations.md @@ -10,7 +10,7 @@ The consultation feature operates as a JSON-driven, dual-view interface: 1. **Regulation JSON**: Each consultation points to a remote JSON file (`jsonUrl`) that defines the entire regulation structure — chapters, articles, geographic areas, cross-references, and definitions. The schema is defined in [`json-schemas/regulation.schema.json`](../../json-schemas/regulation.schema.json). 2. **Database Layer**: Prisma stores consultation metadata (name, end date, active status), comments, and upvotes. Comments are entity-scoped — tied to a specific chapter, article, geoset, or geometry by `entityType` + `entityId`. -3. **Frontend Layer**: A `ConsultationViewer` client component orchestrates two views — a Document View (chapters/articles with markdown content) and a Map View (Mapbox-powered geographic visualization). A floating action button toggles between them. +3. **Frontend Layer**: A `ConsultationViewer` client component orchestrates two views — a Document View (chapters/articles with markdown content) and a Map View (Mapbox-powered geographic visualization). A floating action button toggles between them. The map view features a community picker with address search, allowing citizens to find nearby collection points by searching their address. A welcome dialog shows the regulation summary on first load. 4. **Comment System**: Authenticated users can leave HTML-rich comments on any entity. Comments support upvoting and trigger email notifications to the municipality's contact address. 5. **Admin Geo-Editor**: Administrators can draw missing geometries directly on the map when regulation text defines areas textually but lacks GeoJSON coordinates. Edits are stored in localStorage and exported as a complete updated regulation JSON. @@ -121,15 +121,15 @@ sequenceDiagram * Layout: [`src/app/[locale]/(city)/[cityId]/consultation/[id]/layout.tsx`](../../src/app/%5Blocale%5D/(city)/%5BcityId%5D/consultation/%5Bid%5D/layout.tsx) (header, footer, feature-flag check) * **Frontend Components** (all under `src/components/consultations/`): - * `ConsultationViewer`: Master orchestrator — manages view state (document/map), URL hash navigation, chapter expansion, reference click handling + * `ConsultationViewer`: Master orchestrator — manages view state (document/map), URL hash navigation, chapter expansion, reference click handling, welcome dialog with regulation summary, and `defaultView` support * `ConsultationHeader`: Title, status badge (Active/Inactive), end date, comment count * `ConsultationDocument`: Renders chapters/articles with expand/collapse, AI summary cards, sources list * `ChapterView` / `ArticleView`: Individual chapter and article renderers with comment counts, permalinks, collapsible content * `MarkdownContent`: Renders markdown with `{REF:id}` and `{DEF:id}` pattern handling as interactive links - * `ConsultationMap`: Mapbox map with geoset rendering, layer controls, detail panel, derived geometry computation (buffer/difference) - * `LayerControlsPanel` / `LayerControlsButton`: Sidebar for toggling geoset/geometry visibility with checkbox tree UI - * `DetailPanel`: Side sheet showing selected geoset/geometry info with description, textual definition, and comments - * `GeoSetItem` / `GeometryItem`: Tree items in layer controls with checkboxes, color swatches, and comment counts + * `ConsultationMap`: Mapbox map with geoset rendering, layer controls, detail panel, derived geometry computation (buffer/difference), address search with search location pins, initial fit-to-bounds, and `GeometryCollection` zoom support + * `LayerControlsPanel` / `LayerControlsButton`: Dual-mode sidebar — in normal mode shows a simplified community picker with address search (via `LocationSelector`); in editing mode shows the full layer controls with checkbox tree UI for toggling geoset/geometry visibility + * `DetailPanel`: Side sheet showing selected geoset/geometry/search-location info. For search locations, shows nearby points within 500m sorted by distance (Haversine). For geosets, lists point geometries with comment counts. For geometries, shows description, textual definition, and comments + * `GeoSetItem` / `GeometryItem`: Tree items in layer controls (editing mode) with checkboxes, color swatches, clickable names, and inline comment counts * `CommentSection`: Rich text editor (ReactQuill), authentication check, comment display with upvotes and delete * `CommentsOverviewSheet`: Modal listing all comments with sort options (recent/likes), entity type badges, navigation * `AISummaryCard`: Collapsible card for AI-generated summaries on chapters/articles @@ -144,7 +144,7 @@ sequenceDiagram * `CityConsultations`: [`src/components/cities/CityConsultations.tsx`](../../src/components/cities/CityConsultations.tsx) (card grid listing for city consultations tab) * **Types**: - * `RegulationData`, `Chapter`, `Article`, `GeoSet`, `Geometry`, etc.: [`src/components/consultations/types.ts`](../../src/components/consultations/types.ts) + * `RegulationData`, `Geometry`, `CurrentUser`, `GeoSetData`, `SEARCH_COLORS`, etc.: [`src/components/consultations/types.ts`](../../src/components/consultations/types.ts) (shared types used across all consultation components) * **Email**: * Template: [`src/lib/email/templates/consultation-comment.tsx`](../../src/lib/email/templates/consultation-comment.tsx) (React Email HTML template with entity permalink) @@ -261,12 +261,16 @@ For local development, you can place regulation JSON files in the `public/` dire 4. Geometries may have a `textualDefinition` but null `geojson` — the admin geo-editor addresses this gap ### Map & Geo-Editor -1. The map uses Mapbox GL with custom styling for different geosets (each has a `color`) +1. The map uses Mapbox GL with custom styling for different geosets (each has a `color`) and always-on street labels 2. `defaultVisibleGeosets` in the regulation JSON controls initial map layer visibility -3. Derived geometries are computed client-side using Turf.js operations -4. The admin geo-editor stores drawn geometries in browser `localStorage` until exported -5. Export produces a complete updated `regulation.json` merging local edits with original data -6. Only super-administrators can access editing mode +3. The map auto-fits to all visible features on initial load (unless a hash navigation targets a specific entity) +4. Citizens can search addresses via the community picker; searched locations appear as colored pins and open a detail panel showing nearby points within 500m +5. Clicking a community boundary polygon opens the parent geoset detail; clicking a point opens the geometry detail +6. Point labels (addresses) appear at higher zoom levels; polygon labels (community names) fade out at street level to avoid noise +7. Derived geometries are computed client-side using buffer/difference operations +8. The admin geo-editor stores drawn geometries in browser `localStorage` until exported +9. Export produces a complete updated `regulation.json` merging local edits with original data +10. Only super-administrators can access editing mode (via a small edit icon in the community picker header) ### Navigation 1. URL hash anchors (`#chapter-1`, `#article-3`, `#geoset-prohibited_areas`) enable deep linking to specific entities diff --git a/src/app/[locale]/(city)/[cityId]/consultation/[id]/page.tsx b/src/app/[locale]/(city)/[cityId]/consultation/[id]/page.tsx index 0b0bc67c..789fbdb6 100644 --- a/src/app/[locale]/(city)/[cityId]/consultation/[id]/page.tsx +++ b/src/app/[locale]/(city)/[cityId]/consultation/[id]/page.tsx @@ -1,9 +1,8 @@ import { Metadata } from "next"; import { getCityCached } from "@/lib/cache"; -import { getConsultationById, getConsultationComments } from "@/lib/db/consultations"; +import { getConsultationById, getConsultationComments, fetchRegulationData } from "@/lib/db/consultations"; import { notFound } from "next/navigation"; import { ConsultationViewer } from "@/components/consultations"; -import { RegulationData } from "@/components/consultations/types"; import { auth } from "@/auth"; import { env } from "@/env.mjs"; @@ -11,24 +10,6 @@ interface PageProps { params: { cityId: string; id: string }; } -async function fetchRegulationData(jsonUrl: string): Promise { - try { - // Resolve relative URLs (e.g. /regulation.json) against the app's base URL - const url = jsonUrl.startsWith('http') ? jsonUrl : `${env.NEXTAUTH_URL}${jsonUrl}`; - const response = await fetch(url, { cache: 'no-store' }); - - if (!response.ok) { - console.error(`Failed to fetch regulation data: ${response.status}`); - return null; - } - - return await response.json(); - } catch (error) { - console.error('Error fetching regulation data:', error); - return null; - } -} - export async function generateMetadata({ params }: PageProps): Promise { const [consultation, city] = await Promise.all([ getConsultationById(params.cityId, params.id), @@ -120,7 +101,7 @@ export default async function ConsultationPage({ params }: PageProps) { } // Check if consultations are enabled for this city - if (!(city as any).consultationsEnabled) { + if (!city.consultationsEnabled) { notFound(); } @@ -182,6 +163,8 @@ export default async function ConsultationPage({ params }: PageProps) { currentUser={session?.user} consultationId={params.id} cityId={params.cityId} + cityName={city.name} + cityLogoUrl={city.logoImage || null} /> ); diff --git a/src/components/consultations/ArticleView.tsx b/src/components/consultations/ArticleView.tsx index dbde9238..b5aa6308 100644 --- a/src/components/consultations/ArticleView.tsx +++ b/src/components/consultations/ArticleView.tsx @@ -4,15 +4,9 @@ import PermalinkButton from "./PermalinkButton"; import AISummaryCard from "./AISummaryCard"; import MarkdownContent from "./MarkdownContent"; import CommentSection from "./CommentSection"; -import { Article, ReferenceFormat, RegulationData } from "./types"; +import { Article, ReferenceFormat, RegulationData, CurrentUser } from "./types"; import { ConsultationCommentWithUpvotes } from "@/lib/db/consultations"; -interface CurrentUser { - id?: string; - name?: string | null; - email?: string | null; -} - interface ArticleViewProps { article: Article; baseUrl: string; diff --git a/src/components/consultations/ChapterView.tsx b/src/components/consultations/ChapterView.tsx index c60f2a1b..3cd9af73 100644 --- a/src/components/consultations/ChapterView.tsx +++ b/src/components/consultations/ChapterView.tsx @@ -4,15 +4,9 @@ import PermalinkButton from "./PermalinkButton"; import AISummaryCard from "./AISummaryCard"; import MarkdownContent from "./MarkdownContent"; import CommentSection from "./CommentSection"; -import { RegulationItem, ReferenceFormat, RegulationData } from "./types"; +import { RegulationItem, ReferenceFormat, RegulationData, CurrentUser } from "./types"; import { ConsultationCommentWithUpvotes } from "@/lib/db/consultations"; -interface CurrentUser { - id?: string; - name?: string | null; - email?: string | null; -} - interface ChapterViewProps { chapter: RegulationItem; baseUrl: string; diff --git a/src/components/consultations/ConsultationDocument.tsx b/src/components/consultations/ConsultationDocument.tsx index 7c86b929..4549738c 100644 --- a/src/components/consultations/ConsultationDocument.tsx +++ b/src/components/consultations/ConsultationDocument.tsx @@ -11,15 +11,9 @@ import ArticleView from "./ArticleView"; import DocumentNavigation from "./DocumentNavigation"; import SourcesList from "./SourcesList"; import MarkdownContent from "./MarkdownContent"; -import { RegulationData, ReferenceFormat } from "./types"; +import { RegulationData, ReferenceFormat, CurrentUser } from "./types"; import { ConsultationCommentWithUpvotes } from "@/lib/db/consultations"; -interface CurrentUser { - id?: string; - name?: string | null; - email?: string | null; -} - interface ConsultationDocumentProps { regulationData: RegulationData | null; baseUrl: string; // Base URL for permalinks diff --git a/src/components/consultations/ConsultationMap.tsx b/src/components/consultations/ConsultationMap.tsx index bb2df4e0..36f5b7cd 100644 --- a/src/components/consultations/ConsultationMap.tsx +++ b/src/components/consultations/ConsultationMap.tsx @@ -1,10 +1,10 @@ "use client"; -import { useState, useMemo, useEffect, useCallback } from "react"; +import { useState, useMemo, useEffect, useCallback, useRef } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import Map, { MapFeature } from "@/components/map/map"; import { cn } from "@/lib/utils"; -import { RegulationData, RegulationItem, Geometry, ReferenceFormat, StaticGeometry, DerivedGeometry, BufferOperation, DifferenceOperation } from "./types"; +import { RegulationData, RegulationItem, Geometry, ReferenceFormat, StaticGeometry, DerivedGeometry, BufferOperation, DifferenceOperation, CurrentUser, GeoSetData, SEARCH_COLORS } from "./types"; import LayerControlsButton from "./LayerControlsButton"; import LayerControlsPanel from "./LayerControlsPanel"; import DetailPanel from "./DetailPanel"; @@ -13,12 +13,6 @@ import { CheckboxState } from "./GeoSetItem"; import { ConsultationCommentWithUpvotes } from "@/lib/db/consultations"; import { Location } from "@/lib/types/onboarding"; -interface CurrentUser { - id?: string; - name?: string | null; - email?: string | null; -} - interface ConsultationMapProps { className?: string; regulationData?: RegulationData | null; @@ -29,14 +23,7 @@ interface ConsultationMapProps { currentUser?: CurrentUser; consultationId?: string; cityId?: string; -} - -interface GeoSetData { - id: string; - name: string; - description?: string; - color?: string; - geometries: Geometry[]; + onShowInfo?: () => void; } // Generate distinct colors for different geosets @@ -217,19 +204,21 @@ export default function ConsultationMap({ comments, currentUser, consultationId, - cityId + cityId, + onShowInfo }: ConsultationMapProps) { const router = useRouter(); const searchParams = useSearchParams(); - const [isControlsOpen, setIsControlsOpen] = useState(false); + const [isControlsOpen, setIsControlsOpen] = useState(true); const [enabledGeoSets, setEnabledGeoSets] = useState>(new Set()); const [enabledGeometries, setEnabledGeometries] = useState>(new Set()); const [expandedGeoSets, setExpandedGeoSets] = useState>(new Set()); // Detail panel state - const [detailType, setDetailType] = useState<'geoset' | 'geometry' | null>(null); + const [detailType, setDetailType] = useState<'geoset' | 'geometry' | 'search-location' | null>(null); const [detailId, setDetailId] = useState(null); + const [selectedSearchLocationIndex, setSelectedSearchLocationIndex] = useState(null); // Editing state const [isEditingMode, setIsEditingMode] = useState(false); @@ -242,6 +231,15 @@ export default function ConsultationMap({ // State for selected locations (for line drawing) const [selectedLocations, setSelectedLocations] = useState([]); + // Search locations state (shown when user searches addresses in the community picker) + const [searchLocations, setSearchLocations] = useState([]); + + // The actively viewed search location (passed directly to DetailPanel to avoid index timing issues) + const [activeSearchLocation, setActiveSearchLocation] = useState(null); + + // Ref to prevent hash handler from overriding search-location detail mode + const isInSearchLocationMode = useRef(false); + // Load saved geometries from localStorage on mount and when editing mode changes useEffect(() => { const loadSavedGeometries = () => { @@ -331,8 +329,11 @@ export default function ConsultationMap({ }, [geoSets, regulationData]); const closeDetail = useCallback(() => { + isInSearchLocationMode.current = false; setDetailType(null); setDetailId(null); + setSelectedSearchLocationIndex(null); + setActiveSearchLocation(null); // Remove hash from URL if (window.location.hash) { // Use history.pushState to remove hash without page reload @@ -353,17 +354,34 @@ export default function ConsultationMap({ }, [geoSets, savedGeometries]); const openDetailFromId = useCallback((id: string) => { + // Don't override search-location detail + if (isInSearchLocationMode.current) return; + // Check if it's a geoset const geoSet = geoSets.find(gs => gs.id === id); if (geoSet) { setDetailType('geoset'); setDetailId(id); - // Zoom to the geoset's boundary polygon if it exists - const boundaryGeometry = geoSet.geometries.find(g => g.type === 'polygon'); - if (boundaryGeometry) { - const geoJSON = findGeometryGeoJSON(boundaryGeometry.id); - if (geoJSON) setZoomGeometry(geoJSON); + // Zoom to fit all point geometries in the geoset (shows address labels) + const pointGeometries = geoSet.geometries + .filter(g => g.type === 'point') + .map(g => findGeometryGeoJSON(g.id)) + .filter((g): g is GeoJSON.Geometry => g !== null); + + if (pointGeometries.length > 0) { + const collection: GeoJSON.GeometryCollection = { + type: 'GeometryCollection', + geometries: pointGeometries + }; + setZoomGeometry(collection); + } else { + // Fallback to boundary polygon if no points + const boundaryGeometry = geoSet.geometries.find(g => g.type === 'polygon'); + if (boundaryGeometry) { + const geoJSON = findGeometryGeoJSON(boundaryGeometry.id); + if (geoJSON) setZoomGeometry(geoJSON); + } } return; } @@ -405,27 +423,67 @@ export default function ConsultationMap({ // Functions to manage detail panel const openGeoSetDetail = (geoSetId: string) => { + isInSearchLocationMode.current = false; setDetailType('geoset'); setDetailId(geoSetId); + setSelectedSearchLocationIndex(null); // Update URL hash without triggering navigation window.location.hash = geoSetId; }; const openGeometryDetail = (geometryId: string) => { + isInSearchLocationMode.current = false; setDetailType('geometry'); setDetailId(geometryId); + setSelectedSearchLocationIndex(null); // Update URL hash without triggering navigation window.location.hash = geometryId; }; + const openSearchLocationDetail = (location: Location, locationIndex: number) => { + isInSearchLocationMode.current = true; + setDetailType('search-location'); + setDetailId(`search-location-${locationIndex}`); + setSelectedSearchLocationIndex(locationIndex); + setActiveSearchLocation(location); + // Clear hash so it doesn't conflict (use pushState to avoid triggering hashchange) + if (window.location.hash) { + const url = window.location.href.split('#')[0]; + window.history.pushState({}, '', url); + } + }; + // Handle map feature clicks const handleMapFeatureClick = (feature: GeoJSON.Feature) => { - if (feature.properties?.id) { - openGeometryDetail(feature.properties.id); + // Clicking a search location pin opens its detail panel + if (feature.properties?.type === 'search-location') { + const pinIndex = searchLocations.findIndex(l => l.text === feature.properties?.name); + if (pinIndex >= 0) { + openSearchLocationDetail(searchLocations[pinIndex], pinIndex); + } + return; + } - // Zoom to the clicked feature's geometry - if (feature.geometry) { - setZoomGeometry(feature.geometry); + if (feature.properties?.id) { + const geometryId = feature.properties.id; + + // Check if this is a polygon in a geoset with other geometries + // If so, open the parent geoset (e.g. clicking community boundary opens the community) + const parentGeoSet = geoSets.find(gs => gs.geometries.some(g => g.id === geometryId)); + const clickedGeometry = parentGeoSet?.geometries.find(g => g.id === geometryId); + + if (clickedGeometry?.type === 'polygon' && parentGeoSet && parentGeoSet.geometries.length > 1) { + openGeoSetDetail(parentGeoSet.id); + // Zoom to the polygon + if (feature.geometry) { + setZoomGeometry(feature.geometry); + } + } else { + openGeometryDetail(geometryId); + // Zoom to the clicked feature's geometry + if (feature.geometry) { + setZoomGeometry(feature.geometry); + } } } }; @@ -462,6 +520,17 @@ export default function ConsultationMap({ // Only add to features if we have valid geometry if (geoJSON) { + // For point features, show the address as the map label + // For boundary polygons in a geoset, use the geoset name (without "- Όρια") + let label: string; + if (geometry.type === 'point' && geometry.textualDefinition) { + label = geometry.textualDefinition; + } else if (geometry.type === 'polygon' && geoSet.geometries.length > 1) { + label = geoSet.name; + } else { + label = geometry.name; + } + features.push({ id: geometry.id, geometry: geoJSON, @@ -482,7 +551,7 @@ export default function ConsultationMap({ strokeColor: geometry.type === 'derived' ? 'transparent' : (isFromLocalStorage ? '#1D4ED8' : color), // Stroke width: derived have none, points are smaller, localStorage get thicker stroke strokeWidth: geometry.type === 'derived' ? 0 : (geometry.type === 'point' ? 4 : (isFromLocalStorage ? 3 : 2)), - label: geometry.name + label } }); } @@ -493,16 +562,36 @@ export default function ConsultationMap({ if (isEditingMode && selectedLocations.length > 0) { const locationLineFeatures = createLocationLineFeatures(selectedLocations); features.push(...locationLineFeatures); - - if (selectedLocations.length === 1) { - console.log(`📍 Added 1 prominent location marker`); - } else { - console.log(`🔗 Added ${locationLineFeatures.length} location features (${selectedLocations.length - 1} lines, ${selectedLocations.length} points)`); - } + } + + // Add search location pins (user searched addresses in the community picker) + if (!isEditingMode && searchLocations.length > 0) { + searchLocations.forEach((location, index) => { + const color = SEARCH_COLORS[index % SEARCH_COLORS.length]; + features.push({ + id: `search-location-${index}`, + geometry: { + type: 'Point', + coordinates: location.coordinates + }, + properties: { + type: 'search-location', + name: location.text, + alwaysShowLabel: true + }, + style: { + fillColor: color, + fillOpacity: 0.95, + strokeColor: '#ffffff', + strokeWidth: searchLocations.length === 1 ? 14 : 12, + label: location.text + } + }); + }); } return features; - }, [geoSets, enabledGeoSets, enabledGeometries, savedGeometries, isEditingMode, selectedLocations]); + }, [geoSets, enabledGeoSets, enabledGeometries, savedGeometries, isEditingMode, selectedLocations, searchLocations]); // Get geoset checkbox state (checked, indeterminate, or unchecked) const getGeoSetCheckboxState = (geoSetId: string): CheckboxState => { @@ -601,6 +690,7 @@ export default function ConsultationMap({ // State for geometry to zoom to const [zoomGeometry, setZoomGeometry] = useState(null); + const [hasInitialFit, setHasInitialFit] = useState(false); // City data state const [cityData, setCityData] = useState(null); @@ -621,19 +711,16 @@ export default function ConsultationMap({ // Handle navigation to location (for location search) const handleNavigateToLocation = (coordinates: [number, number]) => { - // Create a point geometry for the location and set it for zooming const pointGeometry: GeoJSON.Geometry = { type: 'Point', coordinates: coordinates }; setZoomGeometry(pointGeometry); - console.log('🗺️ Navigating to location:', coordinates); }; // Handle selected locations change from LocationNavigator const handleSelectedLocationsChange = useCallback((locations: Location[]) => { setSelectedLocations(locations); - console.log('📍 Updated selected locations:', locations.map(l => l.text)); }, []); // Apply a searched location's coordinates as the current geometry's point @@ -648,7 +735,6 @@ export default function ConsultationMap({ localStorage.setItem('opencouncil-edited-geometries', JSON.stringify(saved)); setSavedGeometries({ ...saved }); window.dispatchEvent(new CustomEvent('opencouncil-storage-change')); - console.log(`📍 Applied location to geometry ${selectedGeometryForEdit}:`, coordinates); } catch (error) { console.error('Error applying location to geometry:', error); } @@ -671,26 +757,39 @@ export default function ConsultationMap({ if (selectedGeometryForEdit === geometryId) { setSelectedGeometryForEdit(null); } - - console.log(`🗑️ Deleted saved geometry for ID: ${geometryId}`); } catch (error) { console.error('Error deleting saved geometry:', error); } }; + // Fit map to all features on initial load (unless a hash navigation already set a zoom target) + useEffect(() => { + if (hasInitialFit || zoomGeometry) return; + if (mapFeatures.length === 0) return; + + const geometries = mapFeatures.map(f => f.geometry); + const collection: GeoJSON.GeometryCollection = { + type: 'GeometryCollection', + geometries + }; + setZoomGeometry(collection); + setHasInitialFit(true); + }, [mapFeatures, hasInitialFit, zoomGeometry]); + const zoomToGeometry = zoomGeometry; return (
{/* Map */} { + // Open detail panel for this search location showing nearest points + openSearchLocationDetail(location, index); + // Zoom to the clicked location at street level + const pointGeometry: GeoJSON.Geometry = { + type: 'Point', + coordinates: location.coordinates + }; + setZoomGeometry(pointGeometry); + }} + onSearchLocation={(location) => { + // Add to search locations list and open its detail panel + const newIndex = searchLocations.length; + setSearchLocations(prev => [...prev, location]); + openSearchLocationDetail(location, newIndex); + // Zoom to the searched point at street level + const pointGeometry: GeoJSON.Geometry = { + type: 'Point', + coordinates: location.coordinates + }; + setZoomGeometry(pointGeometry); + }} + onRemoveSearchLocation={(index) => { + setSearchLocations(prev => prev.filter((_, i) => i !== index)); + // If we removed the currently viewed search location, close the detail + if (selectedSearchLocationIndex === index) { + closeDetail(); + } else if (selectedSearchLocationIndex !== null && selectedSearchLocationIndex > index) { + // Adjust index if we removed one before it + setSelectedSearchLocationIndex(selectedSearchLocationIndex - 1); + } + }} + onShowInfo={onShowInfo} /> )} @@ -777,6 +911,7 @@ export default function ConsultationMap({ isEditingMode={isEditingMode} selectedGeometryForEdit={selectedGeometryForEdit} savedGeometries={savedGeometries} + searchLocation={activeSearchLocation || undefined} />
); diff --git a/src/components/consultations/ConsultationViewer.tsx b/src/components/consultations/ConsultationViewer.tsx index 1ef33648..201c8a14 100644 --- a/src/components/consultations/ConsultationViewer.tsx +++ b/src/components/consultations/ConsultationViewer.tsx @@ -1,22 +1,21 @@ "use client"; import { useState, useEffect, useCallback } from "react"; +import Image from "next/image"; import { useRouter, useSearchParams } from "next/navigation"; +import { MapPin, Map, FileText, MessageSquare } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import ConsultationHeader from "./ConsultationHeader"; import ConsultationMap from "./ConsultationMap"; import ConsultationDocument from "./ConsultationDocument"; import ViewToggleButton from "./ViewToggleButton"; import CommentsOverviewSheet from "./CommentsOverviewSheet"; +import MarkdownContent from "./MarkdownContent"; -import { RegulationData } from "./types"; +import { RegulationData, CurrentUser } from "./types"; import { ConsultationCommentWithUpvotes, ConsultationWithStatus } from "@/lib/db/consultations"; -interface CurrentUser { - id?: string; - name?: string | null; - email?: string | null; -} - type ViewMode = 'map' | 'document'; interface Consultation { @@ -36,6 +35,8 @@ interface ConsultationViewerProps { currentUser?: CurrentUser; consultationId: string; cityId: string; + cityName?: string; + cityLogoUrl?: string | null; } export default function ConsultationViewer({ @@ -45,13 +46,17 @@ export default function ConsultationViewer({ comments, currentUser, consultationId, - cityId + cityId, + cityName, + cityLogoUrl }: ConsultationViewerProps) { const router = useRouter(); const searchParams = useSearchParams(); - // Default to document view, unless URL specifies map - const [currentView, setCurrentView] = useState('document'); + const defaultView = regulationData?.defaultView || 'document'; + + // Default to the regulation's defaultView, unless URL specifies otherwise + const [currentView, setCurrentView] = useState(defaultView); // Track which chapters and articles are expanded const [expandedChapters, setExpandedChapters] = useState>(new Set()); @@ -60,24 +65,35 @@ export default function ConsultationViewer({ // Track comments overview sheet state const [commentsSheetOpen, setCommentsSheetOpen] = useState(false); + // Track whether the map summary card has been dismissed + // Don't show by default if URL has a hash (direct link to a community) + const [showMapSummary, setShowMapSummary] = useState(true); + + // Hide welcome dialog if URL has a hash on mount (direct link) + useEffect(() => { + if (window.location.hash) { + setShowMapSummary(false); + } + }, []); + // Update view based on URL on mount and when search params change useEffect(() => { const viewParam = searchParams.get('view'); if (viewParam === 'map' || viewParam === 'document') { setCurrentView(viewParam as ViewMode); } else { - // Default to document view and update URL if no view param exists - setCurrentView('document'); + // Use the regulation's default view and update URL + setCurrentView(defaultView); if (typeof window !== 'undefined') { const params = new URLSearchParams(window.location.search); if (!params.has('view')) { - params.set('view', 'document'); + params.set('view', defaultView); const newUrl = `${window.location.pathname}?${params.toString()}${window.location.hash}`; router.replace(newUrl, { scroll: false }); } } } - }, [searchParams, router]); + }, [searchParams, router, defaultView]); // Helper functions for managing expansion state const expandChapter = (chapterId: string) => { @@ -304,11 +320,17 @@ export default function ConsultationViewer({ const newUrl = `${window.location.pathname}?${params.toString()}#${referenceId}`; router.push(newUrl, { scroll: false }); } else if (referenceType === 'geoset' || referenceType === 'geometry') { - // Navigate to map view - const params = new URLSearchParams(window.location.search); - params.set('view', 'map'); - const newUrl = `${window.location.pathname}?${params.toString()}#${referenceId}`; - router.push(newUrl, { scroll: false }); + if (currentView === 'map') { + // Already in map view - directly set hash to trigger ConsultationMap's hashchange handler + // (router.push uses History.pushState which does NOT trigger hashchange) + window.location.hash = referenceId; + } else { + // Switch to map view first - ConsultationMap will handle the hash on mount + const params = new URLSearchParams(window.location.search); + params.set('view', 'map'); + const newUrl = `${window.location.pathname}?${params.toString()}#${referenceId}`; + router.push(newUrl, { scroll: false }); + } // Scroll to top so the full-screen map is visible window.scrollTo(0, 0); } @@ -333,9 +355,102 @@ export default function ConsultationViewer({ currentUser={currentUser} consultationId={consultationId} cityId={cityId} + onShowInfo={() => setShowMapSummary(true)} />
+ {/* Welcome dialog */} + + + {/* Logos */} +
+ {cityLogoUrl && ( +
+ {cityName +
+ )} +
+ OpenCouncil +
+
+ + +
+ ΔΙΑΒΟΥΛΕΥΣΗ +
+ + {regulationData?.title} + + + Περίληψη διαβούλευσης + +
+ {regulationData?.summary && ( +
+ { + setShowMapSummary(false); + handleReferenceClick(id); + }} + regulationData={regulationData} + /> +
+ )} +
+ +
+ + {comments.length > 0 && ( + + )} +
+
+

+ Σχολιάστε και εκφράστε τη γνώμη σας -- τα σχόλια αποστέλλονται απευθείας στον Δήμο ως επίσημες παρατηρήσεις. +

+
+
+ {/* Floating action button for view toggle */} void; - detailType: 'geoset' | 'geometry' | null; + detailType: 'geoset' | 'geometry' | 'search-location' | null; detailId: string | null; geoSets: GeoSetData[]; baseUrl: string; @@ -45,6 +43,8 @@ interface DetailPanelProps { isEditingMode?: boolean; selectedGeometryForEdit?: string | null; savedGeometries?: Record; + // Search location context - the selected search location for the search-location detail view + searchLocation?: Location; } export default function DetailPanel({ @@ -66,7 +66,8 @@ export default function DetailPanel({ cityId, isEditingMode = false, selectedGeometryForEdit, - savedGeometries + savedGeometries, + searchLocation }: DetailPanelProps) { // Find the current detail data @@ -109,6 +110,12 @@ export default function DetailPanel({ }; const getTitleData = () => { + if (detailType === 'search-location' && searchLocation) { + return { + label: toGreekUppercase('Η τοποθεσία σας'), + title: searchLocation.text + }; + } if (detailType === 'geoset' && currentGeoSet) { return { label: toGreekUppercase('Σύνολο Περιοχών'), @@ -124,8 +131,104 @@ export default function DetailPanel({ return { label: '', title: '' }; }; + // Get coordinates for a geometry (from saved or original) + const getGeometryCoordinates = (geometry: Geometry): [number, number] | null => { + if (savedGeometries?.[geometry.id]) { + const saved = savedGeometries[geometry.id]; + if (saved.type === 'Point') return saved.coordinates; + } + if (geometry.type !== 'derived' && 'geojson' in geometry) { + const geojson = (geometry as StaticGeometry).geojson; + if (geojson?.type === 'Point') return geojson.coordinates as [number, number]; + } + return null; + }; + + // Get comment count for a specific entity + const getCommentCount = (entityType: string, entityId: string): number => { + if (!comments) return 0; + return comments.filter(c => c.entityType === entityType && c.entityId === entityId).length; + }; + + // Nearby points from ALL geoSets within 500m, sorted by distance to the search location + const NEARBY_DISTANCE_LIMIT = 500; // meters + const nearbyPoints = useMemo(() => { + if (!searchLocation) return []; + + const allPoints: { geometry: Geometry; geoSetName: string; geoSetId: string; distance: number }[] = []; + geoSets.forEach(gs => { + gs.geometries.forEach(g => { + if (g.type === 'point') { + const coords = getGeometryCoordinates(g); + if (coords) { + const dist = haversineDistance(searchLocation.coordinates, coords); + if (dist <= NEARBY_DISTANCE_LIMIT) { + allPoints.push({ geometry: g, geoSetName: gs.name, geoSetId: gs.id, distance: dist }); + } + } + } + }); + }); + + return allPoints.sort((a, b) => a.distance - b.distance); + }, [searchLocation, geoSets, savedGeometries]); // eslint-disable-line react-hooks/exhaustive-deps -- getGeometryCoordinates depends only on savedGeometries which is tracked + + // Format distance for display + const formatDistance = (meters: number): string => { + if (meters < 1000) return `${Math.round(meters)}μ`; + return `${(meters / 1000).toFixed(1)}χλμ`; + }; + + // Reusable geometry list item button + const GeometryListItem = ({ geometry, onClick, subtitle, rightLabel }: { + geometry: Geometry; + onClick: () => void; + subtitle?: string; + rightLabel?: string; + }) => { + const commentCount = getCommentCount('GEOMETRY', geometry.id); + return ( + + ); + }; + return ( - !open && onClose()}> + !open && onClose()}>
- + {detailType !== 'search-location' && detailId && ( + + )} @@ -150,6 +255,45 @@ export default function DetailPanel({ className="flex-1 overflow-y-auto overscroll-contain mt-4 pr-2" onWheel={(e) => e.stopPropagation()} > + {/* Search Location Details - shows nearest points from ALL communities */} + {detailType === 'search-location' && searchLocation && ( +
+ {nearbyPoints.length > 0 ? ( + <> +

+ Κοντινές θέσεις συλλογής σε ακτίνα {NEARBY_DISTANCE_LIMIT}μ. +

+ + + +
+

+ Κοντινές Θέσεις ({nearbyPoints.length}) +

+
+ {nearbyPoints.map(({ geometry, geoSetName, distance }) => ( + onOpenGeometryDetail?.(geometry.id)} + subtitle={geoSetName} + rightLabel={formatDistance(distance)} + /> + ))} +
+
+ + ) : ( +
+ +

+ Δεν βρέθηκαν θέσεις συλλογής σε ακτίνα {NEARBY_DISTANCE_LIMIT}μ. +

+
+ )} +
+ )} + {/* GeoSet Details */} {currentGeoSet && (
@@ -170,33 +314,18 @@ export default function DetailPanel({

- Περιεχόμενες Περιοχές ({currentGeoSet.geometries.length}) + Θέσεις ({currentGeoSet.geometries.filter(g => g.type === 'point').length})

-
- {currentGeoSet.geometries.map((geometry) => ( - - ))} +
+ {currentGeoSet.geometries + .filter(g => g.type === 'point') + .map((geometry) => ( + onOpenGeometryDetail?.(geometry.id)} + /> + ))}
@@ -209,9 +338,10 @@ export default function DetailPanel({ {currentGeometryGeoSet && ( )} {currentGeometry.description && ( @@ -318,18 +448,20 @@ export default function DetailPanel({
)} - {/* Comments Section */} -
- -
+ {/* Comments Section - only for geoset/geometry views */} + {detailType !== 'search-location' && detailId && ( +
+ +
+ )} diff --git a/src/components/consultations/GeoSetItem.tsx b/src/components/consultations/GeoSetItem.tsx index 4d30f42a..d0acfde6 100644 --- a/src/components/consultations/GeoSetItem.tsx +++ b/src/components/consultations/GeoSetItem.tsx @@ -1,18 +1,11 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; -import { Label } from "@/components/ui/label"; -import { ChevronDown, ChevronRight, Info, MessageCircle, TriangleAlert } from "lucide-react"; +import { ChevronDown, ChevronRight, MessageCircle, TriangleAlert } from "lucide-react"; import GeometryItem from "./GeometryItem"; import CommentSection from "./CommentSection"; import { Geometry } from "./types"; import { ConsultationCommentWithUpvotes } from "@/lib/db/consultations"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip" export type CheckboxState = 'checked' | 'indeterminate' | 'unchecked'; @@ -106,26 +99,22 @@ export default function GeoSetItem({ )} - - {hasInvalidGeometries && ( - - )} - + {name} + {hasInvalidGeometries && ( + + )} + {geosetCommentCount > 0 && ( + + + {geosetCommentCount} + + )} + {hasGeometries && ( + {/* Edit button - only show in editing mode for non-derived geometries */} {canEdit && onSelectForEdit && ( )} - - {/* Delete button - only show if there's a locally saved geometry */} {hasLocalSave && onDeleteSavedGeometry && ( diff --git a/src/components/consultations/LayerControlsPanel.tsx b/src/components/consultations/LayerControlsPanel.tsx index c4e2f30c..b10e15bb 100644 --- a/src/components/consultations/LayerControlsPanel.tsx +++ b/src/components/consultations/LayerControlsPanel.tsx @@ -1,25 +1,14 @@ +"use client"; + +import { useState } from "react"; import { Button } from "@/components/ui/button"; -import { X, Edit, Download, ChevronDown, ChevronUp, TriangleAlert } from "lucide-react"; +import { X, Edit, Download, MapPin, MessageCircle, ChevronRight, TriangleAlert, Search, Info } from "lucide-react"; import GeoSetItem, { CheckboxState } from "./GeoSetItem"; -import { Geometry } from "./types"; +import { RegulationData, CurrentUser, GeoSetData, SEARCH_COLORS } from './types'; import { ConsultationCommentWithUpvotes } from "@/lib/db/consultations"; -import { RegulationData } from './types'; import { CityWithGeometry } from '@/lib/db/cities'; - -interface GeoSetData { - id: string; - name: string; - description?: string; - color?: string; - geometries: Geometry[]; -} - -interface CurrentUser { - id?: string; - name?: string | null; - email?: string | null; - isSuperAdmin?: boolean; -} +import { LocationSelector } from "@/components/onboarding/selectors/LocationSelector"; +import { Location } from "@/lib/types/onboarding"; interface LayerControlsPanelProps { geoSets: GeoSetData[]; @@ -46,6 +35,12 @@ interface LayerControlsPanelProps { onToggleEditingMode?: (enabled: boolean) => void; onSelectGeometryForEdit?: (geometryId: string | null) => void; onDeleteSavedGeometry?: (geometryId: string) => void; + cityData?: CityWithGeometry | null; + onSearchLocation?: (location: Location) => void; + onRemoveSearchLocation?: (index: number) => void; + onNavigateToSearchLocation?: (location: Location, index: number) => void; + searchLocations?: Location[]; + onShowInfo?: () => void; } export default function LayerControlsPanel({ @@ -72,16 +67,41 @@ export default function LayerControlsPanel({ regulationData, onToggleEditingMode, onSelectGeometryForEdit, - onDeleteSavedGeometry + onDeleteSavedGeometry, + cityData, + onSearchLocation, + onRemoveSearchLocation, + onNavigateToSearchLocation, + searchLocations = [], + onShowInfo }: LayerControlsPanelProps) { - + + const [showSearch, setShowSearch] = useState(false); + // Use savedGeometries from props (now synced from ConsultationMap) const savedGeometriesData = savedGeometries || {}; - - const containsInvalidGeoSets = geoSets.some(gs => + + const containsInvalidGeoSets = geoSets.some(gs => gs.geometries.length > 0 && gs.geometries.every((g: any) => !g.geojson && g.type !== 'derived') ); - + + // Count comments per geoset + const getGeoSetCommentCount = (geoSetId: string) => { + if (!comments) return 0; + // Count direct geoset comments + geometry comments within this geoset + const geoSet = geoSets.find(gs => gs.id === geoSetId); + const geometryIds = geoSet?.geometries.map(g => g.id) || []; + return comments.filter(c => + (c.entityType === 'GEOSET' && c.entityId === geoSetId) || + (c.entityType === 'GEOMETRY' && geometryIds.includes(c.entityId)) + ).length; + }; + + // Count point geometries (excluding boundary polygons) + const getPointCount = (geoSet: GeoSetData) => { + return geoSet.geometries.filter(g => g.type === 'point').length; + }; + // Export function to merge original data with saved geometries const handleExportJSON = () => { try { @@ -89,11 +109,9 @@ export default function LayerControlsPanel({ console.error('No regulation data available for export'); return; } - - // Create a deep copy of the complete regulation data + const exportData = JSON.parse(JSON.stringify(regulationData)); - - // Merge in the saved geometries by updating the regulation items + exportData.regulation.forEach((item: any) => { if (item.type === 'geoset' && item.geometries) { item.geometries.forEach((geometry: any) => { @@ -103,8 +121,7 @@ export default function LayerControlsPanel({ }); } }); - - // Create downloadable file with a more descriptive name + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -114,45 +131,41 @@ export default function LayerControlsPanel({ a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); - - console.log('📥 Exported complete regulation JSON with merged geometries:', exportData); } catch (error) { console.error('Error exporting regulation JSON:', error); } }; - return ( -
-
-
-

Επίπεδα Χάρτη

- -
- - {/* Editing Mode Toggle - Only for super admins */} - {currentUser?.isSuperAdmin && onToggleEditingMode && ( -
+ // Editing mode: show the full detailed layer controls + if (isEditingMode) { + return ( +
+
+
+

Επίπεδα Χάρτη

- - {/* Editing Controls - Only visible when editing */} - {isEditingMode && ( +
+ + {currentUser?.isSuperAdmin && onToggleEditingMode && ( +
+ +
- {/* Export Button */}
+
+ )} +
+ +
e.stopPropagation()} + > + {geoSets.map((geoSet, geoSetIndex) => { + const color = geoSet.color || colors[geoSetIndex % colors.length]; + const checkboxState = getGeoSetCheckboxState(geoSet.id); + const isExpanded = expandedGeoSets.has(geoSet.id); + + const hasInvalidGeometries = geoSet.geometries.length > 0 && geoSet.geometries.every( + (g: any) => !g.geojson && g.type !== 'derived' + ); + + return ( + + ); + })} +
+ +
+
+ Σύνολο: {activeCount} στοιχεία ενεργά +
+
+
+ ); + } + + // Normal mode: simplified community picker + return ( +
+
+
+

Επιλέξτε Περιοχή

+
+ {onShowInfo && ( + )} + {currentUser?.isSuperAdmin && onToggleEditingMode && ( + + )} +
+
+ + {/* Address search */} + {cityData && (showSearch || searchLocations.length > 0) ? ( +
+ onSearchLocation?.(location)} + onRemove={(index) => onRemoveSearchLocation?.(index)} + city={cityData} + hideSelectedList + /> +
+ ) : ( + )} - {containsInvalidGeoSets && !isEditingMode && ( -
+ + {containsInvalidGeoSets && ( +
- Βάζουμε τις περιοχές στον χάρτη, και αυτό είναι μια δουλειά που θέλει χρόνο. - Δεδομένου ότι αυτή η σελίδα δημιουργήθηκε σε μόλις 48 ώρες, κάτι έπρεπε να μείνει πίσω. - Μέχρι τότε, ορισμένες περιοχές ενδέχεται να μην είναι ορατές. + Ορισμένες περιοχές ενδέχεται να μην είναι ακόμα ορατές στον χάρτη.
)}
e.stopPropagation()} > + {/* Selected search locations */} + {searchLocations.length > 0 && ( +
+
+ Οι τοποθεσίες σας +
+ {searchLocations.map((location, index) => { + const pinColor = SEARCH_COLORS[index % SEARCH_COLORS.length]; + return ( +
+ + +
+ ); + })} +
+ )} + {geoSets.map((geoSet, geoSetIndex) => { - // Use geoset's own color if available, otherwise fall back to colors array const color = geoSet.color || colors[geoSetIndex % colors.length]; - const checkboxState = getGeoSetCheckboxState(geoSet.id); - const isExpanded = expandedGeoSets.has(geoSet.id); - - const hasInvalidGeometries = geoSet.geometries.length > 0 && geoSet.geometries.every( - (g: any) => !g.geojson && g.type !== 'derived' - ); + const pointCount = getPointCount(geoSet); + const commentCount = getGeoSetCommentCount(geoSet.id); return ( - + onClick={() => onOpenGeoSetDetail(geoSet.id)} + className="w-full flex items-center gap-3 p-2.5 rounded-lg hover:bg-muted/60 transition-colors text-left group" + > +
+
+
+ {geoSet.name} +
+
+ {pointCount} προτεινόμενες θέσεις +
+
+
+ {commentCount > 0 && ( + + + {commentCount} + + )} + +
+ ); })}
- -
-
- Σύνολο: {activeCount} στοιχεία ενεργά -
-
); -} \ No newline at end of file +} diff --git a/src/components/consultations/types.ts b/src/components/consultations/types.ts index e92d851c..fba18e35 100644 --- a/src/components/consultations/types.ts +++ b/src/components/consultations/types.ts @@ -100,7 +100,28 @@ export interface RegulationData { ccEmails?: string[]; // Additional emails to CC on comments (optional) sources: Source[]; // Array of source documents (required in schema) referenceFormat?: ReferenceFormat; + defaultView?: 'map' | 'document'; // Default view mode (defaults to 'document') defaultVisibleGeosets?: string[]; // Array of geoset IDs that should be visible by default definitions?: Record; // Map from English IDs to term definitions regulation: RegulationItem[]; -} \ No newline at end of file +} + +// Shared interface for current user across consultation components +export interface CurrentUser { + id?: string; + name?: string | null; + email?: string | null; + isSuperAdmin?: boolean; +} + +// Shared interface for geoset data used across consultation components +export interface GeoSetData { + id: string; + name: string; + description?: string; + color?: string; + geometries: Geometry[]; +} + +// Colors for search location pins on the map +export const SEARCH_COLORS = ['#EF4444', '#8B5CF6', '#F59E0B', '#10B981', '#3B82F6']; \ No newline at end of file diff --git a/src/components/map/map.tsx b/src/components/map/map.tsx index 9431b99a..3cbdf096 100644 --- a/src/components/map/map.tsx +++ b/src/components/map/map.tsx @@ -34,6 +34,7 @@ interface MapProps { onFeatureClick?: (feature: GeoJSON.Feature) => void renderPopup?: (feature: GeoJSON.Feature) => React.ReactNode editingMode?: boolean + showStreetLabels?: boolean drawingMode?: 'point' | 'polygon' selectedGeometryForEdit?: string | null zoomToGeometry?: GeoJSON.Geometry | null @@ -58,6 +59,7 @@ const Map = memo(function Map({ onFeatureClick, renderPopup, editingMode = false, + showStreetLabels = false, drawingMode = 'point', selectedGeometryForEdit = null, zoomToGeometry @@ -263,9 +265,19 @@ const Map = memo(function Map({ } }, []); - const handleMapFeatureClick = useCallback((e: mapboxgl.MapMouseEvent & { features?: mapboxgl.MapboxGeoJSONFeature[] }) => { - if (e.features && e.features.length > 0 && onFeatureClick) { - onFeatureClick(e.features[0]); + const handleMapFeatureClick = useCallback((e: mapboxgl.MapMouseEvent) => { + if (!map.current || !onFeatureClick) return; + + // Query both layers but prioritize points over polygons + const pointFeatures = map.current.queryRenderedFeatures(e.point, { layers: ['feature-points'] }); + if (pointFeatures.length > 0) { + onFeatureClick(pointFeatures[0]); + return; + } + + const fillFeatures = map.current.queryRenderedFeatures(e.point, { layers: ['feature-fills'] }); + if (fillFeatures.length > 0) { + onFeatureClick(fillFeatures[0]); } }, [onFeatureClick]); @@ -443,24 +455,37 @@ const Map = memo(function Map({ } }); + // Polygon labels (e.g. community names) - centered, prominent map.current?.addLayer({ 'id': 'feature-labels', 'type': 'symbol', 'source': 'features', - 'filter': ['!=', ['geometry-type'], 'Point'], // Exclude points from labels + 'filter': ['!=', ['geometry-type'], 'Point'], 'layout': { 'text-field': ['get', 'label'], - 'text-size': 12, - 'text-anchor': 'left', - 'text-offset': [1, 0], - 'text-padding': 5, - 'text-optional': true, - 'text-max-width': 24 + 'text-size': [ + 'interpolate', ['linear'], ['zoom'], + 11, 13, + 14, 16, + 17, 12 + ], + 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], + 'text-anchor': 'center', + 'text-offset': [0, 0], + 'text-padding': 8, + 'text-optional': false, + 'text-max-width': 10, + 'text-allow-overlap': true }, 'paint': { - 'text-color': '#000000', + 'text-color': '#1e3a5f', 'text-halo-color': '#ffffff', - 'text-halo-width': 2 + 'text-halo-width': 2.5, + 'text-opacity': [ + 'interpolate', ['linear'], ['zoom'], + 16, 1, + 17, 0.3 + ] } }); @@ -480,10 +505,73 @@ const Map = memo(function Map({ } }); - // Add event listeners + // Point labels layer - show address/name labels when zoomed in + map.current?.addLayer({ + 'id': 'feature-point-labels', + 'type': 'symbol', + 'source': 'features', + 'filter': ['all', + ['==', ['geometry-type'], 'Point'], + ['!=', ['get', 'alwaysShowLabel'], true] + ], + 'minzoom': 14, + 'layout': { + 'text-field': ['get', 'label'], + 'text-size': [ + 'interpolate', ['linear'], ['zoom'], + 14, 10, + 17, 13 + ], + 'text-anchor': 'top', + 'text-offset': [0, 0.8], + 'text-padding': 3, + 'text-optional': true, + 'text-max-width': 16, + 'text-allow-overlap': false + }, + 'paint': { + 'text-color': '#1f2937', + 'text-halo-color': '#ffffff', + 'text-halo-width': 1.5, + 'text-opacity': [ + 'step', ['zoom'], + 0, // hidden below zoom 15 + 15, 1 // fully visible at zoom 15+ + ] + } + }); + + // Always-visible point labels (e.g. search pins) + map.current?.addLayer({ + 'id': 'feature-pinned-labels', + 'type': 'symbol', + 'source': 'features', + 'filter': ['all', + ['==', ['geometry-type'], 'Point'], + ['==', ['get', 'alwaysShowLabel'], true] + ], + 'layout': { + 'text-field': ['get', 'label'], + 'text-size': 12, + 'text-anchor': 'top', + 'text-offset': [0, 1], + 'text-padding': 2, + 'text-optional': false, + 'text-max-width': 18, + 'text-allow-overlap': true, + 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'] + }, + 'paint': { + 'text-color': '#991b1b', + 'text-halo-color': '#ffffff', + 'text-halo-width': 2 + } + }); + + // Add click listener on the whole map (not per-layer) + // The handler queries features and prioritizes points over polygons if (onFeatureClick) { - map.current?.on('click', 'feature-fills', handleMapFeatureClick); - map.current?.on('click', 'feature-points', handleMapFeatureClick); + map.current?.on('click', handleMapFeatureClick); } map.current?.on('mousemove', 'feature-fills', handleFeatureHover); @@ -558,11 +646,12 @@ const Map = memo(function Map({ } }, [zoom]); - // Handle editing mode - add/remove street name layers + // Handle street labels - show in editing mode or when explicitly enabled + const shouldShowStreetLabels = editingMode || showStreetLabels; useEffect(() => { if (!map.current || !isInitialized.current) return; - if (editingMode) { + if (shouldShowStreetLabels) { // Add street data source if it doesn't exist if (!map.current.getSource('mapbox-streets')) { map.current.addSource('mapbox-streets', { @@ -709,96 +798,15 @@ const Map = memo(function Map({ }); } - // Add place labels (enhanced to show more types) - if (!map.current.getLayer('place-labels')) { - map.current.addLayer({ - 'id': 'place-labels', - 'type': 'symbol', - 'source': 'mapbox-streets', - 'source-layer': 'place_label', - 'layout': { - 'text-field': ['get', 'name'], - 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], - 'text-size': [ - 'interpolate', - ['linear'], - ['zoom'], - 10, 11, - 16, 14 - ], - 'text-anchor': 'center' - }, - 'paint': { - 'text-color': '#333333', - 'text-halo-color': '#ffffff', - 'text-halo-width': 2, - 'text-opacity': [ - 'interpolate', - ['linear'], - ['zoom'], - 10, 0.8, - 16, 1 - ] - }, - 'filter': [ - 'all', - ['has', 'name'], - ['in', 'type', 'neighbourhood', 'suburb', 'hamlet', 'village', 'locality'] - ] - }); - } - - // Add POI labels for landmarks - if (!map.current.getLayer('poi-labels')) { - map.current.addLayer({ - 'id': 'poi-labels', - 'type': 'symbol', - 'source': 'mapbox-streets', - 'source-layer': 'poi_label', - 'layout': { - 'text-field': ['get', 'name'], - 'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'], - 'text-size': [ - 'interpolate', - ['linear'], - ['zoom'], - 14, 10, - 18, 12 - ], - 'text-anchor': 'top', - 'text-offset': [0, 0.5], - 'icon-image': ['get', 'maki'], - 'icon-size': 0.8 - }, - 'paint': { - 'text-color': '#555555', - 'text-halo-color': '#ffffff', - 'text-halo-width': 1, - 'text-opacity': [ - 'interpolate', - ['linear'], - ['zoom'], - 14, 0.7, - 18, 1 - ] - }, - 'filter': [ - 'all', - ['has', 'name'], - ['in', 'class', 'park', 'education', 'medical', 'shopping', 'lodging'] - ], - 'minzoom': 14 - }); - } + // Note: place-labels and poi-labels intentionally omitted + // Our community polygon labels serve the same purpose without noise } else { - // Remove all street and place layers when exiting editing mode + // Remove all street layers when disabling const layersToRemove = [ 'major-street-labels', 'street-labels', - 'minor-road-labels', - 'place-labels', - 'poi-labels' + 'minor-road-labels' ]; layersToRemove.forEach(layerId => { @@ -812,7 +820,7 @@ const Map = memo(function Map({ map.current.removeSource('mapbox-streets'); } } - }, [editingMode]); + }, [shouldShowStreetLabels, mapReady]); // Handle Mapbox GL Draw setup for editing mode useEffect(() => { @@ -953,19 +961,16 @@ const Map = memo(function Map({ [bounds.bounds.maxLng, bounds.bounds.maxLat] ], { padding: padding, - maxZoom: 16 // Don't zoom in too much for small geometries + maxZoom: 17 }); - - console.log('🔍 Zoomed to geometry bounds:', bounds); } else { - // For single points, just center on them + // For single points, zoom in close enough to show address labels if (geometry.type === 'Point') { const coordinates = geometry.coordinates as [number, number]; map.current?.easeTo({ center: coordinates, - zoom: 15 + zoom: 16 }); - console.log('🔍 Centered on point:', coordinates); } } } catch (error) { @@ -990,6 +995,7 @@ const Map = memo(function Map({ prevProps.animateRotation === nextProps.animateRotation && prevProps.pitch === nextProps.pitch && prevProps.editingMode === nextProps.editingMode && + prevProps.showStreetLabels === nextProps.showStreetLabels && prevProps.drawingMode === nextProps.drawingMode && prevProps.selectedGeometryForEdit === nextProps.selectedGeometryForEdit && prevProps.zoomToGeometry === nextProps.zoomToGeometry && diff --git a/src/components/onboarding/selectors/LocationSelector.tsx b/src/components/onboarding/selectors/LocationSelector.tsx index b1c8089d..d768bbf4 100644 --- a/src/components/onboarding/selectors/LocationSelector.tsx +++ b/src/components/onboarding/selectors/LocationSelector.tsx @@ -16,6 +16,7 @@ interface LocationSelectorProps { onRemove: (index: number) => void; city: CityWithGeometry; onLocationClick?: (location: Location) => void; + hideSelectedList?: boolean; } export function LocationSelector({ @@ -23,7 +24,8 @@ export function LocationSelector({ onSelect, onRemove, city, - onLocationClick + onLocationClick, + hideSelectedList = false }: LocationSelectorProps) { const [inputValue, setInputValue] = useState(''); const [suggestions, setSuggestions] = useState([]); @@ -224,7 +226,7 @@ export function LocationSelector({ )}
- {selectedLocations.length > 0 ? ( + {!hideSelectedList && (selectedLocations.length > 0 ? (
Επιλεγμένες τοποθεσίες ({selectedLocations.length})
@@ -262,7 +264,7 @@ export function LocationSelector({

Δεν έχετε επιλέξει τοποθεσίες ακόμα.

Χρησιμοποιήστε την αναζήτηση για να προσθέσετε τοποθεσίες ενδιαφέροντος.

- )} + ))}
); } \ No newline at end of file diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 279c544f..7264d896 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -403,7 +403,7 @@ export function calculateGeometryBounds(geometry: any): GeometryBounds { let minLat = Infinity, maxLat = -Infinity; // Check for supported geometry types - if (!['Point', 'Polygon', 'MultiPolygon'].includes(geometry.type)) { + if (!['Point', 'Polygon', 'MultiPolygon', 'GeometryCollection'].includes(geometry.type)) { console.warn(`[Location] Unsupported geometry type: ${geometry.type}, using default coordinates`); return DEFAULT_RETURN; } @@ -418,16 +418,26 @@ export function calculateGeometryBounds(geometry: any): GeometryBounds { }); }; - if (geometry.type === 'Polygon') { - processCoordinates(geometry.coordinates[0]); - } else if (geometry.type === 'MultiPolygon') { - geometry.coordinates.forEach((polygon: number[][][]) => { - processCoordinates(polygon[0]); - }); - } else if (geometry.type === 'Point') { - const [lng, lat] = geometry.coordinates; - minLng = maxLng = lng; - minLat = maxLat = lat; + const processGeometry = (geom: any) => { + if (geom.type === 'Polygon') { + processCoordinates(geom.coordinates[0]); + } else if (geom.type === 'MultiPolygon') { + geom.coordinates.forEach((polygon: number[][][]) => { + processCoordinates(polygon[0]); + }); + } else if (geom.type === 'Point') { + const [lng, lat] = geom.coordinates; + minLng = Math.min(minLng, lng); + maxLng = Math.max(maxLng, lng); + minLat = Math.min(minLat, lat); + maxLat = Math.max(maxLat, lat); + } + }; + + if (geometry.type === 'GeometryCollection') { + geometry.geometries.forEach(processGeometry); + } else { + processGeometry(geometry); } const bounds = { From b3a142dbea2646fbfe9c780723cbc3445fdbaccf Mon Sep 17 00:00:00 2001 From: kouloumos Date: Mon, 16 Feb 2026 11:10:25 +0200 Subject: [PATCH 12/20] fix: use verified domain for consultation emails and show recipients in dev banner Use hardcoded opencouncil.gr sender domain instead of deriving from NEXTAUTH_URL, which breaks on preview deployments (unverified subdomain). Add a dev banner to overridden emails showing original To and CC recipients. --- src/lib/email/consultation.ts | 2 +- src/lib/email/resend.ts | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/lib/email/consultation.ts b/src/lib/email/consultation.ts index 5694cd0e..aefa2d89 100644 --- a/src/lib/email/consultation.ts +++ b/src/lib/email/consultation.ts @@ -77,7 +77,7 @@ export async function sendConsultationCommentEmail(data: ConsultationCommentEmai } const result = await sendEmail({ - from: `OpenCouncil `, + from: 'OpenCouncil ', to: municipalityEmail, cc: allCcEmails, subject, diff --git a/src/lib/email/resend.ts b/src/lib/email/resend.ts index 9c43ae06..02fb36ad 100644 --- a/src/lib/email/resend.ts +++ b/src/lib/email/resend.ts @@ -35,14 +35,23 @@ export async function sendEmail(params: EmailParams) { // Redirect email to dev address to = devEmailOverride; cc = undefined; // Clear CC to avoid sending to real addresses - + // Modify subject to include original recipient subject = `[DEV → ${originalTo}] ${subject}`; - + + // Prepend a dev banner to the email body showing original recipients + const ccList = originalCc ? (Array.isArray(originalCc) ? originalCc.join(', ') : originalCc) : 'none'; + const devBanner = `
+ 🔧 Dev Email Override
+ To: ${originalTo}
+ CC: ${ccList} +
`; + html = devBanner + html; + // Log for debugging console.log(`📧 Dev mode: Redirecting email from "${originalTo}" to "${devEmailOverride}"`); if (originalCc) { - console.log(` Original CC: ${originalCc}`); + console.log(` Original CC: ${Array.isArray(originalCc) ? originalCc.join(', ') : originalCc}`); } } From 3cee77ebd3b36e13196cc997cdf89566f2dff3ca Mon Sep 17 00:00:00 2001 From: kouloumos Date: Mon, 16 Feb 2026 15:31:49 +0200 Subject: [PATCH 13/20] fix: make .next/server/app writable in preview deployments for ISR Pre-rendered pages built during nix build contain stale data (e.g. "Test City" from the build-time DB). The preview-start script symlinked the entire .next/server directory from the read-only nix store, preventing Next.js ISR from updating pages with fresh data at runtime (EROFS error). Copy .next/server/app/ to a writable location instead of symlinking so ISR can regenerate pre-rendered pages after the first real request. --- flake.nix | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index 157e54e7..89591506 100644 --- a/flake.nix +++ b/flake.nix @@ -1295,12 +1295,32 @@ EOF fi done - # Symlink .next contents except cache + # Symlink .next contents except cache and server/app. + # server/app must be writable so Next.js ISR can update + # pre-rendered pages (otherwise build-time data is served forever). mkdir -p "$WORK_DIR/.next" for item in "$APP_DIR/.next"/*; do name="$(basename "$item")" [ "$name" = "cache" ] && continue - ln -sfn "$item" "$WORK_DIR/.next/$name" + if [ "$name" = "server" ]; then + # Remove old symlink from previous script version (upgrade path) + [ -L "$WORK_DIR/.next/server" ] && rm -f "$WORK_DIR/.next/server" + mkdir -p "$WORK_DIR/.next/server" + for sitem in "$APP_DIR/.next/server"/*; do + sname="$(basename "$sitem")" + if [ "$sname" = "app" ]; then + # Remove stale copy from previous deployment, then copy fresh. + # Must chmod after cp since nix store files are read-only. + rm -rf "$WORK_DIR/.next/server/app" + cp -r "$sitem" "$WORK_DIR/.next/server/app" + chmod -R u+w "$WORK_DIR/.next/server/app" + else + ln -sfn "$sitem" "$WORK_DIR/.next/server/$sname" + fi + done + else + ln -sfn "$item" "$WORK_DIR/.next/$name" + fi done cd "$WORK_DIR" From 752d0b2bf3f395dfaf009a5d0e6831ac017491aa Mon Sep 17 00:00:00 2001 From: kouloumos Date: Mon, 16 Feb 2026 17:31:47 +0200 Subject: [PATCH 14/20] feat: render consultation panels as bottom sheet drawers on mobile Convert DetailPanel, LayerControlsPanel, and CommentsOverviewSheet to use vaul Drawer on mobile (<768px) instead of side Sheet/Dialog. The welcome dialog switches to Credenza for automatic desktop/mobile adaptation. - Non-modal drawers keep map interactive behind the sheet - Only one bottom sheet open at a time (opening detail closes controls) - Map zoom padding shifts content upward when drawer is open - ViewToggleButton FAB repositions above any open drawer - LayerControlsButton shows compact icon-only variant on mobile - DrawerContent gains hideOverlay prop for non-modal usage --- docs/guides/consultations.md | 9 + .../consultations/CommentsOverviewSheet.tsx | 172 ++++++++++++ .../consultations/ConsultationMap.tsx | 106 ++++++- .../consultations/ConsultationViewer.tsx | 151 +++++----- src/components/consultations/DetailPanel.tsx | 262 +++++++++++++++++- .../consultations/LayerControlsButton.tsx | 19 +- .../consultations/LayerControlsPanel.tsx | 36 ++- .../consultations/ViewToggleButton.tsx | 10 +- src/components/map/map.tsx | 16 +- src/components/ui/credenza.tsx | 168 +++++++++++ src/components/ui/drawer.tsx | 6 +- 11 files changed, 846 insertions(+), 109 deletions(-) create mode 100644 src/components/ui/credenza.tsx diff --git a/docs/guides/consultations.md b/docs/guides/consultations.md index ca4d48ab..28b1bebe 100644 --- a/docs/guides/consultations.md +++ b/docs/guides/consultations.md @@ -272,6 +272,15 @@ For local development, you can place regulation JSON files in the `public/` dire 9. Export produces a complete updated `regulation.json` merging local edits with original data 10. Only super-administrators can access editing mode (via a small edit icon in the community picker header) +### Mobile Experience +1. On mobile (<768px), overlay panels render as bottom sheet drawers (via vaul) instead of side sheets / dialogs — providing a native iOS-style feel +2. The welcome dialog uses Credenza (Dialog on desktop, Drawer on mobile) for automatic switching +3. DetailPanel, CommentsOverviewSheet, and LayerControlsPanel use an inline `if (isMobile)` pattern: `Sheet` on desktop, `Drawer` on mobile +4. Non-modal drawers (`DetailPanel`, `LayerControlsPanel`) keep the map interactive behind the sheet; modal drawers (`CommentsOverviewSheet`) block interaction +5. Only one bottom sheet is open at a time — opening a detail panel auto-closes the layer controls +6. Map zoom padding shifts content upward on mobile when a drawer is open so geometries aren't hidden behind the bottom sheet +7. The `ViewToggleButton` FAB repositions above any open drawer to avoid overlap + ### Navigation 1. URL hash anchors (`#chapter-1`, `#article-3`, `#geoset-prohibited_areas`) enable deep linking to specific entities 2. `{REF:id}` links in markdown content navigate to the referenced entity, switching between document and map views as needed diff --git a/src/components/consultations/CommentsOverviewSheet.tsx b/src/components/consultations/CommentsOverviewSheet.tsx index 997474ef..04c861c4 100644 --- a/src/components/consultations/CommentsOverviewSheet.tsx +++ b/src/components/consultations/CommentsOverviewSheet.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from "react"; import { useSession } from "next-auth/react"; import sanitizeHtml from 'sanitize-html'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; +import { Drawer, DrawerContent, DrawerTitle, DrawerDescription } from "@/components/ui/drawer"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; @@ -11,6 +12,7 @@ import { Heart, MessageSquare, ChevronDown, Clock, TrendingUp, ChevronUp } from import { formatDistanceToNow } from "date-fns"; import { el } from "date-fns/locale"; import { cn } from "@/lib/utils"; +import { useIsMobile } from "@/hooks/use-mobile"; import { ConsultationCommentWithUpvotes } from "@/lib/db/consultations"; import { RegulationData } from "./types"; @@ -33,6 +35,7 @@ export default function CommentsOverviewSheet({ totalCount, regulationData }: CommentsOverviewSheetProps) { + const isMobile = useIsMobile(); const { data: session } = useSession(); const [sortBy, setSortBy] = useState('recent'); const [upvoting, setUpvoting] = useState(null); @@ -208,6 +211,175 @@ export default function CommentsOverviewSheet({ return false; }; + const renderContent = () => ( + <> + {/* Header */} +
+
+
+
+ ΣΧΟΛΙΑ +
+
+ {totalCount} σχόλια συνολικά +
+
+
+
+ + {/* Sort Controls */} +
+ + +
+ + {/* Comments List */} +
+ {sortedComments.length === 0 ? ( +
+ +

Δεν υπάρχουν σχόλια ακόμα

+
+ ) : ( + sortedComments.map((comment, index) => ( +
+ {/* Reference Box */} +
handleReferenceClick(e, comment)} + className="bg-muted/30 border border-muted/50 rounded-md p-2 cursor-pointer hover:bg-muted/50 transition-colors" + > +
+ + {getEntityTypeLabel(comment.entityType)} + + + {getEntityTitle(comment)} + +
+
+ + {/* Comment */} +
+ {/* Upvote Section */} +
+ + + {comment.upvoteCount || 0} + +
+ + {/* Comment Content */} +
+
handleCommentClick(comment)} + > +
+ + {comment.user?.name || 'Ανώνυμος'} + + + {formatDistanceToNow(new Date(comment.createdAt), { + addSuffix: true, + locale: el + })} + +
+
+
+ + {/* Show More/Less Button */} + {isCommentTruncated(comment.body) && ( + + )} +
+
+ + {/* Separator between comments */} + {index < sortedComments.length - 1 && ( + + )} +
+ )) + )} +
+ + ); + + if (isMobile) { + return ( + !open && onClose()}> + + {totalCount} σχόλια συνολικά + Επισκόπηση σχολίων + {renderContent()} + + + ); + } + return ( !open && onClose()}> void; + onDrawerStateChange?: (isOpen: boolean) => void; } // Generate distinct colors for different geosets @@ -205,10 +208,12 @@ export default function ConsultationMap({ currentUser, consultationId, cityId, - onShowInfo + onShowInfo, + onDrawerStateChange }: ConsultationMapProps) { const router = useRouter(); const searchParams = useSearchParams(); + const isMobile = useIsMobile(); const [isControlsOpen, setIsControlsOpen] = useState(true); const [enabledGeoSets, setEnabledGeoSets] = useState>(new Set()); @@ -240,6 +245,12 @@ export default function ConsultationMap({ // Ref to prevent hash handler from overriding search-location detail mode const isInSearchLocationMode = useRef(false); + // Report drawer state to parent (for ViewToggleButton positioning) + useEffect(() => { + const anyDrawerOpen = isControlsOpen || detailType !== null; + onDrawerStateChange?.(anyDrawerOpen); + }, [isControlsOpen, detailType, onDrawerStateChange]); + // Load saved geometries from localStorage on mount and when editing mode changes useEffect(() => { const loadSavedGeometries = () => { @@ -424,6 +435,7 @@ export default function ConsultationMap({ // Functions to manage detail panel const openGeoSetDetail = (geoSetId: string) => { isInSearchLocationMode.current = false; + if (isMobile) setIsControlsOpen(false); setDetailType('geoset'); setDetailId(geoSetId); setSelectedSearchLocationIndex(null); @@ -433,6 +445,7 @@ export default function ConsultationMap({ const openGeometryDetail = (geometryId: string) => { isInSearchLocationMode.current = false; + if (isMobile) setIsControlsOpen(false); setDetailType('geometry'); setDetailId(geometryId); setSelectedSearchLocationIndex(null); @@ -442,6 +455,7 @@ export default function ConsultationMap({ const openSearchLocationDetail = (location: Location, locationIndex: number) => { isInSearchLocationMode.current = true; + if (isMobile) setIsControlsOpen(false); setDetailType('search-location'); setDetailId(`search-location-${locationIndex}`); setSelectedSearchLocationIndex(locationIndex); @@ -778,6 +792,12 @@ export default function ConsultationMap({ const zoomToGeometry = zoomGeometry; + // On mobile, offset zoom target upward to account for bottom sheet covering ~40% of screen + const anyDrawerOpen = isControlsOpen || detailType !== null; + const mapZoomPadding = isMobile && anyDrawerOpen + ? { top: 60, bottom: Math.round(window.innerHeight * 0.35), left: 40, right: 40 } + : 100; + return (
{/* Map */} @@ -793,6 +813,7 @@ export default function ConsultationMap({ drawingMode={drawingMode} selectedGeometryForEdit={selectedGeometryForEdit} zoomToGeometry={zoomToGeometry} + zoomPadding={mapZoomPadding} /> {/* Editing Tools Panel */} @@ -820,7 +841,81 @@ export default function ConsultationMap({ )} {/* Layer Controls Panel */} - {isControlsOpen && geoSets.length > 0 && ( + {isMobile && geoSets.length > 0 ? ( + + + Επιλέξτε Περιοχή + Επίπεδα χάρτη + setIsControlsOpen(false)} + onToggleGeoSet={toggleGeoSet} + onToggleExpansion={toggleGeoSetExpansion} + onToggleGeometry={toggleGeometry} + getGeoSetCheckboxState={getGeoSetCheckboxState} + onOpenGeoSetDetail={openGeoSetDetail} + onOpenGeometryDetail={openGeometryDetail} + contactEmail={regulationData?.contactEmail} + comments={comments} + consultationId={consultationId} + cityId={cityId} + currentUser={currentUser} + isEditingMode={isEditingMode} + selectedGeometryForEdit={selectedGeometryForEdit} + savedGeometries={savedGeometries} + regulationData={regulationData} + onToggleEditingMode={(enabled: boolean) => { + setIsEditingMode(enabled); + if (!enabled) { + setSelectedGeometryForEdit(null); + setSelectedLocations([]); + } + }} + onSelectGeometryForEdit={handleSelectGeometryForEdit} + onDeleteSavedGeometry={handleDeleteSavedGeometry} + cityData={cityData} + searchLocations={searchLocations} + onNavigateToSearchLocation={(location, index) => { + openSearchLocationDetail(location, index); + const pointGeometry: GeoJSON.Geometry = { + type: 'Point', + coordinates: location.coordinates + }; + setZoomGeometry(pointGeometry); + }} + onSearchLocation={(location) => { + const newIndex = searchLocations.length; + setSearchLocations(prev => [...prev, location]); + openSearchLocationDetail(location, newIndex); + const pointGeometry: GeoJSON.Geometry = { + type: 'Point', + coordinates: location.coordinates + }; + setZoomGeometry(pointGeometry); + }} + onRemoveSearchLocation={(index) => { + setSearchLocations(prev => prev.filter((_, i) => i !== index)); + if (selectedSearchLocationIndex === index) { + closeDetail(); + } else if (selectedSearchLocationIndex !== null && selectedSearchLocationIndex > index) { + setSelectedSearchLocationIndex(selectedSearchLocationIndex - 1); + } + }} + onShowInfo={onShowInfo} + /> + + + ) : isControlsOpen && geoSets.length > 0 && ( { - // Open detail panel for this search location showing nearest points openSearchLocationDetail(location, index); - // Zoom to the clicked location at street level const pointGeometry: GeoJSON.Geometry = { type: 'Point', coordinates: location.coordinates @@ -866,11 +958,9 @@ export default function ConsultationMap({ setZoomGeometry(pointGeometry); }} onSearchLocation={(location) => { - // Add to search locations list and open its detail panel const newIndex = searchLocations.length; setSearchLocations(prev => [...prev, location]); openSearchLocationDetail(location, newIndex); - // Zoom to the searched point at street level const pointGeometry: GeoJSON.Geometry = { type: 'Point', coordinates: location.coordinates @@ -879,11 +969,9 @@ export default function ConsultationMap({ }} onRemoveSearchLocation={(index) => { setSearchLocations(prev => prev.filter((_, i) => i !== index)); - // If we removed the currently viewed search location, close the detail if (selectedSearchLocationIndex === index) { closeDetail(); } else if (selectedSearchLocationIndex !== null && selectedSearchLocationIndex > index) { - // Adjust index if we removed one before it setSelectedSearchLocationIndex(selectedSearchLocationIndex - 1); } }} diff --git a/src/components/consultations/ConsultationViewer.tsx b/src/components/consultations/ConsultationViewer.tsx index 201c8a14..646ce913 100644 --- a/src/components/consultations/ConsultationViewer.tsx +++ b/src/components/consultations/ConsultationViewer.tsx @@ -5,7 +5,7 @@ import Image from "next/image"; import { useRouter, useSearchParams } from "next/navigation"; import { MapPin, Map, FileText, MessageSquare } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Credenza, CredenzaContent, CredenzaHeader, CredenzaTitle, CredenzaDescription, CredenzaBody } from "@/components/ui/credenza"; import ConsultationHeader from "./ConsultationHeader"; import ConsultationMap from "./ConsultationMap"; import ConsultationDocument from "./ConsultationDocument"; @@ -69,6 +69,9 @@ export default function ConsultationViewer({ // Don't show by default if URL has a hash (direct link to a community) const [showMapSummary, setShowMapSummary] = useState(true); + // Track whether any drawer is open in the map view (for ViewToggleButton positioning on mobile) + const [mapDrawerOpen, setMapDrawerOpen] = useState(false); + // Hide welcome dialog if URL has a hash on mount (direct link) useEffect(() => { if (window.location.hash) { @@ -356,105 +359,109 @@ export default function ConsultationViewer({ consultationId={consultationId} cityId={cityId} onShowInfo={() => setShowMapSummary(true)} + onDrawerStateChange={setMapDrawerOpen} />
{/* Welcome dialog */} - - + + {/* Logos */} -
- {cityLogoUrl && ( -
+ +
+ {cityLogoUrl && ( +
+ {cityName +
+ )} +
{cityName
- )} -
- OpenCouncil
-
- -
- ΔΙΑΒΟΥΛΕΥΣΗ -
- - {regulationData?.title} - - - Περίληψη διαβούλευσης - -
- {regulationData?.summary && ( -
- { - setShowMapSummary(false); - handleReferenceClick(id); - }} - regulationData={regulationData} - /> -
- )} -
- -
+ +
+ ΔΙΑΒΟΥΛΕΥΣΗ +
+ + {regulationData?.title} + + + Περίληψη διαβούλευσης + +
+ {regulationData?.summary && ( +
+ { + setShowMapSummary(false); + handleReferenceClick(id); + }} + regulationData={regulationData} + /> +
+ )} +
- {comments.length > 0 && ( +
- )} + {comments.length > 0 && ( + + )} +
-
-

- Σχολιάστε και εκφράστε τη γνώμη σας -- τα σχόλια αποστέλλονται απευθείας στον Δήμο ως επίσημες παρατηρήσεις. -

- -
+

+ Σχολιάστε και εκφράστε τη γνώμη σας -- τα σχόλια αποστέλλονται απευθείας στον Δήμο ως επίσημες παρατηρήσεις. +

+ + + {/* Floating action button for view toggle */}
); diff --git a/src/components/consultations/DetailPanel.tsx b/src/components/consultations/DetailPanel.tsx index e1a90d68..18cf907d 100644 --- a/src/components/consultations/DetailPanel.tsx +++ b/src/components/consultations/DetailPanel.tsx @@ -1,9 +1,10 @@ import { useMemo } from "react"; -import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; +import { Drawer, DrawerContent, DrawerTitle, DrawerDescription } from "@/components/ui/drawer"; import { AlertTriangle, Save, ChevronLeft, MessageCircle, MapPin } from "lucide-react"; import { cn } from "@/lib/utils"; +import { useIsMobile } from "@/hooks/use-mobile"; import PermalinkButton from "./PermalinkButton"; import MarkdownContent from "./MarkdownContent"; import CommentSection from "./CommentSection"; @@ -69,6 +70,7 @@ export default function DetailPanel({ savedGeometries, searchLocation }: DetailPanelProps) { + const isMobile = useIsMobile(); // Find the current detail data const currentGeoSet = detailType === 'geoset' ? geoSets.find(gs => gs.id === detailId) : null; @@ -227,8 +229,262 @@ export default function DetailPanel({ ); }; + const panelOpen = isOpen && !!detailType && (!!detailId || detailType === 'search-location'); + + const renderContent = () => ( + <> + {/* Header */} +
+
+
+
+ {getTitleData().label} +
+
+ {getTitleData().title} +
+
+ {detailType !== 'search-location' && detailId && ( + + )} +
+
+ + {/* Content */} +
e.stopPropagation()} + > + {/* Search Location Details - shows nearest points from ALL communities */} + {detailType === 'search-location' && searchLocation && ( +
+ {nearbyPoints.length > 0 ? ( + <> +

+ Κοντινές θέσεις συλλογής σε ακτίνα {NEARBY_DISTANCE_LIMIT}μ. +

+ + + +
+

+ Κοντινές Θέσεις ({nearbyPoints.length}) +

+
+ {nearbyPoints.map(({ geometry, geoSetName, distance }) => ( + onOpenGeometryDetail?.(geometry.id)} + subtitle={geoSetName} + rightLabel={formatDistance(distance)} + /> + ))} +
+
+ + ) : ( +
+ +

+ Δεν βρέθηκαν θέσεις συλλογής σε ακτίνα {NEARBY_DISTANCE_LIMIT}μ. +

+
+ )} +
+ )} + + {/* GeoSet Details */} + {currentGeoSet && ( +
+
+ {currentGeoSet.description && ( + + )} +
+ + + +
+

+ Θέσεις ({currentGeoSet.geometries.filter(g => g.type === 'point').length}) +

+
+ {currentGeoSet.geometries + .filter(g => g.type === 'point') + .map((geometry) => ( + onOpenGeometryDetail?.(geometry.id)} + /> + ))} +
+
+
+ )} + + {/* Geometry Details */} + {currentGeometry && ( +
+
+ {currentGeometryGeoSet && ( + + )} + {currentGeometry.description && ( +
+

Περιγραφή

+ +
+ )} + {currentGeometry.textualDefinition && ( +
+

Γεωγραφικός Προσδιορισμός

+ +
+ )} +
+ + {/* Geometric Information */} + +
+

Πληροφορίες Γεωμετρίας

+
+
Τύπος: {getGeometryTypeLabel(currentGeometry.type)}
+ + {/* Show saved geometry information */} + {savedGeometries?.[currentGeometry.id] && ( +
+ + Έχει αποθηκευτεί τοπικά νέα γεωμετρία +
+ )} + + {/* Show error for incomplete non-derived geometries */} + {currentGeometry.type !== 'derived' && (!('geojson' in currentGeometry) || !currentGeometry.geojson) && !savedGeometries?.[currentGeometry.id] && ( +
+ + Η γεωμετρία δεν έχει συντεταγμένες και δεν εμφανίζεται στον χάρτη +
+ )} + + {currentGeometry.type === 'derived' ? ( + <> +
Μέθοδος: {currentGeometry.derivedFrom.operation === 'buffer' ? 'Ζώνη Buffer' : 'Αφαίρεση'}
+ {currentGeometry.derivedFrom.operation === 'buffer' && ( + <> +
Πηγή: {currentGeometry.derivedFrom.sourceGeoSetId}
+
Ακτίνα: {currentGeometry.derivedFrom.radius} {currentGeometry.derivedFrom.units || 'meters'}
+ + )} + {currentGeometry.derivedFrom.operation === 'difference' && ( + <> +
Βάση: {currentGeometry.derivedFrom.baseGeoSetId}
+
Αφαίρεση: {currentGeometry.derivedFrom.subtractGeoSetIds.join(', ')}
+ + )} + + ) : ( + <> + {/* Show saved geometry data if available */} + {savedGeometries?.[currentGeometry.id] ? ( + <> + {savedGeometries?.[currentGeometry.id].type === 'Point' && ( +
+ Συντεταγμένες (τοπικά): {savedGeometries?.[currentGeometry.id].coordinates[1].toFixed(6)}, {savedGeometries?.[currentGeometry.id].coordinates[0].toFixed(6)} +
+ )} + {savedGeometries?.[currentGeometry.id].type === 'Polygon' && ( +
+ Σημεία (τοπικά): {savedGeometries?.[currentGeometry.id].coordinates[0]?.length - 1 || 0} vertices +
+ )} + + ) : ( + <> + {'geojson' in currentGeometry && currentGeometry.geojson && currentGeometry.geojson.type === 'Point' && ( +
+ Συντεταγμένες: {currentGeometry.geojson.coordinates[1].toFixed(6)}, {currentGeometry.geojson.coordinates[0].toFixed(6)} +
+ )} + {'geojson' in currentGeometry && currentGeometry.geojson && currentGeometry.geojson.type === 'Polygon' && ( +
+ Σημεία: {currentGeometry.geojson.coordinates[0]?.length - 1 || 0} vertices +
+ )} + + )} + + )} +
+
+
+ )} + + {/* Comments Section - only for geoset/geometry views */} + {detailType !== 'search-location' && detailId && ( +
+ +
+ )} +
+ + ); + + if (isMobile) { + return ( + !open && onClose()} + modal={false} + shouldScaleBackground={false} + > + + {getTitleData().title} + Λεπτομέρειες + {renderContent()} + + + ); + } + return ( - !open && onClose()}> + !open && onClose()}> ); -} \ No newline at end of file +} diff --git a/src/components/consultations/LayerControlsButton.tsx b/src/components/consultations/LayerControlsButton.tsx index f301563d..7fea8a6b 100644 --- a/src/components/consultations/LayerControlsButton.tsx +++ b/src/components/consultations/LayerControlsButton.tsx @@ -1,6 +1,7 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Layers } from "lucide-react"; +import { useIsMobile } from "@/hooks/use-mobile"; interface LayerControlsButtonProps { isOpen: boolean; @@ -9,6 +10,22 @@ interface LayerControlsButtonProps { } export default function LayerControlsButton({ isOpen, activeCount, onToggle }: LayerControlsButtonProps) { + const isMobile = useIsMobile(); + + // On mobile, show a compact icon-only button (drawer handle manages dismiss) + if (isMobile) { + return ( + + ); + } + return ( )} - + {variant === 'desktop' && ( + + )}
@@ -384,6 +388,16 @@ export default function LayerControlsPanel({ ); })}
+ + ); + + if (variant === 'mobile') { + return normalModeContent; + } + + return ( +
+ {normalModeContent}
); } diff --git a/src/components/consultations/ViewToggleButton.tsx b/src/components/consultations/ViewToggleButton.tsx index 43a3bbb2..3ca7d3d6 100644 --- a/src/components/consultations/ViewToggleButton.tsx +++ b/src/components/consultations/ViewToggleButton.tsx @@ -1,6 +1,5 @@ "use client"; -import { Button } from "@/components/ui/button"; import { Map, FileText } from "lucide-react"; import { Tooltip, @@ -8,19 +7,24 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; type ViewMode = 'map' | 'document'; interface ViewToggleButtonProps { currentView: ViewMode; onToggle: () => void; + drawerOpen?: boolean; } -export default function ViewToggleButton({ currentView, onToggle }: ViewToggleButtonProps) { +export default function ViewToggleButton({ currentView, onToggle, drawerOpen }: ViewToggleButtonProps) { const isMapView = currentView === 'map'; return ( -
+
diff --git a/src/components/map/map.tsx b/src/components/map/map.tsx index 3cbdf096..5c310d35 100644 --- a/src/components/map/map.tsx +++ b/src/components/map/map.tsx @@ -38,6 +38,7 @@ interface MapProps { drawingMode?: 'point' | 'polygon' selectedGeometryForEdit?: string | null zoomToGeometry?: GeoJSON.Geometry | null + zoomPadding?: number | { top: number; bottom: number; left: number; right: number } } const ANIMATE_ROTATION_SPEED = 1000; @@ -62,7 +63,8 @@ const Map = memo(function Map({ showStreetLabels = false, drawingMode = 'point', selectedGeometryForEdit = null, - zoomToGeometry + zoomToGeometry, + zoomPadding }: MapProps) { const mapContainer = useRef(null) const map = useRef(null) @@ -951,16 +953,14 @@ const Map = memo(function Map({ const performZoom = (geometry: GeoJSON.Geometry) => { try { const bounds = calculateGeometryBounds(geometry); + const padding = zoomPadding ?? 100; if (bounds.bounds) { - // Add some padding around the geometry - const padding = 100; // pixels - map.current?.fitBounds([ [bounds.bounds.minLng, bounds.bounds.minLat], [bounds.bounds.maxLng, bounds.bounds.maxLat] ], { - padding: padding, + padding, maxZoom: 17 }); } else { @@ -969,7 +969,8 @@ const Map = memo(function Map({ const coordinates = geometry.coordinates as [number, number]; map.current?.easeTo({ center: coordinates, - zoom: 16 + zoom: 16, + padding }); } } @@ -981,7 +982,7 @@ const Map = memo(function Map({ // Perform the zoom performZoom(zoomToGeometry); } - }, [zoomToGeometry, mapReady]); + }, [zoomToGeometry, zoomPadding, mapReady]); return (
@@ -999,6 +1000,7 @@ const Map = memo(function Map({ prevProps.drawingMode === nextProps.drawingMode && prevProps.selectedGeometryForEdit === nextProps.selectedGeometryForEdit && prevProps.zoomToGeometry === nextProps.zoomToGeometry && + prevProps.zoomPadding === nextProps.zoomPadding && JSON.stringify(prevProps.features) === JSON.stringify(nextProps.features) ); }) diff --git a/src/components/ui/credenza.tsx b/src/components/ui/credenza.tsx new file mode 100644 index 00000000..47397aae --- /dev/null +++ b/src/components/ui/credenza.tsx @@ -0,0 +1,168 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" +import { useIsMobile } from "@/hooks/use-mobile" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +interface BaseProps { + children: React.ReactNode +} + +interface RootCredenzaProps extends BaseProps { + open?: boolean + onOpenChange?: (open: boolean) => void +} + +interface CredenzaProps extends BaseProps { + className?: string + asChild?: true +} + +const CredenzaContext = React.createContext<{ isMobile: boolean }>({ + isMobile: false, +}) + +const useCredenzaContext = () => { + const context = React.useContext(CredenzaContext) + if (!context) { + throw new Error( + "Credenza components cannot be rendered outside the Credenza Context" + ) + } + return context +} + +const Credenza = ({ children, ...props }: RootCredenzaProps) => { + const isMobile = useIsMobile() + const Component = isMobile ? Drawer : Dialog + + return ( + + + {children} + + + ) +} + +const CredenzaTrigger = ({ className, children, ...props }: CredenzaProps) => { + const { isMobile } = useCredenzaContext() + const Component = isMobile ? DrawerTrigger : DialogTrigger + + return ( + + {children} + + ) +} + +const CredenzaClose = ({ className, children, ...props }: CredenzaProps) => { + const { isMobile } = useCredenzaContext() + const Component = isMobile ? DrawerClose : DialogClose + + return ( + + {children} + + ) +} + +const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => { + const { isMobile } = useCredenzaContext() + const Component = isMobile ? DrawerContent : DialogContent + + return ( + + {children} + + ) +} + +const CredenzaDescription = ({ + className, + children, + ...props +}: CredenzaProps) => { + const { isMobile } = useCredenzaContext() + const Component = isMobile ? DrawerDescription : DialogDescription + + return ( + + {children} + + ) +} + +const CredenzaHeader = ({ className, children, ...props }: CredenzaProps) => { + const { isMobile } = useCredenzaContext() + const Component = isMobile ? DrawerHeader : DialogHeader + + return ( + + {children} + + ) +} + +const CredenzaTitle = ({ className, children, ...props }: CredenzaProps) => { + const { isMobile } = useCredenzaContext() + const Component = isMobile ? DrawerTitle : DialogTitle + + return ( + + {children} + + ) +} + +const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => { + return ( +
+ {children} +
+ ) +} + +const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => { + const { isMobile } = useCredenzaContext() + const Component = isMobile ? DrawerFooter : DialogFooter + + return ( + + {children} + + ) +} + +export { + Credenza, + CredenzaTrigger, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaHeader, + CredenzaTitle, + CredenzaBody, + CredenzaFooter, +} diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx index 6a0ef53d..af769ca7 100644 --- a/src/components/ui/drawer.tsx +++ b/src/components/ui/drawer.tsx @@ -36,10 +36,10 @@ DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName const DrawerContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { hideOverlay?: boolean } +>(({ className, children, hideOverlay, ...props }, ref) => ( - + {!hideOverlay && } Date: Wed, 25 Feb 2026 18:42:29 +0200 Subject: [PATCH 15/20] refactor: extract getSafeHtmlContent to shared utility Deduplicate the identical HTML sanitization function that was defined inline in both CommentSection and CommentsOverviewSheet. --- .../consultations/CommentSection.tsx | 28 +------------------ .../consultations/CommentsOverviewSheet.tsx | 24 +--------------- src/lib/utils/sanitize.ts | 26 +++++++++++++++++ 3 files changed, 28 insertions(+), 50 deletions(-) create mode 100644 src/lib/utils/sanitize.ts diff --git a/src/components/consultations/CommentSection.tsx b/src/components/consultations/CommentSection.tsx index 65f67b97..4bb8e634 100644 --- a/src/components/consultations/CommentSection.tsx +++ b/src/components/consultations/CommentSection.tsx @@ -3,12 +3,12 @@ import { useState } from "react"; import { useSession } from "next-auth/react"; import dynamic from "next/dynamic"; -import sanitizeHtml from 'sanitize-html'; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { MessageCircle, ChevronDown, LogIn, ChevronUp, Trash2, Clock } from "lucide-react"; import { cn } from "@/lib/utils"; +import { getSafeHtmlContent } from "@/lib/utils/sanitize"; import { ConsultationCommentWithUpvotes } from "@/lib/db/consultations"; // Dynamically import ReactQuill to avoid SSR issues @@ -47,32 +47,6 @@ export default function CommentSection({ const [upvoting, setUpvoting] = useState(null); const [deleting, setDeleting] = useState(null); - // Sanitize HTML content to prevent XSS attacks - const getSafeHtmlContent = (html: string): string => { - return sanitizeHtml(html, { - allowedTags: ['p', 'br', 'strong', 'b', 'em', 'i', 'u', 'a', 'ul', 'ol', 'li'], - allowedAttributes: { - 'a': ['href', 'target', 'rel'] - }, - allowedSchemes: ['http', 'https', 'mailto'], - transformTags: { - // Ensure external links open in new tab with security attributes - 'a': (tagName, attribs) => ({ - tagName: 'a', - attribs: { - ...attribs, - target: '_blank', - rel: 'noopener noreferrer' - } - }) - } - }); - }; - - // Debug logging (can be removed in production) - // console.log('Session:', session); - // console.log('Comments:', comments); - // console.log('Current user ID:', session?.user?.id); const getEntityTypeLabel = (type: string) => { switch (type) { diff --git a/src/components/consultations/CommentsOverviewSheet.tsx b/src/components/consultations/CommentsOverviewSheet.tsx index 04c861c4..6afa41cd 100644 --- a/src/components/consultations/CommentsOverviewSheet.tsx +++ b/src/components/consultations/CommentsOverviewSheet.tsx @@ -2,7 +2,6 @@ import { useState, useEffect } from "react"; import { useSession } from "next-auth/react"; -import sanitizeHtml from 'sanitize-html'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Drawer, DrawerContent, DrawerTitle, DrawerDescription } from "@/components/ui/drawer"; import { Button } from "@/components/ui/button"; @@ -12,6 +11,7 @@ import { Heart, MessageSquare, ChevronDown, Clock, TrendingUp, ChevronUp } from import { formatDistanceToNow } from "date-fns"; import { el } from "date-fns/locale"; import { cn } from "@/lib/utils"; +import { getSafeHtmlContent } from "@/lib/utils/sanitize"; import { useIsMobile } from "@/hooks/use-mobile"; import { ConsultationCommentWithUpvotes } from "@/lib/db/consultations"; import { RegulationData } from "./types"; @@ -42,28 +42,6 @@ export default function CommentsOverviewSheet({ const [localComments, setLocalComments] = useState(comments); const [expandedComments, setExpandedComments] = useState(new Set()); - // Sanitize HTML content to prevent XSS attacks - const getSafeHtmlContent = (html: string): string => { - return sanitizeHtml(html, { - allowedTags: ['p', 'br', 'strong', 'b', 'em', 'i', 'u', 'a', 'ul', 'ol', 'li'], - allowedAttributes: { - 'a': ['href', 'target', 'rel'] - }, - allowedSchemes: ['http', 'https', 'mailto'], - transformTags: { - // Ensure external links open in new tab with security attributes - 'a': (tagName, attribs) => ({ - tagName: 'a', - attribs: { - ...attribs, - target: '_blank', - rel: 'noopener noreferrer' - } - }) - } - }); - }; - // Update local comments when props change useEffect(() => { setLocalComments(comments); diff --git a/src/lib/utils/sanitize.ts b/src/lib/utils/sanitize.ts new file mode 100644 index 00000000..2fe58a59 --- /dev/null +++ b/src/lib/utils/sanitize.ts @@ -0,0 +1,26 @@ +import sanitizeHtml from 'sanitize-html'; + +/** + * Sanitize HTML content for safe rendering in consultation comments. + * Allows basic formatting tags and ensures links open in new tabs. + */ +export function getSafeHtmlContent(html: string): string { + return sanitizeHtml(html, { + allowedTags: ['p', 'br', 'strong', 'b', 'em', 'i', 'u', 'a', 'ul', 'ol', 'li'], + allowedAttributes: { + 'a': ['href', 'target', 'rel'] + }, + allowedSchemes: ['http', 'https', 'mailto'], + transformTags: { + // Ensure external links open in new tab with security attributes + 'a': (tagName, attribs) => ({ + tagName: 'a', + attribs: { + ...attribs, + target: '_blank', + rel: 'noopener noreferrer' + } + }) + } + }); +} From 8ab5427eee9ebb0c95801209cb4b965910cd2ca7 Mon Sep 17 00:00:00 2001 From: kouloumos Date: Wed, 25 Feb 2026 19:03:43 +0200 Subject: [PATCH 16/20] refactor: extract createCircleBuffer to shared geo utility Deduplicate the identical Haversine circle polygon generator that was defined inline in both ConsultationMap and NotificationMapDialog. --- .../admin/NotificationMapDialog.tsx | 33 +----------------- .../consultations/ConsultationMap.tsx | 33 +----------------- src/lib/geo/buffer.ts | 34 +++++++++++++++++++ 3 files changed, 36 insertions(+), 64 deletions(-) create mode 100644 src/lib/geo/buffer.ts diff --git a/src/components/admin/NotificationMapDialog.tsx b/src/components/admin/NotificationMapDialog.tsx index 487ca40d..fb055d2b 100644 --- a/src/components/admin/NotificationMapDialog.tsx +++ b/src/components/admin/NotificationMapDialog.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useMemo } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { Loader2 } from 'lucide-react'; import Map, { MapFeature } from '@/components/map/map'; +import { createCircleBuffer } from '@/lib/geo/buffer'; interface SubjectLocation { id: string; @@ -31,38 +32,6 @@ interface NotificationMapDialogProps { meetingName: string; } -// Helper function to create a circular polygon buffer around a point -function createCircleBuffer(center: [number, number], radiusInMeters: number): GeoJSON.Polygon { - const earthRadius = 6371000; // Earth's radius in meters - const lat = center[1] * Math.PI / 180; // Convert to radians - const lng = center[0] * Math.PI / 180; - - const points: [number, number][] = []; - const numPoints = 64; // Number of points to create the circle - - for (let i = 0; i < numPoints; i++) { - const angle = (i * 360 / numPoints) * Math.PI / 180; - - // Calculate offset in radians - const dLat = radiusInMeters * Math.cos(angle) / earthRadius; - const dLng = radiusInMeters * Math.sin(angle) / (earthRadius * Math.cos(lat)); - - // Convert back to degrees - const newLat = (lat + dLat) * 180 / Math.PI; - const newLng = (lng + dLng) * 180 / Math.PI; - - points.push([newLng, newLat]); - } - - // Close the polygon by adding the first point at the end - points.push(points[0]); - - return { - type: 'Polygon', - coordinates: [points] - }; -} - export function NotificationMapDialog({ open, onOpenChange, diff --git a/src/components/consultations/ConsultationMap.tsx b/src/components/consultations/ConsultationMap.tsx index 6404301d..070b8724 100644 --- a/src/components/consultations/ConsultationMap.tsx +++ b/src/components/consultations/ConsultationMap.tsx @@ -10,6 +10,7 @@ import LayerControlsPanel from "./LayerControlsPanel"; import DetailPanel from "./DetailPanel"; import EditingToolsPanel from "./EditingToolsPanel"; import { CheckboxState } from "./GeoSetItem"; +import { createCircleBuffer } from "@/lib/geo/buffer"; import { ConsultationCommentWithUpvotes } from "@/lib/db/consultations"; import { Location } from "@/lib/types/onboarding"; import { useIsMobile } from "@/hooks/use-mobile"; @@ -41,38 +42,6 @@ const GEOSET_COLORS = [ '#4A5568', // Gray ]; -// Helper function to create a circular polygon buffer around a point -function createCircleBuffer(center: [number, number], radiusInMeters: number): GeoJSON.Polygon { - const earthRadius = 6371000; // Earth's radius in meters - const lat = center[1] * Math.PI / 180; // Convert to radians - const lng = center[0] * Math.PI / 180; - - const points: [number, number][] = []; - const numPoints = 64; // Number of points to create the circle - - for (let i = 0; i < numPoints; i++) { - const angle = (i * 360 / numPoints) * Math.PI / 180; - - // Calculate offset in radians - const dLat = radiusInMeters * Math.cos(angle) / earthRadius; - const dLng = radiusInMeters * Math.sin(angle) / (earthRadius * Math.cos(lat)); - - // Convert back to degrees - const newLat = (lat + dLat) * 180 / Math.PI; - const newLng = (lng + dLng) * 180 / Math.PI; - - points.push([newLng, newLat]); - } - - // Close the polygon by adding the first point at the end - points.push(points[0]); - - return { - type: 'Polygon', - coordinates: [points] - }; -} - // Helper function to compute derived geometry function computeDerivedGeometry(derivedGeometry: DerivedGeometry, allGeoSets: GeoSetData[]): GeoJSON.Geometry | null { const { derivedFrom } = derivedGeometry; diff --git a/src/lib/geo/buffer.ts b/src/lib/geo/buffer.ts new file mode 100644 index 00000000..9e8814d7 --- /dev/null +++ b/src/lib/geo/buffer.ts @@ -0,0 +1,34 @@ +/** + * Create a circular polygon approximation around a center point. + * Uses Haversine-based offsets to generate a 64-point polygon. + */ +export function createCircleBuffer(center: [number, number], radiusInMeters: number): GeoJSON.Polygon { + const earthRadius = 6371000; // Earth's radius in meters + const lat = center[1] * Math.PI / 180; // Convert to radians + const lng = center[0] * Math.PI / 180; + + const points: [number, number][] = []; + const numPoints = 64; // Number of points to create the circle + + for (let i = 0; i < numPoints; i++) { + const angle = (i * 360 / numPoints) * Math.PI / 180; + + // Calculate offset in radians + const dLat = radiusInMeters * Math.cos(angle) / earthRadius; + const dLng = radiusInMeters * Math.sin(angle) / (earthRadius * Math.cos(lat)); + + // Convert back to degrees + const newLat = (lat + dLat) * 180 / Math.PI; + const newLng = (lng + dLng) * 180 / Math.PI; + + points.push([newLng, newLat]); + } + + // Close the polygon by adding the first point at the end + points.push(points[0]); + + return { + type: 'Polygon', + coordinates: [points] + }; +} From de2c24fb18169666987ee9bb800be92a6d1eac3a Mon Sep 17 00:00:00 2001 From: kouloumos Date: Wed, 25 Feb 2026 19:16:20 +0200 Subject: [PATCH 17/20] refactor: deduplicate CommentsOverviewSheet Drawer/Sheet branches The Sheet (desktop) branch duplicated ~150 lines of JSX that the renderContent() helper already handles. Reuse renderContent() for both the mobile Drawer and desktop Sheet containers. --- .../consultations/CommentsOverviewSheet.tsx | 155 +----------------- 1 file changed, 4 insertions(+), 151 deletions(-) diff --git a/src/components/consultations/CommentsOverviewSheet.tsx b/src/components/consultations/CommentsOverviewSheet.tsx index 6afa41cd..f548a517 100644 --- a/src/components/consultations/CommentsOverviewSheet.tsx +++ b/src/components/consultations/CommentsOverviewSheet.tsx @@ -7,7 +7,7 @@ import { Drawer, DrawerContent, DrawerTitle, DrawerDescription } from "@/compone import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; -import { Heart, MessageSquare, ChevronDown, Clock, TrendingUp, ChevronUp } from "lucide-react"; +import { MessageSquare, ChevronDown, Clock, TrendingUp, ChevronUp } from "lucide-react"; import { formatDistanceToNow } from "date-fns"; import { el } from "date-fns/locale"; import { cn } from "@/lib/utils"; @@ -365,157 +365,10 @@ export default function CommentsOverviewSheet({ className="w-96 max-w-[calc(100vw-2rem)] sm:max-w-md flex flex-col" overlayClassName="bg-black/20" > - -
-
-
- ΣΧΟΛΙΑ -
- - {totalCount} σχόλια συνολικά - -
-
+ + {totalCount} σχόλια συνολικά - - {/* Sort Controls */} -
- - -
- - {/* Comments List */} -
- {sortedComments.length === 0 ? ( -
- -

Δεν υπάρχουν σχόλια ακόμα

-
- ) : ( - sortedComments.map((comment, index) => ( -
- {/* Reference Box */} -
handleReferenceClick(e, comment)} - className="bg-muted/30 border border-muted/50 rounded-md p-2 cursor-pointer hover:bg-muted/50 transition-colors" - > -
- - {getEntityTypeLabel(comment.entityType)} - - - {getEntityTitle(comment)} - -
-
- - {/* Comment */} -
- {/* Upvote Section */} -
- - - {comment.upvoteCount || 0} - -
- - {/* Comment Content */} -
-
handleCommentClick(comment)} - > -
- - {comment.user?.name || 'Ανώνυμος'} - - - {formatDistanceToNow(new Date(comment.createdAt), { - addSuffix: true, - locale: el - })} - -
-
-
- - {/* Show More/Less Button */} - {isCommentTruncated(comment.body) && ( - - )} -
-
- - {/* Separator between comments */} - {index < sortedComments.length - 1 && ( - - )} -
- )) - )} -
+ {renderContent()} ); From 274068866850023a2b1b81bbc56cfcbd3bae26d6 Mon Sep 17 00:00:00 2001 From: kouloumos Date: Wed, 25 Feb 2026 19:31:00 +0200 Subject: [PATCH 18/20] refactor: move admin consultation queries to db layer and use server component Extract raw Prisma queries from API routes into centralized functions in src/lib/db/consultations.ts (getConsultationsForAdmin, createConsultation, updateConsultation, deleteConsultation, getAdminCityOptions). Split the admin consultations page into a server component (auth + initial data fetch) and a client component (UI), matching the pattern used by other admin pages like offers and meetings. --- .../(other)/admin/consultations/page.tsx | 464 +----------------- src/app/api/admin/consultations/[id]/route.ts | 21 +- src/app/api/admin/consultations/route.ts | 53 +- .../admin/consultations/consultations.tsx | 442 +++++++++++++++++ src/lib/db/consultations.ts | 83 ++++ 5 files changed, 548 insertions(+), 515 deletions(-) create mode 100644 src/components/admin/consultations/consultations.tsx diff --git a/src/app/[locale]/(other)/admin/consultations/page.tsx b/src/app/[locale]/(other)/admin/consultations/page.tsx index 73e426f5..3e0a8a4d 100644 --- a/src/app/[locale]/(other)/admin/consultations/page.tsx +++ b/src/app/[locale]/(other)/admin/consultations/page.tsx @@ -1,454 +1,12 @@ -'use client'; - -import { useState, useEffect, useRef } from 'react'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Upload, ExternalLink, Pencil, Check, X } from 'lucide-react'; - -type CityOption = { id: string; name: string }; - -type ConsultationRow = { - id: string; - name: string; - jsonUrl: string; - endDate: string; - isActive: boolean; - createdAt: string; - city: { id: string; name: string }; - _count: { comments: number }; -}; - -export default function AdminConsultationsPage() { - const [consultations, setConsultations] = useState([]); - const [cities, setCities] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [isSubmitting, setIsSubmitting] = useState(false); - const [isUploading, setIsUploading] = useState(false); - const [editingUrlId, setEditingUrlId] = useState(null); - const [editingUrlValue, setEditingUrlValue] = useState(''); - const fileInputRef = useRef(null); - const jsonUrlInputRef = useRef(null); - - async function fetchConsultations() { - try { - const res = await fetch('/api/admin/consultations'); - if (!res.ok) throw new Error('Failed to fetch consultations'); - const data = await res.json(); - setConsultations(data); - } catch (err) { - console.error(err); - } finally { - setLoading(false); - } - } - - async function fetchCities() { - try { - const res = await fetch('/api/admin/entities'); - if (!res.ok) throw new Error('Failed to fetch cities'); - const data = await res.json(); - setCities(data.filter((e: { type: string }) => e.type === 'city')); - } catch (err) { - console.error(err); - } - } - - useEffect(() => { - fetchConsultations(); - fetchCities(); - }, []); - - async function uploadJsonFile(file: File): Promise { - const formData = new FormData(); - formData.append('file', file); - - const res = await fetch('/api/upload', { - method: 'POST', - body: formData, - }); - - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || 'Upload failed'); - } - - const data = await res.json(); - return data.url; - } - - async function handleFileUpload(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - if (!file) return; - - if (!file.name.endsWith('.json')) { - setError('Please select a JSON file'); - return; - } - - setIsUploading(true); - setError(null); - - try { - const url = await uploadJsonFile(file); - if (url && jsonUrlInputRef.current) { - jsonUrlInputRef.current.value = url; - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Upload failed'); - } finally { - setIsUploading(false); - // Reset file input - if (fileInputRef.current) fileInputRef.current.value = ''; - } - } - - async function handleCreate(e: React.FormEvent) { - e.preventDefault(); - setError(null); - setIsSubmitting(true); - - const form = e.currentTarget; - const formData = new FormData(form); - const name = String(formData.get('name') || '').trim(); - const jsonUrl = String(formData.get('jsonUrl') || '').trim(); - const endDate = String(formData.get('endDate') || '').trim(); - const cityId = String(formData.get('cityId') || '').trim(); - - if (!name || !jsonUrl || !endDate || !cityId) { - setError('All fields are required'); - setIsSubmitting(false); - return; - } - - try { - const res = await fetch('/api/admin/consultations', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, jsonUrl, endDate, cityId }) - }); - - if (!res.ok) { - const data = await res.json(); - setError(data.error || 'Failed to create consultation'); - return; - } - - form.reset(); - fetchConsultations(); - } finally { - setIsSubmitting(false); - } - } - - async function handleToggle(id: string, isActive: boolean) { - const res = await fetch(`/api/admin/consultations/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ isActive }) - }); - - if (!res.ok) { - const data = await res.json(); - alert(`Error: ${data.error || 'Failed to update consultation'}`); - } - fetchConsultations(); - } - - async function handleUpdateUrl(id: string, newUrl: string) { - const res = await fetch(`/api/admin/consultations/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ jsonUrl: newUrl }) - }); - - if (!res.ok) { - const data = await res.json(); - alert(`Error: ${data.error || 'Failed to update URL'}`); - } else { - setEditingUrlId(null); - fetchConsultations(); - } - } - - async function handleUploadAndUpdateUrl(id: string, file: File) { - setIsUploading(true); - try { - const url = await uploadJsonFile(file); - if (url) { - await handleUpdateUrl(id, url); - } - } catch (err) { - alert(err instanceof Error ? err.message : 'Upload failed'); - } finally { - setIsUploading(false); - } - } - - async function handleDelete(id: string) { - if (!confirm('Are you sure you want to delete this consultation? All comments will also be deleted.')) return; - - const res = await fetch(`/api/admin/consultations/${id}`, { method: 'DELETE' }); - if (!res.ok) { - const data = await res.json(); - alert(`Error: ${data.error || 'Failed to delete consultation'}`); - return; - } - - fetchConsultations(); - } - - if (loading) { - return
Loading...
; - } - - return ( -
-
-

Consultations

-

- Manage public consultations for regulation documents -

-
- - {/* Create Form */} - - - Create Consultation - - -
-
-
- - -
-
- - -
-
-
-
- -
- - - -
-

- Upload a .json file to S3, or paste a URL directly. -

-
-
- - -
- -
-
- {error && ( -
- {error} -
- )} -
-
- - {/* Consultations Table */} - - - All Consultations - - - {consultations.length === 0 ? ( -

No consultations yet.

- ) : ( - - - - Name - City - JSON URL - End Date - Comments - Active - Actions - - - - {consultations.map((c) => ( - - - {c.name} - - {c.city.name} - - {editingUrlId === c.id ? ( -
- setEditingUrlValue(e.target.value)} - className="h-7 text-xs" - /> - - - { - const file = e.target.files?.[0]; - if (file) handleUploadAndUpdateUrl(c.id, file); - }} - /> - -
- ) : ( -
- - {c.jsonUrl} - - - -
- )} -
- - {new Date(c.endDate).toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - })} - - {c._count.comments} - - handleToggle(c.id, e.target.checked)} - /> - - -
- - -
-
-
- ))} -
-
- )} -
-
-
- ); +import Consultations from "@/components/admin/consultations/consultations"; +import { withUserAuthorizedToEdit } from "@/lib/auth"; +import { getConsultationsForAdmin, getAdminCityOptions } from "@/lib/db/consultations"; + +export default async function Page() { + await withUserAuthorizedToEdit({}); + const [consultations, cities] = await Promise.all([ + getConsultationsForAdmin(), + getAdminCityOptions(), + ]); + return ; } diff --git a/src/app/api/admin/consultations/[id]/route.ts b/src/app/api/admin/consultations/[id]/route.ts index d5970d7a..958eb386 100644 --- a/src/app/api/admin/consultations/[id]/route.ts +++ b/src/app/api/admin/consultations/[id]/route.ts @@ -1,10 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; -import prisma from '@/lib/db/prisma'; import { withUserAuthorizedToEdit } from '@/lib/auth'; +import { updateConsultation, deleteConsultation } from '@/lib/db/consultations'; export async function PUT(req: NextRequest, { params }: { params: { id: string } }) { await withUserAuthorizedToEdit({}); - const id = params.id; const body = await req.json(); const { name, jsonUrl, endDate, isActive } = body as { name?: string; @@ -14,20 +13,7 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string } }; try { - const updated = await prisma.consultation.update({ - where: { id }, - data: { - ...(name !== undefined && { name }), - ...(jsonUrl !== undefined && { jsonUrl }), - ...(endDate !== undefined && { endDate: new Date(endDate) }), - ...(isActive !== undefined && { isActive }), - }, - include: { - city: { - select: { id: true, name: true } - } - } - }); + const updated = await updateConsultation(params.id, { name, jsonUrl, endDate, isActive }); return NextResponse.json(updated); } catch (error) { if ((error as { code?: string })?.code === 'P2025') { @@ -39,9 +25,8 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string } export async function DELETE(_req: NextRequest, { params }: { params: { id: string } }) { await withUserAuthorizedToEdit({}); - const id = params.id; try { - await prisma.consultation.delete({ where: { id } }); + await deleteConsultation(params.id); return NextResponse.json({ ok: true }); } catch (error) { if ((error as { code?: string })?.code === 'P2025') { diff --git a/src/app/api/admin/consultations/route.ts b/src/app/api/admin/consultations/route.ts index c5187d19..b2b08d12 100644 --- a/src/app/api/admin/consultations/route.ts +++ b/src/app/api/admin/consultations/route.ts @@ -1,20 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; -import prisma from '@/lib/db/prisma'; import { withUserAuthorizedToEdit } from '@/lib/auth'; +import { getConsultationsForAdmin, createConsultation } from '@/lib/db/consultations'; export async function GET() { await withUserAuthorizedToEdit({}); - const items = await prisma.consultation.findMany({ - orderBy: { createdAt: 'desc' }, - include: { - city: { - select: { id: true, name: true } - }, - _count: { - select: { comments: true } - } - } - }); + const items = await getConsultationsForAdmin(); return NextResponse.json(items); } @@ -36,37 +26,12 @@ export async function POST(req: NextRequest) { ); } - // Validate the city exists and has consultations enabled - const city = await prisma.city.findUnique({ - where: { id: cityId }, - select: { id: true, consultationsEnabled: true } - }); - - if (!city) { - return NextResponse.json({ error: 'City not found' }, { status: 404 }); - } - - if (!city.consultationsEnabled) { - return NextResponse.json( - { error: 'Consultations are not enabled for this city. Enable them first in city settings.' }, - { status: 400 } - ); + try { + const created = await createConsultation({ name, jsonUrl, endDate, isActive, cityId }); + return NextResponse.json(created, { status: 201 }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to create consultation'; + const status = message.includes('not found') ? 404 : 400; + return NextResponse.json({ error: message }, { status }); } - - const created = await prisma.consultation.create({ - data: { - name, - jsonUrl, - endDate: new Date(endDate), - isActive: isActive ?? true, - cityId - }, - include: { - city: { - select: { id: true, name: true } - } - } - }); - - return NextResponse.json(created, { status: 201 }); } diff --git a/src/components/admin/consultations/consultations.tsx b/src/components/admin/consultations/consultations.tsx new file mode 100644 index 00000000..d7fc2069 --- /dev/null +++ b/src/components/admin/consultations/consultations.tsx @@ -0,0 +1,442 @@ +'use client'; + +import { useState, useRef } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Upload, ExternalLink, Pencil, Check, X } from 'lucide-react'; +import { ConsultationForAdmin } from '@/lib/db/consultations'; + +type CityOption = { id: string; name: string }; + +type ConsultationRow = { + id: string; + name: string; + jsonUrl: string; + endDate: string; + isActive: boolean; + createdAt: string; + city: { id: string; name: string }; + _count: { comments: number }; +}; + +interface ConsultationsProps { + initialConsultations: ConsultationForAdmin[]; + initialCities: CityOption[]; +} + +export default function Consultations({ initialConsultations, initialCities }: ConsultationsProps) { + const [consultations, setConsultations] = useState( + initialConsultations.map(c => ({ + ...c, + endDate: c.endDate.toISOString(), + createdAt: c.createdAt.toISOString(), + })) + ); + const cities = initialCities; + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [editingUrlId, setEditingUrlId] = useState(null); + const [editingUrlValue, setEditingUrlValue] = useState(''); + const fileInputRef = useRef(null); + const jsonUrlInputRef = useRef(null); + + async function fetchConsultations() { + try { + const res = await fetch('/api/admin/consultations'); + if (!res.ok) throw new Error('Failed to fetch consultations'); + const data = await res.json(); + setConsultations(data); + } catch (err) { + console.error(err); + } + } + + async function uploadJsonFile(file: File): Promise { + const formData = new FormData(); + formData.append('file', file); + + const res = await fetch('/api/upload', { + method: 'POST', + body: formData, + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Upload failed'); + } + + const data = await res.json(); + return data.url; + } + + async function handleFileUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + + if (!file.name.endsWith('.json')) { + setError('Please select a JSON file'); + return; + } + + setIsUploading(true); + setError(null); + + try { + const url = await uploadJsonFile(file); + if (url && jsonUrlInputRef.current) { + jsonUrlInputRef.current.value = url; + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Upload failed'); + } finally { + setIsUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + } + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setIsSubmitting(true); + + const form = e.currentTarget; + const formData = new FormData(form); + const name = String(formData.get('name') || '').trim(); + const jsonUrl = String(formData.get('jsonUrl') || '').trim(); + const endDate = String(formData.get('endDate') || '').trim(); + const cityId = String(formData.get('cityId') || '').trim(); + + if (!name || !jsonUrl || !endDate || !cityId) { + setError('All fields are required'); + setIsSubmitting(false); + return; + } + + try { + const res = await fetch('/api/admin/consultations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, jsonUrl, endDate, cityId }) + }); + + if (!res.ok) { + const data = await res.json(); + setError(data.error || 'Failed to create consultation'); + return; + } + + form.reset(); + fetchConsultations(); + } finally { + setIsSubmitting(false); + } + } + + async function handleToggle(id: string, isActive: boolean) { + const res = await fetch(`/api/admin/consultations/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isActive }) + }); + + if (!res.ok) { + const data = await res.json(); + alert(`Error: ${data.error || 'Failed to update consultation'}`); + } + fetchConsultations(); + } + + async function handleUpdateUrl(id: string, newUrl: string) { + const res = await fetch(`/api/admin/consultations/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonUrl: newUrl }) + }); + + if (!res.ok) { + const data = await res.json(); + alert(`Error: ${data.error || 'Failed to update URL'}`); + } else { + setEditingUrlId(null); + fetchConsultations(); + } + } + + async function handleUploadAndUpdateUrl(id: string, file: File) { + setIsUploading(true); + try { + const url = await uploadJsonFile(file); + if (url) { + await handleUpdateUrl(id, url); + } + } catch (err) { + alert(err instanceof Error ? err.message : 'Upload failed'); + } finally { + setIsUploading(false); + } + } + + async function handleDelete(id: string) { + if (!confirm('Are you sure you want to delete this consultation? All comments will also be deleted.')) return; + + const res = await fetch(`/api/admin/consultations/${id}`, { method: 'DELETE' }); + if (!res.ok) { + const data = await res.json(); + alert(`Error: ${data.error || 'Failed to delete consultation'}`); + return; + } + + fetchConsultations(); + } + + return ( +
+
+

Consultations

+

+ Manage public consultations for regulation documents +

+
+ + {/* Create Form */} + + + Create Consultation + + +
+
+
+ + +
+
+ + +
+
+
+
+ +
+ + + +
+

+ Upload a .json file to S3, or paste a URL directly. +

+
+
+ + +
+ +
+
+ {error && ( +
+ {error} +
+ )} +
+
+ + {/* Consultations Table */} + + + All Consultations + + + {consultations.length === 0 ? ( +

No consultations yet.

+ ) : ( + + + + Name + City + JSON URL + End Date + Comments + Active + Actions + + + + {consultations.map((c) => ( + + + {c.name} + + {c.city.name} + + {editingUrlId === c.id ? ( +
+ setEditingUrlValue(e.target.value)} + className="h-7 text-xs" + /> + + + { + const file = e.target.files?.[0]; + if (file) handleUploadAndUpdateUrl(c.id, file); + }} + /> + +
+ ) : ( +
+ + {c.jsonUrl} + + + +
+ )} +
+ + {new Date(c.endDate).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} + + {c._count.comments} + + handleToggle(c.id, e.target.checked)} + /> + + +
+ + +
+
+
+ ))} +
+
+ )} +
+
+
+ ); +} diff --git a/src/lib/db/consultations.ts b/src/lib/db/consultations.ts index 7723a7bd..3cb6ca50 100644 --- a/src/lib/db/consultations.ts +++ b/src/lib/db/consultations.ts @@ -8,6 +8,89 @@ import { toZonedTime, fromZonedTime } from 'date-fns-tz'; // Re-export the enum for use in other files export { ConsultationCommentEntityType }; +// ----- Admin types ----- + +export type ConsultationForAdmin = Consultation & { + city: Pick; + _count: { comments: number }; +}; + +// ----- Admin CRUD functions ----- + +export async function getConsultationsForAdmin(): Promise { + return prisma.consultation.findMany({ + orderBy: { createdAt: 'desc' }, + include: { + city: { select: { id: true, name: true } }, + _count: { select: { comments: true } } + } + }); +} + +export async function createConsultation(data: { + name: string; + jsonUrl: string; + endDate: string; + isActive?: boolean; + cityId: string; +}) { + // Validate the city exists and has consultations enabled + const city = await prisma.city.findUnique({ + where: { id: data.cityId }, + select: { id: true, consultationsEnabled: true } + }); + + if (!city) { + throw new Error('City not found'); + } + + if (!city.consultationsEnabled) { + throw new Error('Consultations are not enabled for this city. Enable them first in city settings.'); + } + + return prisma.consultation.create({ + data: { + name: data.name, + jsonUrl: data.jsonUrl, + endDate: new Date(data.endDate), + isActive: data.isActive ?? true, + cityId: data.cityId + }, + include: { + city: { select: { id: true, name: true } } + } + }); +} + +export async function updateConsultation( + id: string, + data: { name?: string; jsonUrl?: string; endDate?: string; isActive?: boolean } +) { + return prisma.consultation.update({ + where: { id }, + data: { + ...(data.name !== undefined && { name: data.name }), + ...(data.jsonUrl !== undefined && { jsonUrl: data.jsonUrl }), + ...(data.endDate !== undefined && { endDate: new Date(data.endDate) }), + ...(data.isActive !== undefined && { isActive: data.isActive }), + }, + include: { + city: { select: { id: true, name: true } } + } + }); +} + +export async function deleteConsultation(id: string) { + return prisma.consultation.delete({ where: { id } }); +} + +export async function getAdminCityOptions() { + return prisma.city.findMany({ + select: { id: true, name: true }, + orderBy: { name: 'asc' } + }); +} + // Types for comment data with upvote information export interface ConsultationCommentWithUpvotes extends ConsultationComment { user: Pick; From 87e26f28af10d8745fa4894b1fb190797e5556d1 Mon Sep 17 00:00:00 2001 From: kouloumos Date: Wed, 25 Feb 2026 19:41:32 +0200 Subject: [PATCH 19/20] refactor: consolidate geo utilities into src/lib/geo.ts Move calculateGeometryBounds from lib/utils.ts, createCircleBuffer from lib/geo/buffer.ts, and haversineDistance from DetailPanel.tsx into a single shared geo module. Update all import sites. --- .../admin/NotificationMapDialog.tsx | 2 +- .../consultations/ConsultationMap.tsx | 2 +- src/components/consultations/DetailPanel.tsx | 11 +- src/components/map/map.tsx | 3 +- .../onboarding/containers/MapContainer.tsx | 2 +- .../onboarding/selectors/LocationSelector.tsx | 3 +- src/lib/geo.ts | 129 ++++++++++++++++++ src/lib/geo/buffer.ts | 34 ----- src/lib/search/filters.ts | 2 +- src/lib/utils.ts | 83 +---------- 10 files changed, 139 insertions(+), 132 deletions(-) create mode 100644 src/lib/geo.ts delete mode 100644 src/lib/geo/buffer.ts diff --git a/src/components/admin/NotificationMapDialog.tsx b/src/components/admin/NotificationMapDialog.tsx index fb055d2b..0911578c 100644 --- a/src/components/admin/NotificationMapDialog.tsx +++ b/src/components/admin/NotificationMapDialog.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useMemo } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { Loader2 } from 'lucide-react'; import Map, { MapFeature } from '@/components/map/map'; -import { createCircleBuffer } from '@/lib/geo/buffer'; +import { createCircleBuffer } from '@/lib/geo'; interface SubjectLocation { id: string; diff --git a/src/components/consultations/ConsultationMap.tsx b/src/components/consultations/ConsultationMap.tsx index 070b8724..8137e959 100644 --- a/src/components/consultations/ConsultationMap.tsx +++ b/src/components/consultations/ConsultationMap.tsx @@ -10,7 +10,7 @@ import LayerControlsPanel from "./LayerControlsPanel"; import DetailPanel from "./DetailPanel"; import EditingToolsPanel from "./EditingToolsPanel"; import { CheckboxState } from "./GeoSetItem"; -import { createCircleBuffer } from "@/lib/geo/buffer"; +import { createCircleBuffer } from "@/lib/geo"; import { ConsultationCommentWithUpvotes } from "@/lib/db/consultations"; import { Location } from "@/lib/types/onboarding"; import { useIsMobile } from "@/hooks/use-mobile"; diff --git a/src/components/consultations/DetailPanel.tsx b/src/components/consultations/DetailPanel.tsx index 18cf907d..fde40b49 100644 --- a/src/components/consultations/DetailPanel.tsx +++ b/src/components/consultations/DetailPanel.tsx @@ -4,6 +4,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sh import { Drawer, DrawerContent, DrawerTitle, DrawerDescription } from "@/components/ui/drawer"; import { AlertTriangle, Save, ChevronLeft, MessageCircle, MapPin } from "lucide-react"; import { cn } from "@/lib/utils"; +import { haversineDistance } from "@/lib/geo"; import { useIsMobile } from "@/hooks/use-mobile"; import PermalinkButton from "./PermalinkButton"; import MarkdownContent from "./MarkdownContent"; @@ -12,16 +13,6 @@ import { Geometry, RegulationData, ReferenceFormat, StaticGeometry, CurrentUser, import { ConsultationCommentWithUpvotes } from "@/lib/db/consultations"; import { Location } from "@/lib/types/onboarding"; -// Compute distance between two [lng, lat] coordinates in meters (Haversine) -function haversineDistance(a: [number, number], b: [number, number]): number { - const R = 6371000; - const dLat = (b[1] - a[1]) * Math.PI / 180; - const dLng = (b[0] - a[0]) * Math.PI / 180; - const sinDLat = Math.sin(dLat / 2); - const sinDLng = Math.sin(dLng / 2); - const h = sinDLat * sinDLat + Math.cos(a[1] * Math.PI / 180) * Math.cos(b[1] * Math.PI / 180) * sinDLng * sinDLng; - return R * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)); -} interface DetailPanelProps { isOpen: boolean; diff --git a/src/components/map/map.tsx b/src/components/map/map.tsx index 5c310d35..cb05a72a 100644 --- a/src/components/map/map.tsx +++ b/src/components/map/map.tsx @@ -4,7 +4,8 @@ import mapboxgl from 'mapbox-gl' import MapboxDraw from '@mapbox/mapbox-gl-draw' import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css' import 'mapbox-gl/dist/mapbox-gl.css' -import { cn, calculateGeometryBounds } from '@/lib/utils' +import { cn } from '@/lib/utils' +import { calculateGeometryBounds } from '@/lib/geo' import { createRoot } from 'react-dom/client' import { env } from '@/env.mjs' diff --git a/src/components/onboarding/containers/MapContainer.tsx b/src/components/onboarding/containers/MapContainer.tsx index 35a12ad9..7c4b0049 100644 --- a/src/components/onboarding/containers/MapContainer.tsx +++ b/src/components/onboarding/containers/MapContainer.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useMemo } from 'react'; import { useOnboarding } from '@/contexts/OnboardingContext'; import { MapFeature } from '@/components/map/map'; -import { calculateGeometryBounds } from '@/lib/utils'; +import { calculateGeometryBounds } from '@/lib/geo'; import Map from '@/components/map/map'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; diff --git a/src/components/onboarding/selectors/LocationSelector.tsx b/src/components/onboarding/selectors/LocationSelector.tsx index d768bbf4..e5bdfb03 100644 --- a/src/components/onboarding/selectors/LocationSelector.tsx +++ b/src/components/onboarding/selectors/LocationSelector.tsx @@ -7,7 +7,8 @@ import { Input } from '@/components/ui/input'; import { Location } from '@/lib/types/onboarding'; import { getPlaceSuggestions, getPlaceDetails, PlaceSuggestion, PlaceSuggestionsResult } from '@/lib/google-maps'; import { useDebounce } from '@/hooks/use-debounce'; -import { cn, calculateGeometryBounds } from '@/lib/utils'; +import { cn } from '@/lib/utils'; +import { calculateGeometryBounds } from '@/lib/geo'; import { CityWithGeometry } from '@/lib/db/cities'; interface LocationSelectorProps { diff --git a/src/lib/geo.ts b/src/lib/geo.ts new file mode 100644 index 00000000..39190b8c --- /dev/null +++ b/src/lib/geo.ts @@ -0,0 +1,129 @@ +export type GeometryBounds = { + bounds: { minLng: number; maxLng: number; minLat: number; maxLat: number } | null; + center: [number, number]; +}; + +/** + * Calculates bounds and center from a GeoJSON geometry. + * Supports Point, Polygon, MultiPolygon, and GeometryCollection. + */ +export function calculateGeometryBounds(geometry: any): GeometryBounds { + const DEFAULT_RETURN: GeometryBounds = { + bounds: null, + center: [23.7275, 37.9838] // Default to Athens + }; + + if (!geometry) { + console.log('[Location] No geometry available, using default coordinates'); + return DEFAULT_RETURN; + } + + try { + let minLng = Infinity, maxLng = -Infinity; + let minLat = Infinity, maxLat = -Infinity; + + // Check for supported geometry types + if (!['Point', 'Polygon', 'MultiPolygon', 'GeometryCollection'].includes(geometry.type)) { + console.warn(`[Location] Unsupported geometry type: ${geometry.type}, using default coordinates`); + return DEFAULT_RETURN; + } + + const processCoordinates = (coords: number[][]) => { + coords.forEach(point => { + const [lng, lat] = point; + minLng = Math.min(minLng, lng); + maxLng = Math.max(maxLng, lng); + minLat = Math.min(minLat, lat); + maxLat = Math.max(maxLat, lat); + }); + }; + + const processGeometry = (geom: any) => { + if (geom.type === 'Polygon') { + processCoordinates(geom.coordinates[0]); + } else if (geom.type === 'MultiPolygon') { + geom.coordinates.forEach((polygon: number[][][]) => { + processCoordinates(polygon[0]); + }); + } else if (geom.type === 'Point') { + const [lng, lat] = geom.coordinates; + minLng = Math.min(minLng, lng); + maxLng = Math.max(maxLng, lng); + minLat = Math.min(minLat, lat); + maxLat = Math.max(maxLat, lat); + } + }; + + if (geometry.type === 'GeometryCollection') { + geometry.geometries.forEach(processGeometry); + } else { + processGeometry(geometry); + } + + const bounds = { + minLng, + maxLng, + minLat, + maxLat + }; + + const center: [number, number] = [ + (minLng + maxLng) / 2, + (minLat + maxLat) / 2 + ]; + + return { bounds, center }; + } catch (error) { + console.error('[Location] Error calculating geometry bounds:', error); + return DEFAULT_RETURN; + } +} + +/** + * Create a circular polygon approximation around a center point. + * Uses Haversine-based offsets to generate a 64-point polygon. + */ +export function createCircleBuffer(center: [number, number], radiusInMeters: number): GeoJSON.Polygon { + const earthRadius = 6371000; // Earth's radius in meters + const lat = center[1] * Math.PI / 180; // Convert to radians + const lng = center[0] * Math.PI / 180; + + const points: [number, number][] = []; + const numPoints = 64; // Number of points to create the circle + + for (let i = 0; i < numPoints; i++) { + const angle = (i * 360 / numPoints) * Math.PI / 180; + + // Calculate offset in radians + const dLat = radiusInMeters * Math.cos(angle) / earthRadius; + const dLng = radiusInMeters * Math.sin(angle) / (earthRadius * Math.cos(lat)); + + // Convert back to degrees + const newLat = (lat + dLat) * 180 / Math.PI; + const newLng = (lng + dLng) * 180 / Math.PI; + + points.push([newLng, newLat]); + } + + // Close the polygon by adding the first point at the end + points.push(points[0]); + + return { + type: 'Polygon', + coordinates: [points] + }; +} + +/** + * Calculate the great-circle distance between two [lng, lat] points using the Haversine formula. + * Returns distance in meters. + */ +export function haversineDistance(a: [number, number], b: [number, number]): number { + const R = 6371000; + const dLat = (b[1] - a[1]) * Math.PI / 180; + const dLng = (b[0] - a[0]) * Math.PI / 180; + const sinDLat = Math.sin(dLat / 2); + const sinDLng = Math.sin(dLng / 2); + const h = sinDLat * sinDLat + Math.cos(a[1] * Math.PI / 180) * Math.cos(b[1] * Math.PI / 180) * sinDLng * sinDLng; + return R * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)); +} diff --git a/src/lib/geo/buffer.ts b/src/lib/geo/buffer.ts deleted file mode 100644 index 9e8814d7..00000000 --- a/src/lib/geo/buffer.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Create a circular polygon approximation around a center point. - * Uses Haversine-based offsets to generate a 64-point polygon. - */ -export function createCircleBuffer(center: [number, number], radiusInMeters: number): GeoJSON.Polygon { - const earthRadius = 6371000; // Earth's radius in meters - const lat = center[1] * Math.PI / 180; // Convert to radians - const lng = center[0] * Math.PI / 180; - - const points: [number, number][] = []; - const numPoints = 64; // Number of points to create the circle - - for (let i = 0; i < numPoints; i++) { - const angle = (i * 360 / numPoints) * Math.PI / 180; - - // Calculate offset in radians - const dLat = radiusInMeters * Math.cos(angle) / earthRadius; - const dLng = radiusInMeters * Math.sin(angle) / (earthRadius * Math.cos(lat)); - - // Convert back to degrees - const newLat = (lat + dLat) * 180 / Math.PI; - const newLng = (lng + dLng) * 180 / Math.PI; - - points.push([newLng, newLat]); - } - - // Close the polygon by adding the first point at the end - points.push(points[0]); - - return { - type: 'Polygon', - coordinates: [points] - }; -} diff --git a/src/lib/search/filters.ts b/src/lib/search/filters.ts index e5a73878..b531abb0 100644 --- a/src/lib/search/filters.ts +++ b/src/lib/search/filters.ts @@ -3,7 +3,7 @@ import { aiChat } from '@/lib/ai'; import { getCities } from '@/lib/db/cities'; import { getCity } from '@/lib/db/cities'; import { getPlaceSuggestions, getPlaceDetails } from '@/lib/google-maps'; -import { calculateGeometryBounds } from '@/lib/utils'; +import { calculateGeometryBounds } from '@/lib/geo'; import { Location } from './types'; // Define the system prompt for filter extraction diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 7264d896..3b98b675 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -376,85 +376,4 @@ export function buildCityUrl(cityId: string, path: string = '', locale: string = // Default path-based URL structure return `/${locale}/${cityId}${path ? `/${path}` : ''}`; -} - -type GeometryBounds = { - bounds: { minLng: number; maxLng: number; minLat: number; maxLat: number } | null; - center: [number, number]; -}; - -/** - * Calculates bounds and center from a GeoJSON geometry - * @param geometry The GeoJSON geometry to process - */ -export function calculateGeometryBounds(geometry: any): GeometryBounds { - const DEFAULT_RETURN: GeometryBounds = { - bounds: null, - center: [23.7275, 37.9838] // Default to Athens - }; - - if (!geometry) { - console.log('[Location] No geometry available, using default coordinates'); - return DEFAULT_RETURN; - } - - try { - let minLng = Infinity, maxLng = -Infinity; - let minLat = Infinity, maxLat = -Infinity; - - // Check for supported geometry types - if (!['Point', 'Polygon', 'MultiPolygon', 'GeometryCollection'].includes(geometry.type)) { - console.warn(`[Location] Unsupported geometry type: ${geometry.type}, using default coordinates`); - return DEFAULT_RETURN; - } - - const processCoordinates = (coords: number[][]) => { - coords.forEach(point => { - const [lng, lat] = point; - minLng = Math.min(minLng, lng); - maxLng = Math.max(maxLng, lng); - minLat = Math.min(minLat, lat); - maxLat = Math.max(maxLat, lat); - }); - }; - - const processGeometry = (geom: any) => { - if (geom.type === 'Polygon') { - processCoordinates(geom.coordinates[0]); - } else if (geom.type === 'MultiPolygon') { - geom.coordinates.forEach((polygon: number[][][]) => { - processCoordinates(polygon[0]); - }); - } else if (geom.type === 'Point') { - const [lng, lat] = geom.coordinates; - minLng = Math.min(minLng, lng); - maxLng = Math.max(maxLng, lng); - minLat = Math.min(minLat, lat); - maxLat = Math.max(maxLat, lat); - } - }; - - if (geometry.type === 'GeometryCollection') { - geometry.geometries.forEach(processGeometry); - } else { - processGeometry(geometry); - } - - const bounds = { - minLng, - maxLng, - minLat, - maxLat - }; - - const center: [number, number] = [ - (minLng + maxLng) / 2, - (minLat + maxLat) / 2 - ]; - - return { bounds, center }; - } catch (error) { - console.error('[Location] Error calculating geometry bounds:', error); - return DEFAULT_RETURN; - } -} +} \ No newline at end of file From 5722304b8f966d9230b143bd34662075b015426d Mon Sep 17 00:00:00 2001 From: kouloumos Date: Wed, 25 Feb 2026 19:43:52 +0200 Subject: [PATCH 20/20] test: add unit tests for geo utilities Cover calculateGeometryBounds (Point, Polygon, MultiPolygon, GeometryCollection, null, unsupported), createCircleBuffer (shape, closure, radius accuracy), and haversineDistance (zero, known distance, symmetry). --- src/lib/__tests__/geo.test.ts | 104 ++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 src/lib/__tests__/geo.test.ts diff --git a/src/lib/__tests__/geo.test.ts b/src/lib/__tests__/geo.test.ts new file mode 100644 index 00000000..959175eb --- /dev/null +++ b/src/lib/__tests__/geo.test.ts @@ -0,0 +1,104 @@ +import { calculateGeometryBounds, createCircleBuffer, haversineDistance } from '../geo'; + +describe('calculateGeometryBounds', () => { + it('returns default Athens center for null geometry', () => { + const result = calculateGeometryBounds(null); + expect(result.bounds).toBeNull(); + expect(result.center).toEqual([23.7275, 37.9838]); + }); + + it('returns default for unsupported geometry type', () => { + const result = calculateGeometryBounds({ type: 'LineString', coordinates: [[0, 0], [1, 1]] }); + expect(result.bounds).toBeNull(); + }); + + it('calculates bounds for a Point', () => { + const result = calculateGeometryBounds({ type: 'Point', coordinates: [23.7, 37.9] }); + expect(result.center).toEqual([23.7, 37.9]); + expect(result.bounds).toEqual({ minLng: 23.7, maxLng: 23.7, minLat: 37.9, maxLat: 37.9 }); + }); + + it('calculates bounds for a Polygon', () => { + const polygon = { + type: 'Polygon', + coordinates: [[[10, 20], [30, 20], [30, 40], [10, 40], [10, 20]]] + }; + const result = calculateGeometryBounds(polygon); + expect(result.bounds).toEqual({ minLng: 10, maxLng: 30, minLat: 20, maxLat: 40 }); + expect(result.center).toEqual([20, 30]); + }); + + it('calculates bounds for a MultiPolygon', () => { + const multi = { + type: 'MultiPolygon', + coordinates: [ + [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + [[[5, 5], [6, 5], [6, 6], [5, 6], [5, 5]]] + ] + }; + const result = calculateGeometryBounds(multi); + expect(result.bounds).toEqual({ minLng: 0, maxLng: 6, minLat: 0, maxLat: 6 }); + expect(result.center).toEqual([3, 3]); + }); + + it('calculates bounds for a GeometryCollection', () => { + const collection = { + type: 'GeometryCollection', + geometries: [ + { type: 'Point', coordinates: [0, 0] }, + { type: 'Point', coordinates: [10, 10] } + ] + }; + const result = calculateGeometryBounds(collection); + expect(result.bounds).toEqual({ minLng: 0, maxLng: 10, minLat: 0, maxLat: 10 }); + expect(result.center).toEqual([5, 5]); + }); +}); + +describe('createCircleBuffer', () => { + it('returns a closed GeoJSON Polygon', () => { + const result = createCircleBuffer([23.7, 37.9], 500); + expect(result.type).toBe('Polygon'); + expect(result.coordinates).toHaveLength(1); + const ring = result.coordinates[0]; + // 64 points + closing point + expect(ring).toHaveLength(65); + // First and last point must be identical (closed ring) + expect(ring[ring.length - 1]).toEqual(ring[0]); + }); + + it('produces points approximately at the requested radius', () => { + const center: [number, number] = [23.7, 37.9]; + const radiusMeters = 1000; + const result = createCircleBuffer(center, radiusMeters); + const ring = result.coordinates[0]; + + // Check that all points are approximately 1000m from center using haversine + for (const point of ring.slice(0, -1)) { + const dist = haversineDistance(center, point as [number, number]); + // Allow 1% tolerance for the spherical approximation + expect(dist).toBeGreaterThan(radiusMeters * 0.99); + expect(dist).toBeLessThan(radiusMeters * 1.01); + } + }); +}); + +describe('haversineDistance', () => { + it('returns 0 for identical points', () => { + expect(haversineDistance([23.7, 37.9], [23.7, 37.9])).toBe(0); + }); + + it('calculates known distance between Athens and Thessaloniki (~300km)', () => { + // Athens: [23.7275, 37.9838], Thessaloniki: [22.9444, 40.6401] + const dist = haversineDistance([23.7275, 37.9838], [22.9444, 40.6401]); + // Approximately 300km, allow 10% tolerance + expect(dist).toBeGreaterThan(290_000); + expect(dist).toBeLessThan(310_000); + }); + + it('is symmetric', () => { + const a: [number, number] = [0, 0]; + const b: [number, number] = [1, 1]; + expect(haversineDistance(a, b)).toBeCloseTo(haversineDistance(b, a)); + }); +});