diff --git a/CLAUDE.md b/CLAUDE.md index f283207d7f..ed1c6a0eb4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,6 +118,10 @@ cd docker/development - **DO** use `useFormErrorResponseHandler` from `frontend/src/hooks/useFormErrorResponseHandler.tsx` for validation errors - **DO** handle errors in parent components, not in reusable components +## Git Commit Guidelines + +- **NEVER** add `Co-Authored-By: Claude` or any AI attribution lines to commit messages. + ## Development Workflows ### Database Changes (in Backend Container) diff --git a/README.el.md b/README.el.md new file mode 100644 index 0000000000..973ffafb7c --- /dev/null +++ b/README.el.md @@ -0,0 +1,195 @@ +
+ +Hi.Events - Open Source Event Ticketing Platform + +# Hi.Events + +### Ανοιχτού κώδικα πλατφόρμα διαχείρισης εκδηλώσεων και έκδοσης εισιτηρίων + +Πωλήστε εισιτήρια online για συνέδρια, εκδηλώσεις νυχτερινής ζωής, συναυλίες, club nights, εργαστήρια και φεστιβάλ. +Self-hosted ή Cloud. Οι εκδηλώσεις σας, η μάρκα σας, τα δεδομένα σας. + +[Δοκιμάστε το Cloud →](https://app.hi.events/auth/register?utm_source=gh-readme) · [Live Demo](https://app.hi.events/event/2/hievents-conference-2030?utm_source=gh-readme) · [Τεκμηρίωση](https://hi.events/docs?utm_source=gh-readme) · [Ιστοσελίδα](https://hi.events?utm_source=gh-readme) + +[![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](https://github.com/HiEventsDev/Hi.Events/blob/develop/LICENCE) +[![GitHub Release](https://img.shields.io/github/v/release/HiEventsDev/hi.events?include_prereleases)](https://github.com/HiEventsDev/hi.events/releases) +[![Run Unit Tests](https://github.com/HiEventsDev/hi.events/actions/workflows/unit-tests.yml/badge.svg?event=push)](https://github.com/HiEventsDev/hi.events/actions/workflows/unit-tests.yml) +[![Docker Pulls](https://img.shields.io/docker/pulls/daveearley/hi.events-all-in-one)](https://hub.docker.com/r/daveearley/hi.events-all-in-one) + + + HiEventsDev%2Fhi.events | Trendshift + + +

+Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Ελληνικά +

+ +
+ +
+ +## Γιατί Hi.Events; + +Οι περισσότερες πλατφόρμες έκδοσης εισιτηρίων χρεώνουν ανά εισιτήριο και κλειδώνουν τα δεδομένα σας στο οικοσύστημά τους. **Το Hi.Events είναι μια σύγχρονη, ανοιχτού κώδικα εναλλακτική για το Eventbrite, το Tickettailor, το Dice.fm και άλλες πλατφόρμες** για διοργανωτές που θέλουν πλήρη έλεγχο πάνω στο branding, το checkout, τα δεδομένα και την υποδομή τους. + +Σχεδιασμένο για προωθητές νυχτερινής ζωής, διοργανωτές φεστιβάλ, χώρους εκδηλώσεων, κοινοτικές ομάδες και οικοδεσπότες συνεδρίων. + +
+ +Hi.Events Dashboard + +
+ +## Δυνατότητες + + + + + + + + + + + + + +
+ +### 🎟️ Έκδοση Εισιτηρίων & Πωλήσεις + +- Ευέλικτοι τύποι εισιτηρίων (δωρεάν, επί πληρωμή, δωρεά, κλιμακωτά) +- Κρυφά και κλειδωμένα εισιτήρια πίσω από κωδικούς προώθησης +- Κωδικοί προώθησης και πρόωρη πρόσβαση +- Πρόσθετα προϊόντα (εμπορεύματα, αναβαθμίσεις, extras) +- Κατηγορίες προϊόντων για οργάνωση +- Πλήρης υποστήριξη φόρων και χρεώσεων (ΦΠΑ, χρεώσεις υπηρεσιών) +- Διαχείριση χωρητικότητας και κοινά όρια + + + +### 🎨 Branding & Προσαρμογή + +- Όμορφο, βελτιστοποιημένο checkout για μετατροπές +- Προσαρμόσιμα σχέδια εισιτηρίων PDF +- Branded αρχική σελίδα διοργανωτή +- Drag-and-drop δημιουργός σελίδας εκδήλωσης +- Ενσωματώσιμο widget εισιτηρίων +- Εργαλεία SEO (meta tags, Open Graph) + +
+ +### 👥 Διαχείριση Συμμετεχόντων + +- Προσαρμοσμένες ερωτήσεις κατά το checkout +- Σύνθετη αναζήτηση, φιλτράρισμα και εξαγωγή (CSV/XLSX) +- Πλήρεις και μερικές επιστροφές χρημάτων +- Μαζικά μηνύματα ανά τύπο εισιτηρίου +- Check-in με QR code και αρχεία σάρωσης +- Λίστες check-in με έλεγχο πρόσβασης + + + +### 📊 Αναλυτικά & Ανάπτυξη + +- Πίνακας πωλήσεων σε πραγματικό χρόνο +- Παρακολούθηση συνεργατών και παραπομπών +- Σύνθετες αναφορές (πωλήσεις, φόροι, promos) +- Webhooks (Zapier, Make, CRMs) + +
+ +### ⚙️ Λειτουργίες + +Ρόλοι και δικαιώματα πολλαπλών χρηστών · Άμεσες πληρωμές Stripe Connect · Μέθοδοι εκτός σύνδεσης πληρωμής · Υποστήριξη εκδηλώσεων εκτός σύνδεσης · +Αυτόματη τιμολόγηση · Αρχείο εκδηλώσεων · Υποστήριξη πολλαπλών γλωσσών · Πλήρες REST API + +
+ +
+ +## Σύγκριση + +| Χαρακτηριστικό | Hi.Events | Eventbrite | Tickettailor | Dice | +|:---------------------------------|:----------|:-----------|:-------------|:------------| +| Επιλογή self-hosted | ✅ | ❌ | ❌ | ❌ | +| Ανοιχτός κώδικας | ✅ | ❌ | ❌ | ❌ | +| Χωρίς χρεώσεις ανά εισιτήριο (self-hosted) | ✅ | ❌ | ❌ | ❌ | +| Πλήρες custom branding | ✅ | Περιορισμένο | ✅ | Περιορισμένο | +| Παρακολούθηση συνεργατών | ✅ | ✅ | ❌ | ❌ | +| Πρόσβαση API | ✅ | ✅ | ✅ | Περιορισμένο | +| Δικά σας δεδομένα | ✅ | ❌ | ❌ | ❌ | + +
+ +## Γρήγορη Εκκίνηση + +### One-Click Deploy + +[![Deploy on DigitalOcean](https://www.deploytodo.com/do-btn-blue.svg)](https://github.com/HiEventsDev/hi.events-digitalocean) +[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://github.com/HiEventsDev/hi.events-render.com) +[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/8CGKmu?referralCode=KvSr11) +[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/8DIRY6) + +### Docker + +```bash +git clone git@github.com:HiEventsDev/hi.events.git +cd hi.events/docker/all-in-one + +# Δημιουργία κλειδιών (Linux/macOS) +echo "APP_KEY=base64:$(openssl rand -base64 32)" >> .env +echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env + +docker compose up -d +``` + +> [!TIP] +> **Χρήστες Windows:** Δείτε το `./docker/all-in-one/README.md` για οδηγίες δημιουργίας κλειδιών. + +Ανοίξτε το `http://localhost:8123` και δημιουργήστε τον λογαριασμό σας. + +📖 [Πλήρης οδηγός εγκατάστασης](https://hi.events/docs/getting-started?utm_source=gh-readme) + +
+ +## Hi.Events Cloud + +Προτιμάτε να μην το φιλοξενείτε μόνοι σας; Το **[Hi.Events Cloud](https://app.hi.events/auth/register?utm_source=gh-readme)** είναι μια πλήρως διαχειριζόμενη επιλογή χωρίς ρύθμιση, με αυτόματες ενημερώσεις και διαχειριζόμενη υποδομή. + +[Ξεκινήστε →](https://app.hi.events/auth/register?utm_source=gh-readme) + +
+ +## Συνεισφορά + +Καλωσορίζουμε συνεισφορές. Δείτε τον [οδηγό συνεισφοράς](CONTRIBUTING.md) για λεπτομέρειες. + +
+ +## Υποστήριξη + +📖 [Τεκμηρίωση](https://hi.events/docs?utm_source=gh-readme) · 📧 [hello@hi.events](mailto:hello@hi.events) · +🐛 [GitHub Issues](https://github.com/HiEventsDev/hi.events/issues) + +
+ +## Changelog + +Μείνετε ενημερωμένοι με νέες δυνατότητες και βελτιώσεις στη [σελίδα εκδόσεων](https://github.com/HiEventsDev/hi.events/releases). + +
+ +## Άδεια Χρήσης + +Το Hi.Events διαθέτει άδεια χρήσης **AGPL-3.0 με πρόσθετους όρους**. Διαθέσιμη εμπορική αδειοδότηση. [Μάθετε περισσότερα](https://hi.events/licensing). + +
+ +
+ +**[Ιστοσελίδα](https://hi.events)** · **[Τεκμηρίωση](https://hi.events/docs)** · **[Twitter/X](https://x.com/HiEventsTickets)** + +Made with ☘️ in Ireland + +
diff --git a/README.md b/README.md index 40c2698fbf..b23e9dbb3e 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Self-hosted or cloud. Your events, your brand, your data.

-Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski +Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Ελληνικά

diff --git a/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php index e9b7b492e5..c571b2c67d 100644 --- a/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php @@ -61,6 +61,7 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ final public const TICKET_DESIGN_SETTINGS = 'ticket_design_settings'; final public const ATTENDEE_DETAILS_COLLECTION_METHOD = 'attendee_details_collection_method'; final public const SHOW_MARKETING_OPT_IN = 'show_marketing_opt_in'; + final public const ALLOW_COPY_DETAILS_TO_ALL_ATTENDEES = 'allow_copy_details_to_all_attendees'; final public const HOMEPAGE_THEME_SETTINGS = 'homepage_theme_settings'; final public const PASS_PLATFORM_FEE_TO_BUYER = 'pass_platform_fee_to_buyer'; final public const ALLOW_ATTENDEE_SELF_EDIT = 'allow_attendee_self_edit'; @@ -119,6 +120,7 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ protected array|string|null $ticket_design_settings = null; protected string $attendee_details_collection_method = 'PER_TICKET'; protected bool $show_marketing_opt_in = true; + protected bool $allow_copy_details_to_all_attendees = true; protected array|string|null $homepage_theme_settings = null; protected bool $pass_platform_fee_to_buyer = false; protected bool $allow_attendee_self_edit = true; @@ -180,6 +182,7 @@ public function toArray(): array 'ticket_design_settings' => $this->ticket_design_settings ?? null, 'attendee_details_collection_method' => $this->attendee_details_collection_method ?? null, 'show_marketing_opt_in' => $this->show_marketing_opt_in ?? null, + 'allow_copy_details_to_all_attendees' => $this->allow_copy_details_to_all_attendees ?? null, 'homepage_theme_settings' => $this->homepage_theme_settings ?? null, 'pass_platform_fee_to_buyer' => $this->pass_platform_fee_to_buyer ?? null, 'allow_attendee_self_edit' => $this->allow_attendee_self_edit ?? null, @@ -751,6 +754,17 @@ public function getShowMarketingOptIn(): bool return $this->show_marketing_opt_in; } + public function setAllowCopyDetailsToAllAttendees(bool $allow_copy_details_to_all_attendees): self + { + $this->allow_copy_details_to_all_attendees = $allow_copy_details_to_all_attendees; + return $this; + } + + public function getAllowCopyDetailsToAllAttendees(): bool + { + return $this->allow_copy_details_to_all_attendees; + } + public function setHomepageThemeSettings(array|string|null $homepage_theme_settings): self { $this->homepage_theme_settings = $homepage_theme_settings; diff --git a/backend/app/Http/Actions/PromoCodes/GetPromoCodePublic.php b/backend/app/Http/Actions/PromoCodes/GetPromoCodePublic.php index 7d97aee3c7..f29d6e1656 100644 --- a/backend/app/Http/Actions/PromoCodes/GetPromoCodePublic.php +++ b/backend/app/Http/Actions/PromoCodes/GetPromoCodePublic.php @@ -5,16 +5,17 @@ use HiEvents\DomainObjects\Generated\PromoCodeDomainObjectAbstract; use HiEvents\Http\Actions\BaseAction; use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; +use HiEvents\Services\Domain\PromoCode\PromoCodeUsageValidationService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class GetPromoCodePublic extends BaseAction { - private PromoCodeRepositoryInterface $promoCodeRepository; - - public function __construct(PromoCodeRepositoryInterface $promoCodeRepository) + public function __construct( + private readonly PromoCodeRepositoryInterface $promoCodeRepository, + private readonly PromoCodeUsageValidationService $promoCodeUsageValidationService, + ) { - $this->promoCodeRepository = $promoCodeRepository; } public function __invoke(int $eventId, string $promoCode, Request $request): JsonResponse @@ -26,7 +27,7 @@ public function __invoke(int $eventId, string $promoCode, Request $request): Jso ]); return $this->jsonResponse([ - 'valid' => $promoCode !== null && $promoCode->isValid(), + 'valid' => $this->promoCodeUsageValidationService->isPromoCodeUsable($promoCode), ]); } } diff --git a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php index beb64f2b7c..d99533f0c1 100644 --- a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php +++ b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php @@ -92,6 +92,9 @@ public function rules(): array // Marketing settings 'show_marketing_opt_in' => ['boolean'], + // Attendee detail copy control + 'allow_copy_details_to_all_attendees' => ['boolean'], + // Platform fee settings 'pass_platform_fee_to_buyer' => ['boolean'], diff --git a/backend/app/Locale.php b/backend/app/Locale.php index fd6e458fa6..e19de26e3c 100644 --- a/backend/app/Locale.php +++ b/backend/app/Locale.php @@ -25,6 +25,7 @@ enum Locale: string case TR = 'tr'; case PL = 'pl'; + case EL = 'el'; public static function getSupportedLocales(): array { diff --git a/backend/app/Repository/Eloquent/OrderRepository.php b/backend/app/Repository/Eloquent/OrderRepository.php index f33c2629ee..3130d9adb2 100644 --- a/backend/app/Repository/Eloquent/OrderRepository.php +++ b/backend/app/Repository/Eloquent/OrderRepository.php @@ -185,6 +185,26 @@ public function countOrdersAssociatedWithProducts(int $eventId, array $productId return $count; } + public function countActivePromoCodeUsage(int $promoCodeId): int + { + $count = $this->model + ->where('promo_code_id', $promoCodeId) + ->where(static function (Builder $query) { + $query->whereIn('status', [ + OrderStatus::COMPLETED->name, + OrderStatus::AWAITING_OFFLINE_PAYMENT->name, + ])->orWhere(static function (Builder $reserved) { + $reserved->where('status', OrderStatus::RESERVED->name) + ->where('reserved_until', '>', now()); + }); + }) + ->count(); + + $this->resetModel(); + + return $count; + } + public function getAllOrdersForAdmin( ?string $search = null, int $perPage = 20, diff --git a/backend/app/Repository/Interfaces/OrderRepositoryInterface.php b/backend/app/Repository/Interfaces/OrderRepositoryInterface.php index 58cf2c7a8e..0f6de621d2 100644 --- a/backend/app/Repository/Interfaces/OrderRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/OrderRepositoryInterface.php @@ -31,6 +31,8 @@ public function findOrdersAssociatedWithProducts(int $eventId, array $productIds public function countOrdersAssociatedWithProducts(int $eventId, array $productIds, array $orderStatuses): int; + public function countActivePromoCodeUsage(int $promoCodeId): int; + public function getAllOrdersForAdmin( ?string $search = null, int $perPage = 20, diff --git a/backend/app/Resources/Attendee/AttendeeWithCheckInPublicResource.php b/backend/app/Resources/Attendee/AttendeeWithCheckInPublicResource.php index 198bf3f1fe..4bca6bd835 100644 --- a/backend/app/Resources/Attendee/AttendeeWithCheckInPublicResource.php +++ b/backend/app/Resources/Attendee/AttendeeWithCheckInPublicResource.php @@ -16,7 +16,6 @@ public function toArray(Request $request): array { return [ 'id' => $this->getId(), - 'email' => $this->getEmail(), 'first_name' => $this->getFirstName(), 'last_name' => $this->getLastName(), 'public_id' => $this->getPublicId(), diff --git a/backend/app/Resources/Event/EventSettingsResource.php b/backend/app/Resources/Event/EventSettingsResource.php index b61c69bf09..c10b065be9 100644 --- a/backend/app/Resources/Event/EventSettingsResource.php +++ b/backend/app/Resources/Event/EventSettingsResource.php @@ -71,6 +71,9 @@ public function toArray($request): array // Marketing settings 'show_marketing_opt_in' => $this->getShowMarketingOptIn(), + // Attendee detail copy control + 'allow_copy_details_to_all_attendees' => $this->getAllowCopyDetailsToAllAttendees(), + // Platform fee settings 'pass_platform_fee_to_buyer' => $this->getPassPlatformFeeToBuyer(), diff --git a/backend/app/Resources/Event/EventSettingsResourcePublic.php b/backend/app/Resources/Event/EventSettingsResourcePublic.php index 02ed37b5c2..822d6a0cc3 100644 --- a/backend/app/Resources/Event/EventSettingsResourcePublic.php +++ b/backend/app/Resources/Event/EventSettingsResourcePublic.php @@ -77,6 +77,9 @@ public function toArray($request): array // Marketing settings 'show_marketing_opt_in' => $this->getShowMarketingOptIn(), + // Attendee detail copy control + 'allow_copy_details_to_all_attendees' => $this->getAllowCopyDetailsToAllAttendees(), + // Platform fee settings 'pass_platform_fee_to_buyer' => $this->getPassPlatformFeeToBuyer(), diff --git a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php index ca73dec5a3..86ce00afdc 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php +++ b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php @@ -76,6 +76,9 @@ public function __construct( // Marketing settings public readonly bool $show_marketing_opt_in = true, + // Attendee detail copy control + public readonly bool $allow_copy_details_to_all_attendees = true, + // Platform fee settings public readonly bool $pass_platform_fee_to_buyer = false, @@ -158,6 +161,9 @@ public static function createWithDefaults( // Marketing defaults show_marketing_opt_in: true, + // Attendee detail copy control default + allow_copy_details_to_all_attendees: true, + // Platform fee defaults pass_platform_fee_to_buyer: false, diff --git a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php index cbad542a81..f873196507 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php @@ -127,6 +127,9 @@ public function handle(PartialUpdateEventSettingsDTO $eventSettingsDTO): EventSe // Marketing settings 'show_marketing_opt_in' => $eventSettingsDTO->settings['show_marketing_opt_in'] ?? $existingSettings->getShowMarketingOptIn(), + // Attendee detail copy control + 'allow_copy_details_to_all_attendees' => $eventSettingsDTO->settings['allow_copy_details_to_all_attendees'] ?? $existingSettings->getAllowCopyDetailsToAllAttendees(), + // Platform fee settings 'pass_platform_fee_to_buyer' => $eventSettingsDTO->settings['pass_platform_fee_to_buyer'] ?? $existingSettings->getPassPlatformFeeToBuyer(), diff --git a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php index de98ee5862..953d819401 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php @@ -89,6 +89,9 @@ public function handle(UpdateEventSettingsDTO $settings): EventSettingDomainObje // Marketing settings 'show_marketing_opt_in' => $settings->show_marketing_opt_in, + // Attendee detail copy control + 'allow_copy_details_to_all_attendees' => $settings->allow_copy_details_to_all_attendees, + // Platform fee settings 'pass_platform_fee_to_buyer' => $settings->pass_platform_fee_to_buyer, diff --git a/backend/app/Services/Application/Handlers/Order/CreateOrderHandler.php b/backend/app/Services/Application/Handlers/Order/CreateOrderHandler.php index 5278ef2243..9ae7d2fce0 100644 --- a/backend/app/Services/Application/Handlers/Order/CreateOrderHandler.php +++ b/backend/app/Services/Application/Handlers/Order/CreateOrderHandler.php @@ -19,6 +19,7 @@ use HiEvents\Services\Application\Handlers\Order\DTO\CreateOrderPublicDTO; use HiEvents\Services\Domain\Order\OrderItemProcessingService; use HiEvents\Services\Domain\Order\OrderManagementService; +use HiEvents\Services\Domain\PromoCode\PromoCodeUsageValidationService; use HiEvents\Services\Domain\Product\AvailableProductQuantitiesFetchService; use Illuminate\Database\DatabaseManager; use Illuminate\Validation\UnauthorizedException; @@ -30,6 +31,7 @@ class CreateOrderHandler public function __construct( private readonly EventRepositoryInterface $eventRepository, private readonly PromoCodeRepositoryInterface $promoCodeRepository, + private readonly PromoCodeUsageValidationService $promoCodeUsageValidationService, private readonly AffiliateRepositoryInterface $affiliateRepository, private readonly OrderManagementService $orderManagementService, private readonly OrderItemProcessingService $orderItemProcessingService, @@ -57,13 +59,14 @@ public function handle( $this->validateEventStatus($event, $createOrderPublicDTO); - $promoCode = $this->getPromoCode($createOrderPublicDTO, $eventId); - $affiliate = $this->getAffiliate($createOrderPublicDTO, $eventId); - + // Remove the session's stale reservations before promo usage is counted in getPromoCode() if ($deleteExistingOrdersForSession) { $this->orderManagementService->deleteExistingOrders($eventId, $createOrderPublicDTO->session_identifier); } + $promoCode = $this->getPromoCode($createOrderPublicDTO, $eventId); + $affiliate = $this->getAffiliate($createOrderPublicDTO, $eventId); + $this->validateProductAvailability($eventId, $createOrderPublicDTO); $order = $this->orderManagementService->createNewOrder( @@ -98,11 +101,11 @@ private function getPromoCode(CreateOrderPublicDTO $createOrderPublicDTO, int $e PromoCodeDomainObjectAbstract::EVENT_ID => $eventId, ]); - if ($promoCode?->isValid()) { - return $promoCode; + if (!$this->promoCodeUsageValidationService->isPromoCodeUsable($promoCode)) { + return null; } - return null; + return $promoCode; } private function getAffiliate(CreateOrderPublicDTO $createOrderPublicDTO, int $eventId): ?AffiliateDomainObject diff --git a/backend/app/Services/Application/Handlers/Organizer/CreateOrganizerHandler.php b/backend/app/Services/Application/Handlers/Organizer/CreateOrganizerHandler.php index 4e8441641b..4277d4523b 100644 --- a/backend/app/Services/Application/Handlers/Organizer/CreateOrganizerHandler.php +++ b/backend/app/Services/Application/Handlers/Organizer/CreateOrganizerHandler.php @@ -7,6 +7,7 @@ use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface; use HiEvents\Services\Application\Handlers\Organizer\DTO\CreateOrganizerDTO; use HiEvents\Services\Domain\Organizer\CreateDefaultOrganizerSettingsService; +use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService; use Illuminate\Database\DatabaseManager; use Throwable; @@ -16,6 +17,7 @@ public function __construct( private readonly OrganizerRepositoryInterface $organizerRepository, private readonly DatabaseManager $databaseManager, private readonly CreateDefaultOrganizerSettingsService $createDefaultOrganizerSettingsService, + private readonly HtmlPurifierService $purifier, ) { } @@ -37,7 +39,7 @@ private function createOrganizer(CreateOrganizerDTO $organizerData): OrganizerDo 'email' => $organizerData->email, 'phone' => $organizerData->phone, 'website' => $organizerData->website, - 'description' => $organizerData->description, + 'description' => $this->purifier->purify($organizerData->description), 'account_id' => $organizerData->account_id, 'timezone' => $organizerData->timezone, 'currency' => $organizerData->currency, diff --git a/backend/app/Services/Application/Handlers/Question/EditQuestionHandler.php b/backend/app/Services/Application/Handlers/Question/EditQuestionHandler.php index 0c2d16a780..1e86435869 100644 --- a/backend/app/Services/Application/Handlers/Question/EditQuestionHandler.php +++ b/backend/app/Services/Application/Handlers/Question/EditQuestionHandler.php @@ -5,13 +5,14 @@ use HiEvents\DomainObjects\QuestionDomainObject; use HiEvents\Services\Application\Handlers\Question\DTO\UpsertQuestionDTO; use HiEvents\Services\Domain\Question\EditQuestionService; +use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService; use Throwable; class EditQuestionHandler { public function __construct( private readonly EditQuestionService $editQuestionService, - + private readonly HtmlPurifierService $purifier, ) { } @@ -30,7 +31,7 @@ public function handle(int $questionId, UpsertQuestionDTO $createQuestionDTO): Q ->setRequired($createQuestionDTO->required) ->setOptions($createQuestionDTO->options) ->setIsHidden($createQuestionDTO->is_hidden) - ->setDescription($createQuestionDTO->description); + ->setDescription($this->purifier->purify($createQuestionDTO->description)); return $this->editQuestionService->editQuestion( question: $question, diff --git a/backend/app/Services/Domain/Event/CreateEventService.php b/backend/app/Services/Domain/Event/CreateEventService.php index 76e08b974d..a963784ee3 100644 --- a/backend/app/Services/Domain/Event/CreateEventService.php +++ b/backend/app/Services/Domain/Event/CreateEventService.php @@ -226,6 +226,7 @@ private function createEventSettings( 'attendee_details_collection_method' => $organizerSettings->getDefaultAttendeeDetailsCollectionMethod(), 'show_marketing_opt_in' => $organizerSettings->getDefaultShowMarketingOptIn(), + 'allow_copy_details_to_all_attendees' => true, 'pass_platform_fee_to_buyer' => $organizerSettings->getDefaultPassPlatformFeeToBuyer(), 'allow_attendee_self_edit' => $organizerSettings->getDefaultAllowAttendeeSelfEdit() ?? false, 'ticket_design_settings' => [ diff --git a/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php b/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php index 6df66a3587..04b09c163f 100644 --- a/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php +++ b/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php @@ -84,7 +84,7 @@ private function validateTypes(array $data): void 'products' => 'required|array', 'products.*.product_id' => 'required|integer', 'products.*.quantities' => 'required|array', - 'products.*.quantities.*.quantity' => 'required|integer', + 'products.*.quantities.*.quantity' => 'required|integer|min:0', 'products.*.quantities.*.price_id' => 'required|integer', 'products.*.quantities.*.price' => 'numeric|min:0', ]); @@ -357,6 +357,10 @@ private function validateOverallCapacity(array $data): void ->filter(fn($product) => in_array($product['product_id'], $productIds->toArray(), true)) ->sum(fn($product) => collect($product['quantities'])->sum('quantity')); + if ($totalQuantity === 0) { + continue; + } + $reservedProductQuantities = $capacity->getProducts() ->map(fn(ProductDomainObject $product) => $this ->availableProductQuantities diff --git a/backend/app/Services/Domain/PromoCode/PromoCodeUsageValidationService.php b/backend/app/Services/Domain/PromoCode/PromoCodeUsageValidationService.php new file mode 100644 index 0000000000..f62ac2776e --- /dev/null +++ b/backend/app/Services/Domain/PromoCode/PromoCodeUsageValidationService.php @@ -0,0 +1,35 @@ +isValid()) { + return false; + } + + $maxAllowedUsages = $promoCode->getMaxAllowedUsages(); + + if ($maxAllowedUsages === null) { + return true; + } + + return $this->orderRepository->countActivePromoCodeUsage($promoCode->getId()) < $maxAllowedUsages; + } +} diff --git a/backend/database/migrations/2026_06_04_120000_add_allow_copy_details_to_all_attendees_to_event_settings.php b/backend/database/migrations/2026_06_04_120000_add_allow_copy_details_to_all_attendees_to_event_settings.php new file mode 100644 index 0000000000..d508f44bf3 --- /dev/null +++ b/backend/database/migrations/2026_06_04_120000_add_allow_copy_details_to_all_attendees_to_event_settings.php @@ -0,0 +1,22 @@ +boolean('allow_copy_details_to_all_attendees')->default(true)->after('show_marketing_opt_in'); + }); + } + + public function down(): void + { + Schema::table('event_settings', function (Blueprint $table) { + $table->dropColumn('allow_copy_details_to_all_attendees'); + }); + } +}; diff --git a/backend/lang/el.json b/backend/lang/el.json new file mode 100644 index 0000000000..f5a7ff5fd5 --- /dev/null +++ b/backend/lang/el.json @@ -0,0 +1,666 @@ +{ + "Older First": "Παλαιότερα Πρώτα", + "Newer First": "Νεότερα Πρώτα", + "Recently Updated First": "Πρόσφατα Ενημερωμένα Πρώτα", + "Recently Updated Last": "Πρόσφατα Ενημερωμένα Τελευταία", + "First Name A-Z": "Όνομα Α-Ω", + "First Name Z-A": "Όνομα Ω-Α", + "Last Name A-Z": "Επώνυμο Α-Ω", + "Last Name Z-A": "Επώνυμο Ω-Α", + "Status A-Z": "Κατάσταση Α-Ω", + "Status Z-A": "Κατάσταση Ω-Α", + "Closest start date": "Πλησιέστερη ημερομηνία έναρξης", + "Furthest start date": "Απώτερη ημερομηνία έναρξης", + "Closest end date": "Πλησιέστερη ημερομηνία λήξης", + "Furthest end date": "Απώτερη ημερομηνία λήξης", + "Newest first": "Νεότερα πρώτα", + "Oldest first": "Παλαιότερα πρώτα", + "Recently Updated": "Πρόσφατα Ενημερωμένα", + "Least Recently Updated": "Λιγότερο Πρόσφατα Ενημερωμένα", + "Oldest First": "Παλαιότερα Πρώτα", + "Newest First": "Νεότερα Πρώτα", + "Buyer Name A-Z": "Αγοραστής Α-Ω", + "Buyer Name Z-A": "Αγοραστής Ω-Α", + "Amount Ascending": "Ποσό Αύξουσα", + "Amount Descending": "Ποσό Φθίνουσα", + "Buyer Email A-Z": "Email Αγοραστή Α-Ω", + "Buyer Email Z-A": "Email Αγοραστή Ω-Α", + "Order # Ascending": "Αριθμός Παραγγελίας Αύξουσα", + "Order # Descending": "Αριθμός Παραγγελίας Φθίνουσα", + "Code Name A-Z": "Όνομα Κωδικού Α-Ω", + "Code Name Z-A": "Όνομα Κωδικού Ω-Α", + "Usage Count Ascending": "Χρήσεις Αύξουσα", + "Usage Count Descending": "Χρήσεις Φθίνουσα", + "Homepage order": "Σειρά Αρχικής Σελίδας", + "Title A-Z": "Τίτλος Α-Ω", + "Title Z-A": "Τίτλος Ω-Α", + "Sale start date closest": "Έναρξη Πώλησης Πλησιέστερη", + "Sale start date furthest": "Έναρξη Πώλησης Απώτερη", + "Sale end date closest": "Λήξη Πώλησης Πλησιέστερη", + "Sale end date furthest": "Λήξη Πώλησης Απώτερη", + "Account registration is disabled": "Η εγγραφή λογαριασμού είναι απενεργοποιημένη", + "The invitation has expired": "Η πρόσκληση έχει λήξει", + "The invitation is invalid": "Η πρόσκληση δεν είναι έγκυρη", + "No user found for this invitation. The invitation may have been revoked.": "Δεν βρέθηκε χρήστης για αυτήν την πρόσκληση. Η πρόσκληση ενδέχεται να έχει ανακληθεί.", + "Logout Successful": "Αποσύνδεση Επιτυχής", + "Your password has been reset. Please login with your new password.": "Ο κωδικός σας έχει επαναφερθεί. Παρακαλώ συνδεθείτε με τον νέο σας κωδικό.", + "Sorry, we could not verify your session. Please restart your order.": "Λυπούμαστε, δεν μπορέσαμε να επαληθεύσουμε τη συνεδρία σας. Παρακαλώ ξεκινήστε την παραγγελία σας από την αρχή.", + "The email confirmation link has expired. Please request a new one.": "Ο σύνδεσμος επιβεβαίωσης email έχει λήξει. Παρακαλώ ζητήστε νέο.", + "The email confirmation link is invalid.": "Ο σύνδεσμος επιβεβαίωσης email δεν είναι έγκυρος.", + "Email is required": "Το email είναι υποχρεωτικό", + "Email must be a valid email address": "Το email πρέπει να είναι έγκυρη διεύθυνση", + "First name is required": "Το όνομα είναι υποχρεωτικό", + "Last name is required": "Το επώνυμο είναι υποχρεωτικό", + "Ticket is required": "Το εισιτήριο είναι υποχρεωτικό", + "Ticket price is required": "Η τιμή εισιτηρίου είναι υποχρεωτική", + "Please enter a valid hex color code. In the format #000000 or #000.": "Παρακαλώ εισάγετε έγκυρο κωδικό χρώματος hex. Στη μορφή #000000 ή #000.", + "The address line 1 field is required": "Το πεδίο διεύθυνσης είναι υποχρεωτικό", + "The city field is required": "Το πεδίο πόλης είναι υποχρεωτικό", + "The zip or postal code field is required": "Ο ταχυδρομικός κώδικας είναι υποχρεωτικός", + "The country field is required": "Το πεδίο χώρας είναι υποχρεωτικό", + "The country field should be a 2 character ISO 3166 code": "Το πεδίο χώρας πρέπει να είναι κωδικός ISO 3166 2 χαρακτήρων", + "Please select at least one ticket.": "Παρακαλώ επιλέξτε τουλάχιστον ένα εισιτήριο.", + "The sale end date must be after the sale start date.": "Η ημερομηνία λήξης πώλησης πρέπει να είναι μετά την ημερομηνία έναρξης.", + "Welcome to :app_name! Please confirm your email address": "Καλώς ήρθατε στο :app_name! Παρακαλώ επιβεβαιώστε τη διεύθυνση email σας", + "🎟️ Your Ticket for :event": "🎟️ Το Εισιτήριό σας για :event", + "Your order has been cancelled": "Η παραγγελία σας ακυρώθηκε", + "Your order wasn\\'t successful": "Η παραγγελία σας δεν ήταν επιτυχής", + "You\\'ve received a refund": "Λάβατε επιστροφή χρημάτων", + "Your Order is Confirmed!": "Η Παραγγελία σας Επιβεβαιώθηκε!", + "We were unable to process your order": "Δεν μπορέσαμε να επεξεργαστούμε την παραγγελία σας", + "New order for :amount for :event 🎉": "Νέα παραγγελία ύψους :amount για :event 🎉", + "New order for :event 🎉": "Νέα παραγγελία για :event 🎉", + "Confirm email change": "Επιβεβαίωση αλλαγής email", + "Password reset": "Επαναφορά κωδικού", + "Your password has been reset": "Ο κωδικός σας επαναφέρθηκε", + "You\\'ve been invited to join :appName": "Σας προσκλήθηκε να συμμετάσχετε στο :appName", + "User not found": "Ο χρήστης δεν βρέθηκε", + "Username or Password are incorrect": "Λάθος όνομα χρήστη ή κωδικός", + "Account not found": "Ο λογαριασμός δεν βρέθηκε", + "You are not authorized to perform this action.": "Δεν είστε εξουσιοδοτημένος για αυτήν την ενέργεια.", + "Your account is not active.": "Ο λογαριασμός σας δεν είναι ενεργός.", + "This promo code is invalid": "Αυτός ο κωδικός έκπτωσης δεν είναι έγκυρος", + "You haven\\'t selected any tickets": "Δεν έχετε επιλέξει εισιτήρια", + "The maximum number of tickets available for :tickets is :max": "Ο μέγιστος αριθμός διαθέσιμων εισιτηρίων για :tickets είναι :max", + "You must order at least :min tickets for :ticket": "Πρέπει να παραγγείλετε τουλάχιστον :min εισιτήρια για :ticket", + "The minimum amount is :price": "Το ελάχιστο ποσό είναι :price", + "The ticket :ticket is sold out": "Το εισιτήριο :ticket έχει εξαντληθεί", + "The maximum number of tickets available for :ticket is :max": "Ο μέγιστος αριθμός διαθέσιμων εισιτηρίων για :ticket είναι :max", + "There was an error communicating with the payment provider. Please try again later.": "Παρουσιάστηκε σφάλμα κατά την επικοινωνία με τον πάροχο πληρωμής. Παρακαλώ δοκιμάστε ξανά αργότερα.", + "This field is required.": "Αυτό το πεδίο είναι υποχρεωτικό.", + "This field must be less than 255 characters.": "Αυτό το πεδίο πρέπει να είναι λιγότερο από 255 χαρακτήρες.", + "This field must be at least 2 characters.": "Αυτό το πεδίο πρέπει να έχει τουλάχιστον 2 χαρακτήρες.", + "Hello": "Γεια σας", + "Reset Password": "Επαναφορά Κωδικού", + "Thank you": "Ευχαριστούμε", + "You\\'re going to": "Πηγαίνετε στο", + "View Ticket": "Προβολή Εισιτηρίου", + "Best regards,": "Με εκτίμηση,", + "Your order for": "Η παραγγελία σας για", + "has been cancelled.": "έχει ακυρωθεί.", + "Order #:": "Παραγγελία #:", + "You\\'ve received a new order!": "Λάβατε νέα παραγγελία!", + "Order Amount:": "Ποσό Παραγγελίας:", + "Order ID:": "ID Παραγγελίας:", + "View Order": "Προβολή Παραγγελίας", + "Ticket": "Εισιτήριο", + "Price": "Τιμή", + "Total": "Σύνολο", + "Your Order is Confirmed! ": "Η Παραγγελία σας Επιβεβαιώθηκε! ", + "Event Details": "Λεπτομέρειες Εκδήλωσης", + "Event Name:": "Όνομα Εκδήλωσης:", + "Date & Time:": "Ημερομηνία & Ώρα:", + "Order Summary": "Σύνοψη Παραγγελίας", + "Order Number:": "Αριθμός Παραγγελίας:", + "Total Amount:": "Συνολικό Ποσό:", + "View Order Summary & Tickets": "Προβολή Σύνοψης Παραγγελίας & Εισιτηρίων", + "What\\'s Next?": "Τι Ακολουθεί;", + "Download Tickets:": "Λήψη Εισιτηρίων:", + "Prepare for the Event:": "Προετοιμαστείτε για την Εκδήλωση:", + "Stay Updated:": "Μείνετε Ενήμεροι:", + "Welcome to :appName! We\\'re excited to have you aboard!": "Καλώς ήρθατε στο :appName! Χαρούμαστε που είστε μαζί μας!", + "Confirm Your Email": "Επιβεβαίωσε το Email σου", + "Best Regards,": "Με εκτίμηση,", + "The :appName Team": "Η Ομάδα του :appName", + "Accept Invitation": "Αποδοχή Πρόσκλησης", + "All rights reserved.": "Με επιφύλαξη παντός δικαιώματος.", + "Congratulations 🎉": "Συγχαρητήρια 🎉", + "Sent Date Oldest": "Παλαιότερη Ημερομηνία Αποστολής", + "Sent Date Newest": "Νεότερη Ημερομηνία Αποστολής", + "Subject A-Z": "Θέμα Α-Ω", + "Subject Z-A": "Θέμα Ω-Α", + "Name A-Z": "Όνομα Α-Ω", + "Name Z-A": "Όνομα Ω-Α", + "Updated oldest first": "Παλαιότερη Ενημέρωση Πρώτη", + "Updated newest first": "Νεότερη Ενημέρωση Πρώτη", + "Most capacity used": "Μεγαλύτερη Χωρητικότητα", + "Least capacity used": "Μικρότερη Χωρητικότητα", + "Sorry, these tickets are sold out": "Λυπούμαστε, αυτά τα εισιτήρια έχουν εξαντληθεί", + "The maximum number of tickets available is :max": "Ο μέγιστος αριθμός διαθέσιμων εισιτηρίων είναι :max", + "Ticket is sold out": "Το εισιτήριο έχει εξαντληθεί", + "Ticket is hidden": "Το εισιτήριο είναι κρυφό", + "Expires soonest": "Λήγει σύντομα", + "Expires latest": "Λήγει αργότερα", + "Reserved": "Κρατημένο", + "Cancelled": "Ακυρωμένο", + "Completed": "Ολοκληρωμένο", + "Awaiting offline payment": "Αναμονή εκτός σύνδεσης πληρωμής", + "ID": "ID", + "First Name": "Όνομα", + "Last Name": "Επώνυμο", + "Email": "Email", + "Status": "Κατάσταση", + "Is Checked In": "Έχει κάνει Check-in", + "Checked In At": "Check-in στις", + "Notes": "Σημειώσεις", + "Currency": "Νόμισμα", + "Created At": "Δημιουργήθηκε στις", + "Payment Status": "Κατάσταση Πληρωμής", + "Refund Status": "Κατάσταση Επιστροφής", + "Is Free Order": "Δωρεάν Παραγγελία", + "Billing Address": "Διεύθυνση Τιμολόγησης", + "Promo Code": "Κωδικός Έκπτωσης", + "Invoice": "Τιμολόγιο", + "Order Status:": "Κατάσταση Παραγγελίας:", + "Payment Instructions": "Οδηγίες Πληρωμής", + "Please follow the instructions below to complete your payment.": "Παρακαλώ ακολουθήστε τις παρακάτω οδηγίες για να ολοκληρώσετε την πληρωμή σας.", + "Invoice Number": "Αριθμός Τιμολογίου", + "Date Issued": "Ημερομηνία Έκδοσης", + "Due Date": "Ημερομηνία Λήξης", + "Amount Due": "Οφειλόμενο Ποσό", + "Billed To": "Χρεώθηκε σε", + "DESCRIPTION": "ΠΕΡΙΓΡΑΦΗ", + "RATE": "ΤΙΜΗ", + "QTY": "ΠΟΣ", + "AMOUNT": "ΠΟΣΟ", + "Subtotal": "Υποσύνολο", + "Total Discount": "Συνολική Έκπτωση", + "Total Service Fee": "Συνολική Χρέωση Υπηρεσίας", + "Total Amount": "Συνολικό Ποσό", + "Tax Information": "Πληροφορίες Φόρου", + "Sales Ascending": "Πωλήσεις Αύξουσα", + "Sales Descending": "Πωλήσεις Φθίνουσα", + "Revenue Ascending": "Έσοδα Αύξουσα", + "Revenue Descending": "Έσοδα Φθίνουσα", + "Social": "Κοινωνικά", + "Music": "Μουσική", + "Art": "Τέχνη", + "Comedy": "Κωμωδία", + "Theater": "Θέατρο", + "Business": "Επιχείρηση", + "Tech": "Τεχνολογία", + "Education": "Εκπαίδευση", + "Workshop": "Εργαστήριο", + "Sports": "Αθλητισμός", + "Festival": "Φεστιβάλ", + "Nightlife": "Νυχτερινή Ζωή", + "Other": "Άλλο", + "Name": "Όνομα", + "Code": "Κωδικός", + "Total Sales": "Συνολικές Πωλήσεις", + "Question": "Ερώτηση", + "Answer": "Απάντηση", + "Order ID": "ID Παραγγελίας", + "Order Email": "Email Παραγγελίας", + "Attendee Name": "Όνομα Συμμετέχοντα", + "Attendee Email": "Email Συμμετέχοντα", + "Product": "Προϊόν", + "Message sent successfully": "Το μήνυμα στάλθηκε επιτυχώς", + "Your email has been successfully verified!": "Το email σας επαληθεύτηκε επιτυχώς!", + "New password must be different from the old password.": "Ο νέος κωδικός πρέπει να διαφέρει από τον παλιό.", + "Organizer not found": "Ο διοργανωτής δεν βρέθηκε", + "Your email address has already been verified.": "Η διεύθυνση email σας έχει ήδη επαληθευτεί.", + "The verification code is invalid or has expired.": "Ο κωδικός επαλήθευσης δεν είναι έγκυρος ή έχει λήξει.", + "Job completed successfully": "Η εργασία ολοκληρώθηκε επιτυχώς", + "Email Subject": "Θέμα Email", + "Need help?": "Χρειάζεστε βοήθεια;", + "Contact Support": "Επικοινωνία με Υποστήριξη", + "Ticket Name A-Z": "Όνομα Εισιτηρίου Α-Ω", + "Ticket Name Z-A": "Όνομα Εισιτηρίου Ω-Α", + "Order Confirmation": "Επιβεβαίωση Παραγγελίας", + "Attendee Ticket": "Εισιτήριο Συμμετέχοντα", + "Sent to the customer after placing an order": "Αποστέλλεται στον πελάτη μετά την υποβολή παραγγελίας", + "Sent to each attendee with their ticket": "Αποστέλλεται σε κάθε συμμετέχοντα με το εισιτήριό του", + "Abandoned": "Εγκαταλελειμμένο", + "Unknown": "Άγνωστο", + "Unauthorized": "Μη εξουσιοδοτημένο", + "Expired": "Ληγμένο", + "Active": "Ενεργό", + "This ticket is invalid": "Αυτό το εισιτήριο δεν είναι έγκυρο", + "Attendee updated successfully": "Ο συμμετέχων ενημερώθηκε επιτυχώς", + "Order updated successfully": "Η παραγγελία ενημερώθηκε επιτυχώς", + "Ticket resent successfully": "Το εισιτήριο εστάλη εκ νέου επιτυχώς", + "Order confirmation resent successfully": "Η επιβεβαίωση παραγγελίας εστάλη εκ νέου επιτυχώς", + "If you have tickets associated with this email, we will send you an email with the details.": "Εάν έχετε εισιτήρια που σχετίζονται με αυτό το email, θα σας στείλουμε email με τις λεπτομέρειες.", + "Your Ticket Details Have Been Changed": "Τα στοιχεία του εισιτηρίου σας άλλαξαν", + "Your Order Details Have Been Changed": "Τα στοιχεία της παραγγελίας σας άλλαξαν", + "Your Tickets": "Τα Εισιτήριά σας", + "View Order & Tickets": "Προβολή Παραγγελίας & Εισιτηρίων", + "Please confirm your email address": "Παρακαλώ επιβεβαιώστε τη διεύθυνση email σας", + "Your confirmation code for :app_name is :code": "Ο κωδικός επιβεβαίωσής σας για το :app_name είναι :code", + "New message from :name": "Νέο μήνυμα από :name", + "View My Tickets": "Προβολή των Εισιτηρίων μου", + "This link will expire in 24 hours.": "Αυτός ο σύνδεσμος λήγει σε 24 ώρες.", + "Continue": "Συνέχεια", + "in": "είσοδος", + "out": "έξοδος", + "Platform Fee": "Χρέωση Πλατφόρμας", + "Ticket Reference": "Αναφορά Εισιτηρίου", + "Order Reference": "Αναφορά Παραγγελίας", + "© :year :app_name. All rights reserved.": "© :year :app_name. Με επιφύλαξη παντός δικαιώματος.", + "A valid email is required": "Απαιτείται έγκυρο email", + "The title field is required": "Το πεδίο τίτλου είναι υποχρεωτικό", + "Please select an option": "Παρακαλώ επιλέξτε μια επιλογή", + "Check Ins": "Check-ins", + "Opted In To Marketing": "Συναίνεση σε Marketing", + "Deleted": "Διαγραμμένο", + "Invitation valid, but user not found": "Η πρόσκληση είναι έγκυρη, αλλά ο χρήστης δεν βρέθηκε", + "No account ID found in token": "Δεν βρέθηκε αναγνωριστικό λογαριασμού στο token", + "Event with ID :eventId is not live and user is not authenticated": "Η εκδήλωση με ID :eventId δεν είναι ενεργή και ο χρήστης δεν είναι πιστοποιημένος", + "No invitation found for this user.": "Δεν βρέθηκε πρόσκληση για αυτόν τον χρήστη.", + "User status is not Invited": "Η κατάσταση χρήστη δεν είναι «Προσκεκλημένος»", + "The maximum timeout is 2 hours.": "Η μέγιστη διάρκεια λήξης είναι 2 ώρες.", + "The sale end date must be a valid date.": "Η ημερομηνία λήξης πώλησης πρέπει να είναι έγκυρη ημερομηνία.", + "The sale start date must be after the ticket sale start date.": "Η ημερομηνία έναρξης πώλησης πρέπει να είναι μετά την ημερομηνία έναρξης πώλησης εισιτηρίων.", + "Current account ID is not set": "Το τρέχον αναγνωριστικό λογαριασμού δεν έχει οριστεί", + "User not found in this account": "Ο χρήστης δεν βρέθηκε σε αυτόν τον λογαριασμό", + "Attempt to log in to a non-active account": "Απόπειρα σύνδεσης σε μη ενεργό λογαριασμό", + "User account is not active": "Ο λογαριασμός χρήστη δεν είναι ενεργός", + "Invalid reset token": "Μη έγκυρο token επαναφοράς", + "Reset token has expired": "Το token επαναφοράς έχει λήξει", + "Event daily statistics updated for event :event_id with total refunded amount of :amount": "Οι ημερήσιες στατιστικές για την εκδήλωση :event_id ενημερώθηκαν με συνολικό επιστρεφόμενο ποσό :amount", + "Event statistics updated for event :event_id with total refunded amount of :amount": "Οι στατιστικές για την εκδήλωση :event_id ενημερώθηκαν με συνολικό επιστρεφόμενο ποσό :amount", + ":field must be specified": "Το :field πρέπει να καθοριστεί", + "Invalid price ID": "Μη έγκυρο αναγνωριστικό τιμής", + "Ticket with id :id not found": "Το εισιτήριο με αναγνωριστικό :id δεν βρέθηκε", + "Failed to refund stripe charge": "Αποτυχία επιστροφής χρέωσης Stripe", + "Payment was successful, but order has expired. Order: :id": "Η πληρωμή ήταν επιτυχής, αλλά η παραγγελία έχει λήξει. Παραγγελία: :id", + "Order is not awaiting payment. Order: :id": "Η παραγγελία δεν αναμένει πληρωμή. Παραγγελία: :id", + "Stripe Connect account not found for the event organizer": "Δεν βρέθηκε λογαριασμός Stripe Connect για τον διοργανωτή", + "Cannot Refund: Stripe connect account not found and saas_mode_enabled is enabled": "Αδύνατη η επιστροφή: Δεν βρέθηκε λογαριασμός Stripe Connect και η λειτουργία saas είναι ενεργή", + "Invalid calculation type": "Μη έγκυρος τύπος υπολογισμού", + "One or more tax IDs are invalid": "Ένα ή περισσότερα αναγνωριστικά φόρου δεν είναι έγκυρα", + "Invalid ticket ids: :ids": "Μη έγκυρα αναγνωριστικά εισιτηρίων: :ids", + "Cannot delete ticket price with id :id because it has sales": "Δεν είναι δυνατή η διαγραφή τιμής εισιτηρίου με αναγνωριστικό :id διότι έχει πωλήσεις", + "Order has no order items": "Η παραγγελία δεν έχει στοιχεία", + "There is already an account associated with this email. Please log in instead.": "Υπάρχει ήδη λογαριασμός συνδεδεμένος με αυτό το email. Παρακαλώ συνδεθείτε.", + "Stripe Connect Account creation is only available in Saas Mode.": "Η δημιουργία λογαριασμού Stripe Connect είναι διαθέσιμη μόνο σε λειτουργία SaaS.", + "There are issues with creating or fetching the Stripe Connect Account. Please try again.": "Υπάρχουν προβλήματα με τη δημιουργία ή ανάκτηση του λογαριασμού Stripe Connect. Παρακαλώ δοκιμάστε ξανά.", + "There are issues with creating the Stripe Connect Account Link. Please try again.": "Υπάρχουν προβλήματα με τη δημιουργία του συνδέσμου λογαριασμού Stripe Connect. Παρακαλώ δοκιμάστε ξανά.", + "Cannot check in attendee as they are not active.": "Αδύνατος ο έλεγχος εισόδου του συμμετέχοντα καθώς δεν είναι ενεργός.", + "There are no tickets available. ' .\n 'If you would like to assign a ticket to this attendee,' .\n ' please adjust the ticket\\'s available quantity.": "Δεν υπάρχουν διαθέσιμα εισιτήρια. Εάν θέλετε να αναθέσετε εισιτήριο σε αυτόν τον συμμετέχοντα, παρακαλώ προσαρμόστε τη διαθέσιμη ποσότητα εισιτηρίων.", + "The ticket price ID is invalid.": "Το αναγνωριστικό τιμής εισιτηρίου δεν είναι έγκυρο.", + "Ticket ID is not valid": "Το αναγνωριστικό εισιτηρίου δεν είναι έγκυρο", + "There are no tickets available. If you would like to assign this ticket to this attendee, please adjust the ticket\\'s available quantity.": "Δεν υπάρχουν διαθέσιμα εισιτήρια. Εάν θέλετε να αναθέσετε αυτό το εισιτήριο σε αυτόν τον συμμετέχοντα, παρακαλώ προσαρμόστε τη διαθέσιμη ποσότητα.", + "Attendee ID is not valid": "Το αναγνωριστικό συμμετέχοντα δεν είναι έγκυρο", + "The invitation does not exist": "Η πρόσκληση δεν υπάρχει", + "The invitation has already been accepted": "Η πρόσκληση έχει ήδη γίνει αποδεκτή", + "Organizer :id not found": "Ο διοργανωτής :id δεν βρέθηκε", + "Event :id not found": "Η εκδήλωση :id δεν βρέθηκε", + "You cannot change the currency of an event that has completed orders": "Δεν μπορείτε να αλλάξετε το νόμισμα μιας εκδήλωσης που έχει ολοκληρωμένες παραγγελίες", + "You must verify your account before you can update an event\\'s status.\n You can resend the confirmation by visiting your profile page.": "Πρέπει να επαληθεύσετε τον λογαριασμό σας πριν ενημερώσετε την κατάσταση εκδήλωσης. Μπορείτε να στείλετε ξανά την επιβεβαίωση από τη σελίδα προφίλ σας.", + "You cannot send messages until your account is verified.": "Δεν μπορείτε να στείλετε μηνύματα μέχρι να επαληθευτεί ο λογαριασμός σας.", + "Order not found": "Η παραγγελία δεν βρέθηκε", + "Order already cancelled": "Η παραγγελία έχει ήδη ακυρωθεί", + "Failed to create attendee": "Αποτυχία δημιουργίας συμμετέχοντα", + "This order is has already been processed": "Αυτή η παραγγελία έχει ήδη επεξεργαστεί", + "This order has expired": "Αυτή η παραγγελία έχει λήξει", + "This order has already been processed": "Αυτή η παραγγελία έχει ήδη επεξεργαστεί", + "There is an unexpected ticket price ID in the order": "Υπάρχει μη αναμενόμενο αναγνωριστικό τιμής εισιτηρίου στην παραγγελία", + "This event is not live.": "Αυτή η εκδήλωση δεν είναι ενεργή.", + "Sorry, we could not verify your session. Please create a new order.": "Λυπούμαστε, δεν μπορέσαμε να επαληθεύσουμε τη συνεδρία σας. Παρακαλώ δημιουργήστε νέα παραγγελία.", + "There is no Stripe data associated with this order.": "Δεν υπάρχουν δεδομένα Stripe συνδεδεμένα με αυτή την παραγγελία.", + "There is already a refund pending for this order.\n Please wait for the refund to be processed before requesting another one.": "Υπάρχει ήδη εκκρεμής επιστροφή για αυτή την παραγγελία. Παρακαλώ αναμείνετε την επεξεργασία της επιστροφής πριν ζητήσετε άλλη.", + "Promo code :code already exists": "Ο κωδικός προώθησης :code υπάρχει ήδη", + "The code :code is already in use for this event": "Ο κωδικός :code χρησιμοποιείται ήδη για αυτή την εκδήλωση", + "You cannot delete this question as there as answers associated with it. You can hide the question instead.": "Δεν μπορείτε να διαγράψετε αυτή την ερώτηση καθώς υπάρχουν απαντήσεις συνδεδεμένες. Μπορείτε να αποκρύψετε την ερώτηση.", + "One or more of the ordered question IDs do not exist for the event.": "Ένα ή περισσότερα από τα αναγνωριστικά ερωτήσεων δεν υπάρχουν για αυτή την εκδήλωση.", + "You cannot delete this ticket because it has orders associated with it. You can hide it instead.": "Δεν μπορείτε να διαγράψετε αυτό το εισιτήριο καθώς υπάρχουν παραγγελίες συνδεδεμένες. Μπορείτε να το αποκρύψετε.", + "Ticket type cannot be changed as tickets have been registered for this type": "Ο τύπος εισιτηρίου δεν μπορεί να αλλάξει καθώς εισιτήρια έχουν εγγραφεί για αυτόν τον τύπο", + "The ordered ticket IDs must exactly match all tickets for the event without missing or extra IDs.": "Τα αναγνωριστικά εισιτηρίων πρέπει να αντιστοιχούν ακριβώς σε όλα τα εισιτήρια της εκδήλωσης χωρίς ελλείψεις ή επιπλέον.", + "No email change pending": "Δεν εκκρεμεί αλλαγή email", + "The email :email already exists on this account": "Το email :email υπάρχει ήδη σε αυτόν τον λογαριασμό", + "Payload has expired or is invalid.": "Το payload έχει λήξει ή δεν είναι έγκυρο.", + "Payload could not be decrypted.": "Το payload δεν μπόρεσε να αποκρυπτογραφηθεί.", + "Could not upload image to :disk. Check :disk is configured correctly": "Αδύνατη η μεταφόρτωση εικόνας στο :disk. Ελέγξτε ότι το :disk έχει ρυθμιστεί σωστά", + "Could not upload image": "Αδύνατη η μεταφόρτωση εικόνας", + "Length must be a positive integer.": "Το μήκος πρέπει να είναι θετικός ακέραιος.", + "Prefix length exceeds the total desired token length.": "Το μήκος προθέματος υπερβαίνει το συνολικό επιθυμητό μήκος token.", + "The attribute name is required": "Το όνομα χαρακτηριστικού είναι υποχρεωτικό", + "The attribute value is required": "Η τιμή χαρακτηριστικού είναι υποχρεωτική", + "The attribute is_public fields is required": "Το πεδίο is_public του χαρακτηριστικού είναι υποχρεωτικό", + "Required questions have not been answered. You may need to reload the page.": "Δεν έχουν απαντηθεί οι υποχρεωτικές ερωτήσεις. Ίσως χρειαστεί να φορτώσετε ξανά τη σελίδα.", + "This question is outdated. Please reload the page.": "Αυτή η ερώτηση είναι παλιά. Παρακαλώ φορτώστε ξανά τη σελίδα.", + "You have requested to reset your password for your account on :appName.": "Ζητήσατε επαναφορά κωδικού πρόσβασης για τον λογαριασμό σας στο :appName.", + "Please click the link below to reset your password.": "Κάντε κλικ στον παρακάτω σύνδεσμο για επαναφορά κωδικού πρόσβασης.", + "If you did not request a password reset, please ignore this email or reply to let us know.": "Εάν δεν ζητήσατε επαναφορά κωδικού, αγνοήστε αυτό το email ή απαντήστε για να μας ενημερώσετε.", + "Your password has been reset for your account on :appName.": "Ο κωδικός πρόσβασής σας έχει επαναφερθεί για τον λογαριασμό σας στο :appName.", + "If you did not request a password reset, please immediately contact reset your password.": "Εάν δεν ζητήσατε επαναφορά κωδικού, παρακαλώ επικοινωνήστε άμεσα και αλλάξτε τον κωδικό σας.", + "You are receiving this communication because you are registered as an attendee for the following event:": "Λαμβάνετε αυτή την επικοινωνία επειδή είστε εγγεγραμμένος συμμετέχων στην παρακάτω εκδήλωση:", + "If you believe you have received this email in error,": "Εάν πιστεύετε ότι λάβατε αυτό το email εκ λάθους,", + "please contact the event organizer at": "παρακαλώ επικοινωνήστε με τον διοργανωτή στο", + "If you believe this is spam, please report it to": "Εάν πιστεύετε ότι πρόκειται για spam, αναφέρατέ το στο", + "Please find your ticket details below.": "Παρακαλώ βρείτε τις λεπτομέρειες εισιτηρίου παρακάτω.", + "If you have any questions or need assistance, please reply to this email or contact the event organizer": "Εάν έχετε ερωτήσεις ή χρειάζεστε βοήθεια, παρακαλώ απαντήστε σε αυτό το email ή επικοινωνήστε με τον διοργανωτή", + "at": "στο", + "If you have any questions or need assistance, please respond to this email.": "Εάν έχετε ερωτήσεις ή χρειάζεστε βοήθεια, παρακαλώ απαντήστε σε αυτό το email.", + "Your recent order for": "Η πρόσφατη παραγγελία σας για", + "was not successful.": "δεν ήταν επιτυχής.", + "View Event Homepage": "Προβολή Αρχικής Σελίδας Εκδήλωσης", + "If you have any questions or need assistance, feel free to reach out to our support team": "Εάν έχετε ερωτήσεις ή χρειάζεστε βοήθεια, μη διστάσετε να επικοινωνήσετε με την ομάδα υποστήριξης", + "Best regards": "Με εκτίμηση", + "You have received a refund of :refundAmount for the following event: :eventTitle.": "Λάβατε επιστροφή ποσού :refundAmount για την παρακάτω εκδήλωση: :eventTitle.", + "Congratulations! You\\'ve received a new order for ": "Συγχαρητήρια! Λάβατε νέα παραγγελία για ", + "Please find the details below.": "Παρακαλώ βρείτε τις λεπτομέρειες παρακάτω.", + "Your recent order for :eventTitle was not successful. The order expired while you were completing the payment. We have issued a refund for the order.": "Η πρόσφατη παραγγελία σας για :eventTitle δεν ήταν επιτυχής. Η παραγγελία έληξε κατά τη διάρκεια της πληρωμής. Εκδώσαμε επιστροφή για την παραγγελία.", + "We apologize for the inconvenience. If you have any questions or need assistance, feel free to reach us at": "Ζητούμε συγγνώμη για την αναστάτωση. Εάν έχετε ερωτήσεις ή χρειάζεστε βοήθεια, επικοινωνήστε μαζί μας στο", + "View Event Page": "Προβολή Σελίδας Εκδήλωσης", + "Hi.Events": "Hi.Events", + "Congratulations! Your order for :eventTitle on :eventDate at :eventTime was successful. Please find your order details below.": "Συγχαρητήρια! Η παραγγελία σας για :eventTitle στις :eventDate στις :eventTime ήταν επιτυχής. Παρακαλώ βρείτε τις λεπτομέρειες παρακάτω.", + "If you have any questions or need assistance, feel free to reach out to our friendly support team at": "Εάν έχετε ερωτήσεις ή χρειάζεστε βοήθεια, επικοινωνήστε με την ομάδα υποστήριξης στο", + "Please download your tickets from the order summary page.": "Παρακαλώ κατεβάστε τα εισιτήριά σας από τη σελίδα σύνοψης παραγγελίας.", + "Make sure to note the event date, time, and location.": "Φροντίστε να σημειώσετε την ημερομηνία, ώρα και τοποθεσία της εκδήλωσης.", + "Keep an eye on your email for any updates from the event organizer.": "Παρακολουθείτε το email σας για ενημερώσεις από τον διοργανωτή.", + "Hi :name": "Γεια :name", + "To get started and activate your account, please click the link below to confirm your email address:": "Για να ξεκινήσετε και να ενεργοποιήσετε τον λογαριασμό σας, κάντε κλικ στον παρακάτω σύνδεσμο για επιβεβαίωση email:", + "If you did not create an account with us, no further action is required. Your email address will not be used without confirmation.": "Εάν δεν δημιουργήσατε λογαριασμό μαζί μας, δεν απαιτείται περαιτέρω ενέργεια. Το email σας δεν θα χρησιμοποιηθεί χωρίς επιβεβαίωση.", + "You have requested to change your email address to :pendingEmail. Please click the link below to confirm this change.": "Ζητήσατε αλλαγή email σε :pendingEmail. Κάντε κλικ στον παρακάτω σύνδεσμο για επιβεβαίωση.", + "If you did not request this change, please immediately change your password.": "Εάν δεν ζητήσατε αυτή την αλλαγή, αλλάξτε αμέσως τον κωδικό πρόσβασής σας.", + "Thanks,": "Ευχαριστούμε,", + "You\\'ve been invited to join :appName.": "Έχετε προσκληθεί να συμμετάσχετε στο :appName.", + "To accept the invitation, please click the link below:": "Για να αποδεχτείτε την πρόσκληση, κάντε κλικ στον παρακάτω σύνδεσμο:", + "Least capacity": "Μικρότερη χωρητικότητα", + "Most capacity": "Μεγαλύτερη χωρητικότητα", + "Ticket is hidden without promo code": "Το εισιτήριο είναι κρυφό χωρίς κωδικό προώθησης", + "Ticket is before sale start date": "Το εισιτήριο είναι πριν την ημερομηνία έναρξης πώλησης", + "Ticket is after sale end date": "Το εισιτήριο είναι μετά την ημερομηνία λήξης πώλησης", + "Price is before sale start date": "Η τιμή είναι πριν την ημερομηνία έναρξης πώλησης", + "Price is after sale end date": "Η τιμή είναι μετά την ημερομηνία λήξης πώλησης", + "Price is sold out": "Η τιμή είναι εξαντλημένη", + "Price is hidden": "Η τιμή είναι κρυφή", + "The expiration date must be after the activation date.": "Η ημερομηνία λήξης πρέπει να είναι μετά την ημερομηνία ενεργοποίησης.", + "The activation date must be before the expiration date.": "Η ημερομηνία ενεργοποίησης πρέπει να είναι πριν την ημερομηνία λήξης.", + "Attendee :attendee_name is not allowed to check in using this check-in list": "Ο συμμετέχων :attendee_name δεν επιτρέπεται να κάνει check-in με αυτή τη λίστα", + "Invalid attendee code detected: :attendees ": "Εντοπίστηκε μη έγκυρος κωδικός συμμετέχοντα: :attendees", + "Check-in list not found": "Η λίστα check-in δεν βρέθηκε", + "Attendee :attendee_name is already checked in": "Ο συμμετέχων :attendee_name έχει ήδη κάνει check-in", + "Check-in list has expired": "Η λίστα check-in έχει λήξει", + "Check-in list is not active yes": "Η λίστα check-in δεν είναι ακόμη ενεργή", + "This attendee is not checked in": "Αυτός ο συμμετέχων δεν έχει κάνει check-in", + "Attendee does not belong to this check-in list": "Ο συμμετέχων δεν ανήκει σε αυτή τη λίστα check-in", + "Attendee :attendee_name\\'s ticket is cancelled": "Το εισιτήριο του συμμετέχοντα :attendee_name έχει ακυρωθεί", + "Check-in list is not active yet": "Η λίστα check-in δεν είναι ακόμη ενεργή", + "The number of attendees does not match the number of tickets in the order": "Ο αριθμός συμμετεχόντων δεν αντιστοιχεί στον αριθμό εισιτηρίων στην παραγγελία", + "Product is required": "Το προϊόν είναι υποχρεωτικό", + "Product price is required": "Η τιμή προϊόντος είναι υποχρεωτική", + "Please select at least one product.": "Παρακαλώ επιλέξτε τουλάχιστον ένα προϊόν.", + "The sale start date must be after the product sale start date.": "Η ημερομηνία έναρξης πώλησης πρέπει να είναι μετά την ημερομηνία έναρξης πώλησης προϊόντος.", + "You must select a product category.": "Πρέπει να επιλέξετε κατηγορία προϊόντος.", + "Invalid direction. Must be either asc or desc": "Μη έγκυρη κατεύθυνση. Πρέπει να είναι asc ή desc", + "DomainObject must be a valid :interface.": "Το DomainObject πρέπει να είναι έγκυρο :interface.", + "Nested relationships must be an array of Relationship objects.": "Οι ένθετες σχέσεις πρέπει να είναι πίνακας αντικειμένων Relationship.", + "OrderAndDirections must be an array of OrderAndDirection objects.": "Το OrderAndDirections πρέπει να είναι πίνακας αντικειμένων OrderAndDirection.", + "Attendee :attendee_name\\'s product is cancelled": "Το προϊόν του συμμετέχοντα :attendee_name έχει ακυρωθεί", + "Tickets": "Εισιτήρια", + "There are no tickets available for this event.": "Δεν υπάρχουν διαθέσιμα εισιτήρια για αυτή την εκδήλωση.", + "You haven\\'t selected any products": "Δεν έχετε επιλέξει προϊόντα", + "The maximum number of products available for :products is :max": "Ο μέγιστος αριθμός διαθέσιμων προϊόντων για :products είναι :max", + "You must order at least :min products for :product": "Πρέπει να παραγγείλετε τουλάχιστον :min προϊόντα για :product", + "The product :product is sold out": "Το προϊόν :product είναι εξαντλημένο", + "The maximum number of products available for :product is :max": "Ο μέγιστος αριθμός διαθέσιμων τεμαχίων για :product είναι :max", + "Sorry, these products are sold out": "Λυπούμαστε, αυτά τα προϊόντα είναι εξαντλημένα", + "The maximum number of products available is :max": "Ο μέγιστος αριθμός διαθέσιμων προϊόντων είναι :max", + "Product with id :id not found": "Το προϊόν με αναγνωριστικό :id δεν βρέθηκε", + "You cannot delete this product because it has orders associated with it. You can hide it instead.": "Δεν μπορείτε να διαγράψετε αυτό το προϊόν καθώς υπάρχουν παραγγελίες συνδεδεμένες. Μπορείτε να το αποκρύψετε.", + "Invalid product ids: :ids": "Μη έγκυρα αναγνωριστικά προϊόντων: :ids", + "Product is hidden without promo code": "Το προϊόν είναι κρυφό χωρίς κωδικό προώθησης", + "Product is sold out": "Το προϊόν είναι εξαντλημένο", + "Product is before sale start date": "Το προϊόν είναι πριν την ημερομηνία έναρξης πώλησης", + "Product is after sale end date": "Το προϊόν είναι μετά την ημερομηνία λήξης πώλησης", + "Product is hidden": "Το προϊόν είναι κρυφό", + "Cannot delete product price with id :id because it has sales": "Δεν είναι δυνατή η διαγραφή τιμής προϊόντος με αναγνωριστικό :id διότι έχει πωλήσεις", + "You cannot delete this product category because it contains the following products: :products. These products are linked to existing orders. Please move the :product_name to another category before attempting to delete this one.": "Δεν μπορείτε να διαγράψετε αυτή την κατηγορία προϊόντων καθώς περιέχει τα εξής προϊόντα: :products. Αυτά τα προϊόντα συνδέονται με υπάρχουσες παραγγελίες. Μεταφέρετε το :product_name σε άλλη κατηγορία πριν τη διαγραφή.", + "products": "προϊόντα", + "product": "προϊόν", + "Product category :productCategoryId has been deleted.": "Η κατηγορία προϊόντων :productCategoryId έχει διαγραφεί.", + "You cannot delete the last product category. Please create another category before deleting this one.": "Δεν μπορείτε να διαγράψετε την τελευταία κατηγορία προϊόντων. Δημιουργήστε άλλη κατηγορία πριν τη διαγραφή αυτής.", + "The product category with ID :id was not found.": "Η κατηγορία προϊόντων με αναγνωριστικό :id δεν βρέθηκε.", + "Limit Reached": "Το όριο συμπληρώθηκε", + "There are no tickets available. ' .\n 'If you would like to assign a product to this attendee,' .\n ' please adjust the product\\'s available quantity.": "Δεν υπάρχουν διαθέσιμα εισιτήρια. Εάν θέλετε να αναθέσετε προϊόν σε αυτόν τον συμμετέχοντα, παρακαλώ προσαρμόστε τη διαθέσιμη ποσότητα προϊόντος.", + "The product price ID is invalid.": "Το αναγνωριστικό τιμής προϊόντος δεν είναι έγκυρο.", + "Product ID is not valid": "Το αναγνωριστικό προϊόντος δεν είναι έγκυρο", + "There are no products available. If you would like to assign this product to this attendee, please adjust the product\\'s available quantity.": "Δεν υπάρχουν διαθέσιμα προϊόντα. Εάν θέλετε να αναθέσετε αυτό το προϊόν σε αυτόν τον συμμετέχοντα, προσαρμόστε τη διαθέσιμη ποσότητα.", + "There is an unexpected product price ID in the order": "Υπάρχει μη αναμενόμενο αναγνωριστικό τιμής προϊόντος στην παραγγελία", + "Product type cannot be changed as products have been registered for this type": "Ο τύπος προϊόντος δεν μπορεί να αλλάξει καθώς προϊόντα έχουν εγγραφεί για αυτόν τον τύπο", + "The ordered category IDs must exactly match all categories for the event without missing or extra IDs.": "Τα αναγνωριστικά κατηγοριών πρέπει να αντιστοιχούν ακριβώς σε όλες τις κατηγορίες της εκδήλωσης.", + "The ordered product IDs must exactly match all products for the event without missing or extra IDs.": "Τα αναγνωριστικά προϊόντων πρέπει να αντιστοιχούν ακριβώς σε όλα τα προϊόντα της εκδήλωσης.", + "This product is outdated. Please reload the page.": "Αυτό το προϊόν είναι παλιό. Παρακαλώ φορτώστε ξανά τη σελίδα.", + "Product ID": "Αναγνωριστικό Προϊόντος", + "Product Name": "Όνομα Προϊόντος", + "Event ID": "Αναγνωριστικό Εκδήλωσης", + "Public ID": "Δημόσιο Αναγνωριστικό", + "Short ID": "Σύντομο Αναγνωριστικό", + "Created Date": "Ημερομηνία Δημιουργίας", + "Last Updated Date": "Ημερομηνία Τελευταίας Ενημέρωσης", + "Total Before Additions": "Σύνολο Πριν Προσθέσεις", + "Total Gross": "Ακαθάριστο Σύνολο", + "Total Tax": "Συνολικός Φόρος", + "Total Fee": "Συνολική Χρέωση", + "Total Refunded": "Συνολικές Επιστροφές", + "Payment Gateway": "Πύλη Πληρωμής", + "Is Partially Refunded": "Μερικώς Επιστρεφόμενη", + "Is Fully Refunded": "Πλήρως Επιστρεφόμενη", + "Is Manually Created": "Δημιουργήθηκε Χειροκίνητα", + "Failed to handle incoming Stripe webhook": "Αποτυχία επεξεργασίας εισερχόμενου webhook Stripe", + "Notes must be less than 2000 characters": "Οι σημειώσεις πρέπει να είναι λιγότερο από 2000 χαρακτήρες", + "Invalid payment provider selected.": "Επιλέχθηκε μη έγκυρος πάροχος πληρωμής.", + "Payment instructions are required when offline payments are enabled.": "Οι οδηγίες πληρωμής είναι υποχρεωτικές όταν είναι ενεργοποιημένες οι εκτός σύνδεσης πληρωμές.", + "The invoice prefix may only contain letters, numbers, and hyphens.": "Το πρόθεμα τιμολογίου μπορεί να περιέχει μόνο γράμματα, αριθμούς και παύλες.", + "The organization name is required when invoicing is enabled.": "Το όνομα οργανισμού είναι υποχρεωτικό όταν είναι ενεργοποιημένη η τιμολόγηση.", + "The organization address is required when invoicing is enabled.": "Η διεύθυνση οργανισμού είναι υποχρεωτική όταν είναι ενεργοποιημένη η τιμολόγηση.", + "The invoice start number must be at least 1.": "Ο αρχικός αριθμός τιμολογίου πρέπει να είναι τουλάχιστον 1.", + "There is no default account configuration available": "Δεν υπάρχει διαθέσιμη προεπιλεγμένη ρύθμιση λογαριασμού", + "Product price ID is not valid": "Το αναγνωριστικό τιμής προϊόντος δεν είναι έγκυρο", + "Editing order with ID: :id": "Επεξεργασία παραγγελίας με αναγνωριστικό: :id", + "Marking order as paid": "Σήμανση παραγγελίας ως πληρωμένης", + "Received a :event Stripe event, which has no handler": "Ελήφθη συμβάν Stripe :event χωρίς διαχειριστή", + "Order is not in the correct status to transition to offline payment": "Η παραγγελία δεν βρίσκεται σε σωστή κατάσταση για μετάβαση σε εκτός σύνδεσης πληρωμή", + "Order reservation has expired": "Η κράτηση παραγγελίας έχει λήξει", + "Offline payments are not enabled for this event": "Οι εκτός σύνδεσης πληρωμές δεν είναι ενεργοποιημένες για αυτή την εκδήλωση", + "There are no products available in this category": "Δεν υπάρχουν διαθέσιμα προϊόντα σε αυτή την κατηγορία", + "Webhook not found": "Το webhook δεν βρέθηκε", + "Unable to check in as attendee :attendee_name\\'s order is awaiting payment": "Αδύνατος ο έλεγχος εισόδου καθώς η παραγγελία του συμμετέχοντα :attendee_name αναμένει πληρωμή", + "Attendee :attendee_name\\'s order cannot be marked as paid. Please check your event settings": "Η παραγγελία του συμμετέχοντα :attendee_name δεν μπορεί να σημανθεί ως πληρωμένη. Ελέγξτε τις ρυθμίσεις εκδήλωσης", + "Invoice already exists": "Το τιμολόγιο υπάρχει ήδη", + "Invoice not found": "Το τιμολόγιο δεν βρέθηκε", + "Order is not awaiting offline payment": "Η παραγγελία δεν αναμένει εκτός σύνδεσης πληρωμή", + "Refund already processed": "Η επιστροφή έχει ήδη επεξεργαστεί", + "Stripe refund successful": "Επιστροφή Stripe επιτυχής", + "There are no tickets available for this event": "Δεν υπάρχουν διαθέσιμα εισιτήρια για αυτή την εκδήλωση", + "Address line 1 is required": "Η γραμμή διεύθυνσης 1 είναι υποχρεωτική", + "City is required": "Η πόλη είναι υποχρεωτική", + "Zip or postal code is required": "Ο ταχυδρομικός κώδικας είναι υποχρεωτικός", + "Country is required": "Η χώρα είναι υποχρεωτική", + "If you did not request a password reset, please immediately reset your password.": "Εάν δεν ζητήσατε επαναφορά κωδικού, αλλάξτε αμέσως τον κωδικό πρόσβασής σας.", + "ℹ️ Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "ℹ️ Η παραγγελία σας εκκρεμεί πληρωμή. Τα εισιτήρια έχουν εκδοθεί αλλά δεν θα ισχύουν μέχρι να ληφθεί η πληρωμή.", + "ℹ️ This order is pending payment. Please mark the payment as received on the order management page once payment is received.": "ℹ️ Αυτή η παραγγελία εκκρεμεί πληρωμή. Σημειώστε την πληρωμή ως ληφθείσα στη σελίδα διαχείρισης παραγγελιών όταν ληφθεί.", + "Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "Η παραγγελία σας εκκρεμεί πληρωμή. Τα εισιτήρια έχουν εκδοθεί αλλά δεν θα ισχύουν μέχρι να ληφθεί η πληρωμή.", + "For any queries, please contact us at": "Για οποιαδήποτε ερώτηση, επικοινωνήστε μαζί μας στο", + "Food & Drink": "Φαγητό & Ποτό", + "Charity": "Φιλανθρωπία", + "Total Sales Gross": "Ακαθάριστες Συνολικές Πωλήσεις", + "Updated At": "Ενημερώθηκε Στις", + "Order URL": "URL Παραγγελίας", + "Attendee Answers": "Απαντήσεις Συμμετεχόντων", + "Order Name": "Όνομα Παραγγελίας", + "Order Answers": "Απαντήσεις Παραγγελίας", + "Product Answers": "Απαντήσεις Προϊόντος", + "Payment Provider": "Πάροχος Πληρωμής", + "Invalid invite token": "Μη έγκυρο token πρόσκλησης", + "Affiliate not found": "Ο συνεργάτης δεν βρέθηκε", + "Organizer with ID :organizerId is not live and user is not authenticated": "Ο διοργανωτής με ID :organizerId δεν είναι ενεργός και ο χρήστης δεν είναι πιστοποιημένος", + "The image must be at least 600 pixels wide and 50 pixels tall, and no more than 4000 pixels wide and 4000 pixels tall.": "Η εικόνα πρέπει να είναι τουλάχιστον 600x50 pixels και όχι περισσότερο από 4000x4000 pixels.", + "The image must be at least :minWidth x :minHeight pixels.": "Η εικόνα πρέπει να είναι τουλάχιστον :minWidth x :minHeight pixels.", + "The entity ID is required when type is provided.": "Το αναγνωριστικό οντότητας είναι υποχρεωτικό όταν παρέχεται τύπος.", + "The type is required when entity ID is provided.": "Ο τύπος είναι υποχρεωτικός όταν παρέχεται αναγνωριστικό οντότητας.", + "An affiliate with this code already exists for this event": "Ένας συνεργάτης με αυτόν τον κωδικό υπάρχει ήδη για αυτή την εκδήλωση", + "Due to issues with spam, you must contact us to enable your account for sending messages. ' .\n 'Please contact us at :email": "Λόγω προβλημάτων spam, πρέπει να επικοινωνήσετε μαζί μας για να ενεργοποιήσετε τον λογαριασμό σας για αποστολή μηνυμάτων. Παρακαλώ επικοινωνήστε μαζί μας στο :email", + "Sorry, is expired or not in a valid state.": "Λυπούμαστε, έχει λήξει ή δεν βρίσκεται σε έγκυρη κατάσταση.", + "You must verify your account before you can update an organizer\\'s status.\n You can resend the confirmation by visiting your profile page.": "Πρέπει να επαληθεύσετε τον λογαριασμό σας πριν ενημερώσετε την κατάσταση διοργανωτή. Μπορείτε να στείλετε ξανά την επιβεβαίωση από τη σελίδα προφίλ σας.", + "Job not found": "Η εργασία δεν βρέθηκε", + "Export file not found": "Το αρχείο εξαγωγής δεν βρέθηκε", + "Job is still in progress": "Η εργασία βρίσκεται ακόμη σε εξέλιξη", + "Additional Information": "Πρόσθετες Πληροφορίες", + "If you have any questions or need assistance, please contact": "Εάν έχετε ερωτήσεις ή χρειάζεστε βοήθεια, παρακαλώ επικοινωνήστε", + "Hello :name": "Γεια :name", + "You have received a new message from": "Λάβατε νέο μήνυμα από", + "Reply to :name": "Απαντήστε στον :name", + "This message was sent via your organizer contact form.": "Αυτό το μήνυμα στάλθηκε μέσω της φόρμας επικοινωνίας του διοργανωτή.", + "Your email confirmation code is:": "Ο κωδικός επιβεβαίωσης email σας είναι:", + "Liquid": "Liquid", + "Blade": "Blade", + "Configuration assigned successfully.": "Η ρύθμιση αντιστοιχίστηκε επιτυχώς.", + "Impersonation started": "Σύνδεση ως χρήστης ξεκίνησε", + "Not currently impersonating": "Δεν βρίσκεστε σε λειτουργία σύνδεσης ως χρήστης", + "Impersonation ended": "Σύνδεση ως χρήστης ολοκληρώθηκε", + "No user role found in token": "Δεν βρέθηκε ρόλος χρήστη στο token", + "You cannot modify email templates until your account is verified.": "Δεν μπορείτε να τροποποιήσετε πρότυπα email μέχρι να επαληθευτεί ο λογαριασμός σας.", + "Due to issues with spam, you must connect a Stripe account before you can modify email templates.": "Λόγω προβλημάτων spam, πρέπει να συνδέσετε λογαριασμό Stripe πριν τροποποιήσετε πρότυπα email.", + "Invalid template type": "Μη έγκυρος τύπος προτύπου", + "Superadmin user is viewing non-live event with ID :eventId": "Ο χρήστης superadmin προβάλλει μη ενεργή εκδήλωση με ID :eventId", + "Invalid report type.": "Μη έγκυρος τύπος αναφοράς.", + "Date range must be less than 370 days.": "Το εύρος ημερομηνιών πρέπει να είναι μικρότερο από 370 ημέρες.", + "The footer text may not be greater than 500 characters.": "Το κείμενο υποσέλιδου δεν μπορεί να υπερβαίνει τους 500 χαρακτήρες.", + "The layout type must be default or modern.": "Ο τύπος διάταξης πρέπει να είναι default ή modern.", + "The mode must be light or dark.": "Η λειτουργία πρέπει να είναι light ή dark.", + "The background type must be COLOR or MIRROR_COVER_IMAGE.": "Ο τύπος φόντου πρέπει να είναι COLOR ή MIRROR_COVER_IMAGE.", + "First name must be a string": "Το όνομα πρέπει να είναι κείμενο", + "First name must not exceed 255 characters": "Το όνομα δεν πρέπει να υπερβαίνει τους 255 χαρακτήρες", + "Last name must be a string": "Το επώνυμο πρέπει να είναι κείμενο", + "Last name must not exceed 255 characters": "Το επώνυμο δεν πρέπει να υπερβαίνει τους 255 χαρακτήρες", + "Email must not exceed 255 characters": "Το email δεν πρέπει να υπερβαίνει τους 255 χαρακτήρες", + "Validation failed after multiple attempts: :error": "Η επικύρωση απέτυχε μετά από πολλαπλές προσπάθειες: :error", + "Response from :organizerName": "Απάντηση από :organizerName", + "Invalid VAT number format": "Μη έγκυρη μορφή αριθμού ΦΠΑ", + "The system default configuration cannot be deleted.": "Η προεπιλεγμένη ρύθμιση συστήματος δεν μπορεί να διαγραφεί.", + "User does not belong to this account": "Ο χρήστης δεν ανήκει σε αυτόν τον λογαριασμό", + "Impersonation not allowed": "Η σύνδεση ως άλλος χρήστης δεν επιτρέπεται", + "Email template not found": "Το πρότυπο email δεν βρέθηκε", + "Unable to verify Stripe signature with any platform": "Αδύνατη η επαλήθευση υπογραφής Stripe με οποιαδήποτε πλατφόρμα", + "Order :id not found for event :eventId": "Η παραγγελία :id δεν βρέθηκε για την εκδήλωση :eventId", + "Order is not in a valid status to be abandoned": "Η παραγγελία δεν βρίσκεται σε έγκυρη κατάσταση για εγκατάλειψη", + "Order has already expired": "Η παραγγελία έχει ήδη λήξει", + "Attendee not found": "Ο συμμετέχων δεν βρέθηκε", + "Event not found": "Η εκδήλωση δεν βρέθηκε", + "Page must be a positive integer": "Η σελίδα πρέπει να είναι θετικός ακέραιος", + "Page not found": "Η σελίδα δεν βρέθηκε", + "Invalid or expired link. Please request a new one.": "Μη έγκυρος ή ληγμένος σύνδεσμος. Παρακαλώ ζητήστε νέο.", + "This link has expired. Please request a new one.": "Αυτός ο σύνδεσμος έχει λήξει. Παρακαλώ ζητήστε νέο.", + "SUPERADMIN users cannot be created through the application": "Οι χρήστες SUPERADMIN δεν μπορούν να δημιουργηθούν μέσω της εφαρμογής", + "Cannot associate a user with SUPERADMIN role to an account": "Δεν είναι δυνατή η συσχέτιση χρήστη με ρόλο SUPERADMIN σε λογαριασμό", + "Summer Music Festival 2024": "Καλοκαιρινό Φεστιβάλ Μουσικής 2024", + "3 Arena, North Wall Quay, Dublin 1, Ireland": "3 Arena, North Wall Quay, Δουβλίνο 1, Ιρλανδία", + "Join us for an unforgettable evening of live music featuring top artists from around the world.": "Ελάτε μαζί μας για μια αξέχαστη βραδιά ζωντανής μουσικής με κορυφαίους καλλιτέχνες από όλο τον κόσμο.", + "Please transfer the total amount to the following bank account within 5 business days.": "Παρακαλώ μεταφέρετε το συνολικό ποσό στον παρακάτω τραπεζικό λογαριασμό εντός 5 εργάσιμων ημερών.", + "Thank you for your purchase! We look forward to seeing you at the event.": "Σας ευχαριστούμε για την αγορά σας! Ανυπομονούμε να σας δούμε στην εκδήλωση.", + "Processing charge event": "Επεξεργασία συμβάντος χρέωσης", + "Charge not in succeeded status, skipping": "Η χρέωση δεν βρίσκεται σε κατάσταση επιτυχίας, παράλειψη", + "Stripe payment not found for charge": "Δεν βρέθηκε πληρωμή Stripe για τη χρέωση", + "Order not found for charge": "Δεν βρέθηκε παραγγελία για τη χρέωση", + "Extracting platform fee for order": "Εξαγωγή χρέωσης πλατφόρμας για παραγγελία", + "Retrieving balance transaction from Stripe": "Ανάκτηση συναλλαγής υπολοίπου από Stripe", + "No balance transaction found for charge": "Δεν βρέθηκε συναλλαγή υπολοίπου για τη χρέωση", + "Platform fee already stored for this transaction": "Η χρέωση πλατφόρμας έχει ήδη αποθηκευτεί για αυτή τη συναλλαγή", + "Platform fee stored successfully": "Η χρέωση πλατφόρμας αποθηκεύτηκε επιτυχώς", + "Failed to store platform fee": "Αποτυχία αποθήκευσης χρέωσης πλατφόρμας", + "The name of the event": "Το όνομα της εκδήλωσης", + "The event start date": "Η ημερομηνία έναρξης εκδήλωσης", + "The event start time": "Η ώρα έναρξης εκδήλωσης", + "The event end date": "Η ημερομηνία λήξης εκδήλωσης", + "The event end time": "Η ώρα λήξης εκδήλωσης", + "The full event address": "Η πλήρης διεύθυνση εκδήλωσης", + "The event venue name": "Το όνομα χώρου εκδήλωσης", + "The venue address line 1": "Γραμμή διεύθυνσης χώρου 1", + "The venue address line 2": "Γραμμή διεύθυνσης χώρου 2", + "The venue city": "Η πόλη του χώρου", + "The venue state or region": "Η περιοχή ή η πολιτεία του χώρου", + "The venue ZIP or postal code": "Ο ταχυδρομικός κώδικας του χώρου", + "The venue country code": "Ο κωδικός χώρας του χώρου", + "The event description": "Η περιγραφή εκδήλωσης", + "The organizer\\'s name": "Το όνομα του διοργανωτή", + "The organizer\\'s email": "Το email του διοργανωτή", + "The support email address": "Η διεύθυνση email υποστήριξης", + "Instructions for offline payment": "Οδηγίες για εκτός σύνδεσης πληρωμή", + "Message shown after checkout": "Μήνυμα που εμφανίζεται μετά την ολοκλήρωση αγοράς", + "Link to view the order summary": "Σύνδεσμος για προβολή σύνοψης παραγγελίας", + "The order reference number": "Ο αριθμός αναφοράς παραγγελίας", + "The total order amount": "Το συνολικό ποσό παραγγελίας", + "The order date": "Η ημερομηνία παραγγελίας", + "The first name of the person who placed the order": "Το όνομα του ατόμου που έκανε την παραγγελία", + "The last name of the person who placed the order": "Το επώνυμο του ατόμου που έκανε την παραγγελία", + "The email of the person who placed the order": "Το email του ατόμου που έκανε την παραγγελία", + "The attendee\\'s full name": "Το πλήρες όνομα του συμμετέχοντα", + "The attendee\\'s email": "Το email του συμμετέχοντα", + "The ticket type name": "Το όνομα τύπου εισιτηρίου", + "The ticket price": "Η τιμή εισιτηρίου", + "Link to view/download the ticket": "Σύνδεσμος για προβολή/λήψη εισιτηρίου", + "Stripe secret key not configured for platform: :platform": "Το μυστικό κλειδί Stripe δεν έχει ρυθμιστεί για πλατφόρμα: :platform", + "VIES service returned HTTP :status": "Η υπηρεσία VIES επέστρεψε HTTP :status", + "VAT number is not valid according to VIES": "Ο αριθμός ΦΠΑ δεν είναι έγκυρος σύμφωνα με το VIES", + "Connection error: :error": "Σφάλμα σύνδεσης: :error", + "Validation error: :error": "Σφάλμα επικύρωσης: :error", + "VIES service is temporarily busy. Validation will be retried.": "Η υπηρεσία VIES είναι προσωρινά απασχολημένη. Η επικύρωση θα επαναληφθεί.", + "Member State service is temporarily unavailable. Validation will be retried.": "Η υπηρεσία κράτους μέλους είναι προσωρινά μη διαθέσιμη. Η επικύρωση θα επαναληφθεί.", + "VIES service timed out. Validation will be retried.": "Η υπηρεσία VIES εξάντλησε το χρονικό όριο. Η επικύρωση θα επαναληφθεί.", + "VIES server is busy. Validation will be retried.": "Ο διακομιστής VIES είναι απασχολημένος. Η επικύρωση θα επαναληφθεί.", + "VIES service is unavailable. Validation will be retried.": "Η υπηρεσία VIES δεν είναι διαθέσιμη. Η επικύρωση θα επαναληφθεί.", + "VIES service has reached maximum requests. Validation will be retried.": "Η υπηρεσία VIES έχει φτάσει τον μέγιστο αριθμό αιτημάτων. Η επικύρωση θα επαναληφθεί.", + "Invalid VAT number format.": "Μη έγκυρη μορφή αριθμού ΦΠΑ.", + "Invalid requester information.": "Μη έγκυρα στοιχεία αιτούντος.", + "VIES validation error: :code": "Σφάλμα επικύρωσης VIES: :code", + "Email addresses do not match": "Οι διευθύνσεις email δεν ταιριάζουν", + "Please confirm the email address": "Παρακαλώ επιβεβαιώστε τη διεύθυνση email", + "Ticket Details Changed": "Τα στοιχεία εισιτηρίου άλλαξαν", + "The details on your ticket for **:eventName** have been updated.": "Τα στοιχεία εισιτηρίου σας για **:eventName** έχουν ενημερωθεί.", + "What Changed": "Τι άλλαξε", + "Security Information": "Πληροφορίες Ασφαλείας", + "This change was made from IP address: :ipAddress": "Αυτή η αλλαγή έγινε από τη διεύθυνση IP: :ipAddress", + "If you did not make this change, please contact the event organizer immediately.": "Εάν δεν κάνατε αυτή την αλλαγή, παρακαλώ επικοινωνήστε αμέσως με τον διοργανωτή.", + "Event Organizer: :organizerName": "Διοργανωτής: :organizerName", + "Contact: :email": "Επικοινωνία: :email", + "Order Details Changed": "Τα στοιχεία παραγγελίας άλλαξαν", + "The details on your order for **:eventName** have been updated.": "Τα στοιχεία παραγγελίας σας για **:eventName** έχουν ενημερωθεί.", + "We found :count order(s) associated with :email.": "Βρήκαμε :count παραγγελία/-ές συνδεδεμένη/-ες με :email.", + "Click the button below to view your tickets and order details.": "Κάντε κλικ στο παρακάτω κουμπί για προβολή εισιτηρίων και λεπτομερειών παραγγελίας.", + "If you did not request this, please ignore this email.": "Εάν δεν το ζητήσατε, αγνοήστε αυτό το email.", + "You have reached your daily message limit. Please try again later or contact support to increase your limits.": "Έχετε φτάσει το ημερήσιο όριο μηνυμάτων. Δοκιμάστε αργότερα ή επικοινωνήστε με την υποστήριξη για αύξηση ορίων.", + "The number of recipients exceeds your account limit. Please contact support to increase your limits.": "Ο αριθμός παραληπτών υπερβαίνει το όριο λογαριασμού. Επικοινωνήστε με την υποστήριξη για αύξηση ορίων.", + "Your account tier does not allow links in messages. Please contact support to enable this feature.": "Το επίπεδο λογαριασμού σας δεν επιτρέπει συνδέσμους στα μηνύματα. Επικοινωνήστε με την υποστήριξη.", + "Deleted :count failed jobs": "Διαγράφηκαν :count αποτυχημένες εργασίες", + "Failed job not found": "Η αποτυχημένη εργασία δεν βρέθηκε", + "Queued :count jobs for retry": "Τέθηκαν σε ουρά :count εργασίες για επανάληψη", + "Job queued for retry": "Η εργασία τέθηκε σε ουρά για επανάληψη", + "Message approved and queued for sending": "Το μήνυμα εγκρίθηκε και τέθηκε σε ουρά για αποστολή", + "Promo code not found": "Ο κωδικός προώθησης δεν βρέθηκε", + "Question not found": "Η ερώτηση δεν βρέθηκε", + "[Action Required] Message Pending Review - :subject": "[Απαιτείται Ενέργεια] Μήνυμα σε Αναμονή Αξιολόγησης - :subject", + "Message not found": "Το μήνυμα δεν βρέθηκε", + "Message must be in pending review status to be approved": "Το μήνυμα πρέπει να βρίσκεται σε κατάσταση εκκρεμούς αξιολόγησης για έγκριση", + "The :attribute must be a valid URL.": "Το :attribute πρέπει να είναι έγκυρο URL.", + "The :attribute must use http or https protocol.": "Το :attribute πρέπει να χρησιμοποιεί πρωτόκολλο http ή https.", + "The :attribute cannot point to localhost or internal addresses.": "Το :attribute δεν μπορεί να δείχνει σε localhost ή εσωτερικές διευθύνσεις.", + "The :attribute cannot use reserved domain names.": "Το :attribute δεν μπορεί να χρησιμοποιεί δεσμευμένα ονόματα domain.", + "The :attribute cannot point to cloud metadata endpoints.": "Το :attribute δεν μπορεί να δείχνει σε endpoints μεταδεδομένων cloud.", + "The :attribute cannot point to private or internal IP addresses.": "Το :attribute δεν μπορεί να δείχνει σε ιδιωτικές ή εσωτερικές διευθύνσεις IP.", + "A message has been flagged for review due to eligibility check failures.": "Ένα μήνυμα επισημάνθηκε για αξιολόγηση λόγω αποτυχιών ελέγχου επιλεξιμότητας.", + "Message Details": "Λεπτομέρειες Μηνύματος", + "Subject": "Θέμα", + "Account": "Λογαριασμός", + "Event": "Εκδήλωση", + "Message ID": "Αναγνωριστικό Μηνύματος", + "Eligibility Failures": "Αποτυχίες Επιλεξιμότητας", + "Stripe payment account not connected": "Ο λογαριασμός πληρωμής Stripe δεν είναι συνδεδεμένος", + "No completed paid orders on this account": "Δεν υπάρχουν ολοκληρωμένες πληρωμένες παραγγελίες σε αυτόν τον λογαριασμό", + "Event was created less than 24 hours ago": "Η εκδήλωση δημιουργήθηκε πριν από λιγότερο από 24 ώρες", + "Review Message": "Αξιολόγηση Μηνύματος" +} \ No newline at end of file diff --git a/backend/lang/es.json b/backend/lang/es.json index a3338aefb7..daa18cabfd 100644 --- a/backend/lang/es.json +++ b/backend/lang/es.json @@ -260,11 +260,11 @@ "Most capacity": "Mayor capacidad", "Sorry, these tickets are sold out": "Lo sentimos, estas entradas están agotadas", "The maximum number of tickets available is :max": "El número máximo de entradas disponibles es :max", - "Ticket is hidden without promo code": "El boleto está oculto sin código promocional", - "Ticket is sold out": "El boleto está agotado", - "Ticket is before sale start date": "El boleto es antes de la fecha de inicio de la venta", - "Ticket is after sale end date": "El boleto es después de la fecha de finalización de la venta", - "Ticket is hidden": "El boleto está oculto", + "Ticket is hidden without promo code": "La entrada está oculta sin código promocional", + "Ticket is sold out": "La entrada está agotada", + "Ticket is before sale start date": "La entrada es antes de la fecha de inicio de la venta", + "Ticket is after sale end date": "La entrada es después de la fecha de finalización de la venta", + "Ticket is hidden": "La entrada está oculta", "Price is before sale start date": "El precio es antes de la fecha de inicio de la venta", "Price is after sale end date": "El precio es después de la fecha de finalización de la venta", "Price is sold out": "El precio está agotado", @@ -393,16 +393,16 @@ "Order is not awaiting offline payment": "El pedido no está en espera de pago offline", "Refund already processed": "Reembolso ya procesado", "Stripe refund successful": "Reembolso de Stripe exitoso", - "There are no tickets available for this event": "No hay boletos disponibles para este evento", + "There are no tickets available for this event": "No hay entradas disponibles para este evento", "Address line 1 is required": "La dirección (línea 1) es obligatoria", "City is required": "La ciudad es obligatoria", "Zip or postal code is required": "El código postal es obligatorio", "Country is required": "El país es obligatorio", "If you did not request a password reset, please immediately reset your password.": "Si no solicitaste un restablecimiento de contraseña, por favor restablécela de inmediato.", - "ℹ️ Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "ℹ️ Tu pedido está pendiente de pago. Los boletos han sido emitidos, pero no serán válidos hasta que se reciba el pago.", + "ℹ️ Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "ℹ️ Tu pedido está pendiente de pago. Los entradas han sido emitidos, pero no serán válidos hasta que se reciba el pago.", "ℹ️ This order is pending payment. Please mark the payment as received on the order management page once payment is received.": "ℹ️ Este pedido está pendiente de pago. Por favor, márcalo como pagado en la página de gestión de pedidos una vez recibido el pago.", "Order Status:": "Estado del pedido:", - "Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "Tu pedido está pendiente de pago. Los boletos han sido emitidos, pero no serán válidos hasta que se reciba el pago.", + "Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "Tu pedido está pendiente de pago. Los entradas han sido emitidos, pero no serán válidos hasta que se reciba el pago.", "Payment Instructions": "Instrucciones de pago", "Please follow the instructions below to complete your payment.": "Por favor, sigue las instrucciones a continuación para completar tu pago.", "Invoice Number": "Número de factura", @@ -670,5 +670,42 @@ "View Event": "Ver evento", "Your waitlist offer has expired": "Tu oferta de la lista de espera ha expirado", "We'll notify you as soon as a spot becomes available.": "Te notificaremos en cuanto haya una plaza disponible.", - "You're on the waitlist!": "¡Estás en la lista de espera!" + "You're on the waitlist!": "¡Estás en la lista de espera!", + "Amount": "Importe", + "Amount Paid": "Importe pagado", + "Balance Due": "Saldo pendiente", + "Default": "Predeterminado", + "Description": "Descripción", + "Google Tag Manager is not available on hosted plans for security reasons.": "Google Tag Manager no está disponible en planes alojados por razones de seguridad.", + "Must be 6-30 alphanumeric characters": "Debe tener entre 6 y 30 caracteres alfanuméricos", + "Must be 9-20 digits (e.g., 1234567890)": "Debe tener entre 9 y 20 dígitos (p. ej., 1234567890)", + "Must start with G- followed by 6-20 characters (e.g., G-XXXXXXXXXX)": "Debe comenzar con G- seguido de 6 a 20 caracteres (p. ej., G-XXXXXXXXXX)", + "Must start with GTM- followed by 4-20 characters (e.g., GTM-XXXXXXX)": "Debe comenzar con GTM- seguido de 4 a 20 caracteres (p. ej., GTM-XXXXXXX)", + "Not enough products available. Please try again.": "No hay suficientes productos disponibles. Por favor, inténtelo de nuevo.", + "Only scheduled messages can be cancelled": "Solo se pueden cancelar los mensajes programados", + "Order Ascending": "Orden ascendente", + "Order Descending": "Orden descendente", + "Paid": "Pagado", + "Position ascending": "Posición ascendente", + "Position descending": "Posición descendente", + "Product not found for this event": "Producto no encontrado para este evento", + "Qty": "Cant.", + "Rate": "Tasa", + "The scheduled time must be in the future.": "La hora programada debe ser en el futuro.", + "The selected font is not supported.": "La fuente seleccionada no es compatible.", + "There are no waiting entries for this product": "No hay entradas en lista de espera para este producto", + "This event cannot be deleted because it has completed orders. Please cancel or refund all orders first.": "Este evento no se puede eliminar porque tiene pedidos completados. Por favor, cancele o reembolse todos los pedidos primero.", + "This event has completed orders. Please cancel or refund all orders before deleting.": "Este evento tiene pedidos completados. Por favor, cancele o reembolse todos los pedidos antes de eliminarlo.", + "This message can no longer be cancelled": "Este mensaje ya no se puede cancelar", + "This organizer has events with completed orders. Please cancel or refund all orders first.": "Este organizador tiene eventos con pedidos completados. Por favor, cancele o reembolse todos los pedidos primero.", + "This waitlist entry cannot be cancelled": "Esta entrada de la lista de espera no se puede cancelar", + "This waitlist entry cannot be offered in its current status": "Esta entrada de la lista de espera no se puede ofrecer en su estado actual", + "Unpaid": "No pagado", + "Void": "Anulado", + "Waitlist entry not found": "Entrada de lista de espera no encontrada", + "Waitlist is not enabled for this product": "La lista de espera no está habilitada para este producto", + "You are already on the waitlist for this product": "Ya estás en la lista de espera de este producto", + "You cannot archive the last active organizer on your account.": "No puedes archivar el último organizador activo de tu cuenta.", + "You cannot delete the last organizer on your account.": "No puedes eliminar el último organizador de tu cuenta.", + "You must acknowledge your data controller responsibilities before enabling tracking pixels.": "Debes reconocer tus responsabilidades como responsable del tratamiento de datos antes de habilitar los píxeles de seguimiento." } diff --git a/backend/tests/Unit/Http/Request/EventSettings/UpdateEventSettingsRequestTest.php b/backend/tests/Unit/Http/Request/EventSettings/UpdateEventSettingsRequestTest.php index 0e66005956..66d8ffa3f2 100644 --- a/backend/tests/Unit/Http/Request/EventSettings/UpdateEventSettingsRequestTest.php +++ b/backend/tests/Unit/Http/Request/EventSettings/UpdateEventSettingsRequestTest.php @@ -43,4 +43,34 @@ public function test_date_display_mode_is_optional(): void $this->assertFalse($validator->errors()->has('ticket_design_settings.date_display_mode')); } + + public function test_allow_copy_details_to_all_attendees_accepts_boolean(): void + { + $validator = Validator::make( + ['allow_copy_details_to_all_attendees' => false], + (new UpdateEventSettingsRequest)->rules() + ); + + $this->assertFalse($validator->errors()->has('allow_copy_details_to_all_attendees')); + } + + public function test_allow_copy_details_to_all_attendees_rejects_non_boolean(): void + { + $validator = Validator::make( + ['allow_copy_details_to_all_attendees' => 'not-a-boolean'], + (new UpdateEventSettingsRequest)->rules() + ); + + $this->assertTrue($validator->errors()->has('allow_copy_details_to_all_attendees')); + } + + public function test_allow_copy_details_to_all_attendees_is_optional(): void + { + $validator = Validator::make( + ['ticket_design_settings' => ['accent_color' => '#333333']], + (new UpdateEventSettingsRequest)->rules() + ); + + $this->assertFalse($validator->errors()->has('allow_copy_details_to_all_attendees')); + } } diff --git a/backend/tests/Unit/Resources/Attendee/AttendeeWithCheckInPublicResourceTest.php b/backend/tests/Unit/Resources/Attendee/AttendeeWithCheckInPublicResourceTest.php new file mode 100644 index 0000000000..1780da0997 --- /dev/null +++ b/backend/tests/Unit/Resources/Attendee/AttendeeWithCheckInPublicResourceTest.php @@ -0,0 +1,33 @@ +setId(1) + ->setOrderId(10) + ->setProductId(20) + ->setProductPriceId(30) + ->setEmail('attendee@example.com') + ->setFirstName('Jane') + ->setLastName('Attendee') + ->setPublicId('A-12345') + ->setStatus('ACTIVE'); + + $resource = (new AttendeeWithCheckInPublicResource($attendee))->toArray(Request::create('/')); + + $this->assertArrayNotHasKey('email', $resource); + $this->assertSame('Jane', $resource['first_name']); + $this->assertSame('Attendee', $resource['last_name']); + $this->assertSame('A-12345', $resource['public_id']); + $this->assertSame(10, $resource['order_id']); + } +} diff --git a/backend/tests/Unit/Resources/Event/EventSettingsResourcePublicTest.php b/backend/tests/Unit/Resources/Event/EventSettingsResourcePublicTest.php new file mode 100644 index 0000000000..96fa0dccaa --- /dev/null +++ b/backend/tests/Unit/Resources/Event/EventSettingsResourcePublicTest.php @@ -0,0 +1,34 @@ +setAllowCopyDetailsToAllAttendees(true); + + $resource = (new EventSettingsResourcePublic($settings))->toArray(Request::create('/')); + + // Load-bearing: the checkout can only hide the control if this flag is on the + // public payload, so it must always be present (outside the post-checkout block). + $this->assertArrayHasKey('allow_copy_details_to_all_attendees', $resource); + $this->assertTrue($resource['allow_copy_details_to_all_attendees']); + } + + public function test_public_resource_exposes_allow_copy_details_when_disabled(): void + { + $settings = (new EventSettingDomainObject()) + ->setAllowCopyDetailsToAllAttendees(false); + + $resource = (new EventSettingsResourcePublic($settings))->toArray(Request::create('/')); + + $this->assertFalse($resource['allow_copy_details_to_all_attendees']); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandlerTest.php new file mode 100644 index 0000000000..55d841ed00 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandlerTest.php @@ -0,0 +1,76 @@ +runPartialUpdate( + existingValue: true, + settings: ['allow_copy_details_to_all_attendees' => false], + ); + + $this->assertFalse($dto->allow_copy_details_to_all_attendees); + } + + public function test_omitted_show_copy_details_key_falls_back_to_existing_value(): void + { + // Existing event has the control OFF; the PATCH omits the key entirely. + // It must keep the existing value (false), NOT reset to the default (true). + $dto = $this->runPartialUpdate( + existingValue: false, + settings: [], + ); + + $this->assertFalse($dto->allow_copy_details_to_all_attendees); + } + + /** + * Drives the partial handler and returns the UpdateEventSettingsDTO it forwards + * to the (mocked) full handler, so we can assert how the field was resolved. + */ + private function runPartialUpdate(bool $existingValue, array $settings): UpdateEventSettingsDTO + { + $existingSettings = (new EventSettingDomainObject()) + ->setAllowCopyDetailsToAllAttendees($existingValue) + ->setPaymentProviders([]); + + $repository = Mockery::mock(EventSettingsRepositoryInterface::class); + $repository->shouldReceive('findFirstWhere') + ->with(['event_id' => 1]) + ->andReturn($existingSettings); + + $captured = null; + $fullHandler = Mockery::mock(UpdateEventSettingsHandler::class); + $fullHandler->shouldReceive('handle') + ->once() + ->andReturnUsing(function (UpdateEventSettingsDTO $dto) use (&$captured, $existingSettings) { + $captured = $dto; + return $existingSettings; + }); + + $handler = new PartialUpdateEventSettingsHandler($fullHandler, $repository); + + $handler->handle(new PartialUpdateEventSettingsDTO( + account_id: 1, + event_id: 1, + settings: array_merge(['location_details' => []], $settings), + )); + + return $captured; + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandlerTest.php index 57d97eebc6..9b364bfd04 100644 --- a/backend/tests/Unit/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandlerTest.php @@ -118,7 +118,43 @@ public function testDoesNotDispatchEventWhenAutoProcessDisabled(): void Event::assertNotDispatched(CapacityChangedEvent::class); } - private function createDTO(?bool $waitlist_auto_process = null): UpdateEventSettingsDTO + public function testPersistsAllowCopyDetailsToAllAttendees(): void + { + Event::fake(); + + $existingSettings = new EventSettingDomainObject(); + + $this->eventSettingsRepository + ->shouldReceive('findFirstWhere') + ->with(['event_id' => 1]) + ->twice() + ->andReturn($existingSettings); + + $captured = null; + $this->eventSettingsRepository + ->shouldReceive('updateWhere') + ->once() + ->andReturnUsing(function (...$args) use (&$captured) { + foreach ($args as $arg) { + if (is_array($arg) && array_key_exists('allow_copy_details_to_all_attendees', $arg)) { + $captured = $arg['allow_copy_details_to_all_attendees']; + } + } + return 1; + }); + + $this->handler->handle($this->createDTO(allow_copy_details_to_all_attendees: false)); + + $this->assertFalse( + $captured, + 'Expected allow_copy_details_to_all_attendees=false to be persisted via updateWhere' + ); + } + + private function createDTO( + ?bool $waitlist_auto_process = null, + bool $allow_copy_details_to_all_attendees = true, + ): UpdateEventSettingsDTO { return UpdateEventSettingsDTO::fromArray([ 'account_id' => 1, @@ -143,6 +179,7 @@ private function createDTO(?bool $waitlist_auto_process = null): UpdateEventSett 'seo_title' => null, 'seo_description' => null, 'seo_keywords' => null, + 'allow_copy_details_to_all_attendees' => $allow_copy_details_to_all_attendees, 'waitlist_auto_process' => $waitlist_auto_process, 'waitlist_offer_timeout_minutes' => 60, ]); diff --git a/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerPromoCodeTest.php b/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerPromoCodeTest.php new file mode 100644 index 0000000000..c63e572160 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerPromoCodeTest.php @@ -0,0 +1,122 @@ +runHandler(isUsable: false); + + $this->assertNull($captured); + } + + public function testPromoCodeIsAppliedWhenUsable(): void + { + $captured = $this->runHandler(isUsable: true); + + $this->assertInstanceOf(PromoCodeDomainObject::class, $captured); + $this->assertSame(self::PROMO_CODE_ID, $captured->getId()); + } + + private function runHandler(bool $isUsable): mixed + { + $promoCode = (new PromoCodeDomainObject()) + ->setId(self::PROMO_CODE_ID) + ->setCode('save50'); + + $eventSettings = Mockery::mock(EventSettingDomainObject::class); + $eventSettings->shouldReceive('getOrderTimeoutInMinutes')->andReturn(15); + + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getEventSettings')->andReturn($eventSettings); + $event->shouldReceive('getCurrency')->andReturn('USD'); + + $eventRepository = Mockery::mock(EventRepositoryInterface::class); + $eventRepository->shouldReceive('loadRelation')->andReturnSelf(); + $eventRepository->shouldReceive('findById')->with(self::EVENT_ID)->andReturn($event); + + $promoCodeRepository = Mockery::mock(PromoCodeRepositoryInterface::class); + $promoCodeRepository->shouldReceive('findFirstWhere')->andReturn($promoCode); + + $usageValidationService = Mockery::mock(PromoCodeUsageValidationService::class); + $usageValidationService->shouldReceive('isPromoCodeUsable')->with($promoCode)->andReturn($isUsable); + + $affiliateRepository = Mockery::mock(AffiliateRepositoryInterface::class); + + $orderManagementService = Mockery::mock(OrderManagementService::class); + $orderManagementService->shouldReceive('deleteExistingOrders'); + + $captured = false; + $order = Mockery::mock(OrderDomainObject::class); + $orderManagementService + ->shouldReceive('createNewOrder') + ->andReturnUsing(function ($eventId, $event, $timeOut, $locale, $promo, $affiliate, $sessionId) use (&$captured, $order) { + $captured = $promo; + return $order; + }); + $orderManagementService->shouldReceive('updateOrderTotals')->andReturn($order); + + $orderItemProcessingService = Mockery::mock(OrderItemProcessingService::class); + $orderItemProcessingService->shouldReceive('process')->andReturn(collect()); + + $availabilityService = Mockery::mock(AvailableProductQuantitiesFetchService::class); + $availabilityService->shouldReceive('getAvailableProductQuantities') + ->andReturn(new AvailableProductQuantitiesResponseDTO( + productQuantities: collect(), + capacities: collect(), + )); + + $databaseManager = Mockery::mock(DatabaseManager::class); + $databaseManager->shouldReceive('statement')->andReturn(true); + $databaseManager->shouldReceive('transaction')->andReturnUsing(fn($callback) => $callback()); + + $handler = new CreateOrderHandler( + $eventRepository, + $promoCodeRepository, + $usageValidationService, + $affiliateRepository, + $orderManagementService, + $orderItemProcessingService, + $availabilityService, + $databaseManager, + ); + + $dto = new CreateOrderPublicDTO( + products: collect(), + is_user_authenticated: true, + session_identifier: 'sess-123', + order_locale: 'en', + promo_code: 'save50', + affiliate_code: null, + ); + + $handler->handle(self::EVENT_ID, $dto); + + return $captured; + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerTest.php index 21a06aeb09..8bc15306ca 100644 --- a/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerTest.php @@ -10,6 +10,7 @@ use HiEvents\Repository\Interfaces\AffiliateRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; +use HiEvents\Services\Domain\PromoCode\PromoCodeUsageValidationService; use HiEvents\Services\Application\Handlers\Order\CreateOrderHandler; use HiEvents\Services\Application\Handlers\Order\DTO\CreateOrderPublicDTO; use HiEvents\Services\Application\Handlers\Order\DTO\ProductOrderDetailsDTO; @@ -29,6 +30,7 @@ class CreateOrderHandlerTest extends TestCase { private EventRepositoryInterface|MockInterface $eventRepository; private PromoCodeRepositoryInterface|MockInterface $promoCodeRepository; + private PromoCodeUsageValidationService|MockInterface $promoCodeUsageValidationService; private AffiliateRepositoryInterface|MockInterface $affiliateRepository; private OrderManagementService|MockInterface $orderManagementService; private OrderItemProcessingService|MockInterface $orderItemProcessingService; @@ -42,6 +44,7 @@ protected function setUp(): void $this->eventRepository = Mockery::mock(EventRepositoryInterface::class); $this->promoCodeRepository = Mockery::mock(PromoCodeRepositoryInterface::class); + $this->promoCodeUsageValidationService = Mockery::mock(PromoCodeUsageValidationService::class); $this->affiliateRepository = Mockery::mock(AffiliateRepositoryInterface::class); $this->orderManagementService = Mockery::mock(OrderManagementService::class); $this->orderItemProcessingService = Mockery::mock(OrderItemProcessingService::class); @@ -54,6 +57,7 @@ protected function setUp(): void $this->handler = new CreateOrderHandler( $this->eventRepository, $this->promoCodeRepository, + $this->promoCodeUsageValidationService, $this->affiliateRepository, $this->orderManagementService, $this->orderItemProcessingService, diff --git a/backend/tests/Unit/Services/Application/Handlers/Organizer/CreateOrganizerHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Organizer/CreateOrganizerHandlerTest.php new file mode 100644 index 0000000000..ee94b20949 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Organizer/CreateOrganizerHandlerTest.php @@ -0,0 +1,66 @@ +shouldReceive('transaction')->andReturnUsing(fn($callback) => $callback()); + $purifier->shouldReceive('purify')->andReturnUsing(fn($v) => is_string($v) ? 'PURIFIED:' . $v : $v); + + $organizer = Mockery::mock(OrganizerDomainObject::class); + $organizer->shouldReceive('getId')->andReturn(5); + + $capturedAttributes = null; + $organizerRepository + ->shouldReceive('create') + ->once() + ->andReturnUsing(function ($attributes) use (&$capturedAttributes, $organizer) { + $capturedAttributes = $attributes; + return $organizer; + }); + + $createDefaultOrganizerSettingsService->shouldReceive('createOrganizerSettings')->once(); + $organizerRepository->shouldReceive('loadRelation')->andReturnSelf(); + $organizerRepository->shouldReceive('findById')->with(5)->andReturn($organizer); + + $handler = new CreateOrganizerHandler( + $organizerRepository, + $databaseManager, + $createDefaultOrganizerSettingsService, + $purifier, + ); + + $dto = new CreateOrganizerDTO( + name: 'Acme', + email: 'acme@test.com', + account_id: 1, + timezone: 'UTC', + currency: 'USD', + description: '', + ); + + $handler->handle($dto); + + $this->assertSame('PURIFIED:', $capturedAttributes['description']); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/Question/EditQuestionHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Question/EditQuestionHandlerTest.php new file mode 100644 index 0000000000..ca4bd07f4f --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Question/EditQuestionHandlerTest.php @@ -0,0 +1,55 @@ +shouldReceive('purify')->andReturnUsing(fn($v) => is_string($v) ? 'PURIFIED:' . $v : $v); + + $capturedQuestion = null; + $editQuestionService + ->shouldReceive('editQuestion') + ->once() + ->andReturnUsing(function ($question) use (&$capturedQuestion) { + $capturedQuestion = $question; + return $question; + }); + + $handler = new EditQuestionHandler($editQuestionService, $purifier); + + $dto = new UpsertQuestionDTO( + title: 'Dietary requirements', + type: QuestionTypeEnum::SINGLE_LINE_TEXT, + required: false, + options: null, + event_id: 1, + product_ids: [], + is_hidden: false, + belongs_to: QuestionBelongsTo::ORDER, + description: '', + ); + + $handler->handle(7, $dto); + + $this->assertInstanceOf(QuestionDomainObject::class, $capturedQuestion); + $this->assertSame('PURIFIED:', $capturedQuestion->getDescription()); + } +} diff --git a/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php index 75fe3c4306..65e772e45e 100644 --- a/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Services\Domain\Order; +use HiEvents\DomainObjects\CapacityAssignmentDomainObject; use HiEvents\DomainObjects\Enums\ProductPriceType; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\ProductDomainObject; @@ -150,12 +151,98 @@ public function testNonZeroQuantityStillValidatesAgainstAvailability(): void $this->service->validateRequestData($eventId, $data); } + public function testNegativeQuantityOnAPriceTierIsRejected(): void + { + $eventId = 1; + $productId = 10; + $cheapPriceId = 101; + $expensivePriceId = 102; + + $this->setupMocks( + eventId: $eventId, + productId: $productId, + priceIds: [$cheapPriceId, $expensivePriceId], + priceLabels: ['Cheap', 'VIP'], + availabilities: [ + ['price_id' => $cheapPriceId, 'quantity_available' => 100, 'quantity_reserved' => 0], + ['price_id' => $expensivePriceId, 'quantity_available' => 100, 'quantity_reserved' => 0], + ], + ); + + $data = [ + 'products' => [ + [ + 'product_id' => $productId, + 'quantities' => [ + ['price_id' => $cheapPriceId, 'quantity' => 5], + ['price_id' => $expensivePriceId, 'quantity' => -1], + ], + ], + ], + ]; + + $this->expectException(ValidationException::class); + $this->service->validateRequestData($eventId, $data); + } + + public function testUnrelatedOverReservedCapacityDoesNotBlockSelectedProduct(): void + { + $eventId = 1; + $selectedProductId = 10; + $selectedPriceId = 101; + $unrelatedProductId = 20; + $unrelatedPriceId = 201; + + $unrelatedProduct = Mockery::mock(ProductDomainObject::class); + $unrelatedProduct->shouldReceive('getId')->andReturn($unrelatedProductId); + + $unrelatedCapacity = (new CapacityAssignmentDomainObject()) + ->setCapacity(10) + ->setUsedCapacity(20) + ->setProducts(collect([$unrelatedProduct])); + + $this->setupMocks( + eventId: $eventId, + productId: $selectedProductId, + priceIds: [$selectedPriceId], + priceLabels: ['Selected Product'], + availabilities: [ + ['price_id' => $selectedPriceId, 'quantity_available' => 5, 'quantity_reserved' => 0], + ], + capacities: collect([$unrelatedCapacity]), + extraAvailabilities: [ + [ + 'product_id' => $unrelatedProductId, + 'price_id' => $unrelatedPriceId, + 'quantity_available' => -10, + 'quantity_reserved' => 0, + ], + ], + ); + + $data = [ + 'products' => [ + [ + 'product_id' => $selectedProductId, + 'quantities' => [ + ['price_id' => $selectedPriceId, 'quantity' => 1], + ], + ], + ], + ]; + + $this->service->validateRequestData($eventId, $data); + $this->assertTrue(true); + } + private function setupMocks( int $eventId, int $productId, array $priceIds, array $priceLabels, array $availabilities, + ?Collection $capacities = null, + array $extraAvailabilities = [], ): void { $event = Mockery::mock(EventDomainObject::class); @@ -188,23 +275,32 @@ private function setupMocks( $quantityDTOs = collect(); foreach ($availabilities as $avail) { - $quantityDTOs->push(AvailableProductQuantitiesDTO::fromArray([ - 'product_id' => $productId, - 'price_id' => $avail['price_id'], - 'product_title' => 'Test Product', - 'price_label' => null, - 'quantity_available' => $avail['quantity_available'], - 'quantity_reserved' => $avail['quantity_reserved'], - 'initial_quantity_available' => 100, - 'capacities' => collect(), - ])); + $quantityDTOs->push($this->makeQuantityDTO($avail, $productId)); + } + + foreach ($extraAvailabilities as $avail) { + $quantityDTOs->push($this->makeQuantityDTO($avail, $productId)); } $this->availabilityService->shouldReceive('getAvailableProductQuantities') ->with($eventId, Mockery::any()) ->andReturn(new AvailableProductQuantitiesResponseDTO( productQuantities: $quantityDTOs, - capacities: collect(), + capacities: $capacities ?? collect(), )); } + + private function makeQuantityDTO(array $availability, int $defaultProductId): AvailableProductQuantitiesDTO + { + return AvailableProductQuantitiesDTO::fromArray([ + 'product_id' => $availability['product_id'] ?? $defaultProductId, + 'price_id' => $availability['price_id'], + 'product_title' => 'Test Product', + 'price_label' => null, + 'quantity_available' => $availability['quantity_available'], + 'quantity_reserved' => $availability['quantity_reserved'], + 'initial_quantity_available' => 100, + 'capacities' => collect(), + ]); + } } diff --git a/backend/tests/Unit/Services/Domain/PromoCode/PromoCodeUsageValidationServiceTest.php b/backend/tests/Unit/Services/Domain/PromoCode/PromoCodeUsageValidationServiceTest.php new file mode 100644 index 0000000000..bf6df6a681 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/PromoCode/PromoCodeUsageValidationServiceTest.php @@ -0,0 +1,88 @@ +assertFalse($service->isPromoCodeUsable(null)); + } + + public function testExpiredPromoCodeIsNotUsable(): void + { + $promoCode = (new PromoCodeDomainObject()) + ->setId(self::PROMO_CODE_ID) + ->setExpiryDate(Carbon::now()->subDay()->toDateTimeString()); + + $orderRepository = Mockery::mock(OrderRepositoryInterface::class); + $orderRepository->shouldNotReceive('countActivePromoCodeUsage'); + + $service = new PromoCodeUsageValidationService($orderRepository); + + $this->assertFalse($service->isPromoCodeUsable($promoCode)); + } + + public function testPromoCodeWithNoUsageLimitIsUsable(): void + { + $promoCode = (new PromoCodeDomainObject()) + ->setId(self::PROMO_CODE_ID) + ->setMaxAllowedUsages(null); + + $orderRepository = Mockery::mock(OrderRepositoryInterface::class); + $orderRepository->shouldNotReceive('countActivePromoCodeUsage'); + + $service = new PromoCodeUsageValidationService($orderRepository); + + $this->assertTrue($service->isPromoCodeUsable($promoCode)); + } + + public function testPromoCodeIsUsableWhenLiveCountIsUnderLimit(): void + { + $promoCode = (new PromoCodeDomainObject()) + ->setId(self::PROMO_CODE_ID) + ->setMaxAllowedUsages(2) + ->setOrderUsageCount(0); + + $orderRepository = Mockery::mock(OrderRepositoryInterface::class); + $orderRepository->shouldReceive('countActivePromoCodeUsage') + ->with(self::PROMO_CODE_ID) + ->andReturn(1); + + $service = new PromoCodeUsageValidationService($orderRepository); + + $this->assertTrue($service->isPromoCodeUsable($promoCode)); + } + + public function testPromoCodeIsNotUsableWhenLiveCountReachesLimit(): void + { + // Stale order_usage_count is 0 (passes isValid), but the live count has reached the limit. + $promoCode = (new PromoCodeDomainObject()) + ->setId(self::PROMO_CODE_ID) + ->setMaxAllowedUsages(1) + ->setOrderUsageCount(0); + + $orderRepository = Mockery::mock(OrderRepositoryInterface::class); + $orderRepository->shouldReceive('countActivePromoCodeUsage') + ->with(self::PROMO_CODE_ID) + ->andReturn(1); + + $service = new PromoCodeUsageValidationService($orderRepository); + + $this->assertFalse($service->isPromoCodeUsable($promoCode)); + } +} diff --git a/frontend/lingui.config.ts b/frontend/lingui.config.ts index a9e476930f..5b40ea4c47 100644 --- a/frontend/lingui.config.ts +++ b/frontend/lingui.config.ts @@ -17,6 +17,7 @@ const config: LinguiConfig = { "se", // Swedish "it", // Italian "pl", // Polish + "el", // Greek // "ja", // Japanese // "ko", // Korean // "id", // Indonesian diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 88dbac4bbf..3bb5477c37 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import {Notifications} from "@mantine/notifications"; import {i18n} from "@lingui/core"; import {I18nProvider} from "@lingui/react"; import {ModalsProvider} from "@mantine/modals"; +import {DatesProvider} from "@mantine/dates"; import {HydrationBoundary, QueryClient, QueryClientProvider} from "@tanstack/react-query"; import {Helmet, HelmetProvider} from "react-helmet-async"; import {generateColors} from '@mantine/colors-generator'; @@ -22,6 +23,7 @@ import {ThirdPartyScripts} from "./components/common/ThirdPartyScripts"; import {getConfig} from "./utilites/config.ts"; import {CookieConsentBanner} from "./components/common/CookieConsentBanner"; import {isConsentPending, setConsentState, updateGoogleConsentMode} from "./utilites/trackingPixels/consent"; +import "./utilites/dateLocales.ts"; declare global { interface Window { @@ -83,6 +85,7 @@ export const App: FC< > + @@ -103,6 +106,7 @@ export const App: FC< )} + diff --git a/frontend/src/components/common/AttendeeStatusBadge/index.tsx b/frontend/src/components/common/AttendeeStatusBadge/index.tsx index 24d1951d2c..1b39c4874f 100644 --- a/frontend/src/components/common/AttendeeStatusBadge/index.tsx +++ b/frontend/src/components/common/AttendeeStatusBadge/index.tsx @@ -1,5 +1,6 @@ import {Attendee} from "../../../types.ts"; import {Badge} from "@mantine/core"; +import {t} from "@lingui/macro"; interface AttendeeStatusBadgeProps { attendee: Attendee; @@ -22,7 +23,13 @@ export const AttendeeStatusBadge = ({attendee, noStyle = false}: AttendeeStatusB break; } - const status = attendee.status.replace('_', ' '); + const statusLabels: Record = { + 'ACTIVE': t`Active`, + 'CANCELLED': t`Cancelled`, + 'AWAITING_PAYMENT': t`Awaiting Payment`, + }; + + const status = statusLabels[attendee.status] || attendee.status.replace('_', ' '); if (noStyle) { return {status}; diff --git a/frontend/src/components/common/CheckIn/AttendeeList.tsx b/frontend/src/components/common/CheckIn/AttendeeList.tsx index 7662b397a2..001ccbb96a 100644 --- a/frontend/src/components/common/CheckIn/AttendeeList.tsx +++ b/frontend/src/components/common/CheckIn/AttendeeList.tsx @@ -83,9 +83,6 @@ export const AttendeeList = ({ {t`Ticket Cancelled`} ) : null} -
- {attendee.email} -
{isAttendeeAwaitingPayment && (
{t`Awaiting payment`} diff --git a/frontend/src/components/common/LanguageSwitcher/index.tsx b/frontend/src/components/common/LanguageSwitcher/index.tsx index 86293728d5..69394d9171 100644 --- a/frontend/src/components/common/LanguageSwitcher/index.tsx +++ b/frontend/src/components/common/LanguageSwitcher/index.tsx @@ -40,6 +40,8 @@ export const LanguageSwitcher = () => { return t`Polish`; case "se": return t`Swedish`; + case "el": + return t`Greek`; default: // Defensive fallback: if a new locale is added to SupportedLocales // but not handled here, return the locale code itself rather than @@ -63,12 +65,13 @@ export const LanguageSwitcher = () => { }))} defaultValue={getClientLocale()} placeholder={t`English`} - onChange={(value) => - dynamicActivateLocale(value as string).then(() => { - document.cookie = `locale=${value};path=/;max-age=31536000`; - // this shouldn't be necessary, but it is due to the wide use of t`...` in the codebase - window.location.reload(); - })} + onChange={(value) => { + if (!value) return; + document.cookie = `locale=${value};path=/;max-age=31536000`; + dynamicActivateLocale(value).finally(() => { + window.location.href = window.location.pathname + window.location.search; + }); + }} /> ) diff --git a/frontend/src/components/common/OrderStatusBadge/index.tsx b/frontend/src/components/common/OrderStatusBadge/index.tsx index a5d4ba4665..4688ce9535 100644 --- a/frontend/src/components/common/OrderStatusBadge/index.tsx +++ b/frontend/src/components/common/OrderStatusBadge/index.tsx @@ -3,27 +3,43 @@ import {Order} from "../../../types.ts"; import {getStatusColor} from "../../../utilites/helpers.ts"; import {t} from "@lingui/macro"; +const getStatusLabel = (status: string): string => { + const labels: Record = { + 'COMPLETED': t`Completed`, + 'CANCELLED': t`Cancelled`, + 'AWAITING_PAYMENT': t`Awaiting Payment`, + 'AWAITING_OFFLINE_PAYMENT': t`Awaiting offline payment`, + 'PAYMENT_RECEIVED': t`Payment Received`, + 'NO_PAYMENT_REQUIRED': t`No Payment Required`, + 'PAYMENT_FAILED': t`Payment Failed`, + 'REFUNDED': t`Refunded`, + 'PARTIALLY_REFUNDED': t`Partially Refunded`, + 'REFUND_PENDING': t`Refund Pending`, + }; + return labels[status] || status.replace('_', ' '); +}; + export const OrderStatusBadge = ({order, variant = 'outline'}: { order: Order, variant?: BadgeVariant }) => { let color; let title; if (order.status === 'CANCELLED') { color = getStatusColor(order.status); - title = t`Cancelled`; + title = getStatusLabel(order.status); } else if (order.status === 'AWAITING_OFFLINE_PAYMENT') { color = getStatusColor('AWAITING_PAYMENT'); - title = t`Awaiting offline payment`; + title = getStatusLabel(order.status); } else if (order.refund_status) { color = getStatusColor(order.refund_status); - title = order.refund_status; + title = getStatusLabel(order.refund_status); } else if (order.payment_status && order.payment_status !== 'PAYMENT_RECEIVED' && order.payment_status !== 'NO_PAYMENT_REQUIRED') { color = getStatusColor(order.payment_status); - title = order.payment_status; + title = getStatusLabel(order.payment_status); } else { color = getStatusColor(order.status); - title = order.status; + title = getStatusLabel(order.status); } - return {title.replace('_', ' ')} + return {title} }; diff --git a/frontend/src/components/forms/ProductCategoryForm/index.tsx b/frontend/src/components/forms/ProductCategoryForm/index.tsx index 40ca97afb4..f98325d8de 100644 --- a/frontend/src/components/forms/ProductCategoryForm/index.tsx +++ b/frontend/src/components/forms/ProductCategoryForm/index.tsx @@ -2,6 +2,7 @@ import {UseFormReturnType} from "@mantine/form"; import {ProductCategory} from "../../../types.ts"; import {Switch, TextInput} from "@mantine/core"; import {Editor} from "../../common/Editor"; +import {t} from "@lingui/macro"; interface ProductCategoryFormProps { form: UseFormReturnType, @@ -11,30 +12,30 @@ export const ProductCategoryForm = ({form}: ProductCategoryFormProps) => { return ( <> form.setFieldValue("description", value)} value={form.values.description || ""} maxLength={5000} />