From 41bc3728e5c1979aa180a2682ec7463742d69676 Mon Sep 17 00:00:00 2001 From: "Claude (Deployyy workspace)" Date: Tue, 20 Oct 2026 16:01:15 +0000 Subject: [PATCH] =?UTF-8?q?A=20downloadable=20product=20page=20is=20served?= =?UTF-8?q?=20from=20documents:=20the=20new=20DownloadableFrontend=20modul?= =?UTF-8?q?e=20answers=20the=20downloadable=20type's=20links=20and=20sampl?= =?UTF-8?q?es=20from=20the=20document's=20downloadable=20options=20and=20s?= =?UTF-8?q?amples,=20so=20a=20Luma=20or=20Hyv=C3=A4=20store=20with=20downl?= =?UTF-8?q?oadable=20products=20can=20turn=20on=20product=20pages=20from?= =?UTF-8?q?=20documents,=20where=20the=20page=20stopped=20before.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 +- README.md | 3 +- composer.json | 3 + .../Plugin/LinksFromDocument.php | 115 +++++++++++++++ .../README.md | 16 +++ .../Unit/Plugin/LinksFromDocumentTest.php | 133 ++++++++++++++++++ .../composer.json | 22 +++ .../etc/frontend/di.xml | 9 ++ .../etc/module.xml | 10 ++ .../registration.php | 6 + 10 files changed, 320 insertions(+), 3 deletions(-) create mode 100644 src/CatalogStorefrontDownloadableFrontend/Plugin/LinksFromDocument.php create mode 100644 src/CatalogStorefrontDownloadableFrontend/README.md create mode 100644 src/CatalogStorefrontDownloadableFrontend/Test/Unit/Plugin/LinksFromDocumentTest.php create mode 100644 src/CatalogStorefrontDownloadableFrontend/composer.json create mode 100644 src/CatalogStorefrontDownloadableFrontend/etc/frontend/di.xml create mode 100644 src/CatalogStorefrontDownloadableFrontend/etc/module.xml create mode 100644 src/CatalogStorefrontDownloadableFrontend/registration.php diff --git a/CLAUDE.md b/CLAUDE.md index 5c59252..42e7de1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -239,8 +239,10 @@ passes. bundle items resolver. - `CatalogStorefrontGroupedProduct` / `...GroupedProductGraphQl`: the grouped range / the deprecated price rewrite, the grouped items resolver. -- `CatalogStorefrontDownloadable` / `...DownloadableGraphQl`: the downloadable - range registration / the links and samples resolvers. +- `CatalogStorefrontDownloadable` / `...DownloadableGraphQl` / `...DownloadableFrontend`: the + downloadable range registration / the links and samples resolvers / the links and samples of + a rendered product page (`getLinks()` and `getSamples()` of the type from `optionsV2` and + `samples`, kept in core's memo keys; a link has a sample when the feed gives it an `infoUrl`). - `CatalogStorefrontReview` / `...ReviewGraphQl` / `...ReviewFrontend`: the reviews and rating feeds made to export like the modern ones, the review date field, the rating position and entity, their writers (reviews become documents of their own, declared to the diff --git a/README.md b/README.md index 715853e..32904b9 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,7 @@ runs core alone. | A configurable's children, super attributes and lowest price on a rendered page | `...ConfigurableProductFrontend` | variant documents and configurable attributes | | Bundle options, selections and price ranges on a rendered page | `...BundleProductFrontend` | child documents, price rows and stock limits | | Grouped children and quantity validation on a rendered page | `...GroupedProductFrontend` | child documents and stock limits | +| Downloadable links and samples on a rendered page | `...DownloadableFrontend` | link ids and sample ids | ## Fields the package adds @@ -504,7 +505,7 @@ the base module and the GraphQl module. | [`...ConfigurableProduct`](src/CatalogStorefrontConfigurableProduct) / [`...GraphQl`](src/CatalogStorefrontConfigurableProductGraphQl) / [`...Frontend`](src/CatalogStorefrontConfigurableProductFrontend) | Variants and the configurable range / options, variants, selection / a configurable on a rendered page | | [`...BundleProduct`](src/CatalogStorefrontBundleProduct) / [`...GraphQl`](src/CatalogStorefrontBundleProductGraphQl) / [`...Frontend`](src/CatalogStorefrontBundleProductFrontend) | Bundle feed fields and range / bundle items and price details / rendered bundle options and prices | | [`...GroupedProduct`](src/CatalogStorefrontGroupedProduct) / [`...GraphQl`](src/CatalogStorefrontGroupedProductGraphQl) / [`...Frontend`](src/CatalogStorefrontGroupedProductFrontend) | Grouped range / grouped items / rendered children and quantity validation | -| [`...Downloadable`](src/CatalogStorefrontDownloadable) / [`...GraphQl`](src/CatalogStorefrontDownloadableGraphQl) | The downloadable range / links and samples | +| [`...Downloadable`](src/CatalogStorefrontDownloadable) / [`...GraphQl`](src/CatalogStorefrontDownloadableGraphQl) / [`...Frontend`](src/CatalogStorefrontDownloadableFrontend) | The downloadable range / links and samples / rendered links and samples | | [`...Review`](src/CatalogStorefrontReview) / [`...GraphQl`](src/CatalogStorefrontReviewGraphQl) / [`...Frontend`](src/CatalogStorefrontReviewFrontend) | Review and rating feeds / rating summary, breakdown and reviews / the rating collection of the rendered review form | | [`...SnowdogMenu`](src/CatalogStorefrontSnowdogMenu) | Snowdog's menus as documents from a feed, and the menus, nodes and node data of a rendered page from them, every node type. Registered only where Snowdog_Menu is installed | | [`...Swatches`](src/CatalogStorefrontSwatches) / [`...GraphQl`](src/CatalogStorefrontSwatchesGraphQl) | The swatch of every option on the attribute documents / the `swatch` fields of GraphQL Catalog from them | diff --git a/composer.json b/composer.json index f2af082..3e7ec4e 100644 --- a/composer.json +++ b/composer.json @@ -63,6 +63,7 @@ "graphcommerce/module-catalog-storefront-statement-gate": "self.version", "graphcommerce/module-catalog-storefront-bundle-product-frontend": "self.version", "graphcommerce/module-catalog-storefront-grouped-product-frontend": "self.version", + "graphcommerce/module-catalog-storefront-downloadable-frontend": "self.version", "graphcommerce/module-catalog-storefront-swatches": "self.version", "graphcommerce/module-catalog-storefront-swatches-graph-ql": "self.version", "graphcommerce/module-catalog-storefront-url-rewrite": "self.version", @@ -121,6 +122,7 @@ "src/CatalogStorefrontReviewFrontend/registration.php", "src/CatalogStorefrontBundleProductFrontend/registration.php", "src/CatalogStorefrontGroupedProductFrontend/registration.php", + "src/CatalogStorefrontDownloadableFrontend/registration.php", "src/CatalogStorefrontSwatches/registration.php", "src/CatalogStorefrontSwatchesGraphQl/registration.php", "src/CatalogStorefrontUrlRewrite/registration.php", @@ -163,6 +165,7 @@ "GraphCommerce\\CatalogStorefrontSearch\\": "src/CatalogStorefrontSearch/", "GraphCommerce\\CatalogStorefrontBundleProductFrontend\\": "src/CatalogStorefrontBundleProductFrontend/", "GraphCommerce\\CatalogStorefrontGroupedProductFrontend\\": "src/CatalogStorefrontGroupedProductFrontend/", + "GraphCommerce\\CatalogStorefrontDownloadableFrontend\\": "src/CatalogStorefrontDownloadableFrontend/", "GraphCommerce\\CatalogStorefrontSwatches\\": "src/CatalogStorefrontSwatches/", "GraphCommerce\\CatalogStorefrontSwatchesGraphQl\\": "src/CatalogStorefrontSwatchesGraphQl/", "GraphCommerce\\CatalogStorefrontUrlRewrite\\": "src/CatalogStorefrontUrlRewrite/", diff --git a/src/CatalogStorefrontDownloadableFrontend/Plugin/LinksFromDocument.php b/src/CatalogStorefrontDownloadableFrontend/Plugin/LinksFromDocument.php new file mode 100644 index 0000000..cf5f6d0 --- /dev/null +++ b/src/CatalogStorefrontDownloadableFrontend/Plugin/LinksFromDocument.php @@ -0,0 +1,115 @@ +getData(ProductDocumentsInterface::DOCUMENT_KEY); + if (!is_array($document) || $product->getDownloadableLinks() !== null) { + return $proceed($product); + } + + $values = []; + foreach ((array)($document['optionsV2'] ?? []) as $option) { + if (($option['type'] ?? null) === 'downloadable') { + array_push($values, ...(array)($option['values'] ?? [])); + } + } + + $links = []; + foreach ($values as $value) { + $parts = explode('/', (string)$this->uid->decode((string)($value['id'] ?? ''))); + if (count($parts) !== 2 || $parts[0] !== 'downloadable' || !ctype_digit($parts[1])) { + throw new DocumentReadException('Catalog downloadable link UID is invalid: ' . ($value['id'] ?? '')); + } + $link = $this->linkFactory->create()->setData([ + 'link_id' => $parts[1], + 'product_id' => (string)$product->getId(), + 'sort_order' => (string)($value['sortOrder'] ?? 0), + 'title' => $value['label'] ?? null, + 'price' => (string)($value['price'] ?? 0), + 'number_of_downloads' => (string)($value['qty'] ?? 0), + 'sample_url' => $value['infoUrl'] ?? null, + ]); + $link->setProduct($product); + $links[$parts[1]] = $link; + } + uasort($links, static fn(Link $a, Link $b) => + [(int)$a->getSortOrder(), (int)$a->getId()] <=> [(int)$b->getSortOrder(), (int)$b->getId()]); + $product->setDownloadableLinks($links); + + return $links; + } + + /** + * @param Type $subject + * @param \Closure $proceed + * @param Product $product + * @return Collection + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function aroundGetSamples(Type $subject, \Closure $proceed, $product) + { + $document = $product->getData(ProductDocumentsInterface::DOCUMENT_KEY); + if (!is_array($document) || $product->getDownloadableSamples() !== null) { + return $proceed($product); + } + + $samples = []; + foreach ((array)($document['samples'] ?? []) as $sample) { + if (!preg_match('~/sample_id/(\d+)~', (string)($sample['resource']['url'] ?? ''), $match)) { + throw new DocumentReadException('Catalog downloadable sample URL names no sample: ' . $product->getId()); + } + $samples[] = $this->sampleFactory->create()->setData([ + 'sample_id' => $match[1], + 'product_id' => (string)$product->getId(), + 'sort_order' => (string)($sample['sortOrder'] ?? 0), + 'title' => $sample['resource']['label'] ?? null, + ]); + } + usort($samples, static fn($a, $b) => + [(int)$a->getSortOrder(), (int)$a->getId()] <=> [(int)$b->getSortOrder(), (int)$b->getId()]); + + $collection = $this->collectionFactory->create(); + foreach ($samples as $sample) { + $collection->addItem($sample); + } + $product->setDownloadableSamples($collection); + + return $collection; + } +} diff --git a/src/CatalogStorefrontDownloadableFrontend/README.md b/src/CatalogStorefrontDownloadableFrontend/README.md new file mode 100644 index 0000000..342363f --- /dev/null +++ b/src/CatalogStorefrontDownloadableFrontend/README.md @@ -0,0 +1,16 @@ +# GraphCommerce_CatalogStorefrontDownloadableFrontend + +The links and samples of a downloadable product page served from documents, for a Luma or +Hyvä theme, under the `serve_pdp` setting of GraphCommerce_CatalogStorefrontProductFrontend and +its `X-Catalog-Storefront` header. Frontend area only. + +- [`Plugin/LinksFromDocument`](Plugin/LinksFromDocument.php) answers `getLinks()` and + `getSamples()` of the downloadable type from the document's downloadable `optionsV2` values and + its `samples`, and keeps them in the product's `downloadable_links` and `downloadable_samples`, + which core's type reads first. A link has a sample when the feed gives it an `infoUrl`. +- It registers `downloadable` with the option types the product page plugin supports. +- A link UID or a sample URL that names no id stops the page with `DocumentReadException`. + +```sh +bin/magento catalog-storefront:parity:detail https://shop.example +``` diff --git a/src/CatalogStorefrontDownloadableFrontend/Test/Unit/Plugin/LinksFromDocumentTest.php b/src/CatalogStorefrontDownloadableFrontend/Test/Unit/Plugin/LinksFromDocumentTest.php new file mode 100644 index 0000000..e6b930a --- /dev/null +++ b/src/CatalogStorefrontDownloadableFrontend/Test/Unit/Plugin/LinksFromDocumentTest.php @@ -0,0 +1,133 @@ +createStub(LinkFactory::class); + $linkFactory->method('create')->willReturnCallback(static function () { + $link = (new \ReflectionClass(Link::class))->newInstanceWithoutConstructor(); + $link->setIdFieldName('link_id'); + + return $link; + }); + $sampleFactory = $this->createStub(SampleFactory::class); + $sampleFactory->method('create')->willReturnCallback(static function () { + $sample = (new \ReflectionClass(Sample::class))->newInstanceWithoutConstructor(); + $sample->setIdFieldName('sample_id'); + + return $sample; + }); + $collectionFactory = $this->createStub(CollectionFactory::class); + $collectionFactory->method('create')->willReturnCallback( + fn() => new Collection($this->createStub(EntityFactoryInterface::class)) + ); + $uid = $this->createStub(Uid::class); + $uid->method('decode')->willReturnCallback('base64_decode'); + + return new LinksFromDocument($linkFactory, $sampleFactory, $collectionFactory, $uid); + } + + private function product(?array $document): Product + { + $product = $this->getMockBuilder(Product::class)->disableOriginalConstructor()->onlyMethods([])->getMock(); + $product->setIdFieldName('entity_id'); + $product->setData(['entity_id' => '42']); + if ($document !== null) { + $product->setData(ProductDocumentsInterface::DOCUMENT_KEY, $document); + } + + return $product; + } + + private static function link(int $id, int $sortOrder, ?string $infoUrl): array + { + return [ + 'id' => base64_encode('downloadable/' . $id), + 'label' => 'Link ' . $id, + 'sortOrder' => $sortOrder, + 'price' => 2.5, + 'qty' => 3, + 'infoUrl' => $infoUrl, + ]; + } + + public function testLinksComeFromTheDownloadableOptionInSortOrder(): void + { + $product = $this->product(['optionsV2' => [ + ['type' => 'downloadable', 'values' => [self::link(5, 2, null), self::link(4, 1, 'https://shop/sample/4')]], + ]]); + $core = static fn() => self::fail('The core link reader was called.'); + + $links = $this->plugin()->aroundGetLinks($this->createStub(Type::class), $core, $product); + + self::assertSame(['4', '5'], array_map('strval', array_keys($links))); + self::assertSame('Link 4', $links['4']->getTitle()); + self::assertSame('2.5', $links['4']->getPrice()); + self::assertSame('3', $links['4']->getNumberOfDownloads()); + self::assertSame('https://shop/sample/4', $links['4']->getSampleUrl()); + self::assertNull($links['5']->getSampleUrl()); + self::assertSame($product, $links['4']->getProduct()); + self::assertSame($links, $product->getDownloadableLinks()); + } + + public function testInvalidLinkUidStopsTheRead(): void + { + $product = $this->product(['optionsV2' => [ + ['type' => 'downloadable', 'values' => [['id' => base64_encode('bundle/1/2/3')]]], + ]]); + + $this->expectException(DocumentReadException::class); + $this->plugin()->aroundGetLinks($this->createStub(Type::class), static fn() => [], $product); + } + + public function testSamplesComeFromTheDocumentInSortOrder(): void + { + $product = $this->product(['samples' => [ + ['resource' => ['label' => 'Second', 'url' => 'https://shop/downloadable/download/sample/sample_id/12'], 'sortOrder' => '2'], + ['resource' => ['label' => 'First', 'url' => 'https://shop/downloadable/download/sample/sample_id/13'], 'sortOrder' => '1'], + ]]); + $core = static fn() => self::fail('The core sample reader was called.'); + + $samples = $this->plugin()->aroundGetSamples($this->createStub(Type::class), $core, $product); + + self::assertSame([13, 12], array_map('intval', $samples->getAllIds())); + self::assertSame('First', $samples->getFirstItem()->getTitle()); + self::assertSame($samples, $product->getDownloadableSamples()); + } + + public function testSampleUrlWithoutIdStopsTheRead(): void + { + $product = $this->product(['samples' => [['resource' => ['url' => 'https://shop/other']]]]); + + $this->expectException(DocumentReadException::class); + $this->plugin()->aroundGetSamples($this->createStub(Type::class), static fn() => null, $product); + } + + public function testProductWithoutADocumentGoesToCore(): void + { + $product = $this->product(null); + $type = $this->createStub(Type::class); + + self::assertSame(['core'], $this->plugin()->aroundGetLinks($type, static fn() => ['core'], $product)); + self::assertSame('core', $this->plugin()->aroundGetSamples($type, static fn() => 'core', $product)); + } +} diff --git a/src/CatalogStorefrontDownloadableFrontend/composer.json b/src/CatalogStorefrontDownloadableFrontend/composer.json new file mode 100644 index 0000000..2e3e105 --- /dev/null +++ b/src/CatalogStorefrontDownloadableFrontend/composer.json @@ -0,0 +1,22 @@ +{ + "name": "graphcommerce/module-catalog-storefront-downloadable-frontend", + "description": "Serves the links and samples of downloadable product pages from catalog documents", + "type": "magento2-module", + "license": "MIT", + "require": { + "php": "~8.1.0|~8.2.0|~8.3.0|~8.4.0", + "magento/framework": "*", + "magento/module-catalog": "*", + "magento/module-downloadable": "*", + "graphcommerce/module-catalog-storefront-product-frontend": "self.version", + "graphcommerce/module-catalog-storefront-downloadable": "self.version" + }, + "autoload": { + "files": [ + "registration.php" + ], + "psr-4": { + "GraphCommerce\\CatalogStorefrontDownloadableFrontend\\": "" + } + } +} diff --git a/src/CatalogStorefrontDownloadableFrontend/etc/frontend/di.xml b/src/CatalogStorefrontDownloadableFrontend/etc/frontend/di.xml new file mode 100644 index 0000000..543fa7b --- /dev/null +++ b/src/CatalogStorefrontDownloadableFrontend/etc/frontend/di.xml @@ -0,0 +1,9 @@ + + + + true + + + + + diff --git a/src/CatalogStorefrontDownloadableFrontend/etc/module.xml b/src/CatalogStorefrontDownloadableFrontend/etc/module.xml new file mode 100644 index 0000000..ec0f21e --- /dev/null +++ b/src/CatalogStorefrontDownloadableFrontend/etc/module.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/CatalogStorefrontDownloadableFrontend/registration.php b/src/CatalogStorefrontDownloadableFrontend/registration.php new file mode 100644 index 0000000..6da9aec --- /dev/null +++ b/src/CatalogStorefrontDownloadableFrontend/registration.php @@ -0,0 +1,6 @@ +