diff --git a/.vscode/settings.json b/.vscode/settings.json index 44a73ec..4ee2942 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,6 @@ { "mode": "auto" } - ] + ], + "css.lint.unknownAtRules": "ignore" } diff --git a/apps/event-app/.gitignore b/apps/event-app/.gitignore new file mode 100644 index 0000000..016b59e --- /dev/null +++ b/apps/event-app/.gitignore @@ -0,0 +1,24 @@ +# build output +dist/ + +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store + +# jetbrains setting folder +.idea/ diff --git a/apps/event-app/.vscode/extensions.json b/apps/event-app/.vscode/extensions.json new file mode 100644 index 0000000..22a1505 --- /dev/null +++ b/apps/event-app/.vscode/extensions.json @@ -0,0 +1,4 @@ +{ + "recommendations": ["astro-build.astro-vscode"], + "unwantedRecommendations": [] +} diff --git a/apps/event-app/.vscode/launch.json b/apps/event-app/.vscode/launch.json new file mode 100644 index 0000000..d642209 --- /dev/null +++ b/apps/event-app/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "command": "./node_modules/.bin/astro dev", + "name": "Development server", + "request": "launch", + "type": "node-terminal" + } + ] +} diff --git a/apps/event-app/README.md b/apps/event-app/README.md new file mode 100644 index 0000000..cb98f27 --- /dev/null +++ b/apps/event-app/README.md @@ -0,0 +1,46 @@ +# Astro Starter Kit: Basics + +```sh +pnpm create astro@latest -- --template basics +``` + +> πŸ§‘β€πŸš€ **Seasoned astronaut?** Delete this file. Have fun! + +## πŸš€ Project Structure + +Inside of your Astro project, you'll see the following folders and files: + +```text +/ +β”œβ”€β”€ public/ +β”‚ └── favicon.svg +β”œβ”€β”€ src +β”‚Β Β  β”œβ”€β”€ assets +β”‚Β Β  β”‚Β Β  └── astro.svg +β”‚Β Β  β”œβ”€β”€ components +β”‚Β Β  β”‚Β Β  └── Welcome.astro +β”‚Β Β  β”œβ”€β”€ layouts +β”‚Β Β  β”‚Β Β  └── Layout.astro +β”‚Β Β  └── pages +β”‚Β Β  └── index.astro +└── package.json +``` + +To learn more about the folder structure of an Astro project, refer to [our guide on project structure](https://docs.astro.build/en/basics/project-structure/). + +## 🧞 Commands + +All commands are run from the root of the project, from a terminal: + +| Command | Action | +| :--------------------- | :----------------------------------------------- | +| `pnpm install` | Installs dependencies | +| `pnpm dev` | Starts local dev server at `localhost:4321` | +| `pnpm build` | Build your production site to `./dist/` | +| `pnpm preview` | Preview your build locally, before deploying | +| `pnpm astro ...` | Run CLI commands like `astro add`, `astro check` | +| `pnpm astro -- --help` | Get help using the Astro CLI | + +## πŸ‘€ Want to learn more? + +Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat). diff --git a/apps/event-app/astro.config.mjs b/apps/event-app/astro.config.mjs new file mode 100644 index 0000000..0564e04 --- /dev/null +++ b/apps/event-app/astro.config.mjs @@ -0,0 +1,25 @@ +// @ts-check +import { defineConfig } from 'astro/config'; + +import cloudflare from '@astrojs/cloudflare'; + +import react from '@astrojs/react'; +import tailwindcss from '@tailwindcss/vite'; + +// https://astro.build/config +export default defineConfig({ + adapter: cloudflare({ + platformProxy: { + enabled: true, + }, + + imageService: 'cloudflare', + }), + + integrations: [react()], + + vite: { + plugins: [tailwindcss()], + }, +}); + diff --git a/apps/event-app/docker-compose.yml b/apps/event-app/docker-compose.yml new file mode 100644 index 0000000..b7f9ee4 --- /dev/null +++ b/apps/event-app/docker-compose.yml @@ -0,0 +1,16 @@ +services: + db: + image: postgres:16 + container_name: my-postgres-db + restart: always + environment: + POSTGRES_USER: my_user + POSTGRES_PASSWORD: my_password + POSTGRES_DB: my_database + ports: + - '5432:5432' + volumes: + - pgdata:/var/lib/postgresql/data + +volumes: + pgdata: diff --git a/apps/event-app/documentation/ADR.md b/apps/event-app/documentation/ADR.md new file mode 100644 index 0000000..7b0055b --- /dev/null +++ b/apps/event-app/documentation/ADR.md @@ -0,0 +1,237 @@ +# Architectural Decision Record: Modular Monolith Architecture + +## Status + +**Proposed** - This ADR documents the architectural decisions for the event-app's modular monolith architecture. + +## Context + +The event-app is being restructured to follow a modular monolith architecture. This approach provides clear separation of concerns, modularity, and scalability while maintaining a cohesive development experience within a single deployable unit. + +## Decision + +We will implement a modular monolith architecture with the following core components: + +### 1. Modules (Feature Modules) + +**Location**: `src/modules/` + +Modules are isolated, independent units representing a complete user-facing feature or "feature story". Each module is a self-contained vertical slice of functionality with its own internal logic. + +**Current Implementation**: + +- `src/modules/main/` - Main application module containing dashboard, tasks, and account features + +**Characteristics**: + +- **Self-contained**: Manages its own internal routing, state, and UI. +- **Isolated**: Has no direct knowledge of other modules. +- **Communicates via Contracts**: Interacts with the system and other modules only through the defined `contracts` layer and by using services provided by the `core` and `shared` layers. + +### 2. Core (Application Core) + +**Location**: `src/core/` + +The Core is the foundational layer of the system. It provides low-level services required for the application to function. Modules typically do not interact with the core directly; instead, they use the more abstract, feature-facing services provided by the `shared` layer. + +**Responsibilities (What goes here?):** + +- **Core Services**: `src/core/db/` - Database connections and clients. +- **Global Styles & Layouts**: `src/core/style/` - The outermost app layout and global CSS resets. +- **Environment Loading**: Bootstrapping environment variables. +- **Authentication Core**: Low-level authentication state management and client setup. + +### 3. Contracts (Inter-Module Communication) + +**Location**: `src/contracts/` + +The Contracts layer defines the interfaces for how different parts of the system communicate. It is the "API" of the frontend, ensuring type-safe and reliable data exchange between modules, the core, and the backend. It contains no executable code, only definitions. + +**Responsibilities (What goes here?):** + +- **API Contracts**: `src/contracts/api/` - Zod schemas and type definitions for all API endpoints. +- **Environment Contracts**: `src/contracts/env/` - Type definitions for environment variables (`env.d.ts`). +- **Event Contracts**: Definitions for any cross-module events (e.g., via a message bus). + +### 4. Shared (Cross-Module Services) + +**Location**: `src/shared/` + +This layer contains domain-specific features, components, and hooks that are designed to be shared and used across multiple modules. It provides abstractions over core services that modules can consume directly. + +**Responsibilities (What goes here?):** + +- **Shared Domain Features**: `src/shared/focus-session/` - Logic and UI for a core business concept used in multiple modules. + +### 5. Libraries (Utility Libraries) + +**Location**: `src/lib/` and `packages/` + +Libraries are domain-agnostic, reusable code modules: + +#### Current Libraries: + +- **Clean API v2**: Type-safe API client with validation + +#### Characteristics: + +- Completely domain-agnostic +- Highly reusable across different modules +- Well-tested and stable +- Version-controlled independently + +### 4. Shared Features (Core Extensions) + +**Location**: `src/shared/` + +For features that need to be shared between modules but are domain-specific: + +#### Decision Framework for Shared Features: + +**When to put in `src/shared/`:** + +- βœ… Feature is used by multiple modules +- βœ… Feature contains domain-specific business logic +- βœ… Feature needs to maintain state consistency across modules + +**When to put in `src/lib/` or `packages/`:** + +- βœ… Code is completely domain-agnostic +- βœ… Code can be used in any application context +- βœ… Code has no business logic dependencies +- βœ… Code is a pure utility or primitive + +**When to duplicate in each module:** + +- βœ… Feature is module-specific but similar to others +- βœ… Feature needs different implementations per module +- βœ… Feature is experimental and may diverge + +## Architecture Diagram + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Application β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Modules (Feature Modules) - src/modules/ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Main Module β”‚ β”‚Admin Module β”‚ β”‚Mobile Moduleβ”‚ ... β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β–² β–² β”‚ +β”‚ β”‚ (Consumes) β”‚ (Consumes) β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Shared (Cross-Module Services) - src/shared/ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ FocusSessionβ”‚ β”‚ NavBar β”‚ β”‚ use-auth β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β–² β–² β”‚ +β”‚ β”‚ (Abstracts) β”‚ (Abstracts) β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Core (Application Core) - src/core/ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ DB Client β”‚ β”‚ Auth State β”‚ β”‚ Global CSS β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Contracts (Interface Layer) - src/contracts/ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ API Schemas β”‚ β”‚ Event Types β”‚ β”‚ Env Defs β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Libraries (Generic Utils) - src/lib/ & src/components/ui/ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Clean API β”‚ β”‚ UI Primitivesβ”‚ β”‚ Utilities β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Implementation Guidelines + +### Module Development + +1. **Isolation**: A module should never import from another module (`../../modules/other-module`). +2. **Communication**: Use only `shared` services and `contracts` definitions. Avoid direct core access where possible. +3. **State Management**: Each module manages its own state independently +4. **Routing**: Use core routing definitions but implement module-specific navigation + +### Core Development + +1. **Stability**: Core APIs should be stable and well-versioned, as changes can have wide-ranging effects. +2. **Minimality**: The core should remain as small as possible. Prefer implementing features in the `shared` layer if they are not fundamental application services. +3. **Documentation**: All core services must be well-documented +4. **Testing**: Comprehensive testing for all core services +5. **Backward Compatibility**: Maintain backward compatibility when possible. + +### Shared Development + +1. **Abstraction**: Services in this layer should provide clean, easy-to-use abstractions over core logic. +2. **Domain-Specific**: Code here is specific to the event-app domain but reusable across features. + +### Library Development + +1. **Domain Agnostic**: Libraries should have no business logic +2. **Reusability**: Design for maximum reusability +3. **Testing**: Extensive unit testing +4. **Documentation**: Clear API documentation + +### Shared Feature Development + +1. **Justification**: Document why a feature needs to be shared +2. **Interface Design**: Design clean, stable interfaces +3. **Versioning**: Use semantic versioning for shared features +4. **Migration Path**: Provide clear migration paths for breaking changes + +## File Structure + +``` +src/ +β”œβ”€β”€ modules/ # Feature Modules (Isolated Features) +β”‚ └── main/ +β”œβ”€β”€ core/ # Application Core (Low-level services) +β”‚ β”œβ”€β”€ db/ +β”‚ └── style/ +β”œβ”€β”€ contracts/ # Communication Contracts (Types, Schemas) +β”‚ β”œβ”€β”€ api/ +β”‚ └── env/ +β”œβ”€β”€ shared/ # Cross-Module Services (Consumed by Modules) +β”‚ └── focus-session/ +β”œβ”€β”€ lib/ # Utility Libraries (Domain-agnostic) +β”‚ └── clean-api-v2/ + └── ui/ +``` + +## Benefits + +1. **Modularity**: Clear separation of concerns and responsibilities +2. **Scalability**: Easy to add new modules and features +3. **Team Independence**: Different teams can work on different modules +4. **Maintainability**: Isolated codebases are easier to maintain +5. **Testing**: Each component can be tested independently +6. **Deployment**: Single deployable unit with well-defined internal boundaries + +## Risks and Mitigations + +### Risks: + +1. **Over-engineering**: Risk of creating unnecessary complexity +2. **Communication Overhead**: Coordination between teams +3. **Version Management**: Managing dependencies between components + +### Mitigations: + +1. **Clear Guidelines**: Well-defined architectural guidelines +2. **Documentation**: Comprehensive documentation and examples +3. **Tooling**: Automated dependency management and versioning +4. **Regular Reviews**: Regular architecture reviews and refactoring + +## Future Considerations + +1. **Module Extraction**: Consider extracting mature modules into independent services if needed +2. **Cross-Module Communication**: Implement an event bus defined in `contracts` for decoupled module-to-module communication. +3. **Shared State Management**: Consider a global state solution managed within the `shared` layer. +4. **Performance**: Optimize bundle splitting and lazy loading +5. **Monitoring**: Implement comprehensive monitoring and observability + +## Conclusion + +This modular monolith architecture provides a solid foundation for scalable, maintainable applications while preserving the simplicity and cohesion of a single deployable unit. The clear separation between modules, core, shared, contracts, and libraries ensures that the system remains flexible and extensible as it grows. diff --git a/apps/event-app/documentation/DOMAINS.md b/apps/event-app/documentation/DOMAINS.md new file mode 100644 index 0000000..591d10f --- /dev/null +++ b/apps/event-app/documentation/DOMAINS.md @@ -0,0 +1,215 @@ +--- +version: 1.1 +updated: 26.05.2026 +--- + +# Application Domains + +This document defines the core domains in the Event Search App application. + +--- + +## 1. Identity & Access + +**Responsibility:** User authentication, authorization, session management, and role-based access control. + +**Key Concepts:** + +- Google OAuth integration +- Facebook OAuth integration +- Email/password authentication +- Login/logout flows +- Session management and token refresh +- Role model: Guest, Authenticated User, Admin +- Access control per feature (browse vs. attend vs. manage) + +**Out of Scope:** + +- User profile data (handled by User Profile domain) + +--- + +## 2. User Profile + +**Responsibility:** User profile data, preferences, and attendance privacy settings. + +**Key Concepts:** + +- Display name and avatar +- Global attendance visibility toggle (public / private) +- Per-event attendance visibility override +- Profile completeness +- Friend list reference (owned by Social domain) + +**Out of Scope:** + +- Authentication flows (handled by Identity & Access domain) +- Friend relationship management (handled by Social domain) + +--- + +## 3. Event Management + +**Responsibility:** Creating, editing, and deleting events β€” the authoritative write-side for event data. + +**Key Concepts:** + +- Event aggregate: name, description, category, start date & time (required), end date & time (optional), address (street, number, postal code, city), coordinates (lat/lng), external link, optional image, keywords +- Location input: city/address autocomplete β€” user types, picks from suggestions, city + coordinates filled automatically via geocoding +- Category taxonomy: Concert, Festival, Sports, Culture, Theatre, Food & Drink +- Keyword tagging (manual + AI-assisted suggestions via LLM) +- All authenticated users can create events +- Event owners can update and delete their own events +- Admins can update and delete any event +- Organizer info + +**Out of Scope:** + +- Event discovery, search, and browsing (handled by Event Discovery domain) +- Attendance tracking (handled by Attendance domain) + +--- + +## 4. Event Discovery + +**Responsibility:** Browsing, searching, filtering, and viewing event details. + +**Key Concepts:** + +- Poland-only scope +- Search bar with four fields: name (free text), category (dropdown), location/city (dropdown), date (preset labels resolving to date ranges) +- City/location filter matches against the normalized city field stored on each event; options: "CaΕ‚a Polska" (no filter) or a specific city +- Date filter uses preset labels (Today, This Weekend, This Week, This Month, etc.) rather than a raw date range picker +- Scrollable list of event cards displaying search results +- Event detail page: full read view of a single event aggregate β€” name, description, category, date & time, address, external link, image, keywords, organizer info, attendee count, and friends attending; navigated to from the event list + +**Out of Scope:** + +- Event data management (handled by Event Management domain) + +--- + +## 5. Attendance + +**Responsibility:** Tracking which authenticated users are attending which events, and exposing aggregate counts. + +**Key Concepts:** + +- Attend / un-attend an event +- Attendance record stored to user profile +- Public attendee count (integer, visible to all users) +- Friends-attending list (names/avatars, visible to authenticated users only) +- Per-event privacy override (public / private attendance) + +**Out of Scope:** + +- Friend relationship resolution (handled by Social domain) +- Calendar view of attended events (handled by Personal Calendar domain) + +--- + +## 6. Social + +**Responsibility:** Friend relationship management and friend-to-event invitation flow. + +**Key Concepts:** + +- Friend discovery by username or email +- Friend request lifecycle: send β†’ accept / decline +- Bidirectional friendship model +- Friend network (used by Attendance and Personal Calendar domains) +- Event invite: authenticated user selects friends and sends invite +- Invite triggers an in-app notification (dispatched to Notification domain) + +**Out of Scope:** + +- Notification delivery (handled by Notification domain) +- Attendance state of friends (handled by Attendance domain) + +--- + +## 7. Personal Calendar + +**Responsibility:** Calendar views aggregating the user's own events and friends' public events. + +**Key Concepts:** + +- My Attended Events calendar β€” events the user has marked as attending +- Friends' Public Events calendar β€” events friends (with public attendance) are attending +- Export to device calendar (iCal/Google Calendar) β€” available to all users, no auth required +- Shared calendar view (authenticated users only) + +**Out of Scope:** + +- Attendance state resolution (handled by Attendance domain) +- Friend network resolution (handled by Social domain) + +--- + +## 8. Notification + +**Responsibility:** In-app notification delivery and feed management. + +**Key Concepts:** + +- Notification types (MVP): event invite from a friend +- Notification bell / feed UI +- Mark as read +- No push or email notifications for MVP (future iteration) + +**Out of Scope:** + +- Invite trigger logic (handled by Social domain) + +--- + +## 9. Saved Events + +**Responsibility:** Bookmarking events for later and presenting the authenticated user's saved event list. + +**Key Concepts:** + +- Save / unsave an event (bookmark, not attendance) +- Saved events list: paginated, sorted by event start date ascending +- Visible only to the owning user (private by default, no sharing) +- Save action available from event cards (list view) and event detail page +- Visual indicator on event cards and detail page showing whether the event is already saved +- Saved events that have passed are retained but visually marked as past + +**Out of Scope:** + +- Attendance tracking (handled by Attendance domain) +- Calendar aggregation of saved events (handled by Personal Calendar domain) +- Authentication and session (handled by Identity & Access domain) + +--- + +## Domain Interactions + +``` +Identity & Access β†’ User Profile (creates profile after first authentication) +Identity & Access β†’ Event Discovery (determines available features: guest vs. auth) +Identity & Access β†’ Event Management (Admin role gates CRUD operations) + +User Profile β†’ Attendance (privacy settings applied to attendance visibility) +User Profile β†’ Social (display name/avatar used in friend and invite flows) + +Event Management β†’ Event Discovery (provides event data for search results and detail page) + +Event Discovery β†’ Attendance (friends-attending filter on event list) +Event Discovery β†’ Event Management (reads event aggregates for detail page) + +Attendance β†’ Personal Calendar (attended events populate calendar view) +Attendance β†’ Social (friends' public attendance exposed to their network) + +Social β†’ Notification (friend invite dispatches in-app notification) +Social β†’ Personal Calendar (friends' public events visible in shared calendar) + +Notification β†’ Social (notification links back to the originating event invite) + +Identity & Access β†’ Saved Events (only authenticated users can save events) +Saved Events β†’ Event Management (reads event aggregates to populate the saved list) +Saved Events β†’ Personal Calendar (saved events surfaced as a calendar source) +``` + +--- diff --git a/apps/event-app/documentation/MODULE-GUIDE.md b/apps/event-app/documentation/MODULE-GUIDE.md new file mode 100644 index 0000000..96ec5a9 --- /dev/null +++ b/apps/event-app/documentation/MODULE-GUIDE.md @@ -0,0 +1,488 @@ +# Module Guide: Store, Mediator & Event-Driven State + +This guide explains how to build a feature module using the project's state +primitives: **`supa-store`** (reactive atoms), **`eda`** (event-driven +architecture over RxJS), and **`power-context`** (scoped React context). It is +meant to be passed as a reference when starting a new module so the wiring does +not have to be re-explained each time. + +--- + +## Mental model + +Data flows in one direction: + +``` + UI (presentation) core (mediator) integration + ───────────────── ─────────────── ─────────── + trigger('[TRIGGER]_X') ──────► registry handler ──────► repository + (RxJS stream) async call (API / mock) + β”‚ + β–Ό + store.$atom.set(...) + β”‚ + re-render ◄── $atom.use() β—„β”€β”€β”€β”€β”˜ +``` + +- **The UI never mutates state directly.** It calls `trigger(...)` to announce + an intent, and reads state via `$atom.use()`. +- **Handlers** (the registry) listen for events, perform async work through the + repository, and write results into the store. +- **The store** holds reactive atoms. Any component reading an atom with + `.use()` re-renders when it changes. +- **The mediator** binds one store instance to one registry instance and is + scoped to a React subtree via `power-context`. State is therefore **per + Provider**, not a global singleton β€” mounting the module twice gives two + independent states. + +--- + +## Folder & file structure + +Create the module under `src/modules//` with four layers: + +``` +src/modules// +β”œβ”€β”€ contracts/ +β”‚ β”œβ”€β”€ models.ts # Domain types (ids, entities, enums) +β”‚ └── events.ts # The event union (TRIGGER / TASK / FACT / EFFECT) +β”œβ”€β”€ core/ +β”‚ β”œβ”€β”€ store.ts # createStore(): atoms, maps, computed values +β”‚ β”œβ”€β”€ registry.ts # createRegistry(store): wires handlers to events +β”‚ β”œβ”€β”€ mediator.ts # createMediator(): store + trigger + registry +β”‚ └── handlers/ +β”‚ β”œβ”€β”€ .ts # One file per event handler +β”‚ └── .ts +β”œβ”€β”€ integration/ +β”‚ └── repository.ts # API calls / data access (returns Promises) +└── presentation/ + β”œβ”€β”€ context.tsx # createHookContext β†’ [Provider, useContext] + β”œβ”€β”€ main.tsx # Provider + bootstrap trigger (module entry point) + └── .tsx # UI components that read store + call trigger +``` + +| Layer | Responsibility | May import from | +| --------------- | ----------------------------------------------------------------- | ---------------------------------------- | +| `contracts/` | Pure types. No logic. | nothing (only `@/libs/eda` types) | +| `core/` | State + business logic. Framework-agnostic except the store hook. | `contracts/`, `integration/`, `@/libs/*` | +| `integration/` | Talking to the outside world (HTTP, Supabase, mocks). | `contracts/` | +| `presentation/` | React components. Reads state, fires triggers. | `core/`, `contracts/`, `@/libs/ui` | + +> Import the shared primitives via the `@/` alias, e.g. +> `import { atom, computed } from '@/libs/supa-store';`. + +--- + +## Step 1 β€” Define the contracts + +### `contracts/models.ts` + +Domain types only. No behaviour. + +```ts +export type EventId = string; + +export type EventStatus = 'draft' | 'published' | 'archived'; + +export type EventItem = { + id: EventId; + title: string; + status: EventStatus; +}; +``` + +### `contracts/events.ts` + +The event union is the contract between the UI and the core. Each event is one +of four kinds, distinguished by a `[PREFIX]_` naming convention: + +| Prefix | Meaning | Emitted by | +| ------------ | -------------------------------------------- | -------------------------- | +| `[TRIGGER]_` | A user action / external input (entry point) | the UI, via `trigger(...)` | +| `[TASK]_` | An async operation about to run | a handler, via `forwardAs` | +| `[FACT]_` | Something that happened (state change) | a handler, via `forwardAs` | +| `[EFFECT]_` | Cross-cutting concern (logging, analytics) | a handler, via `emit` | + +```ts +import { type TriggerEvent } from '@/libs/eda'; + +export type Event = + | TriggerEvent<'[TRIGGER]_BOOTSTRAP'> + | TriggerEvent<'[TRIGGER]_SELECT_EVENT', { eventId: string }> + | TriggerEvent<'[TRIGGER]_UPDATE_SEARCH', { query: string }> + | TriggerEvent<'[TRIGGER]_SAVE_EVENT'>; +``` + +For simple modules you may only need `TriggerEvent`s (the UI triggers, the +handler does the work and writes the store directly). `TaskEvent` / `FactEvent` +become useful once a flow has multiple async stages worth tracing. See the +"Event flow depth" note at the end. + +--- + +## Step 2 β€” Build the store + +### `core/store.ts` + +`createStore()` returns an object of reactive atoms and computed values. It is a +**factory** (not module-level singletons) so each Provider gets its own state. + +```ts +import { atom, computed } from '@/libs/supa-store'; +import type { EventId, EventItem } from '../contracts/models'; + +export const createStore = () => { + const $isBootstrapping = atom(false); + const $bootstrapError = atom(null); + const $events = atom([]); + const $selectedId = atom(''); + const $searchQuery = atom(''); + + return { + $isBootstrapping, + $bootstrapError, + $events, + $selectedId, + $searchQuery, + // Derived state β€” recomputes automatically when dependencies change. + $selectedEvent: computed( + [$events, $selectedId], + (events, selectedId) => events.find((e) => e.id === selectedId) ?? null, + ), + $filteredEvents: computed([$events, $searchQuery], (events, query) => { + const q = query.trim().toLowerCase(); + if (!q) return events; + return events.filter((e) => e.title.toLowerCase().includes(q)); + }), + }; +}; + +export type Store = ReturnType; +``` + +Primitive choice (from `supa-store`): + +| Use | Primitive | +| ------------------------------------------------------ | ------------------------------ | +| flags, strings, numbers, nullable values, whole arrays | `atom` | +| dictionaries needing efficient single-key updates | `map` (`setKey` / `removeKey`) | +| values derived from other stores | `computed` | + +Each atom exposes `.get()`, `.set(v)`, `.reset()`, `.getInitial()`, and the +React hook `.use()`. **Always export `type Store`** β€” the registry and handlers +depend on it. + +--- + +## Step 3 β€” Write the handlers + +Each handler lives in its own file under `core/handlers/`. A handler is a +function that takes `(store, ofType)` and returns an **RxJS stream** that +listens for one event type and reacts to it. + +### `core/handlers/bootstrap.ts` (async example) + +```ts +import { catchError, EMPTY, finalize, from, switchMap, tap } from 'rxjs'; +import { getEvents } from '../../integration/repository'; +import type { OfType } from '../registry'; +import type { Store } from '../store'; + +export const bootstrap = (store: Store, ofType: OfType) => + ofType('[TRIGGER]_BOOTSTRAP').pipe( + tap(() => { + store.$isBootstrapping.set(true); + store.$bootstrapError.reset(); + }), + switchMap(() => { + const ctrl = new AbortController(); + + return from(getEvents(ctrl.signal)).pipe( + tap((events) => store.$events.set(events)), + catchError((error) => { + if (error instanceof DOMException && error.name === 'AbortError') + return EMPTY; + store.$bootstrapError.set( + error instanceof Error ? error.message : 'Failed to load.', + ); + return EMPTY; + }), + finalize(() => { + store.$isBootstrapping.reset(); + ctrl.abort(); + }), + ); + }), + ); +``` + +### `core/handlers/update-search.ts` (synchronous example) + +```ts +import { tap } from 'rxjs'; +import type { OfType } from '../registry'; +import type { Store } from '../store'; + +export const updateSearch = (store: Store, ofType: OfType) => + ofType('[TRIGGER]_UPDATE_SEARCH').pipe( + tap(({ query }) => store.$searchQuery.set(query)), + ); +``` + +Handler conventions: + +- `ofType('[TRIGGER]_X')` emits the event **payload** (already unwrapped), so + destructure it directly: `.pipe(tap(({ query }) => ...))`. +- Pick the RxJS flattening operator deliberately: + - `switchMap` β€” cancel the previous run when a new event arrives (search, + navigation, bootstrap/refresh). + - `exhaustMap` β€” ignore new events while one is in flight (submit/send, to + prevent double-submits). + - `mergeMap` / `concatMap` β€” run concurrently / in sequence. +- For cancellable async work, create an `AbortController`, pass `ctrl.signal` + to the repository, and `ctrl.abort()` inside `finalize`. Swallow `AbortError` + in `catchError`. +- Keep each handler focused on one event. Reset loading flags in `finalize` so + they clear on success, error, and cancellation alike. + +--- + +## Step 4 β€” Assemble the registry + +### `core/registry.ts` + +`createRegistry(store)` creates an `eda` instance bound to your `Event` union, +wires every handler, and returns `{ trigger, registry }`. + +```ts +import { eda } from '@/libs/eda'; +import { type Store } from './store'; +import { type Event } from '../contracts/events'; +import { bootstrap } from './handlers/bootstrap'; +import { selectEvent } from './handlers/select-event'; +import { updateSearch } from './handlers/update-search'; +import { saveEvent } from './handlers/save-event'; + +// Shared handler signature β€” every handler receives this `ofType`. +export type OfType = ReturnType>['ofType']; + +export const createRegistry = (store: Store) => { + const { ofType, trigger, createRegistry } = eda(); + + const registry = createRegistry( + bootstrap(store, ofType), + selectEvent(store, ofType), + updateSearch(store, ofType), + saveEvent(store, ofType), + ); + + return { trigger, registry }; +}; + +export type Registry = ReturnType; +``` + +- `trigger` is the type-safe emitter the UI uses to start a flow. +- `registry` is a function that, when called, subscribes all handler streams + and returns an **unsubscribe** function. It is invoked once by the Provider + (Step 6) and torn down on unmount. Each stream has its own error boundary, so + one failing handler will not stop the others. +- When you add a handler, register it in **both** places: create the file in + `handlers/` and add a line to `createRegistry(...)`. + +--- + +## Step 5 β€” Create the mediator + +### `core/mediator.ts` + +The mediator is the single object that bundles a fresh store, its trigger, and +its registry. It is what the Provider instantiates. + +```ts +import { createRegistry } from './registry'; +import { createStore } from './store'; + +export const createMediator = () => { + const store = createStore(); + const { trigger, registry } = createRegistry(store); + + return [store, trigger, registry] as const; +}; +``` + +That's the entire core. Calling `createMediator()` produces an isolated +`[store, trigger, registry]` triple. + +--- + +## Step 6 β€” Wire it into React with `power-context` + +### `presentation/context.tsx` + +`createHookContext` builds a typed `[Provider, useContext]` pair. The hook runs +`createMediator()` once, spreads the store atoms alongside `trigger` into the +context value, and activates the registry on mount (tearing it down on unmount). + +```tsx +import { useLayoutEffect, useState } from 'react'; +import { createHookContext } from '@/libs/power-context'; +import { createMediator } from '../core/mediator'; + +export const [Provider, useContext] = createHookContext( + 'EventManagement', + () => { + // useState(initialiser)[0] runs createMediator exactly once per Provider. + const [store, trigger, registry] = useState(createMediator)[0]; + const value = useState(() => ({ ...store, trigger }))[0]; + + useLayoutEffect(() => { + const unsub = registry(); // subscribe all handler streams + return () => unsub(); // clean up on unmount + }, [registry]); + + return value; + }, +); +``` + +Notes: + +- The first argument to `createHookContext` is a **PascalCase display name**. It + must not contain spaces and must not end in `Provider` or `Context` (the type + enforces this at compile time). It is used for the error message and React + DevTools labels. +- `value` exposes every store atom plus `trigger`. Components consume them as + `ctx.$someAtom.use()` and `ctx.trigger(...)`. + +### `presentation/main.tsx` β€” module entry point + +Wrap your UI in the `Provider`, and fire the bootstrap trigger once mounted. + +```tsx +import { useEffect } from 'react'; +import { Provider, useContext } from './context'; +import { EventManager } from './event-manager'; + +const Content = () => { + const { trigger } = useContext(); + + useEffect(() => { + trigger('[TRIGGER]_BOOTSTRAP'); + }, [trigger]); + + return ; +}; + +export const Main = () => ( + + + +); +``` + +`Main` is the component you mount from a page / route (e.g. an Astro island). + +--- + +## Consuming the module in the UI + +Inside any component **below the Provider**, call `useContext()` to get the +store + `trigger`. Read state with `.use()` (reactive) and announce intents with +`trigger(...)`. + +```tsx +import { useContext } from './context'; + +export const EventManager = () => { + const ctx = useContext(); + + // Reactive reads β€” component re-renders when these change. + const isBootstrapping = ctx.$isBootstrapping.use(); + const error = ctx.$bootstrapError.use(); + const events = ctx.$filteredEvents.use(); + const selected = ctx.$selectedEvent.use(); + const searchQuery = ctx.$searchQuery.use(); + + const { trigger } = ctx; + + if (error) { + return ( + + ); + } + + return ( +
+ + trigger('[TRIGGER]_UPDATE_SEARCH', { query: e.target.value }) + } + /> + +
    + {events.map((event) => ( +
  • + +
  • + ))} +
+ + {selected &&

Selected: {selected.title}

} +
+ ); +}; +``` + +Consumption rules: + +- **Read with `.use()`, never `.get()`** inside render β€” `.get()` does not + subscribe, so the component won't re-render. (`.get()` is for handlers.) +- **Never mutate the store from a component.** Express the intent with + `trigger(...)`; let a handler perform the change. This keeps the data flow + one-directional and traceable. +- `trigger` is fully typed: the event name autocompletes and the payload is + required/checked against `contracts/events.ts`. +- Prefer `computed` values (`$filteredEvents`, `$selectedEvent`) over deriving + data in the component, so the logic is testable and shared. + +--- + +## Quick checklist for a new module + +1. `contracts/models.ts` β€” domain types. +2. `contracts/events.ts` β€” the `Event` union (`[TRIGGER]_…`). +3. `integration/repository.ts` β€” async data access returning Promises. +4. `core/store.ts` β€” `createStore()` + `export type Store`. +5. `core/handlers/*.ts` β€” one handler per event, signature `(store, ofType)`. +6. `core/registry.ts` β€” `createRegistry(store)` wiring every handler. +7. `core/mediator.ts` β€” `createMediator()` returning `[store, trigger, registry]`. +8. `presentation/context.tsx` β€” `createHookContext('ModuleName', …)`. +9. `presentation/main.tsx` β€” `Provider` + bootstrap trigger. +10. UI components β€” `useContext()`, read with `.use()`, act with `trigger(...)`. + +--- + +## Event flow depth: how many event types do I need? + +Keep it as simple as the feature allows. + +- **Simple action** β€” `[TRIGGER]_X` β†’ handler writes the store directly + (like `update-search` above). This is the common case. +- **Async work** β€” `[TRIGGER]_X` β†’ handler does `switchMap`/`exhaustMap` into + the repository, then writes the store on success/failure (like `bootstrap`). +- **Multi-stage / cross-cutting** β€” introduce `[TASK]_` and `[FACT]_` events and + chain them with `forwardAs`, so each stage is a separate, traceable handler. + Use `[EFFECT]_` (via `emit`) for logging/analytics. See the `@/libs/eda` + README for `forwardAs`, `createForwardErrorAs`, cancellation, and the full + set of good/bad practices. + +Avoid circular event chains and don't call `trigger(...)` from inside a handler +(use `forwardAs` / `emit` for non-trigger events instead). diff --git a/apps/event-app/documentation/MVP.md b/apps/event-app/documentation/MVP.md new file mode 100644 index 0000000..bcd85e9 --- /dev/null +++ b/apps/event-app/documentation/MVP.md @@ -0,0 +1,195 @@ +--- +version: 1.0 +updated: 19.04.2026 +--- + +# MVP Definition: Event Search App + +## 1. Problem Statement + +**What is the core problem that this project aims to solve?** + +Finding local events in Poland is fragmented β€” information is scattered across social media, ticketing platforms, and city portals. There is no single, clean place to discover what's happening nearby, plan around Polish holidays, and coordinate attendance with friends. + +## 2. Target Audience & Early Adopters + +**Primary Users:** Polish residents looking to discover local events (concerts, festivals, cultural events, sports, etc.) in their vicinity. + +**Early Adopters:** Socially active individuals in Polish cities who want to coordinate event attendance with their friend group and stay informed about upcoming local happenings, especially around Polish bank holidays (e.g., MajΓ³wka). + +## 3. Value Proposition + +The app provides a single, clean interface to discover events happening in Poland β€” filtered by location, date, and category β€” with no need to navigate ticketing platforms or social media. The quick-access date labels (including dynamic Polish bank holiday shortcuts like "MajΓ³wka") make it trivially easy to find events around upcoming days off. + +Authenticated users gain a social layer: they can mark attendance, see which friends are going, invite friends, and maintain a shared event calendar. Guest users can still browse and add events to their personal calendar, lowering the barrier to first use. + +## 4. Core Features (The "Minimum" in MVP) + +### Authentication & Access Levels + +- **Guest (unauthenticated):** Browse events, filter, view map, view event details, add events to device calendar +- **Authenticated user:** All guest features + mark attendance (saved to profile), add friends, see friends' attendance, invite friends to events, add new events, shared calendar, privacy toggle for attendance visibility + +- **Auth methods:** Google OAuth, Facebook OAuth, email/password +- **Admin role:** Admins create and manage events (user-created events are a future iteration) + +### Event Discovery & Search + +- **Geolocation:** App requests location permission on first load; defaults to Warsaw if permission is denied or geolocation fails +- **Scope:** Poland only (MVP) +- **Map view:** Interactive map (primary discovery mode) with event pins; authenticated users can toggle a filter to show only events their friends are attending +- **List view:** Scrollable list alongside the map +- **Search bar:** Free-text search by event name, venue, city, or keywords + +### Filters + +- **Category** (e.g., Concert, Festival, Sports, Culture, Theatre, Food & Drink) +- **Date range** (custom date picker) +- **Distance radius** from detected/selected location + +### Quick Date Labels + +Static labels always available: + +- **Today** +- **Tomorrow** +- **This Weekend** (nearest Saturday–Sunday) +- **Next Weekend** + +Dynamic labels for upcoming Polish bank holidays (auto-generated based on the Polish public holiday calendar): + +- Shown when a bank holiday or holiday cluster is within the next 60 days +- Example: **MajΓ³wka** (May 1–3 cluster), **BoΕΌe CiaΕ‚o**, **ŚwiΔ™to NiepodlegΕ‚oΕ›ci**, etc. +- Label triggers a date filter for the relevant holiday period + +### Event Detail Page + +- Event name, description, date & time, location (address + map pin) +- Category tag(s), keyword tags, and organizer info +- **Attendee count** β€” visible to all users (integer count only) +- **Friends attending** β€” visible only to authenticated users (shows friend names/avatars) +- **External link** β€” "Get Tickets / More Info" button linking to the organizer's platform (Ticketmaster, Eventbrite, Facebook Event, etc.) +- **Add to Calendar** β€” exports event to device calendar (no auth required) +- **Attend** button β€” authenticated users only; marks attendance and saves to profile +- **Invite Friends** β€” authenticated users only; sends an in-app notification to selected friends + +### Social Features + +- **Friends system:** Add friends by username/email search; friend requests require acceptance +- **Attendance visibility toggle:** Users can set their attendance as public (friends can see) or private (hidden), configurable per-event or globally in profile settings +- **Shared calendar:** Authenticated users see a personal calendar view populated with events they are attending and events their friends (with public attendance) are attending +- **Friend invites:** Inviting a friend to an event triggers an in-app notification for the invited user + +### Notifications (MVP scope) + +- **Event invite notification** β€” triggered when a friend invites a user to an event +- Notification visible in-app (notification bell / feed) +- No push or email notifications for MVP; extended notification options are a future iteration + +### Admin Event Management + +- Admin panel (internal/simple) for creating, editing, and deleting events +- Event fields: name, description, category, date & time, address, external link, optional image, keywords +- **Keywords:** Free-form tags added manually by the creator; an "Generate with AI" button uses an LLM to suggest keywords derived from the event name and description β€” the creator can accept, edit, or discard suggestions before saving + +## 5. "Measure" β€” Key Metrics for Success + +- **500 event detail page views in the first two weeks** β€” indicates organic discovery usage +- **100 registered accounts in the first month** β€” baseline for social feature adoption +- **Average session length β‰₯ 3 minutes** β€” indicator that users are genuinely exploring events, not bouncing +- **Friend invite conversion rate β‰₯ 20%** β€” % of invites that result in the invited user marking attendance + +## 6. "Learn" β€” Feedback and Iteration Plan + +- **In-app feedback button** on every event detail page ("Was this info helpful?") +- **Early adopter outreach** β€” share direct links with socially active users in Warsaw, KrakΓ³w, and TrΓ³jmiasto for initial testing +- **Analytics events** β€” track: search queries, filter combinations used, date label clicks (especially holiday labels), attend clicks, invite sends +- Feedback reviewed bi-weekly to prioritize: user-generated events feature, additional notification types, and extended social features + +## 7. Assumptions + +- **Geolocation adoption** β€” Most users will grant location permission; Warsaw fallback covers cases where they don't +- **Admin content sufficiency** β€” Manually curated events will be enough to demonstrate value before user-generated events are built +- **Polish holiday calendar accuracy** β€” Dynamic holiday labels will correctly identify upcoming Polish bank holidays and cluster them appropriately (e.g., MajΓ³wka as May 1–3) +- **External link sufficiency** β€” Users accept being redirected to external platforms for ticketing; no in-app purchasing is needed for MVP +- **Social opt-in** β€” Users will be willing to share attendance with friends; the privacy toggle will adequately address concerns +- **Map as primary UX** β€” Users prefer discovering events spatially on a map rather than purely through list/search +- **Friend network cold-start** β€” Early users will have enough mutual connections to make social features immediately useful, or will find solo discovery valuable enough to retain them until their network grows + +## 8. User Flow Diagrams + +### Event Storming User Flow + +```mermaid +graph TB + Start([User Arrives]) --> GeoRequest[Request Geolocation] + GeoRequest --> GeoResult{Location Found?} + GeoResult -->|Yes| LocationSet[Set User Location] + GeoResult -->|No / Denied| WarsawFallback[Fallback to Warsaw] + LocationSet --> HomeMap[Show Map + Event List] + WarsawFallback --> HomeMap + + HomeMap --> DiscoveryAction{User Action} + + DiscoveryAction -->|Search| SearchBar[Free-text Search] + DiscoveryAction -->|Apply Filter| FilterPanel[Open Filter Panel] + DiscoveryAction -->|Date Label| DateLabel[Select Date Label] + DiscoveryAction -->|Map Pin| EventDetail + DiscoveryAction -->|List Item| EventDetail + + SearchBar --> FilteredResults[Filtered Event Results] + FilterPanel --> FilteredResults + DateLabel --> FilteredResults + FilteredResults --> EventDetail[View Event Detail Page] + + EventDetail --> GuestAction{Authenticated?} + + GuestAction -->|No - Guest| GuestOptions{Guest Action} + GuestAction -->|Yes - Auth| AuthOptions{Auth Action} + + GuestOptions -->|Add to Calendar| CalendarExport[Export to Device Calendar] + GuestOptions -->|External Link| ExternalSite[Open Ticketing / Info Site] + GuestOptions -->|Login Prompt| AuthFlow + + AuthOptions -->|Attend| MarkAttendance[Attendance Marked Event] + AuthOptions -->|Add to Calendar| CalendarExport + AuthOptions -->|Invite Friends| FriendInvite[Select Friends to Invite] + AuthOptions -->|External Link| ExternalSite + AuthOptions -->|View Friends Attending| FriendsList[Show Friends Attending] + + FriendInvite --> InviteSent[Invite Sent Event] + InviteSent --> Notification[In-App Notification to Friend] + + MarkAttendance --> ProfileSaved[Saved to User Profile] + ProfileSaved --> SharedCalendar[Visible in Shared Calendar] + + HomeMap --> AuthCheck{User Authenticated?} + AuthCheck -->|Yes| MapToggle[Toggle: Friends' Events on Map] + MapToggle --> FilteredMap[Map Shows Friends' Attended Events] + + HomeMap --> AuthFlow{Login / Register?} + AuthFlow -->|Google OAuth| GoogleLogin[Google OAuth] + AuthFlow -->|Email/Password| EmailLogin[Email/Password Login] + AuthFlow -->|Register| RegisterForm[Register Form] + GoogleLogin --> ProfileReady[Profile Active] + EmailLogin --> ProfileReady + RegisterForm --> ProfileReady + + ProfileReady --> ProfileSettings[Profile Settings] + ProfileSettings --> PrivacyToggle[Attendance Visibility: Public / Private] + ProfileSettings --> FriendManagement[Add / Remove Friends] + + FriendManagement --> FriendSearch[Search by Username / Email] + FriendSearch --> FriendRequest[Send Friend Request] + FriendRequest --> FriendAccepted[Friend Request Accepted Event] + FriendAccepted --> FriendNetwork[Friends Network Updated] + + ProfileReady --> CalendarView[Shared Calendar View] + CalendarView --> MyEvents[My Attended Events] + CalendarView --> FriendsEvents[Friends Public Events] + + Notification --> NotificationBell[Notification Bell / Feed] + NotificationBell --> ViewInvite[View Event Invite] + ViewInvite --> EventDetail +``` + diff --git a/apps/event-app/eslint.config.js b/apps/event-app/eslint.config.js new file mode 100644 index 0000000..dd39676 --- /dev/null +++ b/apps/event-app/eslint.config.js @@ -0,0 +1,4 @@ +import { config } from '@repo/eslint-config/react-internal'; + +/** @type {import("eslint").Linter.Config} */ +export default [...config]; diff --git a/apps/event-app/package.json b/apps/event-app/package.json new file mode 100644 index 0000000..f54f0e1 --- /dev/null +++ b/apps/event-app/package.json @@ -0,0 +1,52 @@ +{ + "name": "event-app", + "type": "module", + "version": "0.0.1", + "engines": { + "node": ">=22.12.0" + }, + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "astro": "astro", + "lint": "eslint .", + "db:gen-types": "pnpx supabase gen types typescript --local > src/shared/data-sources/db-schema.ts" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.98.0", + "@astrojs/cloudflare": "^12.6.12", + "@astrojs/react": "^5.0.3", + "@hookform/resolvers": "^5.2.2", + "@nanostores/react": "^1.0.0", + "@repo/type-beast": "workspace:*", + "@repo/ui": "workspace:*", + "@supabase/ssr": "^0.9.0", + "@supabase/supabase-js": "^2.100.1", + "@tailwindcss/vite": "^4.1.18", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "astro": "^6.1.8", + "clsx": "^2.1.1", + "date-fns": "^4.4.0", + "leaflet": "^1.9.4", + "lucide-react": "^0.576.0", + "nanostores": "^1.1.1", + "react": "^19.2.5", + "react-day-picker": "^10.0.1", + "react-dom": "^19.2.5", + "react-hook-form": "^7.71.2", + "rxjs": "^7.8.2", + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.1.18", + "zod": "^4.3.6" + }, + "devDependencies": { + "@repo/eslint-config": "workspace:*", + "@types/leaflet": "^1.9.21", + "eslint": "^9.39.1", + "supabase": "^2.76.16", + "vite-tsconfig-paths": "^6.1.1" + } +} diff --git a/apps/event-app/public/favicon.ico b/apps/event-app/public/favicon.ico new file mode 100644 index 0000000..7f48a94 Binary files /dev/null and b/apps/event-app/public/favicon.ico differ diff --git a/apps/event-app/public/favicon.svg b/apps/event-app/public/favicon.svg new file mode 100644 index 0000000..f157bd1 --- /dev/null +++ b/apps/event-app/public/favicon.svg @@ -0,0 +1,9 @@ + + + + diff --git a/apps/event-app/public/hero.avif b/apps/event-app/public/hero.avif new file mode 100644 index 0000000..21a7af6 Binary files /dev/null and b/apps/event-app/public/hero.avif differ diff --git a/apps/event-app/public/hero.webp b/apps/event-app/public/hero.webp new file mode 100644 index 0000000..9fde0ea Binary files /dev/null and b/apps/event-app/public/hero.webp differ diff --git a/apps/event-app/src/core/layouts/main.astro b/apps/event-app/src/core/layouts/main.astro new file mode 100644 index 0000000..6f5af5f --- /dev/null +++ b/apps/event-app/src/core/layouts/main.astro @@ -0,0 +1,30 @@ +--- +import '../style/index.css'; +type Props = { + title?: string; +}; + +const { title = 'Afisz' } = Astro.props; +--- + + + + + + + + + + {title} + + + + + + + + + diff --git a/apps/event-app/src/core/modules/create-event.tsx b/apps/event-app/src/core/modules/create-event.tsx new file mode 100644 index 0000000..59aad38 --- /dev/null +++ b/apps/event-app/src/core/modules/create-event.tsx @@ -0,0 +1,3 @@ +import { Main } from '@/modules/event-management/presentation/main'; + +export const Module = () =>
; diff --git a/apps/event-app/src/core/modules/event-details.tsx b/apps/event-app/src/core/modules/event-details.tsx new file mode 100644 index 0000000..4855f43 --- /dev/null +++ b/apps/event-app/src/core/modules/event-details.tsx @@ -0,0 +1,11 @@ +import { DetailsMain } from '@/modules/event-discovery/presentation/details-main'; +import type { User } from '@supabase/supabase-js'; + +type ModuleProps = { + id: string; + user: User | null; +}; + +export const EventDetailsModule = ({ id, user }: ModuleProps) => { + return ; +}; diff --git a/apps/event-app/src/core/modules/landing.tsx b/apps/event-app/src/core/modules/landing.tsx new file mode 100644 index 0000000..900c634 --- /dev/null +++ b/apps/event-app/src/core/modules/landing.tsx @@ -0,0 +1,10 @@ +import { Main } from '@/modules/event-discovery/presentation/main'; +import type { User } from '@supabase/supabase-js'; + +type ModuleProps = { + user: User | null; +}; + +export const Module = ({ user }: ModuleProps) => { + return
; +}; diff --git a/apps/event-app/src/core/modules/login.tsx b/apps/event-app/src/core/modules/login.tsx new file mode 100644 index 0000000..656520f --- /dev/null +++ b/apps/event-app/src/core/modules/login.tsx @@ -0,0 +1,5 @@ +import { LoginForm } from '@/shared/identity-and-access/presentation/login-form'; + +export const Module = () => { + return ; +}; diff --git a/apps/event-app/src/core/modules/register.tsx b/apps/event-app/src/core/modules/register.tsx new file mode 100644 index 0000000..e6e679a --- /dev/null +++ b/apps/event-app/src/core/modules/register.tsx @@ -0,0 +1,5 @@ +import { RegisterForm } from '@/shared/identity-and-access/presentation/register-form'; + +export const Module = () => { + return ; +}; diff --git a/apps/event-app/src/core/modules/results.tsx b/apps/event-app/src/core/modules/results.tsx new file mode 100644 index 0000000..c82bab0 --- /dev/null +++ b/apps/event-app/src/core/modules/results.tsx @@ -0,0 +1,10 @@ +import { ResultsMain } from '@/modules/event-discovery/presentation/results-main'; +import type { User } from '@supabase/supabase-js'; + +type ModuleProps = { + user: User | null; +}; + +export const ResultsModule = ({ user }: ModuleProps) => { + return ; +}; diff --git a/apps/event-app/src/core/style/index.css b/apps/event-app/src/core/style/index.css new file mode 100644 index 0000000..34b0fa9 --- /dev/null +++ b/apps/event-app/src/core/style/index.css @@ -0,0 +1,2 @@ +@import 'tailwindcss'; +@import '../../libs/ui/index.css'; diff --git a/apps/event-app/src/libs/eda/index.tsx b/apps/event-app/src/libs/eda/index.tsx new file mode 100644 index 0000000..64b63cc --- /dev/null +++ b/apps/event-app/src/libs/eda/index.tsx @@ -0,0 +1,178 @@ +import { + catchError, + EMPTY, + filter, + map, + merge, + Subject, + tap, + type Observable, + type OperatorFunction, +} from 'rxjs'; + +import { useEffect } from 'react'; + +import type { ComponentType } from 'react'; + +export type EventMeta = { + id: string; + time: number; +}; + +export type Event< + TType extends string = string, + TPayload = void, +> = TPayload extends void + ? { type: TType; meta: EventMeta } + : { type: TType; payload: TPayload; meta: EventMeta }; + +export type TriggerEvent< + TKey extends `[TRIGGER]_${string}`, + TPayload = void, +> = Event; + +export type TaskEvent = Event< + TKey, + TPayload +>; + +export type FactEvent = Event< + TKey, + TPayload +>; + +export type EffectEvent< + TKey extends `[EFFECT]_${string}`, + TPayload = void, +> = Event; + +export type InferPayload = + Extract extends { payload: infer P } ? P : void; + +export type OnlyFact = T extends `[FACT]_${string}` + ? T + : never; + +export type OnlyTrigger = T extends `[TRIGGER]_${string}` + ? T + : never; + +export type ExcludeTrigger = T extends `[TRIGGER]_${string}` + ? never + : T; + +const createMeta = (): EventMeta => ({ + id: crypto.randomUUID(), + time: performance.now(), +}); + +export const eda = () => { + const event$ = new Subject(); + + const emit = ( + type: TType, + payload?: InferPayload, + ) => { + const meta = createMeta(); + const event = ( + payload === undefined ? { type, meta } : { type, payload, meta } + ) as Extract; + event$.next(event); + }; + + return { + ofAny: () => event$.asObservable(), + + ofType: (type: TType) => + event$.pipe( + filter( + (event): event is Extract => + event.type === type, + ), + map( + (event) => + ('payload' in event ? event.payload : undefined) as InferPayload< + TEvents, + TType + >, + ), + ), + + trigger: >( + ...args: InferPayload extends void + ? [type: TType] + : [type: TType, payload: InferPayload] + ) => { + const [type, payload] = args; + emit(type, payload); + }, + + emit, + + forwardAs: >(type: TType) => + ((source: Observable) => + source.pipe( + tap((payload) => { + emit(type, payload as InferPayload); + }), + )) as InferPayload extends void + ? (source: Observable) => Observable + : >( + source: Observable, + ) => Observable, + + createRegistry: + []>(...args: TStreams) => + () => { + const streams = args.map((stream) => + stream.pipe(catchError((_error, caught) => caught)), + ); + const sub = merge(...streams).subscribe(); + + return () => { + sub.unsubscribe(); + }; + }, + + createForwardErrorAs: + (parseError: (error: unknown) => TError | null) => + >( + ...args: InferPayload extends void + ? [type: TType] + : [ + type: TType, + mapper: (error: TError) => InferPayload, + ] + ): OperatorFunction => + catchError((error: unknown) => { + const parsed = parseError(error); + + if (parsed !== null) { + const [type, mapper] = args; + const payload = mapper ? mapper(parsed) : undefined; + emit(type, payload as InferPayload); + } + + return EMPTY; + }), + }; +}; + +export const withEda =

>( + Component: ComponentType

, + register: () => () => void, +) => { + const WithEda = (props: P) => { + useEffect(() => { + const unsubscribe = register(); + + return () => { + unsubscribe(); + }; + }, []); + + return ; + }; + + return WithEda; +}; diff --git a/apps/event-app/src/libs/power-context/index.tsx b/apps/event-app/src/libs/power-context/index.tsx new file mode 100644 index 0000000..ae90e47 --- /dev/null +++ b/apps/event-app/src/libs/power-context/index.tsx @@ -0,0 +1,84 @@ +import { + createContext, + useContext as useReactContext, + type ReactNode, +} from 'react'; + +/** + * A TypeScript utility type that validates a string. It enforces two rules: + * 1. The string must be in PascalCase. + * 2. The string must NOT end with "Provider" or "Context". + * If valid, it returns the string. If invalid, it returns a specific error + * message type, causing a compile-time error. + */ +type ValidatedName = TName extends '' + ? `Error: Name cannot be an empty string.` + : TName extends `${string} ${string}` + ? `Error: Name cannot contain spaces.` + : TName extends `${infer First}${string}` + ? First extends Uppercase + ? TName extends + | `${string}Provider` + | `${string}Context` + | `${string}provider` + | `${string}context` + ? `Error: Name should not include the 'Provider' or 'Context' keyword.` + : TName // This is the final success path. + : `Error: Name must be in PascalCase (e.g., 'User', 'UserProfile').` + : TName; // Should be unreachable, but keeps the type sound. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type OneArgFn = ((props: any) => any) | (() => any); + +// Helper type to check if a parameter is optional +type IsOptional = undefined extends T ? true : false; + +/** + * Creates a strongly-typed React Context Provider and consumer hook. + * + * @param displayName The base name for the components in PascalCase, e.g., "User". + * It must not end with "Provider" or "Context". + * @param useHook The custom hook to be placed into context. + */ +export const createHookContext = ( + displayName: ValidatedName, + useHook: THook, +) => { + type HookData = Parameters[0]; + type HookReturn = ReturnType; + + const Context = createContext(null); + Context.displayName = `${displayName}Context`; + + type ProviderProps = { + children: ReactNode; + } & (HookData extends undefined + ? { value?: never } + : IsOptional extends true + ? { value?: HookData } + : { value: HookData }); + + const Provider = (props: ProviderProps) => { + const hookArgs = 'value' in props ? props.value : undefined; + const hookValue = useHook(hookArgs as HookData); + + return ( + {props.children} + ); + }; + + Provider.displayName = `${displayName}Provider`; + + const useContext = (): HookReturn => { + const context = useReactContext(Context); + + if (context === null) { + throw new Error( + `use${displayName}Context must be used within a ${displayName}Provider.`, + ); + } + + return context; + }; + + return [Provider, useContext, Context] as const; +}; diff --git a/apps/event-app/src/libs/supa-store/index.ts b/apps/event-app/src/libs/supa-store/index.ts new file mode 100644 index 0000000..0b7e22e --- /dev/null +++ b/apps/event-app/src/libs/supa-store/index.ts @@ -0,0 +1,108 @@ +import { useStore } from '@nanostores/react'; +import { + atom as nanoAtom, + map as nanoMap, + computed as nanoComputed, + type PreinitializedWritableAtom, + type MapStore, + type Store, + type StoreValue, + type ReadableAtom, +} from 'nanostores'; + +type EnhancedAtom = { + reset(): void; + getInitial(): TValue; + use(): TValue; +}; + +type Atom = PreinitializedWritableAtom & EnhancedAtom; + +type EnhancedComputed = { + use(): TValue; +}; + +type Computed = ReadableAtom & EnhancedComputed; + +type EnhancedMap = { + reset(): void; + getInitial(): TValue; + use(): TValue; + removeKey(key: TKey): void; +}; + +type Map = MapStore & EnhancedMap; + +type Obj = Record; + +export const atom = ( + value: TValue, +): Atom => { + const $atom = nanoAtom(value); + + const atomWithMethods: Atom = Object.assign($atom, { + reset() { + $atom.set(value); + }, + getInitial() { + return value; + }, + use() { + return useStore($atom); + }, + } satisfies EnhancedAtom); + + return atomWithMethods; +}; + +export const map = (value: TValue): Map => { + const $map = nanoMap(value); + + const mapWithMethods: Map = Object.assign($map, { + reset() { + $map.set(value); + }, + getInitial() { + return value; + }, + use() { + return useStore($map); + }, + removeKey(key: TKey) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + $map.setKey(key as any, undefined as any); + }, + } satisfies EnhancedMap); + + return mapWithMethods; +}; + +export function computed( + stores: TStore, + cb: (value: StoreValue) => TValue, +): Computed; + +export function computed( + stores: TStores, + cb: (...values: { [K in keyof TStores]: StoreValue }) => TValue, +): Computed; + +export function computed( + stores: Store | Store[], + cb: (...values: unknown[]) => TValue, +): Computed { + const $computed = nanoComputed( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + stores as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + cb as any, + ) as ReadableAtom; + + const computedWithMethods: Computed = Object.assign($computed, { + use() { + return useStore($computed); + }, + } satisfies EnhancedComputed); + + return computedWithMethods; +} diff --git a/apps/event-app/src/libs/ui/button.tsx b/apps/event-app/src/libs/ui/button.tsx new file mode 100644 index 0000000..f4e1a72 --- /dev/null +++ b/apps/event-app/src/libs/ui/button.tsx @@ -0,0 +1,59 @@ +import { type ComponentProps } from 'react'; + +import { cn } from './cn'; + +/* ============================================================================= + * Public Props + * ============================================================================= */ + +export type ButtonVariant = 'primary' | 'secondary' | 'tertiary' | 'ghost'; + +export type ButtonProps = ( + | (ComponentProps<'button'> & { href?: never }) + | (ComponentProps<'a'> & { href: string }) +) & { variant?: ButtonVariant }; + +/* ============================================================================= + * Component + * ============================================================================= */ + +export const Button = ({ + variant = 'primary', + className, + ...props +}: ButtonProps) => { + const classes = cn( + 'inline-flex items-center justify-center gap-2', + 'text-sm', + 'transition-colors', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-0', + 'disabled:cursor-not-allowed disabled:opacity-50', + variant === 'primary' && [ + 'rounded-pill px-5.5 py-3 font-medium', + 'bg-primary text-on-primary border-0', + ], + variant === 'secondary' && [ + 'rounded-pill px-5.5 py-3 font-medium', + 'bg-transparent text-ink border border-border-dark', + 'hover:border-ink', + 'disabled:border-border-light', + ], + variant === 'tertiary' && [ + 'bg-canvas border border-border-dark rounded-full px-4 py-2 text-ink text-sm hover:bg-coral hover:text-white hover:border-coral transition-colors', + ], + variant === 'ghost' && [ + 'bg-transparent text-ink border-0 p-0', + 'border-b border-ink pb-0.5', + 'hover:text-coral hover:border-coral', + ], + className, + ); + + if ('href' in props && props.href !== undefined) { + return )} />; + } + + return ( + + ); +}; + +/* ============================================================================= + * Compound Export + * ============================================================================= */ + +export const Dropdown = { + Root: DropdownRoot, + Trigger: DropdownTrigger, + Content: DropdownContent, + Item: DropdownItem, +}; diff --git a/apps/event-app/src/libs/ui/index.css b/apps/event-app/src/libs/ui/index.css new file mode 100644 index 0000000..83bb510 --- /dev/null +++ b/apps/event-app/src/libs/ui/index.css @@ -0,0 +1,175 @@ +@import 'tailwindcss'; + +@theme { + /* ============================================================ + * Colors β€” Brand & Accent + * ============================================================ */ + --color-canvas: #ffffff; + --color-soft-stone: #eeece7; + --color-pale-green: #edfce9; + --color-pale-blue: #f1f5ff; + --color-hairline: #d9d9dd; + --color-border-light: #e5e7eb; + --color-border-dark: #d1d5dc; + --color-primary: #17171c; + --color-black: #000000; + --color-ink: #212121; + --color-deep-green: #003c33; + --color-navy: #071829; + --color-muted: #93939f; + --color-slate: #75758a; + --color-body-muted: #616161; + --color-action-blue: #1863dc; + --color-coral: #ff7759; + --color-coral-soft: #ffad9b; + --color-on-primary: #ffffff; + + --color-accent: var(--color-coral); + --color-map-grid: #e5e7eb; + --color-map-land: #eeece7; + --color-map-coast: #c6c4be; + --color-map-city: #75758a; + + --color-bg: var(--color-canvas); + --color-fg: var(--color-ink); + --color-surface: #fafaf7; + --color-card-bg: #ffffff; + + /* ============================================================ + * Typography + * ============================================================ */ + --font-display: + 'CohereText', 'Space Grotesk', Inter, ui-sans-serif, system-ui; + --font-sans: 'Unica77 Cohere Web', Inter, Arial, ui-sans-serif, system-ui; + --font-mono: 'CohereMono', 'JetBrains Mono', 'Courier New', monospace; + + /* ============================================================ + * Breakpoints + * ============================================================ */ + --breakpoint-sm: 26.5625rem; /* 425px β€” mobile */ + --breakpoint-md: 40rem; /* 640px β€” large mobile */ + --breakpoint-lg: 48rem; /* 768px β€” tablet */ + --breakpoint-xl: 64rem; /* 1024px β€” desktop */ + --breakpoint-2xl: 90rem; /* 1440px β€” large desktop */ + + /* ============================================================ + * Border Radius + * ============================================================ */ + --radius-xs: 0.25rem; /* 4px β€” search fields, article thumbnails */ + --radius-sm: 0.5rem; /* 8px β€” blog chips, cards, small media */ + --radius-md: 1rem; /* 16px β€” medium product cards */ + --radius-lg: 1.375rem; /* 22px β€” signature media-card radius */ + --radius-xl: 1.875rem; /* 30px β€” research filter pills */ + --radius-pill: 2rem; /* 32px β€” primary CTA buttons */ + --radius-full: 9999px; /* round status elements */ + + /* ============================================================ + * Spacing + * ============================================================ */ + --space-xxs: 0.125rem; /* 2px */ + --space-xs: 0.375rem; /* 6px */ + --space-sm: 0.5rem; /* 8px */ + --space-md: 0.75rem; /* 12px */ + --space-lg: 1rem; /* 16px */ + --space-xl: 1.5rem; /* 24px */ + --space-xxl: 2rem; /* 32px */ + --space-section: 5rem; /* 80px */ +} + +@layer base { + body { + @apply bg-(--color-bg) text-(--color-fg) font-sans text-base leading-normal antialiased; + text-rendering: optimizeLegibility; + } + + button, + a { + @apply cursor-pointer; + } +} + +[data-theme='dark'] { + --color-bg: #0c0c10; + --color-canvas: #0c0c10; + --color-fg: #f1f1f2; + --color-ink: #f1f1f2; + --color-surface: #15151a; + --color-card-bg: #15151a; + --color-hairline: #2e2e36; + --color-border-light: #2e2e36; + --color-primary: #f1f1f2; + --color-on-primary: #0c0c10; + --color-muted: #a8a8b4; + --color-slate: #c0c0cc; + --color-body-muted: #c8c8d0; + --color-coral: #ff8a6e; + --color-soft-stone: #1a1a1f; + --color-map-grid: #1f1f25; + --color-map-land: #1a1a1f; + --color-map-coast: #2c2c34; + --color-map-city: #a8a8b4; +} + +@layer components { + .hero-bg { + background: + linear-gradient(150deg, rgba(0, 0, 0, 0.55)), + image-set( + url('/hero.avif') type('image/avif'), + url('/hero.webp') type('image/webp') + ) + center / cover no-repeat; + } + + .mono-label { + @apply font-mono text-xs font-normal tracking-[0.18em] uppercase text-slate; + } + + .hero-display { + @apply font-display font-medium text-[93.6px] leading-[0.92] tracking-[-0.04em] text-[#f1f1f2]; + } + + .manifest-heading { + @apply font-display font-medium text-[clamp(44px,6vw,80px)] leading-none tracking-[-0.03em]; + } + + .page-heading { + @apply font-display font-medium text-[clamp(40px,5vw,72px)] leading-none tracking-[-0.03em]; + } + + .section-heading { + @apply font-display font-medium text-[clamp(32px,4.5vw,56px)] leading-[1.05] tracking-[-0.02em]; + } + + .category-heading { + @apply font-display font-medium text-[28px] leading-[1.05] tracking-[-0.02em]; + } + + .card-title-lg { + @apply font-display font-medium text-[22px] leading-[1.15] tracking-[-0.02em]; + } + + .card-title { + @apply font-display font-medium text-lg leading-[1.15] tracking-[-0.02em]; + } + + .logo-wordmark { + @apply font-display font-semibold text-[22px] tracking-[-0.02em]; + } + + .footer-brand { + @apply font-display font-semibold text-[36px] tracking-tight; + } + + .date-display { + @apply font-display font-medium text-[44px] leading-none tracking-[-0.02em]; + } + + .date-num { + @apply font-display font-medium text-[36px] leading-none; + } + + .subsection-heading { + @apply font-display font-medium text-2xl leading-[1.2] tracking-[-0.02em]; + } +} diff --git a/apps/event-app/src/libs/ui/logo.tsx b/apps/event-app/src/libs/ui/logo.tsx new file mode 100644 index 0000000..f5c0c78 --- /dev/null +++ b/apps/event-app/src/libs/ui/logo.tsx @@ -0,0 +1,18 @@ +import { LogoWordmark } from './text'; + +type LogoProps = { + href?: string; + className?: string; +}; + +export const Logo = ({ href = '/', className }: LogoProps) => ( + + + A + + Afisz + +); diff --git a/apps/event-app/src/libs/ui/text.tsx b/apps/event-app/src/libs/ui/text.tsx new file mode 100644 index 0000000..1e6c26a --- /dev/null +++ b/apps/event-app/src/libs/ui/text.tsx @@ -0,0 +1,91 @@ +import { type ComponentProps, createElement, type ElementType } from 'react'; + +import { cn } from './cn'; + +/* ============================================================================= + * Shared Types + * ============================================================================= */ + +type TextVariant = + | 'mono-label' + | 'hero-display' + | 'manifest-heading' + | 'page-heading' + | 'section-heading' + | 'category-heading' + | 'card-title-lg' + | 'card-title' + | 'logo-wordmark' + | 'footer-brand' + | 'date-display' + | 'date-num' + | 'subsection-heading'; + +type TextBaseProps = { + as?: TAs; + className?: string; +} & Omit, 'as' | 'className'>; + +type TextPartProps = TextBaseProps; + +/* ============================================================================= + * Internal Factory + * ============================================================================= */ + +const createTextPart = ( + variant: TextVariant, + defaultAs: TDefaultAs, +) => { + const TextPart = ({ + as, + className, + ...props + }: TextPartProps) => { + const tag = (as ?? defaultAs) as ElementType; + + return createElement(tag, { + className: cn(variant, className), + ...props, + }); + }; + + return TextPart; +}; + +/* ============================================================================= + * Compound Parts + * ============================================================================= */ + +export const MonoLabel = createTextPart('mono-label', 'span'); +export const HeroDisplay = createTextPart('hero-display', 'h1'); +export const ManifestHeading = createTextPart('manifest-heading', 'h2'); +export const PageHeading = createTextPart('page-heading', 'h1'); +export const SectionHeading = createTextPart('section-heading', 'h2'); +export const CategoryHeading = createTextPart('category-heading', 'h3'); +export const CardTitleLg = createTextPart('card-title-lg', 'h3'); +export const CardTitle = createTextPart('card-title', 'h3'); +export const LogoWordmark = createTextPart('logo-wordmark', 'span'); +export const FooterBrand = createTextPart('footer-brand', 'span'); +export const DateDisplay = createTextPart('date-display', 'span'); +export const DateNum = createTextPart('date-num', 'span'); +export const SubsectionHeading = createTextPart('subsection-heading', 'h3'); + +/* ============================================================================= + * Compound Export + * ============================================================================= */ + +export const Text = { + MonoLabel, + HeroDisplay, + ManifestHeading, + PageHeading, + SectionHeading, + CategoryHeading, + CardTitleLg, + CardTitle, + LogoWordmark, + FooterBrand, + DateDisplay, + DateNum, + SubsectionHeading, +}; diff --git a/apps/event-app/src/middleware.ts b/apps/event-app/src/middleware.ts new file mode 100644 index 0000000..59a4a7d --- /dev/null +++ b/apps/event-app/src/middleware.ts @@ -0,0 +1,23 @@ +import { defineMiddleware } from 'astro:middleware'; +import { supabaseServer } from '@/shared/data-sources/supabase-server'; + +const PROTECTED_ROUTES = ['/event/create']; + +export const onRequest = defineMiddleware( + async ({ request, cookies, redirect, url }, next) => { + if (!PROTECTED_ROUTES.some((route) => url.pathname.startsWith(route))) { + return next(); + } + + const supabase = supabaseServer({ request, cookies }); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return redirect('/login'); + } + + return next(); + }, +); diff --git a/apps/event-app/src/modules/event-discovery/configuration/constraints.ts b/apps/event-app/src/modules/event-discovery/configuration/constraints.ts new file mode 100644 index 0000000..eb78a23 --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/configuration/constraints.ts @@ -0,0 +1 @@ +export const FEATURE_NAME = 'Landing'; diff --git a/apps/event-app/src/modules/event-discovery/contracts/events.ts b/apps/event-app/src/modules/event-discovery/contracts/events.ts new file mode 100644 index 0000000..11bbf53 --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/contracts/events.ts @@ -0,0 +1,13 @@ +import { type TriggerEvent } from '@/libs/eda'; + +export type SearchFilters = { + name?: string; + category?: string; + city?: string; + dateLabel?: string; + isFeatured?: boolean; +}; + +export type Event = + | TriggerEvent<'[TRIGGER]_SEARCH', SearchFilters> + | TriggerEvent<'[TRIGGER]_FETCH_EVENT', { id: string }>; diff --git a/apps/event-app/src/modules/event-discovery/contracts/models.ts b/apps/event-app/src/modules/event-discovery/contracts/models.ts new file mode 100644 index 0000000..087a08e --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/contracts/models.ts @@ -0,0 +1,41 @@ +export type EventCategory = + | 'Concert' + | 'Festival' + | 'Sports' + | 'Culture' + | 'Theatre' + | 'Food & Drink'; + +export type Event = { + id: string; + name: string; + category: EventCategory; + startDateTime: string; + city: string; + isFeatured: boolean; +}; + +export type EventDetail = { + id: string; + name: string; + description?: string; + category: EventCategory; + startDateTime: string; + endDateTime?: string; + address: { + street: string; + number: string; + postalCode: string; + city: string; + }; + coordinates: { + lat: number; + lng: number; + }; + externalLink?: string; + imageUrl?: string; + keywords: string[]; + organizerInfo?: string; + isFeatured: boolean; + attendeeCount: number; +}; diff --git a/apps/event-app/src/modules/event-discovery/core/handlers/fetch-event.ts b/apps/event-app/src/modules/event-discovery/core/handlers/fetch-event.ts new file mode 100644 index 0000000..6778c73 --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/core/handlers/fetch-event.ts @@ -0,0 +1,31 @@ +import { catchError, EMPTY, finalize, from, switchMap, tap } from 'rxjs'; +import { fetchEvent } from '../../integration/repository'; +import type { OfType } from '../registry'; +import type { Store } from '../store'; + +export const fetchEventHandler = (store: Store, ofType: OfType) => + ofType('[TRIGGER]_FETCH_EVENT').pipe( + tap(() => { + store.$isLoadingEvent.set(true); + store.$eventError.reset(); + }), + switchMap(({ id }) => { + const ctrl = new AbortController(); + + return from(fetchEvent(id, ctrl.signal)).pipe( + tap((event) => store.$event.set(event)), + catchError((error) => { + if (error instanceof DOMException && error.name === 'AbortError') + return EMPTY; + store.$eventError.set( + error instanceof Error ? error.message : 'CoΕ› poszΕ‚o nie tak.', + ); + return EMPTY; + }), + finalize(() => { + store.$isLoadingEvent.set(false); + ctrl.abort(); + }), + ); + }), + ); diff --git a/apps/event-app/src/modules/event-discovery/core/handlers/search.ts b/apps/event-app/src/modules/event-discovery/core/handlers/search.ts new file mode 100644 index 0000000..d61b8b1 --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/core/handlers/search.ts @@ -0,0 +1,35 @@ +import { catchError, EMPTY, finalize, from, switchMap, tap } from 'rxjs'; +import { searchEvents } from '../../integration/repository'; +import type { OfType } from '../registry'; +import type { Store } from '../store'; + +export const searchHandler = (store: Store, ofType: OfType) => + ofType('[TRIGGER]_SEARCH').pipe( + tap((filters) => { + store.$isLoading.set(true); + store.$error.reset(); + store.$filters.set(filters); + }), + switchMap((filters) => { + const ctrl = new AbortController(); + + return from(searchEvents(filters, ctrl.signal)).pipe( + tap(({ events, total }) => { + store.$results.set(events); + store.$total.set(total); + }), + catchError((error) => { + if (error instanceof DOMException && error.name === 'AbortError') + return EMPTY; + store.$error.set( + error instanceof Error ? error.message : 'CoΕ› poszΕ‚o nie tak.', + ); + return EMPTY; + }), + finalize(() => { + store.$isLoading.set(false); + ctrl.abort(); + }), + ); + }), + ); diff --git a/apps/event-app/src/modules/event-discovery/core/mediator.ts b/apps/event-app/src/modules/event-discovery/core/mediator.ts new file mode 100644 index 0000000..49231c3 --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/core/mediator.ts @@ -0,0 +1,9 @@ +import { createRegistry } from './registry'; +import { createStore } from './store'; + +export const createMediator = () => { + const store = createStore(); + const { trigger, registry } = createRegistry(store); + + return [store, trigger, registry] as const; +}; diff --git a/apps/event-app/src/modules/event-discovery/core/registry.ts b/apps/event-app/src/modules/event-discovery/core/registry.ts new file mode 100644 index 0000000..ed374a4 --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/core/registry.ts @@ -0,0 +1,20 @@ +import { eda } from '@/libs/eda'; +import { type Store } from './store'; +import { type Event } from '../contracts/events'; +import { searchHandler } from './handlers/search'; +import { fetchEventHandler } from './handlers/fetch-event'; + +export type OfType = ReturnType>['ofType']; + +export const createRegistry = (store: Store) => { + const { ofType, trigger, createRegistry: register } = eda(); + + const registry = register( + searchHandler(store, ofType), + fetchEventHandler(store, ofType), + ); + + return { trigger, registry }; +}; + +export type Registry = ReturnType; diff --git a/apps/event-app/src/modules/event-discovery/core/store.ts b/apps/event-app/src/modules/event-discovery/core/store.ts new file mode 100644 index 0000000..7c3a8d6 --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/core/store.ts @@ -0,0 +1,28 @@ +import { atom } from '@/libs/supa-store'; +import type { SearchFilters } from '../contracts/events'; +import type { Event, EventDetail } from '../contracts/models'; + +export const createStore = () => { + const $filters = atom({}); + const $results = atom([]); + const $total = atom(0); + const $isLoading = atom(true); + const $error = atom(null); + + const $event = atom(null); + const $isLoadingEvent = atom(true); + const $eventError = atom(null); + + return { + $filters, + $results, + $total, + $isLoading, + $error, + $event, + $isLoadingEvent, + $eventError, + }; +}; + +export type Store = ReturnType; diff --git a/apps/event-app/src/modules/event-discovery/feature.md b/apps/event-app/src/modules/event-discovery/feature.md new file mode 100644 index 0000000..e44ca81 --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/feature.md @@ -0,0 +1,55 @@ +# Event Discovery + +## Refs + +1. [Application Domains](../../../documentation/DOMAINS.md) β€” section 4 (Event Discovery) + +## Dictionary + +- **[event]** - Core aggregate: name, description, [category], start date & time (required), end date & time (optional), address (street, number, postal code, [city_field]), coordinates (lat/lng), external link (optional), image (optional), [keyword] list, [organizer_info] +- **[admin]** - A user with the Admin role +- **[authenticated_user]** - A user who has completed sign-in and holds an active session +- **[guest]** - An unauthenticated visitor with read-only access +- **[category]** - One of the fixed taxonomy values: Concert, Festival, Sports, Culture, Theatre, Food & Drink +- **[keyword]** - A free-text tag attached to an [event] +- **[organizer_info]** - Contact or identity details of the entity hosting the [event]; stored as part of the [event] aggregate +- **[city_field]** - Normalized city string stored on each [event]; used as the match target for the city filter +- **[event_detail_page]** - Full read view of a single [event] aggregate; navigated to from the discovery results list +- **[event_card]** - Condensed visual representation of an [event] shown in the discovery results list +- **[date_label]** - Preset time-range option (e.g., Today, This Weekend, This Month) resolved to a concrete date range by the Polish Holiday Calendar domain + +## Constraints + +- [event] detail view: ``, ``, `` +- [event] discovery: ``, ``, `` +- [event] start_date: `` +- [event] end_date: `` +- [event] image: `` +- [event] location scope: `` + +## DoD + +Any user (including [guest]) can discover events through name, category, city, and date filters and navigate to a full [event_detail_page]. + +### Event Detail Page + +1. The [event_detail_page] displays all [event] aggregate fields: name, description, [category], start date & time, end date & time (when set), address, external link (when set), image (when set), [keyword] list, [organizer_info] and attendee count. +2. The [event_detail_page] is reachable by clicking an [event_card] in the discovery results list. +3. The [event_detail_page] is accessible to all users including [guest]. + +### Event Discovery β€” Search + +1. The search interface exposes four filter fields: name (free text), [category] (dropdown), city (dropdown), date (dropdown of [date_label] values). +2. The name filter performs a free-text match against the [event] name field. +3. The [category] dropdown lists all valid categories; selecting one limits results to that [category]. +4. The city dropdown options are "CaΕ‚a Polska" (no city filter) and individual cities; filtering matches against the stored [city_field] on each [event]. +5. The date dropdown lists preset [date_label] values (Today, This Weekend, This Week, This Month, and dynamic Polish holiday labels); each label resolves to a concrete date range via the Polish Holiday Calendar domain. +6. All four filters can be combined; returned results satisfy every active filter simultaneously. + +### Event Discovery β€” Results + +1. Matching events are presented as a scrollable list of [event_card] items. +2. Each [event_card] displays at minimum: event name, [category], start date & time, and [city_field]. +3. Clicking an [event_card] navigates to the corresponding [event_detail_page]. +4. When no events match the active filters, a visible empty-state message is shown to the user. +5. Results are accessible to all users including [guest]. diff --git a/apps/event-app/src/modules/event-discovery/integration/repository.ts b/apps/event-app/src/modules/event-discovery/integration/repository.ts new file mode 100644 index 0000000..83f945b --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/integration/repository.ts @@ -0,0 +1,58 @@ +import type { Schema as SearchEventsSchema } from '@/shared/server-contracts/schemas/search-events'; +import type { Schema as GetEventByIdSchema } from '@/shared/server-contracts/schemas/get-event-by-id'; +import type { InferOut } from '@/shared/server-contracts/extraction'; +import type { Event, EventDetail } from '../contracts/models'; +import type { SearchFilters } from '../contracts/events'; + +const CATEGORY_MAP: Record = { + koncerty: 'Concert', + festiwale: 'Festival', + sport: 'Sports', + teatr: 'Theatre', + wystawy: 'Culture', +}; + +export const searchEvents = async ( + filters: SearchFilters, + signal: AbortSignal, +): Promise<{ events: Event[]; total: number }> => { + const params = new URLSearchParams(); + if (filters.name) params.set('name', filters.name); + if (filters.category) { + const mapped = CATEGORY_MAP[filters.category]; + if (mapped) params.set('category', mapped); + } + if (filters.city) params.set('city', filters.city); + if (filters.dateLabel) params.set('dateLabel', filters.dateLabel); + if (filters.isFeatured !== undefined) + params.set('isFeatured', String(filters.isFeatured)); + + const res = await fetch(`/api/event/search?${params}`, { signal }); + if (!res.ok) { + throw new Error('CoΕ› poszΕ‚o nie tak. SprΓ³buj ponownie pΓ³ΕΊniej.'); + } + + const data = (await res.json()) as InferOut; + if (data.code !== 200) { + throw new Error(data.message); + } + + return { events: data.events, total: data.total }; +}; + +export const fetchEvent = async ( + id: string, + signal: AbortSignal, +): Promise => { + const res = await fetch(`/api/event/${id}`, { signal }); + if (!res.ok) { + throw new Error('CoΕ› poszΕ‚o nie tak. SprΓ³buj ponownie pΓ³ΕΊniej.'); + } + + const data = (await res.json()) as InferOut; + if (data.code !== 200) { + throw new Error(data.message); + } + + return data.event; +}; diff --git a/apps/event-app/src/modules/event-discovery/plan-be.md b/apps/event-app/src/modules/event-discovery/plan-be.md new file mode 100644 index 0000000..874c02e --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/plan-be.md @@ -0,0 +1,207 @@ +# Event Discovery β€” Backend API Plan + +API Style: RPC + +--- + +## Section 1 β€” Flow Diagrams + +### event.getById + +```mermaid +flowchart TD + A[Client] --> B[Handler: event.getById] + B --> C[DB: fetch event by id] + C -->|Not found| D[NOT_FOUND] + C -->|Found| E[Map all aggregate fields to detail DTO] + E -->|Mapping error| F[INTERNAL_SERVER] + E -->|Success| G[Return 200 + event detail DTO] +``` + +### event.search + +```mermaid +flowchart TD + A[Client] --> B[Handler: event.search] + B --> C{Validate filter values} + C -->|Invalid category or dateLabel| D[VALIDATION_ERROR] + C -->|Valid| E{dateLabel provided?} + E -->|Yes| F[Polish Holiday Calendar: resolve label to date range] + E -->|No| G[No date filter] + F --> H[DB: query events with all active filters] + G --> H + H -->|DB error| I[INTERNAL_SERVER] + H -->|Success| J[Map results to event card DTOs] + J --> K[Return 200 + list] +``` + +--- + +## Section 2 β€” Procedure Index + +| Procedure | Auth | Summary | +| ------------- | ---- | ------------------------------------------------------------ | +| event.getById | None | Fetch full event detail β€” accessible to all including guests | +| event.search | None | Search and filter events by name, category, city, date label | + +--- + +## Section 3 β€” Procedure Behaviors + +### event.getById + +``` +Function Name: event.getById +Auth: none β€” accessible to all users including guests +Input: eventId +Output: full event detail β€” name, description, category, startDateTime, endDateTime, + address, externalLink, imageUrl, keywords, organizerInfo, attendeeCount +Throw Errors When: +- event not found by eventId β†’ NOT_FOUND +- DB read fails β†’ INTERNAL_SERVER +Flow: fetch event by eventId β†’ if not found β†’ NOT_FOUND + β†’ map all aggregate fields to detail DTO + β†’ return 200 + detail DTO +``` + +### event.search + +``` +Function Name: event.search +Auth: none β€” accessible to all users including guests +Input: name (optional, free text), category (optional, one of taxonomy values), + city (optional, specific city name or "CaΕ‚a Polska" which means no city filter), + dateLabel (optional, preset label string), offset (optional), limit (optional) +Output: list of event card DTOs β€” each with id, name, category, startDateTime, city; + total count of matching events +Throw Errors When: +- category provided but not in allowed taxonomy β†’ VALIDATION_ERROR +- dateLabel provided but not a recognized label β†’ VALIDATION_ERROR +- DB read fails β†’ INTERNAL_SERVER +Flow: validate provided filter values + β†’ if dateLabel provided β†’ resolve to concrete date range via Polish Holiday Calendar domain + β†’ query events applying all active filters simultaneously + β†’ apply offset and limit for pagination + β†’ map results to event card DTOs + β†’ return 200 + list + total count +``` + +--- + +## Section 4 β€” Server-Contract Zod Schemas + +### event.getById + +```ts +import z from 'zod'; + +const category = z.enum([ + 'Concert', + 'Festival', + 'Sports', + 'Culture', + 'Theatre', + 'Food & Drink', +]); + +export const schema = () => + z.object({ + in: z.object({ + eventId: z.string(), + }), + out: z.union([ + z.object({ + code: z.literal(200), + event: z.object({ + id: z.string(), + name: z.string(), + description: z.string().optional(), + category, + startDateTime: z.string().datetime(), + endDateTime: z.string().datetime().optional(), + address: z.object({ + street: z.string(), + number: z.string(), + postalCode: z.string(), + city: z.string(), + }), + coordinates: z.object({ + lat: z.number(), + lng: z.number(), + }), + externalLink: z.string().url().optional(), + imageUrl: z.string().url().optional(), + keywords: z.array(z.string()), + organizerInfo: z.string().optional(), + attendeeCount: z.number().int(), + }), + }), + z.object({ + code: z.literal(404), + type: z.literal('not-found'), + message: z.string(), + }), + z.object({ + code: z.literal(500), + type: z.literal('internal-server'), + message: z.string(), + }), + ]), + }); + +export type Schema = z.infer>; +``` + +### event.search + +```ts +import z from 'zod'; + +const category = z.enum([ + 'Concert', + 'Festival', + 'Sports', + 'Culture', + 'Theatre', + 'Food & Drink', +]); + +export const schema = () => + z.object({ + in: z.object({ + name: z.string().optional(), + category: category.optional(), + city: z.string().optional(), + dateLabel: z.string().optional(), + offset: z.number().int().min(0).optional(), + limit: z.number().int().min(1).max(100).optional(), + }), + out: z.union([ + z.object({ + code: z.literal(200), + events: z.array( + z.object({ + id: z.string(), + name: z.string(), + category, + startDateTime: z.string().datetime(), + city: z.string(), + }), + ), + total: z.number().int(), + }), + z.object({ + code: z.literal(400), + type: z.literal('bad-request'), + message: z.string(), + }), + z.object({ + code: z.literal(500), + type: z.literal('internal-server'), + message: z.string(), + }), + ]), + }); + +export type Schema = z.infer>; +``` diff --git a/apps/event-app/src/modules/event-discovery/plan-db.md b/apps/event-app/src/modules/event-discovery/plan-db.md new file mode 100644 index 0000000..a9d7708 --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/plan-db.md @@ -0,0 +1,177 @@ +```mermaid +erDiagram + AUTH_USERS { + uuid id PK + } + EVENTS { + uuid id PK + uuid owner_id FK + text name + text description + event_category category + timestamptz start_date_time + timestamptz end_date_time + text street + text number + text postal_code + text city + float8 lat + float8 lng + text external_link + text image_url + text_array keywords + text organizer_info + timestamptz created_at + timestamptz updated_at + } + EVENT_ATTENDANCES { + uuid event_id FK + uuid user_id FK + attendance_visibility visibility + timestamptz created_at + } + + AUTH_USERS ||--o{ EVENTS : "owns" + AUTH_USERS ||--o{ EVENT_ATTENDANCES : "attends" + EVENTS ||--o{ EVENT_ATTENDANCES : "has" +``` + +--- + +### helpers + +```sql +-- Assumption: pg_trgm is available in Supabase (enabled by default) +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE TYPE event_category AS ENUM ( + 'Concert', 'Festival', 'Sports', 'Culture', 'Theatre', 'Food & Drink' +); + +CREATE TYPE attendance_visibility AS ENUM ('public', 'private'); + +CREATE OR REPLACE FUNCTION set_updated_at() +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$; + +-- rollback: DROP FUNCTION set_updated_at(); DROP TYPE attendance_visibility; DROP TYPE event_category; DROP EXTENSION pg_trgm; +``` + +### 001_events + +```sql +CREATE TABLE events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + name text NOT NULL, + description text, + category event_category NOT NULL, + start_date_time timestamptz NOT NULL, + end_date_time timestamptz, + street text NOT NULL, + number text NOT NULL, + postal_code text NOT NULL, + city text NOT NULL, + lat double precision NOT NULL, + lng double precision NOT NULL, + external_link text, + image_url text, + keywords text[] NOT NULL DEFAULT '{}', + organizer_info text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT events_end_after_start CHECK (end_date_time IS NULL OR end_date_time > start_date_time) +); + +CREATE INDEX ON events (owner_id); +CREATE INDEX ON events (category); +CREATE INDEX ON events (city); +CREATE INDEX ON events (start_date_time); +CREATE INDEX ON events (category, city, start_date_time); -- combined search filter +CREATE INDEX ON events USING gin (name gin_trgm_ops); -- free-text name filter (event.search) +CREATE INDEX ON events USING gin (keywords); -- keyword array lookups + +CREATE TRIGGER events_set_updated_at + BEFORE UPDATE ON events + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +ALTER TABLE events ENABLE ROW LEVEL SECURITY; + +-- all users (anon + authenticated) can read events +CREATE POLICY "events_select_all" + ON events FOR SELECT + USING (true); + +-- authenticated users can insert their own events +CREATE POLICY "events_insert_authenticated" + ON events FOR INSERT + WITH CHECK (auth.uid() IS NOT NULL AND auth.uid() = owner_id); + +-- Assumption: admin role is stored in Supabase auth.jwt() app_metadata as { "role": "admin" } +CREATE POLICY "events_update_owner_or_admin" + ON events FOR UPDATE + USING ( + auth.uid() = owner_id + OR (auth.jwt() -> 'app_metadata' ->> 'role') = 'admin' + ) + WITH CHECK ( + auth.uid() = owner_id + OR (auth.jwt() -> 'app_metadata' ->> 'role') = 'admin' + ); + +CREATE POLICY "events_delete_owner_or_admin" + ON events FOR DELETE + USING ( + auth.uid() = owner_id + OR (auth.jwt() -> 'app_metadata' ->> 'role') = 'admin' + ); + +-- rollback: DROP TABLE events; +``` + +### 002_event_attendances + +```sql +-- FK cross-ref: events.id +CREATE TABLE event_attendances ( + event_id uuid NOT NULL REFERENCES events(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + visibility attendance_visibility NOT NULL DEFAULT 'public', + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (event_id, user_id) +); + +CREATE INDEX ON event_attendances (user_id); +-- partial index: event.getFriendsAttendance filters to public visibility only +CREATE INDEX ON event_attendances (event_id) WHERE visibility = 'public'; + +ALTER TABLE event_attendances ENABLE ROW LEVEL SECURITY; + +-- own attendance always readable; others' only when public +CREATE POLICY "attendance_select" + ON event_attendances FOR SELECT + USING ( + user_id = auth.uid() + OR visibility = 'public' + ); + +CREATE POLICY "attendance_insert_own" + ON event_attendances FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "attendance_update_own" + ON event_attendances FOR UPDATE + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "attendance_delete_own" + ON event_attendances FOR DELETE + USING (auth.uid() = user_id); + +-- rollback: DROP TABLE event_attendances; +``` diff --git a/apps/event-app/src/modules/event-discovery/presentation/category-band.tsx b/apps/event-app/src/modules/event-discovery/presentation/category-band.tsx new file mode 100644 index 0000000..6f44ec4 --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/presentation/category-band.tsx @@ -0,0 +1,39 @@ +import { IconArrow } from './icons'; +import { CATEGORIES, EVENTS } from './mock-data'; +import { Text } from '@/libs/ui/text'; + +export const CategoryBand = () => ( +

+
+
+ KATEGORIE + + Wybierz, co lubisz najbardziej. + +
+
+
+ {CATEGORIES.map((c) => { + const count = EVENTS.filter((e) => e.category === c.id).length; + return ( + + ); + })} +
+
+); diff --git a/apps/event-app/src/modules/event-discovery/presentation/category-chips.tsx b/apps/event-app/src/modules/event-discovery/presentation/category-chips.tsx new file mode 100644 index 0000000..e1d5a2e --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/presentation/category-chips.tsx @@ -0,0 +1,33 @@ +import { CATEGORIES } from './mock-data'; + +type CategoryChipsProps = { + value: string; + onChange: (id: string) => void; + scrollable?: boolean; +}; + +export const CategoryChips = ({ + value, + onChange, + scrollable = true, +}: CategoryChipsProps) => ( +
+ + {CATEGORIES.map((c) => ( + + ))} +
+); diff --git a/apps/event-app/src/modules/event-discovery/presentation/context.tsx b/apps/event-app/src/modules/event-discovery/presentation/context.tsx new file mode 100644 index 0000000..8b78975 --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/presentation/context.tsx @@ -0,0 +1,18 @@ +import { useLayoutEffect, useState } from 'react'; +import { createHookContext } from '@/libs/power-context'; +import { createMediator } from '../core/mediator'; + +export const [Provider, useContext] = createHookContext( + 'EventDiscovery', + () => { + const [store, trigger, registry] = useState(createMediator)[0]; + const value = useState(() => ({ ...store, trigger }))[0]; + + useLayoutEffect(() => { + const unsub = registry(); + return () => unsub(); + }, [registry]); + + return value; + }, +); diff --git a/apps/event-app/src/modules/event-discovery/presentation/details-main.tsx b/apps/event-app/src/modules/event-discovery/presentation/details-main.tsx new file mode 100644 index 0000000..e4c7541 --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/presentation/details-main.tsx @@ -0,0 +1,71 @@ +import { useEffect, useState } from 'react'; +import type { User } from '@supabase/supabase-js'; + +import { Provider, useContext } from './context'; +import { Header } from './header'; +import { Footer } from './footer'; +import { DetailsPage } from './details-page'; +import { SkeletonDetails } from './skeleton-details'; + +type Props = { + id: string; + user: User | null; +}; + +const Content = ({ id, user }: Props) => { + const { trigger, $event, $isLoadingEvent, $eventError } = useContext(); + const [savedSet, setSavedSet] = useState>(new Set()); + + const event = $event.use(); + const isLoading = $isLoadingEvent.use(); + const error = $eventError.use(); + + useEffect(() => { + trigger('[TRIGGER]_FETCH_EVENT', { id }); + }, [trigger, id]); + + const toggleSave = (eid: string) => { + setSavedSet((prev) => { + const next = new Set(prev); + if (next.has(eid)) next.delete(eid); + else next.add(eid); + return next; + }); + }; + + return ( +
+
+
+ {isLoading && } + {error && ( +
{error}
+ )} + {event && !isLoading && ( + window.history.back()} + onOpenEvent={(e) => { + window.location.href = `/event/${e.id}`; + }} + onToggleSave={toggleSave} + savedSet={savedSet} + /> + )} + {!isLoading && !error && !event && ( +
+ Nie znaleziono wydarzenia. +
+ )} +
+
+
+ ); +}; + +export const DetailsMain = ({ id, user }: Props) => ( + + + +); diff --git a/apps/event-app/src/modules/event-discovery/presentation/details-page.tsx b/apps/event-app/src/modules/event-discovery/presentation/details-page.tsx new file mode 100644 index 0000000..1954240 --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/presentation/details-page.tsx @@ -0,0 +1,271 @@ +import { useState } from 'react'; +import { Button } from '@/libs/ui/button'; +import { Text } from '@/libs/ui/text'; +import { Poster } from './poster'; +import { Map } from './map'; +import { EventCard } from './event-card'; +import { IconBack, IconHeart, IconShare, IconCheck } from './icons'; +import { + POSTER_PALETTES, + fmtDate, + fmtDayNum, + fmtMonthShort, + fmtDayName, +} from './mock-data'; +import type { Event, EventDetail } from '../contracts/models'; + +const CATEGORY_LABELS: Record = { + Concert: 'Koncert', + Festival: 'Festiwal', + Sports: 'Sport', + Culture: 'Kultura', + Theatre: 'Teatr', + 'Food & Drink': 'Jedzenie i napoje', +}; + +const dateOnly = (iso: string) => iso.split('T')[0]; +const timeOnly = (iso: string) => iso.split('T')[1]?.substring(0, 5) ?? ''; + +const paletteIndex = (id: string) => + id.split('').reduce((sum, c) => sum + c.charCodeAt(0), 0) % + POSTER_PALETTES.length; + +type DetailsPageProps = { + event: EventDetail; + allEvents: Event[]; + onBack: () => void; + onOpenEvent: (event: Event) => void; + onToggleSave: (id: string) => void; + savedSet: Set; +}; + +export const DetailsPage = ({ + event, + allEvents, + onBack, + onOpenEvent, + onToggleSave, + savedSet, +}: DetailsPageProps) => { + const [going, setGoing] = useState(false); + const saved = savedSet.has(event.id); + + const date = dateOnly(event.startDateTime); + const endDate = event.endDateTime ? dateOnly(event.endDateTime) : undefined; + const time = timeOnly(event.startDateTime); + const venue = `${event.address.street} ${event.address.number}`; + const fullAddress = `${venue}, ${event.address.postalCode} ${event.address.city}`; + const palette = paletteIndex(event.id); + const posterTitle = event.name; + const posterMeta = `${event.address.city.toUpperCase()} Β· ${fmtDate(date).toUpperCase()}`; + const categoryDisplayLabel = + CATEGORY_LABELS[event.category] ?? event.category; + + const similar = allEvents + .filter((e) => e.id !== event.id && e.category === event.category) + .slice(0, 3); + + return ( +
+ + + {/* Hero block */} +
+
+ +
+
+ + {categoryDisplayLabel.toUpperCase()} Β·{' '} + {event.address.city.toUpperCase()} + + {event.name} + + {/* When / where */} +
+
+ DATA + + {fmtDayNum(date)}{' '} + + {fmtMonthShort(date)} + + {endDate && ( + <> + {' '} + β€” {fmtDayNum(endDate)}{' '} + + {fmtMonthShort(endDate)} + + + )} + +
+ {fmtDayName(date)}, godz. {time} +
+
+
+ MIEJSCE + {venue} +
+ {event.address.city} +
+
+
+ + {/* Actions */} +
+ + + +
+ + {going && ( +
+ + POTWIERDZENIE + +
Świetnie. Powiadomimy ciΔ™ na 24h przed startem.
+
+ )} +
+
+ + {/* Body */} +
+
+ {event.description && ( +
+ O WYDARZENIU +

+ {event.description} +

+
+ )} + +
+ LOKALIZACJA + + {venue}, {event.address.city} + + +
+
+ ADRES +
{venue}
+
+ {event.address.postalCode} {event.address.city} +
+
+
+ WSPÓŁRZĘDNE +
+ {event.coordinates.lat.toFixed(4)}Β° N Β·{' '} + {event.coordinates.lng.toFixed(4)}Β° E +
+
+
+ DOJAZD +
Tramwaj, autobus, parking dla rowerΓ³w
+
+
+
+
+ + +
+ + {/* Similar events */} + {similar.length > 0 && ( +
+
+
+ PODOBNE WYDARZENIA + + JeΕ›li podoba ci siΔ™ to, zobacz teΕΌ. + +
+
+
+ {similar.map((e) => ( + + ))} +
+
+ )} +
+ ); +}; diff --git a/apps/event-app/src/modules/event-discovery/presentation/event-card.tsx b/apps/event-app/src/modules/event-discovery/presentation/event-card.tsx new file mode 100644 index 0000000..8649733 --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/presentation/event-card.tsx @@ -0,0 +1,108 @@ +import { Button } from '@/libs/ui/button'; +import { Text } from '@/libs/ui/text'; +import { IconHeart, IconArrow } from './icons'; +import { fmtDayNum, fmtMonthShort, fmtDate, categoryLabel } from './mock-data'; +import type { Event } from '../contracts/models'; +import { Poster } from './poster'; + +type EventCardLayout = 'grid' | 'list'; + +type EventCardProps = { + event: Event; + layout?: EventCardLayout; + onOpen: (event: Event) => void; + onToggleSave: (id: string) => void; + saved: boolean; +}; + +const dateOnly = (startDateTime: string) => startDateTime.slice(0, 10); + +export const EventCard = ({ + event, + layout = 'grid', + onOpen, + onToggleSave, + saved, +}: EventCardProps) => { + if (layout === 'list') { + return ( +
onOpen(event)} + > +
+ + {fmtDayNum(dateOnly(event.startDateTime))} + +
+ {fmtMonthShort(dateOnly(event.startDateTime))} +
+
+ {new Date(event.startDateTime).getFullYear()} +
+
+
+ +
+
+ + {categoryLabel(event.category).toUpperCase()} Β·{' '} + {event.city.toUpperCase()} + + {event.name} +
+
+ + +
+
+ ); + } + + return ( +
onOpen(event)} + > +
+ + + {event.category} + + +
+
+ + {fmtDate(dateOnly(event.startDateTime))} Β· {event.city} + + {event.name} +
+
+ ); +}; diff --git a/apps/event-app/src/modules/event-discovery/presentation/featured-grid.tsx b/apps/event-app/src/modules/event-discovery/presentation/featured-grid.tsx new file mode 100644 index 0000000..7b6ec5b --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/presentation/featured-grid.tsx @@ -0,0 +1,69 @@ +import { useEffect } from 'react'; +import { Button } from '@/libs/ui/button'; +import { Text } from '@/libs/ui/text'; +import { EventCard } from './event-card'; +import { IconArrow } from './icons'; +import { useContext } from './context'; +import { SkeletonCard } from './skeleton-card'; + +const SKELETON_COUNT = 4; + +type FeaturedGridProps = { + onToggleSave: (id: string) => void; + savedSet: Set; +}; + +export const FeaturedGrid = ({ onToggleSave, savedSet }: FeaturedGridProps) => { + const ctx = useContext(); + const events = ctx.$results.use(); + const isLoading = ctx.$isLoading.use(); + + const renderCards = () => { + if (isLoading) { + return Array.from({ length: SKELETON_COUNT }, (_, i) => ( + + )); + } else { + return events.map((e) => ( + { + window.location.href = `/event/${event.id}`; + }} + onToggleSave={onToggleSave} + saved={savedSet.has(e.id)} + /> + )); + } + }; + + useEffect(() => { + ctx.trigger('[TRIGGER]_SEARCH', { isFeatured: true }); + }, [ctx]); + + return ( +
+
+
+ WYDARZENIA POLECANE + + NajgΕ‚oΕ›niejsze afisze tygodnia. + +
+ +
+
+ {renderCards()} +
+
+ ); +}; diff --git a/apps/event-app/src/modules/event-discovery/presentation/footer.tsx b/apps/event-app/src/modules/event-discovery/presentation/footer.tsx new file mode 100644 index 0000000..f5ebce6 --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/presentation/footer.tsx @@ -0,0 +1,73 @@ +import { Text } from '@/libs/ui/text'; + +const DISCOVER_LINKS = [ + 'Koncerty', + 'Festiwale', + 'Sport', + 'Teatr i opera', + 'Wystawy', + 'Stand-up', + 'Kluby', +]; +const CITY_LINKS = [ + 'Warszawa', + 'KrakΓ³w', + 'WrocΕ‚aw', + 'PoznaΕ„', + 'GdaΕ„sk', + 'ŁódΕΊ', + 'Katowice', +]; +const ORG_LINKS = [ + 'Dodaj wydarzenie', + 'Panel organizatora', + 'Kalendarz publiczny', + 'API Β· pl', + 'Pomoc', +]; + +type FooterColProps = { + heading: string; + items: string[]; +}; + +const FooterCol = ({ heading, items }: FooterColProps) => ( +
+ {heading} +
    + {items.map((item) => ( +
  • + {item} +
  • + ))} +
+
+); + +export const Footer = () => ( +
+
+
+ + Afisz. + +

+ CaΕ‚a Polska na ΕΌywo. Promocja wydarzeΕ„, nie sprzedaΕΌ biletΓ³w. +

+ + WYD. 2026 Β· WERSJA ALPHA + +
+ + + +
+
+ Β© 2026 Afisz Β· Polska + Regulamin Β· PrywatnoΕ›Δ‡ Β· Cookies Β· Kontakt +
+
+); diff --git a/apps/event-app/src/modules/event-discovery/presentation/header.tsx b/apps/event-app/src/modules/event-discovery/presentation/header.tsx new file mode 100644 index 0000000..148f820 --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/presentation/header.tsx @@ -0,0 +1,78 @@ +import { Heart, Plus } from 'lucide-react'; + +import { Logo } from '@/libs/ui/logo'; +import { Button } from '@/libs/ui/button'; +import type { User } from '@supabase/supabase-js'; +import { UserProfile } from '@/shared/user-profile/presentation/user-profile'; + +type ActivePage = 'home' | 'results' | 'details'; + +type HeaderProps = { + activePage: ActivePage; + savedCount: number; + user: User | null; +}; + +export const Header = ({ activePage, savedCount, user }: HeaderProps) => ( +
+
+ {/* Logo */} + + + {/* Nav */} + + + {/* Right */} + {user ? ( +
+ + {/* */} + + + +
+ ) : ( +
+ + +
+ )} +
+
+); diff --git a/apps/event-app/src/modules/event-discovery/presentation/hero.tsx b/apps/event-app/src/modules/event-discovery/presentation/hero.tsx new file mode 100644 index 0000000..b023eca --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/presentation/hero.tsx @@ -0,0 +1,84 @@ +import { useState } from 'react'; +import { SearchBar } from './search-bar'; +import type { SearchState } from './search-bar'; +import { Text } from '@/libs/ui/text'; +import { Button } from '@/libs/ui/button'; + +const EMPTY_SEARCH: SearchState = { + name: '', + category: '', + city: '', + date: '', +}; + +const toResultsUrl = (s: SearchState) => { + const p = new URLSearchParams(); + if (s.name) p.set('name', s.name); + if (s.category) p.set('category', s.category); + if (s.city) p.set('city', s.city); + if (s.date) p.set('date', s.date); + return `/results${p.toString() ? `?${p}` : ''}`; +}; + +const QUICK_LINKS: { label: string; query: Partial }[] = [ + { + label: 'Ten weekend w Warszawie', + query: { date: 'weekend', city: 'Warszawa' }, + }, + { + label: 'Festiwale lato 2026', + query: { category: 'festiwale', date: 'summer' }, + }, + { label: 'Sport Β· Ekstraklasa', query: { category: 'sport' } }, + { + label: 'Stand-up Β· KrakΓ³w', + query: { category: 'stand-up', city: 'KrakΓ³w' }, + }, +]; + +export const Hero = () => { + const [search, setSearch] = useState(EMPTY_SEARCH); + + return ( +
+
+ + ZnajdΕΊ coΕ›, +
+ co warto +
+ przeżyć. +
+ +

+ Koncerty, festiwale, sport, teatr, wystawy. Wszystkie wydarzenia w + Polsce w jednym miejscu β€” bez biletΓ³w, bez poΕ›rednikΓ³w, tylko afisz. +

+
+ { + window.location.href = toResultsUrl(search); + }} + /> +
+
+ {QUICK_LINKS.map((ql) => ( + + ))} +
+
+
+ ); +}; diff --git a/apps/event-app/src/modules/event-discovery/presentation/icons.tsx b/apps/event-app/src/modules/event-discovery/presentation/icons.tsx new file mode 100644 index 0000000..de310ff --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/presentation/icons.tsx @@ -0,0 +1,195 @@ +type IconProps = { + size?: number; + fill?: string; +}; + +export const IconSearch = ({ size = 16 }: IconProps) => ( + + + + +); + +export const IconPin = ({ size = 16 }: IconProps) => ( + + + + +); + +export const IconCal = ({ size = 16 }: IconProps) => ( + + + + +); + +export const IconHeart = ({ size = 16, fill = 'none' }: IconProps) => ( + + + +); + +export const IconShare = ({ size = 16 }: IconProps) => ( + + + + + + +); + +export const IconUser = ({ size = 16 }: IconProps) => ( + + + + +); + +export const IconArrow = ({ size = 16 }: IconProps) => ( + + + +); + +export const IconBack = ({ size = 16 }: IconProps) => ( + + + +); + +export const IconClose = ({ size = 16 }: IconProps) => ( + + + +); + +export const IconClock = ({ size = 16 }: IconProps) => ( + + + + +); + +export const IconCheck = ({ size = 16 }: IconProps) => ( + + + +); + +export const IconSun = ({ size = 16 }: IconProps) => ( + + + + +); + +export const IconMoon = ({ size = 16 }: IconProps) => ( + + + +); + +export const IconCaret = ({ size = 10 }: IconProps) => ( + + + +); diff --git a/apps/event-app/src/modules/event-discovery/presentation/landing.tsx b/apps/event-app/src/modules/event-discovery/presentation/landing.tsx new file mode 100644 index 0000000..5719077 --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/presentation/landing.tsx @@ -0,0 +1,16 @@ +import { Hero } from './hero'; +import { FeaturedGrid } from './featured-grid'; +import { CategoryBand } from './category-band'; + +type LandingProps = { + savedSet: Set; + onToggleSave: (id: string) => void; +}; + +export const Landing = ({ savedSet, onToggleSave }: LandingProps) => ( + <> + + + + +); diff --git a/apps/event-app/src/modules/event-discovery/presentation/light-dark-mode-switch-button.tsx b/apps/event-app/src/modules/event-discovery/presentation/light-dark-mode-switch-button.tsx new file mode 100644 index 0000000..2b3d6a3 --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/presentation/light-dark-mode-switch-button.tsx @@ -0,0 +1,34 @@ +import { useState, useEffect } from 'react'; +import { Moon, Sun } from 'lucide-react'; + +export const LightDarkModeSwitchButton = () => { + const [isDark, setIsDark] = useState( + () => + typeof localStorage !== 'undefined' && + localStorage.getItem('theme') === 'dark', + ); + + useEffect(() => { + if (isDark) { + document.documentElement.setAttribute('data-theme', 'dark'); + } else { + document.documentElement.removeAttribute('data-theme'); + } + }, [isDark]); + + const toggleTheme = () => { + const next = !isDark; + setIsDark(next); + localStorage.setItem('theme', next ? 'dark' : 'light'); + }; + + return ( + + ); +}; diff --git a/apps/event-app/src/modules/event-discovery/presentation/main.tsx b/apps/event-app/src/modules/event-discovery/presentation/main.tsx new file mode 100644 index 0000000..aef02d1 --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/presentation/main.tsx @@ -0,0 +1,40 @@ +import { useState } from 'react'; +import type { User } from '@supabase/supabase-js'; + +import { Provider } from './context'; +import { Header } from './header'; +import { Landing } from './landing'; +import { Footer } from './footer'; + +type MainProps = { + user: User | null; +}; + +const Content = ({ user }: MainProps) => { + const [savedSet, setSavedSet] = useState>(new Set()); + + const toggleSave = (id: string) => { + setSavedSet((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + return ( +
+
+
+ +
+
+
+ ); +}; + +export const Main = ({ user }: MainProps) => ( + + + +); diff --git a/apps/event-app/src/modules/event-discovery/presentation/manifest-band.tsx b/apps/event-app/src/modules/event-discovery/presentation/manifest-band.tsx new file mode 100644 index 0000000..de0cf36 --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/presentation/manifest-band.tsx @@ -0,0 +1,39 @@ +import { Text } from '@/libs/ui/text'; + +export const ManifestBand = () => ( +
+
+ AFISZ Β· MANIFEST + + Nie sprzedajemy biletΓ³w. +
+ Pomagamy je znaleΕΊΔ‡. +
+
+
+ 01 Β· OTWARTOΕšΔ† +

+ KaΕΌde wydarzenie w Polsce β€” duΕΌe i maΕ‚e, klubowe i stadionowe β€” w + jednym miejscu, bez priorytetu dla pΕ‚acΔ…cych. +

+
+
+ 02 Β· LOKALNOΕšΔ† +

+ Filtrujesz po mieΕ›cie, dzielnicy, dniu tygodnia. Afisz pokaΕΌe ci, co + dzieje siΔ™ dziΕ› za rogiem i w sΔ…siednim wojewΓ³dztwie. +

+
+
+ + 03 Β· BEZ POŚREDNIKΓ“W + +

+ Klikasz "IdΔ™", organizator widzi zainteresowanie. SprzedaΕΌ + biletΓ³w odbywa siΔ™ u ΕΊrΓ³dΕ‚a β€” nie u nas. +

+
+
+
+
+); diff --git a/apps/event-app/src/modules/event-discovery/presentation/map.css b/apps/event-app/src/modules/event-discovery/presentation/map.css new file mode 100644 index 0000000..eaf3a93 --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/presentation/map.css @@ -0,0 +1,84 @@ +/* + * App-specific Leaflet overrides. Imported after `leaflet/dist/leaflet.css` + * so these rules win. Scoped to `.event-map` to avoid leaking globally. + */ + +.event-map { + font-family: var(--font-sans); + background: var(--color-soft-stone); +} + +/* Warm the minimal Positron basemap toward the app's soft-stone palette. */ +.event-map .leaflet-tile-pane { + filter: grayscale(0.15) sepia(0.12) saturate(0.92) brightness(1.02); +} + +/* Zoom controls — match the app's card/hairline + coral hover. */ +.event-map .leaflet-bar { + border: none; + border-radius: var(--radius-sm); + overflow: hidden; + box-shadow: 0 8px 20px -12px rgba(0, 0, 0, 0.35); +} + +.event-map .leaflet-bar a { + background: var(--color-card-bg); + color: var(--color-ink); + border-bottom: 1px solid var(--color-hairline); + width: 32px; + height: 32px; + line-height: 32px; + transition: + color 0.15s ease, + background 0.15s ease; +} + +.event-map .leaflet-bar a:last-child { + border-bottom: none; +} + +.event-map .leaflet-bar a:hover { + background: var(--color-surface); + color: var(--color-coral); +} + +/* Popup — rounded card with hairline border and app typography. */ +.event-map .leaflet-popup-content-wrapper { + background: var(--color-card-bg); + color: var(--color-ink); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-sm); + box-shadow: 0 18px 40px -20px rgba(0, 0, 0, 0.4); +} + +.event-map .leaflet-popup-content { + font-family: var(--font-sans); + font-size: 13px; + line-height: 1.4; + color: var(--color-ink); +} + +.event-map .leaflet-popup-tip { + background: var(--color-card-bg); + border: 1px solid var(--color-border-light); +} + +.event-map .leaflet-popup-close-button { + color: var(--color-muted); +} + +.event-map .leaflet-popup-close-button:hover { + color: var(--color-coral); +} + +/* Attribution — muted, mono, unobtrusive. */ +.event-map .leaflet-control-attribution { + background: rgba(255, 255, 255, 0.8); + font-family: var(--font-mono); + font-size: 10px; + color: var(--color-muted); +} + +.event-map .leaflet-control-attribution a { + color: var(--color-slate); +} diff --git a/apps/event-app/src/modules/event-discovery/presentation/map.tsx b/apps/event-app/src/modules/event-discovery/presentation/map.tsx new file mode 100644 index 0000000..824233d --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/presentation/map.tsx @@ -0,0 +1,154 @@ +import { useEffect, useRef, useState } from 'react'; +import type { Map as LeafletMap } from 'leaflet'; +import 'leaflet/dist/leaflet.css'; +import './map.css'; + +type Coordinates = { lat: number; lng: number }; + +type MapProps = { + /** Known coordinates. When omitted/invalid, the component geocodes `address`. */ + coordinates?: Partial; + /** Human-readable address used for geocoding fallback and the marker popup. */ + address?: string; + /** Short label shown in the marker popup (e.g. the city). */ + label?: string; + zoom?: number; + className?: string; +}; + +const isValidCoord = (c?: Partial): c is Coordinates => + !!c && + typeof c.lat === 'number' && + typeof c.lng === 'number' && + Number.isFinite(c.lat) && + Number.isFinite(c.lng) && + (c.lat !== 0 || c.lng !== 0); + +/** Geocode a free-form address to coordinates via OpenStreetMap Nominatim. */ +const geocode = async ( + address: string, + signal: AbortSignal, +): Promise => { + const url = `https://nominatim.openstreetmap.org/search?format=json&limit=1&q=${encodeURIComponent( + address, + )}`; + const res = await fetch(url, { + signal, + headers: { Accept: 'application/json' }, + }); + if (!res.ok) return null; + const data = (await res.json()) as Array<{ lat: string; lon: string }>; + if (!data.length) return null; + return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) }; +}; + +/** + * Leaflet-backed map showing a single location pin for an event. + * + * Leaflet touches `window` on import, so it is loaded dynamically inside an + * effect — this keeps the component safe under Astro's SSR (`client:load`). + */ +export const Map = ({ + coordinates, + address, + label, + zoom = 14, + className, +}: MapProps) => { + const containerRef = useRef(null); + const mapRef = useRef(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + const controller = new AbortController(); + + const init = async () => { + const point = isValidCoord(coordinates) + ? coordinates + : address + ? await geocode(address, controller.signal).catch(() => null) + : null; + + if (cancelled) return; + if (!point) { + setError('Nie udało się ustalić lokalizacji.'); + return; + } + setError(null); + + const L = await import('leaflet'); + if (cancelled || !containerRef.current) return; + + const map = L.map(containerRef.current, { + center: [point.lat, point.lng], + zoom, + scrollWheelZoom: false, + }); + mapRef.current = map; + + // CartoDB Positron — a minimal, light basemap that suits the app's + // clean aesthetic. Free and key-less. `{r}` serves retina tiles. + L.tileLayer( + 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', + { + attribution: '© OpenStreetMap · © CARTO', + subdomains: 'abcd', + maxZoom: 20, + }, + ).addTo(map); + + // Coral teardrop pin rendered as an SVG divIcon — avoids the broken + // default-marker asset paths under bundlers and matches the theme. + const icon = L.divIcon({ + className: 'event-map-pin', + html: ` + + + `, + iconSize: [32, 42], + iconAnchor: [16, 42], + popupAnchor: [0, -38], + }); + + const marker = L.marker([point.lat, point.lng], { icon }).addTo(map); + const popupText = [label, address].filter(Boolean).join(' · '); + if (popupText) marker.bindPopup(popupText); + }; + + init(); + + return () => { + cancelled = true; + controller.abort(); + mapRef.current?.remove(); + mapRef.current = null; + }; + // Depend on the primitive coords (not the object) so a new object + // identity on each render doesn't needlessly re-initialise the map. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [coordinates?.lat, coordinates?.lng, address, label, zoom]); + + if (error) { + return ( +
+ {error} +
+ ); + } + + return ( +
+ ); +}; diff --git a/apps/event-app/src/modules/event-discovery/presentation/mock-data.ts b/apps/event-app/src/modules/event-discovery/presentation/mock-data.ts new file mode 100644 index 0000000..9ff3878 --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/presentation/mock-data.ts @@ -0,0 +1,345 @@ +export type Category = { + id: string; + label: string; + mono: string; +}; + +export type PosterPalette = { + bg: string; + fg: string; + accent: string; +}; + +export type EventCoords = { + x: number; + y: number; +}; + +export type Event = { + id: string; + name: string; + category: string; + city: string; + venue: string; + date: string; + endDate?: string; + time: string; + coords: EventCoords; + palette: number; + posterTitle: string; + posterMeta: string; + description: string; + featured: boolean; + badge?: string; +}; + +export const CATEGORIES: Category[] = [ + { id: 'koncerty', label: 'Koncerty', mono: 'MUSIC.LIVE' }, + { id: 'festiwale', label: 'Festiwale', mono: 'FEST.OPEN-AIR' }, + { id: 'sport', label: 'Sport', mono: 'SPORT.LIVE' }, + { id: 'teatr', label: 'Teatr i opera', mono: 'STAGE.DRAMA' }, + { id: 'stand-up', label: 'Stand-up', mono: 'COMEDY.MIC' }, + { id: 'rodzinne', label: 'Rodzinne', mono: 'FAMILY.KIDS' }, + { id: 'konferencje', label: 'Konferencje', mono: 'TALKS.IDEAS' }, + { id: 'wystawy', label: 'Wystawy', mono: 'ART.EXHIBIT' }, + { id: 'kluby', label: 'Kluby', mono: 'NIGHT.CLUB' }, +]; + +export const CITIES: string[] = [ + 'Warszawa', + 'KrakΓ³w', + 'WrocΕ‚aw', + 'PoznaΕ„', + 'GdaΕ„sk', + 'ŁódΕΊ', + 'Katowice', + 'Lublin', + 'Szczecin', + 'Bydgoszcz', + 'BiaΕ‚ystok', + 'ToruΕ„', + 'RzeszΓ³w', + 'Sopot', + 'Gdynia', +]; + +export const POSTER_PALETTES: PosterPalette[] = [ + { bg: '#003c33', fg: '#edfce9', accent: '#ff7759' }, + { bg: '#071829', fg: '#f1f5ff', accent: '#ff7759' }, + { bg: '#ff7759', fg: '#17171c', accent: '#003c33' }, + { bg: '#17171c', fg: '#eeece7', accent: '#ff7759' }, + { bg: '#eeece7', fg: '#17171c', accent: '#003c33' }, + { bg: '#edfce9', fg: '#003c33', accent: '#ff7759' }, + { bg: '#9b60aa', fg: '#f1f5ff', accent: '#ffad9b' }, + { bg: '#1863dc', fg: '#f1f5ff', accent: '#ff7759' }, +]; + +export const EVENTS: Event[] = [ + { + id: 'tame-impala-warszawa', + name: 'Tame Impala β€” Deadbeat Tour', + category: 'koncerty', + city: 'Warszawa', + venue: 'PGE Narodowy', + date: '2026-06-14', + time: '20:00', + coords: { x: 0.66, y: 0.42 }, + palette: 0, + posterTitle: 'TAME\nIMPALA', + posterMeta: 'DEADBEAT TOUR β€” EU 2026', + description: + "Kevin Parker powraca do Polski z nowym albumem 'Deadbeat'. Psychodeliczny krajobraz, lasery, syntezatory i ten charakterystyczny basowy puls β€” wieczΓ³r, ktΓ³ry zatrzyma stadion w pΔ™tli.", + featured: true, + badge: 'NajgorΔ™tsze', + }, + { + id: 'open-er-2026', + name: "Open'er Festival 2026", + category: 'festiwale', + city: 'Gdynia', + venue: 'Lotnisko Kosakowo', + date: '2026-07-01', + endDate: '2026-07-04', + time: '16:00', + coords: { x: 0.55, y: 0.08 }, + palette: 2, + posterTitle: "OPEN'ER\n'26", + posterMeta: '4 DNI Β· 8 SCEN Β· 120 ARTYSTΓ“W', + description: + "Cztery dni nad BaΕ‚tykiem. Headlinerzy, alternatywa, sztuka, kino. Open'er znΓ³w skΕ‚ada PolskΔ™ latem.", + featured: true, + badge: '4 dni', + }, + { + id: 'lech-legia-derby', + name: 'Lech PoznaΕ„ β€” Legia Warszawa', + category: 'sport', + city: 'PoznaΕ„', + venue: 'Stadion PoznaΕ„', + date: '2026-05-23', + time: '18:00', + coords: { x: 0.42, y: 0.32 }, + palette: 3, + posterTitle: 'LECH\nLEGIA', + posterMeta: 'EKSTRAKLASA Β· KOLEJKA 33', + description: + 'Klasyk polskiej Ekstraklasy. Trybuny peΕ‚ne, oprawy gotowe, stawka β€” mistrzostwo. 90 minut, ktΓ³re oglΔ…dajΔ… wszyscy.', + featured: true, + badge: 'Hit kolejki', + }, + { + id: 'dudek-stand-up', + name: 'Kacper RuciΕ„ski β€” MateriaΕ‚ Otwarty', + category: 'stand-up', + city: 'KrakΓ³w', + venue: 'Klub Studio', + date: '2026-05-18', + time: '19:30', + coords: { x: 0.55, y: 0.74 }, + palette: 5, + posterTitle: 'KACPER\nRUCIΕƒSKI', + posterMeta: 'MATERIAŁ OTWARTY Β· 90 MIN', + description: + 'Trasa testowa nowego godzinnego materiaΕ‚u. Bez kamer, bez nagraΕ„, bez filtra β€” w kameralnym ukΕ‚adzie z miejscem na improwizacjΔ™.', + featured: true, + }, + { + id: 'mloda-polska-mnk', + name: 'MΕ‚oda Polska β€” Wystawa staΕ‚a', + category: 'wystawy', + city: 'KrakΓ³w', + venue: 'Muzeum Narodowe w Krakowie', + date: '2026-05-10', + endDate: '2026-09-30', + time: '10:00', + coords: { x: 0.55, y: 0.74 }, + palette: 4, + posterTitle: 'MŁODA\nPOLSKA', + posterMeta: 'WYSPIAΕƒSKI Β· MEHOFFER Β· MALCZEWSKI', + description: + 'NajwiΔ™ksza od dekady prezentacja prac StanisΕ‚awa WyspiaΕ„skiego, JΓ³zefa Mehoffera i Jacka Malczewskiego. 230 obiektΓ³w, 9 sal.', + featured: true, + }, + { + id: 'smolasty-tauron', + name: 'Smolasty β€” Era 03', + category: 'koncerty', + city: 'Katowice', + venue: 'Spodek', + date: '2026-06-07', + time: '20:00', + coords: { x: 0.5, y: 0.78 }, + palette: 7, + posterTitle: 'SMOLASTY\nERA 03', + posterMeta: 'TRASA POLSKA Β· 12 MIAST', + description: + 'Trzeci akt. Nowy album, nowa scenografia, sekcja smyczkowa i peΕ‚na hala Spodka.', + featured: true, + badge: 'Nowy album', + }, + { + id: 'infoshare-2026', + name: 'infoShare 2026', + category: 'konferencje', + city: 'GdaΕ„sk', + venue: 'AmberExpo', + date: '2026-05-28', + endDate: '2026-05-29', + time: '09:00', + coords: { x: 0.55, y: 0.1 }, + palette: 1, + posterTitle: 'INFO\nSHARE 26', + posterMeta: 'AI Β· BIZNES Β· STARTUPY', + description: + 'NajwiΔ™ksza konferencja technologiczna w CEE. 6 scen, 250 prelegentΓ³w, 6000 uczestnikΓ³w.', + featured: true, + }, + { + id: 'tosca-teatr-wielki', + name: 'Tosca β€” Giacomo Puccini', + category: 'teatr', + city: 'Warszawa', + venue: 'Teatr Wielki β€” Opera Narodowa', + date: '2026-05-30', + time: '19:00', + coords: { x: 0.66, y: 0.42 }, + palette: 6, + posterTitle: 'TOSCA', + posterMeta: 'PUCCINI Β· REΕ». M. TRELIΕƒSKI', + description: + 'Inscenizacja Mariusza TreliΕ„skiego. W roli gΕ‚Γ³wnej Aleksandra Kurzak. Trzy akty o miΕ‚oΕ›ci, polityce i zazdroΕ›ci w Rzymie 1800 roku.', + featured: false, + }, + { + id: 'tatry-bieg', + name: 'Bieg RzeΕΊnika 2026', + category: 'sport', + city: 'KomaΕ„cza', + venue: 'Bieszczady', + date: '2026-06-20', + time: '06:00', + coords: { x: 0.92, y: 0.92 }, + palette: 0, + posterTitle: 'BIEG\nRZEΕΉNIKA', + posterMeta: '78 KM Β· BIESZCZADY Β· 2900 D+', + description: + 'Legendarny ultramaraton bieszczadzki. Start o Ε›wicie z KomaΕ„czy, meta w Ustrzykach GΓ³rnych. 78 km, 2900 m przewyΕΌszenia, jeden dzieΕ„.', + featured: false, + }, + { + id: 'audioriver-2026', + name: 'Audioriver 2026', + category: 'festiwale', + city: 'PΕ‚ock', + venue: 'PlaΕΌa nad WisΕ‚Δ…', + date: '2026-07-24', + endDate: '2026-07-26', + time: '16:00', + coords: { x: 0.58, y: 0.42 }, + palette: 2, + posterTitle: 'AUDIO\nRIVER', + posterMeta: 'ELEKTRONIKA Β· 3 DNI Β· WISŁA', + description: + 'Jeden z najwiΔ™kszych festiwali muzyki elektronicznej w Europie Środkowej. PlaΕΌa, WisΕ‚a, Ε›wit nad scenΔ… gΕ‚Γ³wnΔ….', + featured: false, + }, + { + id: 'nosowska-stodola', + name: 'Kasia Nosowska β€” Trasa akustyczna', + category: 'koncerty', + city: 'WrocΕ‚aw', + venue: 'Hala Stulecia', + date: '2026-06-02', + time: '20:00', + coords: { x: 0.3, y: 0.55 }, + palette: 4, + posterTitle: 'NOSOWSKA', + posterMeta: 'AKUSTYCZNIE Β· 18 MIAST', + description: + 'Akustyczny set, szeΕ›cioosobowy zespΓ³Ε‚, repertuar z caΕ‚ej kariery i nowe interpretacje.', + featured: false, + }, + { + id: 'koziolek-rodzinny', + name: 'KozioΕ‚ek MatoΕ‚ek β€” bajka muzyczna', + category: 'rodzinne', + city: 'ŁódΕΊ', + venue: 'Teatr Pinokio', + date: '2026-05-16', + time: '12:00', + coords: { x: 0.55, y: 0.48 }, + palette: 5, + posterTitle: 'KOZIOŁEK\nMATOŁEK', + posterMeta: 'BAJKA MUZYCZNA Β· 4+ LAT', + description: + 'Klasyk polskiej bajki na ΕΌywo, z orkiestrΔ…, lalkami i piosenkami. 60 minut dla caΕ‚ej rodziny.', + featured: false, + }, +]; + +const MONTHS_PL = [ + 'sty', + 'lut', + 'mar', + 'kwi', + 'maj', + 'cze', + 'lip', + 'sie', + 'wrz', + 'paΕΊ', + 'lis', + 'gru', +]; +const MONTHS_SHORT = [ + 'STY', + 'LUT', + 'MAR', + 'KWI', + 'MAJ', + 'CZE', + 'LIP', + 'SIE', + 'WRZ', + 'PAΕΉ', + 'LIS', + 'GRU', +]; +const DAYS_PL = ['niedz.', 'pon.', 'wt.', 'Ε›r.', 'czw.', 'pt.', 'sob.']; + +export function fmtDate(iso: string): string { + const d = new Date(iso + 'T00:00:00'); + return `${d.getDate()} ${MONTHS_PL[d.getMonth()]} ${d.getFullYear()}`; +} + +export function fmtDateShort(iso: string): string { + const d = new Date(iso + 'T00:00:00'); + return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}`; +} + +export function fmtDayName(iso: string): string { + return DAYS_PL[new Date(iso + 'T00:00:00').getDay()]; +} + +export function fmtMonthShort(iso: string): string { + return MONTHS_SHORT[new Date(iso + 'T00:00:00').getMonth()]; +} + +export function fmtDayNum(iso: string): string { + return String(new Date(iso + 'T00:00:00').getDate()).padStart(2, '0'); +} + +export function categoryLabel(id: string): string { + return CATEGORIES.find((c) => c.id === id)?.label ?? id; +} + +export function datePresetLabel(k: string): string { + const labels: Record = { + today: 'DziΕ›', + weekend: 'Ten weekend', + week: 'Ten tydzieΕ„', + month: 'Ten miesiΔ…c', + summer: 'Lato 2026', + }; + return labels[k] ?? 'Dowolna data'; +} diff --git a/apps/event-app/src/modules/event-discovery/presentation/poster.tsx b/apps/event-app/src/modules/event-discovery/presentation/poster.tsx new file mode 100644 index 0000000..4ebc9cd --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/presentation/poster.tsx @@ -0,0 +1,88 @@ +import type { CSSProperties } from 'react'; +import { cn } from '@/libs/ui/cn'; +import { POSTER_PALETTES } from './mock-data'; + +type PosterSize = 'sm' | 'md' | 'lg'; + +type PosterProps = { + palette: number; + posterTitle?: string; + posterMeta?: string; + size?: PosterSize; + className?: string; +}; + +const TITLE_SIZE: Record = { + lg: 'text-[56px]', + md: 'text-[32px]', + sm: 'text-[22px]', +}; +const META_SIZE: Record = { + lg: 'text-[12px]', + md: 'text-[11px]', + sm: 'text-[11px]', +}; +const INSET: Record = { + lg: 'left-7 right-7 bottom-7', + md: 'left-[18px] right-[18px] bottom-[18px]', + sm: 'left-3.5 right-3.5 bottom-3.5', +}; + +export const Poster = ({ + palette, + posterTitle, + posterMeta, + size = 'md', + className, +}: PosterProps) => { + const p = POSTER_PALETTES[palette % POSTER_PALETTES.length]; + + return ( +
+ {/* grid texture */} +
+ {/* radial glow */} +
+ {/* title block */} +
+ {posterTitle && ( +
+ {posterTitle} +
+ )} + {posterMeta && ( +
+ {posterMeta} +
+ )} +
+ {/* accent bar */} +
+
+ ); +}; diff --git a/apps/event-app/src/modules/event-discovery/presentation/results-list.tsx b/apps/event-app/src/modules/event-discovery/presentation/results-list.tsx new file mode 100644 index 0000000..c035c1a --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/presentation/results-list.tsx @@ -0,0 +1,109 @@ +import { Button } from '@/libs/ui/button'; +import { Text } from '@/libs/ui/text'; +import { EventCard } from './event-card'; +import { SkeletonCard } from './skeleton-card'; +import type { Event } from '../contracts/models'; + +const SKELETON_COUNT = 4; + +type ResultsListProps = { + isLoading: boolean; + error: string | null; + results: Event[]; + layout: 'grid' | 'list'; + onToggleSave: (id: string) => void; + savedSet: Set; + onClearAll: () => void; +}; + +const openEvent = (event: Event) => { + window.location.href = `/event/${event.id}`; +}; + +export const ResultsList = ({ + isLoading, + error, + results, + layout, + onToggleSave, + savedSet, + onClearAll, +}: ResultsListProps) => { + if (isLoading) { + const skeletons = Array.from({ length: SKELETON_COUNT }, (_, i) => ( + + )); + + return layout === 'grid' ? ( +
+ {skeletons} +
+ ) : ( +
{skeletons}
+ ); + } + + if (error) { + return ( +
+ BŁĄD +

+ Nie udało się pobrać wyników. +

+

{error}

+ +
+ ); + } + + if (results.length === 0) { + return ( +
+ BRAK WYNIKΓ“W +

+ Nic nie pasuje do tych filtrΓ³w. +

+

+ Spróbuj zmienić miasto, datę lub kategorię — albo wyczyść filtry. +

+ +
+ ); + } + + if (layout === 'grid') { + return ( +
+ {results.map((e) => ( + + ))} +
+ ); + } + + return ( +
+ {results.map((e) => ( + + ))} +
+ ); +}; diff --git a/apps/event-app/src/modules/event-discovery/presentation/results-main.tsx b/apps/event-app/src/modules/event-discovery/presentation/results-main.tsx new file mode 100644 index 0000000..1e681ab --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/presentation/results-main.tsx @@ -0,0 +1,38 @@ +import { useState } from 'react'; +import type { User } from '@supabase/supabase-js'; + +import { Provider } from './context'; +import { Header } from './header'; +import { Footer } from './footer'; +import { ResultsPage } from './results-page'; + +type ResultsMainProps = { + user: User | null; +}; + +const Content = ({ user }: ResultsMainProps) => { + const [savedSet, setSavedSet] = useState>(new Set()); + const toggleSave = (id: string) => { + setSavedSet((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + return ( +
+
+
+ +
+
+
+ ); +}; + +export const ResultsMain = ({ user }: ResultsMainProps) => ( + + + +); diff --git a/apps/event-app/src/modules/event-discovery/presentation/results-page.tsx b/apps/event-app/src/modules/event-discovery/presentation/results-page.tsx new file mode 100644 index 0000000..5135115 --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/presentation/results-page.tsx @@ -0,0 +1,183 @@ +import { useState, useMemo, useEffect } from 'react'; + +import { Text } from '@/libs/ui/text'; +import { ResultsList } from './results-list'; +import { CategoryChips } from './category-chips'; +import { SearchBar, ActiveFilters } from './search-bar'; +import type { SearchState } from './search-bar'; +import { categoryLabel } from './mock-data'; +import { useContext } from './context'; + +const segBtn = (active: boolean) => + `border-0 px-3.5 py-1.5 rounded-full text-sm transition-colors ${active ? 'bg-ink text-canvas' : 'bg-transparent text-ink hover:bg-hairline'}`; + +const plural = (n: number, one: string, few: string, many: string): string => { + if (n === 1) return one; + const mod10 = n % 10, + mod100 = n % 100; + if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return few; + return many; +}; + +const EMPTY_SEARCH: SearchState = { + name: '', + category: '', + city: '', + date: '', +}; + +const getSearchFromUrl = (): SearchState => { + if (typeof window === 'undefined') return EMPTY_SEARCH; + const p = new URLSearchParams(window.location.search); + return { + name: p.get('name') || '', + category: p.get('category') || '', + city: p.get('city') || '', + date: p.get('date') || '', + }; +}; + +type ResultsPageProps = { + onToggleSave: (id: string) => void; + savedSet: Set; +}; + +export const ResultsPage = ({ onToggleSave, savedSet }: ResultsPageProps) => { + const [layout, setLayout] = useState<'grid' | 'list'>('grid'); + const [sort, setSort] = useState<'date' | 'name' | 'city'>('date'); + const [search, setSearch] = useState(getSearchFromUrl); + + const ctx = useContext(); + const results = ctx.$results.use(); + const isLoading = ctx.$isLoading.use(); + const error = ctx.$error.use(); + + const handleSearchSubmit = () => { + const p = new URLSearchParams(); + if (search.name) p.set('name', search.name); + if (search.category) p.set('category', search.category); + if (search.city) p.set('city', search.city); + if (search.date) p.set('date', search.date); + window.history.pushState({}, '', `/results${p.toString() ? `?${p}` : ''}`); + ctx.trigger('[TRIGGER]_SEARCH', { + name: search.name || undefined, + category: search.category || undefined, + city: search.city || undefined, + dateLabel: search.date || undefined, + }); + }; + + useEffect(() => { + const s = getSearchFromUrl(); + ctx.trigger('[TRIGGER]_SEARCH', { + name: s.name || undefined, + category: s.category || undefined, + city: s.city || undefined, + dateLabel: s.date || undefined, + }); + }, [ctx]); + + const sortedResults = useMemo(() => { + const arr = [...results]; + if (sort === 'date') + arr.sort((a, b) => a.startDateTime.localeCompare(b.startDateTime)); + else if (sort === 'name') + arr.sort((a, b) => a.name.localeCompare(b.name, 'pl')); + else arr.sort((a, b) => a.city.localeCompare(b.city, 'pl')); + return arr; + }, [results, sort]); + + const handleClearField = (k: keyof SearchState) => + setSearch({ ...search, [k]: '' }); + + const handleClearAll = () => { + setSearch(EMPTY_SEARCH); + ctx.trigger('[TRIGGER]_SEARCH', {}); + }; + + return ( +
+
+ WYNIKI WYSZUKIWANIA + + {isLoading ? ( + 'Szukam…' + ) : ( + <> + {results.length}{' '} + {plural(results.length, 'wydarzenie', 'wydarzenia', 'wydarzeΕ„')} + {search.city && ( + <> + {' '} + w {search.city} + + )} + {search.category && <> Β· {categoryLabel(search.category)}} + + )} + +
+ +
+ +
+ +
+ setSearch({ ...search, category: v })} + /> +
+
+ + +
+
+ Sortuj: + {(['date', 'name', 'city'] as const).map((k) => ( + + ))} +
+
+
+ + +
+ ); +}; diff --git a/apps/event-app/src/modules/event-discovery/presentation/search-bar.tsx b/apps/event-app/src/modules/event-discovery/presentation/search-bar.tsx new file mode 100644 index 0000000..88134c7 --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/presentation/search-bar.tsx @@ -0,0 +1,315 @@ +import { useRef, useState, useEffect } from 'react'; +import { Text } from '@/libs/ui/text'; +import { IconSearch, IconCaret, IconClose } from './icons'; +import { + CATEGORIES, + CITIES, + categoryLabel, + datePresetLabel, +} from './mock-data'; + +export type SearchState = { + name: string; + category: string; + city: string; + date: string; +}; + +type SearchBarVariant = 'hero' | 'compact'; + +type SearchBarProps = { + value: SearchState; + onChange: (v: SearchState) => void; + onSubmit: () => void; + variant?: SearchBarVariant; +}; + +type DropdownProps = { + children: React.ReactNode; +}; + +const Dropdown = ({ children }: DropdownProps) => ( +
e.stopPropagation()} + > + {children} +
+); + +type DropdownItemProps = { + children: React.ReactNode; + active: boolean; + onClick: () => void; +}; + +const DropdownItem = ({ children, active, onClick }: DropdownItemProps) => ( + +); + +export const SearchBar = ({ + value, + onChange, + onSubmit, + variant = 'hero', +}: SearchBarProps) => { + const [openField, setOpenField] = useState(null); + const wrapRef = useRef(null); + const nameInputRef = useRef(null); + const compact = variant === 'compact'; + + const handleSubmit = (e: React.SubmitEvent) => { + e.preventDefault(); + onSubmit(); + }; + + useEffect(() => { + const onDocClick = (e: MouseEvent) => { + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) + setOpenField(null); + }; + document.addEventListener('mousedown', onDocClick); + return () => document.removeEventListener('mousedown', onDocClick); + }, []); + + const setField = (k: keyof SearchState, v: string) => + onChange({ ...value, [k]: v }); + const fieldPad = compact ? 'px-4 py-2.5' : 'px-[18px] py-3.5'; + const inputSize = compact ? 'text-sm' : 'text-base'; + const submitMinH = compact ? 'min-h-11' : 'min-h-14'; + + return ( +
+
+ {/* Name */} +
{ + setOpenField('name'); + nameInputRef.current?.focus(); + }} + > + Wydarzenie + setField('name', e.target.value)} + onFocus={() => setOpenField('name')} + onKeyDown={(e) => { + if (e.key === 'Enter') onSubmit(); + }} + /> +
+ +
+ + {/* Category */} +
setOpenField('category')} + > + Kategoria + + {openField === 'category' && ( + + { + setField('category', ''); + setOpenField(null); + }} + > + Wszystkie kategorie + + {CATEGORIES.map((c) => ( + { + setField('category', c.id); + setOpenField(null); + }} + > + {c.label} + + {c.mono} + + + ))} + + )} +
+ +
+ + {/* Location */} +
setOpenField('city')} + > + Lokalizacja + + {openField === 'city' && ( + + { + setField('city', ''); + setOpenField(null); + }} + > + CaΕ‚a Polska + + {CITIES.map((c) => ( + { + setField('city', c); + setOpenField(null); + }} + > + {c} + + ))} + + )} +
+ +
+ + {/* Date */} +
setOpenField('date')} + > + Data + + {openField === 'date' && ( + + {( + ['', 'today', 'weekend', 'week', 'month', 'summer'] as const + ).map((k) => ( + { + setField('date', k); + setOpenField(null); + }} + > + {k ? datePresetLabel(k) : 'Dowolna data'} + + ))} + + )} +
+ + {/* Submit */} + + +
+ ); +}; + +type ActiveFilter = { k: keyof SearchState; label: string }; + +type ActiveFiltersProps = { + search: SearchState; + onClear: (k: keyof SearchState) => void; + onClearAll: () => void; +}; + +export const ActiveFilters = ({ + search, + onClear, + onClearAll, +}: ActiveFiltersProps) => { + const active: ActiveFilter[] = []; + if (search.name) active.push({ k: 'name', label: `β€ž${search.name}"` }); + if (search.category) + active.push({ k: 'category', label: categoryLabel(search.category) }); + if (search.city) active.push({ k: 'city', label: search.city }); + if (search.date) + active.push({ k: 'date', label: datePresetLabel(search.date) }); + + if (active.length === 0) return null; + + return ( +
+ FILTRY + {active.map((f) => ( + + ))} + +
+ ); +}; diff --git a/apps/event-app/src/modules/event-discovery/presentation/skeleton-card.tsx b/apps/event-app/src/modules/event-discovery/presentation/skeleton-card.tsx new file mode 100644 index 0000000..65d01b9 --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/presentation/skeleton-card.tsx @@ -0,0 +1,49 @@ +import { cn } from '@/libs/ui/cn'; + +type SkeletonCardLayout = 'grid' | 'list'; + +type SkeletonCardProps = { + layout?: SkeletonCardLayout; +}; + +const block = (className?: string) => + cn('animate-pulse rounded bg-hairline/60', className); + +export const SkeletonCard = ({ layout = 'grid' }: SkeletonCardProps) => { + if (layout === 'list') { + return ( + + ); + } + + return ( + + ); +}; diff --git a/apps/event-app/src/modules/event-discovery/presentation/skeleton-details.tsx b/apps/event-app/src/modules/event-discovery/presentation/skeleton-details.tsx new file mode 100644 index 0000000..c4003ac --- /dev/null +++ b/apps/event-app/src/modules/event-discovery/presentation/skeleton-details.tsx @@ -0,0 +1,75 @@ +import { cn } from '@/libs/ui/cn'; + +const block = (className?: string) => + cn('animate-pulse rounded bg-hairline/60', className); + +export const SkeletonDetails = () => ( + +); diff --git a/apps/event-app/src/modules/event-management/contracts/events.ts b/apps/event-app/src/modules/event-management/contracts/events.ts new file mode 100644 index 0000000..4f354b5 --- /dev/null +++ b/apps/event-app/src/modules/event-management/contracts/events.ts @@ -0,0 +1,15 @@ +import { type TriggerEvent } from '@/libs/eda'; +import type { CreateEventData } from './models'; + +export type Event = + | TriggerEvent<'[TRIGGER]_GEOCODE_ADDRESS', { query: string }> + | TriggerEvent< + '[TRIGGER]_SUGGEST_KEYWORDS', + { name: string; description?: string; category?: string } + > + | TriggerEvent<'[TRIGGER]_SUBMIT_CREATE_EVENT', { data: CreateEventData }> + | TriggerEvent<'[TRIGGER]_SET_KEYWORD_INPUT', { value: string }> + | TriggerEvent<'[TRIGGER]_ADD_KEYWORD', { keyword: string }> + | TriggerEvent<'[TRIGGER]_REMOVE_KEYWORD', { keyword: string }> + | TriggerEvent<'[TRIGGER]_ACCEPT_SUGGESTION', { keyword: string }> + | TriggerEvent<'[TRIGGER]_DISMISS_SUGGESTION', { keyword: string }>; diff --git a/apps/event-app/src/modules/event-management/contracts/models.ts b/apps/event-app/src/modules/event-management/contracts/models.ts new file mode 100644 index 0000000..b232cb6 --- /dev/null +++ b/apps/event-app/src/modules/event-management/contracts/models.ts @@ -0,0 +1,34 @@ +export type EventId = string; + +export type EventCategory = + | 'Concert' + | 'Festival' + | 'Sports' + | 'Culture' + | 'Theatre' + | 'Food & Drink'; + +export type Coordinates = { lat: number; lng: number }; + +export type EventAddress = { + street: string; + number: string; + postalCode: string; + city: string; +}; + +export type GeoStatus = 'idle' | 'loading' | 'success' | 'error'; + +export type CreateEventData = { + name: string; + description?: string; + category: EventCategory; + startDateTime: string; + endDateTime?: string; + address: EventAddress; + coordinates: Coordinates; + externalLink?: string; + imageUrl?: string; + keywords: string[]; + organizerInfo?: string; +}; diff --git a/apps/event-app/src/modules/event-management/core/handlers/accept-suggestion.ts b/apps/event-app/src/modules/event-management/core/handlers/accept-suggestion.ts new file mode 100644 index 0000000..bb65256 --- /dev/null +++ b/apps/event-app/src/modules/event-management/core/handlers/accept-suggestion.ts @@ -0,0 +1,17 @@ +import { tap } from 'rxjs'; +import type { OfType } from '../registry'; +import type { Store } from '../store'; + +export const acceptSuggestionHandler = (store: Store, ofType: OfType) => + ofType('[TRIGGER]_ACCEPT_SUGGESTION').pipe( + tap(({ keyword }) => { + const trimmed = keyword.trim(); + const existing = store.$keywords.get(); + if (trimmed && !existing.includes(trimmed)) { + store.$keywords.set([...existing, trimmed]); + } + store.$aiSuggestions.set( + store.$aiSuggestions.get().filter((s) => s !== keyword), + ); + }), + ); diff --git a/apps/event-app/src/modules/event-management/core/handlers/add-keyword.ts b/apps/event-app/src/modules/event-management/core/handlers/add-keyword.ts new file mode 100644 index 0000000..d0a2603 --- /dev/null +++ b/apps/event-app/src/modules/event-management/core/handlers/add-keyword.ts @@ -0,0 +1,15 @@ +import { tap } from 'rxjs'; +import type { OfType } from '../registry'; +import type { Store } from '../store'; + +export const addKeywordHandler = (store: Store, ofType: OfType) => + ofType('[TRIGGER]_ADD_KEYWORD').pipe( + tap(({ keyword }) => { + const trimmed = keyword.trim(); + const existing = store.$keywords.get(); + if (trimmed && !existing.includes(trimmed)) { + store.$keywords.set([...existing, trimmed]); + } + store.$keywordInput.reset(); + }), + ); diff --git a/apps/event-app/src/modules/event-management/core/handlers/dismiss-suggestion.ts b/apps/event-app/src/modules/event-management/core/handlers/dismiss-suggestion.ts new file mode 100644 index 0000000..baf079c --- /dev/null +++ b/apps/event-app/src/modules/event-management/core/handlers/dismiss-suggestion.ts @@ -0,0 +1,12 @@ +import { tap } from 'rxjs'; +import type { OfType } from '../registry'; +import type { Store } from '../store'; + +export const dismissSuggestionHandler = (store: Store, ofType: OfType) => + ofType('[TRIGGER]_DISMISS_SUGGESTION').pipe( + tap(({ keyword }) => { + store.$aiSuggestions.set( + store.$aiSuggestions.get().filter((s) => s !== keyword), + ); + }), + ); diff --git a/apps/event-app/src/modules/event-management/core/handlers/geocode-address.ts b/apps/event-app/src/modules/event-management/core/handlers/geocode-address.ts new file mode 100644 index 0000000..47589d3 --- /dev/null +++ b/apps/event-app/src/modules/event-management/core/handlers/geocode-address.ts @@ -0,0 +1,33 @@ +import { catchError, EMPTY, finalize, from, switchMap, tap } from 'rxjs'; +import { geocodeAddress } from '../../integration/repository'; +import type { OfType } from '../registry'; +import type { Store } from '../store'; + +export const geocodeAddressHandler = (store: Store, ofType: OfType) => + ofType('[TRIGGER]_GEOCODE_ADDRESS').pipe( + tap(() => { + store.$geoStatus.set('loading'); + store.$coordinates.set(null); + }), + switchMap(({ query }) => { + const ctrl = new AbortController(); + + return from(geocodeAddress(query, ctrl.signal)).pipe( + tap((result) => { + if (result) { + store.$coordinates.set(result); + store.$geoStatus.set('success'); + } else { + store.$geoStatus.set('error'); + } + }), + catchError((error) => { + if (error instanceof DOMException && error.name === 'AbortError') + return EMPTY; + store.$geoStatus.set('error'); + return EMPTY; + }), + finalize(() => ctrl.abort()), + ); + }), + ); diff --git a/apps/event-app/src/modules/event-management/core/handlers/remove-keyword.ts b/apps/event-app/src/modules/event-management/core/handlers/remove-keyword.ts new file mode 100644 index 0000000..850da6d --- /dev/null +++ b/apps/event-app/src/modules/event-management/core/handlers/remove-keyword.ts @@ -0,0 +1,10 @@ +import { tap } from 'rxjs'; +import type { OfType } from '../registry'; +import type { Store } from '../store'; + +export const removeKeywordHandler = (store: Store, ofType: OfType) => + ofType('[TRIGGER]_REMOVE_KEYWORD').pipe( + tap(({ keyword }) => { + store.$keywords.set(store.$keywords.get().filter((k) => k !== keyword)); + }), + ); diff --git a/apps/event-app/src/modules/event-management/core/handlers/set-keyword-input.ts b/apps/event-app/src/modules/event-management/core/handlers/set-keyword-input.ts new file mode 100644 index 0000000..29a12b0 --- /dev/null +++ b/apps/event-app/src/modules/event-management/core/handlers/set-keyword-input.ts @@ -0,0 +1,8 @@ +import { tap } from 'rxjs'; +import type { OfType } from '../registry'; +import type { Store } from '../store'; + +export const setKeywordInputHandler = (store: Store, ofType: OfType) => + ofType('[TRIGGER]_SET_KEYWORD_INPUT').pipe( + tap(({ value }) => store.$keywordInput.set(value)), + ); diff --git a/apps/event-app/src/modules/event-management/core/handlers/submit-create-event.ts b/apps/event-app/src/modules/event-management/core/handlers/submit-create-event.ts new file mode 100644 index 0000000..d012c12 --- /dev/null +++ b/apps/event-app/src/modules/event-management/core/handlers/submit-create-event.ts @@ -0,0 +1,34 @@ +import { catchError, EMPTY, exhaustMap, finalize, from, tap } from 'rxjs'; +import { createEvent } from '../../integration/repository'; +import type { OfType } from '../registry'; +import type { Store } from '../store'; + +export const submitCreateEventHandler = (store: Store, ofType: OfType) => + ofType('[TRIGGER]_SUBMIT_CREATE_EVENT').pipe( + tap(() => { + store.$isSubmitting.set(true); + store.$submitError.reset(); + store.$submitSuccess.set(null); + }), + exhaustMap(({ data }) => { + const ctrl = new AbortController(); + + return from(createEvent(data, ctrl.signal)).pipe( + tap(() => + store.$submitSuccess.set('Udało się utworzyć nowe wydarzenie'), + ), + catchError((error) => { + if (error instanceof DOMException && error.name === 'AbortError') + return EMPTY; + store.$submitError.set( + error instanceof Error ? error.message : 'Coś poszło nie tak.', + ); + return EMPTY; + }), + finalize(() => { + store.$isSubmitting.set(false); + ctrl.abort(); + }), + ); + }), + ); diff --git a/apps/event-app/src/modules/event-management/core/handlers/suggest-keywords.ts b/apps/event-app/src/modules/event-management/core/handlers/suggest-keywords.ts new file mode 100644 index 0000000..3582c8f --- /dev/null +++ b/apps/event-app/src/modules/event-management/core/handlers/suggest-keywords.ts @@ -0,0 +1,34 @@ +import { catchError, EMPTY, exhaustMap, finalize, from, tap } from 'rxjs'; +import { suggestKeywords } from '../../integration/repository'; +import type { OfType } from '../registry'; +import type { Store } from '../store'; + +export const suggestKeywordsHandler = (store: Store, ofType: OfType) => + ofType('[TRIGGER]_SUGGEST_KEYWORDS').pipe( + tap(() => store.$isSuggesting.set(true)), + exhaustMap(({ name, description, category }) => { + const ctrl = new AbortController(); + + return from( + suggestKeywords({ name, description, category }, ctrl.signal), + ).pipe( + tap((result) => { + if (result.code === 200 && 'keywords' in result) { + const existing = store.$keywords.get(); + store.$aiSuggestions.set( + result.keywords.filter((k) => !existing.includes(k)), + ); + } + }), + catchError((error) => { + if (error instanceof DOMException && error.name === 'AbortError') + return EMPTY; + return EMPTY; + }), + finalize(() => { + store.$isSuggesting.set(false); + ctrl.abort(); + }), + ); + }), + ); diff --git a/apps/event-app/src/modules/event-management/core/mediator.ts b/apps/event-app/src/modules/event-management/core/mediator.ts new file mode 100644 index 0000000..49231c3 --- /dev/null +++ b/apps/event-app/src/modules/event-management/core/mediator.ts @@ -0,0 +1,9 @@ +import { createRegistry } from './registry'; +import { createStore } from './store'; + +export const createMediator = () => { + const store = createStore(); + const { trigger, registry } = createRegistry(store); + + return [store, trigger, registry] as const; +}; diff --git a/apps/event-app/src/modules/event-management/core/registry.ts b/apps/event-app/src/modules/event-management/core/registry.ts new file mode 100644 index 0000000..8e0b69f --- /dev/null +++ b/apps/event-app/src/modules/event-management/core/registry.ts @@ -0,0 +1,32 @@ +import { eda } from '@/libs/eda'; +import { type Store } from './store'; +import { type Event } from '../contracts/events'; +import { geocodeAddressHandler } from './handlers/geocode-address'; +import { suggestKeywordsHandler } from './handlers/suggest-keywords'; +import { submitCreateEventHandler } from './handlers/submit-create-event'; +import { addKeywordHandler } from './handlers/add-keyword'; +import { removeKeywordHandler } from './handlers/remove-keyword'; +import { acceptSuggestionHandler } from './handlers/accept-suggestion'; +import { dismissSuggestionHandler } from './handlers/dismiss-suggestion'; +import { setKeywordInputHandler } from './handlers/set-keyword-input'; + +export type OfType = ReturnType>['ofType']; + +export const createRegistry = (store: Store) => { + const { ofType, trigger, createRegistry: register } = eda(); + + const registry = register( + geocodeAddressHandler(store, ofType), + suggestKeywordsHandler(store, ofType), + submitCreateEventHandler(store, ofType), + addKeywordHandler(store, ofType), + removeKeywordHandler(store, ofType), + acceptSuggestionHandler(store, ofType), + dismissSuggestionHandler(store, ofType), + setKeywordInputHandler(store, ofType), + ); + + return { trigger, registry }; +}; + +export type Registry = ReturnType; diff --git a/apps/event-app/src/modules/event-management/core/store.ts b/apps/event-app/src/modules/event-management/core/store.ts new file mode 100644 index 0000000..d6535c1 --- /dev/null +++ b/apps/event-app/src/modules/event-management/core/store.ts @@ -0,0 +1,28 @@ +import { atom } from '@/libs/supa-store'; +import type { Coordinates, GeoStatus } from '../contracts/models'; + +export const createStore = () => { + const $coordinates = atom(null); + const $geoStatus = atom('idle'); + const $keywords = atom([]); + const $keywordInput = atom(''); + const $aiSuggestions = atom([]); + const $isSuggesting = atom(false); + const $isSubmitting = atom(false); + const $submitError = atom(null); + const $submitSuccess = atom(null); + + return { + $coordinates, + $geoStatus, + $keywords, + $keywordInput, + $aiSuggestions, + $isSuggesting, + $isSubmitting, + $submitError, + $submitSuccess, + }; +}; + +export type Store = ReturnType; diff --git a/apps/event-app/src/modules/event-management/feature.md b/apps/event-app/src/modules/event-management/feature.md new file mode 100644 index 0000000..22521f4 --- /dev/null +++ b/apps/event-app/src/modules/event-management/feature.md @@ -0,0 +1,66 @@ +# Event Management + +## Refs + +1. [Application Domains](../../../documentation/DOMAINS.md) + +## Dictionary + +- **[event_aggregate]** - The core event entity composed of: name, description, category, start_date_time, end_date_time (optional), address (street, number, postal_code, city), coordinates (lat/lng), external_link, image (optional), keywords, and organizer_info +- **[event_owner]** - The [authenticated_user] who originally created a given event +- **[authenticated_user]** - A user who has completed login via any supported auth provider; can create events +- **[admin]** - A privileged role that can update and delete any event regardless of ownership +- **[category]** - One of the fixed taxonomy values: Concert, Festival, Sports, Culture, Theatre, Food & Drink +- **[keyword]** - A descriptive tag attached to an event; entered manually or accepted from AI-assisted suggestions +- **[geocoding]** - The automatic resolution of a selected city/address into lat/lng coordinates +- **[organizer_info]** - Data identifying the person or organization responsible for an event + +## Constraints + +- [event_aggregate] create: `` +- [event_aggregate] update/delete own: `` +- [event_aggregate] update/delete any: `` +- [event_aggregate] fields: ``, ``, ``, ``, `` +- [category] values: `` +- [keyword] source: ``, `` +- coordinates: `` + +## DoD + +Authenticated users can create, edit, and delete events with all required fields, location autocomplete with automatic geocoding, category selection, and manual or AI-assisted keyword tagging; event owners manage their own events while admins can manage any event. + +### Event Creation + +1. An [authenticated_user] can open an event creation form and submit a new [event_aggregate]. + 1a. Form collects: name (required), description, [category] (required), start_date_time (required), end_date_time (optional), address with autocomplete, external_link, image (optional), [keyword] list, [organizer_info]. + 1b. On successful submit the submitting user becomes the [event_owner] of the new event. +2. Submitting the form with any required field missing displays a field-level validation error and does not persist the event. +3. A successfully created event is immediately visible in the Event Discovery domain. + +### Event Editing + +1. An [event_owner] can open the edit form for their own event and update any field. + 1a. Changes are saved atomically; partial saves are not permitted. +2. An [admin] can open the edit form for any event and update any field. +3. A user who is neither the [event_owner] nor an [admin] has no access to the edit form for that event. + +### Event Deletion + +1. An [event_owner] can delete their own event. + 1a. Deletion requires explicit confirmation before the event is permanently removed. +2. An [admin] can delete any event. +3. A user who is neither the [event_owner] nor an [admin] is not presented with a delete option for that event. + +### Location Input & Geocoding + +1. The address field provides city/address autocomplete; after the user selects a suggestion, the city field and coordinates are populated automatically via [geocoding]. +2. Coordinates (lat/lng) are never entered manually — they are always derived via [geocoding] from the selected address. +3. If [geocoding] returns no result, the form shows an inline error and blocks submission until a valid address with resolved coordinates is selected. + +### Category & Keyword Tagging + +1. The [category] field renders as a dropdown containing exactly the six taxonomy values defined by ``. +2. [keyword] tags can be added manually by typing free text into the keyword input. +3. The form offers AI-assisted [keyword] suggestions generated via LLM; the user can accept or dismiss each suggestion individually. + 3a. Accepting a suggestion appends it to the [keyword] list; dismissing it removes it from the suggestion panel without adding it. +4. The [keyword] field is optional; an [event_aggregate] can be saved with zero keywords. diff --git a/apps/event-app/src/modules/event-management/integration/repository.ts b/apps/event-app/src/modules/event-management/integration/repository.ts new file mode 100644 index 0000000..77817de --- /dev/null +++ b/apps/event-app/src/modules/event-management/integration/repository.ts @@ -0,0 +1,62 @@ +import type { Schema as CreateEventSchema } from '@/shared/server-contracts/schemas/create-event'; +import type { Schema as GeocodeSchema } from '@/shared/server-contracts/schemas/geocode-address'; +import type { Schema as SuggestKeywordsSchema } from '@/shared/server-contracts/schemas/suggest-event-keywords'; +import type { InferOut } from '@/shared/server-contracts/extraction'; + +export type CreateEventInput = CreateEventSchema['in']; + +export const createEvent = async ( + data: CreateEventInput, + signal: AbortSignal, +): Promise => { + const res = await fetch('/api/event/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + signal, + }); + if (!res.ok) { + const body = await res.json().catch(() => null); + const message = + body?.message ?? 'Coś poszło nie tak. Spróbuj ponownie później.'; + throw new Error(message); + } +}; + +export const suggestKeywords = async ( + data: { name: string; description?: string; category?: string }, + signal: AbortSignal, +): Promise> => { + const res = await fetch('/api/event/suggest-keywords', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + signal, + }); + if (!res.ok) { + throw new Error('Coś poszło nie tak. Spróbuj ponownie później.'); + } + + return await res.json(); +}; + +export const geocodeAddress = async ( + query: string, + signal: AbortSignal, +): Promise<{ lat: number; lng: number } | null> => { + const res = await fetch(`/api/event/geocode?q=${encodeURIComponent(query)}`, { + signal, + }); + + if (!res.ok) { + throw new Error('Coś poszło nie tak. Spróbuj ponownie później.'); + } + + const data = (await res.json()) as InferOut; + + if (data.code !== 200) { + throw new Error('Coś poszło nie tak. Spróbuj ponownie później.'); + } + + return { lat: data.lat, lng: data.lng }; +}; diff --git a/apps/event-app/src/modules/event-management/presentation/context.tsx b/apps/event-app/src/modules/event-management/presentation/context.tsx new file mode 100644 index 0000000..0edc005 --- /dev/null +++ b/apps/event-app/src/modules/event-management/presentation/context.tsx @@ -0,0 +1,18 @@ +import { useLayoutEffect, useState } from 'react'; +import { createHookContext } from '@/libs/power-context'; +import { createMediator } from '../core/mediator'; + +export const [Provider, useContext] = createHookContext( + 'EventManagement', + () => { + const [store, trigger, registry] = useState(createMediator)[0]; + const value = useState(() => ({ ...store, trigger }))[0]; + + useLayoutEffect(() => { + const unsub = registry(); + return () => unsub(); + }, [registry]); + + return value; + }, +); diff --git a/apps/event-app/src/modules/event-management/presentation/create-event-form.tsx b/apps/event-app/src/modules/event-management/presentation/create-event-form.tsx new file mode 100644 index 0000000..b2a9649 --- /dev/null +++ b/apps/event-app/src/modules/event-management/presentation/create-event-form.tsx @@ -0,0 +1,165 @@ +import { useEffect, useCallback } from 'react'; +import { useForm, useWatch, FormProvider } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { toast, Toaster } from 'sonner'; +import { Logo } from '@/libs/ui/logo'; +import { formSchema, type FormValues } from './create-event/schema'; +import { useContext } from './context'; +import { FormSidebar } from './create-event/sidebar'; +import { BasicSection } from './create-event/sections/basic'; +import { DatesSection } from './create-event/sections/dates'; +import { LocationSection } from './create-event/sections/location'; +import { DetailsSection } from './create-event/sections/details'; +import { KeywordsSection } from './create-event/sections/keywords'; + +export const CreateEventForm = () => { + const methods = useForm({ + resolver: zodResolver(formSchema), + }); + const { + control, + register, + handleSubmit, + getValues, + formState: { errors }, + } = methods; + + const { + $coordinates, + $geoStatus, + $keywords, + $isSubmitting, + $submitError, + $submitSuccess, + trigger, + } = useContext(); + const coordinates = $coordinates.use(); + const geoStatus = $geoStatus.use(); + const isSubmitting = $isSubmitting.use(); + const submitError = $submitError.use(); + const submitSuccess = $submitSuccess.use(); + + const handleGeocode = useCallback(() => { + const { street, number, postalCode, city } = getValues('address'); + if (!street || !number || !city) return; + trigger('[TRIGGER]_GEOCODE_ADDRESS', { + query: `${street} ${number}, ${postalCode} ${city}`, + }); + }, [getValues, trigger]); + + const name = useWatch({ control, name: 'name' }); + const category = useWatch({ control, name: 'category' }); + const startDateTime = useWatch({ control, name: 'startDateTime' }); + + const sectionDone: Record = { + basic: !!(name && category), + dates: !!startDateTime, + location: geoStatus === 'success', + details: true, + keywords: true, + }; + + useEffect(() => { + if (submitSuccess) { + toast.success(submitSuccess); + window.location.assign('/'); + } + }, [submitSuccess]); + + useEffect(() => { + if (submitError) toast.error(submitError); + }, [submitError]); + + const onSubmit = (data: FormValues) => { + if (!coordinates) { + toast.error('Uzupełnij adres — lokalizacja jest wymagana.'); + return; + } + trigger('[TRIGGER]_SUBMIT_CREATE_EVENT', { + data: { + name: data.name, + description: data.description || undefined, + category: data.category, + startDateTime: new Date(data.startDateTime).toISOString(), + endDateTime: data.endDateTime + ? new Date(data.endDateTime).toISOString() + : undefined, + address: data.address, + coordinates, + externalLink: data.externalLink || undefined, + imageUrl: data.imageUrl || undefined, + keywords: $keywords.get(), + organizerInfo: data.organizerInfo || undefined, + }, + }); + }; + + return ( + <> + + +
+
+ +
+ +
+
+ + +
+
+

Dodaj wydarzenie

+

+ Opublikuj wydarzenie β€” bΔ™dzie widoczne dla wszystkich + uΕΌytkownikΓ³w Afisz. +

+
+ + +
+ + + + + + +
+ +

+ Wydarzenie zostanie opublikowane natychmiast i bΔ™dzie + widoczne publicznie. +

+
+ +
+
+
+
+
+ + ); +}; diff --git a/apps/event-app/src/modules/event-management/presentation/create-event/date-time-picker.css b/apps/event-app/src/modules/event-management/presentation/create-event/date-time-picker.css new file mode 100644 index 0000000..649b04c --- /dev/null +++ b/apps/event-app/src/modules/event-management/presentation/create-event/date-time-picker.css @@ -0,0 +1,82 @@ +.dp-wrapper { + --rdp-accent-color: var(--color-primary); + --rdp-accent-background-color: var(--color-surface); + --rdp-day_button-border-radius: var(--radius-xs); + --rdp-day-height: 34px; + --rdp-day-width: 34px; + --rdp-day_button-height: 32px; + --rdp-day_button-width: 32px; + --rdp-nav_button-height: 2rem; + --rdp-nav_button-width: 2rem; + --rdp-nav-height: 2.25rem; + --rdp-today-color: var(--color-coral); +} + +/* Selected: filled dark background instead of just a border */ +.dp-wrapper .rdp-selected { + font-weight: 500; + font-size: inherit; +} + +.dp-wrapper .rdp-selected .rdp-day_button { + background-color: var(--color-primary); + color: var(--color-on-primary); + border-color: var(--color-primary); +} + +.rdp-selected .rdp-day_button:hover { + color: var(--color-on-ink); +} + +/* Hover */ +.dp-wrapper .rdp-day_button:hover:not(:disabled) { + background-color: var(--color-surface); +} + +/* Navigation chevron */ +.dp-wrapper .rdp-chevron { + fill: var(--color-muted); +} + +/* Nav buttons */ +.dp-wrapper .rdp-button_previous, +.dp-wrapper .rdp-button_next { + border-radius: var(--radius-xs); + transition: background-color 0.15s; +} + +.dp-wrapper .rdp-button_previous:hover, +.dp-wrapper .rdp-button_next:hover { + background-color: var(--color-surface); +} + +/* Month caption */ +.dp-wrapper .rdp-month_caption { + font-size: 0.875rem; + font-weight: 500; + color: var(--color-ink); +} + +.dp-wrapper .rdp-caption_label { + text-transform: capitalize; +} + +/* Weekday headers */ +.dp-wrapper .rdp-weekday { + font-size: 0.75rem; + font-weight: 500; + color: var(--color-muted); + opacity: 1; +} + +/* Day buttons base */ +.dp-wrapper .rdp-day_button { + font-size: 0.8125rem; + color: var(--color-ink); + transition: background-color 0.1s; +} + +/* Outside days */ +.dp-wrapper .rdp-outside .rdp-day_button { + color: var(--color-muted); +} diff --git a/apps/event-app/src/modules/event-management/presentation/create-event/date-time-picker.tsx b/apps/event-app/src/modules/event-management/presentation/create-event/date-time-picker.tsx new file mode 100644 index 0000000..160d894 --- /dev/null +++ b/apps/event-app/src/modules/event-management/presentation/create-event/date-time-picker.tsx @@ -0,0 +1,104 @@ +import { useRef, useState, useEffect } from 'react'; +import { DayPicker } from 'react-day-picker'; +import { format, isValid } from 'date-fns'; +import { pl } from 'date-fns/locale'; +import 'react-day-picker/style.css'; +import './date-time-picker.css'; +import { cn } from '@/libs/ui/cn'; +import { inputCls } from './field'; + +// ── Types ────────────────────────────────────────────────────────────────────── + +type Props = { + value?: string; + onChange: (iso: string) => void; + onBlur?: () => void; + hasError?: boolean; + id?: string; +}; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +const toDisplay = (iso: string) => { + const d = new Date(iso); + return isValid(d) ? format(d, 'dd/MM/yyyy HH:mm') : ''; +}; + +const buildIso = (day: Date, time: string): string => { + const [hh = '00', mm = '00'] = time.split(':'); + const d = new Date(day); + d.setHours(Number(hh), Number(mm), 0, 0); + return d.toISOString(); +}; + +// ── Component ────────────────────────────────────────────────────────────────── + +export const DateTimePicker = ({ + value, + onChange, + onBlur, + hasError, + id, +}: Props) => { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + const selectedDate = + value && isValid(new Date(value)) ? new Date(value) : undefined; + const timeValue = selectedDate ? format(selectedDate, 'HH:mm') : '00:00'; + + useEffect(() => { + const onOutside = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) + setOpen(false); + }; + document.addEventListener('mousedown', onOutside); + return () => document.removeEventListener('mousedown', onOutside); + }, []); + + const handleDaySelect = (day: Date | undefined) => { + if (!day) return; + onChange(buildIso(day, timeValue)); + }; + + const handleTimeChange = (e: React.ChangeEvent) => { + const base = selectedDate ?? new Date(); + onChange(buildIso(base, e.target.value)); + }; + + return ( +
+ setOpen((v) => !v)} + onBlur={onBlur} + className={cn(inputCls(hasError), 'cursor-pointer')} + /> + + {open && ( +
+ + +
+ Godzina: + +
+
+ )} +
+ ); +}; diff --git a/apps/event-app/src/modules/event-management/presentation/create-event/field.tsx b/apps/event-app/src/modules/event-management/presentation/create-event/field.tsx new file mode 100644 index 0000000..8b745cb --- /dev/null +++ b/apps/event-app/src/modules/event-management/presentation/create-event/field.tsx @@ -0,0 +1,44 @@ +import { type ReactNode } from 'react'; +import { cn } from '@/libs/ui/cn'; + +// ── Utilities ────────────────────────────────────────────────────────────────── + +export const inputCls = (hasError?: boolean) => + cn( + 'w-full rounded-xs border bg-canvas px-4 py-3 text-sm text-ink placeholder:text-muted', + 'outline-none transition-colors', + hasError + ? 'border-coral/60 focus:border-coral' + : 'border-hairline focus:border-primary', + ); + +// ── Component ────────────────────────────────────────────────────────────────── + +export type FieldProps = { + label: string; + error?: string; + htmlFor?: string; + optional?: boolean; + children: ReactNode; +}; + +export const Field = ({ + label, + error, + htmlFor, + optional, + children, +}: FieldProps) => ( +
+ + {children} + {error &&

{error}

} +
+); diff --git a/apps/event-app/src/modules/event-management/presentation/create-event/schema.ts b/apps/event-app/src/modules/event-management/presentation/create-event/schema.ts new file mode 100644 index 0000000..e926938 --- /dev/null +++ b/apps/event-app/src/modules/event-management/presentation/create-event/schema.ts @@ -0,0 +1,63 @@ +import { z } from 'zod'; +import { categorySchema } from '@/shared/server-contracts/schemas/create-event'; + +// ── Constants ────────────────────────────────────────────────────────────────── + +type CategoryValue = (typeof categorySchema.options)[number]; + +const CATEGORY_LABELS: Record = { + Concert: 'Koncert', + Festival: 'Festiwal', + Sports: 'Sport', + Culture: 'Kultura', + Theatre: 'Teatr', + 'Food & Drink': 'Jedzenie i napoje', +}; + +export const CATEGORIES = categorySchema.options.map((value) => ({ + value, + label: CATEGORY_LABELS[value], +})); + +export type Section = { + id: string; + label: string; + num: string; + optional: boolean; +}; + +export const SECTIONS: readonly Section[] = [ + { id: 'basic', label: 'Podstawowe info', num: '01', optional: false }, + { id: 'dates', label: 'Termin', num: '02', optional: false }, + { id: 'location', label: 'Lokalizacja', num: '03', optional: false }, + { id: 'details', label: 'SzczegΓ³Ε‚y', num: '04', optional: true }, + { id: 'keywords', label: 'SΕ‚owa kluczowe', num: '05', optional: true }, +]; + +// ── Schema ───────────────────────────────────────────────────────────────────── + +export const formSchema = z.object({ + name: z.string().min(1, 'Nazwa jest wymagana'), + description: z.string().optional(), + category: categorySchema, + startDateTime: z.string().min(1, 'Data rozpoczΔ™cia jest wymagana'), + endDateTime: z.string().optional(), + address: z.object({ + street: z.string().min(1, 'Ulica jest wymagana'), + number: z.string().min(1, 'Numer jest wymagany'), + postalCode: z.string().min(1, 'Kod pocztowy jest wymagany'), + city: z.string().min(1, 'Miasto jest wymagane'), + }), + externalLink: z + .union([z.string().url('Podaj prawidΕ‚owy URL'), z.literal('')]) + .optional(), + imageUrl: z + .union([z.string().url('Podaj prawidΕ‚owy URL'), z.literal('')]) + .optional(), + organizerInfo: z.string().optional(), +}); + +// ── Types ────────────────────────────────────────────────────────────────────── + +export type FormValues = z.infer; +export type GeoStatus = 'idle' | 'loading' | 'success' | 'error'; diff --git a/apps/event-app/src/modules/event-management/presentation/create-event/sections/basic.tsx b/apps/event-app/src/modules/event-management/presentation/create-event/sections/basic.tsx new file mode 100644 index 0000000..26615ae --- /dev/null +++ b/apps/event-app/src/modules/event-management/presentation/create-event/sections/basic.tsx @@ -0,0 +1,71 @@ +import { type UseFormRegister, type FieldErrors } from 'react-hook-form'; +import { ChevronDown } from 'lucide-react'; +import { cn } from '@/libs/ui/cn'; +import { CATEGORIES, type FormValues } from '../schema'; +import { Field, inputCls } from '../field'; + +// ── Types ────────────────────────────────────────────────────────────────────── + +type Props = { + register: UseFormRegister; + errors: FieldErrors; +}; + +// ── Component ────────────────────────────────────────────────────────────────── + +export const BasicSection = ({ register, errors }: Props) => ( +
+
+ 01 +

Podstawowe informacje

+
+ + + + + + +