diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d238581275..2cc340dd32 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,6 +5,11 @@ on: branches: - main - develop + pull_request: + types: [closed] + branches: + - main + - develop workflow_dispatch: inputs: environment: @@ -25,6 +30,8 @@ jobs: backend: name: Deploy Backend runs-on: ubuntu-latest + # Only run this job when a PR is merged (not when closed without merging) + if: github.event_name != 'pull_request' || github.event.pull_request.merged == true steps: - uses: actions/checkout@v3 @@ -60,7 +67,7 @@ jobs: if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then echo "VAPOR_ENV=${{ github.event.inputs.environment }}" >> "$GITHUB_ENV" echo "TEST_MODE=${{ github.event.inputs.test_mode }}" >> "$GITHUB_ENV" - elif [[ "${{ github.ref_name }}" == "develop" ]]; then + elif [[ "${{ github.base_ref }}" == "develop" || "${{ github.ref_name }}" == "develop" ]]; then echo "VAPOR_ENV=staging" >> "$GITHUB_ENV" echo "TEST_MODE=false" >> "$GITHUB_ENV" else @@ -70,7 +77,7 @@ jobs: - name: Log Branch and Environment run: | - echo "๐Ÿš€ Deploying branch ${{ github.ref_name }} to Vapor environment: ${{ env.VAPOR_ENV }}" + echo "๐Ÿš€ Deploying to Vapor environment: ${{ env.VAPOR_ENV }}" echo "๐Ÿงช Test mode: ${{ env.TEST_MODE }}" - name: Validate Deployment Configuration @@ -92,6 +99,8 @@ jobs: name: Deploy Frontend runs-on: ubuntu-latest needs: backend + # Only run this job when a PR is merged (not when closed without merging) + if: github.event_name != 'pull_request' || github.event.pull_request.merged == true steps: - uses: actions/checkout@v3 @@ -105,7 +114,7 @@ jobs: echo "DO_APP_ID=${{ secrets.DIGITALOCEAN_PRODUCTION_APP_ID }}" >> "$GITHUB_ENV" fi echo "TEST_MODE=${{ github.event.inputs.test_mode }}" >> "$GITHUB_ENV" - elif [[ "${{ github.ref_name }}" == "develop" ]]; then + elif [[ "${{ github.base_ref }}" == "develop" || "${{ github.ref_name }}" == "develop" ]]; then echo "DO_APP_ID=${{ secrets.DIGITALOCEAN_STAGING_APP_ID }}" >> "$GITHUB_ENV" echo "TEST_MODE=false" >> "$GITHUB_ENV" else diff --git a/backend/app/DomainObjects/Enums/QuestionTypeEnum.php b/backend/app/DomainObjects/Enums/QuestionTypeEnum.php index 69678ab638..2340d03807 100644 --- a/backend/app/DomainObjects/Enums/QuestionTypeEnum.php +++ b/backend/app/DomainObjects/Enums/QuestionTypeEnum.php @@ -16,4 +16,12 @@ enum QuestionTypeEnum case DROPDOWN; case MULTI_SELECT_DROPDOWN; case DATE; + + public static function getMultipleChoiceTypes(): array + { + return [ + self::CHECKBOX, + self::MULTI_SELECT_DROPDOWN, + ]; + } } diff --git a/backend/app/DomainObjects/QuestionAndAnswerViewDomainObject.php b/backend/app/DomainObjects/QuestionAndAnswerViewDomainObject.php index 503b3e893e..53938c8a14 100644 --- a/backend/app/DomainObjects/QuestionAndAnswerViewDomainObject.php +++ b/backend/app/DomainObjects/QuestionAndAnswerViewDomainObject.php @@ -15,13 +15,18 @@ class QuestionAndAnswerViewDomainObject extends AbstractDomainObject private int $question_id; private ?int $order_id; private string $title; + private bool $question_required; + private ?string $question_description = null; private ?string $first_name = null; private ?string $last_name = null; private array|string $answer; private string $belongs_to; private ?int $attendee_id = null; + private ?string $attendee_public_id = null; private string $question_type; private int $event_id; + private int $question_answer_id; + private ?array $question_options = null; private ?AttendeeDomainObject $attendee = null; @@ -183,19 +188,86 @@ public function setQuestion(?QuestionDomainObject $question): static return $this; } + public function getQuestionAnswerId(): int + { + return $this->question_answer_id; + } + + public function setQuestionAnswerId(int $question_answer_id): QuestionAndAnswerViewDomainObject + { + $this->question_answer_id = $question_answer_id; + + return $this; + } + + public function getQuestionDescription(): ?string + { + return $this->question_description; + } + + public function setQuestionDescription(?string $question_description): QuestionAndAnswerViewDomainObject + { + $this->question_description = $question_description; + + return $this; + } + + public function getQuestionRequired(): bool + { + return $this->question_required; + } + + public function setQuestionRequired(bool $question_required): QuestionAndAnswerViewDomainObject + { + $this->question_required = $question_required; + + return $this; + } + + public function getQuestionOptions(): ?array + { + return $this->question_options; + } + + public function setQuestionOptions(?array $question_options): QuestionAndAnswerViewDomainObject + { + $this->question_options = $question_options; + + return $this; + } + + public function getAttendeePublicId(): ?string + { + return $this->attendee_public_id; + } + + public function setAttendeePublicId(?string $attendee_public_id): QuestionAndAnswerViewDomainObject + { + $this->attendee_public_id = $attendee_public_id; + + return $this; + } + public function toArray(): array { return [ 'question_id' => $this->question_id ?? null, 'order_id' => $this->order_id ?? null, 'title' => $this->title ?? null, + 'question_description' => $this->question_description ?? null, + 'question_required' => $this->question_required ?? null, 'last_name' => $this->last_name ?? null, 'answer' => $this->answer ?? null, 'belongs_to' => $this->belongs_to ?? null, 'attendee_id' => $this->attendee_id ?? null, + 'attendee_public_id' => $this->attendee_public_id ?? null, 'question_type' => $this->question_type ?? null, 'first_name' => $this->first_name ?? null, 'event_id' => $this->event_id ?? null, + 'product_id' => $this->product_id ?? null, + 'product_title' => $this->product_title ?? null, + 'question_answer_id' => $this->question_answer_id ?? null, + 'question_options' => $this->question_options ?? null, ]; } } diff --git a/backend/app/DomainObjects/QuestionAnswerDomainObject.php b/backend/app/DomainObjects/QuestionAnswerDomainObject.php index 150156938b..8f2e241c92 100644 --- a/backend/app/DomainObjects/QuestionAnswerDomainObject.php +++ b/backend/app/DomainObjects/QuestionAnswerDomainObject.php @@ -2,6 +2,35 @@ namespace HiEvents\DomainObjects; +use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract; + class QuestionAnswerDomainObject extends Generated\QuestionAnswerDomainObjectAbstract { + private ?OrderDomainObjectAbstract $order = null; + + private ?QuestionDomainObject $question = null; + + public function setOrder(?OrderDomainObjectAbstract $order): QuestionAnswerDomainObject + { + $this->order = $order; + + return $this; + } + + public function getOrder(): ?OrderDomainObjectAbstract + { + return $this->order; + } + + public function setQuestion(?QuestionDomainObject $question): QuestionAnswerDomainObject + { + $this->question = $question; + + return $this; + } + + public function getQuestion(): ?QuestionDomainObject + { + return $this->question; + } } diff --git a/backend/app/DomainObjects/QuestionDomainObject.php b/backend/app/DomainObjects/QuestionDomainObject.php index 5b544cc98d..7d84e3a255 100644 --- a/backend/app/DomainObjects/QuestionDomainObject.php +++ b/backend/app/DomainObjects/QuestionDomainObject.php @@ -40,4 +40,20 @@ public function setOptions(array|string|null $options): self return $this; } + public function isAnswerValid(mixed $answer): bool + { + if (!isset($answer)) { + return false; + } + + if (!$this->isPreDefinedChoice()) { + return true; + } + + if (is_string($answer)) { + return in_array($answer, $this->getOptions(), true); + } + + return array_diff((array)$answer, $this->getOptions()) === []; + } } diff --git a/backend/app/Http/Actions/Auth/LoginAction.php b/backend/app/Http/Actions/Auth/LoginAction.php index e5f3b1782f..3f0e0efd6e 100644 --- a/backend/app/Http/Actions/Auth/LoginAction.php +++ b/backend/app/Http/Actions/Auth/LoginAction.php @@ -26,7 +26,7 @@ public function __invoke(LoginRequest $request): JsonResponse $loginResponse = $this->loginHandler->handle(new LoginCredentialsDTO( email: strtolower($request->validated('email')), password: $request->validated('password'), - accountId: $request->validated('account_id'), + accountId: (int)$request->validated('account_id'), )); } catch (UnauthorizedException $e) { return $this->errorResponse( diff --git a/backend/app/Http/Actions/Auth/RefreshTokenAction.php b/backend/app/Http/Actions/Auth/RefreshTokenAction.php index 7f40752a3e..c21f85011e 100644 --- a/backend/app/Http/Actions/Auth/RefreshTokenAction.php +++ b/backend/app/Http/Actions/Auth/RefreshTokenAction.php @@ -8,6 +8,6 @@ class RefreshTokenAction extends BaseAuthAction { public function __invoke(): JsonResponse { - return $this->respondWithToken(auth()->refresh(), auth()->user()->accounts); + return $this->respondWithToken(auth()->refresh(), $this->getAuthenticatedUser()->accounts ?? collect()); } } diff --git a/backend/app/Http/Actions/Orders/GetOrderAction.php b/backend/app/Http/Actions/Orders/GetOrderAction.php index b72c5581e8..0d399cacae 100644 --- a/backend/app/Http/Actions/Orders/GetOrderAction.php +++ b/backend/app/Http/Actions/Orders/GetOrderAction.php @@ -7,6 +7,8 @@ use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\QuestionAndAnswerViewDomainObject; use HiEvents\Http\Actions\BaseAction; +use HiEvents\Repository\Eloquent\Value\OrderAndDirection; +use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Resources\Order\OrderResource; use Illuminate\Http\JsonResponse; @@ -27,7 +29,9 @@ public function __invoke(int $eventId, int $orderId): JsonResponse $order = $this->orderRepository ->loadRelation(OrderItemDomainObject::class) ->loadRelation(AttendeeDomainObject::class) - ->loadRelation(QuestionAndAnswerViewDomainObject::class) + ->loadRelation(new Relationship(domainObject: QuestionAndAnswerViewDomainObject::class, orderAndDirections: [ + new OrderAndDirection(order: 'question_id'), + ])) ->findById($orderId); return $this->resourceResponse(OrderResource::class, $order); diff --git a/backend/app/Http/Actions/Questions/EditQuestionAnswerAction.php b/backend/app/Http/Actions/Questions/EditQuestionAnswerAction.php new file mode 100644 index 0000000000..61bc908be7 --- /dev/null +++ b/backend/app/Http/Actions/Questions/EditQuestionAnswerAction.php @@ -0,0 +1,43 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + try { + $this->editQuestionAnswerHandler->handle(new EditQuestionAnswerDTO( + questionAnswerId: $questionAnswerId, + eventId: $eventId, + answer: $request->validated('answer'), + )); + } catch (InvalidAnswerException $e) { + throw ValidationException::withMessages(['answer.answer' => $e->getMessage()]); + } + + return $this->noContentResponse(); + } +} diff --git a/backend/app/Http/Actions/Users/DeactivateUsersAction.php b/backend/app/Http/Actions/Users/DeactivateUsersAction.php deleted file mode 100644 index fcdc029a66..0000000000 --- a/backend/app/Http/Actions/Users/DeactivateUsersAction.php +++ /dev/null @@ -1,39 +0,0 @@ -userRepository = $userRepository; - } - - public function __invoke(int $userId): Response - { - $this->isActionAuthorized($userId, UserDomainObject::class, Role::ADMIN); - - $authUser = $this->getAuthenticatedUser(); - - $this->userRepository->updateWhere( - attributes: [ - 'status' => UserStatus::INACTIVE->name, - ], - where: [ - 'id' => $authUser->getId(), - 'account_id' => $this->getAuthenticatedAccountId(), - ] - ); - - return $this->deletedResponse(); - } -} diff --git a/backend/app/Http/Request/Questions/EditQuestionAnswerRequest.php b/backend/app/Http/Request/Questions/EditQuestionAnswerRequest.php new file mode 100644 index 0000000000..3949c2f616 --- /dev/null +++ b/backend/app/Http/Request/Questions/EditQuestionAnswerRequest.php @@ -0,0 +1,22 @@ + [ + 'nullable', + function ($attribute, $value, $fail) { + if (!is_string($value) && !is_array($value)) { + $fail("The {$attribute} must be a string or an array."); + } + } + ], + ]; + } +} diff --git a/backend/app/Models/QuestionAndAnswerView.php b/backend/app/Models/QuestionAndAnswerView.php index e5634138ff..ff7971a458 100644 --- a/backend/app/Models/QuestionAndAnswerView.php +++ b/backend/app/Models/QuestionAndAnswerView.php @@ -14,6 +14,7 @@ class QuestionAndAnswerView extends Model protected $casts = [ 'answer' => 'array', + 'question_options' => 'array', ]; public function attendee(): BelongsTo diff --git a/backend/app/Models/QuestionAnswer.php b/backend/app/Models/QuestionAnswer.php index c9c4de1313..28d722c766 100644 --- a/backend/app/Models/QuestionAnswer.php +++ b/backend/app/Models/QuestionAnswer.php @@ -3,6 +3,7 @@ namespace HiEvents\Models; use HiEvents\DomainObjects\Generated\QuestionAnswerDomainObjectAbstract; +use Illuminate\Database\Eloquent\Relations\BelongsTo; class QuestionAnswer extends BaseModel { @@ -23,4 +24,14 @@ protected function getFillableFields(): array QuestionAnswerDomainObjectAbstract::ANSWER, ]; } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function question(): BelongsTo + { + return $this->belongsTo(Question::class); + } } diff --git a/backend/app/Repository/Interfaces/RepositoryInterface.php b/backend/app/Repository/Interfaces/RepositoryInterface.php index 0426e15288..0b5930bc7e 100644 --- a/backend/app/Repository/Interfaces/RepositoryInterface.php +++ b/backend/app/Repository/Interfaces/RepositoryInterface.php @@ -5,6 +5,7 @@ use Exception; use HiEvents\DomainObjects\Interfaces\DomainObjectInterface; use HiEvents\Repository\Eloquent\Value\OrderAndDirection; +use HiEvents\Repository\Eloquent\Value\Relationship; use Illuminate\Contracts\Pagination\Paginator; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Pagination\LengthAwarePaginator; @@ -198,4 +199,6 @@ public function decrementEach(array $where, array $columns, array $extra = []): public function incrementEach(array $columns, array $additionalUpdates = [], ?array $where = null); public function includeDeleted(): static; + + public function loadRelation(string|Relationship $relationship): static; } diff --git a/backend/app/Resources/Question/QuestionAnswerViewResource.php b/backend/app/Resources/Question/QuestionAnswerViewResource.php index 7c3fae4fee..d5b565c26a 100644 --- a/backend/app/Resources/Question/QuestionAnswerViewResource.php +++ b/backend/app/Resources/Question/QuestionAnswerViewResource.php @@ -20,6 +20,8 @@ public function toArray(Request $request): array 'product_title' => $this->getProductTitle(), 'question_id' => $this->getQuestionId(), 'title' => $this->getTitle(), + 'question_required' => $this->getQuestionRequired(), + 'question_description' => $this->getQuestionDescription(), 'answer' => $this->getAnswer(), 'text_answer' => app(QuestionAnswerFormatter::class)->getAnswerAsText( $this->getAnswer(), @@ -28,6 +30,9 @@ public function toArray(Request $request): array 'order_id' => $this->getOrderId(), 'belongs_to' => $this->getBelongsTo(), 'question_type' => $this->getQuestionType(), + 'event_id' => $this->getEventId(), + 'question_answer_id' => $this->getQuestionAnswerId(), + 'question_options' => $this->getQuestionOptions(), $this->mergeWhen( $this->getAttendeeId() !== null, @@ -35,6 +40,7 @@ public function toArray(Request $request): array 'attendee_id' => $this->getAttendeeId(), 'first_name' => $this->getFirstName(), 'last_name' => $this->getLastName(), + 'attendee_public_id' => $this->getAttendeePublicId(), ] ), ]; diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php index f83dccee14..f133c3f0d4 100644 --- a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php +++ b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php @@ -10,7 +10,9 @@ use HiEvents\DomainObjects\AccountConfigurationDomainObject; use HiEvents\DomainObjects\Generated\StripePaymentDomainObjectAbstract; use HiEvents\DomainObjects\OrderItemDomainObject; +use HiEvents\DomainObjects\Status\OrderStatus; use HiEvents\DomainObjects\StripePaymentDomainObject; +use HiEvents\Exceptions\ResourceConflictException; use HiEvents\Exceptions\Stripe\CreatePaymentIntentFailedException; use HiEvents\Exceptions\UnauthorizedException; use HiEvents\Repository\Eloquent\Value\Relationship; @@ -58,6 +60,10 @@ public function handle(string $orderShortId): CreatePaymentIntentResponseDTO throw new UnauthorizedException(__('Sorry, we could not verify your session. Please create a new order.')); } + if ($order->getStatus() !== OrderStatus::RESERVED->name || $order->isReservedOrderExpired()) { + throw new ResourceConflictException(__('Sorry, is expired or not in a valid state.')); + } + $account = $this->accountRepository ->loadRelation(new Relationship( domainObject: AccountConfigurationDomainObject::class, diff --git a/backend/app/Services/Application/Handlers/Question/DTO/EditQuestionAnswerDTO.php b/backend/app/Services/Application/Handlers/Question/DTO/EditQuestionAnswerDTO.php new file mode 100644 index 0000000000..2f94eb76c2 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Question/DTO/EditQuestionAnswerDTO.php @@ -0,0 +1,16 @@ +editQuestionAnswerService->editQuestionAnswer( + eventId: $editQuestionAnswerDTO->eventId, + questionAnswerId: $editQuestionAnswerDTO->questionAnswerId, + answer: $editQuestionAnswerDTO->answer, + ); + } +} diff --git a/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php b/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php index e9dfd36f4a..15a3e3e982 100644 --- a/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php +++ b/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php @@ -6,6 +6,7 @@ use HiEvents\DomainObjects\AccountDomainObject; use HiEvents\DomainObjects\Enums\PaymentProviders; use HiEvents\DomainObjects\Enums\WebhookEventType; +use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\Status\AttendeeStatus; @@ -116,8 +117,8 @@ private function updateAttendeeStatuses(OrderDomainObject $updatedOrder): void private function storeApplicationFeePayment(OrderDomainObject $updatedOrder): void { - /** @var AccountConfigurationDomainObject $config */ - $config = $this->eventRepository + /** @var EventDomainObject $event */ + $event = $this->eventRepository ->loadRelation(new Relationship( domainObject: AccountDomainObject::class, nested: [ @@ -128,16 +129,18 @@ private function storeApplicationFeePayment(OrderDomainObject $updatedOrder): vo ], name: 'account' )) - ->findById($updatedOrder->getEventId()) - ->getAccount() - ->getConfiguration(); + ->findById($updatedOrder->getEventId()); + + /** @var AccountConfigurationDomainObject $config */ + $config = $event->getAccount()->getConfiguration(); $this->orderApplicationFeeService->createOrderApplicationFee( orderId: $updatedOrder->getId(), applicationFeeAmount: $this->orderApplicationFeeCalculationService->calculateApplicationFee( $config, $updatedOrder->getTotalGross(), - ), + $event->getCurrency(), + )->toFloat(), orderApplicationFeeStatus: OrderApplicationFeeStatus::AWAITING_PAYMENT, paymentMethod: PaymentProviders::OFFLINE, ); diff --git a/backend/app/Services/Domain/Order/OrderApplicationFeeCalculationService.php b/backend/app/Services/Domain/Order/OrderApplicationFeeCalculationService.php index 1bb69c18f6..b8ee73a4bf 100644 --- a/backend/app/Services/Domain/Order/OrderApplicationFeeCalculationService.php +++ b/backend/app/Services/Domain/Order/OrderApplicationFeeCalculationService.php @@ -3,6 +3,7 @@ namespace HiEvents\Services\Domain\Order; use HiEvents\DomainObjects\AccountConfigurationDomainObject; +use HiEvents\Values\MoneyValue; use Illuminate\Config\Repository; class OrderApplicationFeeCalculationService @@ -15,16 +16,20 @@ public function __construct( public function calculateApplicationFee( AccountConfigurationDomainObject $accountConfiguration, - float $orderTotal - ): float + float $orderTotal, + string $currency + ): MoneyValue { if (!$this->config->get('app.saas_mode_enabled')) { - return 0; + return MoneyValue::zero($currency); } $fixedFee = $accountConfiguration->getFixedApplicationFee(); $percentageFee = $accountConfiguration->getPercentageApplicationFee(); - return ($fixedFee) + ($orderTotal * ($percentageFee / 100)); + return MoneyValue::fromFloat( + amount: $fixedFee + ($orderTotal * $percentageFee / 100), + currency: $currency + ); } } diff --git a/backend/app/Services/Domain/Order/OrderApplicationFeeService.php b/backend/app/Services/Domain/Order/OrderApplicationFeeService.php index 889bff2503..12904baac0 100644 --- a/backend/app/Services/Domain/Order/OrderApplicationFeeService.php +++ b/backend/app/Services/Domain/Order/OrderApplicationFeeService.php @@ -28,7 +28,7 @@ public function createOrderApplicationFee( OrderApplicationFeeDomainObjectAbstract::STATUS => $orderApplicationFeeStatus->value, OrderApplicationFeeDomainObjectAbstract::PAYMENT_METHOD => $paymentMethod->value, OrderApplicationFeeDomainObjectAbstract::PAID_AT => $orderApplicationFeeStatus->value === OrderApplicationFeeStatus::PAID->value - ? now() + ? now()->toDateTimeString() : null, ]); } diff --git a/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php b/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php index 0906335872..8a9c38d7d3 100644 --- a/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php +++ b/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php @@ -64,8 +64,9 @@ public function createPaymentIntent(CreatePaymentIntentRequestDTO $paymentIntent $applicationFee = $this->orderApplicationFeeCalculationService->calculateApplicationFee( accountConfiguration: $paymentIntentDTO->account->getConfiguration(), - orderTotal: $paymentIntentDTO->amount / 100 - ); + orderTotal: $paymentIntentDTO->amount / 100, + currency: $paymentIntentDTO->currencyCode, + )->toMinorUnit(); $paymentIntent = $this->stripeClient->paymentIntents->create([ 'amount' => $paymentIntentDTO->amount, diff --git a/backend/app/Services/Domain/Question/EditQuestionAnswerService.php b/backend/app/Services/Domain/Question/EditQuestionAnswerService.php new file mode 100644 index 0000000000..899f97928a --- /dev/null +++ b/backend/app/Services/Domain/Question/EditQuestionAnswerService.php @@ -0,0 +1,66 @@ +questionAnswerRepository + ->loadRelation(new Relationship(domainObject: OrderDomainObject::class, name: 'order')) + ->loadRelation(new Relationship(domainObject: QuestionDomainObject::class, name: 'question')) + ->findById($questionAnswerId); + + /** @var QuestionDomainObject $question */ + $question = $questionAnswer->getQuestion(); + /** @var OrderDomainObject $order */ + $order = $questionAnswer->getOrder(); + + if ($order->getEventId() !== $eventId) { + $this->logger->error('Question answer does not belong to the event', [ + 'event_id' => $eventId, + 'question_answer_id' => $questionAnswerId, + ]); + + throw new ResourceNotFoundException('Question answer does not belong to the event'); + } + + if (!$question->isAnswerValid($answer)) { + $this->logger->error('Invalid answer', [ + 'question_id' => $question->getId(), + 'answer' => $answer, + ]); + + throw new InvalidAnswerException('Please provide a valid answer'); + } + + $this->questionAnswerRepository->updateWhere( + attributes: [ + 'answer' => json_encode($answer, JSON_THROW_ON_ERROR), + ], + where: [ + 'id' => $questionAnswerId, + 'order_id' => $order->getId(), + ], + ); + } +} diff --git a/backend/app/Services/Domain/Question/Exception/InvalidAnswerException.php b/backend/app/Services/Domain/Question/Exception/InvalidAnswerException.php new file mode 100644 index 0000000000..5233e65b2f --- /dev/null +++ b/backend/app/Services/Domain/Question/Exception/InvalidAnswerException.php @@ -0,0 +1,10 @@ +getProductId(); } - protected function isAnswerValid(QuestionDomainObject $questionDomainObject, mixed $response): bool - { - if (!$questionDomainObject->isPreDefinedChoice()) { - return true; - } - - if (!isset($response['answer'])) { - return false; - } - - if (is_string($response['answer'])) { - return in_array($response['answer'], $questionDomainObject->getOptions(), true); - } - - return array_diff((array)$response['answer'], $questionDomainObject->getOptions()) === []; - } - protected function getQuestionDomainObject(?int $questionId): ?QuestionDomainObject { if ($questionId === null) { diff --git a/backend/app/Validators/Rules/OrderQuestionRule.php b/backend/app/Validators/Rules/OrderQuestionRule.php index 7d80557a29..303247849f 100644 --- a/backend/app/Validators/Rules/OrderQuestionRule.php +++ b/backend/app/Validators/Rules/OrderQuestionRule.php @@ -32,6 +32,7 @@ protected function validateQuestions(mixed $questions): array $questionDomainObject = $this->getQuestionDomainObject($orderQuestion['question_id']); $key = 'order.questions.' . $index . '.response'; $response = $orderQuestion['response'] ?? null; + $answer = $response['answer'] ?? $response; if (!$questionDomainObject) { $validationMessages[$key . '.answer'][] = 'This question is outdated. Please reload the page.'; @@ -46,7 +47,7 @@ protected function validateQuestions(mixed $questions): array $validationMessages = $this->validateRequiredFields($questionDomainObject, $response, $key, $validationMessages); } - if (!$this->isAnswerValid($questionDomainObject, $response)) { + if (!$questionDomainObject->isAnswerValid($answer)) { $validationMessages[$key . '.answer'][] = 'Please select an option'; } diff --git a/backend/app/Validators/Rules/ProductQuestionRule.php b/backend/app/Validators/Rules/ProductQuestionRule.php index 534dd92ef8..56479d954f 100644 --- a/backend/app/Validators/Rules/ProductQuestionRule.php +++ b/backend/app/Validators/Rules/ProductQuestionRule.php @@ -59,6 +59,7 @@ protected function validateQuestions(mixed $products): array $questionDomainObject = $this->getQuestionDomainObject($question['question_id'] ?? null); $key = 'products.' . $productIndex . '.questions.' . $questionIndex . '.response'; $response = empty($question['response']) ? null : $question['response']; + $answer = $response['answer'] ?? $response; if (!$questionDomainObject) { $validationMessages[$key . '.answer'][] = __('This question is outdated. Please reload the page.'); @@ -73,7 +74,7 @@ protected function validateQuestions(mixed $products): array $validationMessages = $this->validateRequiredFields($questionDomainObject, $response, $key, $validationMessages); } - if (!$this->isAnswerValid($questionDomainObject, $response)) { + if (!$questionDomainObject->isAnswerValid($answer)) { $validationMessages[$key . '.answer'][] = __('Please select an option'); } diff --git a/backend/app/Values/MoneyValue.php b/backend/app/Values/MoneyValue.php index 81e03ee094..d3194e356c 100644 --- a/backend/app/Values/MoneyValue.php +++ b/backend/app/Values/MoneyValue.php @@ -46,6 +46,11 @@ public function add(MoneyValue $other): MoneyValue return new self($this->money->plus($other->getMoney())); } + public static function zero(string $currency): MoneyValue + { + return new self(Money::zero($currency)); + } + /** * @throws UnknownCurrencyException * @throws NumberFormatException diff --git a/backend/config/app.php b/backend/config/app.php index 036c59e034..aca587999b 100644 --- a/backend/config/app.php +++ b/backend/config/app.php @@ -221,4 +221,6 @@ // 'Example' => App\Facades\Example::class, ])->toArray(), + + 'is_hi_events' => env('APP_IS_HI_EVENTS', false), ]; diff --git a/backend/config/timezones.php b/backend/config/timezones.php index 54a80be57c..c15cba5dc6 100644 --- a/backend/config/timezones.php +++ b/backend/config/timezones.php @@ -12,7 +12,86 @@ 'Asia/Macao' => 'Asia/Macau', 'Pacific/Samoa' => 'Pacific/Pago_Pago', 'Pacific/Yap' => 'Pacific/Chuuk', - 'Etc/GMT+0' => 'Etc/GMT', 'Etc/Greenwich' => 'Etc/GMT', + + 'Etc/GMT+1' => 'Atlantic/Azores', // UTC-1 + 'Etc/GMT+2' => 'Atlantic/South_Georgia', // UTC-2 + 'Etc/GMT+3' => 'America/Argentina/Buenos_Aires', // UTC-3 + 'Etc/GMT+4' => 'America/La_Paz', // UTC-4 + 'Etc/GMT+5' => 'America/New_York', // UTC-5 + 'Etc/GMT+6' => 'America/Chicago', // UTC-6 + 'Etc/GMT+7' => 'America/Denver', // UTC-7 + 'Etc/GMT+8' => 'America/Los_Angeles', // UTC-8 + 'Etc/GMT+9' => 'America/Anchorage', // UTC-9 + 'Etc/GMT+10' => 'Pacific/Honolulu', // UTC-10 + 'Etc/GMT+11' => 'Pacific/Pago_Pago', // UTC-11 + 'Etc/GMT-1' => 'Europe/Paris', // UTC+1 + 'Etc/GMT-2' => 'Europe/Athens', // UTC+2 + 'Etc/GMT-3' => 'Europe/Moscow', // UTC+3 + 'Etc/GMT-4' => 'Asia/Dubai', // UTC+4 + 'Etc/GMT-5' => 'Asia/Karachi', // UTC+5 + 'Etc/GMT-6' => 'Asia/Dhaka', // UTC+6 + 'Etc/GMT-7' => 'Asia/Bangkok', // UTC+7 + 'Etc/GMT-8' => 'Asia/Singapore', // UTC+8 + 'Etc/GMT-9' => 'Asia/Tokyo', // UTC+9 + 'Etc/GMT-10' => 'Pacific/Port_Moresby', // UTC+10 + 'Etc/GMT-11' => 'Pacific/Noumea', // UTC+11 + 'Etc/GMT-12' => 'Pacific/Fiji', // UTC+12 + + 'Africa/Timbuktu' => 'Africa/Bamako', + 'America/Argentina/ComodRivadavia' => 'America/Argentina/Catamarca', + 'America/Atka' => 'America/Adak', + 'America/Ensenada' => 'America/Tijuana', + 'America/Fort_Wayne' => 'America/Indiana/Indianapolis', + 'America/Knox_IN' => 'America/Indiana/Knox', + 'America/Louisville' => 'America/Kentucky/Louisville', + 'America/Montreal' => 'America/Toronto', + 'America/Shiprock' => 'America/Denver', + 'Asia/Ashkhabad' => 'Asia/Ashgabat', + 'Asia/Chungking' => 'Asia/Shanghai', + 'Asia/Dacca' => 'Asia/Dhaka', + 'Asia/Harbin' => 'Asia/Shanghai', + 'Asia/Istanbul' => 'Europe/Istanbul', + 'Asia/Kashgar' => 'Asia/Urumqi', + 'Asia/Tel_Aviv' => 'Asia/Jerusalem', + 'Australia/ACT' => 'Australia/Sydney', + 'Australia/Canberra' => 'Australia/Sydney', + 'Australia/LHI' => 'Australia/Lord_Howe', + 'Australia/NSW' => 'Australia/Sydney', + 'Australia/North' => 'Australia/Darwin', + 'Australia/Queensland' => 'Australia/Brisbane', + 'Australia/South' => 'Australia/Adelaide', + 'Australia/Tasmania' => 'Australia/Hobart', + 'Australia/Victoria' => 'Australia/Melbourne', + 'Australia/West' => 'Australia/Perth', + 'Australia/Yancowinna' => 'Australia/Broken_Hill', + 'Brazil/Acre' => 'America/Rio_Branco', + 'Brazil/DeNoronha' => 'America/Noronha', + 'Brazil/East' => 'America/Sao_Paulo', + 'Brazil/West' => 'America/Manaus', + 'Canada/Atlantic' => 'America/Halifax', + 'Canada/Central' => 'America/Winnipeg', + 'Canada/Eastern' => 'America/Toronto', + 'Canada/Mountain' => 'America/Edmonton', + 'Canada/Newfoundland' => 'America/St_Johns', + 'Canada/Pacific' => 'America/Vancouver', + 'Canada/Saskatchewan' => 'America/Regina', + 'Canada/Yukon' => 'America/Whitehorse', + 'Chile/Continental' => 'America/Santiago', + 'Chile/EasterIsland' => 'Pacific/Easter', + 'Cuba' => 'America/Havana', + 'Egypt' => 'Africa/Cairo', + 'Eire' => 'Europe/Dublin', + 'GB' => 'Europe/London', + 'GB-Eire' => 'Europe/London', + 'Hongkong' => 'Asia/Hong_Kong', + 'Iceland' => 'Atlantic/Reykjavik', + 'Iran' => 'Asia/Tehran', + 'Israel' => 'Asia/Jerusalem', + 'Jamaica' => 'America/Jamaica', + 'Japan' => 'Asia/Tokyo', + 'Kwajalein' => 'Pacific/Kwajalein', + 'Libya' => 'Africa/Tripoli', + 'Mexico/BajaNorte' => 'America/Tijuana', ], ]; diff --git a/backend/database/migrations/2025_02_28_112829_update_question_and_answers_view.php b/backend/database/migrations/2025_02_28_112829_update_question_and_answers_view.php new file mode 100644 index 0000000000..42caa13767 --- /dev/null +++ b/backend/database/migrations/2025_02_28_112829_update_question_and_answers_view.php @@ -0,0 +1,62 @@ +get('/users', GetUsersAction::class); $router->get('/users/{user_id}', GetUserAction::class); $router->put('/users/{user_id}', UpdateUserAction::class); - $router->delete('/users/{user_id}', DeactivateUsersAction::class); $router->post('/users/{user_id}/email-change/{token}', ConfirmEmailChangeAction::class); $router->post('/users/{user_id}/invitation', ResendInvitationAction::class); $router->delete('/users/{user_id}/invitation', DeleteInvitationAction::class); @@ -248,6 +247,7 @@ function (Router $router): void { $router->get('/events/{event_id}/questions', GetQuestionsAction::class); $router->post('/events/{event_id}/questions/export', ExportOrdersAction::class); $router->post('/events/{event_id}/questions/sort', SortQuestionsAction::class); + $router->put('/events/{event_id}/questions/{question_id}/answer/{answer_id}', EditQuestionAnswerAction::class); // Images $router->post('/events/{event_id}/images', CreateEventImageAction::class); diff --git a/backend/vapor.yml b/backend/vapor.yml index d1872bbcc5..9684f00aec 100644 --- a/backend/vapor.yml +++ b/backend/vapor.yml @@ -1,15 +1,16 @@ -id: 68983 +id: 69938 name: HiEvents environments: production: gateway-version: 2.0 domain: api.hi.events - memory: 1408 + memory: 2048 cli-memory: 512 + storage: hievents-assets-prod runtime: 'php-8.3:al2' warm: 3 - cache: hievents-redis-prod - database: hievents-db-prod + cache: hievents-redis + database: hievents-postgres queues: - hievents-queue-prod - hievents-webhook-queue-prod @@ -28,10 +29,10 @@ environments: cli-memory: 512 runtime: 'php-8.3:al2' warm: 3 - cache: hievents-redis-prod - database: hievents-db-prod + cache: hievents-redis + database: hievents-postgres queue: - - hievents-queue-prod + - hievents-queue-staging - hievents-webhook-queue-staging queue-memory: 1024 queue-concurrency: 2 diff --git a/frontend/src/api/question.client.ts b/frontend/src/api/question.client.ts index 10e4f8ce56..f921ad8b8a 100644 --- a/frontend/src/api/question.client.ts +++ b/frontend/src/api/question.client.ts @@ -32,7 +32,12 @@ export const questionClient = { }, sortQuestions: async (eventId: IdParam, questionsSort: SortableItem[]) => { return await api.post(`/events/${eventId}/questions/sort`, questionsSort); - } + }, + updateAnswerQuestion: async (eventId: IdParam, questionId: IdParam, answerId: IdParam, answer: string | string[]) => { + await api.put(`/events/${eventId}/questions/${questionId}/answer/${answerId}`, { + 'answer': answer, + }); + }, } export const questionClientPublic = { diff --git a/frontend/src/components/common/AttendeeList/AttendeeList.module.scss b/frontend/src/components/common/AttendeeList/AttendeeList.module.scss index 622146ad4c..367b11820e 100644 --- a/frontend/src/components/common/AttendeeList/AttendeeList.module.scss +++ b/frontend/src/components/common/AttendeeList/AttendeeList.module.scss @@ -12,7 +12,7 @@ .attendee { display: flex; - align-items: center; + flex-direction: column; padding: 0.875rem; background: var(--mantine-color-gray-0); border: 1px solid var(--mantine-color-gray-2); @@ -58,3 +58,32 @@ background: var(--tk-color-gray); font-weight: 500; } + +/* Added styles for answers display */ +.answersContainer { + margin-top: 0.75rem; + padding-top: 0.75rem; + padding-left: .4rem; + border-top: 1px solid var(--mantine-color-gray-2); +} + +.questionAnswer { + margin-bottom: 0.75rem; + + &:last-child { + margin-bottom: 0; + } +} + +.questionTitle { + display: flex; + gap: 0.5rem; + margin-bottom: 0.25rem; + align-items: flex-start; +} + +.answer { + color: var(--mantine-color-gray-7); + padding-left: 1.5rem; + font-size: var(--mantine-font-size-sm); +} diff --git a/frontend/src/components/common/AttendeeList/index.tsx b/frontend/src/components/common/AttendeeList/index.tsx index b93a033d5f..6a8ebc11ba 100644 --- a/frontend/src/components/common/AttendeeList/index.tsx +++ b/frontend/src/components/common/AttendeeList/index.tsx @@ -1,19 +1,21 @@ -import { ActionIcon, Avatar, Tooltip, Text, Group } from "@mantine/core"; -import { getInitials } from "../../../utilites/helpers.ts"; -import { NavLink } from "react-router"; -import { IconExternalLink, IconUsers } from "@tabler/icons-react"; +import {ActionIcon, Avatar, Collapse, Group, Text, Tooltip} from "@mantine/core"; +import {getInitials} from "../../../utilites/helpers.ts"; +import {NavLink} from "react-router"; +import {IconChevronUp, IconExternalLink, IconMessageCircle2} from "@tabler/icons-react"; import classes from './AttendeeList.module.scss'; -import { Order, Product } from "../../../types.ts"; -import { t } from "@lingui/macro"; +import {IdParam, Order, Product, QuestionAnswer} from "../../../types.ts"; +import {t} from "@lingui/macro"; +import {useState} from "react"; +import {QuestionList} from "../QuestionAndAnswerList"; interface AttendeeListProps { order: Order; products: Product[]; + questionAnswers?: QuestionAnswer[]; + refetchOrder?: () => void; } -export const AttendeeList = ({ order, products }: AttendeeListProps) => { - const attendeeCount = order.attendees?.length || 0; - +export const AttendeeList = ({order, products, refetchOrder, questionAnswers = []}: AttendeeListProps) => { if (!order.attendees?.length) { return (
@@ -24,12 +26,35 @@ export const AttendeeList = ({ order, products }: AttendeeListProps) => { ); } + const [expandedAttendees, setExpandedAttendees] = useState([]); + + const hasQuestions = (attendeeId: IdParam) => { + return questionAnswers.some(qa => qa.attendee_id === attendeeId); + }; + + const getAttendeeQuestions = (attendeeId: IdParam) => { + return questionAnswers.filter(qa => qa.attendee_id === attendeeId); + }; + + const toggleExpanded = (attendeeId: IdParam) => { + setExpandedAttendees(prev => + prev.includes(attendeeId) + ? prev.filter(id => id !== attendeeId) + : [...prev, attendeeId] + ); + }; + + const isExpanded = (attendeeId: IdParam) => { + return expandedAttendees.includes(attendeeId); + }; + return (
{order.attendees.map(attendee => { const product = products?.find(p => p.id === attendee.product_id); const fullName = `${attendee.first_name} ${attendee.last_name}`; + const attendeeHasQuestions = hasQuestions(attendee.id); return (
@@ -53,23 +78,56 @@ export const AttendeeList = ({ order, products }: AttendeeListProps) => { )}
- - - + {attendeeHasQuestions && ( + - - - - + toggleExpanded(attendee.id)} + > + {isExpanded(attendee.id) + ? + : } + + + )} + + + + + + + + +
+ + {/* Collapsible answers section */} + +
+ +
+
); })} diff --git a/frontend/src/components/common/QuestionAndAnswerList/QuestionAndAnswerList.module.scss b/frontend/src/components/common/QuestionAndAnswerList/QuestionAndAnswerList.module.scss index ea556c5b97..9acae80f4b 100644 --- a/frontend/src/components/common/QuestionAndAnswerList/QuestionAndAnswerList.module.scss +++ b/frontend/src/components/common/QuestionAndAnswerList/QuestionAndAnswerList.module.scss @@ -1,77 +1,135 @@ +/* QuestionAndAnswerList.module.scss */ .container { display: flex; flex-direction: column; - gap: 2rem; + gap: 1.5rem; } .section { display: flex; flex-direction: column; - gap: 5px; + gap: 0.75rem; } .sectionHeader { - display: flex; - align-items: center; - gap: 0.5rem; - padding-bottom: 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--mantine-color-gray-2); } .questionsList { display: flex; flex-direction: column; - gap: 1rem; + gap: 0.75rem; } .questionCard { - padding: 1rem; - border-radius: var(--mantine-radius-sm); - background: var(--mantine-color-gray-0); + padding: 0.75rem; + background: #ffffff; border: 1px solid var(--mantine-color-gray-2); - transition: all 0.2s ease; + border-radius: var(--mantine-radius-sm); +} + +.questionCompact { + padding: 0.5rem 0; + border-bottom: 1px solid var(--mantine-color-gray-1); - &:hover { - border-color: var(--mantine-color-gray-3); + &:last-child { + border-bottom: none; } } .productTitle { color: var(--mantine-color-gray-6); margin-bottom: 0.5rem; + font-size: var(--mantine-font-size-xs); } .questionTitle { display: flex; gap: 0.5rem; + margin-bottom: 0.375rem; align-items: flex-start; - margin-bottom: 0.75rem; +} - svg { - margin-top: 0; - } +.answerContainer { + display: flex; + align-items: flex-start; + gap: 0.5rem; } .answer { - color: var(--mantine-color-dark-9); - line-height: 1.5; - padding: 0.5rem 0; + flex: 1; + color: var(--mantine-color-dark-6); + word-break: break-word; +} + +.editContainer { + padding: 0.5rem; + background: var(--mantine-color-gray-0); + border: 1px solid var(--mantine-color-gray-2); + border-radius: var(--mantine-radius-sm); +} + +.editActions { + margin-top: 0.5rem; } .attendeeInfo { display: flex; align-items: center; gap: 0.5rem; - margin-top: 0.75rem; - padding-top: 0.75rem; - border-top: 1px solid var(--mantine-color-gray-2); + margin-top: 0.625rem; + padding-top: 0.625rem; + border-top: 1px solid var(--mantine-color-gray-1); + color: var(--mantine-color-gray-6); } .emptyState { + padding: 1.5rem; + text-align: center; +} + +.questionText { + color: #333; + border-bottom: 1px solid #f0f0f0; + padding-bottom: 4px; +} + +/* New styles for attendee-focused layout */ +.attendeeQuestionsList { display: flex; - align-items: center; - justify-content: center; - padding: 2rem; - background: var(--mantine-color-gray-0); + flex-direction: column; + gap: 0.75rem; +} + +.attendeeSection { + border: 1px solid var(--mantine-color-gray-3); border-radius: var(--mantine-radius-sm); - color: var(--mantine-color-gray-6); + overflow: hidden; + background: var(--mantine-color-gray-0); +} + +.attendeeHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0.75rem; + background-color: var(--mantine-color-gray-1); + border-bottom: 1px solid var(--mantine-color-gray-3); +} + +.attendeeQuestions { + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* In compact mode, adjust spacing in attendee sections */ +.questionCompact:first-child { + padding-top: 0.25rem; +} + +.questionCompact:last-child { + padding-bottom: 0.25rem; } diff --git a/frontend/src/components/common/QuestionAndAnswerList/index.tsx b/frontend/src/components/common/QuestionAndAnswerList/index.tsx index 090f88c1fe..ae482bdb3c 100644 --- a/frontend/src/components/common/QuestionAndAnswerList/index.tsx +++ b/frontend/src/components/common/QuestionAndAnswerList/index.tsx @@ -1,16 +1,331 @@ -import {QuestionAnswer} from "../../../types.ts"; -import {ActionIcon, Group, Text, Tooltip} from '@mantine/core'; +import {IdParam, QuestionAnswer} from "../../../types.ts"; +import {ActionIcon, Button, Group, Text, Tooltip} from '@mantine/core'; import {t} from "@lingui/macro"; -import {NavLink} from "react-router"; -import {IconExternalLink, IconMessageCircle2, IconPackage, IconShoppingCart, IconUser} from "@tabler/icons-react"; +import { + IconEdit, + IconExternalLink, + IconPackage, + IconShoppingCart, + IconUser +} from "@tabler/icons-react"; +import {NavLink, useParams} from "react-router"; import classes from './QuestionAndAnswerList.module.scss'; +import {useEditQuestionAnswer} from "../../../mutations/useEditQuestionAnswer.ts"; +import {QuestionInput} from "../CheckoutQuestion"; +import {useForm} from "@mantine/form"; +import {useState} from "react"; +import {showError, showSuccess} from "../../../utilites/notifications.tsx"; +import {formatAnswer} from "../../../utilites/questionHelper.ts"; +import {useFormErrorResponseHandler} from "../../../hooks/useFormErrorResponseHandler.tsx"; interface QuestionAndAnswerListProps { questionAnswers: QuestionAnswer[]; belongsToFilter?: string[]; + onEditAnswer?: () => void; } -export const QuestionAndAnswerList = ({questionAnswers, belongsToFilter}: QuestionAndAnswerListProps) => { +interface QuestionListProps { + questions: QuestionAnswer[]; + onEditAnswer?: () => void; + compact?: boolean; +} + +interface AttendeeQuestionsListProps { + attendeeQuestions: QuestionAnswer[]; + onEditAnswer?: () => void; + compact?: boolean; +} + +interface QuestionItemProps { + qa: QuestionAnswer; + isEditing: boolean; + toggleEditMode: (id: IdParam) => void; + onEditAnswer?: () => void; + compact?: boolean; + eventId?: string; + hideAttendeeInfo?: boolean; +} + +// Separated QuestionItem component to isolate form initialization +const QuestionItem = ({ qa, isEditing, toggleEditMode, onEditAnswer, compact = false, eventId, hideAttendeeInfo = false }: QuestionItemProps) => { + const errorHandler = useFormErrorResponseHandler(); + const updateAnswerMutation = useEditQuestionAnswer(); + + // Form initialization is now isolated in this component + const initialValues = qa.question_type === 'ADDRESS' ? { + answer: qa.answer, + } : { + answer: { + answer: qa.answer, + }, + }; + + const questionForm = useForm({ + initialValues: initialValues, + transformValues: (values) => { + // Make sure we're handling the transformation consistently + // For ADDRESS type, just pass the answer directly + // For other types, extract the nested answer value + // Also add null/undefined checks + let transformedAnswer; + if (qa.question_type === 'ADDRESS') { + transformedAnswer = values.answer; + } else { + // Handle both possible structures to be safe + transformedAnswer = values.answer && typeof values.answer === 'object' && 'answer' in values.answer + ? values.answer.answer + : values.answer; + } + + return { + answer: transformedAnswer + }; + }, + }); + + const handleSubmit = (values: { answer: any }) => { + // Don't transform the answer - the form's transformValues has already done this + // The values parameter here is already the result of the transformValues function + updateAnswerMutation.mutate({ + questionId: qa.question_id, + answer: values.answer, + answerId: qa.question_answer_id, + eventId: eventId, + }, { + onSuccess: () => { + toggleEditMode(qa.question_id); + showSuccess(t`Answer updated successfully.`); + onEditAnswer?.(); + }, + onError: (error) => { + errorHandler(questionForm, error); + showError(t`Failed to update answer.`); + } + }); + }; + + return ( +
+ {qa.product_title && !compact && ( + + {qa.product_title} + + )} + +
+ + {qa.title} + +
+ + {isEditing ? ( +
+
+ + + + + +
+
+ ) : ( +
+ + {formatAnswer(qa.answer)} + + + toggleEditMode(qa.question_id)} + > + + + +
+ )} + + {qa.attendee_id && !compact && !hideAttendeeInfo && ( +
+ + + {qa.first_name + ? `${qa.first_name} ${qa.last_name}` + : t`N/A`} + + + + + + + + +
+ )} +
+ ); +}; + +export const QuestionList = ({questions, onEditAnswer, compact = false}: QuestionListProps) => { + const {eventId} = useParams(); + const [editingQuestionIds, setEditingQuestionIds] = useState([]); + + const toggleEditMode = (questionId: IdParam) => { + setEditingQuestionIds(prev => + prev.includes(questionId) + ? prev.filter(id => id !== questionId) + : [...prev, questionId] + ); + }; + + const isEditing = (questionId: IdParam) => { + return editingQuestionIds.includes(questionId); + }; + + if (!questions.length) { + return null; + } + + return ( +
+ {questions.map((qa, index) => ( + + ))} +
+ ); +}; + +// New component to group questions by attendee +export const AttendeeQuestionsList = ({attendeeQuestions, onEditAnswer, compact = false}: AttendeeQuestionsListProps) => { + const {eventId} = useParams(); + const [editingQuestionIds, setEditingQuestionIds] = useState([]); + + const toggleEditMode = (questionId: IdParam) => { + setEditingQuestionIds(prev => + prev.includes(questionId) + ? prev.filter(id => id !== questionId) + : [...prev, questionId] + ); + }; + + const isEditing = (questionId: IdParam) => { + return editingQuestionIds.includes(questionId); + }; + + if (!attendeeQuestions.length) { + return null; + } + + // Group questions by attendee + const groupedByAttendee: Record = {}; + + attendeeQuestions.forEach(qa => { + const attendeeKey = qa.attendee_id || 'unknown'; + if (!groupedByAttendee[attendeeKey]) { + groupedByAttendee[attendeeKey] = []; + } + groupedByAttendee[attendeeKey].push(qa); + }); + + return ( +
+ {Object.entries(groupedByAttendee).map(([attendeeId, questions]) => { + const attendeeInfo = questions[0]; // Take first question to get attendee info + + return ( +
+ {/* Attendee header with name and link */} +
+ + + + {attendeeInfo.first_name + ? `${attendeeInfo.first_name} ${attendeeInfo.last_name}` + : t`Unknown Attendee`} + + + {attendeeInfo.attendee_public_id && ( + + + + + + + + )} +
+ + {/* Questions for this attendee */} +
+ {questions.map((qa, index) => ( + + ))} +
+
+ ); + })} +
+ ); +}; + +export const QuestionAndAnswerList = ({questionAnswers, belongsToFilter, onEditAnswer}: QuestionAndAnswerListProps) => { const filteredQuestions = belongsToFilter?.length ? questionAnswers.filter(qa => belongsToFilter.includes(qa.belongs_to)) : questionAnswers; @@ -19,7 +334,7 @@ export const QuestionAndAnswerList = ({questionAnswers, belongsToFilter}: Questi const attendeeQuestions = filteredQuestions.filter(qa => qa.belongs_to === 'PRODUCT' && qa.attendee_id); const orderQuestions = filteredQuestions.filter(qa => qa.belongs_to === 'ORDER'); - const renderSection = (title: string, questions: QuestionAnswer[]) => { + const renderSection = (title: string, questions: QuestionAnswer[], isAttendeeSection = false) => { const getIcon = () => { switch (title) { case 'Attendee Answers': @@ -33,6 +348,10 @@ export const QuestionAndAnswerList = ({questionAnswers, belongsToFilter}: Questi } }; + if (questions.length === 0) { + return null; + } + return (
@@ -45,65 +364,16 @@ export const QuestionAndAnswerList = ({questionAnswers, belongsToFilter}: Questi - {questions.length > 0 ? ( -
- {questions.map((qa, index) => ( -
- {qa.product_title && ( - - {qa.product_title} - - )} - -
- - - {qa.title} - -
- - - {Array.isArray(qa.answer) ? qa.answer.join(", ") : qa.answer} - - - {qa.attendee_id && ( -
- - - {qa.first_name - ? `${qa.first_name} ${qa.last_name}` - : t`N/A`} - - - - - - - - -
- )} -
- ))} -
+ {isAttendeeSection ? ( + ) : ( -
- - {t`No ${title.toLowerCase()} available.`} - -
+ )}
); @@ -111,9 +381,9 @@ export const QuestionAndAnswerList = ({questionAnswers, belongsToFilter}: Questi return (
- {orderQuestions.length > 0 && renderSection('Order Answers', orderQuestions)} - {attendeeQuestions.length > 0 && renderSection('Attendee Answers', attendeeQuestions)} - {productQuestions.length > 0 && renderSection('Product Answers', productQuestions)} + {renderSection('Order Answers', orderQuestions)} + {renderSection('Attendee Answers', attendeeQuestions, true)} + {renderSection('Product Answers', productQuestions)}
); }; diff --git a/frontend/src/components/layouts/EventHomepage/EventHomepage.module.scss b/frontend/src/components/layouts/EventHomepage/EventHomepage.module.scss index d41eb9738b..73d5d28a9d 100644 --- a/frontend/src/components/layouts/EventHomepage/EventHomepage.module.scss +++ b/frontend/src/components/layouts/EventHomepage/EventHomepage.module.scss @@ -64,6 +64,7 @@ @include respond-below(md) { min-height: 100vh; justify-content: space-between; + padding: 20px; } a { diff --git a/frontend/src/components/layouts/EventHomepage/EventInformation/EventInformation.module.scss b/frontend/src/components/layouts/EventHomepage/EventInformation/EventInformation.module.scss index eb3e1519c0..6b6ea5ab8d 100644 --- a/frontend/src/components/layouts/EventHomepage/EventInformation/EventInformation.module.scss +++ b/frontend/src/components/layouts/EventHomepage/EventInformation/EventInformation.module.scss @@ -43,6 +43,7 @@ place-self: flex-start; margin-right: 10px; color: var(--homepage-secondary-color, var(--tk-primary)); + min-width: 20px; } } diff --git a/frontend/src/components/layouts/EventHomepage/EventInformation/index.tsx b/frontend/src/components/layouts/EventHomepage/EventInformation/index.tsx index 0d635eb0cd..ef21671e12 100644 --- a/frontend/src/components/layouts/EventHomepage/EventInformation/index.tsx +++ b/frontend/src/components/layouts/EventHomepage/EventInformation/index.tsx @@ -1,6 +1,5 @@ import {IconCalendar, IconExternalLink, IconMapPin} from "@tabler/icons-react"; import classes from "./EventInformation.module.scss"; -import {prettyDate} from "../../../../utilites/dates.ts"; import {formatAddress} from "../../../../utilites/formatAddress.tsx"; import {t} from "@lingui/macro"; import {Button} from "@mantine/core"; @@ -22,8 +21,8 @@ export const EventInformation: FC<{ return ( <>
-
- {prettyDate(event.start_date, event.timezone)} +
+ {event.organizer?.name}
{event.title}
-

{t`Date & Time`}

@@ -48,9 +46,8 @@ export const EventInformation: FC<{ {event.settings?.location_details && (
-

{t`Location`}

- +
{event.settings?.location_details?.venue_name}
{formatAddress(event.settings?.location_details)}
diff --git a/frontend/src/components/modals/ManageAttendeeModal/index.tsx b/frontend/src/components/modals/ManageAttendeeModal/index.tsx index fa1aa0a321..6685cfd909 100644 --- a/frontend/src/components/modals/ManageAttendeeModal/index.tsx +++ b/frontend/src/components/modals/ManageAttendeeModal/index.tsx @@ -12,7 +12,7 @@ import {IconEdit, IconNotebook, IconQuestionMark, IconReceipt, IconTicket, IconU import {LoadingMask} from "../../common/LoadingMask"; import {AttendeeDetails} from "../../common/AttendeeDetails"; import {OrderDetails} from "../../common/OrderDetails"; -import {QuestionAndAnswerList} from "../../common/QuestionAndAnswerList"; +import {QuestionAndAnswerList, QuestionList} from "../../common/QuestionAndAnswerList"; import {AttendeeTicket} from "../../common/AttendeeTicket"; import {getInitials} from "../../../utilites/helpers.ts"; import {t} from "@lingui/macro"; @@ -34,7 +34,7 @@ interface ManageAttendeeModalProps extends GenericModalProps { export const ManageAttendeeModal = ({onClose, attendeeId}: ManageAttendeeModalProps) => { const {eventId} = useParams(); - const {data: attendee} = useGetAttendee(eventId, attendeeId); + const {data: attendee, refetch: refetchAttendee} = useGetAttendee(eventId, attendeeId); const {data: order} = useGetOrder(eventId, attendee?.order_id); const {data: event} = useGetEvent(eventId); const errorHandler = useFormErrorResponseHandler(); @@ -184,7 +184,10 @@ export const ManageAttendeeModal = ({onClose, attendeeId}: ManageAttendeeModalPr title: t`Questions & Answers`, count: hasQuestions ? attendee?.question_answers?.length : undefined, content: hasQuestions ? ( - + ) : ( {t`No questions answered by this attendee.`} diff --git a/frontend/src/components/modals/ManageOrderModal/index.tsx b/frontend/src/components/modals/ManageOrderModal/index.tsx index c7131c5e92..0dc0573517 100644 --- a/frontend/src/components/modals/ManageOrderModal/index.tsx +++ b/frontend/src/components/modals/ManageOrderModal/index.tsx @@ -3,7 +3,6 @@ import {useParams} from "react-router"; import {useGetEvent} from "../../../queries/useGetEvent.ts"; import {useGetOrder} from "../../../queries/useGetOrder.ts"; import {OrderSummary} from "../../common/OrderSummary"; -import {Modal} from "../../common/Modal"; import {AttendeeList} from "../../common/AttendeeList"; import {OrderDetails} from "../../common/OrderDetails"; import {t} from "@lingui/macro"; @@ -30,7 +29,7 @@ interface ManageOrderModalProps { export const ManageOrderModal = ({onClose, orderId}: GenericModalProps & ManageOrderModalProps) => { const {eventId} = useParams(); - const {data: order} = useGetOrder(eventId, orderId); + const {data: order, refetch: refetchOrder} = useGetOrder(eventId, orderId); const {data: event, data: {product_categories: productCategories} = {}} = useGetEvent(eventId); const products = productCategories?.flatMap(category => category.products); const orderHasQuestions = order?.question_answers && order.question_answers.length > 0; @@ -112,7 +111,9 @@ export const ManageOrderModal = ({onClose, orderId}: GenericModalProps & ManageO title: t`Questions & Answers`, count: orderHasQuestions ? order.question_answers.length : undefined, content: orderHasQuestions ? ( - + ) : ( {t`No questions have been asked for this order.`} @@ -125,7 +126,7 @@ export const ManageOrderModal = ({onClose, orderId}: GenericModalProps & ManageO title: t`Attendees`, count: orderHasAttendees ? order.attendees.length : undefined, content: orderHasAttendees ? ( - + ) : ( {t`No attendees have been added to this order.`} diff --git a/frontend/src/components/routes/product-widget/CollectInformation/index.tsx b/frontend/src/components/routes/product-widget/CollectInformation/index.tsx index 1ae14f5444..9d85bcaf16 100644 --- a/frontend/src/components/routes/product-widget/CollectInformation/index.tsx +++ b/frontend/src/components/routes/product-widget/CollectInformation/index.tsx @@ -8,7 +8,7 @@ import {useGetOrderPublic} from "../../../../queries/useGetOrderPublic.ts"; import {useGetEventPublic} from "../../../../queries/useGetEventPublic.ts"; import {useGetEventQuestionsPublic} from "../../../../queries/useGetEventQuestionsPublic.ts"; import {CheckoutOrderQuestions, CheckoutProductQuestions} from "../../../common/CheckoutQuestion"; -import {Event, Order, Question} from "../../../../types.ts"; +import {Event, IdParam, Order, Question} from "../../../../types.ts"; import {useEffect} from "react"; import {t} from "@lingui/macro"; import {InputGroup} from "../../../common/InputGroup"; @@ -80,20 +80,33 @@ export const CollectInformation = () => { }); const copyDetailsToAllAttendees = () => { - const updatedProducts = form.values.products.map((product) => { - return { - ...product, - first_name: form.values.order.first_name, - last_name: form.values.order.last_name, - email: form.values.order.email, - }; + if (!products) { + return; + } + + const attendeeProductIds = new Set( + products + .filter(product => product && product.product_type === 'TICKET') + .map(product => product.id) + ); + + const updatedProducts = form.values.products.map(product => { + if (attendeeProductIds.has(product.product_id)) { + return { + ...product, + first_name: form.values.order.first_name, + last_name: form.values.order.last_name, + email: form.values.order.email, + }; + } + return product; }); form.setValues({ ...form.values, products: updatedProducts, }); - } + }; const mutation = useMutation({ mutationFn: (orderData: FinaliseOrderPayload) => orderClientPublic.finaliseOrder(Number(eventId), String(orderShortId), orderData), diff --git a/frontend/src/components/routes/product-widget/SelectProducts/index.tsx b/frontend/src/components/routes/product-widget/SelectProducts/index.tsx index c57d39a8b9..c22a2c60c4 100644 --- a/frontend/src/components/routes/product-widget/SelectProducts/index.tsx +++ b/frontend/src/components/routes/product-widget/SelectProducts/index.tsx @@ -451,24 +451,34 @@ const SelectProducts = (props: SelectProductsProps) => {
)} + + {(showPromoCodeInput && !form.values.promo_code) && ( + + {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */} + {/*@ts-ignore*/} + { + if (event.key === 'Enter') { + event.preventDefault(); + handleApplyPromoCode(); + } + }} mb={0} ref={promoRef}/> + + setShowPromoCodeInput(false)} + > + + + + )}
- {(showPromoCodeInput && !form.values.promo_code) && ( - - {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */} - {/*@ts-ignore*/} - { - if (event.key === 'Enter') { - event.preventDefault(); - handleApplyPromoCode(); - } - }} mb={0} ref={promoRef}/> - - - )} { /** * (c) Hi.Events Ltd 2025 diff --git a/frontend/src/mutations/useEditQuestionAnswer.ts b/frontend/src/mutations/useEditQuestionAnswer.ts new file mode 100644 index 0000000000..3ac331ed43 --- /dev/null +++ b/frontend/src/mutations/useEditQuestionAnswer.ts @@ -0,0 +1,17 @@ +import {useMutation} from "@tanstack/react-query"; +import {IdParam} from '../types'; +import {questionClient} from "../api/question.client.ts"; + +export const useEditQuestionAnswer = () => { + return useMutation({ + mutationFn: ({eventId, questionId, answerId, answer}: { + eventId: IdParam, + questionId: IdParam, + answerId: IdParam, + answer: string | string[] + }) => questionClient.updateAnswerQuestion(eventId, questionId, answerId, answer), + onSuccess: (_, variables) => { + return; + } + }); +} diff --git a/frontend/src/styles/widget/default.scss b/frontend/src/styles/widget/default.scss index 14ff30ddfc..bf8d1c129a 100644 --- a/frontend/src/styles/widget/default.scss +++ b/frontend/src/styles/widget/default.scss @@ -177,6 +177,12 @@ border: none; } + .hi-close-promo-code-input-button { + background-color: var(--widget-secondary-color, var(--tk-primary)); + color: var(--widget-secondary-text-color, var(--tk-color-white)); + border: none; + } + .hi-promo-code-applied { display: flex; align-items: center; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 83582e8a3d..f6633234ad 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -713,8 +713,13 @@ export interface QuestionAnswer { belongs_to: string; question_type: string; attendee_id?: number; + attendee_public_id?: IdParam; first_name?: string; last_name?: string; + question_answer_id?: IdParam; + question_description?: string; + question_required?: boolean; + question_options?: string[]; } export enum ReportTypes { diff --git a/frontend/src/utilites/formatAddress.tsx b/frontend/src/utilites/formatAddress.tsx index 48ea354cc5..12527c068e 100644 --- a/frontend/src/utilites/formatAddress.tsx +++ b/frontend/src/utilites/formatAddress.tsx @@ -11,4 +11,5 @@ export const formatAddress = (address: VenueAddress) => { ]; return addressLines.filter((line) => line).join(', '); -} \ No newline at end of file +} + diff --git a/frontend/src/utilites/questionHelper.ts b/frontend/src/utilites/questionHelper.ts new file mode 100644 index 0000000000..cce02492b9 --- /dev/null +++ b/frontend/src/utilites/questionHelper.ts @@ -0,0 +1,32 @@ +import {formatAddress} from "./formatAddress.tsx"; + +export const isAddress = (obj: any) => { + if (!obj || typeof obj !== 'object') return false; + + const addressFields = [ + 'address_line_1', + 'address_line_2', + 'city', + 'state_or_region', + 'zip_or_postal_code', + 'country' + ]; + + return addressFields.some(field => field in obj); +}; + +export const formatAnswer = (answer: any) => { + if (answer === null || answer === undefined) return ''; + + if (Array.isArray(answer)) { + return answer.join(", "); + } else if (typeof answer === 'object') { + if (isAddress(answer)) { + return formatAddress(answer); + } else { + return JSON.stringify(answer); + } + } else { + return answer.toString(); + } +};