diff --git a/.claude/settings.json b/.claude/settings.json index eb05d60..ee24782 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,7 +1,11 @@ { - "permissions": { - "defaultMode": "acceptEdits", - "allow": [], - "deny": [] - } + "permissions": { + "defaultMode": "acceptEdits", + "allow": [], + "deny": [] + }, + "enabledMcpjsonServers": [ + "playwright" + ], + "enableAllProjectMcpServers": true } diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..37cce59 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "playwright": { + "type": "stdio", + "command": "npx", + "args": [ + "@playwright/mcp@latest" + ], + "env": {} + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index 0b04dd9..f6f087a 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,86 @@ $bundles = [ Make sure you add it before `SyliusGridBundle`, otherwise you'll get `You have requested a non-existent parameter "setono_sylius_review.model.review_request.class".` exception. +### Extend the ProductReview entity + +This plugin extends Sylius's ProductReview entity with additional fields. You need to create your own ProductReview entity that implements the plugin's interface and uses its trait. + +Create `src/Entity/ProductReview.php`: + +```php +status = self::STATUS_PENDING; + } +} +``` + +### Extend the ProductReview repository + +You also need to create a custom repository that implements the plugin's interface: + +Create `src/Repository/ProductReviewRepository.php`: + +```php +=8.1", + "doctrine/collections": "^1.8", + "doctrine/doctrine-bundle": "^2.0", "doctrine/orm": "^2.0", "doctrine/persistence": "^2.0 || ^3.0", "ocramius/doctrine-batch-utils": "^2.4", @@ -21,11 +23,16 @@ "sylius/mailer-bundle": "^1.8 || ^2.0", "sylius/order": "^1.0", "sylius/resource-bundle": "^1.6", + "sylius/review": "^1.0", "symfony/config": "^5.4 || ^6.4 || ^7.0", "symfony/console": "^5.4 || ^6.4 || ^7.0", "symfony/dependency-injection": "^5.4 || ^6.4 || ^7.0", "symfony/event-dispatcher": "^5.4 || ^6.4 || ^7.0", "symfony/form": "^5.4 || ^6.4 || ^7.0", + "symfony/framework-bundle": "^5.4 || ^6.4 || ^7.0", + "symfony/http-foundation": "^5.4 || ^6.4 || ^7.0", + "symfony/options-resolver": "^5.4 || ^6.4 || ^7.0", + "symfony/validator": "^5.4 || ^6.4 || ^7.0", "symfony/workflow": "^5.4 || ^6.4 || ^7.0", "webmozart/assert": "^1.11" }, diff --git a/phpstan.neon b/phpstan.neon index e480f2b..d87750e 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -31,3 +31,9 @@ parameters: - identifier: missingType.generics path: src/Form + - + identifier: trait.unused + path: src/Model + - + identifier: trait.unused + path: src/Repository diff --git a/src/Checker/ReviewAutoApprovalChecker.php b/src/Checker/ReviewAutoApprovalChecker.php new file mode 100644 index 0000000..47e1272 --- /dev/null +++ b/src/Checker/ReviewAutoApprovalChecker.php @@ -0,0 +1,30 @@ +getRating(); + + return null !== $rating && $rating >= $this->minimumRatingForAutoApproval; + } + + public function shouldAutoApproveProductReview(ReviewInterface $review): bool + { + $rating = $review->getRating(); + + return null !== $rating && $rating >= $this->minimumRatingForAutoApproval; + } +} diff --git a/src/Checker/ReviewAutoApprovalCheckerInterface.php b/src/Checker/ReviewAutoApprovalCheckerInterface.php new file mode 100644 index 0000000..2a269d6 --- /dev/null +++ b/src/Checker/ReviewAutoApprovalCheckerInterface.php @@ -0,0 +1,21 @@ + + */ +final class CompositeReviewableOrderChecker extends CompositeService implements ReviewableOrderCheckerInterface +{ + public function check(OrderInterface $order): ReviewableOrderCheck + { + foreach ($this->services as $service) { + $check = $service->check($order); + if (!$check->reviewable) { + return $check; + } + } + + return ReviewableOrderCheck::reviewable(); + } +} diff --git a/src/Checker/ReviewableOrder/OrderFulfilledReviewableOrderChecker.php b/src/Checker/ReviewableOrder/OrderFulfilledReviewableOrderChecker.php new file mode 100644 index 0000000..b69a42d --- /dev/null +++ b/src/Checker/ReviewableOrder/OrderFulfilledReviewableOrderChecker.php @@ -0,0 +1,19 @@ +getState() === OrderInterface::STATE_FULFILLED) { + return ReviewableOrderCheck::reviewable(); + } + + return ReviewableOrderCheck::notReviewable('setono_sylius_review.ui.order_not_fulfilled'); + } +} diff --git a/src/Checker/ReviewableOrder/ReviewableOrderCheck.php b/src/Checker/ReviewableOrder/ReviewableOrderCheck.php new file mode 100644 index 0000000..3c0887c --- /dev/null +++ b/src/Checker/ReviewableOrder/ReviewableOrderCheck.php @@ -0,0 +1,24 @@ +storeReviewRepository->findOneByOrder($order); + + if (null === $existingReview) { + return ReviewableOrderCheck::reviewable(); + } + + $createdAt = $existingReview->getCreatedAt(); + if (null === $createdAt) { + return ReviewableOrderCheck::reviewable(); + } + + $editableUntil = \DateTimeImmutable::createFromInterface($createdAt)->modify($this->editablePeriod); + + if (new \DateTimeImmutable() < $editableUntil) { + return ReviewableOrderCheck::reviewable(); + } + + return ReviewableOrderCheck::notReviewable('setono_sylius_review.ui.review_period_expired'); + } +} diff --git a/src/Controller/ReviewController.php b/src/Controller/ReviewController.php new file mode 100644 index 0000000..4ad6650 --- /dev/null +++ b/src/Controller/ReviewController.php @@ -0,0 +1,97 @@ + $orderRepository + */ + public function __construct( + private readonly OrderRepositoryInterface $orderRepository, + ManagerRegistry $managerRegistry, + private readonly ReviewableOrderCheckerInterface $reviewableOrderChecker, + private readonly ReviewRepositoryInterface $reviewRepository, + private readonly ReviewFactoryInterface $reviewFactory, + ) { + $this->managerRegistry = $managerRegistry; + } + + public function __invoke(Request $request): Response + { + $token = $request->query->get('token'); + if (!is_string($token) || '' === $token) { + throw $this->createNotFoundException('Token is required.'); + } + + $order = $this->orderRepository->findOneByTokenValue($token); + if (!$order instanceof OrderInterface) { + throw $this->createNotFoundException('Order not found.'); + } + + $reviewableCheck = $this->reviewableOrderChecker->check($order); + + // If not reviewable, render template with error message + if (!$reviewableCheck->reviewable) { + return $this->render('@SetonoSyliusReviewPlugin/shop/review/index.html.twig', [ + 'order' => $order, + 'reviewableCheck' => $reviewableCheck, + ]); + } + + // Get existing Review entity or create new one + $review = $this->reviewRepository->findOneByOrder($order) ?? $this->reviewFactory->createFromOrder($order); + + $form = $this->createForm(ReviewType::class, $review, [ + 'order' => $order, + ]); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + // Remove store review if it has no rating (don't persist empty reviews) + $storeReview = $review->getStoreReview(); + if (null === $storeReview?->getRating()) { + $review->setStoreReview(null); + } + + // Remove product reviews without rating (don't persist empty reviews) + foreach ($review->getProductReviews() as $productReview) { + if (null === $productReview->getRating()) { + $review->removeProductReview($productReview); + } + } + + // Persist the Review entity (cascade-persist will handle children) + $manager = $this->getManager($review); + $manager->persist($review); + $manager->flush(); + + $this->addFlash('success', 'setono_sylius_review.review.submitted_successfully'); + + return $this->redirectToRoute('setono_sylius_review__review', ['token' => $token]); + } + + return $this->render('@SetonoSyliusReviewPlugin/shop/review/index.html.twig', [ + 'order' => $order, + 'reviewableCheck' => $reviewableCheck, + 'form' => $form->createView(), + ]); + } +} diff --git a/src/DependencyInjection/Compiler/OverrideProductReviewWorkflowPass.php b/src/DependencyInjection/Compiler/OverrideProductReviewWorkflowPass.php new file mode 100644 index 0000000..5103553 --- /dev/null +++ b/src/DependencyInjection/Compiler/OverrideProductReviewWorkflowPass.php @@ -0,0 +1,74 @@ +hasDefinition($definitionServiceId)) { + return; + } + + $definition = $container->getDefinition($definitionServiceId); + $arguments = $definition->getArguments(); + + // Get the places (first argument) and add 'pending' state + /** @var list $places */ + $places = $arguments[0] ?? []; + if (!in_array(ProductReviewWorkflow::STATE_PENDING, $places, true)) { + array_unshift($places, ProductReviewWorkflow::STATE_PENDING); + $definition->setArgument(0, $places); + } + + // Get the transitions (second argument) - these are References to transition service definitions + /** @var list $transitionRefs */ + $transitionRefs = $arguments[1] ?? []; + + // Check if we already have the submit transition by looking at the service IDs + $submitTransitionServiceId = sprintf('state_machine.%s.transition.%s', $workflowName, ProductReviewWorkflow::TRANSITION_SUBMIT); + $hasSubmitTransition = false; + foreach ($transitionRefs as $ref) { + if ((string) $ref === $submitTransitionServiceId) { + $hasSubmitTransition = true; + + break; + } + } + + if (!$hasSubmitTransition) { + // Create a new transition service definition + $transitionDefinition = new Definition(Transition::class, [ + ProductReviewWorkflow::TRANSITION_SUBMIT, + [ProductReviewWorkflow::STATE_PENDING], + [ReviewInterface::STATUS_NEW], + ]); + $container->setDefinition($submitTransitionServiceId, $transitionDefinition); + + // Add the reference to the transitions array + array_unshift($transitionRefs, new Reference($submitTransitionServiceId)); + $definition->setArgument(1, $transitionRefs); + } + + // Update the initial marking (third argument) + $definition->setArgument(2, [ProductReviewWorkflow::STATE_PENDING]); + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 7067117..1d72f8f 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -4,8 +4,12 @@ namespace Setono\SyliusReviewPlugin\DependencyInjection; +use Setono\SyliusReviewPlugin\Model\Review; use Setono\SyliusReviewPlugin\Model\ReviewRequest; +use Setono\SyliusReviewPlugin\Model\StoreReview; +use Setono\SyliusReviewPlugin\Repository\ReviewRepository; use Setono\SyliusReviewPlugin\Repository\ReviewRequestRepository; +use Setono\SyliusReviewPlugin\Repository\StoreReviewRepository; use Sylius\Component\Resource\Factory\Factory; use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\TreeBuilder; @@ -65,6 +69,34 @@ private function addResourcesSection(ArrayNodeDefinition $node): void ->scalarNode('model')->defaultValue(ReviewRequest::class)->cannotBeEmpty()->end() ->scalarNode('repository')->defaultValue(ReviewRequestRepository::class)->cannotBeEmpty()->end() ->scalarNode('factory')->defaultValue(Factory::class)->end() + ->end() + ->end() + ->end() + ->end() + ->arrayNode('store_review') + ->addDefaultsIfNotSet() + ->children() + ->variableNode('options')->end() + ->arrayNode('classes') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('model')->defaultValue(StoreReview::class)->cannotBeEmpty()->end() + ->scalarNode('repository')->defaultValue(StoreReviewRepository::class)->cannotBeEmpty()->end() + ->scalarNode('factory')->defaultValue(Factory::class)->end() + ->end() + ->end() + ->end() + ->end() + ->arrayNode('review') + ->addDefaultsIfNotSet() + ->children() + ->variableNode('options')->end() + ->arrayNode('classes') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('model')->defaultValue(Review::class)->cannotBeEmpty()->end() + ->scalarNode('repository')->defaultValue(ReviewRepository::class)->cannotBeEmpty()->end() + ->scalarNode('factory')->defaultValue(Factory::class)->end() ; } } diff --git a/src/DependencyInjection/SetonoSyliusReviewExtension.php b/src/DependencyInjection/SetonoSyliusReviewExtension.php index 836b15a..11ea91c 100644 --- a/src/DependencyInjection/SetonoSyliusReviewExtension.php +++ b/src/DependencyInjection/SetonoSyliusReviewExtension.php @@ -4,10 +4,12 @@ namespace Setono\SyliusReviewPlugin\DependencyInjection; +use Setono\SyliusReviewPlugin\Checker\ReviewableOrder\ReviewableOrderCheckerInterface; use Setono\SyliusReviewPlugin\EligibilityChecker\ReviewRequestEligibilityCheckerInterface; use Setono\SyliusReviewPlugin\Form\Type\ReviewRequestEmailType; use Setono\SyliusReviewPlugin\Mailer\Emails; use Setono\SyliusReviewPlugin\Workflow\ReviewRequestWorkflow; +use Setono\SyliusReviewPlugin\Workflow\StoreReviewWorkflow; use Sylius\Bundle\ResourceBundle\DependencyInjection\Extension\AbstractResourceExtension; use Sylius\Bundle\ResourceBundle\SyliusResourceBundle; use Symfony\Component\Config\FileLocator; @@ -40,6 +42,11 @@ public function load(array $configs, ContainerBuilder $container): void ->addTag('setono_sylius_review.review_request_eligibility_checker') ; + $container + ->registerForAutoconfiguration(ReviewableOrderCheckerInterface::class) + ->addTag('setono_sylius_review.reviewable_order_checker') + ; + self::registerEmailFormType($container); $loader->load('services.xml'); @@ -55,7 +62,10 @@ public function load(array $configs, ContainerBuilder $container): void public function prepend(ContainerBuilder $container): void { $container->prependExtensionConfig('framework', [ - 'workflows' => ReviewRequestWorkflow::getConfig(), + 'workflows' => array_merge( + ReviewRequestWorkflow::getConfig(), + StoreReviewWorkflow::getConfig(), + ), ]); $container->prependExtensionConfig('sylius_mailer', [ diff --git a/src/EventSubscriber/Doctrine/ReviewPersistSubscriber.php b/src/EventSubscriber/Doctrine/ReviewPersistSubscriber.php new file mode 100644 index 0000000..b434e70 --- /dev/null +++ b/src/EventSubscriber/Doctrine/ReviewPersistSubscriber.php @@ -0,0 +1,43 @@ +getObject(); + + if ($entity instanceof StoreReviewInterface) { + $this->transitionToNew($this->storeReviewStateMachine, $entity, StoreReviewWorkflow::TRANSITION_SUBMIT); + } + + if ($entity instanceof ProductReviewInterface) { + $this->transitionToNew($this->productReviewStateMachine, $entity, ProductReviewWorkflow::TRANSITION_SUBMIT); + } + } + + private function transitionToNew(WorkflowInterface $workflow, object $entity, string $transition): void + { + if ($workflow->can($entity, $transition)) { + $workflow->apply($entity, $transition); + } + } +} diff --git a/src/EventSubscriber/Workflow/ReviewAutoApprovalSubscriber.php b/src/EventSubscriber/Workflow/ReviewAutoApprovalSubscriber.php new file mode 100644 index 0000000..0245c2a --- /dev/null +++ b/src/EventSubscriber/Workflow/ReviewAutoApprovalSubscriber.php @@ -0,0 +1,52 @@ + 'onStoreReviewEnteredNew', + 'workflow.' . ProductReviewWorkflow::NAME . '.entered.' . ReviewInterface::STATUS_NEW => 'onProductReviewEnteredNew', + ]; + } + + public function onStoreReviewEnteredNew(EnteredEvent $event): void + { + /** @var StoreReviewInterface $review */ + $review = $event->getSubject(); + + if ($this->autoApprovalChecker->shouldAutoApprove($review)) { + $this->storeReviewStateMachine->apply($review, StoreReviewWorkflow::TRANSITION_ACCEPT); + } + } + + public function onProductReviewEnteredNew(EnteredEvent $event): void + { + /** @var ReviewInterface $review */ + $review = $event->getSubject(); + + if ($this->autoApprovalChecker->shouldAutoApproveProductReview($review)) { + $this->productReviewStateMachine->apply($review, ProductReviewWorkflow::TRANSITION_ACCEPT); + } + } +} diff --git a/src/Factory/ReviewFactory.php b/src/Factory/ReviewFactory.php new file mode 100644 index 0000000..8fe9d79 --- /dev/null +++ b/src/Factory/ReviewFactory.php @@ -0,0 +1,36 @@ + $decorated + */ + public function __construct(private readonly FactoryInterface $decorated) + { + } + + public function createNew(): ReviewInterface + { + $obj = $this->decorated->createNew(); + Assert::isInstanceOf($obj, ReviewInterface::class); + + return $obj; + } + + public function createFromOrder(OrderInterface $order): ReviewInterface + { + $obj = $this->createNew(); + $obj->setOrder($order); + + return $obj; + } +} diff --git a/src/Factory/ReviewFactoryInterface.php b/src/Factory/ReviewFactoryInterface.php new file mode 100644 index 0000000..f983174 --- /dev/null +++ b/src/Factory/ReviewFactoryInterface.php @@ -0,0 +1,17 @@ + + */ +interface ReviewFactoryInterface extends FactoryInterface +{ + public function createFromOrder(OrderInterface $order): ReviewInterface; +} diff --git a/src/Form/Type/ProductReviewType.php b/src/Form/Type/ProductReviewType.php new file mode 100644 index 0000000..c024162 --- /dev/null +++ b/src/Form/Type/ProductReviewType.php @@ -0,0 +1,81 @@ +add('rating', ChoiceType::class, [ + 'choices' => [ + '1' => 1, + '2' => 2, + '3' => 3, + '4' => 4, + '5' => 5, + ], + 'label' => 'sylius.form.review.rating', + 'expanded' => true, + 'multiple' => false, + 'required' => false, + 'placeholder' => false, + ]) + ->add('title', TextType::class, [ + 'label' => 'sylius.form.review.title', + 'required' => false, + ]) + ->add('comment', TextareaType::class, [ + 'label' => 'sylius.form.review.comment', + 'required' => false, + ]) + ; + + $builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) use ($options): void { + $data = $event->getData(); + + if (!$data instanceof ProductReviewInterface) { + return; + } + + /** @var OrderInterface|null $order */ + $order = $options['order'] ?? null; + if (null === $order) { + return; + } + + // Only set order if this is a new review (no ID yet) + if (null !== $data->getId()) { + return; + } + + $data->setOrder($order); + }); + } + + public function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + + $resolver->setDefault('order', null); + $resolver->setAllowedTypes('order', ['null', OrderInterface::class]); + } + + public function getBlockPrefix(): string + { + return 'setono_sylius_review_product_review'; + } +} diff --git a/src/Form/Type/ReviewType.php b/src/Form/Type/ReviewType.php new file mode 100644 index 0000000..febddc8 --- /dev/null +++ b/src/Form/Type/ReviewType.php @@ -0,0 +1,140 @@ + + */ +final class ReviewType extends AbstractType +{ + /** + * @param FactoryInterface $storeReviewFactory + * @param ReviewFactoryInterface $productReviewFactory + */ + public function __construct( + private readonly FactoryInterface $storeReviewFactory, + private readonly ReviewFactoryInterface $productReviewFactory, + private readonly StoreReviewRepositoryInterface $storeReviewRepository, + private readonly ProductReviewRepositoryInterface $productReviewRepository, + ) { + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + /** @var OrderInterface $order */ + $order = $options['order']; + + /** @var ReviewInterface $review */ + $review = $builder->getData(); + + // Get existing store review from the Review entity, or from repository, or create new one + $storeReview = $review->getStoreReview() + ?? $this->storeReviewRepository->findOneByOrder($order) + ?? $this->storeReviewFactory->createNew(); + + $builder->add('storeReview', StoreReviewType::class, [ + 'required' => false, + 'data' => $storeReview, + 'order' => $order, + ]); + + // Build product reviews from the Review entity or create new ones + $productReviews = $this->buildProductReviews($order, $review); + + $builder->add('productReviews', CollectionType::class, [ + 'entry_type' => ProductReviewType::class, + 'entry_options' => [ + 'label' => false, + 'order' => $order, + ], + 'allow_add' => false, + 'allow_delete' => false, + 'label' => false, + 'data' => $productReviews, + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => ReviewInterface::class, + 'validation_groups' => ['setono_sylius_review'], + ]); + $resolver->setRequired(['order']); + $resolver->setAllowedTypes('order', OrderInterface::class); + } + + public function getBlockPrefix(): string + { + return 'setono_sylius_review'; + } + + /** + * @return list + */ + private function buildProductReviews(OrderInterface $order, ReviewInterface $review): array + { + $reviews = []; + $customer = $order->getCustomer(); + + // First check existing reviews from the Review entity + /** @var array $existingByProduct */ + $existingByProduct = []; + + foreach ($review->getProductReviews() as $productReview) { + $subject = $productReview->getReviewSubject(); + if ($subject instanceof ProductInterface) { + $productId = $subject->getId(); + if (null !== $productId) { + $existingByProduct[$productId] = $productReview; + } + } + } + + // Then check repository for existing reviews by order + $existingReviews = $this->productReviewRepository->findByOrder($order); + foreach ($existingReviews as $productReview) { + $subject = $productReview->getReviewSubject(); + if ($subject instanceof ProductInterface) { + $productId = $subject->getId(); + if (null !== $productId && !isset($existingByProduct[$productId])) { + $existingByProduct[$productId] = $productReview; + } + } + } + + foreach ($order->getItems() as $item) { + $product = $item->getProduct(); + if (null !== $product) { + $productId = $product->getId(); + if (null !== $productId && isset($existingByProduct[$productId])) { + // Use existing review + $reviews[] = $existingByProduct[$productId]; + } else { + // Create new review + /** @var ProductReviewInterface $productReview */ + $productReview = $this->productReviewFactory->createForSubjectWithReviewer($product, $customer); // @phpstan-ignore argument.type + $reviews[] = $productReview; + } + } + } + + return $reviews; + } +} diff --git a/src/Form/Type/StoreReviewType.php b/src/Form/Type/StoreReviewType.php new file mode 100644 index 0000000..a390fdc --- /dev/null +++ b/src/Form/Type/StoreReviewType.php @@ -0,0 +1,90 @@ +add('rating', ChoiceType::class, [ + 'choices' => [ + '1' => 1, + '2' => 2, + '3' => 3, + '4' => 4, + '5' => 5, + ], + 'label' => 'setono_sylius_review.form.store_review.rating', + 'expanded' => true, + 'multiple' => false, + 'required' => false, + 'placeholder' => false, + ]) + ->add('comment', TextareaType::class, [ + 'label' => 'setono_sylius_review.form.store_review.comment', + 'required' => false, + ]) + ; + + $builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) use ($options): void { + $data = $event->getData(); + + if (!$data instanceof StoreReviewInterface) { + return; + } + + /** @var OrderInterface|null $order */ + $order = $options['order'] ?? null; + if (null === $order) { + return; + } + + // Only populate if this is a new review (no ID yet) + if (null !== $data->getId()) { + return; + } + + $data->setOrder($order); + + $customer = $order->getCustomer(); + if ($customer instanceof CustomerInterface) { + $data->setAuthorEmail($customer->getEmail()); + $data->setAuthorFirstName($customer->getFirstName()); + $data->setAuthorLastName($customer->getLastName()); + + $billingAddress = $order->getBillingAddress(); + if (null !== $billingAddress) { + $data->setAuthorCity($billingAddress->getCity()); + $data->setAuthorCountry($billingAddress->getCountryCode()); + } + } + }); + } + + public function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + + $resolver->setDefault('order', null); + $resolver->setAllowedTypes('order', ['null', OrderInterface::class]); + } + + public function getBlockPrefix(): string + { + return 'setono_sylius_review_store_review'; + } +} diff --git a/src/Model/ProductReviewInterface.php b/src/Model/ProductReviewInterface.php new file mode 100644 index 0000000..7efff19 --- /dev/null +++ b/src/Model/ProductReviewInterface.php @@ -0,0 +1,21 @@ +order; + } + + public function setOrder(?OrderInterface $order): void + { + $this->order = $order; + } + + public function getReview(): ?ReviewInterface + { + return $this->review; + } + + public function setReview(?ReviewInterface $review): void + { + $this->review = $review; + } +} diff --git a/src/Model/Review.php b/src/Model/Review.php new file mode 100644 index 0000000..c4a92bf --- /dev/null +++ b/src/Model/Review.php @@ -0,0 +1,88 @@ + */ + protected Collection $productReviews; + + public function __construct() + { + $this->productReviews = new ArrayCollection(); + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getOrder(): ?OrderInterface + { + return $this->order; + } + + public function setOrder(?OrderInterface $order): void + { + $this->order = $order; + } + + public function getStoreReview(): ?StoreReviewInterface + { + return $this->storeReview; + } + + public function setStoreReview(?StoreReviewInterface $storeReview): void + { + $this->storeReview = $storeReview; + + if (null !== $storeReview) { + $storeReview->setReview($this); + } + } + + /** + * @return Collection + */ + public function getProductReviews(): Collection + { + return $this->productReviews; + } + + public function addProductReview(ProductReviewInterface $productReview): void + { + if (!$this->hasProductReview($productReview)) { + $this->productReviews->add($productReview); + $productReview->setReview($this); + } + } + + public function removeProductReview(ProductReviewInterface $productReview): void + { + if ($this->hasProductReview($productReview)) { + $this->productReviews->removeElement($productReview); + $productReview->setReview(null); + } + } + + public function hasProductReview(ProductReviewInterface $productReview): bool + { + return $this->productReviews->contains($productReview); + } +} diff --git a/src/Model/ReviewInterface.php b/src/Model/ReviewInterface.php new file mode 100644 index 0000000..45e2eba --- /dev/null +++ b/src/Model/ReviewInterface.php @@ -0,0 +1,34 @@ + + */ + public function getProductReviews(): Collection; + + public function addProductReview(ProductReviewInterface $productReview): void; + + public function removeProductReview(ProductReviewInterface $productReview): void; + + public function hasProductReview(ProductReviewInterface $productReview): bool; +} diff --git a/src/Model/StoreReview.php b/src/Model/StoreReview.php new file mode 100644 index 0000000..cf13396 --- /dev/null +++ b/src/Model/StoreReview.php @@ -0,0 +1,145 @@ +createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getRating(): ?int + { + return $this->rating; + } + + public function setRating(?int $rating): void + { + $this->rating = $rating; + } + + public function getComment(): ?string + { + return $this->comment; + } + + public function setComment(?string $comment): void + { + $this->comment = $comment; + } + + public function getState(): string + { + return $this->state; + } + + public function setState(string $state): void + { + $this->state = $state; + } + + public function getOrder(): ?OrderInterface + { + return $this->order; + } + + public function setOrder(?OrderInterface $order): void + { + $this->order = $order; + } + + public function getAuthorEmail(): ?string + { + return $this->authorEmail; + } + + public function setAuthorEmail(?string $email): void + { + $this->authorEmail = $email; + } + + public function getAuthorFirstName(): ?string + { + return $this->authorFirstName; + } + + public function setAuthorFirstName(?string $firstName): void + { + $this->authorFirstName = $firstName; + } + + public function getAuthorLastName(): ?string + { + return $this->authorLastName; + } + + public function setAuthorLastName(?string $lastName): void + { + $this->authorLastName = $lastName; + } + + public function getAuthorCity(): ?string + { + return $this->authorCity; + } + + public function setAuthorCity(?string $city): void + { + $this->authorCity = $city; + } + + public function getAuthorCountry(): ?string + { + return $this->authorCountry; + } + + public function setAuthorCountry(?string $country): void + { + $this->authorCountry = $country; + } + + public function getReview(): ?ReviewInterface + { + return $this->review; + } + + public function setReview(?ReviewInterface $review): void + { + $this->review = $review; + } +} diff --git a/src/Model/StoreReviewInterface.php b/src/Model/StoreReviewInterface.php new file mode 100644 index 0000000..de72f77 --- /dev/null +++ b/src/Model/StoreReviewInterface.php @@ -0,0 +1,62 @@ + + */ +interface ProductReviewRepositoryInterface extends RepositoryInterface +{ + /** + * @return list + */ + public function findByOrder(OrderInterface $order): array; +} diff --git a/src/Repository/ProductReviewRepositoryTrait.php b/src/Repository/ProductReviewRepositoryTrait.php new file mode 100644 index 0000000..ab537d9 --- /dev/null +++ b/src/Repository/ProductReviewRepositoryTrait.php @@ -0,0 +1,24 @@ + $result */ + $result = $this->createQueryBuilder('r') + ->andWhere('r.order = :order') + ->setParameter('order', $order) + ->getQuery() + ->getResult() + ; + + return $result; + } +} diff --git a/src/Repository/ReviewRepository.php b/src/Repository/ReviewRepository.php new file mode 100644 index 0000000..b20b5f7 --- /dev/null +++ b/src/Repository/ReviewRepository.php @@ -0,0 +1,26 @@ +createQueryBuilder('o') + ->andWhere('o.order = :order') + ->setParameter('order', $order) + ->getQuery() + ->getOneOrNullResult() + ; + + \assert(null === $result || $result instanceof ReviewInterface); + + return $result; + } +} diff --git a/src/Repository/ReviewRepositoryInterface.php b/src/Repository/ReviewRepositoryInterface.php new file mode 100644 index 0000000..f6d4b1b --- /dev/null +++ b/src/Repository/ReviewRepositoryInterface.php @@ -0,0 +1,17 @@ + + */ +interface ReviewRepositoryInterface extends RepositoryInterface +{ + public function findOneByOrder(OrderInterface $order): ?ReviewInterface; +} diff --git a/src/Repository/StoreReviewRepository.php b/src/Repository/StoreReviewRepository.php new file mode 100644 index 0000000..90b2c9b --- /dev/null +++ b/src/Repository/StoreReviewRepository.php @@ -0,0 +1,26 @@ +createQueryBuilder('o') + ->andWhere('o.order = :order') + ->setParameter('order', $order) + ->getQuery() + ->getOneOrNullResult() + ; + + \assert(null === $result || $result instanceof StoreReviewInterface); + + return $result; + } +} diff --git a/src/Repository/StoreReviewRepositoryInterface.php b/src/Repository/StoreReviewRepositoryInterface.php new file mode 100644 index 0000000..6dbf96e --- /dev/null +++ b/src/Repository/StoreReviewRepositoryInterface.php @@ -0,0 +1,17 @@ + + */ +interface StoreReviewRepositoryInterface extends RepositoryInterface +{ + public function findOneByOrder(OrderInterface $order): ?StoreReviewInterface; +} diff --git a/src/Resources/config/doctrine/model/Review.orm.xml b/src/Resources/config/doctrine/model/Review.orm.xml new file mode 100644 index 0000000..0152d44 --- /dev/null +++ b/src/Resources/config/doctrine/model/Review.orm.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/doctrine/model/StoreReview.orm.xml b/src/Resources/config/doctrine/model/StoreReview.orm.xml new file mode 100644 index 0000000..f441178 --- /dev/null +++ b/src/Resources/config/doctrine/model/StoreReview.orm.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/routes.yaml b/src/Resources/config/routes.yaml new file mode 100644 index 0000000..39e3bf4 --- /dev/null +++ b/src/Resources/config/routes.yaml @@ -0,0 +1,5 @@ +setono_sylius_review_shop: + resource: "@SetonoSyliusReviewPlugin/Resources/config/routes/shop.yaml" + prefix: /{_locale} + requirements: + _locale: ^[A-Za-z]{2,4}(_([A-Za-z]{4}|[0-9]{3}))?(_([A-Za-z]{2}|[0-9]{3}))?$ diff --git a/src/Resources/config/routes/shop.yaml b/src/Resources/config/routes/shop.yaml new file mode 100644 index 0000000..9e85786 --- /dev/null +++ b/src/Resources/config/routes/shop.yaml @@ -0,0 +1,5 @@ +setono_sylius_review__review: + path: /review + methods: [GET, POST] + defaults: + _controller: Setono\SyliusReviewPlugin\Controller\ReviewController diff --git a/src/Resources/config/routes_no_locale.yaml b/src/Resources/config/routes_no_locale.yaml new file mode 100644 index 0000000..9a31b9e --- /dev/null +++ b/src/Resources/config/routes_no_locale.yaml @@ -0,0 +1,2 @@ +setono_sylius_review_shop: + resource: "@SetonoSyliusReviewPlugin/Resources/config/routes/shop.yaml" diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index 3074ea2..d47ed74 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -61,13 +61,23 @@ - + + - + %setono_sylius_review.eligibility.initial_delay% + + + + + + @@ -87,5 +97,83 @@ + + + + + + + + + + + + + + + + + + +24 hours + + + + + + + + + + + + + + + + + + + + %setono_sylius_review.model.store_review.class% + + setono_sylius_review + + + + + + %sylius.model.product_review.class% + + sylius + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/validation/Review.xml b/src/Resources/config/validation/Review.xml new file mode 100644 index 0000000..b4ed6bd --- /dev/null +++ b/src/Resources/config/validation/Review.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + diff --git a/src/Resources/config/validation/StoreReview.xml b/src/Resources/config/validation/StoreReview.xml new file mode 100644 index 0000000..68dc72e --- /dev/null +++ b/src/Resources/config/validation/StoreReview.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/translations/flashes.en.yaml b/src/Resources/translations/flashes.en.yaml new file mode 100644 index 0000000..79e8ca0 --- /dev/null +++ b/src/Resources/translations/flashes.en.yaml @@ -0,0 +1,3 @@ +setono_sylius_review: + review: + submitted_successfully: Your reviews have been submitted successfully. Thank you for your feedback! diff --git a/src/Resources/translations/messages.en.yaml b/src/Resources/translations/messages.en.yaml index 9e0b58a..23ffcc2 100644 --- a/src/Resources/translations/messages.en.yaml +++ b/src/Resources/translations/messages.en.yaml @@ -2,3 +2,23 @@ setono_sylius_review: email: review_request: subject: Please review %channel% + ui: + review_your_order: Review Your Order + order_summary: Order Summary + store_review: Store Review + store_review_description: Tell us about your overall shopping experience + product_reviews: Product Reviews + product_reviews_description: Share your thoughts on the products you purchased + submit_reviews: Submit Reviews + review_no_longer_editable: This review can no longer be edited + order_not_fulfilled: This order cannot be reviewed yet. Please wait until your order has been fulfilled. + review_period_expired: The review period for this order has expired. + please_correct_errors: Please correct the following errors + form: + store_review: + rating: Rating + comment: Comment (optional) + product_review: + rating: Rating + title: Title + comment: Comment (optional) diff --git a/src/Resources/translations/validators.en.yaml b/src/Resources/translations/validators.en.yaml new file mode 100644 index 0000000..d971edd --- /dev/null +++ b/src/Resources/translations/validators.en.yaml @@ -0,0 +1,3 @@ +setono_sylius_review: + review: + at_least_one_review: "You must provide at least a store review or a product review." diff --git a/src/Resources/views/shop/review/index.html.twig b/src/Resources/views/shop/review/index.html.twig new file mode 100644 index 0000000..1c6b5d5 --- /dev/null +++ b/src/Resources/views/shop/review/index.html.twig @@ -0,0 +1,110 @@ +{% extends '@SyliusShop/layout.html.twig' %} + +{% block content %} +
+

{{ 'setono_sylius_review.ui.review_your_order'|trans }}

+ +
+

{{ 'setono_sylius_review.ui.order_summary'|trans }}

+

{{ 'sylius.ui.order_number'|trans }}: {{ order.number }}

+ {% if order.checkoutCompletedAt %} +

{{ 'sylius.ui.date'|trans }}: {{ order.checkoutCompletedAt|date('Y-m-d') }}

+ {% endif %} +
+ + {% if not reviewableCheck.reviewable %} +
+

{{ reviewableCheck.reason|trans }}

+
+ {% else %} + {{ form_start(form) }} + + {# Display form-level validation errors #} + {% if form.vars.errors|length > 0 or form.storeReview.vars.errors|length > 0 %} +
+
{{ 'setono_sylius_review.ui.please_correct_errors'|trans }}
+
    + {% for error in form.vars.errors %} +
  • {{ error.message|trans }}
  • + {% endfor %} + {% for error in form.storeReview.vars.errors %} +
  • {{ error.message|trans }}
  • + {% endfor %} +
+
+ {% endif %} + + {# Store Review Section #} +
+

{{ 'setono_sylius_review.ui.store_review'|trans }}

+

{{ 'setono_sylius_review.ui.store_review_description'|trans }}

+ +
+ {{ form_label(form.storeReview.rating) }} +
+ {{ form_widget(form.storeReview.rating) }} +
+ {{ form_errors(form.storeReview.rating) }} +
+ {{ form_row(form.storeReview.comment) }} +
+ + {# Product Reviews Section #} +
+

{{ 'setono_sylius_review.ui.product_reviews'|trans }}

+

{{ 'setono_sylius_review.ui.product_reviews_description'|trans }}

+ + {% for index, item in order.items %} + {% set product = item.product %} + {% if product %} +
+
+
+ {% set productImage = product.images|first %} + {% if productImage %} + {{ product.name }} + {% endif %} +
+
+

{{ product.name }}

+ {% if item.variantName %} +

{{ item.variantName }}

+ {% endif %} + + {% if form.productReviews[index] is defined %} +
+ {{ form_label(form.productReviews[index].rating) }} +
+ {{ form_widget(form.productReviews[index].rating) }} +
+ {{ form_errors(form.productReviews[index].rating) }} +
+ {{ form_row(form.productReviews[index].title) }} + {{ form_row(form.productReviews[index].comment) }} + {% endif %} +
+
+
+ {% endif %} + {% endfor %} +
+ + + + {{ form_end(form) }} + {% endif %} +
+ + +{% endblock %} diff --git a/src/SetonoSyliusReviewPlugin.php b/src/SetonoSyliusReviewPlugin.php index 8c5fbc0..19ce36b 100644 --- a/src/SetonoSyliusReviewPlugin.php +++ b/src/SetonoSyliusReviewPlugin.php @@ -5,9 +5,12 @@ namespace Setono\SyliusReviewPlugin; use Setono\CompositeCompilerPass\CompositeCompilerPass; +use Setono\SyliusReviewPlugin\Checker\ReviewableOrder\CompositeReviewableOrderChecker; +use Setono\SyliusReviewPlugin\DependencyInjection\Compiler\OverrideProductReviewWorkflowPass; use Sylius\Bundle\CoreBundle\Application\SyliusPluginTrait; use Sylius\Bundle\ResourceBundle\AbstractResourceBundle; use Sylius\Bundle\ResourceBundle\SyliusResourceBundle; +use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\ContainerBuilder; final class SetonoSyliusReviewPlugin extends AbstractResourceBundle @@ -22,6 +25,14 @@ public function build(ContainerBuilder $container): void 'setono_sylius_review.review_request_eligibility_checker.composite', 'setono_sylius_review.review_request_eligibility_checker', )); + + $container->addCompilerPass(new CompositeCompilerPass( + CompositeReviewableOrderChecker::class, + 'setono_sylius_review.reviewable_order_checker', + )); + + // Must run after the FrameworkBundle's WorkflowPass (PassConfig::TYPE_BEFORE_OPTIMIZATION, priority 0) + $container->addCompilerPass(new OverrideProductReviewWorkflowPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -10); } /** diff --git a/src/Validator/Constraints/HasAtLeastOneReview.php b/src/Validator/Constraints/HasAtLeastOneReview.php new file mode 100644 index 0000000..5991111 --- /dev/null +++ b/src/Validator/Constraints/HasAtLeastOneReview.php @@ -0,0 +1,19 @@ +getStoreReview()?->getRating(); + + $hasProductReviewWithRating = false; + foreach ($value->getProductReviews() as $productReview) { + if (null !== $productReview->getRating()) { + $hasProductReviewWithRating = true; + + break; + } + } + + if (!$hasStoreReviewWithRating && !$hasProductReviewWithRating) { + $this->context->buildViolation($constraint->message) + ->atPath('storeReview') + ->addViolation(); + } + } +} diff --git a/src/Workflow/ProductReviewWorkflow.php b/src/Workflow/ProductReviewWorkflow.php new file mode 100644 index 0000000..3e18436 --- /dev/null +++ b/src/Workflow/ProductReviewWorkflow.php @@ -0,0 +1,84 @@ + + */ + public static function getStates(): array + { + return [ + ProductReviewInterface::STATUS_PENDING, + ReviewInterface::STATUS_NEW, + ReviewInterface::STATUS_ACCEPTED, + ReviewInterface::STATUS_REJECTED, + ]; + } + + /** + * @return array> + */ + public static function getConfig(): array + { + $transitions = []; + foreach (self::getTransitions() as $transition) { + $transitions[$transition->getName()] = [ + 'from' => $transition->getFroms(), + 'to' => $transition->getTos(), + ]; + } + + return [ + self::NAME => [ + 'type' => 'state_machine', + 'marking_store' => [ + 'type' => 'method', + 'property' => self::PROPERTY_NAME, + ], + 'supports' => ProductReviewInterface::class, + 'initial_marking' => ProductReviewInterface::STATUS_PENDING, + 'places' => self::getStates(), + 'transitions' => $transitions, + ], + ]; + } + + /** + * @return list + */ + public static function getTransitions(): array + { + return [ + new Transition(self::TRANSITION_SUBMIT, [ProductReviewInterface::STATUS_PENDING], ReviewInterface::STATUS_NEW), + new Transition(self::TRANSITION_ACCEPT, [ReviewInterface::STATUS_NEW], ReviewInterface::STATUS_ACCEPTED), + new Transition(self::TRANSITION_REJECT, [ReviewInterface::STATUS_NEW], ReviewInterface::STATUS_REJECTED), + ]; + } +} diff --git a/src/Workflow/StoreReviewWorkflow.php b/src/Workflow/StoreReviewWorkflow.php new file mode 100644 index 0000000..0d01798 --- /dev/null +++ b/src/Workflow/StoreReviewWorkflow.php @@ -0,0 +1,78 @@ + + */ + public static function getStates(): array + { + return [ + StoreReviewInterface::STATE_PENDING, + StoreReviewInterface::STATE_NEW, + StoreReviewInterface::STATE_ACCEPTED, + StoreReviewInterface::STATE_REJECTED, + ]; + } + + /** + * @return array> + */ + public static function getConfig(): array + { + $transitions = []; + foreach (self::getTransitions() as $transition) { + $transitions[$transition->getName()] = [ + 'from' => $transition->getFroms(), + 'to' => $transition->getTos(), + ]; + } + + return [ + self::NAME => [ + 'type' => 'state_machine', + 'marking_store' => [ + 'type' => 'method', + 'property' => self::PROPERTY_NAME, + ], + 'supports' => StoreReviewInterface::class, + 'initial_marking' => StoreReviewInterface::STATE_PENDING, + 'places' => self::getStates(), + 'transitions' => $transitions, + ], + ]; + } + + /** + * @return list + */ + public static function getTransitions(): array + { + return [ + new Transition(self::TRANSITION_SUBMIT, [StoreReviewInterface::STATE_PENDING], StoreReviewInterface::STATE_NEW), + new Transition(self::TRANSITION_ACCEPT, [StoreReviewInterface::STATE_NEW], StoreReviewInterface::STATE_ACCEPTED), + new Transition(self::TRANSITION_REJECT, [StoreReviewInterface::STATE_NEW], StoreReviewInterface::STATE_REJECTED), + ]; + } +} diff --git a/tests/Application/Entity/ProductReview.php b/tests/Application/Entity/ProductReview.php new file mode 100644 index 0000000..6226e83 --- /dev/null +++ b/tests/Application/Entity/ProductReview.php @@ -0,0 +1,34 @@ +status = self::STATUS_PENDING; + } +} diff --git a/tests/Application/Repository/ProductReviewRepository.php b/tests/Application/Repository/ProductReviewRepository.php new file mode 100644 index 0000000..85b0a39 --- /dev/null +++ b/tests/Application/Repository/ProductReviewRepository.php @@ -0,0 +1,14 @@ +