diff --git a/Dockerfile.all-in-one b/Dockerfile.all-in-one index b140532ad8..396d42309a 100644 --- a/Dockerfile.all-in-one +++ b/Dockerfile.all-in-one @@ -22,7 +22,7 @@ ENV PHP_OPCACHE_ENABLE=1 # Switch to root for installing extensions and packages USER root -RUN install-php-extensions intl gd +RUN install-php-extensions intl gd zip RUN apk add --no-cache nodejs yarn nginx supervisor dos2unix diff --git a/backend/.env.example b/backend/.env.example index 93be0ca20d..8d4b4e739b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -81,5 +81,14 @@ JWT_ALGO=HS256 GEO_PROVIDER=google GOOGLE_MAPS_API_KEY= +# Ticket wallet passes. Values ending in _BASE64 contain the base64-encoded file contents. +APPLE_WALLET_PASS_TYPE_IDENTIFIER= +APPLE_WALLET_TEAM_IDENTIFIER= +APPLE_WALLET_CERTIFICATE_BASE64= +APPLE_WALLET_CERTIFICATE_PASSWORD= +APPLE_WALLET_WWDR_CERTIFICATE_BASE64= +GOOGLE_WALLET_ISSUER_ID= +GOOGLE_WALLET_SERVICE_ACCOUNT_BASE64= + # Only required for SAAS mode and if your're charging fees # OPEN_EXCHANGE_RATES_APP_ID= diff --git a/backend/Dockerfile b/backend/Dockerfile index 9d67d2f42c..96d335cb30 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -9,7 +9,7 @@ RUN echo "" >> /usr/local/etc/php-fpm.d/docker-php-serversideup-pool.conf && \ echo "user = www-data" >> /usr/local/etc/php-fpm.d/docker-php-serversideup-pool.conf && \ echo "group = www-data" >> /usr/local/etc/php-fpm.d/docker-php-serversideup-pool.conf -RUN install-php-extensions intl imagick gd +RUN install-php-extensions intl imagick gd zip COPY --chown=www-data:www-data . . diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index 668cc0e892..19e3248f56 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -12,7 +12,7 @@ COPY --chown=www-data:www-data . /var/www/html # Switch to root user to install PHP extensions USER root -RUN install-php-extensions intl imagick gd +RUN install-php-extensions intl imagick gd zip USER www-data RUN chmod -R 755 /var/www/html/storage \ diff --git a/backend/app/DataTransferObjects/Wallet/TicketWalletPassData.php b/backend/app/DataTransferObjects/Wallet/TicketWalletPassData.php new file mode 100644 index 0000000000..b751406892 --- /dev/null +++ b/backend/app/DataTransferObjects/Wallet/TicketWalletPassData.php @@ -0,0 +1,24 @@ +walletPassService->create($this->handler->handle($eventId, $attendeeShortId)); + } catch (WalletPassNotAvailableException $exception) { + return $this->errorResponse($exception->getMessage(), 404); + } + + return new Response($pass, 200, [ + 'Content-Type' => 'application/vnd.apple.pkpass', + 'Content-Disposition' => 'attachment; filename="ticket.pkpass"', + 'Cache-Control' => 'private, no-store', + ]); + } +} diff --git a/backend/app/Http/Actions/Attendees/RedirectGoogleWalletPassAction.php b/backend/app/Http/Actions/Attendees/RedirectGoogleWalletPassAction.php new file mode 100644 index 0000000000..5500340ba1 --- /dev/null +++ b/backend/app/Http/Actions/Attendees/RedirectGoogleWalletPassAction.php @@ -0,0 +1,31 @@ +walletPassService->createSaveUrl($this->handler->handle($eventId, $attendeeShortId)); + } catch (WalletPassNotAvailableException $exception) { + return $this->errorResponse($exception->getMessage(), 404); + } + + return redirect()->away($url); + } +} diff --git a/backend/app/Resources/Attendee/AttendeeResourcePublic.php b/backend/app/Resources/Attendee/AttendeeResourcePublic.php index 54e5eaf842..9c8b0154ad 100644 --- a/backend/app/Resources/Attendee/AttendeeResourcePublic.php +++ b/backend/app/Resources/Attendee/AttendeeResourcePublic.php @@ -45,6 +45,13 @@ public function toArray(Request $request): array ), ), 'locale' => $this->getLocale(), + 'wallet_passes' => [ + 'apple' => collect(config('services.wallet_passes.apple')) + ->except('certificate_password') + ->every(fn ($value) => filled($value)), + 'google' => collect(config('services.wallet_passes.google')) + ->every(fn ($value) => filled($value)), + ], ]; } } diff --git a/backend/app/Services/Application/Handlers/Attendee/GetTicketWalletPassDataHandler.php b/backend/app/Services/Application/Handlers/Attendee/GetTicketWalletPassDataHandler.php new file mode 100644 index 0000000000..9a445a460f --- /dev/null +++ b/backend/app/Services/Application/Handlers/Attendee/GetTicketWalletPassDataHandler.php @@ -0,0 +1,74 @@ +attendeeRepository + ->loadRelation(new Relationship(ProductDomainObject::class, name: 'product')) + ->loadRelation(new Relationship(EventOccurrenceDomainObject::class, name: 'event_occurrence')) + ->findFirstWhere([ + AttendeeDomainObjectAbstract::SHORT_ID => $attendeeShortId, + AttendeeDomainObjectAbstract::EVENT_ID => $eventId, + ]); + + if (! $attendee || $attendee->getStatus() !== AttendeeStatus::ACTIVE->name) { + throw new WalletPassNotAvailableException(__('This ticket is not available for Wallet.')); + } + + $event = $this->eventRepository + ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) + ->findFirstWhere([EventDomainObjectAbstract::ID => $eventId]); + + if (! $event) { + throw new WalletPassNotAvailableException(__('This ticket is not available for Wallet.')); + } + + $occurrence = $attendee->getEventOccurrence(); + $timezone = $event->getTimezone() ?: 'UTC'; + $startDate = $occurrence?->getStartDate() ?? $event->getStartDate(); + $endDate = $occurrence?->getEndDate() ?? $event->getEndDate(); + + return new TicketWalletPassData( + eventId: $eventId, + serialNumber: $attendee->getPublicId(), + eventTitle: $event->getTitle(), + organizerName: $event->getOrganizer()?->getName() ?? config('app.name'), + ticketTitle: $attendee->getProduct()?->getTitle() ?? __('Ticket'), + attendeeName: $attendee->getFullName(), + barcodeValue: $attendee->getPublicId(), + startDate: $this->toIso8601($startDate, $timezone), + endDate: $this->toIso8601($endDate, $timezone), + timezone: $timezone, + ticketUrl: sprintf(Url::getFrontEndUrlFromConfig(Url::ATTENDEE_TICKET), $eventId, $attendee->getShortId()), + ); + } + + private function toIso8601(?string $date, string $timezone): ?string + { + return $date ? CarbonImmutable::parse($date, $timezone)->toIso8601String() : null; + } +} diff --git a/backend/app/Services/Domain/Wallet/AppleWalletPassService.php b/backend/app/Services/Domain/Wallet/AppleWalletPassService.php new file mode 100644 index 0000000000..a442cb0575 --- /dev/null +++ b/backend/app/Services/Domain/Wallet/AppleWalletPassService.php @@ -0,0 +1,93 @@ +setCertificateString($certificate); + $builder->setCertificatePassword((string) $config['certificate_password']); + $builder->setWwdrCertificatePath($wwdrPath); + $builder->setData($this->passDefinition($pass, $config)); + $builder->addFile(resource_path('wallet/icon.png')); + $builder->addFile(resource_path('wallet/icon@2x.png')); + + return $builder->create(); + } catch (Throwable $exception) { + throw new WalletPassNotAvailableException(__('Unable to create the Apple Wallet pass.'), previous: $exception); + } finally { + unlink($wwdrPath); + } + } + + private function passDefinition(TicketWalletPassData $pass, array $config): array + { + return [ + 'formatVersion' => 1, + 'passTypeIdentifier' => $config['pass_type_identifier'], + 'serialNumber' => $pass->serialNumber, + 'teamIdentifier' => $config['team_identifier'], + 'organizationName' => $pass->organizerName, + 'description' => $pass->eventTitle, + 'logoText' => $pass->eventTitle, + 'foregroundColor' => 'rgb(255, 255, 255)', + 'backgroundColor' => 'rgb(107, 70, 193)', + 'labelColor' => 'rgb(237, 233, 254)', + 'barcode' => $this->barcode($pass->barcodeValue), + 'barcodes' => [$this->barcode($pass->barcodeValue)], + ...($pass->startDate ? ['relevantDate' => $pass->startDate] : []), + 'eventTicket' => [ + 'primaryFields' => [[ + 'key' => 'event', + 'label' => __('Event'), + 'value' => $pass->eventTitle, + ]], + 'secondaryFields' => [[ + 'key' => 'attendee', + 'label' => __('Attendee'), + 'value' => $pass->attendeeName, + ]], + 'auxiliaryFields' => array_values(array_filter([ + ['key' => 'ticket', 'label' => __('Ticket'), 'value' => $pass->ticketTitle], + $pass->startDate ? ['key' => 'date', 'label' => __('Date'), 'value' => $pass->startDate, 'dateStyle' => 'PKDateStyleMedium', 'timeStyle' => 'PKDateStyleShort'] : null, + ])), + 'backFields' => [[ + 'key' => 'ticketLink', + 'label' => __('View Ticket'), + 'value' => $pass->ticketUrl, + ]], + ], + ]; + } + + private function barcode(string $value): array + { + return [ + 'format' => 'PKBarcodeFormatQR', + 'message' => $value, + 'messageEncoding' => 'iso-8859-1', + 'altText' => $value, + ]; + } +} diff --git a/backend/app/Services/Domain/Wallet/GoogleWalletPassService.php b/backend/app/Services/Domain/Wallet/GoogleWalletPassService.php new file mode 100644 index 0000000000..0f4ab5d55c --- /dev/null +++ b/backend/app/Services/Domain/Wallet/GoogleWalletPassService.php @@ -0,0 +1,85 @@ +eventId; + $objectId = $issuerId.'.ticket_'.self::identifier($pass->serialNumber); + $claims = [ + 'iss' => $serviceAccount['client_email'], + 'aud' => 'google', + 'typ' => 'savetowallet', + 'iat' => time(), + 'origins' => [parse_url(config('app.frontend_url'), PHP_URL_HOST)], + 'payload' => [ + 'genericClasses' => [[ + 'id' => $classId, + ]], + 'genericObjects' => [[ + 'id' => $objectId, + 'classId' => $classId, + 'state' => 'ACTIVE', + 'cardTitle' => self::localized($pass->organizerName), + 'header' => self::localized($pass->eventTitle), + 'subheader' => self::localized($pass->ticketTitle), + 'barcode' => [ + 'type' => 'QR_CODE', + 'value' => $pass->barcodeValue, + 'alternateText' => $pass->barcodeValue, + ], + 'hexBackgroundColor' => '#6B46C1', + 'textModulesData' => array_values(array_filter([ + ['id' => 'attendee', 'header' => __('Attendee'), 'body' => $pass->attendeeName], + $pass->startDate ? ['id' => 'date', 'header' => __('Date'), 'body' => $pass->startDate] : null, + ])), + 'linksModuleData' => [ + 'uris' => [[ + 'id' => 'ticket', + 'uri' => $pass->ticketUrl, + 'description' => __('View Ticket'), + ]], + ], + ...($pass->startDate ? ['validTimeInterval' => [ + 'start' => ['date' => $pass->startDate], + ...($pass->endDate ? ['end' => ['date' => $pass->endDate]] : []), + ]] : []), + ]], + ], + ]; + + try { + return 'https://pay.google.com/gp/v/save/'.JWT::encode($claims, $serviceAccount['private_key'], 'RS256'); + } catch (Throwable $exception) { + throw new WalletPassNotAvailableException(__('Unable to sign the Google Wallet pass.'), previous: $exception); + } + } + + private static function localized(string $value): array + { + return ['defaultValue' => ['language' => 'en-US', 'value' => $value]]; + } + + private static function identifier(string $value): string + { + return trim(preg_replace('/[^A-Za-z0-9._-]/', '_', $value), '_') ?: 'event'; + } +} diff --git a/backend/composer.json b/backend/composer.json index 2c84523b8a..0e9b92c752 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -8,12 +8,15 @@ "require": { "php": "^8.3", "ext-intl": "*", + "ext-openssl": "*", "ext-xmlwriter": "*", + "ext-zip": "*", "barryvdh/laravel-dompdf": "^3.0", "brick/money": "^0.10.1", "dedoc/scramble": "^0.13", "doctrine/dbal": "^3.6", "ezyang/htmlpurifier": "^4.17", + "firebase/php-jwt": "^7.0", "guzzlehttp/guzzle": "^7.2", "lab404/laravel-impersonate": "^1.7", "laravel/ai": "^0.11.0", @@ -26,6 +29,7 @@ "maatwebsite/excel": "^4.0", "nette/php-generator": "^4.0", "php-open-source-saver/jwt-auth": "^2.1", + "pkpass/pkpass": "^2.5", "sentry/sentry-laravel": "^4.13", "spatie/icalendar-generator": "^3.0", "spatie/laravel-data": "^4.15", diff --git a/backend/composer.lock b/backend/composer.lock index fa81cbe443..daa5c00de4 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "04a800a0d003c70ba45f2415a7d1f63e", + "content-hash": "be7a8c66ddac3a9c391ce10c07d7a467", "packages": [ { "name": "aws/aws-crt-php", @@ -1496,6 +1496,72 @@ }, "time": "2025-10-17T16:34:55+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v7.1.0", + "source": { + "type": "git", + "url": "https://github.com/googleapis/php-jwt.git", + "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/b374a5d1a4f1f67fadc2165cdb284645945e2fc0", + "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpfastcache/phpfastcache": "^9.2", + "phpseclib/phpseclib": "~3.0", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present", + "phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/googleapis/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/googleapis/php-jwt/issues", + "source": "https://github.com/googleapis/php-jwt/tree/v7.1.0" + }, + "time": "2026-06-11T17:54:14+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", @@ -5310,6 +5376,69 @@ }, "time": "2026-07-08T07:01:06+00:00" }, + { + "name": "pkpass/pkpass", + "version": "v2.5.1", + "source": { + "type": "git", + "url": "https://github.com/tschoffelen/php-pkpass.git", + "reference": "d735bb0dda737573fbd099684b90b33707f767d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tschoffelen/php-pkpass/zipball/d735bb0dda737573fbd099684b90b33707f767d7", + "reference": "d735bb0dda737573fbd099684b90b33707f767d7", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "ext-zip": "*", + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6" + }, + "type": "library", + "autoload": { + "psr-4": { + "PKPass\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Thomas Schoffelen", + "email": "thomas@includable.com", + "homepage": "https://schof.co/" + } + ], + "description": "PHP PKPass class for iOS Wallet", + "homepage": "https://github.com/includable/php-pkpass", + "keywords": [ + "apple", + "ios", + "ipad", + "iphone", + "passbook", + "php", + "wallet" + ], + "support": { + "issues": "https://github.com/tschoffelen/php-pkpass/issues", + "source": "https://github.com/tschoffelen/php-pkpass/tree/v2.5.1" + }, + "funding": [ + { + "url": "https://github.com/tschoffelen", + "type": "github" + } + ], + "time": "2026-01-02T19:00:21+00:00" + }, { "name": "psr/cache", "version": "3.0.0", @@ -13124,7 +13253,9 @@ "platform": { "php": "^8.3", "ext-intl": "*", - "ext-xmlwriter": "*" + "ext-openssl": "*", + "ext-xmlwriter": "*", + "ext-zip": "*" }, "platform-dev": {}, "platform-overrides": { diff --git a/backend/config/services.php b/backend/config/services.php index c6b82eedba..7eb2a23e0c 100644 --- a/backend/config/services.php +++ b/backend/config/services.php @@ -58,4 +58,17 @@ 'api_key' => env('GOOGLE_MAPS_API_KEY'), ], ], + 'wallet_passes' => [ + 'apple' => [ + 'pass_type_identifier' => env('APPLE_WALLET_PASS_TYPE_IDENTIFIER'), + 'team_identifier' => env('APPLE_WALLET_TEAM_IDENTIFIER'), + 'certificate' => env('APPLE_WALLET_CERTIFICATE_BASE64'), + 'certificate_password' => env('APPLE_WALLET_CERTIFICATE_PASSWORD'), + 'wwdr_certificate' => env('APPLE_WALLET_WWDR_CERTIFICATE_BASE64'), + ], + 'google' => [ + 'issuer_id' => env('GOOGLE_WALLET_ISSUER_ID'), + 'service_account' => env('GOOGLE_WALLET_SERVICE_ACCOUNT_BASE64'), + ], + ], ]; diff --git a/backend/lang/de.json b/backend/lang/de.json index e56d05ac3c..f8603186ab 100644 --- a/backend/lang/de.json +++ b/backend/lang/de.json @@ -720,5 +720,12 @@ "Event must be pending manual review to be approved": "Die Veranstaltung muss auf manuelle Überprüfung warten, um genehmigt werden zu können", "Event must be pending manual review to be confirmed as spam": "Die Veranstaltung muss auf manuelle Überprüfung warten, um als Spam bestätigt werden zu können", "Event approved and published": "Veranstaltung genehmigt und veröffentlicht", - "Event confirmed as spam": "Veranstaltung als Spam bestätigt" + "Event confirmed as spam": "Veranstaltung als Spam bestätigt", + "Attendee": "Teilnehmer", + "Date": "Datum", + "Apple Wallet is not configured.": "Apple Wallet ist nicht konfiguriert.", + "Google Wallet is not configured.": "Google Wallet ist nicht konfiguriert.", + "This ticket is not available for Wallet.": "Dieses Ticket ist nicht für Wallet verfügbar.", + "Unable to create the Apple Wallet pass.": "Der Apple Wallet-Pass konnte nicht erstellt werden.", + "Unable to sign the Google Wallet pass.": "Der Google Wallet-Pass konnte nicht signiert werden." } diff --git a/backend/lang/el.json b/backend/lang/el.json index f456d67145..a63e1a05e7 100644 --- a/backend/lang/el.json +++ b/backend/lang/el.json @@ -720,5 +720,12 @@ "Event must be pending manual review to be approved": "Η εκδήλωση πρέπει να βρίσκεται σε αναμονή χειροκίνητου ελέγχου για να εγκριθεί", "Event must be pending manual review to be confirmed as spam": "Η εκδήλωση πρέπει να βρίσκεται σε αναμονή χειροκίνητου ελέγχου για να επιβεβαιωθεί ως spam", "Event approved and published": "Η εκδήλωση εγκρίθηκε και δημοσιεύτηκε", - "Event confirmed as spam": "Η εκδήλωση επιβεβαιώθηκε ως spam" + "Event confirmed as spam": "Η εκδήλωση επιβεβαιώθηκε ως spam", + "Attendee": "Συμμετέχων", + "Date": "Ημερομηνία", + "Apple Wallet is not configured.": "Το Apple Wallet δεν έχει ρυθμιστεί.", + "Google Wallet is not configured.": "Το Google Wallet δεν έχει ρυθμιστεί.", + "This ticket is not available for Wallet.": "Αυτό το εισιτήριο δεν είναι διαθέσιμο για το Wallet.", + "Unable to create the Apple Wallet pass.": "Δεν ήταν δυνατή η δημιουργία του πάσου Apple Wallet.", + "Unable to sign the Google Wallet pass.": "Δεν ήταν δυνατή η υπογραφή του πάσου Google Wallet." } diff --git a/backend/lang/es.json b/backend/lang/es.json index f43ed37531..4058cb6b52 100644 --- a/backend/lang/es.json +++ b/backend/lang/es.json @@ -765,5 +765,12 @@ "Event must be pending manual review to be approved": "El evento debe estar pendiente de revisión manual para poder aprobarse", "Event must be pending manual review to be confirmed as spam": "El evento debe estar pendiente de revisión manual para poder confirmarse como spam", "Event approved and published": "Evento aprobado y publicado", - "Event confirmed as spam": "Evento confirmado como spam" + "Event confirmed as spam": "Evento confirmado como spam", + "Attendee": "Asistente", + "Date": "Fecha", + "Apple Wallet is not configured.": "Apple Wallet no está configurado.", + "Google Wallet is not configured.": "Google Wallet no está configurado.", + "This ticket is not available for Wallet.": "Este billete no está disponible para Wallet.", + "Unable to create the Apple Wallet pass.": "No se pudo crear el pase de Apple Wallet.", + "Unable to sign the Google Wallet pass.": "No se pudo firmar el pase de Google Wallet." } diff --git a/backend/lang/fr.json b/backend/lang/fr.json index a71aaaf4bb..bcf100f150 100644 --- a/backend/lang/fr.json +++ b/backend/lang/fr.json @@ -720,5 +720,12 @@ "Event must be pending manual review to be approved": "L'événement doit être en attente de révision manuelle pour être approuvé", "Event must be pending manual review to be confirmed as spam": "L'événement doit être en attente de révision manuelle pour être confirmé comme spam", "Event approved and published": "Événement approuvé et publié", - "Event confirmed as spam": "Événement confirmé comme spam" + "Event confirmed as spam": "Événement confirmé comme spam", + "Attendee": "Participant", + "Date": "Date", + "Apple Wallet is not configured.": "Apple Wallet n'est pas configuré.", + "Google Wallet is not configured.": "Google Wallet n'est pas configuré.", + "This ticket is not available for Wallet.": "Ce billet n'est pas disponible pour Wallet.", + "Unable to create the Apple Wallet pass.": "Impossible de créer le pass Apple Wallet.", + "Unable to sign the Google Wallet pass.": "Impossible de signer le pass Google Wallet." } diff --git a/backend/lang/hu.json b/backend/lang/hu.json index eaf2205965..56a7dd71c1 100644 --- a/backend/lang/hu.json +++ b/backend/lang/hu.json @@ -725,5 +725,12 @@ "Event must be pending manual review to be approved": "Az eseménynek kézi felülvizsgálatra kell várnia a jóváhagyáshoz", "Event must be pending manual review to be confirmed as spam": "Az eseménynek kézi felülvizsgálatra kell várnia a spamként való megerősítéshez", "Event approved and published": "Esemény jóváhagyva és közzétéve", - "Event confirmed as spam": "Esemény spamként megerősítve" + "Event confirmed as spam": "Esemény spamként megerősítve", + "Attendee": "Résztvevő", + "Date": "Dátum", + "Apple Wallet is not configured.": "Az Apple Wallet nincs beállítva.", + "Google Wallet is not configured.": "A Google Wallet nincs beállítva.", + "This ticket is not available for Wallet.": "Ez a jegy nem érhető el a Wallethez.", + "Unable to create the Apple Wallet pass.": "Nem sikerült létrehozni az Apple Wallet-jegyet.", + "Unable to sign the Google Wallet pass.": "Nem sikerült aláírni a Google Wallet-jegyet." } diff --git a/backend/lang/it.json b/backend/lang/it.json index d8930dca92..2e2a6b0453 100644 --- a/backend/lang/it.json +++ b/backend/lang/it.json @@ -721,5 +721,12 @@ "Event must be pending manual review to be approved": "L'evento deve essere in attesa di revisione manuale per poter essere approvato", "Event must be pending manual review to be confirmed as spam": "L'evento deve essere in attesa di revisione manuale per poter essere confermato come spam", "Event approved and published": "Evento approvato e pubblicato", - "Event confirmed as spam": "Evento confermato come spam" + "Event confirmed as spam": "Evento confermato come spam", + "Attendee": "Partecipante", + "Date": "Data", + "Apple Wallet is not configured.": "Apple Wallet non è configurato.", + "Google Wallet is not configured.": "Google Wallet non è configurato.", + "This ticket is not available for Wallet.": "Questo biglietto non è disponibile per Wallet.", + "Unable to create the Apple Wallet pass.": "Impossibile creare il pass Apple Wallet.", + "Unable to sign the Google Wallet pass.": "Impossibile firmare il pass Google Wallet." } diff --git a/backend/lang/nl.json b/backend/lang/nl.json index 415004f380..23031f126d 100644 --- a/backend/lang/nl.json +++ b/backend/lang/nl.json @@ -720,5 +720,12 @@ "Event must be pending manual review to be approved": "Het evenement moet op handmatige beoordeling wachten om goedgekeurd te kunnen worden", "Event must be pending manual review to be confirmed as spam": "Het evenement moet op handmatige beoordeling wachten om als spam bevestigd te kunnen worden", "Event approved and published": "Evenement goedgekeurd en gepubliceerd", - "Event confirmed as spam": "Evenement bevestigd als spam" + "Event confirmed as spam": "Evenement bevestigd als spam", + "Attendee": "Bezoeker", + "Date": "Datum", + "Apple Wallet is not configured.": "Apple Wallet is niet geconfigureerd.", + "Google Wallet is not configured.": "Google Wallet is niet geconfigureerd.", + "This ticket is not available for Wallet.": "Dit ticket is niet beschikbaar voor Wallet.", + "Unable to create the Apple Wallet pass.": "De Apple Wallet-pas kon niet worden aangemaakt.", + "Unable to sign the Google Wallet pass.": "De Google Wallet-pas kon niet worden ondertekend." } diff --git a/backend/lang/pl.json b/backend/lang/pl.json index dd6b6f31d0..36c93f8a96 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -720,5 +720,12 @@ "Event must be pending manual review to be approved": "Wydarzenie musi oczekiwać na ręczną weryfikację, aby mogło zostać zatwierdzone", "Event must be pending manual review to be confirmed as spam": "Wydarzenie musi oczekiwać na ręczną weryfikację, aby mogło zostać potwierdzone jako spam", "Event approved and published": "Wydarzenie zatwierdzone i opublikowane", - "Event confirmed as spam": "Wydarzenie potwierdzone jako spam" + "Event confirmed as spam": "Wydarzenie potwierdzone jako spam", + "Attendee": "Uczestnik", + "Date": "Data", + "Apple Wallet is not configured.": "Apple Wallet nie jest skonfigurowany.", + "Google Wallet is not configured.": "Google Wallet nie jest skonfigurowany.", + "This ticket is not available for Wallet.": "Ten bilet nie jest dostępny w Wallet.", + "Unable to create the Apple Wallet pass.": "Nie udało się utworzyć karty Apple Wallet.", + "Unable to sign the Google Wallet pass.": "Nie udało się podpisać karty Google Wallet." } diff --git a/backend/lang/pt-br.json b/backend/lang/pt-br.json index 4f600e4a68..3f398787f1 100644 --- a/backend/lang/pt-br.json +++ b/backend/lang/pt-br.json @@ -720,5 +720,12 @@ "Event must be pending manual review to be approved": "O evento deve estar aguardando revisão manual para ser aprovado", "Event must be pending manual review to be confirmed as spam": "O evento deve estar aguardando revisão manual para ser confirmado como spam", "Event approved and published": "Evento aprovado e publicado", - "Event confirmed as spam": "Evento confirmado como spam" + "Event confirmed as spam": "Evento confirmado como spam", + "Attendee": "Participante", + "Date": "Data", + "Apple Wallet is not configured.": "O Apple Wallet não está configurado.", + "Google Wallet is not configured.": "O Google Wallet não está configurado.", + "This ticket is not available for Wallet.": "Este bilhete não está disponível para a Wallet.", + "Unable to create the Apple Wallet pass.": "Não foi possível criar o passe do Apple Wallet.", + "Unable to sign the Google Wallet pass.": "Não foi possível assinar o passe do Google Wallet." } diff --git a/backend/lang/pt.json b/backend/lang/pt.json index 2e8178f2c0..66d00dc5f2 100644 --- a/backend/lang/pt.json +++ b/backend/lang/pt.json @@ -720,5 +720,12 @@ "Event must be pending manual review to be approved": "O evento tem de estar a aguardar revisão manual para poder ser aprovado", "Event must be pending manual review to be confirmed as spam": "O evento tem de estar a aguardar revisão manual para poder ser confirmado como spam", "Event approved and published": "Evento aprovado e publicado", - "Event confirmed as spam": "Evento confirmado como spam" + "Event confirmed as spam": "Evento confirmado como spam", + "Attendee": "Participante", + "Date": "Data", + "Apple Wallet is not configured.": "O Apple Wallet não está configurado.", + "Google Wallet is not configured.": "O Google Wallet não está configurado.", + "This ticket is not available for Wallet.": "Este bilhete não está disponível para a Wallet.", + "Unable to create the Apple Wallet pass.": "Não foi possível criar o passe do Apple Wallet.", + "Unable to sign the Google Wallet pass.": "Não foi possível assinar o passe do Google Wallet." } diff --git a/backend/lang/ru.json b/backend/lang/ru.json index 1523d351b4..df72a07dda 100644 --- a/backend/lang/ru.json +++ b/backend/lang/ru.json @@ -708,5 +708,12 @@ "Event must be pending manual review to be approved": "Событие должно ожидать ручной проверки, чтобы его можно было одобрить", "Event must be pending manual review to be confirmed as spam": "Событие должно ожидать ручной проверки, чтобы его можно было подтвердить как спам", "Event approved and published": "Событие одобрено и опубликовано", - "Event confirmed as spam": "Событие подтверждено как спам" + "Event confirmed as spam": "Событие подтверждено как спам", + "Attendee": "Участник", + "Date": "Дата", + "Apple Wallet is not configured.": "Apple Wallet не настроен.", + "Google Wallet is not configured.": "Google Wallet не настроен.", + "This ticket is not available for Wallet.": "Этот билет недоступен для Wallet.", + "Unable to create the Apple Wallet pass.": "Не удалось создать пропуск Apple Wallet.", + "Unable to sign the Google Wallet pass.": "Не удалось подписать пропуск Google Wallet." } diff --git a/backend/lang/se.json b/backend/lang/se.json index 22dca73b30..e83c2cac46 100644 --- a/backend/lang/se.json +++ b/backend/lang/se.json @@ -720,5 +720,12 @@ "Event must be pending manual review to be approved": "Evenemanget måste vänta på manuell granskning för att kunna godkännas", "Event must be pending manual review to be confirmed as spam": "Evenemanget måste vänta på manuell granskning för att kunna bekräftas som spam", "Event approved and published": "Evenemanget har godkänts och publicerats", - "Event confirmed as spam": "Evenemanget har bekräftats som spam" + "Event confirmed as spam": "Evenemanget har bekräftats som spam", + "Attendee": "Deltagare", + "Date": "Datum", + "Apple Wallet is not configured.": "Apple Wallet är inte konfigurerat.", + "Google Wallet is not configured.": "Google Wallet är inte konfigurerat.", + "This ticket is not available for Wallet.": "Den här biljetten är inte tillgänglig för Wallet.", + "Unable to create the Apple Wallet pass.": "Det gick inte att skapa Apple Wallet-passet.", + "Unable to sign the Google Wallet pass.": "Det gick inte att signera Google Wallet-passet." } diff --git a/backend/lang/sk.json b/backend/lang/sk.json index a4bf682933..7c68016d43 100644 --- a/backend/lang/sk.json +++ b/backend/lang/sk.json @@ -720,5 +720,12 @@ "Event must be pending manual review to be approved": "Podujatie musí čakať na manuálnu kontrolu, aby mohlo byť schválené", "Event must be pending manual review to be confirmed as spam": "Podujatie musí čakať na manuálnu kontrolu, aby mohlo byť potvrdené ako spam", "Event approved and published": "Podujatie bolo schválené a zverejnené", - "Event confirmed as spam": "Podujatie bolo potvrdené ako spam" + "Event confirmed as spam": "Podujatie bolo potvrdené ako spam", + "Attendee": "Účastník", + "Date": "Dátum", + "Apple Wallet is not configured.": "Apple Wallet nie je nakonfigurovaný.", + "Google Wallet is not configured.": "Google Wallet nie je nakonfigurovaný.", + "This ticket is not available for Wallet.": "Tento lístok nie je dostupný pre Wallet.", + "Unable to create the Apple Wallet pass.": "Nepodarilo sa vytvoriť preukaz Apple Wallet.", + "Unable to sign the Google Wallet pass.": "Nepodarilo sa podpísať preukaz Google Wallet." } diff --git a/backend/lang/tr.json b/backend/lang/tr.json index 5532fd501f..ade6d018f0 100644 --- a/backend/lang/tr.json +++ b/backend/lang/tr.json @@ -735,5 +735,12 @@ "Event must be pending manual review to be approved": "Etkinliğin onaylanabilmesi için manuel inceleme bekliyor olması gerekir", "Event must be pending manual review to be confirmed as spam": "Etkinliğin spam olarak onaylanabilmesi için manuel inceleme bekliyor olması gerekir", "Event approved and published": "Etkinlik onaylandı ve yayınlandı", - "Event confirmed as spam": "Etkinlik spam olarak onaylandı" + "Event confirmed as spam": "Etkinlik spam olarak onaylandı", + "Attendee": "Katılımcı", + "Date": "Tarih", + "Apple Wallet is not configured.": "Apple Wallet yapılandırılmamış.", + "Google Wallet is not configured.": "Google Wallet yapılandırılmamış.", + "This ticket is not available for Wallet.": "Bu bilet Wallet için kullanılamıyor.", + "Unable to create the Apple Wallet pass.": "Apple Wallet kartı oluşturulamadı.", + "Unable to sign the Google Wallet pass.": "Google Wallet kartı imzalanamadı." } diff --git a/backend/lang/vi.json b/backend/lang/vi.json index 270fa6627f..712de557fd 100644 --- a/backend/lang/vi.json +++ b/backend/lang/vi.json @@ -720,5 +720,12 @@ "Event must be pending manual review to be approved": "Sự kiện phải đang chờ xem xét thủ công thì mới có thể được phê duyệt", "Event must be pending manual review to be confirmed as spam": "Sự kiện phải đang chờ xem xét thủ công thì mới có thể được xác nhận là spam", "Event approved and published": "Sự kiện đã được phê duyệt và xuất bản", - "Event confirmed as spam": "Sự kiện đã được xác nhận là spam" + "Event confirmed as spam": "Sự kiện đã được xác nhận là spam", + "Attendee": "Người tham dự", + "Date": "Ngày", + "Apple Wallet is not configured.": "Apple Wallet chưa được cấu hình.", + "Google Wallet is not configured.": "Google Wallet chưa được cấu hình.", + "This ticket is not available for Wallet.": "Vé này không khả dụng cho Wallet.", + "Unable to create the Apple Wallet pass.": "Không thể tạo thẻ Apple Wallet.", + "Unable to sign the Google Wallet pass.": "Không thể ký thẻ Google Wallet." } diff --git a/backend/lang/zh-cn.json b/backend/lang/zh-cn.json index 88493dddba..bd1e1f1bb0 100644 --- a/backend/lang/zh-cn.json +++ b/backend/lang/zh-cn.json @@ -720,5 +720,12 @@ "Event must be pending manual review to be approved": "活动必须处于等待人工审核状态才能批准", "Event must be pending manual review to be confirmed as spam": "活动必须处于等待人工审核状态才能确认为垃圾内容", "Event approved and published": "活动已批准并发布", - "Event confirmed as spam": "活动已确认为垃圾内容" + "Event confirmed as spam": "活动已确认为垃圾内容", + "Attendee": "与会者", + "Date": "日期", + "Apple Wallet is not configured.": "Apple 钱包尚未配置。", + "Google Wallet is not configured.": "Google 钱包尚未配置。", + "This ticket is not available for Wallet.": "此票券不可添加到钱包。", + "Unable to create the Apple Wallet pass.": "无法创建 Apple 钱包票券。", + "Unable to sign the Google Wallet pass.": "无法签名 Google 钱包票券。" } diff --git a/backend/lang/zh-hk.json b/backend/lang/zh-hk.json index e8a03d5d7b..3cb8615f7d 100644 --- a/backend/lang/zh-hk.json +++ b/backend/lang/zh-hk.json @@ -720,5 +720,12 @@ "Event must be pending manual review to be approved": "活動必須處於等待人工審核狀態才能批准", "Event must be pending manual review to be confirmed as spam": "活動必須處於等待人工審核狀態才能確認為垃圾內容", "Event approved and published": "活動已批准並發佈", - "Event confirmed as spam": "活動已確認為垃圾內容" + "Event confirmed as spam": "活動已確認為垃圾內容", + "Attendee": "與會者", + "Date": "日期", + "Apple Wallet is not configured.": "Apple 銀包尚未設定。", + "Google Wallet is not configured.": "Google 錢包尚未設定。", + "This ticket is not available for Wallet.": "此門票無法加入銀包。", + "Unable to create the Apple Wallet pass.": "無法建立 Apple 銀包票券。", + "Unable to sign the Google Wallet pass.": "無法簽署 Google 錢包票券。" } diff --git a/backend/resources/wallet/icon.png b/backend/resources/wallet/icon.png new file mode 100644 index 0000000000..3b2597ea39 Binary files /dev/null and b/backend/resources/wallet/icon.png differ diff --git a/backend/resources/wallet/icon@2x.png b/backend/resources/wallet/icon@2x.png new file mode 100644 index 0000000000..9c6705693e Binary files /dev/null and b/backend/resources/wallet/icon@2x.png differ diff --git a/backend/routes/api.php b/backend/routes/api.php index 34f13f2dfb..afc17edacf 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -60,9 +60,11 @@ use HiEvents\Http\Actions\Attendees\ExportAttendeesAction; use HiEvents\Http\Actions\Attendees\GetAttendeeAction; use HiEvents\Http\Actions\Attendees\GetAttendeeActionPublic; +use HiEvents\Http\Actions\Attendees\DownloadAppleWalletPassAction; use HiEvents\Http\Actions\Attendees\GetAttendeesAction; use HiEvents\Http\Actions\Attendees\PartialEditAttendeeAction; use HiEvents\Http\Actions\Attendees\ResendAttendeeTicketAction; +use HiEvents\Http\Actions\Attendees\RedirectGoogleWalletPassAction; use HiEvents\Http\Actions\Auth\AcceptInvitationAction; use HiEvents\Http\Actions\Auth\ForgotPasswordAction; use HiEvents\Http\Actions\Auth\GetUserInvitationAction; @@ -628,6 +630,10 @@ function (Router $router): void { // Attendees $router->get('/events/{event_id}/attendees/{attendee_short_id}', GetAttendeeActionPublic::class); + $router->get('/events/{event_id}/attendees/{attendee_short_id}/wallet/apple', DownloadAppleWalletPassAction::class) + ->middleware('throttle:30,1'); + $router->get('/events/{event_id}/attendees/{attendee_short_id}/wallet/google', RedirectGoogleWalletPassAction::class) + ->middleware('throttle:30,1'); // Waitlist $router->post('/events/{event_id}/waitlist', CreateWaitlistEntryActionPublic::class) diff --git a/backend/tests/Unit/Services/Domain/Wallet/AppleWalletPassServiceTest.php b/backend/tests/Unit/Services/Domain/Wallet/AppleWalletPassServiceTest.php new file mode 100644 index 0000000000..d11ab99f6e --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Wallet/AppleWalletPassServiceTest.php @@ -0,0 +1,40 @@ + [ + 'pass_type_identifier' => null, + 'team_identifier' => null, + 'certificate' => null, + 'certificate_password' => null, + 'wwdr_certificate' => null, + ]]); + + $this->expectException(WalletPassNotAvailableException::class); + + app(AppleWalletPassService::class)->create(new TicketWalletPassData( + eventId: 42, + serialNumber: 'PUBLIC-123', + eventTitle: 'Laravel Live', + organizerName: 'Hi.Events', + ticketTitle: 'General admission', + attendeeName: 'Ada Lovelace', + barcodeValue: 'PUBLIC-123', + startDate: null, + endDate: null, + timezone: 'UTC', + ticketUrl: 'https://tickets.example/product/42/abc', + )); + } +} diff --git a/backend/tests/Unit/Services/Domain/Wallet/GoogleWalletPassServiceTest.php b/backend/tests/Unit/Services/Domain/Wallet/GoogleWalletPassServiceTest.php new file mode 100644 index 0000000000..7cf0e86ed8 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Wallet/GoogleWalletPassServiceTest.php @@ -0,0 +1,67 @@ + 2048]); + openssl_pkey_export($key, $privateKey); + $publicKey = openssl_pkey_get_details($key)['key']; + config(['services.wallet_passes.google' => [ + 'issuer_id' => '3388000000012345678', + 'service_account' => base64_encode(json_encode([ + 'client_email' => 'wallet@example.iam.gserviceaccount.com', + 'private_key' => $privateKey, + ], JSON_THROW_ON_ERROR)), + ]]); + + $url = app(GoogleWalletPassService::class)->createSaveUrl($this->passData()); + $jwt = substr($url, strlen('https://pay.google.com/gp/v/save/')); + $claims = json_decode(json_encode(JWT::decode($jwt, new Key($publicKey, 'RS256')), JSON_THROW_ON_ERROR), true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame('wallet@example.iam.gserviceaccount.com', $claims['iss']); + $this->assertSame('3388000000012345678.event_42', $claims['payload']['genericClasses'][0]['id']); + $this->assertSame('PUBLIC-123', $claims['payload']['genericObjects'][0]['barcode']['value']); + $this->assertSame('Ada Lovelace', $claims['payload']['genericObjects'][0]['textModulesData'][0]['body']); + } + + public function test_it_rejects_missing_configuration(): void + { + config(['services.wallet_passes.google' => [ + 'issuer_id' => null, + 'service_account' => null, + ]]); + + $this->expectException(WalletPassNotAvailableException::class); + + app(GoogleWalletPassService::class)->createSaveUrl($this->passData()); + } + + private function passData(): TicketWalletPassData + { + return new TicketWalletPassData( + eventId: 42, + serialNumber: 'PUBLIC-123', + eventTitle: 'Laravel Live', + organizerName: 'Hi.Events', + ticketTitle: 'General admission', + attendeeName: 'Ada Lovelace', + barcodeValue: 'PUBLIC-123', + startDate: '2026-09-20T18:00:00+02:00', + endDate: '2026-09-20T20:00:00+02:00', + timezone: 'Europe/Berlin', + ticketUrl: 'https://tickets.example/product/42/abc', + ); + } +} diff --git a/docker/all-in-one/.env.example b/docker/all-in-one/.env.example index 03898186d6..a56440f4f1 100644 --- a/docker/all-in-one/.env.example +++ b/docker/all-in-one/.env.example @@ -68,5 +68,14 @@ REDIS_PORT=6379 GEO_PROVIDER=google GOOGLE_MAPS_API_KEY= +# Wallet passes. Base64 values contain the full credential file contents. +APPLE_WALLET_PASS_TYPE_IDENTIFIER= +APPLE_WALLET_TEAM_IDENTIFIER= +APPLE_WALLET_CERTIFICATE_BASE64= +APPLE_WALLET_CERTIFICATE_PASSWORD= +APPLE_WALLET_WWDR_CERTIFICATE_BASE64= +GOOGLE_WALLET_ISSUER_ID= +GOOGLE_WALLET_SERVICE_ACCOUNT_BASE64= + # Serve the interactive OpenAPI documentation at /docs/api. Disabled by default. API_DOCS_ENABLED=false diff --git a/docker/all-in-one/README.md b/docker/all-in-one/README.md index cab5878ac9..5daeb0207c 100644 --- a/docker/all-in-one/README.md +++ b/docker/all-in-one/README.md @@ -63,6 +63,29 @@ docker compose up -d Visit [http://localhost:8123/auth/register](http://localhost:8123/auth/register) to create an account. +## Ticket wallet passes + +Wallet buttons appear on active attendee tickets when the corresponding provider is configured. Credential files are passed as base64 so they work with Docker Compose secrets and environment variables without multiline parsing issues. + +For Apple Wallet, create a Pass Type ID and certificate in the Apple Developer portal, export the certificate as a password-protected `.p12`, and download Apple's WWDR G4 intermediate certificate. Add these values to `.env`: + +```dotenv +APPLE_WALLET_PASS_TYPE_IDENTIFIER=pass.com.example.tickets +APPLE_WALLET_TEAM_IDENTIFIER=ABCDE12345 +APPLE_WALLET_CERTIFICATE_BASE64= +APPLE_WALLET_CERTIFICATE_PASSWORD= +APPLE_WALLET_WWDR_CERTIFICATE_BASE64= +``` + +For Google Wallet, enable the Google Wallet API, create an issuer account, and grant the service account Developer access in the Google Pay & Wallet Console. Add these values to `.env`: + +```dotenv +GOOGLE_WALLET_ISSUER_ID=3388000000012345678 +GOOGLE_WALLET_SERVICE_ACCOUNT_BASE64= +``` + +On Linux, encode a file with `base64 -w 0 filename`. On macOS, use `base64 -i filename`. + --- **Production Note:** diff --git a/docker/all-in-one/docker-compose.yml b/docker/all-in-one/docker-compose.yml index e57c557fe3..476777d6ce 100644 --- a/docker/all-in-one/docker-compose.yml +++ b/docker/all-in-one/docker-compose.yml @@ -40,6 +40,13 @@ services: - MAIL_FROM_NAME=${MAIL_FROM_NAME} - GEO_PROVIDER=${GEO_PROVIDER} - GOOGLE_MAPS_API_KEY=${GOOGLE_MAPS_API_KEY} + - APPLE_WALLET_PASS_TYPE_IDENTIFIER=${APPLE_WALLET_PASS_TYPE_IDENTIFIER:-} + - APPLE_WALLET_TEAM_IDENTIFIER=${APPLE_WALLET_TEAM_IDENTIFIER:-} + - APPLE_WALLET_CERTIFICATE_BASE64=${APPLE_WALLET_CERTIFICATE_BASE64:-} + - APPLE_WALLET_CERTIFICATE_PASSWORD=${APPLE_WALLET_CERTIFICATE_PASSWORD:-} + - APPLE_WALLET_WWDR_CERTIFICATE_BASE64=${APPLE_WALLET_WWDR_CERTIFICATE_BASE64:-} + - GOOGLE_WALLET_ISSUER_ID=${GOOGLE_WALLET_ISSUER_ID:-} + - GOOGLE_WALLET_SERVICE_ACCOUNT_BASE64=${GOOGLE_WALLET_SERVICE_ACCOUNT_BASE64:-} - FILESYSTEM_PUBLIC_DISK=${FILESYSTEM_PUBLIC_DISK} - FILESYSTEM_PRIVATE_DISK=${FILESYSTEM_PRIVATE_DISK} - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-secret}@postgres:5432/${POSTGRES_DB:-hi-events} diff --git a/frontend/src/components/common/AttendeeTicket/index.tsx b/frontend/src/components/common/AttendeeTicket/index.tsx index 2ef4cc574d..938edbcdd4 100644 --- a/frontend/src/components/common/AttendeeTicket/index.tsx +++ b/frontend/src/components/common/AttendeeTicket/index.tsx @@ -4,10 +4,10 @@ import {formatCurrency} from "../../../utilites/currency.ts"; import {t} from "@lingui/macro"; import {prettyDate} from "../../../utilites/dates.ts"; import QRCode from "react-qr-code"; -import {IconCopy, IconPrinter, IconLock, IconX} from "@tabler/icons-react"; +import {IconBrandApple, IconBrandGoogle, IconCopy, IconPrinter, IconLock, IconX} from "@tabler/icons-react"; import {Attendee, Event, EventOccurrence, LocationType, Product} from "../../../types.ts"; import classes from './AttendeeTicket.module.scss'; -import {imageUrl} from "../../../utilites/urlHelper.ts"; +import {attendeeWalletPassUrl, imageUrl} from "../../../utilites/urlHelper.ts"; import {resolveEventLocation} from "../../../utilites/effectiveLocation.ts"; import {formatAddress} from "../../../utilites/addressUtilities.ts"; import {PoweredByFooter} from "../PoweredByFooter"; @@ -168,6 +168,30 @@ export const AttendeeTicket = ({ {!hideButtons && (
+ {attendee.wallet_passes?.apple && !isVoid && ( + + )} + + {attendee.wallet_passes?.google && !isVoid && ( + + )} +