From 58377bb34e8403bbf87b54a60eca388beb1987ee Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 27 Nov 2025 11:03:52 +0000 Subject: [PATCH 01/98] Bump shopify dep --- composer.json | 2 +- composer.lock | 214 +++++++++++++++--------------- src/templates/webhooks/index.twig | 59 -------- 3 files changed, 108 insertions(+), 167 deletions(-) delete mode 100644 src/templates/webhooks/index.twig diff --git a/composer.json b/composer.json index 4d77183..fcd8e26 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ "php": "^8.2", "carnage/php-graphql-client": "^1.14", "craftcms/cms": "^5.0.0-beta.10||^4.15.0", - "shopify/shopify-api": "^5.11.0" + "shopify/shopify-api": "^6.0.0" }, "require-dev": { "craftcms/feed-me": "^6.6.1||^5.9.0", diff --git a/composer.lock b/composer.lock index 4b01964..deedefa 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "24939c1fcdda7ece25f15e8e71719862", + "content-hash": "fffa4c7b5d520b39f150fc69fa3aa850", "packages": [ { "name": "bacon/bacon-qr-code", @@ -390,16 +390,16 @@ }, { "name": "craftcms/cms", - "version": "5.8.18", + "version": "5.8.19", "source": { "type": "git", "url": "https://github.com/craftcms/cms.git", - "reference": "ff394ccb9e70c83f16f47ea159ab2331b5e05084" + "reference": "2fdb2031cdf41875121c3f4b0094551ae95a2c38" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/cms/zipball/ff394ccb9e70c83f16f47ea159ab2331b5e05084", - "reference": "ff394ccb9e70c83f16f47ea159ab2331b5e05084", + "url": "https://api.github.com/repos/craftcms/cms/zipball/2fdb2031cdf41875121c3f4b0094551ae95a2c38", + "reference": "2fdb2031cdf41875121c3f4b0094551ae95a2c38", "shasum": "" }, "require": { @@ -513,7 +513,7 @@ "rss": "https://github.com/craftcms/cms/releases.atom", "source": "https://github.com/craftcms/cms" }, - "time": "2025-10-06T18:25:26+00:00" + "time": "2025-10-28T19:21:54+00:00" }, { "name": "craftcms/plugin-installer", @@ -773,16 +773,16 @@ }, { "name": "doctrine/collections", - "version": "2.3.0", + "version": "2.4.0", "source": { "type": "git", "url": "https://github.com/doctrine/collections.git", - "reference": "2eb07e5953eed811ce1b309a7478a3b236f2273d" + "reference": "9acfeea2e8666536edff3d77c531261c63680160" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/collections/zipball/2eb07e5953eed811ce1b309a7478a3b236f2273d", - "reference": "2eb07e5953eed811ce1b309a7478a3b236f2273d", + "url": "https://api.github.com/repos/doctrine/collections/zipball/9acfeea2e8666536edff3d77c531261c63680160", + "reference": "9acfeea2e8666536edff3d77c531261c63680160", "shasum": "" }, "require": { @@ -791,11 +791,11 @@ "symfony/polyfill-php84": "^1.30" }, "require-dev": { - "doctrine/coding-standard": "^12", + "doctrine/coding-standard": "^14", "ext-json": "*", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.0", - "phpunit/phpunit": "^10.5" + "phpstan/phpstan": "^2.1.30", + "phpstan/phpstan-phpunit": "^2.0.7", + "phpunit/phpunit": "^10.5.58 || ^11.5.42 || ^12.4" }, "type": "library", "autoload": { @@ -839,7 +839,7 @@ ], "support": { "issues": "https://github.com/doctrine/collections/issues", - "source": "https://github.com/doctrine/collections/tree/2.3.0" + "source": "https://github.com/doctrine/collections/tree/2.4.0" }, "funding": [ { @@ -855,7 +855,7 @@ "type": "tidelift" } ], - "time": "2025-03-22T10:17:19+00:00" + "time": "2025-10-25T09:18:13+00:00" }, { "name": "doctrine/deprecations", @@ -1229,20 +1229,20 @@ }, { "name": "ezyang/htmlpurifier", - "version": "v4.18.0", + "version": "v4.19.0", "source": { "type": "git", "url": "https://github.com/ezyang/htmlpurifier.git", - "reference": "cb56001e54359df7ae76dc522d08845dc741621b" + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/cb56001e54359df7ae76dc522d08845dc741621b", - "reference": "cb56001e54359df7ae76dc522d08845dc741621b", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf", "shasum": "" }, "require": { - "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" + "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" }, "require-dev": { "cerdic/css-tidy": "^1.7 || ^2.0", @@ -1284,9 +1284,9 @@ ], "support": { "issues": "https://github.com/ezyang/htmlpurifier/issues", - "source": "https://github.com/ezyang/htmlpurifier/tree/v4.18.0" + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0" }, - "time": "2024-11-01T03:51:45+00:00" + "time": "2025-10-17T16:34:55+00:00" }, { "name": "firebase/php-jwt", @@ -2224,23 +2224,23 @@ }, { "name": "moneyphp/money", - "version": "v4.7.1", + "version": "v4.8.0", "source": { "type": "git", "url": "https://github.com/moneyphp/money.git", - "reference": "1a23f0e1b22e2c59ed5ed70cfbe4cbe696be9348" + "reference": "b358727ea5a5cd2d7475e59c31dfc352440ae7ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/moneyphp/money/zipball/1a23f0e1b22e2c59ed5ed70cfbe4cbe696be9348", - "reference": "1a23f0e1b22e2c59ed5ed70cfbe4cbe696be9348", + "url": "https://api.github.com/repos/moneyphp/money/zipball/b358727ea5a5cd2d7475e59c31dfc352440ae7ec", + "reference": "b358727ea5a5cd2d7475e59c31dfc352440ae7ec", "shasum": "" }, "require": { "ext-bcmath": "*", "ext-filter": "*", "ext-json": "*", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" }, "require-dev": { "cache/taggable-cache": "^1.1.0", @@ -2308,9 +2308,9 @@ ], "support": { "issues": "https://github.com/moneyphp/money/issues", - "source": "https://github.com/moneyphp/money/tree/v4.7.1" + "source": "https://github.com/moneyphp/money/tree/v4.8.0" }, - "time": "2025-06-06T07:12:38+00:00" + "time": "2025-10-23T07:55:09+00:00" }, { "name": "monolog/monolog", @@ -3733,16 +3733,16 @@ }, { "name": "shopify/shopify-api", - "version": "v5.11.0", + "version": "v6.0.0", "source": { "type": "git", "url": "https://github.com/Shopify/shopify-api-php.git", - "reference": "ed1c9cd01b68a0beb89ad770123ebc926bb7a98c" + "reference": "f4d177e8ce062aa302aef10e29bc274ac26ccd19" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Shopify/shopify-api-php/zipball/ed1c9cd01b68a0beb89ad770123ebc926bb7a98c", - "reference": "ed1c9cd01b68a0beb89ad770123ebc926bb7a98c", + "url": "https://api.github.com/repos/Shopify/shopify-api-php/zipball/f4d177e8ce062aa302aef10e29bc274ac26ccd19", + "reference": "f4d177e8ce062aa302aef10e29bc274ac26ccd19", "shasum": "" }, "require": { @@ -3801,9 +3801,9 @@ ], "support": { "issues": "https://github.com/Shopify/shopify-api-php/issues", - "source": "https://github.com/Shopify/shopify-api-php/tree/v5.11.0" + "source": "https://github.com/Shopify/shopify-api-php/tree/v6.0.0" }, - "time": "2025-07-11T14:07:16+00:00" + "time": "2025-10-28T19:19:42+00:00" }, { "name": "spomky-labs/cbor-php", @@ -3890,20 +3890,20 @@ }, { "name": "spomky-labs/pki-framework", - "version": "1.3.0", + "version": "1.4.0", "source": { "type": "git", "url": "https://github.com/Spomky-Labs/pki-framework.git", - "reference": "eced5b5ce70518b983ff2be486e902bbd15135ae" + "reference": "bf6f55a9d9eb25b7781640221cb54f5c727850d7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/eced5b5ce70518b983ff2be486e902bbd15135ae", - "reference": "eced5b5ce70518b983ff2be486e902bbd15135ae", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/bf6f55a9d9eb25b7781640221cb54f5c727850d7", + "reference": "bf6f55a9d9eb25b7781640221cb54f5c727850d7", "shasum": "" }, "require": { - "brick/math": "^0.10|^0.11|^0.12|^0.13", + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14", "ext-mbstring": "*", "php": ">=8.1" }, @@ -3911,7 +3911,7 @@ "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", "ext-gmp": "*", "ext-openssl": "*", - "infection/infection": "^0.28|^0.29", + "infection/infection": "^0.28|^0.29|^0.31", "php-parallel-lint/php-parallel-lint": "^1.3", "phpstan/extension-installer": "^1.3|^2.0", "phpstan/phpstan": "^1.8|^2.0", @@ -3921,8 +3921,8 @@ "phpunit/phpunit": "^10.1|^11.0|^12.0", "rector/rector": "^1.0|^2.0", "roave/security-advisories": "dev-latest", - "symfony/string": "^6.4|^7.0", - "symfony/var-dumper": "^6.4|^7.0", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", "symplify/easy-coding-standard": "^12.0" }, "suggest": { @@ -3983,7 +3983,7 @@ ], "support": { "issues": "https://github.com/Spomky-Labs/pki-framework/issues", - "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.3.0" + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.0" }, "funding": [ { @@ -3995,7 +3995,7 @@ "type": "patreon" } ], - "time": "2025-06-13T08:35:04+00:00" + "time": "2025-10-22T08:24:34+00:00" }, { "name": "symfony/css-selector", @@ -4610,16 +4610,16 @@ }, { "name": "symfony/mailer", - "version": "v7.3.4", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "ab97ef2f7acf0216955f5845484235113047a31d" + "reference": "fd497c45ba9c10c37864e19466b090dcb60a50ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/ab97ef2f7acf0216955f5845484235113047a31d", - "reference": "ab97ef2f7acf0216955f5845484235113047a31d", + "url": "https://api.github.com/repos/symfony/mailer/zipball/fd497c45ba9c10c37864e19466b090dcb60a50ba", + "reference": "fd497c45ba9c10c37864e19466b090dcb60a50ba", "shasum": "" }, "require": { @@ -4670,7 +4670,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.3.4" + "source": "https://github.com/symfony/mailer/tree/v7.3.5" }, "funding": [ { @@ -4690,7 +4690,7 @@ "type": "tidelift" } ], - "time": "2025-09-17T05:51:54+00:00" + "time": "2025-10-24T14:27:20+00:00" }, { "name": "symfony/mime", @@ -5821,23 +5821,23 @@ }, { "name": "symfony/property-info", - "version": "v7.3.4", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "7b6db23f23d13ada41e1cb484748a8ec028fbace" + "reference": "0b346ed259dc5da43535caf243005fe7d4b0f051" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/7b6db23f23d13ada41e1cb484748a8ec028fbace", - "reference": "7b6db23f23d13ada41e1cb484748a8ec028fbace", + "url": "https://api.github.com/repos/symfony/property-info/zipball/0b346ed259dc5da43535caf243005fe7d4b0f051", + "reference": "0b346ed259dc5da43535caf243005fe7d4b0f051", "shasum": "" }, "require": { "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/string": "^6.4|^7.0", - "symfony/type-info": "~7.2.8|^7.3.1" + "symfony/type-info": "^7.3.5" }, "conflict": { "phpdocumentor/reflection-docblock": "<5.2", @@ -5887,7 +5887,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v7.3.4" + "source": "https://github.com/symfony/property-info/tree/v7.3.5" }, "funding": [ { @@ -5907,20 +5907,20 @@ "type": "tidelift" } ], - "time": "2025-09-15T13:55:54+00:00" + "time": "2025-10-05T22:12:41+00:00" }, { "name": "symfony/serializer", - "version": "v6.4.26", + "version": "v6.4.27", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "48d0477483614d615aa1d5e5d90a45e4c7bfa2c9" + "reference": "28779bbdb398cac3421d0e51f7ca669e4a27c5ac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/48d0477483614d615aa1d5e5d90a45e4c7bfa2c9", - "reference": "48d0477483614d615aa1d5e5d90a45e4c7bfa2c9", + "url": "https://api.github.com/repos/symfony/serializer/zipball/28779bbdb398cac3421d0e51f7ca669e4a27c5ac", + "reference": "28779bbdb398cac3421d0e51f7ca669e4a27c5ac", "shasum": "" }, "require": { @@ -5989,7 +5989,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v6.4.26" + "source": "https://github.com/symfony/serializer/tree/v6.4.27" }, "funding": [ { @@ -6009,7 +6009,7 @@ "type": "tidelift" } ], - "time": "2025-09-15T13:37:27+00:00" + "time": "2025-10-08T04:24:22+00:00" }, { "name": "symfony/service-contracts", @@ -6186,16 +6186,16 @@ }, { "name": "symfony/type-info", - "version": "v7.3.4", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "d34eaeb57f39c8a9c97eb72a977c423207dfa35b" + "reference": "8b36f41421160db56914f897b57eaa6a830758b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/d34eaeb57f39c8a9c97eb72a977c423207dfa35b", - "reference": "d34eaeb57f39c8a9c97eb72a977c423207dfa35b", + "url": "https://api.github.com/repos/symfony/type-info/zipball/8b36f41421160db56914f897b57eaa6a830758b3", + "reference": "8b36f41421160db56914f897b57eaa6a830758b3", "shasum": "" }, "require": { @@ -6245,7 +6245,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v7.3.4" + "source": "https://github.com/symfony/type-info/tree/v7.3.5" }, "funding": [ { @@ -6265,7 +6265,7 @@ "type": "tidelift" } ], - "time": "2025-09-11T15:33:27+00:00" + "time": "2025-10-16T12:30:12+00:00" }, { "name": "symfony/uid", @@ -6343,16 +6343,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.3.4", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb" + "reference": "476c4ae17f43a9a36650c69879dcf5b1e6ae724d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb", - "reference": "b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/476c4ae17f43a9a36650c69879dcf5b1e6ae724d", + "reference": "476c4ae17f43a9a36650c69879dcf5b1e6ae724d", "shasum": "" }, "require": { @@ -6406,7 +6406,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.3.4" + "source": "https://github.com/symfony/var-dumper/tree/v7.3.5" }, "funding": [ { @@ -6426,20 +6426,20 @@ "type": "tidelift" } ], - "time": "2025-09-11T10:12:26+00:00" + "time": "2025-09-27T09:00:46+00:00" }, { "name": "symfony/yaml", - "version": "v7.3.3", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "d4f4a66866fe2451f61296924767280ab5732d9d" + "reference": "90208e2fc6f68f613eae7ca25a2458a931b1bacc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/d4f4a66866fe2451f61296924767280ab5732d9d", - "reference": "d4f4a66866fe2451f61296924767280ab5732d9d", + "url": "https://api.github.com/repos/symfony/yaml/zipball/90208e2fc6f68f613eae7ca25a2458a931b1bacc", + "reference": "90208e2fc6f68f613eae7ca25a2458a931b1bacc", "shasum": "" }, "require": { @@ -6482,7 +6482,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.3.3" + "source": "https://github.com/symfony/yaml/tree/v7.3.5" }, "funding": [ { @@ -6502,7 +6502,7 @@ "type": "tidelift" } ], - "time": "2025-08-27T11:34:33+00:00" + "time": "2025-09-27T09:00:46+00:00" }, { "name": "theiconic/name-parser", @@ -7448,28 +7448,28 @@ }, { "name": "webmozart/assert", - "version": "1.11.0", + "version": "1.12.1", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991" + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", "shasum": "" }, "require": { "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", "php": "^7.2 || ^8.0" }, - "conflict": { - "phpstan/phpstan": "<0.12.20", - "vimeo/psalm": "<4.6.1 || 4.6.2" - }, - "require-dev": { - "phpunit/phpunit": "^8.5.13" + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" }, "type": "library", "extra": { @@ -7500,9 +7500,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.11.0" + "source": "https://github.com/webmozarts/assert/tree/1.12.1" }, - "time": "2022-06-03T18:03:27+00:00" + "time": "2025-10-29T15:56:20+00:00" }, { "name": "webonyx/graphql-php", @@ -8063,7 +8063,7 @@ "packages-dev": [ { "name": "cakephp/core", - "version": "5.2.8", + "version": "5.2.9", "source": { "type": "git", "url": "https://github.com/cakephp/core.git", @@ -8130,7 +8130,7 @@ }, { "name": "cakephp/utility", - "version": "5.2.8", + "version": "5.2.9", "source": { "type": "git", "url": "https://github.com/cakephp/utility.git", @@ -8296,16 +8296,16 @@ }, { "name": "craftcms/feed-me", - "version": "6.10.1", + "version": "6.11.0", "source": { "type": "git", "url": "https://github.com/craftcms/feed-me.git", - "reference": "8818ee101baf758225884828b7d11688f373b4d8" + "reference": "46cbf5aeb3c6ddabb9ab248c0e6742444533f1bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/feed-me/zipball/8818ee101baf758225884828b7d11688f373b4d8", - "reference": "8818ee101baf758225884828b7d11688f373b4d8", + "url": "https://api.github.com/repos/craftcms/feed-me/zipball/46cbf5aeb3c6ddabb9ab248c0e6742444533f1bb", + "reference": "46cbf5aeb3c6ddabb9ab248c0e6742444533f1bb", "shasum": "" }, "require": { @@ -8362,7 +8362,7 @@ "rss": "https://github.com/craftcms/feed-me/commits/master.atom", "source": "https://github.com/craftcms/feed-me" }, - "time": "2025-08-27T17:11:59+00:00" + "time": "2025-10-31T00:53:51+00:00" }, { "name": "craftcms/html-field", @@ -8666,16 +8666,16 @@ }, { "name": "league/csv", - "version": "9.26.0", + "version": "9.27.1", "source": { "type": "git", "url": "https://github.com/thephpleague/csv.git", - "reference": "7fce732754d043f3938899e5183e2d0f3d31b571" + "reference": "26de738b8fccf785397d05ee2fc07b6cd8749797" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/csv/zipball/7fce732754d043f3938899e5183e2d0f3d31b571", - "reference": "7fce732754d043f3938899e5183e2d0f3d31b571", + "url": "https://api.github.com/repos/thephpleague/csv/zipball/26de738b8fccf785397d05ee2fc07b6cd8749797", + "reference": "26de738b8fccf785397d05ee2fc07b6cd8749797", "shasum": "" }, "require": { @@ -8753,7 +8753,7 @@ "type": "github" } ], - "time": "2025-10-01T11:24:54+00:00" + "time": "2025-10-25T08:35:20+00:00" }, { "name": "league/html-to-markdown", diff --git a/src/templates/webhooks/index.twig b/src/templates/webhooks/index.twig deleted file mode 100644 index 00be7e4..0000000 --- a/src/templates/webhooks/index.twig +++ /dev/null @@ -1,59 +0,0 @@ -{# @var craft \craft\web\twig\variables\CraftVariable #} -{% extends "_layouts/cp" %} -{% set selectedSubnavItem = 'webhooks' %} - -{% set title = "Webhooks"|t('shopify') %} - -{% set navItems = {} %} - -{% block content %} - -

{{ "Webhook Management"|t('shopify') }}

- -
-

{{ "Create the webhooks for the current environment."|t('shopify') }}

-
- {{ actionInput('shopify/webhooks/create') }} - {{ redirectInput('shopify/webhooks') }} - {{ csrfInput() }} - -
-
- - {# Divs needed for the Admin Table js below #} -
-
-
- - {% set tableData = [] %} - {% for webhook in webhooks %} - {% set tableData = tableData|merge([{ - id: webhook.id, - title: webhook.topic, - callbackUrl: webhook.endpoint.callbackUrl - }]) %} - {% endfor %} - - {% js %} - var columns = [ - { name: '__slot:title', title: '{{ 'Topic'|t('shopify') }}' }, - { name: 'callbackUrl', title: '{{ 'URL'|t('shopify') }}' } - ]; - - new Craft.VueAdminTable({ - fullPane: false, - columns: columns, - container: '#webhooks-container', - deleteAction: 'shopify/webhooks/delete', - deleteConfirmationMessage: Craft.t('shopify', "Are you sure you want to delete this webhook?"), - deleteFailMessage: Craft.t('shopify', "Webhook could not be deleted"), - deleteSuccessMessage: Craft.t('shopify', "Webhook deleted"), - emptyMessage: Craft.t('shopify', 'No webhooks exist yet.'), - tableData: {{ tableData|json_encode|raw }}, - deleteCallback: function(){ - window.location.reload(); // We need to reload to get the create button showing again - } - }); - {% endjs %} -{% endblock %} - From c1d848b3b3fea94b6b0b885db3e37f984e34225c Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 27 Nov 2025 11:04:55 +0000 Subject: [PATCH 02/98] Revert "Bump shopify dep" This reverts commit 58377bb34e8403bbf87b54a60eca388beb1987ee. --- composer.json | 2 +- composer.lock | 214 +++++++++++++++--------------- src/templates/webhooks/index.twig | 59 ++++++++ 3 files changed, 167 insertions(+), 108 deletions(-) create mode 100644 src/templates/webhooks/index.twig diff --git a/composer.json b/composer.json index fcd8e26..4d77183 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ "php": "^8.2", "carnage/php-graphql-client": "^1.14", "craftcms/cms": "^5.0.0-beta.10||^4.15.0", - "shopify/shopify-api": "^6.0.0" + "shopify/shopify-api": "^5.11.0" }, "require-dev": { "craftcms/feed-me": "^6.6.1||^5.9.0", diff --git a/composer.lock b/composer.lock index deedefa..4b01964 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "fffa4c7b5d520b39f150fc69fa3aa850", + "content-hash": "24939c1fcdda7ece25f15e8e71719862", "packages": [ { "name": "bacon/bacon-qr-code", @@ -390,16 +390,16 @@ }, { "name": "craftcms/cms", - "version": "5.8.19", + "version": "5.8.18", "source": { "type": "git", "url": "https://github.com/craftcms/cms.git", - "reference": "2fdb2031cdf41875121c3f4b0094551ae95a2c38" + "reference": "ff394ccb9e70c83f16f47ea159ab2331b5e05084" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/cms/zipball/2fdb2031cdf41875121c3f4b0094551ae95a2c38", - "reference": "2fdb2031cdf41875121c3f4b0094551ae95a2c38", + "url": "https://api.github.com/repos/craftcms/cms/zipball/ff394ccb9e70c83f16f47ea159ab2331b5e05084", + "reference": "ff394ccb9e70c83f16f47ea159ab2331b5e05084", "shasum": "" }, "require": { @@ -513,7 +513,7 @@ "rss": "https://github.com/craftcms/cms/releases.atom", "source": "https://github.com/craftcms/cms" }, - "time": "2025-10-28T19:21:54+00:00" + "time": "2025-10-06T18:25:26+00:00" }, { "name": "craftcms/plugin-installer", @@ -773,16 +773,16 @@ }, { "name": "doctrine/collections", - "version": "2.4.0", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/doctrine/collections.git", - "reference": "9acfeea2e8666536edff3d77c531261c63680160" + "reference": "2eb07e5953eed811ce1b309a7478a3b236f2273d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/collections/zipball/9acfeea2e8666536edff3d77c531261c63680160", - "reference": "9acfeea2e8666536edff3d77c531261c63680160", + "url": "https://api.github.com/repos/doctrine/collections/zipball/2eb07e5953eed811ce1b309a7478a3b236f2273d", + "reference": "2eb07e5953eed811ce1b309a7478a3b236f2273d", "shasum": "" }, "require": { @@ -791,11 +791,11 @@ "symfony/polyfill-php84": "^1.30" }, "require-dev": { - "doctrine/coding-standard": "^14", + "doctrine/coding-standard": "^12", "ext-json": "*", - "phpstan/phpstan": "^2.1.30", - "phpstan/phpstan-phpunit": "^2.0.7", - "phpunit/phpunit": "^10.5.58 || ^11.5.42 || ^12.4" + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^10.5" }, "type": "library", "autoload": { @@ -839,7 +839,7 @@ ], "support": { "issues": "https://github.com/doctrine/collections/issues", - "source": "https://github.com/doctrine/collections/tree/2.4.0" + "source": "https://github.com/doctrine/collections/tree/2.3.0" }, "funding": [ { @@ -855,7 +855,7 @@ "type": "tidelift" } ], - "time": "2025-10-25T09:18:13+00:00" + "time": "2025-03-22T10:17:19+00:00" }, { "name": "doctrine/deprecations", @@ -1229,20 +1229,20 @@ }, { "name": "ezyang/htmlpurifier", - "version": "v4.19.0", + "version": "v4.18.0", "source": { "type": "git", "url": "https://github.com/ezyang/htmlpurifier.git", - "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf" + "reference": "cb56001e54359df7ae76dc522d08845dc741621b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf", - "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/cb56001e54359df7ae76dc522d08845dc741621b", + "reference": "cb56001e54359df7ae76dc522d08845dc741621b", "shasum": "" }, "require": { - "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" }, "require-dev": { "cerdic/css-tidy": "^1.7 || ^2.0", @@ -1284,9 +1284,9 @@ ], "support": { "issues": "https://github.com/ezyang/htmlpurifier/issues", - "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0" + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.18.0" }, - "time": "2025-10-17T16:34:55+00:00" + "time": "2024-11-01T03:51:45+00:00" }, { "name": "firebase/php-jwt", @@ -2224,23 +2224,23 @@ }, { "name": "moneyphp/money", - "version": "v4.8.0", + "version": "v4.7.1", "source": { "type": "git", "url": "https://github.com/moneyphp/money.git", - "reference": "b358727ea5a5cd2d7475e59c31dfc352440ae7ec" + "reference": "1a23f0e1b22e2c59ed5ed70cfbe4cbe696be9348" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/moneyphp/money/zipball/b358727ea5a5cd2d7475e59c31dfc352440ae7ec", - "reference": "b358727ea5a5cd2d7475e59c31dfc352440ae7ec", + "url": "https://api.github.com/repos/moneyphp/money/zipball/1a23f0e1b22e2c59ed5ed70cfbe4cbe696be9348", + "reference": "1a23f0e1b22e2c59ed5ed70cfbe4cbe696be9348", "shasum": "" }, "require": { "ext-bcmath": "*", "ext-filter": "*", "ext-json": "*", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" }, "require-dev": { "cache/taggable-cache": "^1.1.0", @@ -2308,9 +2308,9 @@ ], "support": { "issues": "https://github.com/moneyphp/money/issues", - "source": "https://github.com/moneyphp/money/tree/v4.8.0" + "source": "https://github.com/moneyphp/money/tree/v4.7.1" }, - "time": "2025-10-23T07:55:09+00:00" + "time": "2025-06-06T07:12:38+00:00" }, { "name": "monolog/monolog", @@ -3733,16 +3733,16 @@ }, { "name": "shopify/shopify-api", - "version": "v6.0.0", + "version": "v5.11.0", "source": { "type": "git", "url": "https://github.com/Shopify/shopify-api-php.git", - "reference": "f4d177e8ce062aa302aef10e29bc274ac26ccd19" + "reference": "ed1c9cd01b68a0beb89ad770123ebc926bb7a98c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Shopify/shopify-api-php/zipball/f4d177e8ce062aa302aef10e29bc274ac26ccd19", - "reference": "f4d177e8ce062aa302aef10e29bc274ac26ccd19", + "url": "https://api.github.com/repos/Shopify/shopify-api-php/zipball/ed1c9cd01b68a0beb89ad770123ebc926bb7a98c", + "reference": "ed1c9cd01b68a0beb89ad770123ebc926bb7a98c", "shasum": "" }, "require": { @@ -3801,9 +3801,9 @@ ], "support": { "issues": "https://github.com/Shopify/shopify-api-php/issues", - "source": "https://github.com/Shopify/shopify-api-php/tree/v6.0.0" + "source": "https://github.com/Shopify/shopify-api-php/tree/v5.11.0" }, - "time": "2025-10-28T19:19:42+00:00" + "time": "2025-07-11T14:07:16+00:00" }, { "name": "spomky-labs/cbor-php", @@ -3890,20 +3890,20 @@ }, { "name": "spomky-labs/pki-framework", - "version": "1.4.0", + "version": "1.3.0", "source": { "type": "git", "url": "https://github.com/Spomky-Labs/pki-framework.git", - "reference": "bf6f55a9d9eb25b7781640221cb54f5c727850d7" + "reference": "eced5b5ce70518b983ff2be486e902bbd15135ae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/bf6f55a9d9eb25b7781640221cb54f5c727850d7", - "reference": "bf6f55a9d9eb25b7781640221cb54f5c727850d7", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/eced5b5ce70518b983ff2be486e902bbd15135ae", + "reference": "eced5b5ce70518b983ff2be486e902bbd15135ae", "shasum": "" }, "require": { - "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14", + "brick/math": "^0.10|^0.11|^0.12|^0.13", "ext-mbstring": "*", "php": ">=8.1" }, @@ -3911,7 +3911,7 @@ "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", "ext-gmp": "*", "ext-openssl": "*", - "infection/infection": "^0.28|^0.29|^0.31", + "infection/infection": "^0.28|^0.29", "php-parallel-lint/php-parallel-lint": "^1.3", "phpstan/extension-installer": "^1.3|^2.0", "phpstan/phpstan": "^1.8|^2.0", @@ -3921,8 +3921,8 @@ "phpunit/phpunit": "^10.1|^11.0|^12.0", "rector/rector": "^1.0|^2.0", "roave/security-advisories": "dev-latest", - "symfony/string": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/string": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0", "symplify/easy-coding-standard": "^12.0" }, "suggest": { @@ -3983,7 +3983,7 @@ ], "support": { "issues": "https://github.com/Spomky-Labs/pki-framework/issues", - "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.0" + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.3.0" }, "funding": [ { @@ -3995,7 +3995,7 @@ "type": "patreon" } ], - "time": "2025-10-22T08:24:34+00:00" + "time": "2025-06-13T08:35:04+00:00" }, { "name": "symfony/css-selector", @@ -4610,16 +4610,16 @@ }, { "name": "symfony/mailer", - "version": "v7.3.5", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "fd497c45ba9c10c37864e19466b090dcb60a50ba" + "reference": "ab97ef2f7acf0216955f5845484235113047a31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/fd497c45ba9c10c37864e19466b090dcb60a50ba", - "reference": "fd497c45ba9c10c37864e19466b090dcb60a50ba", + "url": "https://api.github.com/repos/symfony/mailer/zipball/ab97ef2f7acf0216955f5845484235113047a31d", + "reference": "ab97ef2f7acf0216955f5845484235113047a31d", "shasum": "" }, "require": { @@ -4670,7 +4670,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.3.5" + "source": "https://github.com/symfony/mailer/tree/v7.3.4" }, "funding": [ { @@ -4690,7 +4690,7 @@ "type": "tidelift" } ], - "time": "2025-10-24T14:27:20+00:00" + "time": "2025-09-17T05:51:54+00:00" }, { "name": "symfony/mime", @@ -5821,23 +5821,23 @@ }, { "name": "symfony/property-info", - "version": "v7.3.5", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "0b346ed259dc5da43535caf243005fe7d4b0f051" + "reference": "7b6db23f23d13ada41e1cb484748a8ec028fbace" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/0b346ed259dc5da43535caf243005fe7d4b0f051", - "reference": "0b346ed259dc5da43535caf243005fe7d4b0f051", + "url": "https://api.github.com/repos/symfony/property-info/zipball/7b6db23f23d13ada41e1cb484748a8ec028fbace", + "reference": "7b6db23f23d13ada41e1cb484748a8ec028fbace", "shasum": "" }, "require": { "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/string": "^6.4|^7.0", - "symfony/type-info": "^7.3.5" + "symfony/type-info": "~7.2.8|^7.3.1" }, "conflict": { "phpdocumentor/reflection-docblock": "<5.2", @@ -5887,7 +5887,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v7.3.5" + "source": "https://github.com/symfony/property-info/tree/v7.3.4" }, "funding": [ { @@ -5907,20 +5907,20 @@ "type": "tidelift" } ], - "time": "2025-10-05T22:12:41+00:00" + "time": "2025-09-15T13:55:54+00:00" }, { "name": "symfony/serializer", - "version": "v6.4.27", + "version": "v6.4.26", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "28779bbdb398cac3421d0e51f7ca669e4a27c5ac" + "reference": "48d0477483614d615aa1d5e5d90a45e4c7bfa2c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/28779bbdb398cac3421d0e51f7ca669e4a27c5ac", - "reference": "28779bbdb398cac3421d0e51f7ca669e4a27c5ac", + "url": "https://api.github.com/repos/symfony/serializer/zipball/48d0477483614d615aa1d5e5d90a45e4c7bfa2c9", + "reference": "48d0477483614d615aa1d5e5d90a45e4c7bfa2c9", "shasum": "" }, "require": { @@ -5989,7 +5989,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v6.4.27" + "source": "https://github.com/symfony/serializer/tree/v6.4.26" }, "funding": [ { @@ -6009,7 +6009,7 @@ "type": "tidelift" } ], - "time": "2025-10-08T04:24:22+00:00" + "time": "2025-09-15T13:37:27+00:00" }, { "name": "symfony/service-contracts", @@ -6186,16 +6186,16 @@ }, { "name": "symfony/type-info", - "version": "v7.3.5", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "8b36f41421160db56914f897b57eaa6a830758b3" + "reference": "d34eaeb57f39c8a9c97eb72a977c423207dfa35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/8b36f41421160db56914f897b57eaa6a830758b3", - "reference": "8b36f41421160db56914f897b57eaa6a830758b3", + "url": "https://api.github.com/repos/symfony/type-info/zipball/d34eaeb57f39c8a9c97eb72a977c423207dfa35b", + "reference": "d34eaeb57f39c8a9c97eb72a977c423207dfa35b", "shasum": "" }, "require": { @@ -6245,7 +6245,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v7.3.5" + "source": "https://github.com/symfony/type-info/tree/v7.3.4" }, "funding": [ { @@ -6265,7 +6265,7 @@ "type": "tidelift" } ], - "time": "2025-10-16T12:30:12+00:00" + "time": "2025-09-11T15:33:27+00:00" }, { "name": "symfony/uid", @@ -6343,16 +6343,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.3.5", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "476c4ae17f43a9a36650c69879dcf5b1e6ae724d" + "reference": "b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/476c4ae17f43a9a36650c69879dcf5b1e6ae724d", - "reference": "476c4ae17f43a9a36650c69879dcf5b1e6ae724d", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb", + "reference": "b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb", "shasum": "" }, "require": { @@ -6406,7 +6406,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.3.5" + "source": "https://github.com/symfony/var-dumper/tree/v7.3.4" }, "funding": [ { @@ -6426,20 +6426,20 @@ "type": "tidelift" } ], - "time": "2025-09-27T09:00:46+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "symfony/yaml", - "version": "v7.3.5", + "version": "v7.3.3", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "90208e2fc6f68f613eae7ca25a2458a931b1bacc" + "reference": "d4f4a66866fe2451f61296924767280ab5732d9d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/90208e2fc6f68f613eae7ca25a2458a931b1bacc", - "reference": "90208e2fc6f68f613eae7ca25a2458a931b1bacc", + "url": "https://api.github.com/repos/symfony/yaml/zipball/d4f4a66866fe2451f61296924767280ab5732d9d", + "reference": "d4f4a66866fe2451f61296924767280ab5732d9d", "shasum": "" }, "require": { @@ -6482,7 +6482,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.3.5" + "source": "https://github.com/symfony/yaml/tree/v7.3.3" }, "funding": [ { @@ -6502,7 +6502,7 @@ "type": "tidelift" } ], - "time": "2025-09-27T09:00:46+00:00" + "time": "2025-08-27T11:34:33+00:00" }, { "name": "theiconic/name-parser", @@ -7448,28 +7448,28 @@ }, { "name": "webmozart/assert", - "version": "1.12.1", + "version": "1.11.0", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" + "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991", + "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991", "shasum": "" }, "require": { "ext-ctype": "*", - "ext-date": "*", - "ext-filter": "*", "php": "^7.2 || ^8.0" }, - "suggest": { - "ext-intl": "", - "ext-simplexml": "", - "ext-spl": "" + "conflict": { + "phpstan/phpstan": "<0.12.20", + "vimeo/psalm": "<4.6.1 || 4.6.2" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.13" }, "type": "library", "extra": { @@ -7500,9 +7500,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.12.1" + "source": "https://github.com/webmozarts/assert/tree/1.11.0" }, - "time": "2025-10-29T15:56:20+00:00" + "time": "2022-06-03T18:03:27+00:00" }, { "name": "webonyx/graphql-php", @@ -8063,7 +8063,7 @@ "packages-dev": [ { "name": "cakephp/core", - "version": "5.2.9", + "version": "5.2.8", "source": { "type": "git", "url": "https://github.com/cakephp/core.git", @@ -8130,7 +8130,7 @@ }, { "name": "cakephp/utility", - "version": "5.2.9", + "version": "5.2.8", "source": { "type": "git", "url": "https://github.com/cakephp/utility.git", @@ -8296,16 +8296,16 @@ }, { "name": "craftcms/feed-me", - "version": "6.11.0", + "version": "6.10.1", "source": { "type": "git", "url": "https://github.com/craftcms/feed-me.git", - "reference": "46cbf5aeb3c6ddabb9ab248c0e6742444533f1bb" + "reference": "8818ee101baf758225884828b7d11688f373b4d8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/feed-me/zipball/46cbf5aeb3c6ddabb9ab248c0e6742444533f1bb", - "reference": "46cbf5aeb3c6ddabb9ab248c0e6742444533f1bb", + "url": "https://api.github.com/repos/craftcms/feed-me/zipball/8818ee101baf758225884828b7d11688f373b4d8", + "reference": "8818ee101baf758225884828b7d11688f373b4d8", "shasum": "" }, "require": { @@ -8362,7 +8362,7 @@ "rss": "https://github.com/craftcms/feed-me/commits/master.atom", "source": "https://github.com/craftcms/feed-me" }, - "time": "2025-10-31T00:53:51+00:00" + "time": "2025-08-27T17:11:59+00:00" }, { "name": "craftcms/html-field", @@ -8666,16 +8666,16 @@ }, { "name": "league/csv", - "version": "9.27.1", + "version": "9.26.0", "source": { "type": "git", "url": "https://github.com/thephpleague/csv.git", - "reference": "26de738b8fccf785397d05ee2fc07b6cd8749797" + "reference": "7fce732754d043f3938899e5183e2d0f3d31b571" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/csv/zipball/26de738b8fccf785397d05ee2fc07b6cd8749797", - "reference": "26de738b8fccf785397d05ee2fc07b6cd8749797", + "url": "https://api.github.com/repos/thephpleague/csv/zipball/7fce732754d043f3938899e5183e2d0f3d31b571", + "reference": "7fce732754d043f3938899e5183e2d0f3d31b571", "shasum": "" }, "require": { @@ -8753,7 +8753,7 @@ "type": "github" } ], - "time": "2025-10-25T08:35:20+00:00" + "time": "2025-10-01T11:24:54+00:00" }, { "name": "league/html-to-markdown", diff --git a/src/templates/webhooks/index.twig b/src/templates/webhooks/index.twig new file mode 100644 index 0000000..00be7e4 --- /dev/null +++ b/src/templates/webhooks/index.twig @@ -0,0 +1,59 @@ +{# @var craft \craft\web\twig\variables\CraftVariable #} +{% extends "_layouts/cp" %} +{% set selectedSubnavItem = 'webhooks' %} + +{% set title = "Webhooks"|t('shopify') %} + +{% set navItems = {} %} + +{% block content %} + +

{{ "Webhook Management"|t('shopify') }}

+ +
+

{{ "Create the webhooks for the current environment."|t('shopify') }}

+
+ {{ actionInput('shopify/webhooks/create') }} + {{ redirectInput('shopify/webhooks') }} + {{ csrfInput() }} + +
+
+ + {# Divs needed for the Admin Table js below #} +
+
+
+ + {% set tableData = [] %} + {% for webhook in webhooks %} + {% set tableData = tableData|merge([{ + id: webhook.id, + title: webhook.topic, + callbackUrl: webhook.endpoint.callbackUrl + }]) %} + {% endfor %} + + {% js %} + var columns = [ + { name: '__slot:title', title: '{{ 'Topic'|t('shopify') }}' }, + { name: 'callbackUrl', title: '{{ 'URL'|t('shopify') }}' } + ]; + + new Craft.VueAdminTable({ + fullPane: false, + columns: columns, + container: '#webhooks-container', + deleteAction: 'shopify/webhooks/delete', + deleteConfirmationMessage: Craft.t('shopify', "Are you sure you want to delete this webhook?"), + deleteFailMessage: Craft.t('shopify', "Webhook could not be deleted"), + deleteSuccessMessage: Craft.t('shopify', "Webhook deleted"), + emptyMessage: Craft.t('shopify', 'No webhooks exist yet.'), + tableData: {{ tableData|json_encode|raw }}, + deleteCallback: function(){ + window.location.reload(); // We need to reload to get the create button showing again + } + }); + {% endjs %} +{% endblock %} + From e3edd7a1ee0b681c8a25029f66a1af777714b33e Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 27 Nov 2025 11:06:11 +0000 Subject: [PATCH 03/98] Bump shopify deps --- composer.json | 2 +- composer.lock | 368 ++++++++++++++++++++++++++------------------------ 2 files changed, 189 insertions(+), 181 deletions(-) diff --git a/composer.json b/composer.json index 4d77183..fcd8e26 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ "php": "^8.2", "carnage/php-graphql-client": "^1.14", "craftcms/cms": "^5.0.0-beta.10||^4.15.0", - "shopify/shopify-api": "^5.11.0" + "shopify/shopify-api": "^6.0.0" }, "require-dev": { "craftcms/feed-me": "^6.6.1||^5.9.0", diff --git a/composer.lock b/composer.lock index 4b01964..7433963 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "24939c1fcdda7ece25f15e8e71719862", + "content-hash": "fffa4c7b5d520b39f150fc69fa3aa850", "packages": [ { "name": "bacon/bacon-qr-code", @@ -390,16 +390,16 @@ }, { "name": "craftcms/cms", - "version": "5.8.18", + "version": "5.8.20", "source": { "type": "git", "url": "https://github.com/craftcms/cms.git", - "reference": "ff394ccb9e70c83f16f47ea159ab2331b5e05084" + "reference": "cec2d4fa0d9d158df458ea7222e87a1d33fe60bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/cms/zipball/ff394ccb9e70c83f16f47ea159ab2331b5e05084", - "reference": "ff394ccb9e70c83f16f47ea159ab2331b5e05084", + "url": "https://api.github.com/repos/craftcms/cms/zipball/cec2d4fa0d9d158df458ea7222e87a1d33fe60bb", + "reference": "cec2d4fa0d9d158df458ea7222e87a1d33fe60bb", "shasum": "" }, "require": { @@ -513,7 +513,7 @@ "rss": "https://github.com/craftcms/cms/releases.atom", "source": "https://github.com/craftcms/cms" }, - "time": "2025-10-06T18:25:26+00:00" + "time": "2025-11-18T16:45:49+00:00" }, { "name": "craftcms/plugin-installer", @@ -773,16 +773,16 @@ }, { "name": "doctrine/collections", - "version": "2.3.0", + "version": "2.4.0", "source": { "type": "git", "url": "https://github.com/doctrine/collections.git", - "reference": "2eb07e5953eed811ce1b309a7478a3b236f2273d" + "reference": "9acfeea2e8666536edff3d77c531261c63680160" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/collections/zipball/2eb07e5953eed811ce1b309a7478a3b236f2273d", - "reference": "2eb07e5953eed811ce1b309a7478a3b236f2273d", + "url": "https://api.github.com/repos/doctrine/collections/zipball/9acfeea2e8666536edff3d77c531261c63680160", + "reference": "9acfeea2e8666536edff3d77c531261c63680160", "shasum": "" }, "require": { @@ -791,11 +791,11 @@ "symfony/polyfill-php84": "^1.30" }, "require-dev": { - "doctrine/coding-standard": "^12", + "doctrine/coding-standard": "^14", "ext-json": "*", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.0", - "phpunit/phpunit": "^10.5" + "phpstan/phpstan": "^2.1.30", + "phpstan/phpstan-phpunit": "^2.0.7", + "phpunit/phpunit": "^10.5.58 || ^11.5.42 || ^12.4" }, "type": "library", "autoload": { @@ -839,7 +839,7 @@ ], "support": { "issues": "https://github.com/doctrine/collections/issues", - "source": "https://github.com/doctrine/collections/tree/2.3.0" + "source": "https://github.com/doctrine/collections/tree/2.4.0" }, "funding": [ { @@ -855,7 +855,7 @@ "type": "tidelift" } ], - "time": "2025-03-22T10:17:19+00:00" + "time": "2025-10-25T09:18:13+00:00" }, { "name": "doctrine/deprecations", @@ -1229,20 +1229,20 @@ }, { "name": "ezyang/htmlpurifier", - "version": "v4.18.0", + "version": "v4.19.0", "source": { "type": "git", "url": "https://github.com/ezyang/htmlpurifier.git", - "reference": "cb56001e54359df7ae76dc522d08845dc741621b" + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/cb56001e54359df7ae76dc522d08845dc741621b", - "reference": "cb56001e54359df7ae76dc522d08845dc741621b", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf", "shasum": "" }, "require": { - "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" + "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" }, "require-dev": { "cerdic/css-tidy": "^1.7 || ^2.0", @@ -1284,9 +1284,9 @@ ], "support": { "issues": "https://github.com/ezyang/htmlpurifier/issues", - "source": "https://github.com/ezyang/htmlpurifier/tree/v4.18.0" + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0" }, - "time": "2024-11-01T03:51:45+00:00" + "time": "2025-10-17T16:34:55+00:00" }, { "name": "firebase/php-jwt", @@ -1937,33 +1937,38 @@ }, { "name": "league/uri", - "version": "7.5.1", + "version": "7.6.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "81fb5145d2644324614cc532b28efd0215bda430" + "reference": "f625804987a0a9112d954f9209d91fec52182344" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/81fb5145d2644324614cc532b28efd0215bda430", - "reference": "81fb5145d2644324614cc532b28efd0215bda430", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/f625804987a0a9112d954f9209d91fec52182344", + "reference": "f625804987a0a9112d954f9209d91fec52182344", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.5", - "php": "^8.1" + "league/uri-interfaces": "^7.6", + "php": "^8.1", + "psr/http-factory": "^1" }, "conflict": { "league/uri-schemes": "^1.0" }, "suggest": { "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", "ext-fileinfo": "to create Data URI from file contennts", "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", "league/uri-components": "Needed to easily manipulate URI objects components", + "league/uri-polyfill": "Needed to backport the PHP URI extension for older versions of PHP", "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle WHATWG URL", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -1991,6 +1996,7 @@ "description": "URI manipulation library", "homepage": "https://uri.thephpleague.com", "keywords": [ + "URN", "data-uri", "file-uri", "ftp", @@ -2003,9 +2009,11 @@ "psr-7", "query-string", "querystring", + "rfc2141", "rfc3986", "rfc3987", "rfc6570", + "rfc8141", "uri", "uri-template", "url", @@ -2015,7 +2023,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.5.1" + "source": "https://github.com/thephpleague/uri/tree/7.6.0" }, "funding": [ { @@ -2023,26 +2031,25 @@ "type": "github" } ], - "time": "2024-12-08T08:40:02+00:00" + "time": "2025-11-18T12:17:23+00:00" }, { "name": "league/uri-interfaces", - "version": "7.5.0", + "version": "7.6.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742" + "reference": "ccbfb51c0445298e7e0b7f4481b942f589665368" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", - "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/ccbfb51c0445298e7e0b7f4481b942f589665368", + "reference": "ccbfb51c0445298e7e0b7f4481b942f589665368", "shasum": "" }, "require": { "ext-filter": "*", "php": "^8.1", - "psr/http-factory": "^1", "psr/http-message": "^1.1 || ^2.0" }, "suggest": { @@ -2050,6 +2057,7 @@ "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle WHATWG URL", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -2074,7 +2082,7 @@ "homepage": "https://nyamsprod.com" } ], - "description": "Common interfaces and classes for URI representation and interaction", + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", "homepage": "https://uri.thephpleague.com", "keywords": [ "data-uri", @@ -2099,7 +2107,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.5.0" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.6.0" }, "funding": [ { @@ -2107,7 +2115,7 @@ "type": "github" } ], - "time": "2024-12-08T08:18:47+00:00" + "time": "2025-11-18T12:17:23+00:00" }, { "name": "masterminds/html5", @@ -2224,23 +2232,23 @@ }, { "name": "moneyphp/money", - "version": "v4.7.1", + "version": "v4.8.0", "source": { "type": "git", "url": "https://github.com/moneyphp/money.git", - "reference": "1a23f0e1b22e2c59ed5ed70cfbe4cbe696be9348" + "reference": "b358727ea5a5cd2d7475e59c31dfc352440ae7ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/moneyphp/money/zipball/1a23f0e1b22e2c59ed5ed70cfbe4cbe696be9348", - "reference": "1a23f0e1b22e2c59ed5ed70cfbe4cbe696be9348", + "url": "https://api.github.com/repos/moneyphp/money/zipball/b358727ea5a5cd2d7475e59c31dfc352440ae7ec", + "reference": "b358727ea5a5cd2d7475e59c31dfc352440ae7ec", "shasum": "" }, "require": { "ext-bcmath": "*", "ext-filter": "*", "ext-json": "*", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" }, "require-dev": { "cache/taggable-cache": "^1.1.0", @@ -2308,9 +2316,9 @@ ], "support": { "issues": "https://github.com/moneyphp/money/issues", - "source": "https://github.com/moneyphp/money/tree/v4.7.1" + "source": "https://github.com/moneyphp/money/tree/v4.8.0" }, - "time": "2025-06-06T07:12:38+00:00" + "time": "2025-10-23T07:55:09+00:00" }, { "name": "monolog/monolog", @@ -2589,16 +2597,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.3", + "version": "5.6.4", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "94f8051919d1b0369a6bcc7931d679a511c03fe9" + "reference": "90a04bcbf03784066f16038e87e23a0a83cee3c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/94f8051919d1b0369a6bcc7931d679a511c03fe9", - "reference": "94f8051919d1b0369a6bcc7931d679a511c03fe9", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/90a04bcbf03784066f16038e87e23a0a83cee3c2", + "reference": "90a04bcbf03784066f16038e87e23a0a83cee3c2", "shasum": "" }, "require": { @@ -2647,22 +2655,22 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.3" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.4" }, - "time": "2025-08-01T19:43:32+00:00" + "time": "2025-11-17T21:13:10+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "1.10.0", + "version": "1.12.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a" + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/679e3ce485b99e84c775d28e2e96fade9a7fb50a", - "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", "shasum": "" }, "require": { @@ -2705,9 +2713,9 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.10.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" }, - "time": "2024-11-09T15:12:26+00:00" + "time": "2025-11-21T15:09:14+00:00" }, { "name": "phpstan/phpdoc-parser", @@ -3733,16 +3741,16 @@ }, { "name": "shopify/shopify-api", - "version": "v5.11.0", + "version": "v6.0.0", "source": { "type": "git", "url": "https://github.com/Shopify/shopify-api-php.git", - "reference": "ed1c9cd01b68a0beb89ad770123ebc926bb7a98c" + "reference": "f4d177e8ce062aa302aef10e29bc274ac26ccd19" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Shopify/shopify-api-php/zipball/ed1c9cd01b68a0beb89ad770123ebc926bb7a98c", - "reference": "ed1c9cd01b68a0beb89ad770123ebc926bb7a98c", + "url": "https://api.github.com/repos/Shopify/shopify-api-php/zipball/f4d177e8ce062aa302aef10e29bc274ac26ccd19", + "reference": "f4d177e8ce062aa302aef10e29bc274ac26ccd19", "shasum": "" }, "require": { @@ -3801,46 +3809,34 @@ ], "support": { "issues": "https://github.com/Shopify/shopify-api-php/issues", - "source": "https://github.com/Shopify/shopify-api-php/tree/v5.11.0" + "source": "https://github.com/Shopify/shopify-api-php/tree/v6.0.0" }, - "time": "2025-07-11T14:07:16+00:00" + "time": "2025-10-28T19:19:42+00:00" }, { "name": "spomky-labs/cbor-php", - "version": "3.1.1", + "version": "3.2.2", "source": { "type": "git", "url": "https://github.com/Spomky-Labs/cbor-php.git", - "reference": "5404f3e21cbe72f5cf612aa23db2b922fd2f43bf" + "reference": "2a5fb86aacfe1004611370ead6caa2bfc88435d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/5404f3e21cbe72f5cf612aa23db2b922fd2f43bf", - "reference": "5404f3e21cbe72f5cf612aa23db2b922fd2f43bf", + "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/2a5fb86aacfe1004611370ead6caa2bfc88435d0", + "reference": "2a5fb86aacfe1004611370ead6caa2bfc88435d0", "shasum": "" }, "require": { - "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13", + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14", "ext-mbstring": "*", "php": ">=8.0" }, "require-dev": { - "deptrac/deptrac": "^3.0", - "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", "ext-json": "*", - "infection/infection": "^0.29", - "php-parallel-lint/php-parallel-lint": "^1.3", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.0|^2.0", - "phpstan/phpstan-beberlei-assert": "^1.0|^2.0", - "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", - "phpstan/phpstan-phpunit": "^1.0|^2.0", - "phpstan/phpstan-strict-rules": "^1.0|^2.0", - "phpunit/phpunit": "^10.1|^11.0|^12.0", - "rector/rector": "^1.0|^2.0", "roave/security-advisories": "dev-latest", - "symfony/var-dumper": "^6.0|^7.0", - "symplify/easy-coding-standard": "^12.0" + "symfony/error-handler": "^6.4|^7.1|^8.0", + "symfony/var-dumper": "^6.4|^7.1|^8.0" }, "suggest": { "ext-bcmath": "GMP or BCMath extensions will drastically improve the library performance. BCMath extension needed to handle the Big Float and Decimal Fraction Tags", @@ -3874,7 +3870,7 @@ ], "support": { "issues": "https://github.com/Spomky-Labs/cbor-php/issues", - "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.1.1" + "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.2.2" }, "funding": [ { @@ -3886,24 +3882,24 @@ "type": "patreon" } ], - "time": "2025-06-13T11:57:55+00:00" + "time": "2025-11-13T13:00:34+00:00" }, { "name": "spomky-labs/pki-framework", - "version": "1.3.0", + "version": "1.4.0", "source": { "type": "git", "url": "https://github.com/Spomky-Labs/pki-framework.git", - "reference": "eced5b5ce70518b983ff2be486e902bbd15135ae" + "reference": "bf6f55a9d9eb25b7781640221cb54f5c727850d7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/eced5b5ce70518b983ff2be486e902bbd15135ae", - "reference": "eced5b5ce70518b983ff2be486e902bbd15135ae", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/bf6f55a9d9eb25b7781640221cb54f5c727850d7", + "reference": "bf6f55a9d9eb25b7781640221cb54f5c727850d7", "shasum": "" }, "require": { - "brick/math": "^0.10|^0.11|^0.12|^0.13", + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14", "ext-mbstring": "*", "php": ">=8.1" }, @@ -3911,7 +3907,7 @@ "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", "ext-gmp": "*", "ext-openssl": "*", - "infection/infection": "^0.28|^0.29", + "infection/infection": "^0.28|^0.29|^0.31", "php-parallel-lint/php-parallel-lint": "^1.3", "phpstan/extension-installer": "^1.3|^2.0", "phpstan/phpstan": "^1.8|^2.0", @@ -3921,8 +3917,8 @@ "phpunit/phpunit": "^10.1|^11.0|^12.0", "rector/rector": "^1.0|^2.0", "roave/security-advisories": "dev-latest", - "symfony/string": "^6.4|^7.0", - "symfony/var-dumper": "^6.4|^7.0", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", "symplify/easy-coding-standard": "^12.0" }, "suggest": { @@ -3983,7 +3979,7 @@ ], "support": { "issues": "https://github.com/Spomky-Labs/pki-framework/issues", - "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.3.0" + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.0" }, "funding": [ { @@ -3995,20 +3991,20 @@ "type": "patreon" } ], - "time": "2025-06-13T08:35:04+00:00" + "time": "2025-10-22T08:24:34+00:00" }, { "name": "symfony/css-selector", - "version": "v7.3.0", + "version": "v7.3.6", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2" + "reference": "84321188c4754e64273b46b406081ad9b18e8614" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/601a5ce9aaad7bf10797e3663faefce9e26c24e2", - "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/84321188c4754e64273b46b406081ad9b18e8614", + "reference": "84321188c4754e64273b46b406081ad9b18e8614", "shasum": "" }, "require": { @@ -4044,7 +4040,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.3.0" + "source": "https://github.com/symfony/css-selector/tree/v7.3.6" }, "funding": [ { @@ -4055,12 +4051,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2025-10-29T17:24:25+00:00" }, { "name": "symfony/deprecation-contracts", @@ -4432,16 +4432,16 @@ }, { "name": "symfony/http-client", - "version": "v7.3.4", + "version": "v7.3.6", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "4b62871a01c49457cf2a8e560af7ee8a94b87a62" + "reference": "3c0a55a2c8e21e30a37022801c11c7ab5a6cb2de" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/4b62871a01c49457cf2a8e560af7ee8a94b87a62", - "reference": "4b62871a01c49457cf2a8e560af7ee8a94b87a62", + "url": "https://api.github.com/repos/symfony/http-client/zipball/3c0a55a2c8e21e30a37022801c11c7ab5a6cb2de", + "reference": "3c0a55a2c8e21e30a37022801c11c7ab5a6cb2de", "shasum": "" }, "require": { @@ -4508,7 +4508,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.3.4" + "source": "https://github.com/symfony/http-client/tree/v7.3.6" }, "funding": [ { @@ -4528,7 +4528,7 @@ "type": "tidelift" } ], - "time": "2025-09-11T10:12:26+00:00" + "time": "2025-11-05T17:41:46+00:00" }, { "name": "symfony/http-client-contracts", @@ -4610,16 +4610,16 @@ }, { "name": "symfony/mailer", - "version": "v7.3.4", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "ab97ef2f7acf0216955f5845484235113047a31d" + "reference": "fd497c45ba9c10c37864e19466b090dcb60a50ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/ab97ef2f7acf0216955f5845484235113047a31d", - "reference": "ab97ef2f7acf0216955f5845484235113047a31d", + "url": "https://api.github.com/repos/symfony/mailer/zipball/fd497c45ba9c10c37864e19466b090dcb60a50ba", + "reference": "fd497c45ba9c10c37864e19466b090dcb60a50ba", "shasum": "" }, "require": { @@ -4670,7 +4670,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.3.4" + "source": "https://github.com/symfony/mailer/tree/v7.3.5" }, "funding": [ { @@ -4690,7 +4690,7 @@ "type": "tidelift" } ], - "time": "2025-09-17T05:51:54+00:00" + "time": "2025-10-24T14:27:20+00:00" }, { "name": "symfony/mime", @@ -5821,23 +5821,23 @@ }, { "name": "symfony/property-info", - "version": "v7.3.4", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "7b6db23f23d13ada41e1cb484748a8ec028fbace" + "reference": "0b346ed259dc5da43535caf243005fe7d4b0f051" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/7b6db23f23d13ada41e1cb484748a8ec028fbace", - "reference": "7b6db23f23d13ada41e1cb484748a8ec028fbace", + "url": "https://api.github.com/repos/symfony/property-info/zipball/0b346ed259dc5da43535caf243005fe7d4b0f051", + "reference": "0b346ed259dc5da43535caf243005fe7d4b0f051", "shasum": "" }, "require": { "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/string": "^6.4|^7.0", - "symfony/type-info": "~7.2.8|^7.3.1" + "symfony/type-info": "^7.3.5" }, "conflict": { "phpdocumentor/reflection-docblock": "<5.2", @@ -5887,7 +5887,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v7.3.4" + "source": "https://github.com/symfony/property-info/tree/v7.3.5" }, "funding": [ { @@ -5907,20 +5907,20 @@ "type": "tidelift" } ], - "time": "2025-09-15T13:55:54+00:00" + "time": "2025-10-05T22:12:41+00:00" }, { "name": "symfony/serializer", - "version": "v6.4.26", + "version": "v6.4.27", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "48d0477483614d615aa1d5e5d90a45e4c7bfa2c9" + "reference": "28779bbdb398cac3421d0e51f7ca669e4a27c5ac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/48d0477483614d615aa1d5e5d90a45e4c7bfa2c9", - "reference": "48d0477483614d615aa1d5e5d90a45e4c7bfa2c9", + "url": "https://api.github.com/repos/symfony/serializer/zipball/28779bbdb398cac3421d0e51f7ca669e4a27c5ac", + "reference": "28779bbdb398cac3421d0e51f7ca669e4a27c5ac", "shasum": "" }, "require": { @@ -5989,7 +5989,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v6.4.26" + "source": "https://github.com/symfony/serializer/tree/v6.4.27" }, "funding": [ { @@ -6009,20 +6009,20 @@ "type": "tidelift" } ], - "time": "2025-09-15T13:37:27+00:00" + "time": "2025-10-08T04:24:22+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.6.0", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", "shasum": "" }, "require": { @@ -6076,7 +6076,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" }, "funding": [ { @@ -6087,12 +6087,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-25T09:37:31+00:00" + "time": "2025-07-15T11:30:57+00:00" }, { "name": "symfony/string", @@ -6186,16 +6190,16 @@ }, { "name": "symfony/type-info", - "version": "v7.3.4", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "d34eaeb57f39c8a9c97eb72a977c423207dfa35b" + "reference": "8b36f41421160db56914f897b57eaa6a830758b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/d34eaeb57f39c8a9c97eb72a977c423207dfa35b", - "reference": "d34eaeb57f39c8a9c97eb72a977c423207dfa35b", + "url": "https://api.github.com/repos/symfony/type-info/zipball/8b36f41421160db56914f897b57eaa6a830758b3", + "reference": "8b36f41421160db56914f897b57eaa6a830758b3", "shasum": "" }, "require": { @@ -6245,7 +6249,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v7.3.4" + "source": "https://github.com/symfony/type-info/tree/v7.3.5" }, "funding": [ { @@ -6265,7 +6269,7 @@ "type": "tidelift" } ], - "time": "2025-09-11T15:33:27+00:00" + "time": "2025-10-16T12:30:12+00:00" }, { "name": "symfony/uid", @@ -6343,16 +6347,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.3.4", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb" + "reference": "476c4ae17f43a9a36650c69879dcf5b1e6ae724d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb", - "reference": "b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/476c4ae17f43a9a36650c69879dcf5b1e6ae724d", + "reference": "476c4ae17f43a9a36650c69879dcf5b1e6ae724d", "shasum": "" }, "require": { @@ -6406,7 +6410,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.3.4" + "source": "https://github.com/symfony/var-dumper/tree/v7.3.5" }, "funding": [ { @@ -6426,20 +6430,20 @@ "type": "tidelift" } ], - "time": "2025-09-11T10:12:26+00:00" + "time": "2025-09-27T09:00:46+00:00" }, { "name": "symfony/yaml", - "version": "v7.3.3", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "d4f4a66866fe2451f61296924767280ab5732d9d" + "reference": "90208e2fc6f68f613eae7ca25a2458a931b1bacc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/d4f4a66866fe2451f61296924767280ab5732d9d", - "reference": "d4f4a66866fe2451f61296924767280ab5732d9d", + "url": "https://api.github.com/repos/symfony/yaml/zipball/90208e2fc6f68f613eae7ca25a2458a931b1bacc", + "reference": "90208e2fc6f68f613eae7ca25a2458a931b1bacc", "shasum": "" }, "require": { @@ -6482,7 +6486,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.3.3" + "source": "https://github.com/symfony/yaml/tree/v7.3.5" }, "funding": [ { @@ -6502,7 +6506,7 @@ "type": "tidelift" } ], - "time": "2025-08-27T11:34:33+00:00" + "time": "2025-09-27T09:00:46+00:00" }, { "name": "theiconic/name-parser", @@ -7448,28 +7452,28 @@ }, { "name": "webmozart/assert", - "version": "1.11.0", + "version": "1.12.1", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991" + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", "shasum": "" }, "require": { "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", "php": "^7.2 || ^8.0" }, - "conflict": { - "phpstan/phpstan": "<0.12.20", - "vimeo/psalm": "<4.6.1 || 4.6.2" - }, - "require-dev": { - "phpunit/phpunit": "^8.5.13" + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" }, "type": "library", "extra": { @@ -7500,9 +7504,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.11.0" + "source": "https://github.com/webmozarts/assert/tree/1.12.1" }, - "time": "2022-06-03T18:03:27+00:00" + "time": "2025-10-29T15:56:20+00:00" }, { "name": "webonyx/graphql-php", @@ -8063,7 +8067,7 @@ "packages-dev": [ { "name": "cakephp/core", - "version": "5.2.8", + "version": "5.2.9", "source": { "type": "git", "url": "https://github.com/cakephp/core.git", @@ -8130,7 +8134,7 @@ }, { "name": "cakephp/utility", - "version": "5.2.8", + "version": "5.2.9", "source": { "type": "git", "url": "https://github.com/cakephp/utility.git", @@ -8296,16 +8300,16 @@ }, { "name": "craftcms/feed-me", - "version": "6.10.1", + "version": "6.11.0", "source": { "type": "git", "url": "https://github.com/craftcms/feed-me.git", - "reference": "8818ee101baf758225884828b7d11688f373b4d8" + "reference": "46cbf5aeb3c6ddabb9ab248c0e6742444533f1bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/feed-me/zipball/8818ee101baf758225884828b7d11688f373b4d8", - "reference": "8818ee101baf758225884828b7d11688f373b4d8", + "url": "https://api.github.com/repos/craftcms/feed-me/zipball/46cbf5aeb3c6ddabb9ab248c0e6742444533f1bb", + "reference": "46cbf5aeb3c6ddabb9ab248c0e6742444533f1bb", "shasum": "" }, "require": { @@ -8362,7 +8366,7 @@ "rss": "https://github.com/craftcms/feed-me/commits/master.atom", "source": "https://github.com/craftcms/feed-me" }, - "time": "2025-08-27T17:11:59+00:00" + "time": "2025-10-31T00:53:51+00:00" }, { "name": "craftcms/html-field", @@ -8666,16 +8670,16 @@ }, { "name": "league/csv", - "version": "9.26.0", + "version": "9.27.1", "source": { "type": "git", "url": "https://github.com/thephpleague/csv.git", - "reference": "7fce732754d043f3938899e5183e2d0f3d31b571" + "reference": "26de738b8fccf785397d05ee2fc07b6cd8749797" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/csv/zipball/7fce732754d043f3938899e5183e2d0f3d31b571", - "reference": "7fce732754d043f3938899e5183e2d0f3d31b571", + "url": "https://api.github.com/repos/thephpleague/csv/zipball/26de738b8fccf785397d05ee2fc07b6cd8749797", + "reference": "26de738b8fccf785397d05ee2fc07b6cd8749797", "shasum": "" }, "require": { @@ -8753,7 +8757,7 @@ "type": "github" } ], - "time": "2025-10-01T11:24:54+00:00" + "time": "2025-10-25T08:35:20+00:00" }, { "name": "league/html-to-markdown", @@ -9376,16 +9380,16 @@ }, { "name": "symfony/translation-contracts", - "version": "v3.6.0", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d" + "reference": "65a8bc82080447fae78373aa10f8d13b38338977" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/df210c7a2573f1913b2d17cc95f90f53a73d8f7d", - "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", + "reference": "65a8bc82080447fae78373aa10f8d13b38338977", "shasum": "" }, "require": { @@ -9434,7 +9438,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" }, "funding": [ { @@ -9445,12 +9449,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-27T08:32:26+00:00" + "time": "2025-07-15T13:41:35+00:00" }, { "name": "symplify/easy-coding-standard", From 1fa402b9238f027a6f0297573d712678dd041d3a Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 27 Nov 2025 11:06:47 +0000 Subject: [PATCH 04/98] Add `SHOPIFY_WEBHOOK_BASE_URL` for local dev webhooks --- src/models/Settings.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/models/Settings.php b/src/models/Settings.php index 5294e43..5316104 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -270,6 +270,13 @@ public function setProductFieldLayout(mixed $fieldLayout): void */ public function getWebhookUrl(): string { - return UrlHelper::actionUrl('shopify/webhook/handle'); + $url = UrlHelper::actionUrl('shopify/webhook/handle'); + $webhookBaseUrl = App::env('SHOPIFY_WEBHOOK_BASE_URL'); + + if ($webhookBaseUrl) { + $url = StringHelper::replaceFirst($url, rtrim(UrlHelper::baseUrl(), '/'), rtrim($webhookBaseUrl, '/')); + } + + return $url; } } From 35d26e76a82a5ee2ddc68808ac67815405a955d1 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 27 Nov 2025 11:07:17 +0000 Subject: [PATCH 05/98] Move to new way of creating integration apps --- CHANGELOG.md | 10 +++++ src/controllers/WebhooksController.php | 58 ++++++++++++++++++++++++- src/models/Settings.php | 28 +----------- src/services/Api.php | 60 ++++++++++++++++++++++++-- src/services/BulkOperations.php | 1 + src/templates/settings/index.twig | 9 ---- src/templates/webhooks/index.twig | 59 ------------------------- src/translations/en/shopify.php | 2 +- 8 files changed, 126 insertions(+), 101 deletions(-) delete mode 100644 src/templates/webhooks/index.twig diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d77896..6f69f53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Release Notes for Shopify +## Unreleased + +> [!IMPORTANT] +> After updating, this plugin now requires API version `2025-10` and the creation of an app via the Dev Dashboard. +> Webhooks will need to be recreated for the new app. Go to **Shopify** → **Webhooks** and create the missing webhooks. + +- Shopify for Craft now supports version `2025-10` of Shopify’s GraphQL Admin API. +- It is now possible to configure the webhook URLs using the `SHOPIFY_WEBHOOK_BASE_URL` environment variable. ([#185](https://github.com/craftcms/shopify/issues/185)) +- Added `craft\shopify\services\Api::API_ACCESS_TOKEN_CACHE_KEY`. + ## 6.1.1 - 2025-11-06 - Fixed a bug where file storage could be maxed out when using multiple queue workers. diff --git a/src/controllers/WebhooksController.php b/src/controllers/WebhooksController.php index 1ee1b4a..796444c 100644 --- a/src/controllers/WebhooksController.php +++ b/src/controllers/WebhooksController.php @@ -8,6 +8,8 @@ namespace craft\shopify\controllers; use Craft; +use craft\helpers\Html; +use craft\helpers\Json; use craft\shopify\Plugin; use craft\web\assets\admintable\AdminTableAsset; use craft\web\Controller; @@ -41,13 +43,65 @@ public function actionEdit(): YiiResponse } $webhooks = $api->getWebhooks(); + $tableData = []; // If we don't have all webhooks needed for the current environment show the create button - $containsAllWebhooks = $webhooks->filter(function($item) use ($api) { + $containsAllWebhooks = $webhooks->filter(function($item) use ($api, &$tableData) { + $tableData[] = [ + 'id' => $item['id'], + 'title' => $item['topic'], + 'callbackUrl' => $item['endpoint']['callbackUrl'], + ]; return in_array($item['topic'], $api::WEBHOOK_TOPICS) && $item['endpoint']['callbackUrl'] == Plugin::getInstance()->getSettings()->getWebhookUrl(); })->count() === count($api::WEBHOOK_TOPICS); - return $this->renderTemplate('shopify/webhooks/index', compact('webhooks', 'containsAllWebhooks')); + $view->registerTranslations('shopify', [ + 'Are you sure you want to delete this webhook?', + 'No webhooks exist yet.', + 'Topic', + 'URL', + 'Webhook could not be deleted', + 'Webhook deleted', + ]); + + $tableData = Json::encode($tableData); + + $view->registerJs(<<asCpScreen() + ->title(Craft::t('shopify', 'Webhooks')) + ->selectedSubnavItem('webhooks') + ->contentHtml( + Html::tag('p', Craft::t('shopify', 'Webhooks for the current environment.')) . + Html::tag('div', Html::tag('div', '', ['id' => 'webhooks-container']), ['class' => 'field']) + ); + + if (!$containsAllWebhooks) { + $screen->action('shopify/webhooks/create') + ->submitButtonLabel(Craft::t('shopify', 'Create webhooks')); + } + + return $screen; } /** diff --git a/src/models/Settings.php b/src/models/Settings.php index 5316104..d43c7e5 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -27,7 +27,6 @@ class Settings extends Model { private string $_apiKey = ''; private string $_apiSecretKey = ''; - private string $_accessToken = ''; private string $_hostName = ''; public string $uriFormat = ''; public string $template = ''; @@ -43,7 +42,7 @@ class Settings extends Model * @see setApiVersion() * @see getApiVersion() */ - private string $_apiVersion = ApiVersion::JULY_2025; + private string $_apiVersion = ApiVersion::OCTOBER_2025; /** * Whether product metafields should be included when syncing products. This adds an extra API request per product. @@ -66,7 +65,7 @@ class Settings extends Model public function rules(): array { return [ - [['apiSecretKey', 'apiKey', 'accessToken', 'hostName', 'apiVersion'], 'required'], + [['apiSecretKey', 'apiKey', 'hostName', 'apiVersion'], 'required'], [['apiVersion'], 'in', 'range' => Plugin::getInstance()->getApi()->getSupportedApiVersions()], [['hostName'], function($attribute) { $hostName = $this->$attribute; @@ -84,7 +83,6 @@ public function attributes() $names[] = 'apiVersion'; $names[] = 'apiKey'; $names[] = 'apiSecretKey'; - $names[] = 'accessToken'; $names[] = 'contextualPricingCountries'; $names[] = 'hostName'; $names[] = 'uriFormat'; @@ -99,7 +97,6 @@ public function fields(): array 'apiVersion' => fn() => $this->getApiVersion(false), 'apiKey' => fn() => $this->getApiKey(false), 'apiSecretKey' => fn() => $this->getApiSecretKey(false), - 'accessToken' => fn() => $this->getAccessToken(false), 'contextualPricingCountries' => fn() => $this->getContextualPricingCountries(false), 'hostName' => fn() => $this->getHostName(false), 'uriFormat' => 'uriFormat', @@ -116,7 +113,6 @@ public function attributeLabels(): array 'apiKey' => Craft::t('shopify', 'Shopify API Key'), 'apiSecretKey' => Craft::t('shopify', 'Shopify API Secret Key'), 'apiVersion' => Craft::t('shopify', 'Shopify API Version'), - 'accessToken' => Craft::t('shopify', 'Shopify Access Token'), 'contextualPricingCountries' => Craft::t('shopify', 'Context Pricing Countries'), 'hostName' => Craft::t('shopify', 'Shopify Host Name'), 'uriFormat' => Craft::t('shopify', 'Product URI format'), @@ -204,26 +200,6 @@ public function getHostName(bool $parse = true): string return ($parse ? App::parseEnv($this->_hostName) : $this->_hostName) ?? ''; } - /** - * @param string $accessToken - * @return void - * @since 6.0.0 - */ - public function setAccessToken(string $accessToken): void - { - $this->_accessToken = $accessToken; - } - - /** - * @param bool $parse - * @return string - * @since 6.0.0 - */ - public function getAccessToken(bool $parse = true): string - { - return ($parse ? App::parseEnv($this->_accessToken) : $this->_accessToken) ?? ''; - } - /** * @param string $contextualPricingCountries * @return void diff --git a/src/services/Api.php b/src/services/Api.php index 15d2cf1..0aaef4a 100644 --- a/src/services/Api.php +++ b/src/services/Api.php @@ -19,6 +19,7 @@ use GraphQL\QueryBuilder\QueryBuilder; use GraphQL\Variable; use GuzzleHttp\Client; +use GuzzleHttp\Exception\GuzzleException; use Illuminate\Support\Collection; use Psr\Http\Client\ClientInterface; use Shopify\ApiVersion; @@ -36,6 +37,7 @@ use Shopify\Rest\Admin2024_10\Product as ShopifyProduct2410; use Shopify\Rest\Admin2024_10\Variant as ShopifyVariant2410; use Shopify\Rest\Base as ShopifyBaseResource; +use Shopify\Utils; use Shopify\Webhooks\Topics; /** @@ -61,6 +63,11 @@ class Api extends Component Topics::SHOP_UPDATE, ]; + /** + * @since 7.0.0 + */ + public const API_ACCESS_TOKEN_CACHE_KEY = 'shopifyApiAccessToken'; + /** * @var Session|null */ @@ -83,9 +90,7 @@ class Api extends Component public function getSupportedApiVersions(): array { return [ - ApiVersion::JULY_2025, - ApiVersion::OCTOBER_2024, - ApiVersion::OCTOBER_2023, + ApiVersion::OCTOBER_2025, ]; } @@ -508,7 +513,7 @@ public function client(): ClientInterface }; $hostName = $pluginSettings->getHostName(true); - $accessToken = $pluginSettings->getAccessToken(true); + $accessToken = $this->getAccessToken(); $this->_session = new Session( id: 'NA', @@ -523,6 +528,53 @@ public function client(): ClientInterface return $this->_session; } + + /** + * @return string + * @throws GuzzleException + * @since 7.0.0 + */ + public function getAccessToken(): string + { + // Try and retrieve the access token from the cache + if ($accessToken = Craft::$app->getCache()->get(self::API_ACCESS_TOKEN_CACHE_KEY)) { + return $accessToken; + } + + $client = Craft::createGuzzleClient([ + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + ]); + + $shopDomain = Utils::sanitizeShopDomain(Plugin::getInstance()->getSettings()->getHostName()); + $endpoint = 'https://' . $shopDomain . '/admin/oauth/access_token'; + + try { + $response = $client->post($endpoint, [ + 'form_params' => [ + 'client_id' => Plugin::getInstance()->getSettings()->getApiKey(true), + 'client_secret' => Plugin::getInstance()->getSettings()->getApiSecretKey(true), + 'grant_type' => 'client_credentials', + ], + ]); + + $body = Json::decodeIfJson((string)$response->getBody()); + + if (!isset($body['access_token'])) { + throw new \Exception('No access token returned from Shopify.'); + } + + // Cache the access token for its lifetime minus 2 minutes + Craft::$app->getCache()->set(self::API_ACCESS_TOKEN_CACHE_KEY, $body['access_token'], $body['expires_in'] - 120); + + return $body['access_token']; + } catch (\Exception $e) { + Craft::error('Could not get access token from Shopify: ' . $e->getMessage(), __METHOD__); + throw $e; + } + } + /** * @return Collection * @throws \Exception diff --git a/src/services/BulkOperations.php b/src/services/BulkOperations.php index 9a19d16..2c4c7ff 100644 --- a/src/services/BulkOperations.php +++ b/src/services/BulkOperations.php @@ -155,6 +155,7 @@ public function nextBulkOperation(): bool ]), (new \GraphQL\Query('userErrors')) ->setSelectionSet([ + 'code', 'field', 'message', ]), diff --git a/src/templates/settings/index.twig b/src/templates/settings/index.twig index 1c3a2a2..4c147f2 100644 --- a/src/templates/settings/index.twig +++ b/src/templates/settings/index.twig @@ -88,15 +88,6 @@ suggestEnvVars: true, }) }} - {{ forms.autosuggestField({ - label: 'Access Token'|t('shopify'), - id: 'accessToken', - name: 'settings[accessToken]', - value: settings.getAccessToken(false), - errors: settings.getErrors('accessToken'), - suggestEnvVars: true, - }) }} - {{ forms.autosuggestField({ label: 'Host Name'|t('shopify'), instructions: 'The Shopify store hostname.'|t('shopify'), diff --git a/src/templates/webhooks/index.twig b/src/templates/webhooks/index.twig deleted file mode 100644 index 00be7e4..0000000 --- a/src/templates/webhooks/index.twig +++ /dev/null @@ -1,59 +0,0 @@ -{# @var craft \craft\web\twig\variables\CraftVariable #} -{% extends "_layouts/cp" %} -{% set selectedSubnavItem = 'webhooks' %} - -{% set title = "Webhooks"|t('shopify') %} - -{% set navItems = {} %} - -{% block content %} - -

{{ "Webhook Management"|t('shopify') }}

- -
-

{{ "Create the webhooks for the current environment."|t('shopify') }}

-
- {{ actionInput('shopify/webhooks/create') }} - {{ redirectInput('shopify/webhooks') }} - {{ csrfInput() }} - -
-
- - {# Divs needed for the Admin Table js below #} -
-
-
- - {% set tableData = [] %} - {% for webhook in webhooks %} - {% set tableData = tableData|merge([{ - id: webhook.id, - title: webhook.topic, - callbackUrl: webhook.endpoint.callbackUrl - }]) %} - {% endfor %} - - {% js %} - var columns = [ - { name: '__slot:title', title: '{{ 'Topic'|t('shopify') }}' }, - { name: 'callbackUrl', title: '{{ 'URL'|t('shopify') }}' } - ]; - - new Craft.VueAdminTable({ - fullPane: false, - columns: columns, - container: '#webhooks-container', - deleteAction: 'shopify/webhooks/delete', - deleteConfirmationMessage: Craft.t('shopify', "Are you sure you want to delete this webhook?"), - deleteFailMessage: Craft.t('shopify', "Webhook could not be deleted"), - deleteSuccessMessage: Craft.t('shopify', "Webhook deleted"), - emptyMessage: Craft.t('shopify', 'No webhooks exist yet.'), - tableData: {{ tableData|json_encode|raw }}, - deleteCallback: function(){ - window.location.reload(); // We need to reload to get the create button showing again - } - }); - {% endjs %} -{% endblock %} - diff --git a/src/translations/en/shopify.php b/src/translations/en/shopify.php index 2acd171..f906d51 100644 --- a/src/translations/en/shopify.php +++ b/src/translations/en/shopify.php @@ -24,7 +24,6 @@ 'Channel' => 'Channel', 'Completed' => 'Completed', 'Couldn’t save settings.' => 'Couldn’t save settings.', - 'Create the webhooks for the current environment.' => 'Create the webhooks for the current environment.', 'Create' => 'Create', 'Created' => 'Created', 'Created At' => 'Created At', @@ -85,6 +84,7 @@ 'Webhook deleted' => 'Webhook deleted', 'Webhooks could not be deleted' => 'Webhooks could not be deleted', 'Webhooks could not be registered.' => 'Webhooks could not be registered.', + 'Webhooks for the current environment.' => 'Webhooks for the current environment.', 'Webhooks registered.' => 'Webhooks registered.', 'Webhooks' => 'Webhooks', '{name} option values: {values}' => '{name} option values: {values}', From 054fcf6a6d1573c4ccb78b7e444842e3b8d04f67 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 3 Dec 2025 10:01:49 +0000 Subject: [PATCH 06/98] Added new Media, Meta fields, Options and Variants native fields --- CHANGELOG.md | 5 ++ src/Plugin.php | 28 +++++++ src/controllers/ProductsController.php | 13 --- src/elements/Product.php | 24 +++++- src/fieldlayoutelements/MediaField.php | 83 +++++++++++++++++++ src/fieldlayoutelements/MetafieldsField.php | 86 +++++++++++++++++++ src/fieldlayoutelements/OptionsField.php | 92 +++++++++++++++++++++ src/fieldlayoutelements/VariantsField.php | 88 ++++++++++++++++++++ src/helpers/Product.php | 27 +++--- src/translations/en/shopify.php | 15 ++++ 10 files changed, 429 insertions(+), 32 deletions(-) create mode 100644 src/fieldlayoutelements/MediaField.php create mode 100644 src/fieldlayoutelements/MetafieldsField.php create mode 100644 src/fieldlayoutelements/OptionsField.php create mode 100644 src/fieldlayoutelements/VariantsField.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f69f53..a6c5e1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ - Shopify for Craft now supports version `2025-10` of Shopify’s GraphQL Admin API. - It is now possible to configure the webhook URLs using the `SHOPIFY_WEBHOOK_BASE_URL` environment variable. ([#185](https://github.com/craftcms/shopify/issues/185)) - Added `craft\shopify\services\Api::API_ACCESS_TOKEN_CACHE_KEY`. +- Added `craft\shopify\fieldlayoutelements\MediaField`. +- Added `craft\shopify\fieldlayoutelements\MetafieldsField`. +- Added `craft\shopify\fieldlayoutelements\OptionsField`. +- Added `craft\shopify\fieldlayoutelements\VariantsField`. +- Removed `craft\shopify\controllers\ProductsController::actionRenderCardHtml()` ## 6.1.1 - 2025-11-06 diff --git a/src/Plugin.php b/src/Plugin.php index fc6df28..0594974 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -18,6 +18,7 @@ use craft\console\controllers\ResaveController; use craft\db\Query; use craft\events\DefineConsoleActionsEvent; +use craft\events\DefineFieldLayoutFieldsEvent; use craft\events\RegisterComponentTypesEvent; use craft\events\RegisterUrlRulesEvent; use craft\feedme\events\RegisterFeedMeFieldsEvent; @@ -25,6 +26,7 @@ use craft\helpers\ArrayHelper; use craft\helpers\Console; use craft\helpers\UrlHelper; +use craft\models\FieldLayout; use craft\services\Elements; use craft\services\Fields; use craft\services\Gc; @@ -32,6 +34,10 @@ use craft\shopify\db\Table; use craft\shopify\elements\Product; use craft\shopify\feedme\fields\Products as FeedMeProductsField; +use craft\shopify\fieldlayoutelements\MediaField; +use craft\shopify\fieldlayoutelements\MetafieldsField; +use craft\shopify\fieldlayoutelements\OptionsField; +use craft\shopify\fieldlayoutelements\VariantsField; use craft\shopify\fields\Products as ProductsField; use craft\shopify\handlers\Webhook; use craft\shopify\linktypes\Product as ProductLinkType; @@ -125,6 +131,7 @@ public function init() $this->_registerElementTypes(); $this->_registerUtilityTypes(); $this->_registerFieldTypes(); + $this->_registerFieldLayoutElements(); $this->_registerLinkTypes(); $this->_registerVariables(); $this->_registerResaveCommands(); @@ -256,6 +263,27 @@ private function _registerFieldTypes(): void }); } + /** + * @return void + * @since 7.0.0 + */ + private function _registerFieldLayoutElements(): void + { + Event::on(FieldLayout::class, FieldLayout::EVENT_DEFINE_NATIVE_FIELDS, static function(DefineFieldLayoutFieldsEvent $e) { + /** @var FieldLayout $fieldLayout */ + $fieldLayout = $e->sender; + + switch ($fieldLayout->type) { + case Product::class: + $e->fields[] = VariantsField::class; + $e->fields[] = OptionsField::class; + $e->fields[] = MetafieldsField::class; + $e->fields[] = MediaField::class; + break; + } + }); + } + /** * Register Link types * diff --git a/src/controllers/ProductsController.php b/src/controllers/ProductsController.php index c764f53..cfa53e7 100644 --- a/src/controllers/ProductsController.php +++ b/src/controllers/ProductsController.php @@ -53,17 +53,4 @@ public function actionSync(): ?Response return $this->asSuccess(Craft::t('shopify', 'Products sync created')); } - - /** - * Renders the card HTML. - * - * @return string - */ - public function actionRenderCardHtml(): string - { - $id = (int)Craft::$app->request->getParam('id'); - /** @var Product $product */ - $product = Product::find()->id($id)->status(null)->one(); - return ProductHelper::renderCardHtml($product); - } } diff --git a/src/elements/Product.php b/src/elements/Product.php index 04bcdc0..8229e9f 100644 --- a/src/elements/Product.php +++ b/src/elements/Product.php @@ -20,6 +20,9 @@ use craft\models\FieldLayout; use craft\shopify\elements\conditions\products\ProductCondition; use craft\shopify\elements\db\ProductQuery; +use craft\shopify\fieldlayoutelements\MetafieldsField; +use craft\shopify\fieldlayoutelements\OptionsField; +use craft\shopify\fieldlayoutelements\VariantsField; use craft\shopify\helpers\Product as ProductHelper; use craft\shopify\Plugin; use craft\shopify\records\Product as ProductRecord; @@ -696,8 +699,25 @@ public function getSidebarHtml(bool $static): string { /** @noinspection PhpUnhandledExceptionInspection */ Craft::$app->getView()->registerAssetBundle(ShopifyCpAsset::class); - $productCard = ProductHelper::renderCardHtml($this); - return $productCard . parent::getSidebarHtml($static); + + // Conditionally show metadata in the sidebar dependent on the field layout + $excludeKeys = []; + $this->getFieldLayout()->getFields(Function ($field) use (&$excludeKeys) { + if ($field instanceof VariantsField) { + $excludeKeys[] = 'Variants'; + return true; + } else if ($field instanceof OptionsField) { + $excludeKeys[] = 'Options'; + return true; + } else if ($field instanceof MetafieldsField) { + $excludeKeys[] = 'Metafields'; + return true; + } + + return false; + }); + + return ProductHelper::renderCardHtml($this, $excludeKeys) . parent::getSidebarHtml($static); } /** diff --git a/src/fieldlayoutelements/MediaField.php b/src/fieldlayoutelements/MediaField.php new file mode 100644 index 0000000..87473b7 --- /dev/null +++ b/src/fieldlayoutelements/MediaField.php @@ -0,0 +1,83 @@ + + * @since 7.0.0 + */ +class MediaField extends BaseNativeField +{ + /** + * @inheritdoc + */ + public string $attribute = 'images'; + + /** + * @inheritdoc + */ + public function hasCustomWidth(): bool + { + return false; + } + + /** + * @inheritdoc + */ + protected function defaultLabel(ElementInterface $element = null, bool $static = false): ?string + { + return Craft::t('shopify', 'Media'); + } + + /** + * @inheritdoc + */ + protected function inputHtml(ElementInterface $element = null, bool $static = false): ?string + { + if (!$element instanceof Product) { + throw new InvalidArgumentException(__CLASS__ . ' can only be used in product field layouts.'); + } + + $media = $element->getImages(); + + if (empty($media)) { + return Html::beginTag('div', ['class' => 'zilch']) . + Html::tag('p', Craft::t('shopify', 'This product has no media.')) . + Html::endTag('div'); + } + + $html = + Html::beginTag('div', ['class' => 'elements']) . + Html::beginTag('ul', ['class' => 'thumbsview']); + + foreach ($media as $item) { + $html .= + Html::beginTag('li') . + Html::beginTag('div', ['class' => 'chip large element']) . + Html::tag('div', Html::img($item['image']['url']), ['class' => 'thumb']) . + Html::endTag('div') . + Html::endTag('li'); + } + + $html .= Html::endTag('ul') . + Html::endTag('div'); + + return $html; + } +} diff --git a/src/fieldlayoutelements/MetafieldsField.php b/src/fieldlayoutelements/MetafieldsField.php new file mode 100644 index 0000000..ec9d5c2 --- /dev/null +++ b/src/fieldlayoutelements/MetafieldsField.php @@ -0,0 +1,86 @@ + + * @since 7.0.0 + */ +class MetafieldsField extends BaseNativeField +{ + /** + * @inheritdoc + */ + public string $attribute = 'metafields'; + + /** + * @inheritdoc + */ + public function hasCustomWidth(): bool + { + return false; + } + + /** + * @inheritdoc + */ + protected function defaultLabel(ElementInterface $element = null, bool $static = false): ?string + { + return Craft::t('shopify', 'Meta fields'); + } + + /** + * @inheritdoc + */ + protected function inputHtml(ElementInterface $element = null, bool $static = false): ?string + { + if (!$element instanceof Product) { + throw new InvalidArgumentException(__CLASS__ . ' can only be used in product field layouts.'); + } + + $metafields = $element->getMetafields(); + + if (empty($metafields)) { + return Html::beginTag('div', ['class' => 'zilch']) . + Html::tag('p', Craft::t('shopify', 'This product has no meta fields.')) . + Html::endTag('div'); + } + + $cols = [ + 'key' => ['heading' => Craft::t('shopify', 'Key'), 'type' => 'html'], + 'value' => ['heading' => Craft::t('shopify', 'Value'), 'type' => 'html'], + ]; + + $tableData = []; + foreach ($metafields as $key => $value) { + $tableData[] = [ + 'key' => Html::tag('code', Html::encode($key)), + 'value' => Html::encode($value), + ]; + } + + return Cp::editableTableHtml([ + 'id' => $this->id(), + 'name' => $this->baseInputName(), + 'cols' => $cols, + 'rows' => $tableData, + 'static' => true, + ]); + } +} diff --git a/src/fieldlayoutelements/OptionsField.php b/src/fieldlayoutelements/OptionsField.php new file mode 100644 index 0000000..0835f58 --- /dev/null +++ b/src/fieldlayoutelements/OptionsField.php @@ -0,0 +1,92 @@ + + * @since 7.0.0 + */ +class OptionsField extends BaseNativeField +{ + /** + * @inheritdoc + */ + public string $attribute = 'options'; + + /** + * @inheritdoc + */ + public function hasCustomWidth(): bool + { + return false; + } + + /** + * @inheritdoc + */ + protected function defaultLabel(ElementInterface $element = null, bool $static = false): ?string + { + return Craft::t('shopify', 'Options'); + } + + /** + * @inheritdoc + */ + protected function inputHtml(ElementInterface $element = null, bool $static = false): ?string + { + if (!$element instanceof Product) { + throw new InvalidArgumentException(__CLASS__ . ' can only be used in product field layouts.'); + } + + $options = $element->getOptions(); + + if (empty($options)) { + return Html::beginTag('div', ['class' => 'zilch']) . + Html::tag('p', Craft::t('shopify', 'This product has no options.')) . + Html::endTag('div'); + } + + $cols = [ + 'option' => ['heading' => Craft::t('shopify', 'Option'), 'type' => 'html'], + 'values' => ['heading' => Craft::t('shopify', 'Values'), 'type' => 'html'], + 'hasVariants' => ['heading' => Craft::t('shopify', 'Has variants'), 'type' => 'html'], + ]; + + $tableData = []; + foreach ($options as $opt) { + foreach ($opt['optionValues'] as $i => $val) { + $tableData[] = [ + 'option' => $i === 0 ? Html::tag('strong', Html::encode($opt['name'])) : '', + 'values' => Html::encode($val['name']), + 'hasVariants' => (bool)$val['hasVariants'] ? Html::tag('div', Cp::iconSvg('check'), [ + 'class' => array_filter(['thumb', 'cp-icon', Color::Green->value]), + ]) : '', + ]; + } + } + + return Cp::editableTableHtml([ + 'id' => $this->id(), + 'name' => $this->baseInputName(), + 'cols' => $cols, + 'rows' => $tableData, + 'static' => true, + ]); + } +} diff --git a/src/fieldlayoutelements/VariantsField.php b/src/fieldlayoutelements/VariantsField.php new file mode 100644 index 0000000..b5c7962 --- /dev/null +++ b/src/fieldlayoutelements/VariantsField.php @@ -0,0 +1,88 @@ + + * @since 7.0.0 + */ +class VariantsField extends BaseNativeField +{ + /** + * @inheritdoc + */ + public string $attribute = 'variants'; + + /** + * @inheritdoc + */ + public function hasCustomWidth(): bool + { + return false; + } + + /** + * @inheritdoc + */ + protected function defaultLabel(ElementInterface $element = null, bool $static = false): ?string + { + return Craft::t('shopify', 'Variants'); + } + + /** + * @inheritdoc + */ + protected function inputHtml(ElementInterface $element = null, bool $static = false): ?string + { + if (!$element instanceof Product) { + throw new InvalidArgumentException(__CLASS__ . ' can only be used in product field layouts.'); + } + + $variants = $element->getVariants(); + + $cols = [ + 'title' => ['heading' => Craft::t('shopify', 'Variant'), 'type' => 'html'], + 'sku' => ['heading' => Craft::t('shopify', 'SKU'), 'type' => 'html'], + 'price' => ['heading' => Craft::t('shopify', 'Price'), 'type' => 'html'], + ]; + + foreach ($variants as &$variant) { + $link = sprintf('%s/variants/%s', $element->getShopifyEditUrl(), str_replace('gid://shopify/ProductVariant/', '', $variant['id'])); + $variant['title'] = Html::a(Html::encode($variant['title']), $link, [ + 'aria-label' => Craft::t('shopify', 'Edit variant {title} on Shopify', ['title' => $variant['title']]), + 'target' => '_blank', + 'class' => '' + ]); + $variant['sku'] = Html::tag('code', $variant['sku']); + } + + if (empty($variants)) { + return Html::beginTag('div', ['class' => 'zilch']) . + Html::tag('p', Craft::t('shopify', 'This product has no variants.')) . + Html::endTag('div'); + } + + return Cp::editableTableHtml([ + 'id' => $this->id(), + 'name' => $this->baseInputName(), + 'cols' => $cols, + 'rows' => $variants, + 'static' => true, + ]); + } +} diff --git a/src/helpers/Product.php b/src/helpers/Product.php index 3363fde..fb37912 100644 --- a/src/helpers/Product.php +++ b/src/helpers/Product.php @@ -29,10 +29,11 @@ class Product { /** * @param ProductElement $product + * @param array $excludeMetaDataKeys * @return string * @throws InvalidConfigException */ - public static function renderCardHtml(ProductElement $product): string + public static function renderCardHtml(ProductElement $product, array $excludeMetaDataKeys = []): string { $formatter = Craft::$app->getFormatter(); @@ -105,7 +106,7 @@ public static function renderCardHtml(ProductElement $product): string // Metafields if (count($product->getMetafields()) > 0) { - $meta[Craft::t('shopify', 'Metafields')] = collect($product->getMetafields()) + $meta[Craft::t('shopify', 'Meta fields')] = collect($product->getMetafields()) ->keys() ->join(', '); } @@ -116,14 +117,13 @@ public static function renderCardHtml(ProductElement $product): string $meta[Craft::t('shopify', 'Published at')] = $formatter->asDatetime($product->publishedAt, Formatter::FORMAT_WIDTH_SHORT); $meta[Craft::t('shopify', 'Updated at')] = $formatter->asDatetime($product->updatedAt, Formatter::FORMAT_WIDTH_SHORT); - $metadataHtml = Cp::metadataHtml($meta); + foreach ($excludeMetaDataKeys as $key) { + if (array_key_exists($key, $meta)) { + unset($meta[$key]); + } + } - $spinner = Html::tag('div', '', [ - 'class' => 'spinner', - 'hx' => [ - 'indicator', - ], - ]); + $metadataHtml = Cp::metadataHtml($meta); // This is the date updated in the database which represents the last time it was updated from a Shopify webhook or sync. /** @var ShopifyData $productData */ @@ -132,20 +132,13 @@ public static function renderCardHtml(ProductElement $product): string $now = new \DateTime(); $diff = $now->diff($dateUpdated); $duration = DateTimeHelper::humanDuration($diff, false); - $footer = Html::tag('div', 'Updated ' . $duration . ' ago.' . $spinner, [ + $footer = Html::tag('div', 'Updated ' . $duration . ' ago.', [ 'class' => 'pec-footer', ]); return Html::tag('div', $cardHeader . $hr . $metadataHtml . $footer, [ 'class' => 'meta proxy-element-card', 'id' => 'pec-' . $product->id, - 'hx' => [ - 'get' => UrlHelper::actionUrl('shopify/products/render-card-html', [ - 'id' => $product->id, - ]), - 'swap' => 'outerHTML', - 'trigger' => 'every 15s', - ], ]); } diff --git a/src/translations/en/shopify.php b/src/translations/en/shopify.php index f906d51..bf33188 100644 --- a/src/translations/en/shopify.php +++ b/src/translations/en/shopify.php @@ -29,16 +29,23 @@ 'Created At' => 'Created At', 'Description HTML' => 'Description HTML', 'Draft in Shopify' => 'Draft in Shopify', + 'Edit variant {title} on Shopify' => 'Edit variant {title} on Shopify', 'Failed to create products sync' => 'Failed to create products sync', 'Failed to delete sync' => 'Failed to delete sync', 'General' => 'General', 'Handle' => 'Handle', + 'Has variants' => 'Has variants', + 'Key' => 'Key', 'Live' => 'Live', + 'Media' => 'Media', + 'Meta fields' => 'Meta fields', 'Meta Fields' => 'Meta Fields', 'New Product' => 'New Product', 'No Shopify session available.' => 'No Shopify session available.', 'Objects' => 'Objects', + 'Option' => 'Option', 'Options' => 'Options', + 'Price' => 'Price', 'Processing' => 'Processing', 'Processing bulk operation data' => 'Processing bulk operation data', 'Product Template' => 'Product Template', @@ -65,6 +72,7 @@ 'Shopify Status' => 'Shopify Status', 'Shopify plugin loaded' => 'Shopify plugin loaded', 'Shopify' => 'Shopify', + 'SKU' => 'SKU', 'Status' => 'Status', 'Supported API versions: {versions}' => 'Supported API versions: {versions}', 'Sync all' => 'Sync all', @@ -73,12 +81,19 @@ 'Sync deleted' => 'Sync deleted', 'Tags' => 'Tags', 'Template Suffix' => 'Template Suffix', + 'This product has no media.' => 'This product has no media.', + 'This product has no meta fields.' => 'This product has no meta fields.', + 'This product has no options.' => 'This product has no options.', + 'This product has no variants.' => 'This product has no variants.', 'Total variants' => 'Total variants', 'Unpublished' => 'Unpublished', 'Untitled product' => 'Untitled product', 'Updated At' => 'Updated At', 'Updating product metafields for “{title}”' => 'Updating product metafields for “{title}”', 'Updating product variants for “{title}”' => 'Updating product variants for “{title}”', + 'Value' => 'Value', + 'Values' => 'Values', + 'Variant' => 'Variant', 'Variants' => 'Variants', 'Vendor' => 'Vendor', 'Webhook deleted' => 'Webhook deleted', From 3ff4ecfe421b110c8b118f75478a76d4c22d5576 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 14 Jan 2026 13:43:51 +0000 Subject: [PATCH 07/98] Move variants to be actual models --- CHANGELOG.md | 6 +- src/collections/VariantCollection.php | 73 ++++++++++ src/elements/Product.php | 66 ++++++--- src/models/Variant.php | 185 ++++++++++++++++++++++++++ src/records/ShopifyData.php | 1 + src/services/Products.php | 44 +++++- 6 files changed, 353 insertions(+), 22 deletions(-) create mode 100644 src/collections/VariantCollection.php create mode 100644 src/models/Variant.php diff --git a/CHANGELOG.md b/CHANGELOG.md index a6c5e1d..5f3895c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,16 @@ - Shopify for Craft now supports version `2025-10` of Shopify’s GraphQL Admin API. - It is now possible to configure the webhook URLs using the `SHOPIFY_WEBHOOK_BASE_URL` environment variable. ([#185](https://github.com/craftcms/shopify/issues/185)) -- Added `craft\shopify\services\Api::API_ACCESS_TOKEN_CACHE_KEY`. +- Fixed a bug where product slugs weren’t syncing correctly. +- Added `craft\shopify\collections\VariantCollection`. - Added `craft\shopify\fieldlayoutelements\MediaField`. - Added `craft\shopify\fieldlayoutelements\MetafieldsField`. - Added `craft\shopify\fieldlayoutelements\OptionsField`. - Added `craft\shopify\fieldlayoutelements\VariantsField`. +- Added `craft\shopify\models\Variant`. +- Added `craft\shopify\services\Api::API_ACCESS_TOKEN_CACHE_KEY`. - Removed `craft\shopify\controllers\ProductsController::actionRenderCardHtml()` +- `craft\shopify\elements\Product::getVariants()` now returns a collection. ## 6.1.1 - 2025-11-06 diff --git a/src/collections/VariantCollection.php b/src/collections/VariantCollection.php new file mode 100644 index 0000000..27b63c3 --- /dev/null +++ b/src/collections/VariantCollection.php @@ -0,0 +1,73 @@ + + * + * @author Pixel & Tonic, Inc. + * @since 7.0.0 + */ +class VariantCollection extends Collection +{ + /** + * Creates a VariantCollection from an array of Variant attributes. + * + * @param array $items + * @return static + */ + public static function make($items = []) + { + foreach ($items as &$item) { + if ($item instanceof Variant) { + continue; + } else if (is_array($item)) { + $item += ['class' => Variant::class]; + $item = \Craft::createObject($item); + } else if ($item instanceof ShopifyData) { + $item = Craft::createObject([ + 'class' => Variant::class, + 'id' => $item->id, + 'shopifyId' => $item->shopifyId, + 'type' => $item->type, + 'parentId' => $item->parentId, + 'dateCreated' => $item->dateCreated, + 'dateUpdated' => $item->dateUpdated, + 'uid' => $item->uid, + 'data' => $item->data, + ]); + } else { + throw new \InvalidArgumentException('Items must be arrays, ShopifyData instances, or Variant instances.'); + } + } + + /** @var static $collection */ + $collection = parent::make($items); + return $collection; + } + + /** + * Returns the cheapest variant in the collection. + * + * @return Variant|null The cheapest variant in the collection, or null if there aren't any + */ + public function cheapest(): ?Variant + { + $variant = $this->sortBy('price')->first(); + return $variant instanceof Variant ? $variant : null; + } +} diff --git a/src/elements/Product.php b/src/elements/Product.php index 8229e9f..f7fc6db 100644 --- a/src/elements/Product.php +++ b/src/elements/Product.php @@ -18,18 +18,22 @@ use craft\helpers\Template; use craft\helpers\UrlHelper; use craft\models\FieldLayout; +use craft\shopify\collections\VariantCollection; use craft\shopify\elements\conditions\products\ProductCondition; use craft\shopify\elements\db\ProductQuery; use craft\shopify\fieldlayoutelements\MetafieldsField; use craft\shopify\fieldlayoutelements\OptionsField; use craft\shopify\fieldlayoutelements\VariantsField; use craft\shopify\helpers\Product as ProductHelper; +use craft\shopify\models\Variant; use craft\shopify\Plugin; use craft\shopify\records\Product as ProductRecord; +use craft\shopify\records\ShopifyData; use craft\shopify\web\assets\shopifycp\ShopifyCpAsset; use craft\web\CpScreenResponseBehavior; use DateTime; use Exception; +use Illuminate\Support\Collection; use yii\base\InvalidConfigException; use yii\helpers\Html as HtmlHelper; use yii\web\Response; @@ -167,9 +171,9 @@ public function getDescriptionHtml(): ?string public ?DateTime $updatedAt = null; /** - * @var array|null + * @var VariantCollection|null */ - private ?array $_variants = null; + private ?VariantCollection $_variants = null; /** * @var string @@ -393,37 +397,47 @@ public function getMetafields(): array } /** - * @param string|array $value + * @param string|array|Collection $value * @return void */ - public function setVariants(string|array $value): void + public function setVariants(string|array|Collection $value): void { if (is_string($value)) { $value = Json::decodeIfJson($value); } + if (is_iterable($value)) { + if (is_array($value)) { + $value = VariantCollection::make($value); + } else if ($value instanceof Collection && !($value instanceof VariantCollection)) { + $value = VariantCollection::make($value->all()); + } + } + $this->_variants = $value; } /** - * @return array + * @return VariantCollection * @throws InvalidConfigException */ - public function getVariants(): array + public function getVariants(): VariantCollection { if (!$this->shopifyGid) { - return []; + return VariantCollection::make(); } - if ($this->_variants !== null) { + if ($this->_variants instanceof VariantCollection){ return $this->_variants; + } else if ($this->_variants === null) { + $variants = Plugin::getInstance()->getApi()->getShopifyDataByType('ProductVariant', $this->shopifyGid, true); + } else { + $variants = $this->_variants; } - $variants = Plugin::getInstance()->getApi()->getShopifyDataByType('ProductVariant', $this->shopifyGid); + $this->setVariants($variants); - $this->setVariants($variants->all()); - - return $this->_variants ?? []; + return $this->_variants ?? VariantCollection::make(); } /** @@ -443,21 +457,24 @@ public function attributes(): array /** * Gets the cheapest variant. * - * @return array + * @return Variant|null + * @throws InvalidConfigException */ - public function getCheapestVariant(): array + public function getCheapestVariant(): ?Variant { - return collect($this->getVariants())->sortBy('price')->first() ?? []; + return $this->getVariants()->cheapest(); } /** * Gets the first variant which is Shopify's default variant. * - * @return array + * @return Variant|null */ - public function getDefaultVariant(): array + public function getDefaultVariant(): ?Variant { - return collect($this->getVariants())->first() ?? []; + /** @var Variant|null $variant */ + $variant = $this->getVariants()->first(); + return $variant ?? null; } /** @@ -736,6 +753,19 @@ public static function defineSources(string $context): array ]; } + /** + * @inerhitdoc + */ + public function beforeSave(bool $isNew): bool + { + // Ensure slug and handle match + if ($this->handle !== $this->slug) { + $this->slug = $this->handle; + } + + return parent::beforeSave($isNew); + } + /** * @param bool $isNew * @return void diff --git a/src/models/Variant.php b/src/models/Variant.php new file mode 100644 index 0000000..2fe636e --- /dev/null +++ b/src/models/Variant.php @@ -0,0 +1,185 @@ + + * @since 7.0.0 + */ +class Variant extends Model +{ + /** + * @var int|null + */ + public ?int $id = null; + + /** + * @var string|null The Shopify ID of the variant. + */ + public ?string $shopifyId = null; + + /** + * @var string|null + */ + public ?string $type = null; + + /** + * @var string|null + */ + public ?string $parentId = null; + + /** + * @var array|null + * @see getData() + * @see setData() + */ + private ?array $_data = null; + + /** + * @var array|null + * @see getMetafields() + * @see setMetafields() + */ + private ?array $_metaFields = null; + + /** + * @var DateTime|null + */ + public ?DateTime $dateCreated = null; + + /** + * @var DateTime|null + */ + public ?DateTime $dateUpdated = null; + + /** + * @var string|null + */ + public ?string $uid = null; + + + public function __call($name, $params) + { + if (array_key_exists($name, $this->_data)) { + return $this->_data[$name]; + } + + return parent::__call($name, $params); + } + + /** + * @inheritdoc + */ + public function __get($name) + { + if (array_key_exists($name, $this->_data)) { + return $this->_data[$name]; + } + + return parent::__get($name); + } + + /** + * @inheritdoc + */ + protected function defineRules(): array + { + $rules = parent::defineRules(); + + $rules[] = [['id', 'shopifyId', 'type', 'parentId', 'data', 'dateCreated', 'dateUpdated', 'uid'], 'safe']; + + return $rules; + } + + /** + * @inheritdoc + */ + public function attributes() + { + $names = parent::attributes(); + $names[] = 'data'; + $names[] = 'metafields'; + + return $names; + } + + /** + * @param string|array|null $data + * @return void + */ + public function setData(string|array|null $data): void + { + if (is_string($data)) { + $data = Json::decodeIfJson($data); + } + + $this->_data = $data; + } + + /** + * @return array + */ + public function getData(): array + { + return $this->_data; + } + + /** + * @param string|array $value + * @return void + */ + public function setMetafields(string|array $value): void + { + if (is_string($value)) { + $value = Json::decodeIfJson($value); + $value = collect($value)->mapWithKeys(function($d) { + return [ + $d['key'] => Json::decodeIfJson($d['value']), + ]; + }); + } + + $this->_metaFields = $value; + } + + /** + * @return array + * @throws InvalidConfigException + */ + public function getMetafields(): array + { + if (!$this->shopifyId) { + return []; + } + + if ($this->_metaFields !== null) { + return $this->_metaFields; + } + + $data = Plugin::getInstance()->getApi()->getShopifyDataByType('Metafield', $this->shopifyId); + + $metafields = $data + ->mapWithKeys(function($d) { + return [ + $d['key'] => Json::decodeIfJson($d['value']), + ]; + }); + + $this->setMetafields($metafields->all()); + + return $this->_metaFields ?? []; + } +} diff --git a/src/records/ShopifyData.php b/src/records/ShopifyData.php index b725939..c4aa2d0 100644 --- a/src/records/ShopifyData.php +++ b/src/records/ShopifyData.php @@ -23,6 +23,7 @@ * @property string $parentId * @property string $dateCreated * @property string $dateUpdated + * @property string $uid */ class ShopifyData extends ActiveRecord { diff --git a/src/services/Products.php b/src/services/Products.php index 0455c61..8b68865 100644 --- a/src/services/Products.php +++ b/src/services/Products.php @@ -12,9 +12,11 @@ use craft\helpers\ProjectConfig; use craft\helpers\StringHelper; use craft\models\FieldLayout; +use craft\shopify\collections\VariantCollection; use craft\shopify\db\Table; use craft\shopify\elements\Product; use craft\shopify\events\ShopifyProductSyncEvent; +use craft\shopify\models\Variant; use craft\shopify\Plugin; use craft\shopify\records\ShopifyData; use GraphQL\QueryBuilder\QueryBuilder; @@ -289,15 +291,51 @@ public function eagerLoadImagesForProducts(array $products): array */ public function eagerLoadVariantsForProducts(array $products): array { - return $this->_eagerLoadTypeOnProducts($products, 'ProductVariant', function($product, $rows) { - $product->setVariants(array_column($rows, 'data')); + $variantIds = []; + $variantsByProductId = []; + $return = $this->_eagerLoadTypeOnProducts($products, 'ProductVariant', function($product, $rows) use (&$variantsByProductId, &$variantIds) { + foreach ($rows as $row) { + $variantIds[] = $row->shopifyId; + } + + $variantsByProductId[$product->shopifyGid] = $rows; }); + + // If we are eager loading the variants, for best performance we should also eager load the metafields on the variants + $metafieldsData = collect(); + if (!empty($variantIds)) { + $metafieldsData = Plugin::getInstance() + ->getApi() + ->getShopifyDataByType('Metafield', $variantIds, true) + ->groupBy('parentId'); + } + + foreach ($return as $product) { + $variants = VariantCollection::make($variantsByProductId[$product->shopifyGid]); + + if ($metafieldsData->isNotEmpty()) { + $variants?->map(function(Variant$variant) use ($metafieldsData) { + $metafields = $metafieldsData->get($variant->shopifyId); + if (!empty($metafields)) { + $variant->setMetafields(collect($metafields)->mapWithKeys(function($d) { + return [ + $d->data['key'] => Json::decodeIfJson($d->data['value']), + ]; + })->all()); + } + }); + } + + $product->setMetafields($variants); + } + + return $return; } /** * @param array|Product[] $products * @param string $type - * @param callable $callback + * @param callable(Product, ShopifyData[]): void $callback * @return array * @throws InvalidConfigException */ From 2f32c8f0370882da3bb95eb3aafda4c1da5af670 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 14 Jan 2026 17:36:11 +0000 Subject: [PATCH 08/98] cleanup --- src/collections/VariantCollection.php | 4 +-- src/controllers/ProductsController.php | 1 - src/elements/Product.php | 13 +++++----- src/fieldlayoutelements/MediaField.php | 2 -- src/fieldlayoutelements/MetafieldsField.php | 1 - src/fieldlayoutelements/OptionsField.php | 2 +- src/fieldlayoutelements/VariantsField.php | 27 ++++++++++++++------- src/helpers/Product.php | 1 - src/models/Variant.php | 4 +++ src/records/ShopifyData.php | 2 +- src/services/Products.php | 9 ++++++- 11 files changed, 40 insertions(+), 26 deletions(-) diff --git a/src/collections/VariantCollection.php b/src/collections/VariantCollection.php index 27b63c3..a489f64 100644 --- a/src/collections/VariantCollection.php +++ b/src/collections/VariantCollection.php @@ -35,10 +35,10 @@ public static function make($items = []) foreach ($items as &$item) { if ($item instanceof Variant) { continue; - } else if (is_array($item)) { + } elseif (is_array($item)) { $item += ['class' => Variant::class]; $item = \Craft::createObject($item); - } else if ($item instanceof ShopifyData) { + } elseif ($item instanceof ShopifyData) { $item = Craft::createObject([ 'class' => Variant::class, 'id' => $item->id, diff --git a/src/controllers/ProductsController.php b/src/controllers/ProductsController.php index cfa53e7..d6c220c 100644 --- a/src/controllers/ProductsController.php +++ b/src/controllers/ProductsController.php @@ -11,7 +11,6 @@ use craft\helpers\App; use craft\helpers\UrlHelper; use craft\shopify\elements\Product; -use craft\shopify\helpers\Product as ProductHelper; use craft\shopify\Plugin; use yii\web\Response; diff --git a/src/elements/Product.php b/src/elements/Product.php index f7fc6db..5205560 100644 --- a/src/elements/Product.php +++ b/src/elements/Product.php @@ -28,7 +28,6 @@ use craft\shopify\models\Variant; use craft\shopify\Plugin; use craft\shopify\records\Product as ProductRecord; -use craft\shopify\records\ShopifyData; use craft\shopify\web\assets\shopifycp\ShopifyCpAsset; use craft\web\CpScreenResponseBehavior; use DateTime; @@ -409,7 +408,7 @@ public function setVariants(string|array|Collection $value): void if (is_iterable($value)) { if (is_array($value)) { $value = VariantCollection::make($value); - } else if ($value instanceof Collection && !($value instanceof VariantCollection)) { + } elseif ($value instanceof Collection && !($value instanceof VariantCollection)) { $value = VariantCollection::make($value->all()); } } @@ -427,9 +426,9 @@ public function getVariants(): VariantCollection return VariantCollection::make(); } - if ($this->_variants instanceof VariantCollection){ + if ($this->_variants instanceof VariantCollection) { return $this->_variants; - } else if ($this->_variants === null) { + } elseif ($this->_variants === null) { $variants = Plugin::getInstance()->getApi()->getShopifyDataByType('ProductVariant', $this->shopifyGid, true); } else { $variants = $this->_variants; @@ -719,14 +718,14 @@ public function getSidebarHtml(bool $static): string // Conditionally show metadata in the sidebar dependent on the field layout $excludeKeys = []; - $this->getFieldLayout()->getFields(Function ($field) use (&$excludeKeys) { + $this->getFieldLayout()->getFields(function($field) use (&$excludeKeys) { if ($field instanceof VariantsField) { $excludeKeys[] = 'Variants'; return true; - } else if ($field instanceof OptionsField) { + } elseif ($field instanceof OptionsField) { $excludeKeys[] = 'Options'; return true; - } else if ($field instanceof MetafieldsField) { + } elseif ($field instanceof MetafieldsField) { $excludeKeys[] = 'Metafields'; return true; } diff --git a/src/fieldlayoutelements/MediaField.php b/src/fieldlayoutelements/MediaField.php index 87473b7..fa6e6df 100644 --- a/src/fieldlayoutelements/MediaField.php +++ b/src/fieldlayoutelements/MediaField.php @@ -9,9 +9,7 @@ use Craft; use craft\base\ElementInterface; -use craft\enums\Color; use craft\fieldlayoutelements\BaseNativeField; -use craft\helpers\Cp; use craft\helpers\Html; use craft\shopify\elements\Product; use yii\base\InvalidArgumentException; diff --git a/src/fieldlayoutelements/MetafieldsField.php b/src/fieldlayoutelements/MetafieldsField.php index ec9d5c2..5ffe7f3 100644 --- a/src/fieldlayoutelements/MetafieldsField.php +++ b/src/fieldlayoutelements/MetafieldsField.php @@ -9,7 +9,6 @@ use Craft; use craft\base\ElementInterface; -use craft\enums\Color; use craft\fieldlayoutelements\BaseNativeField; use craft\helpers\Cp; use craft\helpers\Html; diff --git a/src/fieldlayoutelements/OptionsField.php b/src/fieldlayoutelements/OptionsField.php index 0835f58..3c43f14 100644 --- a/src/fieldlayoutelements/OptionsField.php +++ b/src/fieldlayoutelements/OptionsField.php @@ -70,7 +70,7 @@ protected function inputHtml(ElementInterface $element = null, bool $static = fa $tableData = []; foreach ($options as $opt) { - foreach ($opt['optionValues'] as $i => $val) { + foreach ($opt['optionValues'] as $i => $val) { $tableData[] = [ 'option' => $i === 0 ? Html::tag('strong', Html::encode($opt['name'])) : '', 'values' => Html::encode($val['name']), diff --git a/src/fieldlayoutelements/VariantsField.php b/src/fieldlayoutelements/VariantsField.php index b5c7962..1e32b2f 100644 --- a/src/fieldlayoutelements/VariantsField.php +++ b/src/fieldlayoutelements/VariantsField.php @@ -54,6 +54,7 @@ protected function inputHtml(ElementInterface $element = null, bool $static = fa } $variants = $element->getVariants(); + $variantRows = []; $cols = [ 'title' => ['heading' => Craft::t('shopify', 'Variant'), 'type' => 'html'], @@ -61,17 +62,25 @@ protected function inputHtml(ElementInterface $element = null, bool $static = fa 'price' => ['heading' => Craft::t('shopify', 'Price'), 'type' => 'html'], ]; - foreach ($variants as &$variant) { + foreach ($variants as $variant) { $link = sprintf('%s/variants/%s', $element->getShopifyEditUrl(), str_replace('gid://shopify/ProductVariant/', '', $variant['id'])); - $variant['title'] = Html::a(Html::encode($variant['title']), $link, [ - 'aria-label' => Craft::t('shopify', 'Edit variant {title} on Shopify', ['title' => $variant['title']]), - 'target' => '_blank', - 'class' => '' - ]); - $variant['sku'] = Html::tag('code', $variant['sku']); + + $title = $variant->title; + $sku = $variant->sku; + $price = $variant->price; + + $variantRows[] = [ + 'title' => Html::a(Html::encode($title), $link, [ + 'aria-label' => Craft::t('shopify', 'Edit variant {title} on Shopify', ['title' => $title]), + 'target' => '_blank', + 'class' => '', + ]), + 'sku' => Html::tag('code', $sku), + 'price' => $price, + ]; } - if (empty($variants)) { + if (empty($variantRows)) { return Html::beginTag('div', ['class' => 'zilch']) . Html::tag('p', Craft::t('shopify', 'This product has no variants.')) . Html::endTag('div'); @@ -81,7 +90,7 @@ protected function inputHtml(ElementInterface $element = null, bool $static = fa 'id' => $this->id(), 'name' => $this->baseInputName(), 'cols' => $cols, - 'rows' => $variants, + 'rows' => $variantRows, 'static' => true, ]); } diff --git a/src/helpers/Product.php b/src/helpers/Product.php index fb37912..abd0ba5 100644 --- a/src/helpers/Product.php +++ b/src/helpers/Product.php @@ -13,7 +13,6 @@ use craft\helpers\DateTimeHelper; use craft\helpers\Html; use craft\helpers\StringHelper; -use craft\helpers\UrlHelper; use craft\i18n\Formatter; use craft\shopify\elements\Product as ProductElement; use craft\shopify\records\ShopifyData; diff --git a/src/models/Variant.php b/src/models/Variant.php index 2fe636e..b70af2e 100644 --- a/src/models/Variant.php +++ b/src/models/Variant.php @@ -7,6 +7,7 @@ namespace craft\shopify\models; +use AllowDynamicProperties; use craft\base\Model; use craft\helpers\Json; use craft\shopify\Plugin; @@ -16,6 +17,9 @@ /** * Variant model. * + * @property-read string $title + * @property-read string $sku + * @property-read string $price * @author Pixel & Tonic, Inc. * @since 7.0.0 */ diff --git a/src/records/ShopifyData.php b/src/records/ShopifyData.php index c4aa2d0..59625b0 100644 --- a/src/records/ShopifyData.php +++ b/src/records/ShopifyData.php @@ -19,7 +19,7 @@ * @property int $id * @property string $shopifyId * @property string $type - * @property string $data + * @property string|array $data * @property string $parentId * @property string $dateCreated * @property string $dateUpdated diff --git a/src/services/Products.php b/src/services/Products.php index 8b68865..da5a5bb 100644 --- a/src/services/Products.php +++ b/src/services/Products.php @@ -262,6 +262,13 @@ public function eagerLoadMetafieldsForProducts(array $products): array return $this->_eagerLoadTypeOnProducts($products, 'Metafield', function($product, $rows) { $metafields = collect($rows) ->mapWithKeys(function($d, $key) { + /** @var ShopifyData $d */ + + // Map if the data has `key` and `value` properties + if (!isset($d->data['key']) || !isset($d->data['value'])) { + return []; + } + return [ $d->data['key'] => Json::decodeIfJson($d->data['value']), ]; @@ -314,7 +321,7 @@ public function eagerLoadVariantsForProducts(array $products): array $variants = VariantCollection::make($variantsByProductId[$product->shopifyGid]); if ($metafieldsData->isNotEmpty()) { - $variants?->map(function(Variant$variant) use ($metafieldsData) { + $variants->map(function(Variant$variant) use ($metafieldsData) { $metafields = $metafieldsData->get($variant->shopifyId); if (!empty($metafields)) { $variant->setMetafields(collect($metafields)->mapWithKeys(function($d) { From 0832474e6e6c35771d8ddad9e0b3ae1002eabbc4 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 14 Jan 2026 17:36:27 +0000 Subject: [PATCH 09/98] tidy --- src/models/Variant.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/models/Variant.php b/src/models/Variant.php index b70af2e..5cafc97 100644 --- a/src/models/Variant.php +++ b/src/models/Variant.php @@ -7,7 +7,6 @@ namespace craft\shopify\models; -use AllowDynamicProperties; use craft\base\Model; use craft\helpers\Json; use craft\shopify\Plugin; From 474018207246ebf323362513eb562663c2dbbf8c Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 14 Jan 2026 17:49:59 +0000 Subject: [PATCH 10/98] Fix variants link --- src/fieldlayoutelements/VariantsField.php | 2 +- src/models/Variant.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/fieldlayoutelements/VariantsField.php b/src/fieldlayoutelements/VariantsField.php index 1e32b2f..46511a2 100644 --- a/src/fieldlayoutelements/VariantsField.php +++ b/src/fieldlayoutelements/VariantsField.php @@ -63,7 +63,7 @@ protected function inputHtml(ElementInterface $element = null, bool $static = fa ]; foreach ($variants as $variant) { - $link = sprintf('%s/variants/%s', $element->getShopifyEditUrl(), str_replace('gid://shopify/ProductVariant/', '', $variant['id'])); + $link = sprintf('%s/variants/%s', $element->getShopifyEditUrl(), str_replace('gid://shopify/ProductVariant/', '', $variant->shopifyId)); $title = $variant->title; $sku = $variant->sku; diff --git a/src/models/Variant.php b/src/models/Variant.php index 5cafc97..688e499 100644 --- a/src/models/Variant.php +++ b/src/models/Variant.php @@ -16,6 +16,7 @@ /** * Variant model. * + * @property-read string $shopifyId * @property-read string $title * @property-read string $sku * @property-read string $price From 738c9a95bab52f4e9d1bf5ba27d976d541b376ac Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 28 Jan 2026 11:54:13 -0800 Subject: [PATCH 11/98] New Shopify API connection instructions --- CHANGELOG.md | 2 +- README.md | 162 +++++++++++++++--------------- UPGRADE.md | 7 +- docs/shopify-add-collaborator.png | Bin 0 -> 96106 bytes docs/shopify-hostname.png | Bin 160755 -> 63888 bytes 5 files changed, 87 insertions(+), 84 deletions(-) create mode 100644 docs/shopify-add-collaborator.png diff --git a/CHANGELOG.md b/CHANGELOG.md index b911a23..8209322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ > [!IMPORTANT] > After updating, this plugin now requires API version `2025-10` and the creation of an app via the Dev Dashboard. -> Webhooks will need to be recreated for the new app. Go to **Shopify** → **Webhooks** and create the missing webhooks. +> Webhooks will need to be recreated for the new app. Go to **Shopify** → **Webhooks** and create the missing webhooks. See the [upgrade notes](https://github.com/craftcms/shopify/blob/7.x/README.md#upgrading) for additional information. - Shopify for Craft now supports version `2025-10` of Shopify’s GraphQL Admin API. - It is now possible to configure the webhook URLs using the `SHOPIFY_WEBHOOK_BASE_URL` environment variable. ([#185](https://github.com/craftcms/shopify/issues/185)) diff --git a/README.md b/README.md index 495d69a..a78143a 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ Build a content-driven storefront by synchronizing [Shopify](https://shopify.com) products into [Craft CMS](https://craftcms.com/). > [!IMPORTANT] -> Version 6.x of the Shopify plugin uses the new [GraphQL Admin API](https://shopify.dev/docs/api/admin-graphql) to [set up webhooks](#set-up-webhooks) and [synchronize](#synchronization) data. Review the [Upgrading](#upgrading) section for more info about the impacts of this change. +> Version 7.x of the Shopify plugin uses new app-based authorization. +> Existing integrations and credentials should continue to work with no changes, but the process for creating _new_ credentials has changed significantly in Shopify. ## Topics @@ -39,127 +40,126 @@ To install the plugin, visit the [Plugin Store](https://plugins.craftcms.com/sho php craft plugin/install shopify ``` -### Create a Shopify App +### Connect to Shopify -The plugin works with Shopify’s [Custom Apps](https://help.shopify.com/en/manual/apps/custom-apps) system. +The plugin works with Shopify’s [Dev Dashboard](https://shopify.dev/docs/apps/build/dev-dashboard) app system, and is split into three parts: [creating an app](#create-an-app), [installing it into a store](#install-in-a-store), and finally [connecting it to Craft](#connect-to-craft). -> [!NOTE] -> If you are not the owner of the Shopify store, have the owner add you as a collaborator or staff member with the [_Develop Apps_ permission](https://help.shopify.com/en/manual/apps/custom-apps#api-scope-permissions-for-custom-apps). +> [!CAUTION] +> The following process may differ significantly if you are part of a [Partner](https://www.shopify.com/partners) organization or are a collaborator on multiple stores. +> Shopify implicitly creates an “organization” for each store, and that organization gets a dedicated Dev Dashboard. +> **You must access the Dev Dashboard via the store you want to create an app for, _not_ via a link in the documentation or by directly navigating to its URL!** -Follow [Shopify’s directions](https://help.shopify.com/en/manual/apps/custom-apps) for creating a private app (through the _Get the API credentials for a custom app_ section), and take these actions when prompted: +To perform these steps, you must either be the owner of a store, or a collaborator with the [App developer role](https://shopify.dev/docs/apps/build/dev-dashboard/user-permissions). -1. **App Name**: Choose something that identifies the integration, like “Craft CMS.” -2. **Admin API access scopes**: The following scopes are required for the plugin to function correctly: +![Adding a collaborator via the Shopify admin](docs/shopify-add-collaborator.png) - - `read_products` - - `read_product_listings` - - `read_inventory` +#### Create an App - Additionally (at the bottom of this screen), the **Webhook subscriptions** → **Event version** should be `2025-07`. +1. From a store, open **Settings** → **Apps**, press **Develop apps** in the toolbar, then follow the **Build apps in Dev Dashboard** link. +1. In the Dev Dashboard, press **Create app**. +2. In the first screen, pick an **App name** that identifies the integration, like _Craft CMS_. +1. Press **Create**, then fill out the following fields to create your first “version”: -3. **Storefront API access scopes**: The following scopes are required for the plugin to function correctly: + - **App URL**: (Optional) Replace `example.com` with `https://shopify.dev/apps/default-app-home`. This is just a friendlier (but still somewhat confusing) page hosted by Shopify, displayed when your “app” is accessed in the Shopify store’s admin. (If you turn off **Embed app in Shopify admin**, you are redirected to this URL after [installing](#install-in-a-store) the app. Neither is particularly useful; this is a symptom of Shopify’s lack of an API-only integration path.) + - **Webhooks API Version**: Choose `2025-10`, and add the same string to your project’s `.env` file: + ```bash + SHOPIFY_WEBHOOK_VERSION="2025-10" + ``` + - **Access** → **Scopes**: The following scopes are required for the plugin to function correctly: + - `read_inventory` + - `read_product_listings` + - `read_products` + - `unauthenticated_read_product_listings` + - Shopify requires these to be in a comma-separated list: `read_inventory,read_product_listings,read_products,unauthenticated_read_product_listings` + - Do _not_ enable the **Use legacy install flow** as it can result in mismatched scopes during installation. +1. Press **Release** to deploy the configuration. You may give it a name and description, or let Shopify tag it with an incrementing number. +1. Switch to the **Settings** screen of the new app, and copy the credentials into your `.env` file: + ```bash + SHOPIFY_CLIENT_ID="..." # Client ID + SHOPIFY_CLIENT_SECRET="..." # Secret + ``` - - `unauthenticated_read_product_listings` +#### Install in a Store -4. **Admin API access token**: Reveal and copy this value into your `.env` file, as `SHOPIFY_ADMIN_ACCESS_TOKEN`. -5. **API key and secret key**: Reveal and/or copy the **API key** and **API secret key** into your `.env` under `SHOPIFY_API_KEY` and `SHOPIFY_API_SECRET_KEY`, respectively. +> [!WARNING] +> If you are not the owner of the Shopify store, have the owner add you as a [collaborator](https://help.shopify.com/en/manual/your-account/users/security/collaborator-accounts). -#### Store Hostname +From your new app’s **Home** screen, press **Install**. A new window will open with the store selector; pick the store you started from. _Your app can only be installed in the store associated with the Dev Dashboard it was created in._ -The last piece of info you’ll need on hand is your store’s hostname. This is usually what appears in the browser when using the Shopify admin—it’s also shown if you navigate to the `Settings -> Domains` screen of your store: +> [!TIP] +> You may see a warning like “This app hasn't been reviewed.” +> This is to be expected; your app is not publicly available, and doesn’t need to go through the normal Marketplace approval process. +> +> If you see “This app can't be installed on this store,” it was probably created from the wrong Dev Dashboard, and you’ll need to [start over](#create-an-app). -Screenshot of the settings screen in the Shopify admin, with an arrow pointing to the store’s default hostname in the sidebar. +Press **Install** on this screen. +When Shopify redirects to the generic embedded app view, you’re done! -Save this value (_without_ the leading `http://` or `https://`) in your `.env` as `SHOPIFY_HOSTNAME`. +### Connect Plugin -> [!NOTE] -> The hostname required is the one ending with `myshopify.com` or `myshopify.io`. +We still need one piece of information from your store—the hostname, or “domain.” +Visit the **Settings** screen in the Shopify admin, and copy this value from the sidebar: -At this point, you should have the following Shopify-specific values: +![Adding a collaborator via the Shopify admin](docs/shopify-hostname.png) -```env -# ... +Add this to your `.env`, without any `https://` prefix: -SHOPIFY_ADMIN_ACCESS_TOKEN="..." -SHOPIFY_API_VERSION="2025-07" -SHOPIFY_API_KEY="..." -SHOPIFY_API_SECRET_KEY="..." -SHOPIFY_HOSTNAME="my-storefront.myshopify.com" +```bash +SHOPIFY_HOSTNAME="rpfxyv-fk.myshopify.com" ``` -### Connect Plugin +You should now have a total of _four_ `SHOPIFY_*` variables in your `.env` file. +In your Craft project’s control panel, navigate to **Shopify** → **Settings** to configure the plugin: -Now that you have credentials for your custom app, it’s time to add them to Craft. +- **API Version**: `$SHOPIFY_WEBHOOKS_VERSION` +- **API Key**: `$SHOPIFY_CLIENT_ID` +- **API Secret Key**: `$SHOPIFY_CLIENT_SECRET` +- **Host Name**: `$SHOPIFY_HOSTNAME` -1. Visit the **Shopify** → **Settings** screen in your project’s control panel. -2. Assign the four environment variables to the corresponding settings, using the special [config syntax](https://craftcms.com/docs/5.x/configure.html#control-panel-settings): - - **API Version**: `$SHOPIFY_API_VERSION` - - **API Key**: `$SHOPIFY_API_KEY` - - **API Secret Key**: `$SHOPIFY_API_SECRET_KEY` - - **Access Token**: `SHOPIFY_ADMIN_ACCESS_TOKEN` - - **Host Name**: `$SHOPIFY_HOSTNAME` -3. Click **Save**. +Save the settings to test the connection; an exception will be thrown if there are issues. -> [!NOTE] -> These settings are stored in [Project Config](https://craftcms.com/docs/5.x/system/project-config.html), and will be automatically applied in other environments. [Webhooks](#set-up-webhooks) will still need to be configured for each environment! +> [!TIP] +> The temporary session token from Shopify is cached for its expected duration. +> This _can_ obscure connection issues after changing credentials, so it’s always a good idea to run `craft clear-caches/all`, beforehand. ### Set up Webhooks Once your credentials have been added to Craft, a new **Webhooks** tab will appear in the **Shopify** section of the control panel. -Click **Create** on the Webhooks screen to add the required webhooks to Shopify. The plugin will use the credentials you just configured to perform this operation—so this also serves as an initial communication test. +Click **Create webhooks** on the Webhooks screen to add the required webhooks to Shopify. The plugin will use the credentials you just configured to perform this operation—so this also serves as an initial communication test. > [!WARNING] -> You will need to add webhooks for each environment you deploy the plugin to, because each webhook is tied to a specific URL. +> You must add webhooks for every environment you deploy the plugin to; webhooks are tied to the specific, registered URL. +> Be aware that Shopify will continue to attempt delivery to your development webhooks, which may impact the statistics you see in the Dev Dashboard. > [!NOTE] -> If you need to test live synchronization in development, we recommend using [ngrok](https://ngrok.com/) to create a tunnel to your local environment. DDEV makes this simple, with [the `ddev share` command](https://ddev.readthedocs.io/en/latest/users/topics/sharing/). Keep in mind that your site’s primary/base URL is used when registering webhooks, so you may need to update it to match the ngrok tunnel, then recreate your webhooks. +> If you need to test synchronization in development, we recommend using [ngrok](https://ngrok.com/) to create a tunnel to your local environment. +> DDEV makes this simple, with [the `ddev share` command](https://ddev.readthedocs.io/en/latest/users/topics/sharing/). +> Use the `SHOPIFY_WEBHOOKS_BASE_URL` environment variable to override the base URL used when generating webhook URLs; this allows you to continue using your regular DDEV site URL for control panel and front-end access, rather than overriding the entire project or site’s base URL. +> This setting may not work if you have set a custom `cpBaseUrl`! ## Upgrading -To guarantee that the plugin can access all the Shopify resources it needs, review **Admin API access scopes** and **Storefront API access scopes** in the [requirements](#create-a-shopify-app) section _before_ performing an upgrade. - -_After_ upgrading, check that the required webhooks are in place by visiting **Shopify** → **Webhooks** in the Craft control panel. The plugin will retrieve all the webhooks for your storefront, and display a **Create** button if any are missing for the current environment. - -> [!NOTE] -> You must create webhooks for each environment. Repeat this process in your live environment, after deploying. - -The remainder of this section applies specifically to the 5.x → 6.x upgrade. Review the [changelog](CHANGELOG.md) for a complete list of added, removed, and deprecated APIs. - -### Deprecated Settings - -The `syncProductMetafields` and `syncVariantMetafields` are no longer used, and should be removed from your [configuration file](#settings). Meta fields are now automatically loaded alongside product and variant data. - -### Property Names - -Accessors on our [product element](#native-attributes) remain stable, but with the shift to the GraphQL Admin API, many _canonical_ property names on products and variants have changed. If you directly output properties of _variants_ in your templates, they are apt to need updates. The [`ProductVariant` model documentation](https://shopify.dev/docs/api/admin-rest/2025-01/resources/product-variant) shows how to translate old property names (teal) to the new GraphQL schema (magenta). - -### Contextual Pricing +This release (7.x) is primarily concerned with Shopify API compatability. -Shopify’s “presentment prices” are now referred to as “contextual pricing.” Variant arrays still have the default `price` and `compareAtPrice` fields (previously `price` and `compare_at_price`, respectively), but to fetch context-dependent prices, you must provide a list of [two-letter country codes](https://shopify.dev/docs/api/admin-graphql/latest/enums/CountryCode) via the **Contextual Pricing Countries** setting. _Product data must be [sychronized](#synchronization) after changing this setting._ +> [!TIP] +> We strongly recommend reviewing this same section on the [6.x](https://github.com/craftcms/shopify/blob/6.x/README.md#upgrading) branch, as there were a number of breaking changes and deprecations during the upgrade from 5.x. -Contextual prices are stored among other variant properties, with keys corresponding to each country code. For example: `US` pricing would be available as `usContextualPricing`; `DE` pricing would be available as `deContextualPricing`. Each contextual price has this structure: +After the upgrade, you **must**: -```php -[ - 'price' => [ - 'amount' => '50.0', - 'currencyCode' => 'USD', - ], - 'compareAtPrice' => null, -] -``` +1. Update the webhook version setting to `2025-10` in your app _and_ Craft project +1. Review the required [access scopes](#create-an-app) +1. Delete and re-create webhooks for each environment (This is essential! Webhooks are registered and delivered with a specific version, and a mismatch will result in errors.) -You can display these prices using Craft’s [built-in currency formatter](https://craftcms.com/docs/5.x/reference/twig/filters.html#currency): +This ensures that the plugin can properly communicate with the Shopify API. +If you elect to migrate to the Dev Dashboard during the upgrade, you can leave your “legacy custom app” configuration as-is. -```twig -{% set usPrice = variant.usContextualPricing.price %} -{{ usPrice.amount|currency(usPrice.currency) }} -``` +### Credentials -### Resource IDs +At the beginning of 2026, Shopify overhauled how “apps” are created, moving them to the new [Dev Dashboard](https://shopify.dev/docs/apps/build/dev-dashboard). +_Your existing credentials will continue to work_, but you may find that some features (like webhook delivery logs) are worth making the transition. -The GraphQL API no longer uses numeric IDs to look up objects; instead, it expects a [new `gid://`-prefixed value](https://shopify.dev/docs/api/admin-graphql/latest/scalars/ID). [Product elements](#product-element) expose this as `shopifyId` (so as to avoid conflicts with the internal, Craft-specific _element_ `id` property), but it appears at the top level of other resources, like [options](#using-options), [variants](#variants-and-pricing), and media. +You should be able to [create a new app](#create-an-app), [install it](#install-in-a-store), and [replace credentials](#connect-to-shopify) without disruption. ## Product Element diff --git a/UPGRADE.md b/UPGRADE.md index 42bcfe5..ec61b37 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,6 +1,9 @@ -## Upgrading from 5.x to 6.x +## Upgrading from 6.x to 7.x + +This version is primarily concerned with Shopify API compatability. +There is a new process for creating and renewing credentials that work for the Storefront _and_ admin APIs. + -All json based attributes of the product including: **product.options** before diff --git a/docs/shopify-add-collaborator.png b/docs/shopify-add-collaborator.png new file mode 100644 index 0000000000000000000000000000000000000000..c856c9f18f62ae92f157a8a74420574bfb1ebee6 GIT binary patch literal 96106 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

Xg!4O{7F=n*|`$ z075FrNtP?OB-$35_;tnnjR_;Su~K6Go%FC$c`ead^B?hY@ObgJ(^{Y{Aj$c0O{5o? zBC_~K=h$!MVnEdNIxczyPXl&5j(OJvP8_5*06|H14{X(u65>$P*q3+nCELW3YkeF@ zch-Sa>IE7Sef8s=Gei2_Qga^wP#Ayr%bpDGU2*!mNIgu_HgNpw+|!j>0ur^HDD#-q z{9uJ%tb{|1dA{6h#h_lm;f}vHf8Prq{tCHR10@eLub9fbm89V)Lq=|EZlpHXM9A+~ zjJFyC=TC4_ZNL(;GkK4B|50CNZ*u^I5*^_(*fY$RD=!Bw2B=8GfFMY5j+WV0^oQ!e2Q7f}4hEcovU_5us<4nf*8Kh~G9WXuv_%71g;e#nIe#6% z?t8xPX3$TelU!LDKwII{ZD&!BtD+)RVSMK1s2ky~Z$gU^>Xsky~T z-2ecSBkNW-bgQEOYq#GJ0m{vgd++`)P{Dc+yseqNqViu)?@!9pn>{+L$QjpvH3WcK z-x~OpWw~nC|M8X60B1Mx@bj_%x`aPFM>j0_02JKHE7t%2_{tEV)SiBF|KFXFbNyBv z0GvINmN))?eC7Y!tvx9%v|oPkmId$#KL8MH)cey;!9O271c?~m`2nQvv(ayI!x&Zqemp#>4HxjTk+jCv}4v@fnfKukh1Wb?;1I!*G=APL?veFM>m64%j~jCgm#r^9JIgsxi`QZ zl4?H!0Inkes1J`H-DBPWw#RqDnszOB4(w4?f$Qod)ItRhNb7dh+AHt5DS!V8I8b>3 zh*;^m(^~spApj5~s(}#qZO=w|c@)SYy#dEN^SizP8}zk|lON{%`k!P9KrwumKHFOk zAWoyeeMioCmU1J2YV=P5W;O|6Z8-zSK}8C<#4rpYz`s`Z{V{Hjcm!}<6~KqsK+Tm?1N7Afx9%W-a(f-MyWL>}K{67&<39ksi>PR_ zvGSOhl0A~P2i%>G)Z|*-a~3@RBN6k)*gXm;`-b;elfMs_R@{rbzF~L5$%CkNHu_?010!>G-c7TWctj-1uvyRl;%?f@EJJDp%i05sU{Kt1^yodCDzhXC|gB~6O$20#ZN0}^skjL;WbiyhA= zYo88_K66R2b3IGE_mpQ5@J_7&jEqNt31gP!f);}_$Ou5UD?$vVl(&5sSx6hMa>Ko8 z>B?~#=9>mCznX?+$-HLepyh#b;U&L9lTFH@u|1FFt5PRp&gc1<38;K+yyZg{HZ7U2GA{dpbs6Y#WNzDH-8-&+DMsBRCP+|w^FV|NTKzXCilso``78Guaw zNi*F}`jQhM)O|zN6NH{RAe9vre$yru_!BQL5~c=5n;;Ey-5D|ft@qMjX_rN(FWiRZ zC0|v$lnl`Dy!i_O)p|OnrdTRv5Lf`hi;B&_MZ0!q;|HcYz5q=t;RD&ss|cX|a{f&& z(*bCUd?dcCu${ZU(nJ^b9oXQj4gr3rW2?IBeYLv(R^ezcVCnd7&tvue(E^|-92*5h z>)z%XyuyZIiR&6Ap8s{#H)}3Ex%tDmIY|kTqw~@{OU;bX#5`w zwjGgu@m%K?s;U6u*c{c{KVrt3sMh0}?0lh9^BYrmNLHM7Ky03hjxZt2og1cIgI8W)skWw@n^ogO{!5 z0urXj%P@ERd&0K%MB^WLnFDvEqww}#(hdN@`zdM%z2Hj%vbhsl3V@Gh>b#JH<*IYe z8C3TzqD0`*I)l4`>96ruOuym5W%aPNqp@l3g5kj>$!~eljy#_Qc{lge3P8lE=}*&< zzN}a-+s1rQ$nA+NQ3nhM$^i&SY4ii9aWhcMe@@ZaeNYy?0W{=BO#ssXPk!^g?j+D$ zQ90!Bwj`1R;OQ4ie8|0d2X?;&7*+YOF2yxuQWV)~b4{O>JzZ|ShW#2bkaCmf;rpAf zYT_hYICrf3rP6@V&#}jHY`YrsG!HqLGQQVYx$CighIe!B*Pg}6Z~SpwOLN^!;QyYs zw`~T}i=c1kUp#|)W?{v`EZ#tY_S0HQav_kAaodR32?3%DCkj%qRtx~RQ#V@-JhF-Z>iu=B&AwLkZ>!3C!H`A?$f@Mg|&4I{A zGh4O~jP-h!^2hXEI4nL^_F6!w5nwasguNqELq-aYX;y3o3hGcJI8`W)Z#9))eA5qs=7D%Gs%oG3;r8yW3`{pPL{@G&bpLPLKJuG z2EykRDsC`+$aJ+F;l^F^G%BZ&9^ zP88ohzoE;CSfgYt-OEW9H(-v~LwW*l=5bFrPCcG8tQ{Trfx>*Vp`-&{=!@_3q8Ea* zuh=(pIqmsq|1{hih2XPix*W^`xD>})nkE5$>G`=S%F#2|&N*%`q3 zc-`9?1z;x%g}W@$W{=TX1^K81`6P%;-7`*Zt6(sHmAWu=@0Ldomp%mI4Lp~S6~DX2T_$q|7~u?LgIpAkt!Np{6-W0uyyI8~;4WXNeqlzi z?XvM%mprJRG@~6tuc1B=2P>9{FcuF!?zTP1?$Y5mU(mYiKIxyNe-pdssd0#J=KX0E zOugCT8(Wm_Mj5eTdn#}W9h27Ele~Pu*=uj6|Sz6Q>MA1XVyN3dFVV!;(Y4V=oZ*AUbJs@w2%LD}E_XiYN zzxO%}sr9|x>nAR&TJN*lzZBeMD_aTyHSNe)o)&FSL&W+BGSOdadQfbSP>8aoJNca?j--IK$;AwK-REhCJYi z;$xfT2IR$t@nls@4LgJ~Yq>QqvolVqZH&&ov+dvGV)IDv=eySo)JSa&p#9kwW3PW6 z7BD9d0I^F35q`>y2=w{5s1_4+7iia+$-9FAa0`6!PSalvjwgdBHX)*EgOZlbDPJ48 z=MR{IS{9`4<#7kLc-f+F?Kw>!B~`46Tl3%Y#Fi_DuFWlH(6l*T1{Mi%*lK*wbAeSB zJ$^N6dkqAF9E0T)K*BbF)2{!JBjhn`KN`K}=oK zUMAlgbBa*S>U{jxh&6Fk8ORnra2Kju%|eDtZ$Z3giXEK2u8OeweylZA1|mlH=GPC6KN>f99JfUG$Z^KGn{ z3^4zMz!!U1!IG%wBXKy`ea@DKxinJXIGyKg!usX2(_K>=UwEv^=wqy??@m@0V~hyw zDV^WVJ{{)>2z;RoGb8{-*R57v7mGJH%bf!sEl7*zZfyppG5gyq5)OELv>qgz!h3nw4$6^Lx&VW23HThUjrYox zZ1oWjdujP!CEsDi+tH8uUZyDEDT+i;XpLc4>(K8qPZosthD>Z_@w{c2zD2G@``(N&ahtQ4eTa%hiaKV;iQY;=OFq&NBD?mLvV4SVR*nv1?9wV4E>xJInQM zZ(>0p*`kb-x7EWbXPRhap;u=I05+~wR$gb4itd7X*I+l_C!hD^DPWH9M?f!zkLQyL zX>?$~ha9m~3zw1#eR4{{%BJCVV(d*}xPd26?ljTB;_&^bo`{B6l?YRu+UdL}e8Rq5 zB9ik}0>%I*mq*siGbgD~Yuv1Sc{o>}d<5)QlAa;DF+kNXB7LU6#FFWV)VZ(;gFdpy zBrK;qJ3Z17Xw843gIF15PEAc}r%we&UrI7(%;9h}N|Ux^xq$%i-T zrgW-+U*rBrk*3O3oHJG2YS5ZgHQb*fR`L$jJ-3d~PRwYG{wY9G{#$a=fBgU-zM%Ul zH|83iKge_ekIo0uH`$#iW580iv*k<&nH*Yv`pg0$Z|U}gfj<9`C&!6qzg0-oNYsU_ zQ&$G~4U%w{gc+nJR3&RW(g{pl`qU{QDd?#{NLNC3=;DcYtwQ+8$uRtG)RJ2@1f@#ZX2R!cfjOu_>>#~)gA zVes4(*XM;CXZw~euU0C8KL=Kl9?UOGjU(!B$j)ntt$eF7LWiy}8KH3%Mb0MV{ldHx zX~x~d`ogEDGLFkepz$8HiEF_ij3at=c)ckL*h#UnBbf=9D`dlPwCvXqMW&tkxti}K zXjJ0!>$5|P0dm?Y15{@CB~3VfLmkjsc8VnH2lmF|FvYGz2(4#g|u85lW@vID9(X2&2_tYd&TCOM@oeUN+Ua)n~$Vh zT$q3Fd2PA+^O7**?cD3OTS~`bFr18U)vE)K(>+UaqV;+D%jAwFIBx;6VShlc3n;YnK*B;5@L zN{p^OYrI6}2ZFa)2S^e1Jj=vhlmMxbG4sg%UYz8?3N7!ZcH zy_&&fI7?;K#hw(0$n1p6LHMUK;`25B_d)W}n?!Js8H*d1;F?%1=G>ST4cl~>+MLDK z_(7wn0+n>Xn|PXAjt5jBYk{e{g1j2#tcCU6!JKhf(@4l_q%17BkpJe)3Ycc+0%WPH zSANfd;FYxWHA++)IiFceol}y-rwl(3BmHJr9vbK-O|mB?V(Xdhi8#xY-^7!Y!dJ=* z89<+5MYk=0FjH186!tS@{=qrhl=t6%$em`PO{w%qvu#2g)=!2#+0=BI8JkD_+}avkN*gw=zLO)>CAcYwP5hOp+NCncvdtV zM*pa6MG_+&mbC?EvG1;;H3pha1zL4$ah%t@ava(6Zm3<&?UbY?wC;b_XLZKTSwklL z9L6WXW2ACN#si_?EB?+`b9bB-at`B|Mt3(x@nsSNjY7=E9AZ4v!&CGp(RJag_yIB@ z+{9q!UEGo(V($r0NzP9O%j!VENaWu<#O#$rrMyRmf@j>{NQc2?z%MDe$~*m!KP+?= zoO>>__A03%PUn})V0$tqW9z$?0DcOn=HHdy@kl{QKmB&J=K7%QV~p_< zU{Z5|c6aVq!ObERkPs~*cvpcsP5S(q92G^qczcuf#%B`2g)d~nb3cjH92+Ec{Q^OD zl73024ZbyRF)5vixBp%|^ZYW$ARr-=iLiU?MjA7MKluY6Ux#?LBbn(txypB&^!cro zEA3b@UhPt#zHo@%;e+_F8e{lGh}#iGAd5&ueSH_-Y>}q6j1XX|8YB0drh|Z#&qnZ& zJ<3kFEXo2oYMh3=V%4WeD+`DHEWeS`$1#u}kf2qCogCPBrJ1>&VNRqLkxT}s#OM7R zy1+g3Q1mvePPgFZQ%-C3qPcDVJ`2roa^onmcUaXfaHrY^T6&o^%bHue@aZU?>3*a4RTtxorX97und-d{w z?2xB;PCjIr4wP9>9Qa-joNH>ogJH$V^uPq+HQD_sCN-A^+uw$JM29~aX@B8EFC`9X z0VD9vx8_F6k_ce9yTz-Z{f3PQV^&`4leEn;?r8ghE7^GvxRUFerHO(^vD?x0s{=Ri z%o^*uRZT#B`dK$OT-w%8r!LESOH*PwC;J)pc4=7ps!g}Z2q^IAMTisWw>7W)jJ?@< zzd!8HOb1>SKNaH~BXcRR8^ZIrRJMsruS6;OtIq0ve`I3~aXP@z&~me*I4M+sn|5u0Yz~$iv9H8h5(4y~-HAbeW!aK&IUJ>7)fWGNU7RD+ z=avbxxvt{5E$Ry@KAkl|D7;G^*mTo<%BDEx($gXhndiSAlvi8lTN|8IBZ)YXHQ3E& za66d(tDST4vjGXTaRdx`bO1W+EWYt zI8J6C;DG-?47?4%;GfR_pac4_7grfB_MeW#oIJs^IcLmqfw@i{&sLm%{xTW!p4e@a7O z_xoc?ZZ|8YdId)rf)b>t<_-Rm`+0~KM+6BbJ#R;i6B^H*7RV%Crt38V)J z9uabS`g#EkRw}6O?x0&li?BbJX6vFGTfU^kVQu=2;OYy2l|+e)iK4`EVX~Nj_Jz)< zyyO({S}iFHk|rO@&#^m0pkG<9vga+ae*0BQGVt)nv~_7n!!W_nr$J-2+;RvZe3?sY z;oVx}dVZyWi*^suO!Uy;mU+rG4O||w=8x`Z4XNe#}fK6rB zn?FNb;a?#R)CI-^Ar3l6M<*w9$@7&?DUzQ%5?4di3Im<+lPPIhUTQnljtMf!9$J@` zGQ$jizJnJb_JSBtG78`kGcJF1DgYH)&fP%pl8XM78g|d_5UeDr`e;|4?4W0%ljR`4 zgVH10x{EanFJoDGTg-&gC1sICz%tC37~4_=b>DuD0P)xHYAiqg6FGmrJag)e05CE% zxa73APnvv3|g`;3&<^s}iVQDUT z#FwU0^>4*ShCj<-!_#meV_+#dh_`l$t{(1l?6TnB*mP=j9 zU%G*L`YJbytFSofB__yY`~dQ1^aNFcx>^POa7;n5weTy#tWFljn2Vo(kPm_Ky&sg0 z7#T67-}&>)IynUBO<7HEum15L>ay7a;Pxo_Nx)(#G-fEbl1uS=MM7E=>t4xyeQ_S>a#I^gu_LTmcvjC6nhOtGc!bd}?DlH-kD2EfW&?k}JK{X!7-g5ib7H=2J_ zoDFH72zvFuV1fL}D-0Ph;MlYMc<6B-7t_5dUF=h0bvM7)22x{xjMNm|UczQ#`YCWt zRv<XX2&CGLZUz^K%o9ybW!AAp-_^Rvu3=_d2VYP*~l4xwo$0zx|A9cE9qUPFcmb zpL|uTu+3Fs{YT>SHt`0N*#CBDqDuDvz{$>X0Sc`)gMXonfXMVC0IyjEv8y)z^)a*O zz?v-?Y-6`>!?Gyd zIP<=;G+jfQp0VYo;C5kepZqt;x8Oqm`_xqXax0geGSB;?^7L_ex>1+3*}#5Lj?hcS z3h5akms%59`|iv9>yziQDeFBmjmEg`B1$mFV|%v)T2T3To&(xdc(Zm+#3d7@^mo6l z8`%bk{L2vYh$1kx^6~IX9nBzuYpx*FLAm4?KJ31f{11imCl|N844R1_|C$uXTFbj%W9Jj{Tbk0{n58K9E3SW-qhWTguEP4w@HDylj!}CpLW09}NEagUeWB(!jv3mFT&p#{8ejLa7Xz4RH>{T#N0xsC&@w|9nor`Lz{tac%m2`_~rVcOdD4ncP!lTZ5>N zsDG@3y&w0B#{TomfMAu~1JDWH+C_a-|My33@EwBjzf(qHknAbk`b?en_u?KMkNBwB zI$edhC~`Wn)V{iyrT6XM+N0m^072^Vc`J1LC6lZND%u)f(DmfwENYx~fb;{fTF{4! z8{hkmeGd=tB4VujT;Bcfsq>#*ddl*DY#dK3v{i@nW~dGUlhWNtjd0UO&(fKWog~kd zNbZ*PPe;^OKT{UVM8msehz&Zd{#<>9;a_gss48Uwawmk^b@Etcjm=%PMvZ@46Pjrs zpjTI*5Sn-Z4|Rw(ip%QDRA;?U2rY~tPv-eBui$z&-A1|`7nDxWa`5#w zyxMfHW?V~@o=MvI5)jmV5%;XM-{f+J^Fb4>o*;rB)w8<*nIi*m9~j2DBZL|c=jf}~ zQN7;|jcT1$`f6X}t?Hl};IhVifg&{Hj#lZwC)5U38&zBZn$HdUb51R>R|f_ z3WplRbZ@46$+W!H^zZagmKb6iQQRGK@SIMIzGNhJF7_}r3a<4nP{#|(-fb|Qn34P$ z<8@g3{=ucN9<=I~A2DRg$=E+-gzdKm*S7Cir^-JmYv5XXN~1qc-0^7i{^;{*M$q#~ zHEX~OAYu^D(%bH`yncx}yr&#Uz2_IQt6E&)tcJ?*tr3#;n4KMc)D30#5EF@^-8VQ$7GX=uW2Coecm2rfH_f#Jm+kP4TGsnY}Yunw9ot6#L5lLX_6HRW#;3Gf2W-nfWL>ztI#vdVT$)VB4dW8ThWCH#WT1VxmF$`F&^as`H+ejvHlX zf-+bMQlku3cd4PQ&*jA-VxM-d>B7A@gR$Q`9$}%wu*pOzm7WCPCNb#AYhj%>zh9dY z!kL?)4$QC^m^$8ziT?hB4C%Z|_qjq7=$1~E{`J;qcc(Gyj3>?Y zfCHDjGpGKHH-MXpz?Es?HxNa(3Hl#}FqRpr^iCcFC-5d|5W4r0Y8& zNLoz)*MLhMS2Ua#4jT&0$;ULgeT!c2T0Xwc#tCd$2I;>GWd)sT3# zm7Ne9I;?F9%{yi$`6I*DPAi~^O{-d9(K?DTnj-WF_0!w0H$y!%5SeoUIaAvO8``z% zs9>!__jr4%G0{aV6tXi5NoV_A^%?PEEE1K3Z5Pr)sR!eT(Wz=yYHQlIZkXbrny2!p z7Hb8RjwwKRAr0T&sc(jVlsc?=A~wda$1QRKa8?%3@=BRxziDQ4l2adjAP|$iDqO?7 zu~^?rw15O$S=Z*aLmRanuFVD4b@WY|@y;8uxwGBfBq|!!C%kg&lG1Rfq_bR~phxXl z*@~}*AKS2N(F?`Dst*W_AS&%7)m60OHfI}xgi-k z;#KnomJskTApnwqE2VYU_keX@Y$LC_8=cJL-I(yyiBKO17<6t`2xBG2|5R@eOAWv! zq^=xOTECB;?s$y}nfEQiSne`es8n=&v;36kI#-0-bJv|d3N6bgHiy1>zPYYv{F-x? zcTm;~tnbzaKgxFQuGOWTv)>rV0vWQktd8-dHkUAOl$I2hXL5reln9DtTn+O6OvSkL ziC(5PMw8;Vu*EISy$a5^po0P8L`1oDX=XZd3qelDESVzMcOR8C8#PY)#+~uNVOOxt ziSq}FX-`=`jenF}ZXMas6j)c95Z3B-t1g^+Z=Vy;bcZmM3U<#@ZM&|mqP<<68U7qy)?pZL{x01oZF%|;zy)qDcEe1 zJ-M zb}n}r**)N4FBhX9q!a&#SVsfr(_LCW{?;3+XsYbnowE#YMZG6otP0KA4Lh5;dI1)3 z^T!V5b*}w|NEQ&l>HN*iQbYS8;S#?2y$9k{=ctF+O(xXh{&% z{>2X*@Z4M`UHYC3;0k?Jn2vMw(#J_!tWt7qxkHpJ5&kgJnR!+7(N-$Q;Yj0vrI*Qu zL!hHIj&e9bP+bXCZ-|!Kx)aoqk^0;72tn5}5s|(_sj!^Y&f*H4?qPb|xa^rwB)a;` zg>}12H%eQ04VVRv67wPY-Jxvhq~Dm}a9mpzN}Yvz^PBQj+kW{@@@-;w%7O*1OJSY~ z(kjCQt1i&!CW8SFa{?fPxYBjqj?c9n87=}3tw9};JF|1wjnf8&4g#Yv0Uxk8)Ix6OH)`u{h`dHw2u!XQ*3vJ#phcVMYJ}vmYphM&o_N3RAA|rE_W(uXC`&9w7 zP6?R;&OyjKv4$(c@fdHRb z$oaIOrx}>Yqd|2~R|$Z>Cd*47TBG;nXsdunc_5+xpGE!);MF zj44GSvc0vZo|%8pZ*fN=X!`^o?be)MF4TU-|1i9Hl>c)u=6XQxQd}Hmt1*8D$2e>L zn3k&Iiqsnn{!;g|B2kEp3G3A>t2l$zD08t^oR0*%j8u#Z+;)=ad{i?pAoXIZ^wud96~ESd5mYKNi9IG zXp{qFfyR{`;6wH`DGte382a%q@7t%C47z>ODvC5ryTZB}pb>uE{jqQhoc4PYVd^dS zXE$`=wpYN?@;295rhmJJuNbM&mx$uM&U*Db@Jcg%NkMj-LK=aOT7It{t>52}u%m^H z_b4e2=)lKKB=Gc!YtnAhw@qgq`844>klF6KzRV&B-0klB{&4ZFjN5hmfOoH^R7F08 zQiXYh8%J5!aGHJ4m!2D5knUCeSQr2y;7WbY<=it}yL$$4fAaTzLi9G%N0^L?A)$j6 zt~N;wtZognJH-cg$meion8kHjp5}G!9F9196USiI zy~dd}=g-@93K0rnGK~*6o20H}iSenOrq7tark9d4DTw5?;45o(E6~k=G-nHnnC7VF zE&|6qe)Fb_umiKPj2;`STJv#4?u_YHpUTS2;NBsOs2EzT3-7r;-}U6@J1n)QM^1B- zlXWh@sLXmRf^$NJcxe!!mnh^`1wAK!vDa(hb?aMe{S4Hk|jhVu1p5$Ka|-_ zQoL3T-yM{mL}4+zyLE6;Ka;mxq8+07caEFw7nBTI#1AKhqaU@8qwBiquy z3_cz)4}-7BrXsqNY~C79%o1Ib@L53_Wx56Gm{QGx#(Du`y^G7mvzZ6V)Zz$rUS2@; zlqe}%k{S2z4jilX(2J*7V_kda4*xgD3C?u;itnE@HX=wQr3E@IBCxmVf{llNbI|Q_ z*#*Weh)QnlNd{Bavao@0ytLepu6nt_D7JL4HT-gfW53x% z7&JJ-hwB`d2_a*HUgYZz7mbY1eJrRagD{Ngu#7|*Q5afltBdHfvCRUZJKd6hwMfdFHsz(ZEnb?jZt@Le7o=D=`{p@cR(y`spmZ!|6Y}|FWJ6=s0>`N^+ zey0QVYgOUHBRqqgR0C?uJPKQ@J?UM``96l8j&UD*b*J(jyC0lKmxb>Rf^l5dBHPN5 zaoC780@OoUAZJ{BGr#b5tzRKV0fcGjUUAtQZ4yie6!5FZ8+4uSx# zRJHK!5-ZCIU*q$J-B)@kA3NqY2#gG-U!HU>VjHIn&PXr8#{wx+HLIuG&9cVOsg7ls zDFm@2CZTY|R<}}oBy`}g?==|7M@nJy4}M4ECqF1Z=byrfoy7u_0yvMHwsF>i=fOFX z8PBtk_kO~@AVuH~3x;<<+L!brtd&}GT-^+`j{MBJ$}al3kXT5CLXi`V(ZwzUO~2IH=Am~S7p z^b7mxf&9rp>KUgw{ftF<5;S~Rf#zw7^8J3JtL*?DIh@a5jBPH2R}{anEq z6PmQtiyz^Kijb{_@Y^%OMy1EAVtH)Bm3G{SZ~dGkAD4vu%DM>Bj3jOhJ?h??ST`&v zz1-rSZYc3BXZ9%bctM&Y*Np|A2Ggx%sq)JyO*PsM=s|JlI)j_hXn9 zRBYtPAb;w03DOpGWpMR-c{i%Je;YF`_j%pCQ2vLP^w!3Svdmco6&LOG}Cj=&Tt^!GJY}orwf}nnc%jZhohQ zAH`;*O?b0QKb_lcb|KZ470wPk;}vLcPM)?u&HV_>P|sQlE!^NT|8s>t>V7!*QDj^% z*xm)?BKgj0Hc;4N-}I30SHa0cru5su^6X~)@A72m6jVnP8%dqXkuOiH`}B5NsJAXd zTvdgw^;Pw<=M>?m_7pFOezrn@`Dwa^RwCiN$;!g|I=ZD_WxZVs?qF|sZhYTjb89o3 z#Mv@}PQ1A8Js7KJs)F}H0pws?d52>)!8=?yJafdg_!EDc5_;nJhn9eJYV-u!!FOS4 zSn7JdXCBisMu2qRa`HCTIOu$k_EIe(;F)+Y^lLt;I<4uMW!??#t3Nk#vs9`wW1lD1 zIW)Ngtk;^e*fAUH2pp&XHm?M7z}!p>p~Au_#46a5SibZG>N$sy{ud0NTJ7wrxP@8I zfqa`Z*IRI9Gz)(c-CTb_ORK!P`aP2z6>K$Tb~xDJFmdN@u0?;So$76B^lnbhtiyZ) z^F;qnX_fb+_8+s|fw7y7K@GwA#2585j_`KC#idG+V+!XgK_mKCT5JX%R$UF&`AEm;Ib(6?YALE91TNgB`}Q@LymTiONrD zWvWjSm^{B-9$9r9;#?RWzLFszL&ZsNhsxkHUvtWF=>Th~sUkFs_Q(X&)Hl+GS$eP( z?)q$nT$(0SMoZG6stRe?COTdmnvtGwSm^%DgqA^0@?E#l_A5P}EZr`Bkf1&CYcM1Y zJsBV9GU%(m#P|Y9Wc!ba&?Ta`9BrSn_^?@98>`a1;hH}?>i-OGzLz%UfdmlFyj&LLe|ls5 z=n_05AXgbJ%*9C|5LYa5(GFWmBW&Bx?#_rgCwt zWWo1Svl+{jhpd@k|F7W)`ErYHPho@b%+y)g!i`g}`@fy#S}GnI+2{y|W{GY+F=s() z>TDgx>LG)XTdT%gkrnNv|yr*3+SdmfQ3a zu1dY3fh&Go4~lWmCTw}R`qDP4mqu%9h0V(BX>azky>|x7n6k^=gXbj!%4Etz3S%!0 z6?nAZ{A4RI{(%a;f!*^p`6A|&KOFjMno27t>5GOw(-%1qS$C9<&s#tlTW`y5&6E>N z_?9`S&Vob)B|>>$#ko@6CZ=(5SFJ^%?5p-b>YY&RCgN*|W7zzh?#ZF;H~6^YXD@-e zS20r?Qk@~cQdZ)&M97ZG_=eKQaeAKy8cOvGvcll&jWcTBO;_#W?XO*nUvspMQ7+U+=MEVghDQkhjmYfnhhuh^G^|o29!_klakGo3SQ)}M z4dDCR@D>q0Y{q#~Ejz5;kNS;)0HstcdpQtiqYW0^t?=jB`&K|BYYZI?pVAnLiQnX} zjLV<4lrm|L`dqep@xE?r8-?X#dEsuuO=kF1Fm;)%#kcV8vZ`&Jqgrl^Z`RKZ-8j#1 zp`xo|(g!h>N}t1h2f;Ad2O%Tp7Pj1Gb)OMA6x{pv)2*Zz#n3Mji4AWV#nci=8_k=? zp2t?i2)j5F)}~+*9!cy~%v78te%HFdw;aMslr0;!7*rmTZCN)ZGDf*IO2E+*y*Gn)hK<vOS`VgTum?8THBw5o*J(w zTJ)Y(nm^<7!b51QlfQ)Fd8^GAuCXKpFRJMhRNDN)J4R9*w*5M_i!38tab?PVXp?hG z*E&cm{-`gVCgYKn*lFpaA5l8?`&E6+)Ecez&Y|#j<~=JeW%eqq9XY(*X&?~QqhdW2 zFw(yezB4lWlDopVyrobmVOobBIoJL8K)j^rr@$?e4vEyz_eAvGr;RD2%PTMKA>;l_ zDYiWO87GzdW3`Gz6ExQeH3Z>du^9t+|JzG1m|>E)C$_cl!+F|4{5;_w0MEG2^;8(K z1r>lCTCAS=8lNsZTywGOntz+e#2tUE`-&KGm{-P{1JD0c%Dcf<)XZSPs1eut$wzIe zB_E$zPhrq?XQbvse&LHc5OD$M?lR8Ez7Yz5x{{{NI@q7?ISe|Bz^Fj-?bFnkhL(8s z{JN;m3;7L@lAH{N4cYPd+L76y-dEVLgO0zxb6DOoJBa}IMjO*bA}Jqc%n=*=lqOH5>(v~|0MItk*B?VgSJNwwCVg3;HCTXx$5uVX5_B+J-nCX$BpN}p&P zI@b57)h>ldL+h+5uDIPA-#dRY*QFK&AWFVbyE0|aL<7iU0j=L+E7RmPw|{xf5>er> zw*8*&l-b^$#iKzyVTW;XJ^AzZ(&W5iRaZ)hj`~>Ufr)N(8CslYqg>Wqlf_$|_iNGJ zzWn=mlbq-K5U(CGx=oXznJ6J#*@0eKdhz_%$Q-D&{Mw*QStA|eaKh+OT>)=>j_ta_ zi|^qsR#L90Ottz#|AnUDV&UhHa(J#mn+GtTtnqVeS?WH{oMS%=Wj{ayvAZq_4x};HeX!-JSh810CQckM4(bF zIGG*K&l3nwwW=a-#fui|9p#oOE3{cCzMCnH@~7TYF-XueH%N%pyvIwt$xx}qW!LpO z6@eGkE_=IrLAUHrFTC%!9YRaI5ZmgHD?4A?kiirhY{0iCo09ktq|w)d{S?eWh5pem zT}t9BF9~{oF4@U*Ts9?z*`;SUKIHXAStj_8kkGVF+etYxZ)oeHhx&Jo;q}A$v8~+k zb;|%c(x68TnGAY|rn`}+SYsyl)xXod3PU zKTMar90v}-j_vkzgUQ>Tp81l$v`y0%^i(X7C|@Ww_PWG8>~f#lQ~K?_)qKLy4cnoL zpMjwzukYZBo)>A_CW_FDy&wqz)=1PiX*Y1gdHzcoXDXTqy&tjt-orQcwQ>Lz>^E}8 zs#Vd_z@9Ty6jL#9Bo=eNs{1##F7C^O_x|&+8aXV8gwKl%o zXx4so2x?j5c6r)v!Yw87+>FK6QRn zzMB}IR~_qzIyrJ$@-x4Q`mY_uFr)l z$yxVh&yEBQ@*9zI=4)9dv)amL$BjU>1*M-B4(p3zF&|hvCr+6S@-qFDR(}L>X_lXR z9ZTbQx&$||Z}ChWr*A0qjBIGzi2oSsiLK7h{%NN&0oD6HkaFsjg~iD3C;qiZ!Vwt$ z3p*sQ?g>zBg$QFR&;F{x3wA-GFf&My|Hl#8pj6@0wt8(72wZakZ;RNBnYNHpQv<=D za`d|-fT+fZp+bcetRqJ?V7T$`sZ_~yJn zyw|x@7p*PHW4(B%*i4+FNu8IF$^VjVm`MXHQzD_%{pW=e~GTy*KoDMwOin$ z-Tp_y5FTD3BT!sJatvm@y;;UfSRPvr@pY&QsuyIKUfeC&RUus;AcJZj$BoA4A1$8k zLZ+-Kkx8K~A<*HX|EIm{3~MUe+R{Y9!YGJ>h>BO5fRxZlKn74S1f&UsqDT!zdJiCq zVyih4bWO?|s%@d#!cW z`@V~+eMiut!RMV=mdU*d;V{6cKfloZ(`lb7Mb=>QZo6RMt(9?{!pD)RGQKaFFy+}HveS-67t-%S@;U~Y+w9x&FrTsR)`<+nhg$!`MwLs!K{g6P1*$W1VrlwUmBu_FVXHuY z*+3>Dq_UcaRFzw%Cl(qJ^zPleb9|G%s^QtE%#4lW0lv1rxh8D8*a#^FNw?~0?tg1w zbOz+8yiu>#-nH!uWkc@7anA)h(Q=4P!>q3P3$ni&+S6YzojH4BsMB)b0_g2iZ5D%H zEjD%0y|H><(9O4Qm4U(u&R*Un zEOHy;qa3Y+^=*=v<={FIxUzm%qv6U7X&|d^i1k-%-Wfh6~VI*m%YF4dx&tY7K-AV@;Y#F@d=lG$I>FHFjrK&nTDqUd`K_1y4I3Y)qOTj4 zW=>(!ZnY+i*M{8=a^ISn1QUaAdk;a_(s+k{@$7T+)X)5lULOtQ1;S zqPI6=I*W{&WUQtTTUD;wgUUM&i2)EL?~d<)2qQGqQqr;vE~9v(Lp=Kf+fmx|k!_4y z7;+1;>x?WhF%5NbW;u(BEPer{)FxL1 z@@zU*0a_@;Rph?6*ELgzTr^>`hB~sy!sp|$n$MvaZYyB2`l8zKY{MrrUv|aD1yKvLJfzF)->L! znrS+YqZ3D2@S$aa-FYamqZ}N=tlDr9<>#4B6p!z5>zg@!f{@e7yI?UEDi8Gi`rcF* zx|UT;JL#4Js`U1`Re)g)CVgAU!CRTQmGQdOX?G4|wMgaWkUOTXW9^`YZV_1*uQX!W zpRJ1#KbVbNGa6j7H`fqL@tUr$LP0{N!ZeO-Scy=#Gq*^M_I~^Db1fjuVN*QyoRp)D z%09WZQAH*=2)QhN;)4ujUpQmSdzX#*NNi!a_Im?CP!7&|C@J8NF{1JCl0H;UPyF8R zpF7amxRc09NPY@-9GNnIZLQ+d8?$^Bx?);~#6FXndM$J1r6LWSb^fh^Vd}2XcpuNg zD&J*G7LjV|kmA4+gvtkI4OtaY{Zk6zns`K@ z-*2;(BTJ2+z3!_du*uz-C}};H?;+i4)F|79xoL# zSY#4J`}FBo*zFS!t6ye9CJ9g8CmvxMebO)ZXB*K)2vew}Mfyz;F!Uin?-U5?`YryoYK!1Ul(cu6JzN6RYwBXRw;IW;imt_1f4nu zw0m-oUIN&C{`TMf_FG`;JNwL1v(4SotY@ylo%TC6y=P;-*tNU0Z`(B|c)Qm&hfsRH z-la&jC5Sn6$fShrr6tJK+&6cRb7SDzy7+pZnE$e*Lx+~_TKQ6YYBNUR{TS%0aeI7% zMdRZ=sI<-Qa!i6km<9<1h3_r*5I^u*%L3J8-FE{ozPE&p3%dD1Ha`wFDg4bxWtL2MXJgOP3uynym z-W}&aTIY;bYj-W#;3Gbcr0Uj~ik(ggRT(?~uz!YFZ{>fTYCVE8{;UONSexhj>Nsx% zUf&r>-fEL@WC4%bIp#$k=bHbC07V7Bk==sN6Ny9PY$MJo@A%4Yk(cmaMSJjv5qa(> z=1L-m>~o;m3*$5!;RG|tPH*)+MDMln!if38mMCI53-$V|A0;psi#}>p7+qLU0g}RD4;^hStJUn(i_VTfwY1&9X>RCs@ogkAhv(IDp9Kf4I_TeieHnS=n zaivZ4F#akz*IEBZ8EUAEzD3L$7%LjS;70X;6@PKyPvAVPW-I14uJj%g5Nn>-r)yFM zL3F^q7bU0_AtM2%QfU)!-uLh#<<_bt zPSEbhu6s#8AMq}k=Up$XD-CE!8{1qHr;vM{^W}SLt6XPYR^>8n#wGmL>8J!@>(F5+ zgxb$ej74}TAWEF=CBYwbTuMFrW6%jl8TY_t*4>>_L1`8#-i&&Wx=uN@z1@?P6|-@I zt&V0-8NO9Mo{~`EG1x}zwpN1zkrr%$MwXxRj7>IccpN7#2a>U%Mj>oZHpInTzSyIj^ATfiD zkU2gLSLz~dLLd!0s?OO;nch0o?u@dbn2jl^VUHPR@QX82ex0~MTdSVgLi*1A#g!*G z$mgY+1!i&Zpezt8AiR?U5o@zpK+N8ukq2E7S~pHe@^8OKbmebvjoV%SJ%>1wxa3<5 zGOv9TxR3_J3rW@7i-u$d$;$dpSBH(a!G(J`7IVjf>?vc8Q%Nh!qsnL%DP{aMN9t5f zXRX)f+OVm*o93_&KBJtxLZSS|=nrBKCClryaBq^cML*hhY9#y1je|qq2ku4ikB*Cp zm&z#|3nYjHq^`}-QrcI{MqF9c@a={ClG0O{>b8*euMm9JQ$br~ykbj#wE3ytBMxR{ zllocU+?Ru>1{YN9y_Lm_i5EESZa19WR!{~ZMnZ2&6#Cpp36zy>hv4R`7l?~I>)+F6 zw--k)spbSDd&jCOblCiUpTA|ve{()Fw6a`>ZiM0fVR-z5)CGQ5N^nM?^S47iO$0b~ z;xzG#RINTf(S6d2KfYq3jQw^`Q(NNGe8uJvJL*D@&fx^j${!Eh1E+^vUck5Orz8h^ z>e1a}0yrqB*uwvn3BCpWMS#~yPgCLxgm*rg=X|Q#PPu0+q*EHWu~br{luPbdq)XfEy+%tdf;i)N@} zij%SBTz-bmgOQkKYh+trROQa6I^}Wg<9zWZZuMl~HL`SKKF+Vye!Y@eS0QPrjqRCS zq;ke$k)xwRfwt(27>-hm;_&?o|J-a5-TUYv9n?}o)xz`!Tr+uGaqXO0jAPMp2N7CP zNLo}D{M9*XO``jKL{27*Z;uLW!}P7CO^4sV_U@n!pz)<<2&0)&AXlEj(ZY} zQWheG<9p#bIc^ST^j+k!;cp+=#bC|O_HpsKMCzmJWV-Rn&K6n$Z%R5jOED$lvsw)E z_kyKP8xL{H<4HLgZn`ruE=-~l z&Pe$t=+nI?6#ft!Pk9Oj@&fsYdU~&ZF#0_Pw4f+je#Gov599xE%}R*lG!{fhZ_27Yb4DDWReR_$*J{m7eWTUKKR-OFN=oLf=YCPkB62Qqz)ylSpxF+5# zE$W19P4XAhvHTUkl<(-8sNBlxF--)_M6tdOJ|kW8`&;wg3NM5TdhnBTT|xzgn2v7S z%If7ez+*pj5xPeqOY2&{WBO@3OU48)V>8>v|fPQQ5-sUu02# z&1fEwyUm(vzWe2qVC8nlqDQ#EYPDru4m{&4pNVd=kVo;#mc!&9^Vm_6Te~&_m-lAG zVfg6{w~|@wDnPD?8BR*F09=D#&gY=x&!;j@!%_N=Mj`vFoxxT(#`h?qn16wq{ zdp3lm+}v+KMGU^Fu&!QsZrme-=f;V?Cz=QkKe>zQ*J4D;g@Z##PUq%)XY#P2)UmS7 zE~%{ZaxR1Q{Ws0KVXr&_`vMd3BirvsLA~q#Q;yce!Yu*|5+dZ#O2uru^ zky3+IURbwUE6i(2*T*rk6CZiZmihhknJl4B&@}GRNucg1$G%vZmeO$^MA?oy5&i|GAA7n;W)|f*yWn*Lq5$6kIzXzB5z0e{2#ICd zC>V+Cm?0tWx2R(+_s!kI((KQYBw8t~ol=~Bp)bTeaf4jZ@d;MpSLp1)V(hq~I=Fr= z_JNvItx9WBXpfqEZ>hAVhj+4XhWziW{|ph;hxY9-O>3e==!D!WJ~_Do$WAP0xCp0zH6Td&ImhCN92r-cOX)A%pJEgz@n3U;&OHP-UZNv5U`oY2~U7_O>kF_gkep zkMPgC7kc+x9lwUPCoV-2c-Z##&|?kKKn}U_9sNLB8llMi^-!zGL{A|y z>2ibWfqc+l08o#OHLuUOP#OYI-n8v9uEuLa^*Z`dTC7ohv%Iw>#sYi}#xceyo0gi- zIfo1T@_D}g>Xy?x9*wEPP2W6?eC|yM{PXx_VkLbJM^eZ*NY+ai+MI2UuG#6}31@;< z7vsQFOIX(|ZR$3iJ4&a^<4leV+E?7tv_lgh42A(jv$4_PNHSRg;`~hA+|=jJC4)V@ z_nEWB4}Z}*h&?1_hoe2S98NZ1vULitv4AfdXgJw&Hp2PWHtjx0zu+}{^8MUDeFX3W z+y&w2hgp#h>qDBd`K0D7R~GO`gNSc7cKpi;9*C`Lg(%J+X<~G)L)q4M1K1cIeFWi@ zLAVaRJ9AZ=g^t0YSc5*Iw9VN=R9~FjIZN0IJavHqBnrSTPjHGT{#k-y$Thh{E&)0J zzkIH@a2y!q#eI#uSQf_h2JZHCAH+Gx^sk?QI04*eD?>i8|Iy$+P$SU&5&rhT;{$Fd z!VCnleX=ijrHD=kstbphwWKEYl~lPAV$Z`n0d^fnK;+gqSNq4$)LBpcTb)((?HZuY zdhKrEMS5cO^MeE8eiM$mCxS7|%@WzTtqus$>lE=7*&MLtaCP1KeR_>dK6b$8Mc7NFV&U1NX|=)Jbs`(aK1?Y9a5fQ8*m-r2@Sf%?Xduo^!s$pqf5ORI zX^SctJ^j+Yw>8>+$jOj3c9qE!cW|2+%rI<6=2BnEIQgK2Cxzb znzcD5xTMa&HA<^M2z-9>-5Zo-WZD5x5Uxf2&SO(6#!*ht|PK;0f!mzNw$4o<} z^&we638~nr>oV`0bN8ob1C2H@$2w#7*pm4>;jWq$TL zu#-genEFUrnVHw?03ctMOFstzc)_i~FcGuZlagI!@9pw~K!*v=e<19BqpWu3tT zXDzUOxR^=t=4lU|=X}|GLQYGr51l@?%$mVjG>W#+F$B%uxf;nNxHrO%hOAAtuDkWAXgU9T%P|P35+dxH!!K$hy1MgJmrAHRbSwwDQeUP z-^pB`t`dA=QQN^564FGE_(Q>VYG7)hP3XWu5sOmKYGNa~);IIk_^@9czxdil;U`;I znR)*Hk@T3VY^5v;An!h8zM7kK!nSgU!guhFxO%Ud+8D5|cL1YX6!5F86tIzL?ir3ibMl+t80FMCx@r?~+ zx_D4<9ALi!j=H7zw?YT^yEky6+FKv`gCTnbO!L3f`kN8__i6pFvjU882U6lZ=_#{o SU4IDpyQQW7YyJ(}NB;xGW7#tR literal 0 HcmV?d00001 diff --git a/docs/shopify-hostname.png b/docs/shopify-hostname.png index 63f62d4683f76d59aeb5a7d49466b7f1d86399d4..fd866539b28935ded69d8dafa75d2f20ff83eada 100644 GIT binary patch literal 63888 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 z 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 qjk@NQ0PpqnAl~6%K!m>s}LNma^0G_;% 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 z z8EcA}q6j#Z8h5nbf0gB##}PaehZ(>utVQVhD^@olZ#NIxUx(5-SB7@)E>xBSfjUJ6 zg56ovFqR}P*~5Flq#AmDP1EC)&nlUROMKzOX50R<`%Sq{_A1`1xc9p_b+U0chlFWngDz=0!qIV-#C$N`gyISYsn!ie55LFz_Ktp|UuMSHTJ8{`&?U}&t% z2(iL#1?C#4$gbX;jcJ~?WHEh$BNE)G3&4uS#Srq=0f+3-YITi#quT_eO6{9*zcF*- z=|OYcWWGqT7rtNT>h96wR!_a@b8+!_GO}~4w;N${ufq z9!k081N2_9`$$1%UQ`b6H#)$SiwRx2Z_+=~39JDw)fic~yJZp+UkKh}D1C%;E13QY z3Bl4OUezn{u$|h0?z6V+R_?H_d}MW1TarvK#I20s;4D22sS=J*CwiNMwlfe>Ff7Ku zeF5UXM-|stzf0_6B)Dq?4dW}0OPRx!fP~<*1FTm3085@@C ziIj(+)<`)lrv=C2O}1(VH8!gesZFr3KxSBi(~X)iF>7!J^rBvBgTB|Z^EKrofCx(8 zyuR!mhNlP0!sLdNuS^K)9ANH>W{p1>w>R1y_mSuBmtL6T3_u&*~t80hTq(ch&D0!^&@hyA`%Q7}1+`RL+ zp@Glj`f4F}YZ}r5av@(r$U@#F^@wZwky{fW`FI0IF{=ZeMgcqr2jxQtc=24yeHreG zl8_DIHJ|72FO|RO25TNqzo#E?(uC&M$Y4F?02jB%L8dkQwBC;>w@`4vkwrd6(k{OM3uvfTx4@I!J zXc7-mOWZUOZ>!#Y0K1B13Hz6c!{Z)!!a>N~x4;V9U}gk%0HjMvjp3Kx9;?USu03mAeX&bHEuF@92jCQEn7g4N|ehEg{+^ahG#^jR^PsTPl&1;Xy zdrgq(N;a#jN$H(*KDfMd3-U;sSZlJqL<#grp!w8tp8tTcPJPZMnL1q$XwY)~>qQH=UVUz51u-JL4EHK)Y8`_;Ih>bi8igPkg*fz?uB!Yt#?*+$&DZ zj58y;NPMDiNvZf|)~(EG(WT1W3EueCw$9Yn2>^{G|8hX<{E?ud^(*o3WZwro5USI=!Z9ZLpeyoM3`G zYr6Jo`mYu52WQL4J3fixGS7vwLIXhW_ebTf3TbK2wSpS1ZyBPwA%_x5ROazFBM1fZU2I1DBf>>8< zBn}jNQGzF?bj#T8O}w{;c}VGikY#d6>&;E~ra?nCIWA?k5uW+>VMBplt>H>Z>+tn& zVoCfU@uXn?Z5Sj+esm(r@SaW3pO~EzBsNNT5p7gb(HY%p>#3F|UDN>F!p%Z*+H2<%%j4K;xrIlc4S>YNIJ0 z-BC51J>FgJR0tZrw(XDG;CbuEi!>gEh$mxz{kD2?tMolo&+gM0Ab_2si?K?Sit%EH zN{4(H%pf{zc6bf4TTY!3bS?tsp%H{RMjH=i-@r-z84Pg&-MM7_eCsirxCYI!=8%ix zrRp$0^dSS-1WaxBuQe8b(5&v!n7SA2VWY3{N95cs`bv5ru@7mIW)FZ2#t`Nen~McX z4A=OX0|KO{Ss#@p>_6}cB#0C_Pe7AhBFl`D>8kJ9{gN~P*I!T?;x5d5DPB~x)7X&$kOeGeVq@`EDBExa?;%GU`z7 zq{B6$u8qz3{Oyk|)i{8N=fZhcBoARQ@vo}=vAnGy*f7Kc<>`#{i4c%P8=iXi>2G8n zl(opTFfaKQ=jWAX0Z|jw^@4TP^Ox|Yu*1Hc2wMjR$Gt%7fBl53hu}vNQ9S&c>-ZDv z?fxBlx<`faw0kY#Kfu&L1dLyuzjS)9%dxnGx8fBv_vZ~%z;R`;;nDszZtH$G;VX!*I7Y_GHYk5yTA z5%%{wTF4go7%0%l*2cY*X!FmMf|}@LsFUn2x~FRj7M_xF@btn_ zSf?)-funDWOF&pMfT%I89-4_ord`ZufHHOgw|ksQ#<2-d z*XJUa>RIRSBG4!5rQUlr-I0A;bXU~%Za+9t)=D9rEnC;0w`QPAV#&4os@-v^Yr<}4 zkaIZ?X0uZpysUXC^3V*DY*pf};7x(E=h;m?o;~l%-4RsL3`U?xQZAjhziH&$5%;*2 zqky6m{9pu$WL`NL796QJC*|MA^l1W+7)H5s_TJRn1))~ zes#!J6W{@mZ!YNKdXHKkv6USoZ{>fzoDLo}x5_(xz8c4O_uc}oF~Vy|)&eb8 z4-mVj#?98=M<&2U+W{u?gu(`Q>3T#kD0$fA>Q9A80{2~R82{R&q?;zI)HsS5?Ih_y#v%AzF zN)@2t;NmNC5!5iZV5X^b|GC}gY7YmOuwP!jCBVx9%OPLZF!ZIxeGoa#+$(nXX#w$< zgtw24w2Qw$N`JxKukR+##jWO9&9?YT#p@aj1R;JI694&D9D%K{&y^!LrO@i2!%HaKKSha3A+6WZ1jZo_`#^L4S4Dvp8nnDeVY)v zINX`ia12hpj34IN`{U%NzAJ*}%3eN+I(VC^`+CzbBxQd;W*m#!>lp@7Z=A~=Fy*p{ zV^oLe^d*q<+27Jv-dLI(#vy}<2K}2_wjul#pi@LVEj7+*BA$3#2b!C~0>h9Xn^F!; z9kY|i&1=I5Y6pC{^nTl>0^5PA;u{{~rp6#A_}KZqnovO%pq4FW-%~7h5nNmQJ-LZk zUpAcU--hW?18NgzQGR2Xoao$b19g?A7$X4`Awb_QVRML*yuBH6r=J-WxiRO>d0hwx zjf?g(6dl2=Q4b>;^dj@%JewtT3|m^wNGIQueh{Wi!=-aMA*!NwFkYiMR-kcnB5MXv zSxFlu#2m9>?lk@FEsHJ8fC9o;=f?^WMel=1F{)-&Q}v-7)+5+TZ_atgRvKpR{DXTN0*6F(%TwZ1OhS|!-RZ_-d_dPXPg(YxsI9Q1f^y38MQJ2s^09Uvd zGYjG2?@EITHgDe|vm+9kp%|6c_Twuh|0YD z0YYGA=W0JI_7KmNfSxk<1dVjj2?lKXJ(yL|4HHo(a_@pg=|%)L+Jjl=3P-W3O3Tzpb+bWU7&vLsm60QG z;6*`g0EHz%w8=n4`76z!e!`HMxV`J<9%OR~w#pW}@;%stC4N8a9X#VyBFQo^;uW7$ z)P+UJb$>xmdHqDlT%D|PCqB5fqonO{^RyhsJ*o&$638U2lCm9c$UDX3PjYP!gAGz& zO!4It9MOCWFz_o5!H|?}6x)NgDzSEnXqm?%pz5I>Te2e{zBpnov86&8fE`P{k*V1; zw7iIYCw5rgkd|Rwe!PvIH>QC<9xwG#KyH{rge-!lJhE)$Np3F{egP9c(mWjn)ap+N z&|fuH?u>+h4hp9+9zl7=gHl_KPY)OyUBIKXnd=vjdlk5Lvwnvi?E1E**I#;c1)qb` zkQ7NZQ7PY$e)^X7+$!+Y<#N0B*-KB{SZkiJktKG4Qg^~$iI-SEJeXA&rVS}9ZA!d896v5+xf`zQKVLdMP&qK*->=9jEujIsW$T;7csGdw zOh_Qg8DNm%(5*Qf#ct?NpDiipw)(j%z=IP=qA(vCwu5_?dB>Fz;qwuSbTiP$xKrnF zvjfW$!BLpm3Uge$2`TnTE??^Xy-iUq2=Zx47%8h3&fON&69TLGPG2yKc7HJDHt5n( zMXszPo;_LC#Zf32YM4)N4X3HVmu3j3)`K&kAcEbn#Pnje$co$gM{P4uV@}G)TzqK5 zLi8yPcDBqBZx2Nxop>vn9BC`4zAl>RZIr$W=U+Rz1yaFFETm&|v}12=jwTP0x6%Y_ zi3_oX4wv0uG=l*inIry9rid{UjNg@bBfn?sG;+&*tJr?>mG=6($G^A7aO8=Q_E+D| z{JPxeJy5m4Oi++|GbJ?x!QBtI0+447g>91; zJ&Y?Qa&rT=e#dU<2^$5Zc$g_(C3*u1WNJWqYF!OR@=%I}PdByKd%d}?-3~O`aTpI? z7EzNa;{x%WCHv4Sps{CjC=6(ct#4+if2s_|u7PvkPuEY*hLL!#4qCfy%0$#YAt)Al zALcVM-}{MV#YOj6y!&u-Q54^UX|PM;I2m&j0(&o0gRJ21J&!4@WzDyjN_T_;e3IO^=kcv*9%ZP~Rj648m`=sx7eSGeNP&)!~SFs0@`3b`UvR zB~Nfy>H7y{f@LZD?%`$v>H}s`&s@rCY^V}6Ta{VZ!bisSq>qXv$^@YX&UG`HXo)lS zP)EQy%)tpAprt-j-$Z^mRPln(guPml{+*Dr`OZ`wOcwon>G%caTS9a8Y;#_niHuB0 zgXwf^`RIWt>=YT?aaEH}eY%sg`I;8~il^@j7w`h@EY8KpV znCr|lR6ra_Z(5q;a|)qBC( zU~*9)enn_^Q7HM@8I!_($211<_<#|Dnj(-(p~H7J`il*d{`Z#JO~{ZhI`$2bVIg!% z?}EP$fu1())gy1|NR+c?5xvu%c$H8{co?FhqZo|ioD}Pi;}MoEwaK_@Yq879bW%2O z4lNghbnSIG7b}t=T;-c)lpgmJ{h2| zmWVffbUJoa3xx(mahirA4SvPef()WTsWb1x{Dy8gEzJn1yeu{bGoBtZfgUaoGQHQe zmf|$`orb2n5>z;J1i3BuI9+(UB$r@#YA4*mQ_3xmUE`xX_=$cS;&d6jnf>-D3Huk% zHR!3LT;nz1gqsFixwjyhwGhd}a6*GY9l%OJb#^=XB>^5Tz3GQ)Z^CVo;E+4OK{I#1 z6*_H2-BY|gdlY=@TH0!KOJztZq^(s$km(JolXPOo18DBem(%5j8%jE2j_#zSfj#zO zx~K(N6*OtQ*nA>Q%-4eh!Y1?{6A-r@-*Zc%gkIa&R70;`->NooqmC?#{`W_@Bom^x ztk0ixxd9!8k~vosW*nGWIfwk}n}&fSws+r$eR`bHB|c!fuf#Ubb$dfCJ)Lsnw;jm) z*pw-Ui_zpdy!-a+k&rCK19+Sv6<*BlKbkhi-&<*IJe?vXmo?_xu zb`Zvxc4x=kr?~?GI78BH6Za3ucvY*1#fn3I0JXPtkoW$+1m!aZYwr(M(T-u?{e6$rLVYHy>PCs$F&{ zOg1BPbaf1c2AWI%rBw20qWi1}2`5pO)b$4N2SY4dd>b9Ao+;DtuHU)3&jNsorf)Krg#P9@ zCi{vINP(B&Zz)YDQSwyI<=3k_ev_1n=J};V|N3_$FouKEsxjeC+^+#Fy<*7Pi}$(_ zDK<)ct&d|o=+?^lPpb{izy240G6)=}S7fyc6F8&^@|sLC4{Y%C=Qj-W!N3HTSY3p0 zJ1LX12dMuEpej6j3QDIY_Uk(05G-j1?C1)dfyFOSDGU|RX~XJW@}?I;m7hQ@fQr-A z_Ln02!A_(4?o#wWgH98W0UftlZQn`QzHEgKmE0rvs~YZq|2KS^K%wg_e0$fof84S2 z2xM)0B>PXkH2}(wP{|lgSiWa*`3H2I2vn$4WZ?a%C^E|U%7wkE=zl$dcj!=P^1k(tF6Du68$uuD>Tv(z z$9dqzc*ooN`~QC2e|33MNQjVl2-5!dZ2Hhg@M&QQ@X$K`!zFVSvDqBw2>V;z)XREv}7owVF`SVo_PFcuH)DD^uLS!7fa&5i~Sc%`2W?5 zU6FN?#6R~C%EdmzZL#DAYG^~^i`TQk5>oO4+CvI`sxQp{X!CqT44YwM2ejQtwzC2f zn$J)k==x*fjEez*BU1FHLGojRAlW}W-fmJ3K$i6}{n{Cd3jY}Z08GGbOCBcE!hL$8l!{_Zm8@aw9k1Y>;~1mXLhp3S~T;cPpJZ7BLD+cQC=>Dh$}R) zcoF}DJ(kQCCs@#XzU~-gkwTMt`0bPSt;R_)*SBL`YZI!Na`3GgsNFZ#smj;E_M+h}Q;r7Qb;uwdR+Nk0~Y?n+R z`EMr`=&_C7`?k7JhjIaKXzv8QBFcMADgLDM^65X}CgBXd_u_Dg7j+QIOyeLnJjvP$xbNBiYFxwvH1+|eD78F-65zmHG@$p#&+pQ* z&mc@xIt(Tt-gX7oh388!t224x^8($!H?Q-)RWU*&x zF2Y1$8F3w1U?tjNmRV>3ni=Z?dhq-FnWuUbNlixY+r#p+ntM_sG4SXRVH;%fMILJg z!e^aVcl_ZoK`FGJUHhT37HEXA9rtCG-NYgyzxz)!1{Lc|ltqi%mQ(;r`2_~*C( z_a9mWadAx=2Kmxq{Dxm_4)$VNB3g?Mt=dyeIE+x4cL30LED0FH8)s=_Eq$2^q+sS^ zRy#OiG$yn9d0?VqJIs!Xh(CX|m?^J;&A2Y`oN-!4!A1L<1=Qb%0G5+SYD|Gz01uM| zR==>%fS)>K9uqj9K(U>gt8FGGESC?nMYzbV8!p}7gb^58C1AE$T!D@>3Z8RRnbyuQ z;J|gGO0UPsN3XBIXkawdF4S{;2LA&6Y+==bGl}d?Jk&AZvYdoz(lWra*X#1x&qfaw z5+-9VU+Y7u=*~@uQra(st-YykoMh9XB$)rxH08R zbi+Y*KJS8}v8PRO#ls-eoLOprvBM#-Hu%uigJWn5|4K2EbF;uGOh>!;cj9}y?lu(jDsKLi=%mYP>o0|8;o4hbCkX0cC`&(1h zF_=|+d!y=@t0KMALaoqF&aJ^JWGf~Tp=ym6lh%9db(jzaqYboyh#+cnIA8$c&0o>c zf_eRn8?k#s&?>2va{fwwTuE_|FHoF~zE z!@z0hR|etEwct)~S?UjtBO*J0d9VKS8o5WFIG)=2)+!^XhSV6at0QDrCn2YyX_SGh zNAG#G@$LTm<8lZ{cJ-#v7a%o7BV~0xKg+q{?n4+($hw&D68TKzLI;^|cO9|8b zu?BuNJ2BxOQJZL_FI7UFx@pFz!I@QXWBJ-@RzvKZw;1UE^kx63`^as)! zmgTl90Xm^Yg?mkaCKrVccmEU*$j=h}xIU-K+j$>&!dc32tGbr6*J%^tG%hMn!{PP~ z=m`DFvK+9xsn}6x55sFH1uuOn;e(d8Yb>BUaw-9~wH?wRo`JGOy;woRB6vS7RK;Br z8AB01!hQ?9qlE9`;yT0ipOUlbNik1d3zqQC;_zqIpQIig<)kvL$ z1V@FZ_c;y@0R#@2}M6ApuJeJDM?og|^~IT>+f433X74Z&SFsFy8u{~kT(R1P7DwZK20g)seSTmBssl(u(GJ_l&B( z6XXafprNxdlzu!s2~`v!zsnmYY#p9&IMTQYQlYEJ=`t!kYJD+=JqIIl3Bl?WSM;V- zEs96mU{yzgG9Z#j-^fRWi($iguPuBAAIiu(8Dk)PS;~8{o$ig1qwujFCVkTedX7jc zsd%GWa(m4rT*=l|AR1F(Jr{M$q4!4L3Dr7~o>jkzSd|hunJ(7)_`v`W*041mhyrVa znor#(rO3V&nT5YAgWA3Ly~V6!(YM@Vy>)fvl;{Go&XmdMC_* zzn6sqNxl3M)8uJ(16gL{g+@zP_8B-Qj3K4hmYGn58W8=oC3tDFa?Ky^^#Uji?pBg# zl@~WZBXOJ37?8%;u);=9QlgE^T3tL77vnJG{R*tLe)9UT4Jn}0x%L>(;DfC)Odv)C zcXwHPH>BW;)yMocX@*f@Z%}cTpTNxlvZ?b>#{>+q+L*C)i}Js`Q0JV1$}9ok7m6Rr z21_D7stpWSkpbeY5_*kvpJ68aO|!9kDBvK8sr0;Wcz)OA;XL-BrIONv z*7*JxP%gz*%==j;emJSY2)$+Y<@|oTOLO5)xDsN=>w#H|94C+9h~H{NyGU~soNVo| zF>B6|GR>$$>0#X&8gBn(3g$!0LL!^8y6vYM*6Yt%8KhrSm+NM5>{0ipYVC6;ZB#9N zu{ASsRnut1bV=@NXIF&$43l8sV&;@hNJpn=uu0G>1S!AiON8Ac7a$#M4uUyzRuN5%9O z{ecE3)QgFvM0=R;$m+#-={!e~LoZJ3J2&tB_Q@okN%f(|2zy{6+yAJS&S&&c@fg56 z&#Or7;W4D#cSq|T|JchtX4?tR5J2hj^o?&WnIzdgF?G=ArSS8yofDJrwxSQ1jO}PT zAl6ci8q)$OGaQ>Q0lAq3HHAP7M3{%L(SB+lfC<>`w>`;2^E9th_zpgW6Nx2kGgZ*8 z;ZkiPvJ>*Y7hCW4VaN<`R)aSa+qYamedYs*Zp8dYtQAP=uUxVA$&XnFTQL-g}Re7ups+7d4C9pVPyo zgKfiMlHj+ltbz0*t*9@rU;X4bmEhO+V9mjDR6M&>t*^L^KzFE^J{nI7rmq zbN%IMh<`l191quoBXp2{l&6^kCI~Gqtw6aNB;67KOea8%hN3kaK4VoDV7BcSr=ILlR6EONJ(mc6qX$HY58T0h%Lk z9CAbDDo9XFPFZsu+I#!n#<$sk8Gp%*K9kP(UvS)C7ysrUUL5N|OBL`4>qI zo6w;o=>8dEAlzdTWsTgqKI|C^qt$G_R~<~D2~~}s7xDksc1C^+c0y0>JIADcv1>;% z!Oz3I2Ddd8IUMW8TWj+n7^R^Rje!jnb%>H|ht44iH8`3ZBdGR)(UHUM#{j^KXxkCP1tCC>LPHK8p?@@&NZ%ioT)i2UM<{b%QG z;DYn;btoS;<_FA2=_h_li-X*C?JJr%eS><}auvZDmAl(>XD*+Nf$m5KvTzzmxI0yK z{W?HfBT-v0<}UK^z^7tO$npKrnWbdK#o#mcfC#(|kn@Y42{s?~!?aX-UJ##wiL+Lu z06tWF>bke9Hve>XuM5n30Y_k4q(T%IQ;gkb<4N#6d@RQ#-h(uaB-C(oyq-1CYcon0 zzgrE{1!Ekc;OP$JnhLOD96kA?dsH`n^9fbRC`^>Ypdp-ZTWLXGrLgY$waf3JhZp#0 zLDB3Ajp4{9Q+~@8V+i?((O zUcw%L!Nygcsa}VEWmnyUgEYD&EC!il&hTHKv}_8;4jwJSm~MdahttiXWHR~a+M~1H zd(8G;T=Bj>^-Qjp2KNiaD{4W+s97PV&p&&n>mu9h zYIkJJvD${P)D*UYGR&<@%+fWN;jS2ioOtwo!Cs|`Fu8lC?TW#040vDio7%zB=Ur@5 zZ<3awk|Jb>vbvXGU~tD&fIqDNr2R*EC~YAX$Z89ajJnkZyJ0B+rbpKY?#Xov zrO$c*Y#4oS+TE<&)tKC^;jRdRnWL3A;3-mNfBi|=lrxfp(L@fKZ$tF5rzyx(ZuZSt zDN2&@K%dkG7P=SmkY6VKK=g|>+^|ghTegz8+FPKDlz%)4W7R(917M=^L_AwcQwSRl z=nrkkPPZyF#_eb`f+&=mlL3@|6x$P$A=`I!fqZL>f_B=RZ!BEbXV!V(xjY{|3QVE* z5W$)!g{VkNlG|Qa2d(GFzP)eQGo|4)3K6{yxJDY4><_gl}p!x*Mq?}Cn9)IzXn8%=&DI7%NR+fcwh|-Sc|cP zcbZRB0T2taR3-96%tNY0aa|zf*s&-&)qM^&vP6Cgm%CWROiYY{phu6AX)4poi^jf z5Ic0N()OyvQ=~mYO_vuNqV;KJHxY{y5QSKrlw|DN>&!-bA!Oh96s8c>VVjxAavLUm`}jr>6i#x{wwhHw4Le^ zi2qU#AW2LX#bU2%R{6zMpO+r^`E;LQ&|1GvC)^14gGxs)WUj+Ylng?90koNacr^*3 z?DrkP3)%+HD6CdO;u+vhMOI~Ht&oRjQ$u2y2sCi%m%a(RbR}G4kS<>cScexfX8#>5 zEzB+Ci-8!tVpFi;$|L?8@y?5`!F=A&m@#B0dRT`Xb22g433U_BV*xK#E7rulEv9W- z3UCdBR^{3UmNr;b;j55k{o*L^NFdqBiRJsH-Q2eJCssjO?dND`GA znAlv!bi|Z~&SPH?xsy^$Jf(Y*1TSl5ZeO&YHz9aZ0ubwmMt#yp@2oewsmS{c`@P}p zR87bcwcSW)kPa62e(7a|IgE+8OqF-lmLW?S*A^vMXPv!fC^SaiBLe!IYblklV(MPb z0|GsM4i3Y6a7LB3!8sWNWKp2pd-Y$|nm>1kBcPU8lTK22At-migoC(R=}0NC-YG!6 z)7xvqtFG-N;djS%V0+e{!JSm8Vi-0FIp#);zH~e)X%CVS4M%NM!1HKFo8nnG=A;k! zCf9J=q{j*SKB*Cx2!sv4(u&T*pBHTLsq?-(SN5m|^OB(0$_jOXqg@Ii@6Uxi z;Ferss@z(T^Y?xjbT1CSqX-iHfZf242RHj9s)NxPWk~n%y^Upe!#+Tr<<;+a(^%kNMw`HFb3Z zzI77nUWHmR>joPZ4ZS1ACOXRcmZW@^KK0&yx)Ip+)%|d zy?`(#9#s10T~?)*ej;Z497;xRAwyh3;5b{UhHIA8g9(a(4P3~s^SWd+(UK2OGNsN+ zDSv3=gR7}Cg~OmbeQvDSp(r4G3As;9~_Vht;r`e@KIBcoIPQZ9`t&3s)>7+0K zP+eb$>khduQZd}g>$lgd2#)}r)gvOctMTFzL8GA>%ro$MO~PLPYLVX&3KPRIt|pql zuRYz5UZ7MQPQ&vXdEa)L%H9UUy#uue=d#}) z4Vg!(1AQkI3q}y27{j!d#=$QBU!G!LBxt|2mXl6uz zEEJ0jU2CH(ZV~aaa`|*U3sc&=d~(q|IabcV9bw%GTknAZKM9c)R_WaiFz~Sbz!|eZ zRAqq3Wc$@b(|}TEq7kHYyO!%jLq9#jNUn6@f`Kcce2f((FHrx$W0$=USU)B}> zc}d?+qy960N2%rx81Pw^Im&r~UrIh0?7uS6N-cghIKCW~+lMj6U5nJ}tD-JPMU7NamxQTi*%47821=nx(igt}Cf54x8H zSG~);h&cp*b1W6>1so1$xP|$^agPuB*9GRj#rgeulb;_{CArt0U5Fk2{{C3AJD2!a z5b7Gm2c`5z)}ZCLvI}vTMH}GbT1(f(sOx|xBPt2fGqlGy?0#;Xzj#x1R!*+%PGSr! zJ-3#W-VFi&EsqXUtAZz!L1G4eV+D0Pip=(HrS@}Uv7f&UQ&En>3|J{~Ve#M@RpX?P zNZS|C7xI|rHFEK4i0kF@EO#g`rtsVc>Jm#MjIzh$(>_(g#ZB5w#CawI(Z#0CkM)E3 zIIQCIp+=bFgd#*ijpx6h`gL4EP<2BW7-{)|&Hhzs0eZvtUb1vx(Z4ZU2@MQvgM(DF z7$)ohno2Q1jqR>ZDS@K%``7o%xS@NWg2w^dlyY7!uRbA@2)kEOfPN^HZA&WL%YJ?2rKp%TF!FI2);otghmOqL4_2B`VAW(hdb29i^gGQQY0#9qGJ-89`Ct?%~)C{ zKQ`=dH+XzSkH_m85zH!zA})-YbOK(JdJ?hbM7+yB404Ed5T>Pd6KL9NaV6xu$%W_$PhPcxhcOqddKp@go8i$?9Q!D_gJqu-c^wK z&x`kex8fay@Sp-lcJ0NEUkPqL4QZ^qb7p#`XkSTU9=fYb!bv4XgEbd3_J{c?99A@a zf2?d_N%(iIttuUhzj)2vL!p5P_t(;sg?XrY%!CW@f^hTkN$W>L$h$Gv2ea4>z!z7{ z@dHT$?oxR?ocxcXQD+4f&n2hyyp&&21C5voD_G!1Et}p(kh7y*IO#k(fg{R07zHXp zC0VRl1MJ^(gyTT1{QKyp0Fs_a8j=a zn*Zzfb78#LjAQui<&ikUY@zz>FX1;9o*#^+b}ILP4w$dO`2QdPY9?~g`w$sI^b8>xEX`LEW&f9?#Q z2Yl2&+b7oj|6bmIaYIrMAv!BZ_QKwDmJKd^l>9ca!2Uno!TF{4);D3JT17SJD5qayg zUETWvsNtg%_-PX)fBv$6`dR<^w*OtQ|6Q=c|6Q>E&vw|BH`JHKOoiSf6+U1RL8yFz z?5sq`FzwrqiQD+5(w{>(&-FP~o2O^EnNd3ZdN(8mw?iCP*C0>Kp0E1bX&TtXP0F>Q z)UbisTZyd+k~hg;mGV2Ueb0vux^rgBgvEmcb&J>>1aR-I3n7&=wPo%KJ={AXaL0q+4(#P;_Aj=~@uc2EbVbc3!PG2a?Q_p)Aa^`ndntWmafU z?Lu76%!ulMx^fcqmUYj0p-^kT5M|aTctM5J7sT?sM@ZPtuQKOUpO^&xD~+`ElHJGV z9_VOnKz(HQ^q}c*iq`;w$D`&>R8BT>^``sHO)UJ~7cfB@SmT%ra*@|Ro9-Ck8lyRF zM`Bqa&Tt7rdPa3WtO{;Uw-0s~Ogn=ZtR3(swegdPP{A|lz!+2TBgNweX!#fPK02|JEC>O`zjW)xF%o=-{(3$AgtN{<{mp-+BQUB+Ep-Az3R%^3-ZK2ZggfJ38#7$CpJQiAxzL)mb8_s{Vm%^#^;5;ZM63dPwrfT zI@ShC?lFF-oucWbUq(aENxuP^R$~Z}ZhqHOM7-Amf%S!JlK<{=Gd#LinoaO%kG=}# z_onoNHzbF6wrxS?GTe_zfJ_@tsUoHln2PBAobl9b<{ZSSmk_-IE#87=!*ov9wm7WTMSSroxd^J0ZSq#W^4aMSb8!gt?8*PFjF}aeVS8GvpJtGTK>aIS_5D2hv$a z;8hkDtBar0Dm1MI2nWPJp4r))dqK)4RO1Y8l1b>z6=1xd_C4vHe%K2a-+Z%a8f*gV zQaIvC_@)TMCO}et^a8@ZtaYT8^)8VL@Om@6Lyqfi^}a4tU1@0 zyYHhpbrSrzUN3>hKrUn^nG3{*FL4eWg`J$^YAiUEiUc1lWB7MpWdD+5x~TM zczen>IXf}>#z-h{Q-yb`b8T93|9ijx45?O7XN{_j(7YQ*>zRi z$7BEoE_-m?kxg$GIY=I?{WPrEcyxI)7>L#qQ+~ZpS_`;fPXA$=jlTwJSj#{*6{>{p!)!*Mf!Jmi2QP zJlqu;j;=CY+0Jv{W(w2?zHN5X{$(lq#|G7M77xh23@kc?VO~@0N0Q)@DRYVM;!n-f zkDR?+WR+T`H5vW*OwgO#K)J5_EA_|GeZ-6^P_3O)*Lw}Hj_akqw$t-ji`3R{S3zX3 zlws+@fKm?EX`C?UF)USK>L#z2KnKmQUAPet8tn#POfr&#XkQ#V+jE)9S|nI_y?0+Bd`=L*(%yYHfbo?inuv%ny-|j==mHG&NDkB;kp7`yZ)k*~< zHE$Ap#{YVU2ev0=Ae^kYg6giF(7(W3G7(Rd#5DB51=keF^pmz&<1L_tepqKJu0@x^ zkv4LTL+JoD4(FY6s`u}n7C7`!RI%L~Fv1XJ14^2NEJH|<&Z|R(qkq}6;bv>USlJk%4j7u)7a6ttN~*FL zGK;7TmAoH5cG*%%Si0JPAQ-F&8?quJh~}E!i&qwy2F$)=Ur+5G5$B3Pe-Wbd90FUD zzzB6=2Fzk1%GBADAn*+}yyaKJ~?_=egBM zKTGG~GsR>Qdg@;3#6d@NKujdpZwC{Nc-zHre4{Zeoo99UCzx=k5#F>FFNAZ3s)Z_6A6dL=&umg-dD1~c&3on@vZQIhlw=icrJ}x~ zC*U>iorCxb*K^urEN+sAo;%QM<5pJZAVMrm&U*&Jrx*=;>HH;={qcS1)f;w82evJ8 zq6)xSsL4h%E0Dm1!%D?2k)Q1?OCj#769hp>Np*LQ%OjNZ9-WY`>HX`>8>14q8Db=X zMnDMI!}8B-Lt3EKgpR9QJ434{s;&f?Q+we1^9*@8mnAhnQ?M8b2v@)*Af5ryhpo?m zMJv^sAv#DgiI^E6?Pb8MdubeoUd&9&!d)^bY>I*+?gl1X(ox4)l2^5&E{Wpy+jGdx6 z4LfxvmU!r~RV$URFs{W@GF~=&@eAIdhZj2;WECiJF9RJ&=Eb9->mN43MH9@f$q`H( zPcvRyS;3-SR^6}nT+XL2`c=F`vG!zilLYRtn6Q{LscF#UOCu$98a7&vj(73+@8EW~ zi0TC(-^HxY%YZwwnrXnzT|e69wG*zPDxo8e>)jy5z+Vnb13jA!W7+q2Au{RUH=!5& zl))*0?W%wUd$C7=DoqxXsC1ScxE5)ZFshGIWR{b8!C6}6ecX(bFM`_NvLuu|H{7MlveTfX{->t zsjAD|f~!Wl<@(0oN*?m<0sBZI_BmA?q&5U42HkS!03(T3pZ9DD}e|Y8lb3-?tnNov9*a$jHA|9>>Hh z#Ab9EH00~*h?6{e+1u*8QY2O8sU%v1WbuNBbcPsx+IS8rJ<&B_xYdgF$Sl*v?R>tS ztn{XfxCfB`xTwBECdT0pE|`hdmhdMVR7Esm-9O3)mE~^%(ru^t=7{H|lI{h6Qh#NJW`WTYa z1{{jiSUaRPDR!1h*T>Zq3k_Zq^Bwd6mkCGMh}zvMJcdO&F7@{bMJmo=4~b#!)hyAA zWOdxN>N#2*X@eCt(h`OHf?s>vI=dt7!H{1s1D_}@j+RcZ?atASBJ1t0ssZ`hT*d|; zyQ6E5nM7CiM*rw`f$G4*viVQ=^sg``qd7p!q!~EMWl59bc&8*VsjY2`*e=tV85rnC zhiuXzA3=e`Ne+WJL$;rA4bfOByOWMS!}~NI762^t_vY7^lZ(fjVd(TEP!07FLKe`A zEP;%1z-s_}Dl zV0B*ygavTgxavLhQYh04$N`&lrM1LU5bH4I>N=1ApZ2~yoXWLrzb!jTlR}viQk134 zV=QGRGbNG4Vws9iGDVYF%A8>pGKI|Zutj7nQ!Gl6N=P!#zw4&<+xPBw@&5NazT^1z zU;D^f>v^90xrggIuk$=F@m@v$kX+7hdpKYc`qz3M`&s<`oW)ul4rfAsr&astwR99m z&qfY>PAgN?s!6HQ9hLv&7rQuYHp>s``3mC};%bh!px_d%mwP%2YO5%qHYm`H7 z3*GEp;rUqQ1i6%eZbw|gXL;|?KG~}#<$tRJv*n?Xl*m_)OeVA5cD`7t80Y+6F}6zG zGdr*$m{I`GnSGKx28+wx7F0e}e#`_xYKXEVlc0|7vU?vCgN(-<|~qP*IcbC!|+HbtU}w?8+@i z;zwOaokNO4H%h?ettG&lbgm^sO7ci*tD=9rae4A;7@;C%J8Gy!8@o1 ziVrhzR0Q=m<~&ci)zPrscy4UGi)JCW@~o*7?&o`C=oU zZx?^s(>^>>OSWs^09&d;5GKyW&gsg~?_xTbt)qg^QP1KySQPk+!KHEAjS6x z7#L8YtyV-Kk`Lc@q&z1=umJHO(BqDm!diAxFlj}#cD=0`5XXF$$KQgKOgQW^c%Z7a z0QwXT`A=dFsLVh8IHd6|0nEhTAiDU_+w3CtEMFQ-Q{a2=YoV0)y&5PGLyJMm&4o8! zr~J8q=Pn*7TDiQ1lE1iYXb>W3f>3sUwM!)*-vybj`}BCrv|L>dEW0P3O<(@niZW0| zMf4aepEpffngkul)0ymd(I2Q3;?t3N)(DtdG=KOl2XZ-;C=uQ;vsqAN>8o%-E_Qg2 zxltVK=7ag=i`HTQm5skfVoh0sLT59eJ6)1>&t-f!#MI`roW*o}fNc6@m@hVJhPmbv zYsVabG7YgEMW-zd&2SyhlAUs@=fF>|=0EN6zY0t_!YeTcmzf!-L;lxKPYY^b@|=qp zM|t#liPK^XonCtqp($-j@KNul3{p#DfkXZaFd-uOEqVZefM^O+XDL8S({g_tX;B4W z+X8k~(B?B+%)_xn}A-EjjtlghdzMVT*N&kW5vG#7#ydIx?9*i zEsWo?aRRJSR$CtZ)sLGu2=Ve%b;AJM8F<9d)d?H4onAz3IQ75{i__H z3F=7wYA6d zkVn%9p6~qhxjGJ3q6Lq&Yao!fSxXmw)Q1epzq~t`%UPqo@6DNe6+jV-&6ciG9=6+j zx0Xm9LDnaGO)HJ#GwewF&E~ykQP|Klz&>7u_OqLZ+4d(CVzMM;_A8kTaprS&?=n5P zilL<1-0cPf*>K0s3wOmlID*<)v#gmM+B7g_y~t`LgSm^L&eWhUW~|s(WjnQi#$u24 z;0kAl@_q8s+nR+?fb8tSuOWdligP)JjF8aGcf17oyDaPWziY?1y~$kCut^l`2Eu2Q za`ID97dyo8d*-=K#IYm*&7fT`qod@i<+;=ZI5|tZE#Tx#T>Iqo#U>tIuAa})NyK1WPZ4T9v|m#5tm0Ww#cURa*XSWXF+_sZIZJcph*Hz6%1Vvh;{pG)Pc zl{5X$1eI?PeMv;a86jjA=M{}~pD|gXhgkkv~JNhhHhKLB)4S zHX^J5hm0w7gCYvw`Nc5aL0t^;^nsE&R8kfD+EOPla4TIP{@$7t0X!-@MadY2E>#uGB)H6lokF`&Ow0PA3Nx8-PoV` zDNRo|LGW`&lx42{wueQIkiTe6U-A1__3z6kv1#d-D?YZ zBM`_80^(V_`x~$qfOkB-^^_Z~Irh;*dN37*@gOEwAY&(FNN^n*i>jKLpCwfmsCWwH z6%$>>H(y+JfngUBIol8;r6qh7EIvtnWp(Jy&B&BubF%;B{mI?J}Rt`TLFC$jcDtFhI zH>-d8Qh;jo--y@w))@d${YE28F*7P-v9*T^?i*V}3$w=dO~-?EboOu)BcMr<6W8C_ zx{d`146|4|B0m)8#jLd+{(fI%g2XP*@SlpUwX#)P$X4xpawkpJe=c#f8k*>;?AL#_ z0Y~7~_NS*ES}TZOy)9w{JeOnM{wF8bm{zNQvOkBI8dt54)BpRl`|YdGmLHUC*l@r{ zQAS#O^<%G^fHd*{dHND8Yg}`l#G;-9c+kCZmHDbgp(@%FHT&S7FYPKBC==_he1)v^ zuq5&h=^nAkIw$cR%D+&PbY^=#imJI7wka6CnT$ zx;cm0qMxVSs{r!8QF2*Fe@6j~7RmL7WB1t3n4}Rex+k8OaWV4v;gxYSzk>yuuKVd% zq^ExNS;F&87AlbfiiHpK7A@_hR>m9rp@JNaETymExq-{Y-rd^v9W;H*em$#}MQt%R z2u(zNetuFw6SFkoBwP&MyrS7VYPXo5jHsMw?1BO7Qqx*jOTgZRRyRfChD8l^vG<%; z_9zb`tyejJRWI`|qlnd)_WLFbhsT%8K>weT5%SlY9JggzeY|e6v8#7xif@wMm&0|) zn0eHnm-|HPNHI^Zjyv19keHO}M_^~RFimLqNy;Yaq(+ypI?CFU}St^w<}nnG1i3jCGJ=T)2!$TPJW|eSbNF zVa0cwzwBiucCnM*r`1oAF{e+wY#^QoT!Y_v^XK(r7IFp}*rxaUHU;o}hm<1mp-mAN z2DD~~BC8rSE3@v7Y`r2G{(!O~1F)kUL|s2jWa^7P1D`SnaV?O?m+2n!PkJZsY*hp& z(p0|~06~5@A_p!qD(-t5X;5*wY%=ZAikj<1BM;+Bp$C%pdo20T`2-KyPn5yFw^!CG zi^Alqc*6I3Lv^=a z`z1F{+^aoIElr8g&+*)EAfn#1Q4rAy?BNmPfqgCpEdbK&dQmq*ROG{h3?zkITzk7) zp)IgUbkkiSQytjv->NE(p}0=dk5pngP-hz;_|$&41#&|ol(^24-`dJ3#Er>q)090u zAnqxN95p9U2_10XI%~*rl#zZ4`V`!(d+Sszk+C@4D`lPYTCPyaQysvy-j5>yKCv%? zpjvfBY6;c?TEX7lbRKlb6RUi#J);G(&GORxfM_rzi#E%snapV?3raQym#>#WL>0eVRfLryM*IACgd#QTZExY^ z^St#2?c4!yZ(m!7*V*kVtoHAZdtvpgw$-`HZZL@QfY*k*oT9r>t0mDd`#m`xV*o)D zotG(hTWmqe)lC7md;Q>Br5wb?o~0t7OY8^ur}6V8cs5ubrC|wWwH;!g{la^ zp~HV*f{w2UHf%?~Bj30mQSAILPwqGl1;VJIwMwFB1&ASM9^BKhUt3Fyp>mSUYyhR- zUU5e&H!qMU6#O^_azYQr*T}z00QiAz(4VM;WkBk&i9#?%gov#%(z9xr)R-r=DMao3 zjC>SC-?L5$cN*PX@t>gdLiJC{eShua4KqgwWRoHF8^0;Q+CGlXu=6={1MNq&RW^!c zK&j4S?}bI+NhPibVo^7tR+uzeqUEBDAD)4h>Ivr#tsGGHNWp$)Ci;bKj_pib58H;g zT7_MIaTk9pvK+V^?B&hJ1F~W=L+H|~BZ?yxGycjTaXUb+i~AsUG{47g)~0%zyJXV- z5-mNK4_()h$Fc`~YvLJPKCPm(K=}$VsFOD9m%*F{Pqi!ad}2GTnA5X7d>DW9sasud;1%_ z?;_NA^6k11uzX4Wpg)_qOfbLqR=RDuA-ZRiTMU>B)J4A!ya1?5<6UDnI3%7=$Y467 zVb7CQ%T|p$Od+i;R!;OH+=re2^Dv8s4@VXrNmoKH*t*Dd115O>=;Z_HnP9znnnx%2 z7MGarnG3fC-kiIVfS|4Jk@DpwuB^*j0VAA;fBxKaoIU0Y5P*5LL*NgVG2GNS-_uEaR9GsF(A*=>FJ(oQcul)q-?z_3>@RgDUm{6I*Qy(+wDxn-7wyAsSKw zLd+>FWNy>~r1(I})`mO|6^QUa(MZxt;RslpHVNk5V}CNZ_xFs&mbS+p%TkrOCr552 z0YL5SZs9?on>0O9C%2Ko`B3(lA5A-_c_7OpxSl|BB???+GWAA_lP^wg$~Ue-w$|w| zf9h!2cOFp6tU6TZQv%G$LGboZXNuKfM6PyXP$_B!X=adeO$msx@DMA`P9c9?wEuW8 zBBwytxrqp!R=d?;`&YQ~dsx1D?wM0SxO~=@nyvMONsHHhe>>j_f8fpxs7=V<4IChzDXIb+f3iCFxXvD5Re$KQpCc7N`gkM7lC znTLi>v?mxoIH98y3YlfErp^1o>?0%aiBM_);*&%}DB4BGAKCyd!x6Kpi!R#=wfO@M z;Ke~WI(nYYxvu9u68QwH*E;ODI8_9vz25EFrQWuE*|;{HzPodJ*|>HGeZ}-Zn2JDC zalW0$HheEEJmRr+cV1UbI5k`4LBSA!4O1?Q;i$M|BX}>$J&Hi8JH!bbx-HOW++dQK zpv29grKcAm2voi9M6`VMcbS6m;%y$;YbQx2o2%KRN18HLy+3vgA$&s4GQ-Q(UEz?} z?}vtvlb0S6heuCqYsXWbuJYS{v~)uAQOudFez0WVbO|t3XCn^^18Wa~>Z`u>fE%;| zZb+4g+AUPtp4=zX`(u}Kj5db1{{=*@dXx4y-2?O_?rf#MkBDxDphC46p`CUHRoqj@ zx9g#l=I_WtC1=fCkPC%XYT%=H;DeAgAZ;9RFH-520J4CY45dvTESr-M2H9H<_;Hx( z!1kGeUs{;u!qsx}AgL8_Mvh2^Fe3qM*Dwopt(P7fS|lr$>S$+xB5Wcma;52IA1s~* z3Ee4pIQv{NhQ2D`8)_#B&AozNTWt_2K0V6Y{Q_|?G1{6lCpPYb)`4+5UwQ3R5s^O+ zD!S-K1wM${PYhK*3a?b8AeA-#j2d4({gYhqtJAgm`g$$y<;+K>CyF2oK!MeDo7}0d zp*#kW#;M6wRy44opJR}?cA4&2@^v+CirpXlD8sl7OGGf|3XUOx?N{q$FlroxGfz#h z(MDtB({+*8;9XCXeHE#9xkB&L3)`H_8_J=Z<-k0mty85|+3`^uMSwR& zo{qIdiK@g{RZ5~ODFEQ-6M5ix_9tahZ|GhSL~E4G#zl+Ieo%3-l>NQPWlI!>!#d$lTK> zY%(dg@_NzUdfzG)R_Yfs5VKO{Iu5pQYy+`q<3r($%jv-s?D7FBh`ISG*W~5>%3%&u zQ&T)}cGZvp3czX4L}Q+^J^Axgo=A}Xc$l3EkQLxJk03^DWM2g zSy@@1?|-G}q-H&nzYERK-@goXxpBtu0B-(FnTykwAa8xVFxUz5aW7+4Pmz z$u>KLgln*ecGW6#yYAvYU3H&{n#s%bB%8!esaIflB#4^1I}CU7mX|7yT;vLd?Wb5{ zQZ!WGJL0ghrBwoS`7iN;sag0u+)nq+-dJjJY!IaO2v{6$~6m#M>TEIE>`{H!);@!HsUJ zs2e46^3EMs{S|eQ2F3L6V)d>#H)LhurWG2ky(D(5gb3!^WJ`|b+Rq>H|2}rV9czRx zDuH6{n=JZ~i{eA8-9E6TTV}c+8H(zVM4H-I_a|vk+^b*xP3$<`&NlOR{X>H!?hbo# zx4EOVKlB4Rm6zn@+ooCgRrR_%i`N_y< z*o8(Ise=+XidF==|Kaf>3D|5Zi>kdY z+TYtOP*0FkxnO8$Xyf%u*~v)bRZh$8oF%<)4~Yle;IV0!d8oAivmgD+8%udpR_zKJ zvDl<+`mK-Ue;fAy0X3h}CUk8QG!`2NK}|=|_^FBNwpHouD|Iqh)c zvP^y4d5a7ocg#=xR6}8R@2uSzb%NoP(KGuX601?*q6dzp%`4NF#pEm;2}__Y zu!7ZOz`sv3@aHU|p^`DmE-8`d=Dw=Q-5*Zkhjw=EBGZ>uXE*A<8*ye6#|g%ekdUr} z&oVg-MXgsJ-=l@HYI*)5F0HfSu?EAHG?EL$fyzoy+CPfTEy)|RWNOWQJIir|SV=cc zdFi9()XFFj1}Q)Cn+Au3B)Ir4_ts>Rc04gxA=5WdXRm2?uX-ufO=9W(4t+74t&rlk zHoq|WUwn4yJXZaA>Obe;KmLU?=mtT~n;^K}{sa%Vv+etKk|Xo4DWG%!)-juT0^~|N0`y`M+y3rA091SyH>r@6%p-+bF3?@Y_Nm$dGw5aCmlX{e5`<^W|X}#s4vA zzx~wDi~rv+uKl|IcMPM@ICD4za16i}@F^v|a{qq)oqPX~_V?FF`4E#BbNw(zKaacG zko2kZ_Xt_}!J4B@fCyYD8U!?hNZSXko>J{5-C2$tKAg2fs+n&uX8Mke*4 zNSWq?@5={=5|JlN$1}N*j5$(GP*U_FXFTkHnvXjuI@XO}=Mq6Gb8ld_*s z;^7Lj)5doK{@A#wZMO7I2`i4$+YQ0UjgTFpn6VKu1Ri8eH96WVg;KQSrz$pytToa< zO)W?-Z+MzI@tqg^C{1NvKBvnZg_b4AQD)24e0=LceKwA%YEwWKZ;w{b_R)*^j2>wp z16(EyHCD>G{Grm~R669O(Bu1f_v2nrS}Ia7UB>3l=e2nw2dc(M!6TMsKs!S16!cS$ z5Ut6`pi7+6*gkoL>$HOWEE1dHKM;y*C8?(|G=VP&OH%T28L_i zckT%T7nI`fyk#u>_EyP1pydW2Nrfh*_v9qHk*01tbk@j$LIX_^od*;wSOwg#Fk5yxRL`LsF)P#>?jnsFRwb zJkY`46oY86StU~klNjxI_Btf=TDJs18ds!IpP-!v=p z9;AMI$QUwJ>On&kg0yaE!sI|?B>*-|6Yz^?HYG@Il4f3SrziNJWpS@Bkh%lx0C)SS zwL77_h^9M~2lc$VaM1r+09+!Mm|ZnX89WS1i@_Gi`&?gF*Mby~BQYYGFuf-Luz1pyZI%cy4zI)LyW@+X zW=M9t)DS~0{Qv;NyvvvXap>FQ8Za6X7PcpSfP$A%p=}wQapJc#i&;HeT3Gs%iZ1@l z5{D9^WkH+l7U&TdR0f%|6)p%enS7$=FyZh-m zh{<@DzOy1#;~zP2_~e%tv|@^{Nr0Q&eH1B`^7Br;9^E@Faz3{zyt-gd`B5m^HNN2b z_7-|Nw4gQ(la^hbHRF)5@iErSjP8GT{x<1lg-*C_T#%-E7op~5;NIF$Ny#fN3ue1n zEM>x(k()}7lTo9N@sIogCd@jk5O;Yj_9i8hj1lT&(4<2=Al{RkfI&# z(OOuXc_dzbsFI1rZRWz*z_9#ccy2$Wam|(ctM|l>AWFI{C)-i94pGy+CEjjS?cmDe zeBJy-+0eKv6p36x2qDjf+x${_f+OY#ZWkV2fm_kDa^#-})r#z9kn)<)wG=?nBa0yK zriID~Kvj*pz0+9KpRzW>9s_%g+zPo& zC!pG8&t24uL}03O8I&QM;%+j0pnep6=ET7l$ptcRg?lcaB*I?4B@rCAmS~ilWW39e z=HRwvH3hW4Rzlqv91p+v4r*tfvJ$;!1Brczhn({CX~R4JKI}?bk9mCs*lx5$-}en* z96%L2esdujVU}7YT29{6SD-ZveA}%~&2;|Sd1x&k3?PuEwMs|vYYGwt_@5!m0@WYO zE-t&r4ywgOVGi*D9Jx}wKt`uS8Re4Ifiuu{yR(YO@M#L^p7mcG$XFcCZf@EkT)-mf za;*p&U}Rw`w@vDVa_ikkj;Ay>ys0OH`y%!j)7>hN1OiN>1s~3;016#hmlbRk*bnPN z>vUAq%^En@SCF@B(d)XgP(a@gSf~k5d*&(qUP;xZ$yd$lUvLyxxL3%k^Ln=O$KJsG z*fu@Ecia Date: Wed, 28 Jan 2026 11:57:11 -0800 Subject: [PATCH 12/98] Indentation? --- README.md | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index a78143a..bdb552f 100644 --- a/README.md +++ b/README.md @@ -59,25 +59,24 @@ To perform these steps, you must either be the owner of a store, or a collaborat 1. In the Dev Dashboard, press **Create app**. 2. In the first screen, pick an **App name** that identifies the integration, like _Craft CMS_. 1. Press **Create**, then fill out the following fields to create your first “version”: - - - **App URL**: (Optional) Replace `example.com` with `https://shopify.dev/apps/default-app-home`. This is just a friendlier (but still somewhat confusing) page hosted by Shopify, displayed when your “app” is accessed in the Shopify store’s admin. (If you turn off **Embed app in Shopify admin**, you are redirected to this URL after [installing](#install-in-a-store) the app. Neither is particularly useful; this is a symptom of Shopify’s lack of an API-only integration path.) - - **Webhooks API Version**: Choose `2025-10`, and add the same string to your project’s `.env` file: - ```bash - SHOPIFY_WEBHOOK_VERSION="2025-10" - ``` - - **Access** → **Scopes**: The following scopes are required for the plugin to function correctly: - - `read_inventory` - - `read_product_listings` - - `read_products` - - `unauthenticated_read_product_listings` - - Shopify requires these to be in a comma-separated list: `read_inventory,read_product_listings,read_products,unauthenticated_read_product_listings` - - Do _not_ enable the **Use legacy install flow** as it can result in mismatched scopes during installation. + - **App URL**: (Optional) Replace `example.com` with `https://shopify.dev/apps/default-app-home`. This is just a friendlier (but still somewhat confusing) page hosted by Shopify, displayed when your “app” is accessed in the Shopify store’s admin. (If you turn off **Embed app in Shopify admin**, you are redirected to this URL after [installing](#install-in-a-store) the app. Neither is particularly useful; this is a symptom of Shopify’s lack of an API-only integration path.) + - **Webhooks API Version**: Choose `2025-10`, and add the same string to your project’s `.env` file: + ```bash + SHOPIFY_WEBHOOK_VERSION="2025-10" + ``` + - **Access** → **Scopes**: The following scopes are required for the plugin to function correctly: + - `read_inventory` + - `read_product_listings` + - `read_products` + - `unauthenticated_read_product_listings` + - Shopify requires these to be in a comma-separated list: `read_inventory,read_product_listings,read_products,unauthenticated_read_product_listings` + - Do _not_ enable the **Use legacy install flow** as it can result in mismatched scopes during installation. 1. Press **Release** to deploy the configuration. You may give it a name and description, or let Shopify tag it with an incrementing number. 1. Switch to the **Settings** screen of the new app, and copy the credentials into your `.env` file: - ```bash - SHOPIFY_CLIENT_ID="..." # Client ID - SHOPIFY_CLIENT_SECRET="..." # Secret - ``` + ```bash + SHOPIFY_CLIENT_ID="..." # Client ID + SHOPIFY_CLIENT_SECRET="..." # Secret + ``` #### Install in a Store From 56d817b51179ac192ab2f0bfdc592d34d2a7fabc Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 28 Jan 2026 11:59:29 -0800 Subject: [PATCH 13/98] Indentation, again? --- README.md | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index bdb552f..8434d45 100644 --- a/README.md +++ b/README.md @@ -59,24 +59,24 @@ To perform these steps, you must either be the owner of a store, or a collaborat 1. In the Dev Dashboard, press **Create app**. 2. In the first screen, pick an **App name** that identifies the integration, like _Craft CMS_. 1. Press **Create**, then fill out the following fields to create your first “version”: - - **App URL**: (Optional) Replace `example.com` with `https://shopify.dev/apps/default-app-home`. This is just a friendlier (but still somewhat confusing) page hosted by Shopify, displayed when your “app” is accessed in the Shopify store’s admin. (If you turn off **Embed app in Shopify admin**, you are redirected to this URL after [installing](#install-in-a-store) the app. Neither is particularly useful; this is a symptom of Shopify’s lack of an API-only integration path.) - - **Webhooks API Version**: Choose `2025-10`, and add the same string to your project’s `.env` file: - ```bash - SHOPIFY_WEBHOOK_VERSION="2025-10" - ``` - - **Access** → **Scopes**: The following scopes are required for the plugin to function correctly: - - `read_inventory` - - `read_product_listings` - - `read_products` - - `unauthenticated_read_product_listings` - - Shopify requires these to be in a comma-separated list: `read_inventory,read_product_listings,read_products,unauthenticated_read_product_listings` - - Do _not_ enable the **Use legacy install flow** as it can result in mismatched scopes during installation. -1. Press **Release** to deploy the configuration. You may give it a name and description, or let Shopify tag it with an incrementing number. -1. Switch to the **Settings** screen of the new app, and copy the credentials into your `.env` file: + - **App URL**: (Optional) Replace `example.com` with `https://shopify.dev/apps/default-app-home`. This is just a friendlier (but still somewhat confusing) page hosted by Shopify, displayed when your “app” is accessed in the Shopify store’s admin. (If you turn off **Embed app in Shopify admin**, you are redirected to this URL after [installing](#install-in-a-store) the app. Neither is particularly useful; this is a symptom of Shopify’s lack of an API-only integration path.) + - **Webhooks API Version**: Choose `2025-10`, and add the same string to your project’s `.env` file: ```bash - SHOPIFY_CLIENT_ID="..." # Client ID - SHOPIFY_CLIENT_SECRET="..." # Secret + SHOPIFY_WEBHOOK_VERSION="2025-10" ``` + - **Access** → **Scopes**: The following scopes are required for the plugin to function correctly: + - `read_inventory` + - `read_product_listings` + - `read_products` + - `unauthenticated_read_product_listings` + - Shopify requires these to be in a comma-separated list: `read_inventory,read_product_listings,read_products,unauthenticated_read_product_listings` + - Do _not_ enable the **Use legacy install flow** as it can result in mismatched scopes during installation. +1. Press **Release** to deploy the configuration. You may give it a name and description, or let Shopify tag it with an incrementing number. +1. Switch to the **Settings** screen of the new app, and copy the credentials into your `.env` file: + ```bash + SHOPIFY_CLIENT_ID="..." # Client ID + SHOPIFY_CLIENT_SECRET="..." # Secret + ``` #### Install in a Store From 4bdf9267853e5d2aa20335aba01e935e4d9f3d22 Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 28 Jan 2026 12:02:01 -0800 Subject: [PATCH 14/98] IIIndentation --- README.md | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 8434d45..e23a4bf 100644 --- a/README.md +++ b/README.md @@ -57,26 +57,26 @@ To perform these steps, you must either be the owner of a store, or a collaborat 1. From a store, open **Settings** → **Apps**, press **Develop apps** in the toolbar, then follow the **Build apps in Dev Dashboard** link. 1. In the Dev Dashboard, press **Create app**. -2. In the first screen, pick an **App name** that identifies the integration, like _Craft CMS_. +1. In the first screen, pick an **App name** that identifies the integration, like _Craft CMS_. 1. Press **Create**, then fill out the following fields to create your first “version”: - - **App URL**: (Optional) Replace `example.com` with `https://shopify.dev/apps/default-app-home`. This is just a friendlier (but still somewhat confusing) page hosted by Shopify, displayed when your “app” is accessed in the Shopify store’s admin. (If you turn off **Embed app in Shopify admin**, you are redirected to this URL after [installing](#install-in-a-store) the app. Neither is particularly useful; this is a symptom of Shopify’s lack of an API-only integration path.) - - **Webhooks API Version**: Choose `2025-10`, and add the same string to your project’s `.env` file: - ```bash - SHOPIFY_WEBHOOK_VERSION="2025-10" - ``` - - **Access** → **Scopes**: The following scopes are required for the plugin to function correctly: - - `read_inventory` - - `read_product_listings` - - `read_products` - - `unauthenticated_read_product_listings` - - Shopify requires these to be in a comma-separated list: `read_inventory,read_product_listings,read_products,unauthenticated_read_product_listings` - - Do _not_ enable the **Use legacy install flow** as it can result in mismatched scopes during installation. + - **App URL**: (Optional) Replace `example.com` with `https://shopify.dev/apps/default-app-home`. This is just a friendlier (but still somewhat confusing) page hosted by Shopify, displayed when your “app” is accessed in the Shopify store’s admin. (If you turn off **Embed app in Shopify admin**, you are redirected to this URL after [installing](#install-in-a-store) the app. Neither is particularly useful; this is a symptom of Shopify’s lack of an API-only integration path.) + - **Webhooks API Version**: Choose `2025-10`, and add the same string to your project’s `.env` file: + ```bash + SHOPIFY_WEBHOOK_VERSION="2025-10" + ``` + - **Access** → **Scopes**: The following scopes are required for the plugin to function correctly: + - `read_inventory` + - `read_product_listings` + - `read_products` + - `unauthenticated_read_product_listings` + - Shopify requires these to be in a comma-separated list: `read_inventory,read_product_listings,read_products,unauthenticated_read_product_listings` + - Do _not_ enable the **Use legacy install flow** as it can result in mismatched scopes during installation. 1. Press **Release** to deploy the configuration. You may give it a name and description, or let Shopify tag it with an incrementing number. 1. Switch to the **Settings** screen of the new app, and copy the credentials into your `.env` file: - ```bash - SHOPIFY_CLIENT_ID="..." # Client ID - SHOPIFY_CLIENT_SECRET="..." # Secret - ``` + ```bash + SHOPIFY_CLIENT_ID="..." # Client ID + SHOPIFY_CLIENT_SECRET="..." # Secret + ``` #### Install in a Store From cbd319b31359d64623ce30c3d990fb912d050a2e Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 28 Jan 2026 14:35:10 -0800 Subject: [PATCH 15/98] Handle different response structure in low-level credential/connection errors --- src/controllers/WebhooksController.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/controllers/WebhooksController.php b/src/controllers/WebhooksController.php index 796444c..0889e41 100644 --- a/src/controllers/WebhooksController.php +++ b/src/controllers/WebhooksController.php @@ -178,7 +178,15 @@ public function actionCreate(): YiiResponse $body = $response->getDecodedBody(); if (array_key_exists('errors', $body)) { - throw new \Exception($body['errors'][0]['message']); + $message = $body['errors']; + + // Some low-level errors (like an unavailable shop) are reported as a single string. + // Others need to be unpacked from an array: + if (!is_string($message)) { + $message = $message[0]['message']; + } + + throw new \Exception($message); } } catch (\Exception $e) { Craft::error('Could not register webhooks with Shopify API: ' . $e->getMessage(), __METHOD__); From a3c1d44514fb60f16e3dee5a4ff37508d662a797 Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 28 Jan 2026 14:35:55 -0800 Subject: [PATCH 16/98] Array, rather than "not string" --- src/controllers/WebhooksController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/WebhooksController.php b/src/controllers/WebhooksController.php index 0889e41..435a36d 100644 --- a/src/controllers/WebhooksController.php +++ b/src/controllers/WebhooksController.php @@ -182,7 +182,7 @@ public function actionCreate(): YiiResponse // Some low-level errors (like an unavailable shop) are reported as a single string. // Others need to be unpacked from an array: - if (!is_string($message)) { + if (is_array($message)) { $message = $message[0]['message']; } From 283c0f651659ce9b15036a1260b059f842129f55 Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 28 Jan 2026 17:05:31 -0800 Subject: [PATCH 17/98] Harden settings controller permissions This technically has some basic guarding from settings path/URI, but a nosy user could still attempt to hit some actions via `Craft.postActionRequest(...)` or other means. --- src/controllers/SettingsController.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/controllers/SettingsController.php b/src/controllers/SettingsController.php index 62e31f8..dc83a4b 100644 --- a/src/controllers/SettingsController.php +++ b/src/controllers/SettingsController.php @@ -24,6 +24,21 @@ */ class SettingsController extends Controller { + /** + * @inheritdoc + */ + public function beforeAction($action): bool + { + if (!parent::beforeAction($action)) { + return false; + } + + // Only administrators should be allowed to update plugin settings + $this->requireAdmin(); + + return true; + } + /** * Display a form to allow an administrator to update plugin settings. * From 9bec5e2c368e8f806d07c14b6842785fbe62b089 Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 28 Jan 2026 17:06:38 -0800 Subject: [PATCH 18/98] =?UTF-8?q?Require=20specific=20permissions=20to=20v?= =?UTF-8?q?iew=20+=20manage=20webhooks=20Also=20guarded=20=E2=80=9Ccoincid?= =?UTF-8?q?entally,=E2=80=9D=20but=20accessible=20via=20direct=20`=3Factio?= =?UTF-8?q?n=3D`=20routing.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/WebhooksController.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/controllers/WebhooksController.php b/src/controllers/WebhooksController.php index 435a36d..82e9005 100644 --- a/src/controllers/WebhooksController.php +++ b/src/controllers/WebhooksController.php @@ -27,6 +27,21 @@ */ class WebhooksController extends Controller { + /** + * @inheritdoc + */ + public function beforeAction($action): bool + { + if (!parent::beforeAction($action)) { + return false; + } + + // All actions in this controller should be restricted to users with explicit plugin permissions: + $this->requirePermission('accessPlugin-' . $this->module->id); + + return true; + } + /** * Edit page for the webhook management * From 95cf4eaec9072940c12d4b268108103fec9d4086 Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 28 Jan 2026 17:09:41 -0800 Subject: [PATCH 19/98] Guard utility side-effects with permission check --- src/controllers/SyncController.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/controllers/SyncController.php b/src/controllers/SyncController.php index dd671e7..72b1dac 100644 --- a/src/controllers/SyncController.php +++ b/src/controllers/SyncController.php @@ -32,6 +32,8 @@ class SyncController extends Controller */ public function actionDelete(): Response { + // Users must have access to the utility to manage synchronizations: + $this->requirePermission('utility:shopify-sync'); $this->requireAcceptsJson(); $id = Craft::$app->getRequest()->getBodyParam('id'); From 27e9a0289dd28fc22d7b723a3f249de68e6d0125 Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 28 Jan 2026 17:10:10 -0800 Subject: [PATCH 20/98] Require access to utility to start sync --- src/controllers/ProductsController.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/controllers/ProductsController.php b/src/controllers/ProductsController.php index d6c220c..3709196 100644 --- a/src/controllers/ProductsController.php +++ b/src/controllers/ProductsController.php @@ -44,6 +44,9 @@ public function actionProductIndex(): Response */ public function actionSync(): ?Response { + // Users must have access to the utility to manage synchronizations: + $this->requirePermission('utility:shopify-sync'); + $result = Plugin::getInstance()->getBulkOperations()->createProductsBulkOperation(); if ($result === false) { From e8365a1300baf13e58f9a5da76ad49ba7217aa84 Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 28 Jan 2026 17:22:07 -0800 Subject: [PATCH 21/98] The --throttle option is no longer used --- CHANGELOG.md | 1 + src/console/controllers/SyncController.php | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8209322..7bb457d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Added `craft\shopify\models\Variant`. - Added `craft\shopify\services\Api::API_ACCESS_TOKEN_CACHE_KEY`. - Removed `craft\shopify\controllers\ProductsController::actionRenderCardHtml()` +- Deprecated the `--throttle` option for `shopify/sync` commands. - `craft\shopify\elements\Product::getVariants()` now returns a collection. ## Unreleased diff --git a/src/console/controllers/SyncController.php b/src/console/controllers/SyncController.php index 60b3f14..277d349 100644 --- a/src/console/controllers/SyncController.php +++ b/src/console/controllers/SyncController.php @@ -26,6 +26,7 @@ class SyncController extends Controller /** * @var bool Whether to slow down API requests to avoid rate limiting. * @since 5.2.0 + * @deprecated 7.0.0 */ public bool $throttle = false; @@ -39,6 +40,15 @@ public function options($actionID): array return $options; } + public function beforeAction($action): bool + { + if ($this->throttle) { + $this->stdout('The --throttle option has been deprecated and has no effect, as we fetch product data in bulk (and therefore are not subject to API rate limiting).' . PHP_EOL, Console::FG_YELLOW); + } + + return parent::beforeAction($action); + } + /** * Sync all Shopify data. */ From cf98fb9881eea83238234218bf11e66fbd48166e Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Fri, 30 Jan 2026 10:44:51 +0000 Subject: [PATCH 22/98] =?UTF-8?q?Move=20from=20`apiKey`=20and=20`apiSecret?= =?UTF-8?q?Key`=20to=20`clientId`=20and=20`clientSecretKey`=20to=20be=20co?= =?UTF-8?q?nsistent=20with=20Shopify=E2=80=99s=20terminology?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++ README.md | 8 ++-- src/models/Settings.php | 76 +++++++++++++++++++++++++------ src/services/Api.php | 8 ++-- src/templates/settings/index.twig | 24 +++++----- src/translations/en/shopify.php | 4 +- 6 files changed, 93 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 294e7e5..ceeeefe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,17 @@ - Added `craft\shopify\fieldlayoutelements\MetafieldsField`. - Added `craft\shopify\fieldlayoutelements\OptionsField`. - Added `craft\shopify\fieldlayoutelements\VariantsField`. +- Added `craft\shopify\models\Settings::getClientId()`. +- Added `craft\shopify\models\Settings::getClientSecret()`. +- Added `craft\shopify\models\Settings::setClientId()`. +- Added `craft\shopify\models\Settings::setClientSecret()`. - Added `craft\shopify\models\Variant`. - Added `craft\shopify\services\Api::API_ACCESS_TOKEN_CACHE_KEY`. - Removed `craft\shopify\controllers\ProductsController::actionRenderCardHtml()` +- Deprecated `craft\shopify\models\Settings::getApiKey()`. `getClientId()` should be used instead. +- Deprecated `craft\shopify\models\Settings::getApiSecret()`. `getClientSecret()` should be used instead. +- Deprecated `craft\shopify\models\Settings::setApiKey()`. `setClientId()` should be used instead. +- Deprecated `craft\shopify\models\Settings::setApiSecret()`. `setClientSecret()` should be used instead. - Deprecated the `--throttle` option for `shopify/sync` commands. - `craft\shopify\elements\Product::getVariants()` now returns a collection. diff --git a/README.md b/README.md index e23a4bf..0be460e 100644 --- a/README.md +++ b/README.md @@ -111,8 +111,8 @@ You should now have a total of _four_ `SHOPIFY_*` variables in your `.env` file. In your Craft project’s control panel, navigate to **Shopify** → **Settings** to configure the plugin: - **API Version**: `$SHOPIFY_WEBHOOKS_VERSION` -- **API Key**: `$SHOPIFY_CLIENT_ID` -- **API Secret Key**: `$SHOPIFY_CLIENT_SECRET` +- **Client ID**: `$SHOPIFY_CLIENT_ID` +- **Client Secret Key**: `$SHOPIFY_CLIENT_SECRET` - **Host Name**: `$SHOPIFY_HOSTNAME` Save the settings to test the connection; an exception will be thrown if there are issues. @@ -147,16 +147,16 @@ This release (7.x) is primarily concerned with Shopify API compatability. After the upgrade, you **must**: 1. Update the webhook version setting to `2025-10` in your app _and_ Craft project +1. Update client credentials in your settings 1. Review the required [access scopes](#create-an-app) 1. Delete and re-create webhooks for each environment (This is essential! Webhooks are registered and delivered with a specific version, and a mismatch will result in errors.) This ensures that the plugin can properly communicate with the Shopify API. -If you elect to migrate to the Dev Dashboard during the upgrade, you can leave your “legacy custom app” configuration as-is. +When you migrate to the Dev Dashboard custom app during the upgrade, you can leave your “legacy custom app” configuration as-is. This will no longer be used. ### Credentials At the beginning of 2026, Shopify overhauled how “apps” are created, moving them to the new [Dev Dashboard](https://shopify.dev/docs/apps/build/dev-dashboard). -_Your existing credentials will continue to work_, but you may find that some features (like webhook delivery logs) are worth making the transition. You should be able to [create a new app](#create-an-app), [install it](#install-in-a-store), and [replace credentials](#connect-to-shopify) without disruption. diff --git a/src/models/Settings.php b/src/models/Settings.php index 2895018..c7cfe72 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -25,8 +25,9 @@ */ class Settings extends Model { - private string $_apiKey = ''; - private string $_apiSecretKey = ''; + private string $_clientId = ''; + private string $_clientSecret = ''; + private string $_hostName = ''; public string $uriFormat = ''; public string $template = ''; @@ -65,7 +66,7 @@ class Settings extends Model public function rules(): array { return [ - [['apiSecretKey', 'apiKey', 'hostName', 'apiVersion'], 'required'], + [['clientSecret', 'clientId', 'hostName', 'apiVersion'], 'required'], [['apiVersion'], 'in', 'range' => Plugin::getInstance()->getApi()->getSupportedApiVersions()], [['hostName'], function($attribute) { $hostName = $this->$attribute; @@ -81,8 +82,8 @@ public function attributes() { $names = parent::attributes(); $names[] = 'apiVersion'; - $names[] = 'apiKey'; - $names[] = 'apiSecretKey'; + $names[] = 'clientId'; + $names[] = 'clientSecret'; $names[] = 'contextualPricingCountries'; $names[] = 'hostName'; $names[] = 'uriFormat'; @@ -95,8 +96,8 @@ public function fields(): array { return [ 'apiVersion' => fn() => $this->getApiVersion(false), - 'apiKey' => fn() => $this->getApiKey(false), - 'apiSecretKey' => fn() => $this->getApiSecretKey(false), + 'clientId' => fn() => $this->getClientId(false), + 'clientSecret' => fn() => $this->getClientSecret(false), 'contextualPricingCountries' => fn() => $this->getContextualPricingCountries(false), 'hostName' => fn() => $this->getHostName(false), 'uriFormat' => 'uriFormat', @@ -110,8 +111,8 @@ public function fields(): array public function attributeLabels(): array { return [ - 'apiKey' => Craft::t('shopify', 'Shopify API Key'), - 'apiSecretKey' => Craft::t('shopify', 'Shopify API Secret Key'), + 'clientId' => Craft::t('shopify', 'Shopify Client ID'), + 'clientSecret' => Craft::t('shopify', 'Shopify Client Secret Key'), 'apiVersion' => Craft::t('shopify', 'Shopify API Version'), 'contextualPricingCountries' => Craft::t('shopify', 'Context Pricing Countries'), 'hostName' => Craft::t('shopify', 'Shopify Host Name'), @@ -144,42 +145,91 @@ public function getApiVersion(bool $parse = true): string * @param string $apiKey * @return void * @since 6.0.0 + * @deprecated in 7.0.0. Use [[setClientId()]] instead. */ public function setApiKey(string $apiKey): void { - $this->_apiKey = $apiKey; + Craft::$app->getDeprecator()->log(__METHOD__, '`setApiKey()` method has been deprecated. Use `setClientId()` instead.'); + return; } /** * @param bool $parse * @return string * @since 6.0.0 + * @deprecated in 7.0.0. Use [[getClientId()]] instead. */ public function getApiKey(bool $parse = true): string { - return ($parse ? App::parseEnv($this->_apiKey) : $this->_apiKey) ?? ''; + Craft::$app->getDeprecator()->log(__METHOD__, '`getApiKey()` method has been deprecated. Use `getClientId()` instead.'); + return $this->getClientId($parse); + } + + /** + * @param string $clientId + * @return void + * @since 7.0.0 + */ + public function setClientId(string $clientId): void + { + $this->_clientId = $clientId; + } + + /** + * @param bool $parse + * @return string + * @since 7.0.0 + */ + public function getClientId(bool $parse = true): string + { + return ($parse ? App::parseEnv($this->_clientId) : $this->_clientId) ?? ''; } /** * @param string $apiSecretKey * @return void * @since 6.0.0 + * @deprecated in 7.0.0. Use [[setClientSecret()]] instead. */ public function setApiSecretKey(string $apiSecretKey): void { - $this->_apiSecretKey = $apiSecretKey; + Craft::$app->getDeprecator()->log(__METHOD__, '`setApiSecretKey()` method has been deprecated. Use `setClientSecret()` instead.'); + return; } /** * @param bool $parse * @return string * @since 6.0.0 + * @deprecated in 7.0.0. Use [[getClientSecret()]] instead. */ public function getApiSecretKey(bool $parse = true): string { - return ($parse ? App::parseEnv($this->_apiSecretKey) : $this->_apiSecretKey) ?? ''; + Craft::$app->getDeprecator()->log(__METHOD__, '`getApiSecretKey()` method has been deprecated. Use `getClientSecret()` instead.'); + return $this->getClientSecret($parse); } + /** + * @param string $clientSecret + * @return void + * @since 7.0.0 + */ + public function setClientSecret(string $clientSecret): void + { + $this->_clientSecret = $clientSecret; + } + + /** + * @param bool $parse + * @return string + * @since 7.0.0 + */ + public function getClientSecret(bool $parse = true): string + { + return ($parse ? App::parseEnv($this->_clientSecret) : $this->_clientSecret) ?? ''; + } + + /** * @param string $hostName * @return void diff --git a/src/services/Api.php b/src/services/Api.php index 0aaef4a..2bac896 100644 --- a/src/services/Api.php +++ b/src/services/Api.php @@ -485,8 +485,8 @@ public function getSession(): ?Session if ( $this->_session === null && - ($apiKey = $pluginSettings->getApiKey(true)) && - ($apiSecretKey = $pluginSettings->getApiSecretKey(true)) + ($apiKey = $pluginSettings->getClientId(true)) && + ($apiSecretKey = $pluginSettings->getClientSecret(true)) ) { /** @var MonologTarget $webLogTarget */ $webLogTarget = Craft::$app->getLog()->targets['web']; @@ -553,8 +553,8 @@ public function getAccessToken(): string try { $response = $client->post($endpoint, [ 'form_params' => [ - 'client_id' => Plugin::getInstance()->getSettings()->getApiKey(true), - 'client_secret' => Plugin::getInstance()->getSettings()->getApiSecretKey(true), + 'client_id' => Plugin::getInstance()->getSettings()->getClientId(true), + 'client_secret' => Plugin::getInstance()->getSettings()->getClientSecret(true), 'grant_type' => 'client_credentials', ], ]); diff --git a/src/templates/settings/index.twig b/src/templates/settings/index.twig index 4c147f2..7743f25 100644 --- a/src/templates/settings/index.twig +++ b/src/templates/settings/index.twig @@ -60,7 +60,7 @@

{{ forms.autosuggestField({ first: true, - label: 'API Version'|t('shopify'), + label: settings.getAttributeLabel('apiVersion'), instructions: 'Supported API versions: {versions}'|t('shopify', {versions: craft.shopify.api.getSupportedApiVersions()|join(', ')}), id: 'apiVersion', name: 'settings[apiVersion]', @@ -71,25 +71,25 @@ }) }} {{ forms.autosuggestField({ - label: 'API Key'|t('shopify'), - id: 'apiKey', - name: 'settings[apiKey]', - value: settings.getApiKey(false), - errors: settings.getErrors('apiKey'), + label: settings.getAttributeLabel('clientId'), + id: 'clientId', + name: 'settings[clientId]', + value: settings.getClientId(false), + errors: settings.getErrors('clientId'), suggestEnvVars: true, }) }} {{ forms.autosuggestField({ - label: 'API Secret Key'|t('shopify'), - id: 'apiSecretKey', - name: 'settings[apiSecretKey]', - value: settings.getApiSecretKey(false), - errors: settings.getErrors('apiSecretKey'), + label: settings.getAttributeLabel('clientSecret'), + id: 'clientSecret', + name: 'settings[clientSecret]', + value: settings.getClientSecret(false), + errors: settings.getErrors('clientSecret'), suggestEnvVars: true, }) }} {{ forms.autosuggestField({ - label: 'Host Name'|t('shopify'), + label: settings.getAttributeLabel('hostName'), instructions: 'The Shopify store hostname.'|t('shopify'), id: 'hostName', name: 'settings[hostName]', diff --git a/src/translations/en/shopify.php b/src/translations/en/shopify.php index bf33188..f8c1a27 100644 --- a/src/translations/en/shopify.php +++ b/src/translations/en/shopify.php @@ -60,8 +60,8 @@ 'Queued' => 'Queued', 'Settings saved.' => 'Settings saved.', 'Settings' => 'Settings', - 'Shopify API Key' => 'Shopify API Key', - 'Shopify API Secret Key' => 'Shopify API Secret Key', + 'Shopify Client ID' => 'Shopify Client ID', + 'Shopify Client Secret Key' => 'Shopify Client Secret Key', 'Shopify API Version' => 'Shopify API Version', 'Shopify Access Token' => 'Shopify Access Token', 'Shopify Edit' => 'Shopify Edit', From 60e9291b9674298328413603b39538e916c0d228 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Fri, 6 Feb 2026 11:47:21 +0000 Subject: [PATCH 23/98] Use user interaction oauth process --- src/Plugin.php | 1 + src/controllers/AuthController.php | 199 +++++++++++++ src/controllers/SettingsController.php | 137 ++++++++- src/models/Settings.php | 25 ++ src/services/Api.php | 374 ++++++------------------- src/templates/settings/_layout.twig | 33 --- src/templates/settings/index.twig | 114 -------- 7 files changed, 430 insertions(+), 453 deletions(-) create mode 100644 src/controllers/AuthController.php delete mode 100644 src/templates/settings/_layout.twig delete mode 100644 src/templates/settings/index.twig diff --git a/src/Plugin.php b/src/Plugin.php index 0594974..47e3826 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -344,6 +344,7 @@ private function _registerCpRoutes(): void $event->rules['shopify/products/'] = 'elements/edit'; $event->rules['shopify/settings'] = 'shopify/settings'; $event->rules['shopify/webhooks'] = 'shopify/webhooks/edit'; + $event->rules['shopify/auth'] = 'shopify/auth/index'; }); } diff --git a/src/controllers/AuthController.php b/src/controllers/AuthController.php new file mode 100644 index 0000000..86e2a51 --- /dev/null +++ b/src/controllers/AuthController.php @@ -0,0 +1,199 @@ + + * @since 7.0.0 + */ +class AuthController extends Controller +{ + /** + * @inheritdoc + */ + public function beforeAction($action): bool + { + if (!parent::beforeAction($action)) { + return false; + } + + // All actions in this controller should be restricted to users with explicit plugin permissions: + $this->requirePermission('accessPlugin-' . $this->module->id); + + return true; + } + + /** + * @return YiiResponse + * @throws \yii\base\InvalidConfigException + */ + public function actionIndex(): YiiResponse + { + Plugin::getInstance()->getApi()->initializeContext(); + $settings = Plugin::getInstance()->getSettings(); + + $screen = $this->asCpScreen() + ->title(Craft::t('shopify', 'Authorize App')); + + $validHmac = Utils::validateHmac(Craft::$app->getRequest()->getQueryParams(), $settings->getClientSecret()); + if (!$validHmac) { + return $screen->contentHtml($this->_errorHtml(Craft::t('shopify', 'Error authorizing app'), Craft::t('shopify', 'Invalid or missing HMAC. Please try re-installing the app.'))); + } + + // If a code is present, it means the user has been redirected back from Shopify after authorizing the app. + $code = Craft::$app->getRequest()->getQueryParam('code'); + if ($code) { + $cookies = Craft::$app->getRequest()->getCookies()->toArray(); + foreach ($cookies as $name => $cookie) { + if (!in_array($name, [OAuth::STATE_COOKIE_NAME, OAuth::STATE_SIG_COOKIE_NAME]) || !$cookie instanceof Cookie) { + continue; + } + + $cookies[$name] = $cookie->value; + } + + try { + $accessToken = $this->_fetchAccessToken($cookies, Craft::$app->getRequest()->getQueryParams(), fn(OAuthCookie $oauthCookie) => $this->_setCookies($oauthCookie, $screen)); + + if (!$accessToken) { + throw new InvalidOAuthException('Failed to retrieve access token.'); + } + + return $screen->contentHtml(Html::tag('p', Craft::t('shopify', 'App authorized successfully.'))); + } catch (\Exception $e) { + Craft::error($e->getMessage(), __METHOD__); + return $screen->contentHtml($this->_errorHtml(Craft::t('shopify', 'Error authorizing app'), $e->getMessage())); + } + } + + // If no code is present, it means the user is initiating the authorization process. + $path = Plugin::getInstance()->getSettings()->getAuthPath(); + $authorizeUrl = OAuth::begin($settings->getHostName(), $path, false, fn(OAuthCookie $oauthCookie) => $this->_setCookies($oauthCookie, $screen)); + + return $screen + ->contentHtml(Html::a(Craft::t('shopify', 'Authorize'), $authorizeUrl)); + } + + /** + * @param array $cookies + * @param array $query + * @param callable|null $setCookieFunction + * @return string|null + * @throws InvalidOAuthException + * @throws \Shopify\Exception\PrivateAppException + * @throws \Shopify\Exception\UninitializedContextException + * @throws \yii\base\InvalidConfigException + */ + private function _fetchAccessToken(array $cookies, array $query, ?callable $setCookieFunction = null): ?string + { + Context::throwIfUninitialized(); + Context::throwIfPrivateApp('OAuth is not allowed for private apps'); + + // `getCookie()` + $signature = $cookies[OAuth::STATE_SIG_COOKIE_NAME] ?? null; + $cookieId = $cookies[OAuth::STATE_COOKIE_NAME] ?? null; + + $cookieState = null; + if ($signature && $cookieId) { + $expectedSignature = hash_hmac('sha256', (string) $cookieId, Context::$API_SECRET_KEY); + + if ($signature === $expectedSignature) { + $cookieState = $cookieId; + } + } + + if (!self::_isCallbackQueryValid($query, $cookieState)) { + throw new InvalidOAuthException('Invalid OAuth callback.'); + } + + $sanitizedShop = Utils::sanitizeShopDomain($query['shop'] ?? ''); + return Plugin::getInstance()->getApi()->getAccessToken($query['code'], $sanitizedShop); + } + + /** + * @param array $query + * @param string|null $stateCookie + * @return bool + */ + private static function _isCallbackQueryValid(array $query, string | null $stateCookie): bool + { + $sanitizedShop = Utils::sanitizeShopDomain($query['shop'] ?? ''); + $state = $query['state'] ?? ''; + $code = $query['code'] ?? ''; + + return ( + ($code) && + ($sanitizedShop) && + ($state && $stateCookie && strcmp($stateCookie, (string) $state) === 0) && + Utils::validateHmac($query, Context::$API_SECRET_KEY) + ); + } + + /** + * @param OAuthCookie $oauthCookie + * @param Response $screen + * @return bool + * @throws \yii\base\InvalidConfigException + */ + private function _setCookies(OAuthCookie $oauthCookie, Response $screen): bool + { + $cookieConfig = Craft::cookieConfig([ + 'name' => $oauthCookie->getName(), + 'value' => $oauthCookie->getValue(), + 'expire' => $oauthCookie->getExpire(), + ]); + + $cookie = Craft::createObject(array_merge($cookieConfig, ['class' => Cookie::class])); + + $screen->getCookies()->add($cookie); + + return true; + } + + /** + * @param string $heading + * @param string $message + * @return string + */ + private function _errorHtml(string $heading, string $message): string + { + return Html::beginTag('div', [ + 'class' => ['error-summary'], + ]) . + Html::beginTag('div') . + Html::tag('span', '', [ + 'class' => 'notification-icon', + 'data-icon' => 'alert', + 'aria-label' => Craft::t('app', 'Error'), + 'role' => 'img', + ]) . + Html::tag('h2', $heading) . + Html::endTag('div') . + Html::beginTag('ul', [ + 'class' => ['errors'], + ]) . + Html::tag('li', $message) . + Html::endTag('ul') . + Html::endTag('div'); + } +} diff --git a/src/controllers/SettingsController.php b/src/controllers/SettingsController.php index dc83a4b..452d3ce 100644 --- a/src/controllers/SettingsController.php +++ b/src/controllers/SettingsController.php @@ -8,6 +8,8 @@ namespace craft\shopify\controllers; use Craft; +use craft\helpers\Cp; +use craft\helpers\Html; use craft\helpers\StringHelper; use craft\queue\jobs\ResaveElements; use craft\shopify\elements\Product; @@ -50,18 +52,133 @@ public function actionIndex(?Settings $settings = null): Response $settings = Plugin::getInstance()->getSettings(); } - $tabs = [ - 'apiConnection' => [ - 'label' => Craft::t('shopify', 'API Connection'), - 'url' => '#api', - ], - 'products' => [ - 'label' => Craft::t('shopify', 'Products'), - 'url' => '#products', - ], + $headlessMode = Craft::$app->getConfig()->getGeneral()->headlessMode; + + $authUrlFieldConfig = [ + 'label' => $settings->getAttributeLabel('authUrl'), + 'instructions' => Craft::t('shopify', 'The URL of your Shopify app in the Dev Dashboard. This is automatically generated from your CP URL.'), + 'id' => 'authUrl', + 'name' => 'settings[authUrl]', + 'value' => $settings->getAuthUrl(), + 'readonly' => true, ]; - return $this->renderTemplate('shopify/settings/index', compact('settings', 'tabs')); + $html = Html::beginTag('div', ['id' => 'products', 'class' => 'hidden']) . + // Products tab has to go first because the routing table overrides the `settings` key + Cp::editableTableFieldHtml([ + 'label' => Craft::t('shopify', 'Routing Settings'), + 'instructions' => Craft::t('shopify', 'Configure the product’s front-end routing settings.'), + 'id' => 'routing', + 'name' => 'settings', + 'allowAdd' => false, + 'allowDelete' => false, + 'allowReorder' => false, + 'errors' => array_unique($settings->getErrors('routing')), + 'cols' => array_filter([ + 'uriFormat' => [ + 'type' => 'singleline', + 'heading' => Craft::t('shopify', 'Product URI Format'), + 'info' => Craft::t('shopify', 'What product URIs should look like.'), + 'placeholder' => Craft::t('shopify', 'Leave blank if products don’t have URLs'), + 'code' => true + ], + 'template' => $headlessMode ? [] : [ + 'type' => 'template', + 'heading' => Craft::t('app', 'Template'), + 'info' => Craft::t('shopify', 'Which template should be loaded when a product’s URL is requested.'), + 'code' => true + ], + ]), + 'rows' => [ + 'routing' => [ + 'uriFormat' => [ + 'value' => $settings->uriFormat ?? null, + 'hasErrors' => $settings->hasErrors('uriFormat') ?? false + ], + 'template' => $headlessMode ? [] : [ + 'value' => $settings->template ?? null, + 'hasErrors' => $settings->hasErrors('template') ?? false, + ], + ], + ], + ]) . + + Cp::fieldLayoutDesignerHtml($settings->getProductFieldLayout()) . + + Html::endTag('div') . + + Html::beginTag('div', ['id' => 'api']) . + + Cp::autosuggestFieldHtml([ + 'first' => true, + 'label' => $settings->getAttributeLabel('apiVersion'), + 'instructions' => Craft::t('shopify', 'Supported API versions: {versions}', ['versions' => implode(', ', Plugin::getInstance()->getApi()->getSupportedApiVersions())]), + 'id' => 'apiVersion', + 'name' => 'settings[apiVersion]', + 'value' => $settings->getApiVersion(false), + 'errors' => $settings->getErrors('apiVersion'), + 'suggestEnvVars' => true, + 'autofocus' => true + ]) . + + Cp::autosuggestFieldHtml([ + 'label' => $settings->getAttributeLabel('clientId'), + 'id' => 'clientId', + 'name' => 'settings[clientId]', + 'value' => $settings->getClientId(false), + 'errors' => $settings->getErrors('clientId'), + 'suggestEnvVars' => true, + ]) . + + Cp::autosuggestFieldHtml([ + 'label' => $settings->getAttributeLabel('clientSecret'), + 'id' => 'clientSecret', + 'name' => 'settings[clientSecret]', + 'value' => $settings->getClientSecret(false), + 'errors' => $settings->getErrors('clientSecret'), + 'suggestEnvVars' => true, + ]) . + + Cp::autosuggestFieldHtml([ + 'label' => $settings->getAttributeLabel('hostName'), + 'instructions' => Craft::t('shopify', 'The Shopify store hostname.'), + 'id' => 'hostName', + 'name' => 'settings[hostName]', + 'value' => $settings->getHostName(false), + 'errors' => $settings->getErrors('hostName'), + 'suggestEnvVars' => true, + ]) . + + Cp::autosuggestFieldHtml([ + 'label' => $settings->getAttributeLabel('contextualPricingCountries'), + 'instructions' => Craft::t('shopify', 'A comma separated list of country codes used to return contextual pricing.'), + 'id' => 'contextualPricingCountries', + 'name' => 'settings[contextualPricingCountries]', + 'value' => $settings->getContextualPricingCountries(false), + 'errors' => $settings->getErrors('hostName'), + 'suggestEnvVars' => true, + ]) . + + Html::tag('hr') . + + Cp::fieldHtml( + Cp::renderTemplate('_includes/forms/copytext.twig', $authUrlFieldConfig), + $authUrlFieldConfig + ) . + + Html::endTag('div') + ; + + return $this->asCpScreen() + ->title(Craft::t('shopify', 'Settings')) + ->tabs([ + ['label' => Craft::t('shopify', 'API Connection'), 'url' => '#api'], + ['label' => Craft::t('shopify', 'Products'), 'url' => '#products'], + ]) + ->action('shopify/settings/save-settings') + ->redirectUrl('shopify/settings') + ->selectedSubnavItem('settings') + ->contentHtml($html); } /** diff --git a/src/models/Settings.php b/src/models/Settings.php index c7cfe72..28e0f83 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -111,6 +111,7 @@ public function fields(): array public function attributeLabels(): array { return [ + 'authUrl' => Craft::t('shopify', 'Shopify App Auth URL'), 'clientId' => Craft::t('shopify', 'Shopify Client ID'), 'clientSecret' => Craft::t('shopify', 'Shopify Client Secret Key'), 'apiVersion' => Craft::t('shopify', 'Shopify API Version'), @@ -307,4 +308,28 @@ public function getWebhookUrl(): string return $url; } + + /** + * @return string + * @since 7.0.0 + */ + public function getAuthUrl(): string + { + // Trim CP trigger if it's present. + $authPath = $this->getAuthPath(); + if ($cpTrigger = Craft::$app->getConfig()->getGeneral()->cpTrigger) { + $authPath = StringHelper::removeLeft($authPath, $cpTrigger . '/'); + } + + return UrlHelper::cpUrl($authPath); + } + + /** + * @return string + * @since 7.0.0 + */ + public function getAuthPath(): string + { + return UrlHelper::prependCpTrigger('shopify/auth'); + } } diff --git a/src/services/Api.php b/src/services/Api.php index 2bac896..c154774 100644 --- a/src/services/Api.php +++ b/src/services/Api.php @@ -19,26 +19,22 @@ use GraphQL\QueryBuilder\QueryBuilder; use GraphQL\Variable; use GuzzleHttp\Client; -use GuzzleHttp\Exception\GuzzleException; use Illuminate\Support\Collection; +use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\ClientInterface; use Shopify\ApiVersion; use Shopify\Auth\FileSessionStorage; +use Shopify\Auth\OAuth; use Shopify\Auth\Session; use Shopify\Clients\Graphql; +use Shopify\Clients\Http; use Shopify\Clients\HttpClientFactory; use Shopify\Clients\Rest; use Shopify\Context; use Shopify\Exception\MissingArgumentException; -use Shopify\Rest\Admin2023_10\Metafield as ShopifyMetafield; -use Shopify\Rest\Admin2023_10\Product as ShopifyProduct; -use Shopify\Rest\Admin2023_10\Variant as ShopifyVariant; -use Shopify\Rest\Admin2024_10\Metafield as ShopifyMetafield2410; -use Shopify\Rest\Admin2024_10\Product as ShopifyProduct2410; -use Shopify\Rest\Admin2024_10\Variant as ShopifyVariant2410; -use Shopify\Rest\Base as ShopifyBaseResource; -use Shopify\Utils; +use Shopify\Exception\UninitializedContextException; use Shopify\Webhooks\Topics; +use yii\base\InvalidConfigException; /** * Shopify API service. @@ -78,11 +74,6 @@ class Api extends Component */ private ?Graphql $_gqlClient = null; - /** - * @var Rest|null - */ - private ?Rest $_client = null; - /** * @return array * @since 5.3.0 @@ -467,6 +458,11 @@ public function getGqlClient(): Graphql { if ($this->_gqlClient === null) { $session = $this->getSession(); + + if (!$session) { + throw new InvalidConfigException('Unable to initialize API session. Check that your API credentials are correct and that you have authorized the app.'); + } + $this->_gqlClient = new Graphql($session->getShop(), $session->getAccessToken()); } @@ -485,88 +481,104 @@ public function getSession(): ?Session if ( $this->_session === null && - ($apiKey = $pluginSettings->getClientId(true)) && - ($apiSecretKey = $pluginSettings->getClientSecret(true)) + ($pluginSettings->getClientId(true)) && + ($pluginSettings->getClientSecret(true)) ) { - /** @var MonologTarget $webLogTarget */ - $webLogTarget = Craft::$app->getLog()->targets['web']; - - Context::initialize( - apiKey: $apiKey, - apiSecretKey: $apiSecretKey, - scopes: ['write_products', 'read_products', 'read_inventory'], - // This `hostName` is different from the `shop` value used when creating a Session! - // Shopify wants a name for the host/environment that is initiating the connection. - hostName: !Craft::$app->request->isConsoleRequest ? Craft::$app->getRequest()->getHostName() : 'localhost', - sessionStorage: new FileSessionStorage(Craft::$app->getPath()->getStoragePath() . DIRECTORY_SEPARATOR . 'shopify_api_sessions'), - apiVersion: $pluginSettings->getApiVersion(), - isEmbeddedApp: false, - logger: $webLogTarget->getLogger(), - ); - - Context::$HTTP_CLIENT_FACTORY = new class() extends HttpClientFactory { - public function client(): ClientInterface - { - // This is the default client, but we need to add the header for presentment prices - return new Client(['headers' => ['X-Shopify-Api-Features' => 'include-presentment-prices']]); - } - }; + $this->initializeContext(); $hostName = $pluginSettings->getHostName(true); - $accessToken = $this->getAccessToken(); - - $this->_session = new Session( - id: 'NA', - shop: $hostName, - isOnline: false, - state: 'NA' - ); - - $this->_session->setAccessToken($accessToken); // this is the most important part of the authentication + $accessToken = $this->getAccessToken(shop: $hostName); + + // If there isn't an access token we can't create a session + if ($accessToken) { + $this->_session = new Session( + id: 'NA', + shop: $hostName, + isOnline: false, + state: 'NA' + ); + + $this->_session->setAccessToken($accessToken); // this is the most important part of the authentication + } } return $this->_session; } + /** + * @return void + * @throws MissingArgumentException + * @throws \yii\base\Exception + * @since 7.0.0 + */ + public function initializeContext(): void + { + $pluginSettings = Plugin::getInstance()->getSettings(); + /** @var MonologTarget $webLogTarget */ + $webLogTarget = Craft::$app->getLog()->targets['web']; + + Context::initialize( + apiKey: $pluginSettings->getClientId(), + apiSecretKey: $pluginSettings->getClientSecret(), + scopes: ['write_products', 'read_products', 'read_inventory'], + // This `hostName` is different from the `shop` value used when creating a Session! + // Shopify wants a name for the host/environment that is initiating the connection. + hostName: !Craft::$app->request->isConsoleRequest ? Craft::$app->getRequest()->getHostName() : 'localhost', + sessionStorage: new FileSessionStorage(Craft::$app->getPath()->getStoragePath() . DIRECTORY_SEPARATOR . 'shopify_api_sessions'), + apiVersion: $pluginSettings->getApiVersion(), + isEmbeddedApp: false, + logger: $webLogTarget->getLogger(), + ); + + Context::$HTTP_CLIENT_FACTORY = new class() extends HttpClientFactory { + public function client(): ClientInterface + { + // This is the default client, but we need to add the header for presentment prices + return new Client(['headers' => ['X-Shopify-Api-Features' => 'include-presentment-prices']]); + } + + + }; + } /** + * @param string $code + * @param string $shop * @return string - * @throws GuzzleException + * @throws \JsonException + * @throws ClientExceptionInterface + * @throws UninitializedContextException * @since 7.0.0 */ - public function getAccessToken(): string + public function getAccessToken(?string $code = null, ?string $shop = null): string { // Try and retrieve the access token from the cache if ($accessToken = Craft::$app->getCache()->get(self::API_ACCESS_TOKEN_CACHE_KEY)) { return $accessToken; } - $client = Craft::createGuzzleClient([ - 'headers' => [ - 'Content-Type' => 'application/x-www-form-urlencoded', - ], - ]); + if (!$accessToken && !$code || !$shop) { + return ''; + } - $shopDomain = Utils::sanitizeShopDomain(Plugin::getInstance()->getSettings()->getHostName()); - $endpoint = 'https://' . $shopDomain . '/admin/oauth/access_token'; + $client = new Http($shop); try { - $response = $client->post($endpoint, [ - 'form_params' => [ - 'client_id' => Plugin::getInstance()->getSettings()->getClientId(true), - 'client_secret' => Plugin::getInstance()->getSettings()->getClientSecret(true), - 'grant_type' => 'client_credentials', - ], + $response = $client->post(OAuth::ACCESS_TOKEN_POST_PATH, [ + 'client_id' => Plugin::getInstance()->getSettings()->getClientId(true), + 'client_secret' => Plugin::getInstance()->getSettings()->getClientSecret(true), + 'code' => $code, + 'expiring' => 0, ]); - $body = Json::decodeIfJson((string)$response->getBody()); + $body = $response->getDecodedBody(); if (!isset($body['access_token'])) { throw new \Exception('No access token returned from Shopify.'); } // Cache the access token for its lifetime minus 2 minutes - Craft::$app->getCache()->set(self::API_ACCESS_TOKEN_CACHE_KEY, $body['access_token'], $body['expires_in'] - 120); + Craft::$app->getCache()->set(self::API_ACCESS_TOKEN_CACHE_KEY, $body['access_token'], 0); return $body['access_token']; } catch (\Exception $e) { @@ -652,234 +664,4 @@ public function deleteWebhookById(string $id, ?string &$error = null): bool return false; } } - - // Old REST methods - // ========================================================================= - - /** - * Retrieve all a shop’s products. - * - * @return ShopifyProduct[]|ShopifyProduct2410[] - * @deprecated in 6.0.0 - */ - public function getAllProducts(): array - { - /** @var ShopifyProduct[]|ShopifyProduct2410[] $all */ - $all = $this->getAll($this->getProductClass()); - - return $all; - } - - /** - * Retrieve a single product by its Shopify ID. - * - * @return ShopifyProduct|ShopifyProduct2410 - * @deprecated in 6.0.0 - */ - public function getProductByShopifyId($id): ShopifyProduct|ShopifyProduct2410 - { - return $this->getProductClass()::find($this->getSession(), $id); - } - - /** - * Retrieve a product ID by a variant's inventory item ID. - * - * @return ?int The product Shopify ID - * @deprecated in 6.0.0 - */ - public function getProductIdByInventoryItemId($id): ?int - { - $variant = Plugin::getInstance()->getApi()->get('variants', [ - 'inventory_item_id' => $id, - ]); - - if (isset($variant['variants'])) { - return $variant['variants'][0]['product_id']; - } - - return null; - } - - /** - * Retrieves "metafields" for the provided Shopify product ID. - * - * @param int $id Shopify Product ID - * @return ShopifyMetafield[]|ShopifyMetafield2410[] - * @deprecated in 6.0.0 - */ - public function getMetafieldsByProductId(int $id): array - { - if (!Plugin::getInstance()->getSettings()->syncProductMetafields) { - return []; - } - - return $this->getMetafieldsByIdAndOwnerResource($id, 'products'); - } - - /** - * @param int $id - * @return ShopifyMetafield[]|ShopifyMetafield2410[] - * @since 4.1.0 - * @deprecated in 6.0.0 - */ - public function getMetafieldsByVariantId(int $id): array - { - if (!Plugin::getInstance()->getSettings()->syncVariantMetafields) { - return []; - } - - return $this->getMetafieldsByIdAndOwnerResource($id, 'variants'); - } - - /** - * @param int $id - * @param string $ownerResource - * @return ShopifyMetafield[]|ShopifyMetafield2410[] - * @since 4.1.0 - * @deprecated in 6.0.0 - */ - public function getMetafieldsByIdAndOwnerResource(int $id, string $ownerResource): array - { - /** @var array $metafields */ - $metafields = $this->get("{$ownerResource}/{$id}/metafields", [ - 'metafield' => [ - 'owner_id' => $id, - 'owner_resource' => $ownerResource, - ], - ]); - - if (empty($metafields) || !isset($metafields['metafields'])) { - return []; - } - - $return = []; - - foreach ($metafields['metafields'] as $metafield) { - $metafieldClass = $this->getMetaFieldClass(); - $return[] = new $metafieldClass($this->getSession(), $metafield); - } - - return $return; - } - - /** - * Retrieves "variants" for the provided Shopify product ID. - * - * @param int $id Shopify Product ID - * @deprecated in 6.0.0 - */ - public function getVariantsByProductId(int $id): array - { - $resources = []; - $params = ['limit' => 250]; - - do { - $resources = array_merge($resources, $this->getVariantClass()::all( - $this->getSession(), - ['product_id' => $id], - $this->getVariantClass()::$NEXT_PAGE_QUERY ?: $params, - )); - } while ($this->getVariantClass()::$NEXT_PAGE_QUERY); - - $variants = []; - foreach ($resources as $resource) { - $variants[] = $resource->toArray(); - } - - return $variants; - } - - /** - * Shortcut for retrieving arbitrary API resources. A plain (parsed) response body is returned, so it’s the caller’s responsibility for unpacking it properly. - * - * @see Rest::get(); - * @deprecated in 6.0.0. Use [[query()]] instead. - */ - public function get($path, array $query = []) - { - $response = $this->getClient()->get($path, [], $query, 5); - - return $response->getDecodedBody(); - } - - /** - * Iteratively retrieves a paginated collection of API resources. - * - * @param string $type Stripe API resource class - * @param array $params - * @return ShopifyBaseResource[] - * @deprecated in 6.0.0. Use [[query()]] instead. - */ - public function getAll(string $type, array $params = []): array - { - $resources = []; - - // Force maximum page size: - $params['limit'] = 250; - - do { - $resources = array_merge($resources, $type::all( - $this->getSession(), - [], - $type::$NEXT_PAGE_QUERY ?: $params, - )); - } while ($type::$NEXT_PAGE_QUERY); - - return $resources; - } - - /** - * Returns or sets up a Rest API client. - * - * @return Rest - * @throws MissingArgumentException - * @deprecated in 6.0.0. Use [[getGqlClient()]] instead. - */ - public function getClient(): Rest - { - if ($this->_client === null) { - $session = $this->getSession(); - $this->_client = new Rest($session->getShop(), $session->getAccessToken()); - } - - return $this->_client; - } - - /** - * @return string - * @since 5.3.0 - * @phpstan-return class-string - */ - public function getProductClass(): string - { - return $this->_apiNamespace() . '\Product'; - } - - /** - * @return string - * @since 5.3.0 - * @phpstan-return class-string - */ - public function getVariantClass(): string - { - return $this->_apiNamespace() . '\Variant'; - } - - /** - * @return string - * @since 5.3.0 - * @phpstan-return class-string - */ - public function getMetaFieldClass(): string - { - return $this->_apiNamespace() . '\Metafield'; - } - - /** - * @return string - */ - private function _apiNamespace(): string - { - return 'Shopify\Rest\Admin' . str_replace('-', '_', Plugin::getInstance()->getSettings()->getApiVersion()); - } } diff --git a/src/templates/settings/_layout.twig b/src/templates/settings/_layout.twig deleted file mode 100644 index d8a2473..0000000 --- a/src/templates/settings/_layout.twig +++ /dev/null @@ -1,33 +0,0 @@ -{# @var craft \craft\web\twig\variables\CraftVariable #} -{% extends "_layouts/cp" %} -{% set selectedSubnavItem = 'settings' %} - -{% set title = "Settings"|t('shopify') %} - -{% set navItems = {} %} - -{% if currentUser.admin %} - {% set navItems = { - 'general': { title: "General"|t('shopify') }, - 'products': { title: "Products"|t('shopify') }, - } %} -{% endif %} - -{% if selectedItem is not defined %} - {% set selectedItem = craft.app.request.getSegment(2) %} -{% endif %} - -{% macro configWarning(setting, file) -%} - {%- apply spaceless %} - {% set config = craft.app.config.getConfigFromFile(file) %} - {% if config[setting] is defined %} - {{ "This is being overridden by the {setting} config setting in `config/{file}.php`."|t('commerce', { - setting: setting, - file: file, - })|raw }} - {% else %} - {{ false }} - {% endif %} - {% endapply -%} -{%- endmacro %} - diff --git a/src/templates/settings/index.twig b/src/templates/settings/index.twig deleted file mode 100644 index 7743f25..0000000 --- a/src/templates/settings/index.twig +++ /dev/null @@ -1,114 +0,0 @@ -{% extends "shopify/settings/_layout" %} -{% import '_includes/forms.twig' as forms %} -{% set fullPageForm = true %} - -{% do view.registerTranslations('shopify', [ - "Topic", - "Address" -]) %} - -{% block content %} - {% set headlessMode = craft.app.config.general.headlessMode %} - {{ actionInput('shopify/settings/save-settings') }} - {{ redirectInput('shopify/settings') }} - - - -
- {{ forms.autosuggestField({ - first: true, - label: settings.getAttributeLabel('apiVersion'), - instructions: 'Supported API versions: {versions}'|t('shopify', {versions: craft.shopify.api.getSupportedApiVersions()|join(', ')}), - id: 'apiVersion', - name: 'settings[apiVersion]', - value: settings.getApiVersion(false), - errors: settings.getErrors('apiVersion'), - suggestEnvVars: true, - autofocus: true - }) }} - - {{ forms.autosuggestField({ - label: settings.getAttributeLabel('clientId'), - id: 'clientId', - name: 'settings[clientId]', - value: settings.getClientId(false), - errors: settings.getErrors('clientId'), - suggestEnvVars: true, - }) }} - - {{ forms.autosuggestField({ - label: settings.getAttributeLabel('clientSecret'), - id: 'clientSecret', - name: 'settings[clientSecret]', - value: settings.getClientSecret(false), - errors: settings.getErrors('clientSecret'), - suggestEnvVars: true, - }) }} - - {{ forms.autosuggestField({ - label: settings.getAttributeLabel('hostName'), - instructions: 'The Shopify store hostname.'|t('shopify'), - id: 'hostName', - name: 'settings[hostName]', - value: settings.getHostName(false), - errors: settings.getErrors('hostName'), - suggestEnvVars: true, - }) }} - - {{ forms.autosuggestField({ - label: 'Contextual Pricing Countries'|t('shopify'), - instructions: 'A comma separated list of country codes used to return contextual pricing.'|t('shopify'), - id: 'contextualPricingCountries', - name: 'settings[contextualPricingCountries]', - value: settings.getContextualPricingCountries(false), - errors: settings.getErrors('hostName'), - suggestEnvVars: true, - }) }} - -
- - -{% endblock %} \ No newline at end of file From 4ff0c558d4dfc70043c90d0d4f22e05367896915 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Fri, 6 Feb 2026 14:19:04 +0000 Subject: [PATCH 24/98] Move to storing the access token in the env file/db --- src/Plugin.php | 12 +++--- src/controllers/SettingsController.php | 9 ++-- src/db/Table.php | 1 + src/migrations/Install.php | 9 ++++ .../m260206_131734_add_access_token_table.php | 41 +++++++++++++++++++ src/models/Settings.php | 38 +++++++++++++++++ src/records/AccessToken.php | 34 +++++++++++++++ src/services/Api.php | 36 +++++++++++----- 8 files changed, 158 insertions(+), 22 deletions(-) create mode 100644 src/migrations/m260206_131734_add_access_token_table.php create mode 100644 src/records/AccessToken.php diff --git a/src/Plugin.php b/src/Plugin.php index 47e3826..37bace3 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -73,7 +73,7 @@ class Plugin extends BasePlugin /** * @var string */ - public string $schemaVersion = '6.0.5.0'; + public string $schemaVersion = '7.0.0.0'; /** * @inheritdoc @@ -475,12 +475,10 @@ public function getCpNavItem(): ?array $session = Plugin::getInstance()->getApi()->getSession(); - if ($session) { - $ret['subnav']['products'] = [ - 'label' => Craft::t('shopify', 'Products'), - 'url' => 'shopify/products', - ]; - } + $ret['subnav']['products'] = [ + 'label' => Craft::t('shopify', 'Products'), + 'url' => 'shopify/products', + ]; if (Craft::$app->getUser()->getIsAdmin() && Craft::$app->getConfig()->getGeneral()->allowAdminChanges) { $ret['subnav']['settings'] = [ diff --git a/src/controllers/SettingsController.php b/src/controllers/SettingsController.php index 452d3ce..4a577b4 100644 --- a/src/controllers/SettingsController.php +++ b/src/controllers/SettingsController.php @@ -61,6 +61,7 @@ public function actionIndex(?Settings $settings = null): Response 'name' => 'settings[authUrl]', 'value' => $settings->getAuthUrl(), 'readonly' => true, + 'warning' => !Plugin::getInstance()->getApi()->getSession() ? Craft::t('shopify', 'Unable to connect to custom app. Syncing will be unavailable until the app has been authorized.') : null, ]; $html = Html::beginTag('div', ['id' => 'products', 'class' => 'hidden']) . @@ -80,20 +81,20 @@ public function actionIndex(?Settings $settings = null): Response 'heading' => Craft::t('shopify', 'Product URI Format'), 'info' => Craft::t('shopify', 'What product URIs should look like.'), 'placeholder' => Craft::t('shopify', 'Leave blank if products don’t have URLs'), - 'code' => true + 'code' => true, ], 'template' => $headlessMode ? [] : [ 'type' => 'template', 'heading' => Craft::t('app', 'Template'), 'info' => Craft::t('shopify', 'Which template should be loaded when a product’s URL is requested.'), - 'code' => true + 'code' => true, ], ]), 'rows' => [ 'routing' => [ 'uriFormat' => [ 'value' => $settings->uriFormat ?? null, - 'hasErrors' => $settings->hasErrors('uriFormat') ?? false + 'hasErrors' => $settings->hasErrors('uriFormat') ?? false, ], 'template' => $headlessMode ? [] : [ 'value' => $settings->template ?? null, @@ -118,7 +119,7 @@ public function actionIndex(?Settings $settings = null): Response 'value' => $settings->getApiVersion(false), 'errors' => $settings->getErrors('apiVersion'), 'suggestEnvVars' => true, - 'autofocus' => true + 'autofocus' => true, ]) . Cp::autosuggestFieldHtml([ diff --git a/src/db/Table.php b/src/db/Table.php index d2f097e..71512b9 100644 --- a/src/db/Table.php +++ b/src/db/Table.php @@ -18,4 +18,5 @@ abstract class Table public const DATA = '{{%shopify_data}}'; public const PRODUCTS = '{{%shopify_products}}'; public const BULK_OPERATIONS = '{{%shopify_bulkoperations}}'; + public const ACCESS_TOKENS = '{{%shopify_accesstokens}}'; } diff --git a/src/migrations/Install.php b/src/migrations/Install.php index 7b9da79..7f7bf50 100644 --- a/src/migrations/Install.php +++ b/src/migrations/Install.php @@ -34,6 +34,15 @@ public function safeUp(): bool */ public function createTables(): void { + $this->archiveTableIfExists(Table::ACCESS_TOKENS); + $this->createTable(Table::ACCESS_TOKENS, [ + 'id' => $this->primaryKey(), + 'accessToken' => $this->string(), + 'dateCreated' => $this->dateTime()->notNull(), + 'dateUpdated' => $this->dateTime()->notNull(), + 'uid' => $this->uid(), + ]); + $this->archiveTableIfExists(Table::PRODUCTS); $this->createTable(Table::PRODUCTS, [ 'id' => $this->integer()->notNull(), diff --git a/src/migrations/m260206_131734_add_access_token_table.php b/src/migrations/m260206_131734_add_access_token_table.php new file mode 100644 index 0000000..76f64e1 --- /dev/null +++ b/src/migrations/m260206_131734_add_access_token_table.php @@ -0,0 +1,41 @@ +db->tableExists(Table::ACCESS_TOKENS)) { + return true; + } + + $this->createTable(Table::ACCESS_TOKENS, [ + 'id' => $this->primaryKey(), + 'accessToken' => $this->string(), + 'dateCreated' => $this->dateTime()->notNull(), + 'dateUpdated' => $this->dateTime()->notNull(), + 'uid' => $this->uid(), + ]); + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + echo "m260206_131734_add_access_token_table cannot be reverted.\n"; + return false; + } +} diff --git a/src/models/Settings.php b/src/models/Settings.php index 28e0f83..db161a9 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -14,6 +14,7 @@ use craft\helpers\UrlHelper; use craft\shopify\elements\Product; use craft\shopify\Plugin; +use craft\shopify\records\AccessToken; use Shopify\ApiVersion; use Shopify\Utils; @@ -27,6 +28,7 @@ class Settings extends Model { private string $_clientId = ''; private string $_clientSecret = ''; + private string $_accessToken = ''; private string $_hostName = ''; public string $uriFormat = ''; @@ -251,6 +253,42 @@ public function getHostName(bool $parse = true): string return ($parse ? App::parseEnv($this->_hostName) : $this->_hostName) ?? ''; } + /** + * @param string $accessToken + * @return void + * @since 6.0.0 + */ + public function setAccessToken(string $accessToken): void + { + $this->_accessToken = $accessToken; + } + + /** + * @param bool $parse + * @return string + * @since 6.0.0 + */ + public function getAccessToken(bool $parse = true): string + { + if (!$this->_accessToken) { + $accessTokenRecord = AccessToken::find()->one() ?? new AccessToken(); + if (!$accessTokenRecord->accessToken) { + return ''; + } + + $accessToken = $accessTokenRecord->accessToken; + + // If an actual access token, and not a env var, has been stored we need to unencrypt it + if (!str_starts_with($accessToken, '$')) { + $accessToken = Craft::$app->getSecurity()->decryptByKey($accessToken); + } + + $this->setAccessToken($accessToken); + } + + return ($parse ? App::parseEnv($this->_accessToken) : $this->_accessToken) ?? ''; + } + /** * @param string $contextualPricingCountries * @return void diff --git a/src/records/AccessToken.php b/src/records/AccessToken.php new file mode 100644 index 0000000..26121d4 --- /dev/null +++ b/src/records/AccessToken.php @@ -0,0 +1,34 @@ + + * @since 7.0.0 + * + * @property int $id + * @property string $accessToken + * @property string $dateCreated + * @property string $dateUpdated + * @property string $uid + */ +class AccessToken extends ActiveRecord +{ + /** + * @inheritdoc + */ + public static function tableName(): string + { + return Table::ACCESS_TOKENS; + } +} diff --git a/src/services/Api.php b/src/services/Api.php index c154774..49f3fa0 100644 --- a/src/services/Api.php +++ b/src/services/Api.php @@ -13,6 +13,7 @@ use craft\helpers\Json; use craft\log\MonologTarget; use craft\shopify\Plugin; +use craft\shopify\records\AccessToken; use craft\shopify\records\ShopifyData; use GraphQL\Mutation; use GraphQL\Query; @@ -62,7 +63,7 @@ class Api extends Component /** * @since 7.0.0 */ - public const API_ACCESS_TOKEN_CACHE_KEY = 'shopifyApiAccessToken'; + public const API_ACCESS_TOKEN_ENV_VAR = 'SHOPIFY_API_ACCESS_TOKEN'; /** * @var Session|null @@ -542,23 +543,23 @@ public function client(): ClientInterface } /** - * @param string $code - * @param string $shop - * @return string - * @throws \JsonException + * @param string|null $code + * @param string|null $shop + * @return string|null * @throws ClientExceptionInterface * @throws UninitializedContextException + * @throws \JsonException * @since 7.0.0 */ - public function getAccessToken(?string $code = null, ?string $shop = null): string + public function getAccessToken(?string $code = null, ?string $shop = null): ?string { // Try and retrieve the access token from the cache - if ($accessToken = Craft::$app->getCache()->get(self::API_ACCESS_TOKEN_CACHE_KEY)) { + if ($accessToken = Plugin::getInstance()->getSettings()->getAccessToken()) { return $accessToken; } - if (!$accessToken && !$code || !$shop) { - return ''; + if (!$code || !$shop) { + return null; } $client = new Http($shop); @@ -577,8 +578,21 @@ public function getAccessToken(?string $code = null, ?string $shop = null): stri throw new \Exception('No access token returned from Shopify.'); } - // Cache the access token for its lifetime minus 2 minutes - Craft::$app->getCache()->set(self::API_ACCESS_TOKEN_CACHE_KEY, $body['access_token'], 0); + $configService = Craft::$app->getConfig(); + $record = AccessToken::find()->one() ?? new AccessToken(); + + $success = true; + try { + $configService->setDotEnvVar(self::API_ACCESS_TOKEN_ENV_VAR, $body['access_token']); + } catch (\Throwable $e) { + $success = false; + Craft::error('Couldn\'t save the Shopify Access Token in the .env file. ' . $e->getMessage(), __METHOD__); + } + $record->accessToken = $success ? '$' . self::API_ACCESS_TOKEN_ENV_VAR : Craft::$app->getSecurity()->encryptByKey($body['access_token']); + + if (!$record->save()) { + Craft::error('Couldn\'t save the Shopify Access Token in the database. ' . $record->getErrors()[0], __METHOD__); + } return $body['access_token']; } catch (\Exception $e) { From df5a7a754b9662edf29a85a53f41021082cd597a Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Fri, 6 Feb 2026 16:17:16 +0000 Subject: [PATCH 25/98] Tidy auth controller visuals --- src/controllers/AuthController.php | 65 +++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 20 deletions(-) diff --git a/src/controllers/AuthController.php b/src/controllers/AuthController.php index 86e2a51..bbaa09d 100644 --- a/src/controllers/AuthController.php +++ b/src/controllers/AuthController.php @@ -53,7 +53,7 @@ public function actionIndex(): YiiResponse $settings = Plugin::getInstance()->getSettings(); $screen = $this->asCpScreen() - ->title(Craft::t('shopify', 'Authorize App')); + ->title(Craft::t('shopify', 'Authorization')); $validHmac = Utils::validateHmac(Craft::$app->getRequest()->getQueryParams(), $settings->getClientSecret()); if (!$validHmac) { @@ -79,7 +79,18 @@ public function actionIndex(): YiiResponse throw new InvalidOAuthException('Failed to retrieve access token.'); } - return $screen->contentHtml(Html::tag('p', Craft::t('shopify', 'App authorized successfully.'))); + return $screen->contentHtml( + Html::beginTag('div', ['class' => 'flex flex-justify-center']) . + Html::beginTag('div', ['class' => 'pane centeralign']) . + + Html::tag('p', + Html::tag('span', '', ['class' => 'checkmark-icon']) . ' ' . + Craft::t('shopify', 'Your Shopify app has been successfully authorized.') + ) . + + Html::endTag('div') . + Html::endTag('div') + ); } catch (\Exception $e) { Craft::error($e->getMessage(), __METHOD__); return $screen->contentHtml($this->_errorHtml(Craft::t('shopify', 'Error authorizing app'), $e->getMessage())); @@ -88,10 +99,21 @@ public function actionIndex(): YiiResponse // If no code is present, it means the user is initiating the authorization process. $path = Plugin::getInstance()->getSettings()->getAuthPath(); + $shop = Craft::$app->getRequest()->getQueryParam('shop'); $authorizeUrl = OAuth::begin($settings->getHostName(), $path, false, fn(OAuthCookie $oauthCookie) => $this->_setCookies($oauthCookie, $screen)); return $screen - ->contentHtml(Html::a(Craft::t('shopify', 'Authorize'), $authorizeUrl)); + ->contentHtml( + Html::beginTag('div', ['class' => 'flex flex-justify-center']) . + Html::beginTag('div', ['class' => 'pane centeralign', 'style' => 'max-width: 400px']) . + + Html::tag('h2', Craft::t('shopify', 'Authorize App')) . + Html::tag('p', Craft::t('shopify', 'The Shopify store {shop} needs to be authorized to connect with this plugin.', ['shop' => Html::tag('strong', $shop)])) . + Html::a(Craft::t('shopify', 'Authorize'), $authorizeUrl, ['class' => 'btn submit']) . + + Html::endTag('div') . + Html::endTag('div') + ); } /** @@ -177,23 +199,26 @@ private function _setCookies(OAuthCookie $oauthCookie, Response $screen): bool */ private function _errorHtml(string $heading, string $message): string { - return Html::beginTag('div', [ - 'class' => ['error-summary'], - ]) . - Html::beginTag('div') . - Html::tag('span', '', [ - 'class' => 'notification-icon', - 'data-icon' => 'alert', - 'aria-label' => Craft::t('app', 'Error'), - 'role' => 'img', - ]) . - Html::tag('h2', $heading) . - Html::endTag('div') . - Html::beginTag('ul', [ - 'class' => ['errors'], - ]) . - Html::tag('li', $message) . - Html::endTag('ul') . + return Html::beginTag('div', ['class' => 'flex flex-justify-center']) . + Html::beginTag('div', [ + 'class' => ['error-summary fullwidth'], + 'style' => 'max-width: 400px', + ]) . + Html::beginTag('div') . + Html::tag('span', '', [ + 'class' => 'notification-icon', + 'data-icon' => 'alert', + 'aria-label' => Craft::t('app', 'Error'), + 'role' => 'img', + ]) . + Html::tag('h2', $heading) . + Html::endTag('div') . + Html::beginTag('ul', [ + 'class' => ['errors'], + ]) . + Html::tag('li', $message) . + Html::endTag('ul') . + Html::endTag('div') . Html::endTag('div'); } } From 0b5417090e38141afa5d2177f3579c459cc49347 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Fri, 6 Feb 2026 16:17:43 +0000 Subject: [PATCH 26/98] fix cs --- src/services/Api.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/services/Api.php b/src/services/Api.php index 49f3fa0..c1ef1ed 100644 --- a/src/services/Api.php +++ b/src/services/Api.php @@ -537,8 +537,6 @@ public function client(): ClientInterface // This is the default client, but we need to add the header for presentment prices return new Client(['headers' => ['X-Shopify-Api-Features' => 'include-presentment-prices']]); } - - }; } From 7fd16c65ecf28f68579e51b857144fa0c4bdbaf7 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Mon, 9 Feb 2026 08:38:12 +0000 Subject: [PATCH 27/98] fix cs/phpstan --- src/controllers/SettingsController.php | 4 ++-- src/models/Settings.php | 12 +++++++++++- src/records/AccessToken.php | 1 + src/services/Api.php | 1 + 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/controllers/SettingsController.php b/src/controllers/SettingsController.php index 4a577b4..2b162a9 100644 --- a/src/controllers/SettingsController.php +++ b/src/controllers/SettingsController.php @@ -94,11 +94,11 @@ public function actionIndex(?Settings $settings = null): Response 'routing' => [ 'uriFormat' => [ 'value' => $settings->uriFormat ?? null, - 'hasErrors' => $settings->hasErrors('uriFormat') ?? false, + 'hasErrors' => $settings->hasErrors('uriFormat'), ], 'template' => $headlessMode ? [] : [ 'value' => $settings->template ?? null, - 'hasErrors' => $settings->hasErrors('template') ?? false, + 'hasErrors' => $settings->hasErrors('template'), ], ], ], diff --git a/src/models/Settings.php b/src/models/Settings.php index db161a9..6c39a46 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -10,6 +10,7 @@ use Craft; use craft\base\Model; use craft\helpers\App; +use craft\helpers\Cp; use craft\helpers\StringHelper; use craft\helpers\UrlHelper; use craft\shopify\elements\Product; @@ -271,6 +272,7 @@ public function setAccessToken(string $accessToken): void public function getAccessToken(bool $parse = true): string { if (!$this->_accessToken) { + /** @var AccessToken $accessTokenRecord */ $accessTokenRecord = AccessToken::find()->one() ?? new AccessToken(); if (!$accessTokenRecord->accessToken) { return ''; @@ -359,7 +361,15 @@ public function getAuthUrl(): string $authPath = StringHelper::removeLeft($authPath, $cpTrigger . '/'); } - return UrlHelper::cpUrl($authPath); + $url = UrlHelper::cpUrl($authPath); + $requestedSite = Cp::requestedSite()?->handle ?? null; + + if ($requestedSite && strpos($url, 'site=' . $requestedSite) > -1) { + $url = str_replace("site={$requestedSite}", '', $url); + $url = StringHelper::removeRight($url, '?'); + } + + return $url; } /** diff --git a/src/records/AccessToken.php b/src/records/AccessToken.php index 26121d4..176613d 100644 --- a/src/records/AccessToken.php +++ b/src/records/AccessToken.php @@ -16,6 +16,7 @@ * @author Pixel & Tonic, Inc. * @since 7.0.0 * + * * @property int $id * @property string $accessToken * @property string $dateCreated diff --git a/src/services/Api.php b/src/services/Api.php index c1ef1ed..5fc8493 100644 --- a/src/services/Api.php +++ b/src/services/Api.php @@ -577,6 +577,7 @@ public function getAccessToken(?string $code = null, ?string $shop = null): ?str } $configService = Craft::$app->getConfig(); + /** @var AccessToken $record */ $record = AccessToken::find()->one() ?? new AccessToken(); $success = true; From ded284704e4752d60a23b3fddb26c8b109239ad4 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Mon, 9 Feb 2026 16:51:01 +0000 Subject: [PATCH 28/98] Fix forcing of resaving the access token if authorization happens again --- src/controllers/AuthController.php | 2 +- src/services/Api.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/controllers/AuthController.php b/src/controllers/AuthController.php index bbaa09d..66819f1 100644 --- a/src/controllers/AuthController.php +++ b/src/controllers/AuthController.php @@ -149,7 +149,7 @@ private function _fetchAccessToken(array $cookies, array $query, ?callable $setC } $sanitizedShop = Utils::sanitizeShopDomain($query['shop'] ?? ''); - return Plugin::getInstance()->getApi()->getAccessToken($query['code'], $sanitizedShop); + return Plugin::getInstance()->getApi()->getAccessToken($query['code'], $sanitizedShop, true); } /** diff --git a/src/services/Api.php b/src/services/Api.php index 5fc8493..b1c7a5a 100644 --- a/src/services/Api.php +++ b/src/services/Api.php @@ -549,10 +549,10 @@ public function client(): ClientInterface * @throws \JsonException * @since 7.0.0 */ - public function getAccessToken(?string $code = null, ?string $shop = null): ?string + public function getAccessToken(?string $code = null, ?string $shop = null, bool $forceRefresh = false): ?string { // Try and retrieve the access token from the cache - if ($accessToken = Plugin::getInstance()->getSettings()->getAccessToken()) { + if (!$forceRefresh && $accessToken = Plugin::getInstance()->getSettings()->getAccessToken()) { return $accessToken; } From 66bb79b5384d0a11873d8d0a0410a627a23c1234 Mon Sep 17 00:00:00 2001 From: August Miller Date: Mon, 16 Feb 2026 16:50:51 -0800 Subject: [PATCH 29/98] Add API console controller for debugging --- src/console/controllers/ApiController.php | 55 +++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/console/controllers/ApiController.php diff --git a/src/console/controllers/ApiController.php b/src/console/controllers/ApiController.php new file mode 100644 index 0000000..91da0cb --- /dev/null +++ b/src/console/controllers/ApiController.php @@ -0,0 +1,55 @@ + + * @since 7.0 + */ +class ApiController extends Controller +{ + /** @var string $defaultAction */ + public $defaultAction = 'query'; + + /** + * Send the provided GraphQL query string to the Shopify API. + * + * @param string $gql GraphQL fragment to send. Variables are not supported! + */ + public function actionQuery(string $gql): int + { + // Record how long the API service is: + $start = microtime(true); + + $this->stdout("Running query... "); + $data = Plugin::getInstance()->getApi()->query($gql); + $this->stdout("done!", Console::FG_GREEN); + + // Report timing: + $this->stdout(sprintf(' (%fs)', microtime(true) - $start), Console::FG_GREY); + $this->stdout(PHP_EOL); + + if (!$data) { + $this->stderr('The response was empty or had an unexpected structure. Check your console logs for more information!' . PHP_EOL); + + return ExitCode::UNAVAILABLE; + } + + $this->stdout('Response:' . PHP_EOL . print_r($data, true) . PHP_EOL); + + return ExitCode::OK; + } +} From c4c6a6063267f399d1a5c6370bc579250ee2b36a Mon Sep 17 00:00:00 2001 From: August Miller Date: Mon, 16 Feb 2026 16:51:09 -0800 Subject: [PATCH 30/98] Normalize case for Shopify bulk op statuses in the CP --- src/utilities/Sync.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utilities/Sync.php b/src/utilities/Sync.php index fd7871f..21cd76f 100644 --- a/src/utilities/Sync.php +++ b/src/utilities/Sync.php @@ -9,6 +9,7 @@ use Craft; use craft\base\Utility; +use craft\helpers\StringHelper; use craft\shopify\enums\BulkOperationStatus; use craft\shopify\models\BulkOperation; use craft\shopify\Plugin; @@ -77,7 +78,7 @@ public static function contentHtml(): string return [ 'id' => $bo->id, 'status' => $bo->statusLabelHtml(), - 'shopifyStatus' => $bo->shopifyStatus, + 'shopifyStatus' => StringHelper::toTitleCase($bo->shopifyStatus), 'objects' => $bo->objectCount ? $formatter->asInteger($bo->objectCount) : '', 'dateCreated' => $formatter->asDatetime($bo->dateCreated), 'dateUpdated' => $formatter->asDatetime($bo->dateUpdated), From de1e0ea6f5485ec3d73c82299e80de947bfc4bc1 Mon Sep 17 00:00:00 2001 From: August Miller Date: Mon, 16 Feb 2026 16:52:37 -0800 Subject: [PATCH 31/98] All bulk op work is done via the API The webhook has the correct object ID, but the schema (and even the underlying data) is in a different format. This uses the API object as the source of truth, by fetching it fresh when a webhook arrives. --- src/services/BulkOperations.php | 104 ++++++++++++++++++++------------ 1 file changed, 64 insertions(+), 40 deletions(-) diff --git a/src/services/BulkOperations.php b/src/services/BulkOperations.php index 2c4c7ff..b5fe378 100644 --- a/src/services/BulkOperations.php +++ b/src/services/BulkOperations.php @@ -185,69 +185,75 @@ public function nextBulkOperation(): bool } /** - * @param array $data + * Processes an incoming bulk operation webhook. + * + * The only topic that Shopify supports is {@see Topics::BULK_OPERATIONS_FINISH}, which covers both successes and failures. + * This method runs synchronously, *during the webhook delivery*, which means it should return as early as possible. + * + * When we receive a webhook indicating that a bulk operation has finished successfully, the next operation should be queued. + * + * @param array $payload * @return void * @since 6.0.0 */ - public function handleBulkOperationFinished(array $data): void + public function handleBulkOperationFinished(array $payload): void { - // Exit out if we don't have the necessary data - if (!isset($data['admin_graphql_api_id']) || !isset($data['status'])) { + // An API ID must be present in order to do anything: + if (!isset($payload['admin_graphql_api_id'])) { return; } - $bulkOperation = $this->getBulkOperationByShopifyId($data['admin_graphql_api_id']); + // Load our local record of the bulk op: + $bulkOperation = $this->getBulkOperationByShopifyId($payload['admin_graphql_api_id']); + if (!$bulkOperation) { + // Ok... maybe it was initiated for a different environment? + return; + } + + // Load the complete bulk operation object from the API: + // (The schema of the webhook payload and the actual object are different in subtle ways—like the casing of statuses.) + $query = $this->_createBulkOperationGqlQuery() + ->setArguments(['id' => $payload['admin_graphql_api_id']]); + + try { + $apiObject = Plugin::getInstance()->getApi()->query($query); + } catch (\Exception $e) { + Craft::error('Could not get bulk operation data: ' . $e->getMessage(), __METHOD__); return; } - // If it isn't a completed status we need to update the queue - if ($data['status'] !== 'completed') { - $deletableStatuses = [ + Craft::info(sprintf('Shopify bulk data op finished with status %s! (Error code: %s)', $apiObject['status'], $apiObject['errorCode'] ?? 'none')); + + // If it isn't “completed,” the only action we need to take is updating our record: + if ($apiObject['status'] !== 'COMPLETED') { + $terminalStatuses = [ 'CANCELED', 'EXPIRED', 'FAILED', ]; - if (in_array($data['status'], $deletableStatuses)) { + + // Our similarly-named “complete” status is mostly an indication about whether we expect further activity from the operation. + // Moving it out of the “processing” status also allows a user to delete/purge it from the sync history. {@see craft\shopify\controllers\SyncController::actionDelete()} + if (in_array($apiObject['status'], $terminalStatuses)) { $bulkOperation->setStatus(BulkOperationStatus::Completed); } - $bulkOperation->shopifyStatus = $data['status']; + // Save the “real” Shopify status, whatever it was: + $bulkOperation->shopifyStatus = $apiObject['status']; $this->saveBulkOperation($bulkOperation, false); + return; } - $query = (new \GraphQL\Query('node')) - ->setArguments(['id' => $data['admin_graphql_api_id']]) - ->setSelectionSet([ - (new InlineFragment('BulkOperation')) - ->setSelectionSet([ - 'status', - 'url', - 'partialDataUrl', - 'objectCount', - ]), - ]); + // At this point, we know the status is `COMPLETED` and it’s safe to process! + // Store the new status, and a reference to the external JSONL file: + $bulkOperation->shopifyStatus = $apiObject['status']; + $bulkOperation->url = $apiObject['url']; + $bulkOperation->objectCount = $apiObject['objectCount']; - try { - $response = Plugin::getInstance()->getApi()->getGqlClient()->query(['query' => (string)$query]); - $body = $response->getDecodedBody(); - - if (!isset($body['data']['node'])) { - return; - } - - // Store the data from the `$body['data']['url'] and start the queue job to process it - $bulkOperation->shopifyStatus = $body['data']['node']['status']; - $bulkOperation->url = $body['data']['node']['url']; - $bulkOperation->objectCount = $body['data']['node']['objectCount']; - - if (!$this->saveBulkOperation($bulkOperation)) { - Craft::error('Could not save bulk operation data.', __METHOD__); - return; - } - } catch (\Exception $e) { - Craft::error('Could not get bulk operation data: ' . $e->getMessage(), __METHOD__); + if (!$this->saveBulkOperation($bulkOperation)) { + Craft::error('Could not save bulk operation data.', __METHOD__); } $this->queueNextBulkOperation(); @@ -414,4 +420,22 @@ private function _createBulkOperationQuery(): Query ->from([Table::BULK_OPERATIONS]) ->orderBy(['id' => SORT_DESC]); } + + /** + * Creates a GQL query for retrieving BulkOperation objects. + */ + private function _createBulkOperationGqlQuery(): \GraphQL\Query + { + return (new \GraphQL\Query('node')) + ->setSelectionSet([ + (new InlineFragment('BulkOperation')) + ->setSelectionSet([ + 'status', + 'errorCode', + 'url', + 'partialDataUrl', + 'objectCount', + ]), + ]); + } } From d2b5ba2db177f0f39dc0175b1d4340710938e439 Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 18 Feb 2026 23:33:41 -0800 Subject: [PATCH 32/98] =?UTF-8?q?Consolidate=20API=20error=20handling=20in?= =?UTF-8?q?to=20service=20layer=20This=20is=20a=20bit=20of=20housekeeping,?= =?UTF-8?q?=20and=20a=20bit=20of=20soul-searching.=20I=20had=20a=20heck=20?= =?UTF-8?q?of=20a=20time=20reasoning=20about=20when=20and=20why=20Shopify?= =?UTF-8?q?=20was=20producing=20errors,=20so=20I=20figured=20it=20was=20wo?= =?UTF-8?q?rth=20expressing=20the=20variety=20of=20message=20structures=20?= =?UTF-8?q?that=20we=20may=20get=20back=20from=20the=20API.=20-=20Network/?= =?UTF-8?q?connection=20or=20"client"=20errors,=20in=20Guzzle-land=20(not?= =?UTF-8?q?=20specific=20to=20Shopify)=20are=20caught=20and=20re-thrown=20?= =?UTF-8?q?with=20a=20generic=20message.=20-=20API=20consumers=20(things?= =?UTF-8?q?=20that=20call=20`Api::query()`=20can=20basically=20catch=20a?= =?UTF-8?q?=20single=20kind=20of=20error=20and=20decide=20what=20to=20do?= =?UTF-8?q?=20-=20Those=20queries=20no=20longer=20have=20to=20worry=20abou?= =?UTF-8?q?t=20unpacking=20and=20handling=20various=20response=20structure?= =?UTF-8?q?s=20(like=20`userErrors`),=20or=20the=20root=20element=20in=20e?= =?UTF-8?q?ach=20response=20named=20the=20same=20as=20the=20original=20que?= =?UTF-8?q?ry=20or=20procedure.=20-=20We=20now=20throw=20on=20any=20sessio?= =?UTF-8?q?n=20issue=20before=20querying,=20rather=20than=20relying=20on?= =?UTF-8?q?=20individual=20consumers.=20-=20We=20no=20longer=20throw=20on?= =?UTF-8?q?=20=E2=80=9Cempty=E2=80=9D=20or=20missing=20`data`=20keys=20in?= =?UTF-8?q?=20responses=20(they're=20just=20returned=20verbatim,=20and=20i?= =?UTF-8?q?t's=20up=20to=20the=20consumer=20to=20decide=20whether=20that?= =?UTF-8?q?=E2=80=99s=20=E2=80=9Cbad=E2=80=9D)=20-=20If=20you=20really=20n?= =?UTF-8?q?eed=20to=20perform=20a=20low-level,=20unopinionated=20query,=20?= =?UTF-8?q?use=20`Api::getGqlClient()`=20directly.=20-=20Webhook=20setup?= =?UTF-8?q?=20and=20messaging=20has=20been=20improved=20=20=20-=20Filter?= =?UTF-8?q?=20webhooks=20by=20environment/URI=20at=20the=20API=20level,=20?= =?UTF-8?q?rather=20than=20in=20the=20controller=20=20=20-=20Simplified=20?= =?UTF-8?q?how=20we=20detect=20and=20repair=20"missing"=20webhook=20topic?= =?UTF-8?q?=20subscriptions=20=20=20-=20We=20now=20use=20a=20template=20an?= =?UTF-8?q?d=20natural=20form=20requests=20and=20success/failure=20respons?= =?UTF-8?q?e=20handlers=20for=20the=20webhook=20management=20view=20=20=20?= =?UTF-8?q?-=20Moved=20away=20from=20deprecated=20`endpoint`=20and=20`call?= =?UTF-8?q?backUrl`=20fields=20when=20creating=20and=20fetching=20webhooks?= =?UTF-8?q?,=20in=20favor=20of=20the=20plain=20`uri`=20field=20-=20Bulk=20?= =?UTF-8?q?operation=20handling=20is=20no=20longer=20concerned=20with=20AP?= =?UTF-8?q?I=20response=20structure,=20errors,=20etc.=20(per=20above)=20?= =?UTF-8?q?=20=20-=20For=20stores=20that=20use=20bulk=20GQL=20*mutations*,?= =?UTF-8?q?=20we=20now=20look=20for=20specifically=20bulk=20*query*=20oper?= =?UTF-8?q?ations=20(one=20mutation=20and=20one=20query=20can=20run=20in?= =?UTF-8?q?=20parallel)=20-=20=3F!=3F!?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/console/controllers/ApiController.php | 28 +++- src/controllers/WebhooksController.php | 149 +++++++--------------- src/services/Api.php | 102 +++++++++------ src/services/BulkOperations.php | 42 ++++-- src/templates/_webhooks.twig | 63 +++++++++ src/translations/en/shopify.php | 3 + 6 files changed, 226 insertions(+), 161 deletions(-) create mode 100644 src/templates/_webhooks.twig diff --git a/src/console/controllers/ApiController.php b/src/console/controllers/ApiController.php index 91da0cb..f3328c1 100644 --- a/src/console/controllers/ApiController.php +++ b/src/console/controllers/ApiController.php @@ -7,9 +7,11 @@ namespace craft\shopify\console\controllers; +use Craft; use craft\console\Controller; use craft\helpers\Console; use craft\shopify\Plugin; +use Shopify\Exception\ShopifyException; use yii\console\ExitCode; /** @@ -34,21 +36,35 @@ public function actionQuery(string $gql): int // Record how long the API service is: $start = microtime(true); - $this->stdout("Running query... "); - $data = Plugin::getInstance()->getApi()->query($gql); - $this->stdout("done!", Console::FG_GREEN); + $err = null; + + try { + $this->stdout("Running query... "); + + $data = Plugin::getInstance()->getApi()->query($gql); + } catch (ShopifyException $e) { + $err = $e->getMessage(); + } + + $this->stdout("done!", $err ? Console::FG_RED : Console::FG_GREEN); // Report timing: $this->stdout(sprintf(' (%fs)', microtime(true) - $start), Console::FG_GREY); $this->stdout(PHP_EOL); - if (!$data) { - $this->stderr('The response was empty or had an unexpected structure. Check your console logs for more information!' . PHP_EOL); + if ($err) { + $this->failure("There was a problem with the query: " . $err); return ExitCode::UNAVAILABLE; } - $this->stdout('Response:' . PHP_EOL . print_r($data, true) . PHP_EOL); + $print = Craft::dump( + $data, + highlight: false, + return: true, + ); + + $this->stdout('Response:' . PHP_EOL . $print . PHP_EOL); return ExitCode::OK; } diff --git a/src/controllers/WebhooksController.php b/src/controllers/WebhooksController.php index 82e9005..17d6d6d 100644 --- a/src/controllers/WebhooksController.php +++ b/src/controllers/WebhooksController.php @@ -8,14 +8,11 @@ namespace craft\shopify\controllers; use Craft; -use craft\helpers\Html; -use craft\helpers\Json; use craft\shopify\Plugin; -use craft\web\assets\admintable\AdminTableAsset; use craft\web\Controller; -use GraphQL\InlineFragment; use GraphQL\Query; use GraphQL\Variable; +use Shopify\Exception\ShopifyException; use yii\web\ConflictHttpException; use yii\web\Response as YiiResponse; @@ -50,71 +47,34 @@ public function beforeAction($action): bool public function actionEdit(): YiiResponse { $view = $this->getView(); - $view->registerAssetBundle(AdminTableAsset::class); $api = Plugin::getInstance()->getApi(); - if (!$session = $api->getSession()) { - throw new ConflictHttpException('No Shopify API session found, check credentials in settings.'); + try { + $webhooks = $api->getWebhooks(); + } catch (ShopifyException $e) { + throw new ConflictHttpException('There was an issue connecting to the Shopify API. Please check your credentials.'); } - $webhooks = $api->getWebhooks(); - $tableData = []; - - // If we don't have all webhooks needed for the current environment show the create button - $containsAllWebhooks = $webhooks->filter(function($item) use ($api, &$tableData) { - $tableData[] = [ - 'id' => $item['id'], - 'title' => $item['topic'], - 'callbackUrl' => $item['endpoint']['callbackUrl'], - ]; - return in_array($item['topic'], $api::WEBHOOK_TOPICS) && $item['endpoint']['callbackUrl'] == Plugin::getInstance()->getSettings()->getWebhookUrl(); - })->count() === count($api::WEBHOOK_TOPICS); - - $view->registerTranslations('shopify', [ - 'Are you sure you want to delete this webhook?', - 'No webhooks exist yet.', - 'Topic', - 'URL', - 'Webhook could not be deleted', - 'Webhook deleted', - ]); - - $tableData = Json::encode($tableData); - - $view->registerJs(<<asCpScreen() ->title(Craft::t('shopify', 'Webhooks')) ->selectedSubnavItem('webhooks') - ->contentHtml( - Html::tag('p', Craft::t('shopify', 'Webhooks for the current environment.')) . - Html::tag('div', Html::tag('div', '', ['id' => 'webhooks-container']), ['class' => 'field']) - ); - - if (!$containsAllWebhooks) { - $screen->action('shopify/webhooks/create') - ->submitButtonLabel(Craft::t('shopify', 'Create webhooks')); - } + ->contentTemplate('shopify/_webhooks', [ + 'webhooks' => $webhooks, + 'hasAllHooks' => $hasAllHooks, + ]); return $screen; } @@ -124,30 +84,27 @@ public function actionEdit(): YiiResponse * * @return YiiResponse */ - public function actionCreate(): YiiResponse + public function actionCreate(): ?YiiResponse { $this->requirePostRequest(); - - $view = $this->getView(); - $view->registerAssetBundle(AdminTableAsset::class); $api = Plugin::getInstance()->getApi(); - if (!$session = $api->getSession()) { - throw new ConflictHttpException('No Shopify API session found, check credentials in settings.'); + try { + $webhooks = $api->getWebhooks(); + } catch (ShopifyException $e) { + throw new ConflictHttpException('There was an issue connecting to the Shopify API. Please check your credentials.'); } - $webhooks = $api->getWebhooks(); $errors = []; - // If we don't have all the webhooks loop through the topics and create them if they don't exist + // Check each required topic and create missing subscriptions: foreach ($api::WEBHOOK_TOPICS as $topic) { - // If the webhook already exists skip - if ($webhooks->filter(function($item) use ($topic) { - return $item['topic'] === $topic && $item['endpoint']['callbackUrl'] == Plugin::getInstance()->getSettings()->getWebhookUrl(); - })->count() > 0) { + // Is there at least one webhook with this topic? + if ($webhooks->contains('topic', $topic)) { continue; } + // Ok, we need to create a subscription with the API: $query = (new \GraphQL\Mutation('webhookSubscriptionCreate')) ->setOperationName('webhookSubscriptionCreate') ->setVariables([ @@ -164,14 +121,7 @@ public function actionCreate(): YiiResponse 'id', 'topic', 'format', - (new Query('endpoint')) - ->setSelectionSet([ - '__typename', - (new InlineFragment('WebhookHttpEndpoint')) - ->setSelectionSet([ - 'callbackUrl', - ]), - ]), + 'uri', ]), (new Query('userErrors')) ->setSelectionSet([ @@ -184,38 +134,24 @@ public function actionCreate(): YiiResponse 'topic' => $topic, 'webhookSubscription' => [ 'format' => 'JSON', - 'callbackUrl' => Plugin::getInstance()->getSettings()->getWebhookUrl(), + 'uri' => Plugin::getInstance()->getSettings()->getWebhookUrl(), ], ]; try { - $response = $api->getGqlClient()->query(['query' => $query->__toString(), 'variables' => $variables]); - $body = $response->getDecodedBody(); - - if (array_key_exists('errors', $body)) { - $message = $body['errors']; - - // Some low-level errors (like an unavailable shop) are reported as a single string. - // Others need to be unpacked from an array: - if (is_array($message)) { - $message = $message[0]['message']; - } - - throw new \Exception($message); - } - } catch (\Exception $e) { + // Fire it off; if anything goes wrong, we’ll just catch + log it. + $api->query($query, $variables); + } catch (ShopifyException $e) { Craft::error('Could not register webhooks with Shopify API: ' . $e->getMessage(), __METHOD__); $errors[] = $e->getMessage(); } } if (!empty($errors)) { - $this->setFailFlash(Craft::t('shopify', 'Webhooks could not be registered.')); - } else { - $this->setSuccessFlash(Craft::t('shopify', 'Webhooks registered.')); + return $this->asFailure(Craft::t('shopify', 'Webhooks could not be registered.')); } - return $this->redirectToPostedUrl(); + return $this->asSuccess(Craft::t('shopify', 'Webhooks registered.')); } /** @@ -225,14 +161,15 @@ public function actionCreate(): YiiResponse */ public function actionDelete(): YiiResponse { - $this->requireAcceptsJson(); - $id = Craft::$app->getRequest()->getBodyParam('id'); + $this->requirePostRequest(); + $id = Craft::$app->getRequest()->getRequiredBodyParam('id'); - $error = null; - if (Plugin::getInstance()->getApi()->deleteWebhookById($id, $error)) { - return $this->asSuccess(Craft::t('shopify', 'Webhook deleted')); + try { + Plugin::getInstance()->getApi()->deleteWebhookById($id); + } catch (ShopifyException $e) { + return $this->asFailure(Craft::t('shopify', 'Webhook could not be deleted')); } - return $this->asFailure($error); + return $this->asSuccess(Craft::t('shopify', 'Webhook deleted')); } } diff --git a/src/services/Api.php b/src/services/Api.php index b1c7a5a..82e7414 100644 --- a/src/services/Api.php +++ b/src/services/Api.php @@ -30,9 +30,10 @@ use Shopify\Clients\Graphql; use Shopify\Clients\Http; use Shopify\Clients\HttpClientFactory; -use Shopify\Clients\Rest; use Shopify\Context; use Shopify\Exception\MissingArgumentException; +use Shopify\Exception\SessionNotFoundException; +use Shopify\Exception\ShopifyException; use Shopify\Exception\UninitializedContextException; use Shopify\Webhooks\Topics; use yii\base\InvalidConfigException; @@ -383,39 +384,78 @@ public function createQuery(string $name, array $fields, callable $beforeFields } /** - * Run a Shopify GraphQL query. + * Run a GraphQL query against the Shopify API. + * + * If you need to control how a response is unpacked, use {@see getGqlClient()} directly. + * + * Under normal circumstances, the selected fields (including `userErrors`, when requested) are returned as an array. + * A `false` return value indicates a low-level communication failure. + * + * All other issues should trigger a {@see ShopifyException}. * * @param Query|string $query * @param array|null $variables - * @return mixed + * @return mixed Typically an array with the same structure as the selection, or `null` for nonexistent nodes. + * @throws ShopifyException when the response looks unusual (i.e. an `errors` key is present, or a `data` key was not returned) + * @throws SessionNotFoundException if a session can’t be established * @since 6.0.0 */ public function query(Query|string $query, ?array $variables = null): mixed { - $data = ['query' => (string)$query]; + // An invalid session will cause everything to fail: + if ($this->getSession() === null) { + throw new SessionNotFoundException(Craft::t('shopify', 'No Shopify session available. Please check your credentials and re-authorize the application, if necessary.')); + } + + $payload = ['query' => (string)$query]; + if ($variables) { - $data['variables'] = $variables; + $payload['variables'] = $variables; } try { - $response = $this->getGqlClient()->query($data); + $response = $this->getGqlClient()->query($payload); $body = $response->getDecodedBody(); + if (array_key_exists('errors', $body)) { + $message = $body['errors']; + + // https://shopify.dev/docs/api/admin-graphql/2025-10#status-and-error-codes + // Some low-level errors (like an unavailable shop) are reported as a single string. + // Others need to be unpacked from an array: + if (is_array($message)) { + $message = $message[0]['message']; + // (Shopify also suggests that 400 errors may have a key like `query`, but we haven’t observed this!) + } + + throw new ShopifyException($message); + } + + // GraphQL responses are always nested inside a `data` key: if (!isset($body['data'])) { - throw new \Exception('No data returned from GraphQL query.'); + throw new ShopifyException('No data was returned from the GraphQL query.'); } $data = $body['data']; + + // Queries and mutations have implicit “names” based on the procedure, which is where our data will be in the response. + // The name itself doesn’t matter (we are only sending one query or mutation at a time), so we can just unwrap the “first” item: $data = ArrayHelper::firstValue($data); + + // The query may have selected `userErrors`, so we should check and throw: if (!empty($data['userErrors'])) { - throw new \Exception($data['userErrors'][0]['message']); + Craft::error('A GraphQL response included `userErrors`: ' . join(', ', array_column($data['userErrors'], 'message')), __METHOD__); + throw new ShopifyException($data['userErrors'][0]['message']); } return $data; - } catch (\Exception $e) { + } catch (ClientExceptionInterface $e) { + // We only intercept communication-related exceptions, here. + // Everything else (like a query or mutation issue) is allowed to bubble out so it can be reported to the user. Craft::error('Could not run GraphQL query: ' . $e->getMessage(), __METHOD__); - return false; + // Re-throw as an API error: + throw new ShopifyException('An issue occurred while communicating with the Shopify API. Check the logs for more information.'); } } @@ -449,8 +489,9 @@ public function getShopifyDataByType(string $type, array|string|null|false $pare } /** - * Returns or sets up a Rest API client. + * Returns or sets up a GraphQL API client. * + * @see query() * @return Graphql * @throws MissingArgumentException * @since 6.0.0 @@ -523,7 +564,8 @@ public function initializeContext(): void apiSecretKey: $pluginSettings->getClientSecret(), scopes: ['write_products', 'read_products', 'read_inventory'], // This `hostName` is different from the `shop` value used when creating a Session! - // Shopify wants a name for the host/environment that is initiating the connection. + // Shopify wants a name for the host/environment that is *initiating* the API connection. + // Internally, they appear to use this for starting OAuth flows and creating webhooks (but we handle the latter, manually). hostName: !Craft::$app->request->isConsoleRequest ? Craft::$app->getRequest()->getHostName() : 'localhost', sessionStorage: new FileSessionStorage(Craft::$app->getPath()->getStoragePath() . DIRECTORY_SEPARATOR . 'shopify_api_sessions'), apiVersion: $pluginSettings->getApiVersion(), @@ -611,14 +653,11 @@ public function getWebhooks(): Collection 'nodes' => [ 'id', 'topic', - 'endpoint' => [ - '... on WebhookHttpEndpoint' => [ - 'callbackUrl', - ], - ], + 'uri', ], ], function(QueryBuilder $builder) { $builder->setArgument('first', 100); + $builder->setArgument('uri', Plugin::getInstance()->getSettings()->getWebhookUrl()); }); $response = $this->query($query); @@ -635,15 +674,11 @@ public function getWebhooks(): Collection * @param string|null $error * @return bool * @throws MissingArgumentException + * @throws ShopifyException * @since 6.0.0 */ - public function deleteWebhookById(string $id, ?string &$error = null): bool + public function deleteWebhookById(string $id): bool { - if ($this->getSession() === null) { - $error = Craft::t('shopify', 'No Shopify session available.'); - return false; - } - $mutation = (new Mutation('webhookSubscriptionDelete')) ->setOperationName('webhookSubscriptionDelete') ->setVariables([ @@ -661,20 +696,15 @@ public function deleteWebhookById(string $id, ?string &$error = null): bool 'deletedWebhookSubscriptionId', ]); - try { - Plugin::getInstance()->getApi()->getGqlClient()->query([ - 'query' => (string)$mutation, - 'variables' => [ - 'id' => $id, - ], - ]); - - return true; - } catch (\Exception $e) { - Craft::error('Could not delete webhook with Shopify API: ' . $e->getMessage(), __METHOD__); + $variables = [ + 'id' => $id, + ]; - $error = Craft::t('shopify', 'Webhook could not be deleted'); - return false; + if (!$this->query($mutation, $variables)) { + Craft::error(sprintf('No data was returned while deleting webhook %s', $id), __METHOD__); + throw new ShopifyException('The webhook may not have been deleted.'); } + + return true; } } diff --git a/src/services/BulkOperations.php b/src/services/BulkOperations.php index b5fe378..005d9b2 100644 --- a/src/services/BulkOperations.php +++ b/src/services/BulkOperations.php @@ -23,6 +23,7 @@ use GraphQL\Mutation; use GraphQL\Variable; use Illuminate\Support\Collection; +use Shopify\Exception\ShopifyException; use yii\base\InvalidConfigException; use yii\db\Exception; use yii\db\StaleObjectException; @@ -120,25 +121,38 @@ public function nextBulkOperation(): bool ->one(); if (!$result) { - // There isn't any queued bulk operations + // We don’t have any queued bulk operations, locally return true; } /** @var BulkOperation $bulkOperation */ $bulkOperation = Craft::createObject(array_merge($result, ['class' => BulkOperation::class])); - // Before trying to create a new bulk op in shopify, we should call their API to see if there is one running + // Before trying to create a new bulk op in Shopify, we should check if there is one running // This will ensure we don't cause any issue with other processes + // @todo This procedure is deprecated in 2025-10, and Shopify suggests moving to the generic bulkOperations() query. + // @see https://shopify.dev/docs/api/admin-graphql/latest/queries/bulkOperations $bulkOpsStatusQuery = (new \GraphQL\Query('currentBulkOperation')) + ->setOperationName('currentBulkOperation') + ->setVariables([new Variable('bulkOpType', 'BulkOperationType')]) + ->setArguments([ + 'type' => '$bulkOpType', + ]) ->setSelectionSet([ 'id', 'type', 'status', ]); - $bulkOpStatusResponse = Plugin::getInstance()->getApi()->query($bulkOpsStatusQuery); - if ($bulkOpStatusResponse === false || ($bulkOpStatusResponse !== null && in_array($bulkOpStatusResponse['status'], ['RUNNING', 'CREATED']))) { + try { + $bulkOpStatusResponse = Plugin::getInstance()->getApi()->query($bulkOpsStatusQuery, ['bulkOpType' => 'QUERY']); + } catch (ShopifyException $e) { + return false; + } + + // If there is a bulk operation in progress, we should bail before trying to start another: + if ($bulkOpStatusResponse && in_array($bulkOpStatusResponse['status'], ['RUNNING', 'CREATED'])) { return false; } @@ -161,26 +175,28 @@ public function nextBulkOperation(): bool ]), ]); - $response = Plugin::getInstance()->getApi()->query($mutation, ['query' => $bulkOperation->query]); + try { + $data = Plugin::getInstance()->getApi()->query($mutation, ['query' => $bulkOperation->query]); + } catch (ShopifyException $e) { + // If there was an issue creating the operation that we haven’t accounted for, just mark it as completed: + Craft::error('Could not start bulk operation: ' . $e->getMessage(), __METHOD__); - if (!$response) { $bulkOperation->setStatus(BulkOperationStatus::Completed); $this->saveBulkOperation($bulkOperation, false); - return false; - } - if (isset($response['bulkOperation']['userErrors']) && !empty($response['bulkOperation']['userErrors'])) { - Craft::error('Could not start bulk operation: ' . $response['bulkOperation']['userErrors'][0]['message'], __METHOD__); return false; } - if ($response['bulkOperation']['status'] === 'CREATED') { + $op = $data['bulkOperation']; + + if ($op['status'] === 'CREATED') { $bulkOperation->setStatus(BulkOperationStatus::Created); } - $bulkOperation->shopifyStatus = $response['bulkOperation']['status']; - $bulkOperation->shopifyId = $response['bulkOperation']['id']; + $bulkOperation->shopifyStatus = $op['status']; + $bulkOperation->shopifyId = $op['id']; $this->saveBulkOperation($bulkOperation); + return true; } diff --git a/src/templates/_webhooks.twig b/src/templates/_webhooks.twig new file mode 100644 index 0000000..c404f1a --- /dev/null +++ b/src/templates/_webhooks.twig @@ -0,0 +1,63 @@ +{# Report partial webhook configuration: #} +{% if not webhooks is empty and not hasAllHooks %} +
+

{{ 'This environment is not subscribed to all the required webhook topics.'|t('shopify') }}

+
+ {{ csrfInput() }} + {{ actionInput('shopify/webhooks/create') }} + + +
+
+{% endif %} + +{# Confirm correct configuration: #} +{% if hasAllHooks %} +
+

{{ 'This environment is subscribed to all the required webhook topics!'|t('shopify') }}

+
+{% endif %} + +{# Display existing webhooks: #} +{% if webhooks is not empty %} + + + + + + + + + + {% for webhook in webhooks %} + + + + + + {% endfor %} + +
{{ 'Topic'|t('shopify') }}{{ 'URI'|t('app') }}{{ 'Actions'|t('app') }}
{{ webhook.topic }}{{ webhook.uri }} +
+ {{ csrfInput() }} + {{ actionInput('shopify/webhooks/delete') }} + {{ hiddenInput('id', webhook.id) }} + +
+
+{% else %} + {# Initial/onboarding state (no webhooks present): #} +
+

{{ 'No webhooks exist for this environment.'|t('shopify') }}

+
+
+ {{ csrfInput() }} + {{ actionInput('shopify/webhooks/create') }} + + +
+{% endif %} \ No newline at end of file diff --git a/src/translations/en/shopify.php b/src/translations/en/shopify.php index f8c1a27..b6728a0 100644 --- a/src/translations/en/shopify.php +++ b/src/translations/en/shopify.php @@ -20,6 +20,7 @@ 'Archived in Shopify' => 'Archived in Shopify', 'Are you sure you want to run a complete sync of all products?' => 'Are you sure you want to run a complete sync of all products?', 'Are you sure you want to delete this sync?' => 'Are you sure you want to delete this sync?', + 'Are you sure you want to delete this webhook?' => 'Are you sure you want to delete this webhook?', 'Body HTML' => 'Body HTML', 'Channel' => 'Channel', 'Completed' => 'Completed', @@ -27,6 +28,7 @@ 'Create' => 'Create', 'Created' => 'Created', 'Created At' => 'Created At', + 'Delete {topic} webhook?' => 'Delete {topic} webhook?', 'Description HTML' => 'Description HTML', 'Draft in Shopify' => 'Draft in Shopify', 'Edit variant {title} on Shopify' => 'Edit variant {title} on Shopify', @@ -42,6 +44,7 @@ 'Meta Fields' => 'Meta Fields', 'New Product' => 'New Product', 'No Shopify session available.' => 'No Shopify session available.', + 'No webhooks exist for this environment' => 'No webhooks exist for this environment', 'Objects' => 'Objects', 'Option' => 'Option', 'Options' => 'Options', From bc18e894cb35b4ddcb7da268a64a32e308af03d4 Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 18 Feb 2026 23:35:21 -0800 Subject: [PATCH 33/98] Simplify fetching of inventory items and products in response to webhooks This was made easier with the introduction of GIDs (`admin_graphql_api_id`) in most/all webhooks. --- src/handlers/Webhook.php | 2 +- src/services/Products.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/handlers/Webhook.php b/src/handlers/Webhook.php index 90f0eb6..1b31c92 100644 --- a/src/handlers/Webhook.php +++ b/src/handlers/Webhook.php @@ -30,7 +30,7 @@ public function handle(string $topic, string $shop, array $body): void Plugin::getInstance()->getProducts()->deleteProductByShopifyId($body['id']); break; case Topics::INVENTORY_ITEMS_UPDATE: - Plugin::getInstance()->getProducts()->syncProductByInventoryItemId($body['inventory_item_id']); + Plugin::getInstance()->getProducts()->syncProductByInventoryItemId($body['admin_graphql_api_id']); break; case Topics::BULK_OPERATIONS_FINISH: Plugin::getInstance()->getBulkOperations()->handleBulkOperationFinished($body); diff --git a/src/services/Products.php b/src/services/Products.php index da5a5bb..c4a6543 100644 --- a/src/services/Products.php +++ b/src/services/Products.php @@ -104,13 +104,13 @@ public function syncProductByInventoryItemId($id): void $builder->setArgument('id', $id); }); - $response = Plugin::getInstance()->getApi()->query($query); + $item = Plugin::getInstance()->getApi()->query($query); - if (empty($response) || empty($response['data']['inventoryItem']['variant']['product']['id'])) { + if (!$item) { return; } - $productId = $response['data']['inventoryItem']['variant']['product']['id']; + $productId = $item['variant']['product']['id']; $this->syncProductByShopifyId($productId); } From 06e4d93d5694d0de6c0200cd1952206d74f8656b Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 18 Feb 2026 23:38:23 -0800 Subject: [PATCH 34/98] =?UTF-8?q?Readme=20overhaul=20for=20OAuth=20app=20c?= =?UTF-8?q?reation=20and=20connection=20Howdy,=20stranger!=20If=20you're?= =?UTF-8?q?=20reading=20this=20and=20would=20like=20to=20start=20a=20suppo?= =?UTF-8?q?rt=20group=20for=20Shopify=20developers,=20let=20me=20know?= =?UTF-8?q?=E2=80=A6=20they=20have=20it=20pretty=20rough.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 130 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 87 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 0be460e..5351f76 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Build a content-driven storefront by synchronizing [Shopify](https://shopify.com) products into [Craft CMS](https://craftcms.com/). -> [!IMPORTANT] +> [!IMPORTANT] > Version 7.x of the Shopify plugin uses new app-based authorization. > Existing integrations and credentials should continue to work with no changes, but the process for creating _new_ credentials has changed significantly in Shopify. @@ -40,26 +40,32 @@ To install the plugin, visit the [Plugin Store](https://plugins.craftcms.com/sho php craft plugin/install shopify ``` -### Connect to Shopify - -The plugin works with Shopify’s [Dev Dashboard](https://shopify.dev/docs/apps/build/dev-dashboard) app system, and is split into three parts: [creating an app](#create-an-app), [installing it into a store](#install-in-a-store), and finally [connecting it to Craft](#connect-to-craft). +## Connect to Shopify -> [!CAUTION] -> The following process may differ significantly if you are part of a [Partner](https://www.shopify.com/partners) organization or are a collaborator on multiple stores. -> Shopify implicitly creates an “organization” for each store, and that organization gets a dedicated Dev Dashboard. -> **You must access the Dev Dashboard via the store you want to create an app for, _not_ via a link in the documentation or by directly navigating to its URL!** +The plugin works with Shopify’s [Dev Dashboard](https://shopify.dev/docs/apps/build/dev-dashboard) app system, and is split into two primary parts: [creating an app](#create-an-app) and [performing authorization](#). -To perform these steps, you must either be the owner of a store, or a collaborator with the [App developer role](https://shopify.dev/docs/apps/build/dev-dashboard/user-permissions). +To install an app into a store, one of these statements must describe your account’s relationship with it: +- You are the owner of the store; +- You have been added as a collaborator on the store, with the [App developer role](https://shopify.dev/docs/apps/build/dev-dashboard/user-permissions) (see screenshot, below); +- You are working with a [dev store](https://shopify.dev/docs/apps/build/dev-dashboard/development-stores) or [client transfer store](https://help.shopify.com/en/partners/manage-clients-stores/client-transfer-stores/create-client-transfer-stores) belonging to your Partner organization; ![Adding a collaborator via the Shopify admin](docs/shopify-add-collaborator.png) -#### Create an App +> [!CAUTION] +> The new OAuth-based API connection requires that apps are created from an “organization” that has access to the [Partner Dashboard](https://www.shopify.com/partners). +> Standalone stores (like the one created when you sign up for a Shopify account) belong to their own organization. +> If you are working with a store or account that has never accessed a Partner Dashboard, you may need to create a Partner profile before proceeding. +> When working from an account that has access to multiple organizations, **it is generally safest to access the new Dev Dashboard via the Partner Dashboard you want the app associated with.** + +### Create an App -1. From a store, open **Settings** → **Apps**, press **Develop apps** in the toolbar, then follow the **Build apps in Dev Dashboard** link. +1. Navigate to your **Dev Dashboard**: + - From a store, open the account context menu (upper-right corner) and select **Dev Dashboard**; + - From the Partner Dashboard, open the account context menu (upper-right corner) and select **Dev Dashboard**; 1. In the Dev Dashboard, press **Create app**. 1. In the first screen, pick an **App name** that identifies the integration, like _Craft CMS_. 1. Press **Create**, then fill out the following fields to create your first “version”: - - **App URL**: (Optional) Replace `example.com` with `https://shopify.dev/apps/default-app-home`. This is just a friendlier (but still somewhat confusing) page hosted by Shopify, displayed when your “app” is accessed in the Shopify store’s admin. (If you turn off **Embed app in Shopify admin**, you are redirected to this URL after [installing](#install-in-a-store) the app. Neither is particularly useful; this is a symptom of Shopify’s lack of an API-only integration path.) + - **App URL**: Retrieve the **Shopify App Auth URL** value from the plugin’s setting screen in the Craft control panel. (This will always be your project’s URL, followed by the [cpTrigger](https://craftcms.com/docs/5.x/reference/config/general.html#cptrigger), then the action `shopify/auth`: `https://my-project.com/admin/shopify/auth`.) - **Webhooks API Version**: Choose `2025-10`, and add the same string to your project’s `.env` file: ```bash SHOPIFY_WEBHOOK_VERSION="2025-10" @@ -78,68 +84,106 @@ To perform these steps, you must either be the owner of a store, or a collaborat SHOPIFY_CLIENT_SECRET="..." # Secret ``` -#### Install in a Store - -> [!WARNING] -> If you are not the owner of the Shopify store, have the owner add you as a [collaborator](https://help.shopify.com/en/manual/your-account/users/security/collaborator-accounts). - -From your new app’s **Home** screen, press **Install**. A new window will open with the store selector; pick the store you started from. _Your app can only be installed in the store associated with the Dev Dashboard it was created in._ +Next, you’ll configure the app’s _distribution_ scheme. -> [!TIP] -> You may see a warning like “This app hasn't been reviewed.” -> This is to be expected; your app is not publicly available, and doesn’t need to go through the normal Marketplace approval process. -> -> If you see “This app can't be installed on this store,” it was probably created from the wrong Dev Dashboard, and you’ll need to [start over](#create-an-app). +1. From the new app’s **Home** screen in the Dev Dashboard, follow the **Select distribution method** link, within the **Distribution** widget. +1. The Partner Dashboard will open, with your app selected. Choose **Custom distribution**, press **Select**, then confirm in the dialog box. +1. Locate your store’s _hostname_ (see screenshot, below), and paste it into the **Store domain** field, then press **Generate link**. + - _Once you choose a hostname, the app is permanently locked to that store. If you do not provide the correct hostname at this stage, you’ll need to delete the app and start over._ + - If you want to use the same connection across multiple related stores, check **Allow multi-store install for one Plus organization**. + - Take this opportunity to add the hostname to your `.env` file: + ```bash + SHOPIFY_HOSTNAME="my-store-name.myshopify.com" + ``` +1. Switch to the **API access requests** screen and press **Enable Storefront API**. +1. Return to the **Distribution** screen and press **Copy link**. -Press **Install** on this screen. -When Shopify redirects to the generic embedded app view, you’re done! +![Identifying your store’s hostname, used when creating a distribution](docs/shopify-hostname.png) -### Connect Plugin +You should now have a total of _four_ `SHOPIFY_*` variables in your `.env` file: -We still need one piece of information from your store—the hostname, or “domain.” -Visit the **Settings** screen in the Shopify admin, and copy this value from the sidebar: +```bash +# 1. Webhook API Version +# This is tied to your app’s release, and should not change (except potentially during a future plugin upgrade). +SHOPIFY_WEBHOOK_VERSION="2025-10" -![Adding a collaborator via the Shopify admin](docs/shopify-hostname.png) +# 2. Client ID +# This can be found in your Shopify app’s Settings screen. +SHOPIFY_CLIENT_ID="..." -Add this to your `.env`, without any `https://` prefix: +# 3. Secret +# This can be found in your Shopify app’s Settings screen. +SHOPIFY_CLIENT_SECRET="..." -```bash -SHOPIFY_HOSTNAME="rpfxyv-fk.myshopify.com" +# 4. Hostname +# Found in your store’s settings screen. Include only the domain (no leading `https://`) +SHOPIFY_HOSTNAME="my-store-name.myshopify.com" ``` -You should now have a total of _four_ `SHOPIFY_*` variables in your `.env` file. -In your Craft project’s control panel, navigate to **Shopify** → **Settings** to configure the plugin: +In the Craft control panel, navigate to **Shopify** → **Settings** to configure the plugin: - **API Version**: `$SHOPIFY_WEBHOOKS_VERSION` - **Client ID**: `$SHOPIFY_CLIENT_ID` - **Client Secret Key**: `$SHOPIFY_CLIENT_SECRET` - **Host Name**: `$SHOPIFY_HOSTNAME` -Save the settings to test the connection; an exception will be thrown if there are issues. +Use these literal strings in the corresponding fields. +As you type the `$`-prefixed value into an input, Craft will [suggest](https://craftcms.com/docs/5.x/system/project-config.html#secrets-and-the-environment) matching variables. -> [!TIP] -> The temporary session token from Shopify is cached for its expected duration. -> This _can_ obscure connection issues after changing credentials, so it’s always a good idea to run `craft clear-caches/all`, beforehand. +Press **Save** to commit the settings to [project config](https://craftcms.com/docs/5.x/system/project-config.html). + +> [!TIP] +> You may see a warning below the read-only **Shopify App Auth URL** field. +> This is expected, until you’ve completed the OAuth flow! + +### Install in a Store + +In this step, we’ll perform the [authorization code grant](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/authorization-code-grant) or _OAuth_ flow, during which Craft and Shopify negotiate a long-lived access token. + +> [!TIP] +> Whoever installs the app must be able to access to the store _and_ the Craft project from the same browser. +> Shopify does _not_ need to directly contact the Craft, so you may do this from your local development machine! + +1. Visit the installation URL you copied from the **Distribution** screen in the Partner Dashboard. You must be logged in to a Shopify account with access to the target store (but it does not need to be the same account that created the app). +1. Select the store in Shopify’s context picker. +1. On the **Install app** screen within the store’s admin, review the permissions and press **Install**. + > [!WARNING] + > If you do not see a blue banner confirming **This app is exclusive to your store**, _do not proceed_! + > A banner saying **This app can’t be installed on this store** (or landing on a generic Shopify error page) usually means that the hostname is not valid for the distribution. +1. You will be redirected to the Craft control panel “auth” URL you used when creating the Shopify app. (If you were not already logged in, Craft will ask for your username and password; your user must have the **Access Shopify** permission or be an administrator to complete the authorization flow.) +1. Press **Authorize** in the dialog. +1. Craft and Shopify will perform the OAuth handshake, and you should land on a confirmation screen in the Craft control panel saying **Your Shopify app has been successfully authorized**. + +🎊 Congratulations! Your Craft project can now communicate with the Shopify API. Let’s take it for a spin by importing your store’s products. ### Set up Webhooks -Once your credentials have been added to Craft, a new **Webhooks** tab will appear in the **Shopify** section of the control panel. +A new **Webhooks** tab will appear in the **Shopify** section of the control panel once you’ve completed the authorization flow. -Click **Create webhooks** on the Webhooks screen to add the required webhooks to Shopify. The plugin will use the credentials you just configured to perform this operation—so this also serves as an initial communication test. +Click **Create webhooks** on the Webhooks screen to add the required webhooks to Shopify. +The plugin will use your newly-issued access token to perform this operation, so this also serves as an initial communication test. > [!WARNING] > You must add webhooks for every environment you deploy the plugin to; webhooks are tied to the specific, registered URL. -> Be aware that Shopify will continue to attempt delivery to your development webhooks, which may impact the statistics you see in the Dev Dashboard. +> Be aware that Shopify will continue to attempt delivery to your development environment’s subscriptions, which may impact the statistics you see in the Dev Dashboard. > [!NOTE] > If you need to test synchronization in development, we recommend using [ngrok](https://ngrok.com/) to create a tunnel to your local environment. > DDEV makes this simple, with [the `ddev share` command](https://ddev.readthedocs.io/en/latest/users/topics/sharing/). -> Use the `SHOPIFY_WEBHOOKS_BASE_URL` environment variable to override the base URL used when generating webhook URLs; this allows you to continue using your regular DDEV site URL for control panel and front-end access, rather than overriding the entire project or site’s base URL. +> Use the `SHOPIFY_WEBHOOKS_BASE_URL` environment variable to override your project’s base URL when creating webhooks; this allows you to continue using your regular DDEV site URL for control panel and front-end access, rather than overriding the entire project or site’s base URL. > This setting may not work if you have set a custom `cpBaseUrl`! +#### Cleanup + +Each time you open an `ngrok` tunnel, you get a new public URL. +This means that you may accumulate broken webhook subscriptions over the course of development. +In the control panel, we only display the webhooks relevant to the _current_ environment, or more accurately, when the webhook’s `uri` matches the resolved webhook URL (which can be influenced by the `SHOPIFY_WEBHOOKS_BASE_URL` variable). + + + ## Upgrading -This release (7.x) is primarily concerned with Shopify API compatability. +This release (7.x) is primarily concerned with Shopify API compatability, but the [new authentication mechanism](#connect-to-shopify) means that you’ll need to re-establish the > [!TIP] > We strongly recommend reviewing this same section on the [6.x](https://github.com/craftcms/shopify/blob/6.x/README.md#upgrading) branch, as there were a number of breaking changes and deprecations during the upgrade from 5.x. From a78cb7790db341d806a745e3c4a0a23d9a058943 Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 18 Feb 2026 23:39:15 -0800 Subject: [PATCH 35/98] Don't try title-casing a null string. :O --- src/utilities/Sync.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utilities/Sync.php b/src/utilities/Sync.php index 21cd76f..cf2996b 100644 --- a/src/utilities/Sync.php +++ b/src/utilities/Sync.php @@ -78,7 +78,7 @@ public static function contentHtml(): string return [ 'id' => $bo->id, 'status' => $bo->statusLabelHtml(), - 'shopifyStatus' => StringHelper::toTitleCase($bo->shopifyStatus), + 'shopifyStatus' => $bo->shopifyStatus ? StringHelper::toTitleCase($bo->shopifyStatus) : null, 'objects' => $bo->objectCount ? $formatter->asInteger($bo->objectCount) : '', 'dateCreated' => $formatter->asDatetime($bo->dateCreated), 'dateUpdated' => $formatter->asDatetime($bo->dateUpdated), From 30621584a1b04ca744007e1fb9fbf578267800f5 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 19 Feb 2026 10:17:42 +0000 Subject: [PATCH 36/98] PHPstan fixes --- src/console/controllers/ApiController.php | 1 + src/services/Api.php | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/console/controllers/ApiController.php b/src/console/controllers/ApiController.php index f3328c1..e42adb8 100644 --- a/src/console/controllers/ApiController.php +++ b/src/console/controllers/ApiController.php @@ -37,6 +37,7 @@ public function actionQuery(string $gql): int $start = microtime(true); $err = null; + $data = null; try { $this->stdout("Running query... "); diff --git a/src/services/Api.php b/src/services/Api.php index 82e7414..ecb5a09 100644 --- a/src/services/Api.php +++ b/src/services/Api.php @@ -671,7 +671,6 @@ public function getWebhooks(): Collection /** * @param string $id - * @param string|null $error * @return bool * @throws MissingArgumentException * @throws ShopifyException From aabc0f1261acf4580f42fde1fc470587262c6641 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 19 Feb 2026 15:14:49 +0000 Subject: [PATCH 37/98] Move templating back into controller to be similar to other Craft and plugin actions --- src/controllers/WebhooksController.php | 104 +++++++++++++++++++++++-- src/templates/_webhooks.twig | 63 --------------- src/translations/en/shopify.php | 1 + 3 files changed, 98 insertions(+), 70 deletions(-) delete mode 100644 src/templates/_webhooks.twig diff --git a/src/controllers/WebhooksController.php b/src/controllers/WebhooksController.php index 17d6d6d..4be2c14 100644 --- a/src/controllers/WebhooksController.php +++ b/src/controllers/WebhooksController.php @@ -8,6 +8,7 @@ namespace craft\shopify\controllers; use Craft; +use craft\helpers\Html; use craft\shopify\Plugin; use craft\web\Controller; use GraphQL\Query; @@ -68,15 +69,104 @@ public function actionEdit(): YiiResponse // (We use this later to decide whether the "Create webhooks" button should be shown) $hasAllHooks = count($requiredTopics) === 0; - $screen = $this->asCpScreen() + $html = ''; + + if ($webhooks->isNotEmpty() && !$hasAllHooks) { + $html .= Html::beginTag('div', ['class' => 'pane warning']) . + Html::tag('p', Craft::t('shopify', 'This environment is not subscribed to all the required webhook topics.')) . + Html::beginForm() . + Html::actionInput('shopify/webhooks/create') . + Html::submitButton(Craft::t('shopify', 'Create missing webhooks'), [ + 'class' => ['btn', 'submit'], + ]) . + Html::endForm() . + Html::endTag('div'); + } + + if ($hasAllHooks) { + $html .= Html::beginTag('div', ['class' => 'pane']) . + Html::beginTag('p') . + Html::tag('span', '', ['class' => 'checkmark-icon']) . ' ' . + Craft::t('shopify', 'This environment is subscribed to all the required webhook topics!') . + Html::endTag('p') . + Html::endTag('div'); + } + + if ($webhooks->isEmpty()) { + $html .= Html::beginTag('div', ['class' => 'zilch']) . + Html::tag('p', Craft::t('shopify', 'No webhooks exist for this environment.')) . + Html::endTag('div') . + Html::beginForm() . + Html::actionInput('shopify/webhooks/create') . + Html::submitButton(Craft::t('shopify', 'Create all webhooks'), [ + 'class' => ['btn', 'submit'], + ]) . + Html::endForm(); + } else { + $html .= Html::beginTag('table', ['class' => 'data fullwidth']) . + Html::beginTag('thead') . + Html::beginTag('tr') . + Html::tag('th', Craft::t('shopify', 'Topic')) . + Html::tag('th', Craft::t('app', 'URI')) . + Html::tag('th', '') . + Html::endTag('tr') . + Html::endTag('thead') . + Html::beginTag('tbody'); + + $webhooks->each(function($hook) use (&$html) { + $html .= Html::beginTag('tr') . + Html::tag('td', $hook['topic']) . + Html::tag('td', $hook['uri']) . + Html::beginTag('td', ['class' => 'rightalign']) . + Html::beginForm() . + Html::actionInput('shopify/webhooks/delete') . + Html::hiddenInput('id', $hook['id']) . + Html::tag('a', '', [ + 'class' => 'delete icon', + 'href' => '#', + 'title' => Craft::t('shopify', 'Delete {topic} webhook', ['topic' => $hook['topic']]), 'role' => 'button', + 'data-confirm' => Craft::t('shopify', 'Are you sure you want to delete the {topic} webhook?', ['topic' => $hook['topic']]), + 'data-error' => Craft::t('shopify', 'There was a problem deleting the {topic} webhook', ['topic' => $hook['topic']]), + ]) . + Html::endForm() . + Html::endTag('td') . + Html::endTag('tr'); + }); + + $html .= Html::endTag('tbody') . + Html::endTag('table'); + + $js = << { + const table = document.querySelector('table.data'); + const deleteButtons = table.querySelectorAll('.delete'); + if (!table || deleteButtons.length == 0) return; + + deleteButtons.forEach(button => { + button.addEventListener('click', async (e) => { + e.preventDefault(); + + if (!confirm(button.dataset.confirm)) { + return; + } + + try { + const deleteForm = button.closest('form'); + deleteForm.submit(); + } catch (error) { + Craft.cp.displayError(button.dataset.error) + } + }); + }); + })(); + JS; + $this->getView()->registerJs($js); + } + + return $this->asCpScreen() ->title(Craft::t('shopify', 'Webhooks')) ->selectedSubnavItem('webhooks') - ->contentTemplate('shopify/_webhooks', [ - 'webhooks' => $webhooks, - 'hasAllHooks' => $hasAllHooks, - ]); - - return $screen; + ->contentHtml($html); } /** diff --git a/src/templates/_webhooks.twig b/src/templates/_webhooks.twig deleted file mode 100644 index c404f1a..0000000 --- a/src/templates/_webhooks.twig +++ /dev/null @@ -1,63 +0,0 @@ -{# Report partial webhook configuration: #} -{% if not webhooks is empty and not hasAllHooks %} -
-

{{ 'This environment is not subscribed to all the required webhook topics.'|t('shopify') }}

-
- {{ csrfInput() }} - {{ actionInput('shopify/webhooks/create') }} - - -
-
-{% endif %} - -{# Confirm correct configuration: #} -{% if hasAllHooks %} -
-

{{ 'This environment is subscribed to all the required webhook topics!'|t('shopify') }}

-
-{% endif %} - -{# Display existing webhooks: #} -{% if webhooks is not empty %} - - - - - - - - - - {% for webhook in webhooks %} - - - - - - {% endfor %} - -
{{ 'Topic'|t('shopify') }}{{ 'URI'|t('app') }}{{ 'Actions'|t('app') }}
{{ webhook.topic }}{{ webhook.uri }} -
- {{ csrfInput() }} - {{ actionInput('shopify/webhooks/delete') }} - {{ hiddenInput('id', webhook.id) }} - -
-
-{% else %} - {# Initial/onboarding state (no webhooks present): #} -
-

{{ 'No webhooks exist for this environment.'|t('shopify') }}

-
-
- {{ csrfInput() }} - {{ actionInput('shopify/webhooks/create') }} - - -
-{% endif %} \ No newline at end of file diff --git a/src/translations/en/shopify.php b/src/translations/en/shopify.php index b6728a0..92085ed 100644 --- a/src/translations/en/shopify.php +++ b/src/translations/en/shopify.php @@ -84,6 +84,7 @@ 'Sync deleted' => 'Sync deleted', 'Tags' => 'Tags', 'Template Suffix' => 'Template Suffix', + 'There was a problem deleting the {topic} webhook' => 'There was a problem deleting the {topic} webhook', 'This product has no media.' => 'This product has no media.', 'This product has no meta fields.' => 'This product has no meta fields.', 'This product has no options.' => 'This product has no options.', From bca5b764941e63e64dd5b0c7f11a592abfe07676 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 19 Feb 2026 16:44:21 +0000 Subject: [PATCH 38/98] Tidy webhook delete form to use correct elements --- src/controllers/WebhooksController.php | 28 +++++++++++--------------- src/translations/en/shopify.php | 1 - 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/src/controllers/WebhooksController.php b/src/controllers/WebhooksController.php index 4be2c14..a57b107 100644 --- a/src/controllers/WebhooksController.php +++ b/src/controllers/WebhooksController.php @@ -118,15 +118,16 @@ public function actionEdit(): YiiResponse Html::tag('td', $hook['topic']) . Html::tag('td', $hook['uri']) . Html::beginTag('td', ['class' => 'rightalign']) . - Html::beginForm() . + Html::beginForm(options: [ + 'class' => 'shopify-webhook-delete', + 'data-confirm' => Craft::t('shopify', 'Are you sure you want to delete the {topic} webhook?', ['topic' => $hook['topic']]), + ]) . Html::actionInput('shopify/webhooks/delete') . Html::hiddenInput('id', $hook['id']) . - Html::tag('a', '', [ + Html::submitButton('', [ 'class' => 'delete icon', 'href' => '#', 'title' => Craft::t('shopify', 'Delete {topic} webhook', ['topic' => $hook['topic']]), 'role' => 'button', - 'data-confirm' => Craft::t('shopify', 'Are you sure you want to delete the {topic} webhook?', ['topic' => $hook['topic']]), - 'data-error' => Craft::t('shopify', 'There was a problem deleting the {topic} webhook', ['topic' => $hook['topic']]), ]) . Html::endForm() . Html::endTag('td') . @@ -139,23 +140,18 @@ public function actionEdit(): YiiResponse $js = << { const table = document.querySelector('table.data'); - const deleteButtons = table.querySelectorAll('.delete'); - if (!table || deleteButtons.length == 0) return; + const deleteForms = table.querySelectorAll('.shopify-webhook-delete'); + if (!table || deleteForms.length == 0) return; - deleteButtons.forEach(button => { - button.addEventListener('click', async (e) => { + deleteForms.forEach(deleteForm => { + deleteForm.addEventListener('submit', async (e) => { e.preventDefault(); - if (!confirm(button.dataset.confirm)) { + if (!confirm(deleteForm.dataset.confirm)) { return; } - - try { - const deleteForm = button.closest('form'); - deleteForm.submit(); - } catch (error) { - Craft.cp.displayError(button.dataset.error) - } + + deleteForm.submit(); }); }); })(); diff --git a/src/translations/en/shopify.php b/src/translations/en/shopify.php index 92085ed..b6728a0 100644 --- a/src/translations/en/shopify.php +++ b/src/translations/en/shopify.php @@ -84,7 +84,6 @@ 'Sync deleted' => 'Sync deleted', 'Tags' => 'Tags', 'Template Suffix' => 'Template Suffix', - 'There was a problem deleting the {topic} webhook' => 'There was a problem deleting the {topic} webhook', 'This product has no media.' => 'This product has no media.', 'This product has no meta fields.' => 'This product has no meta fields.', 'This product has no options.' => 'This product has no options.', From 3fd39e1fc43e57d6abf07fafb4f0528d1d148158 Mon Sep 17 00:00:00 2001 From: August Miller Date: Thu, 19 Feb 2026 16:32:42 -0800 Subject: [PATCH 39/98] Fix display error with complex metafield values --- src/fieldlayoutelements/MetafieldsField.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fieldlayoutelements/MetafieldsField.php b/src/fieldlayoutelements/MetafieldsField.php index 5ffe7f3..d39c5e6 100644 --- a/src/fieldlayoutelements/MetafieldsField.php +++ b/src/fieldlayoutelements/MetafieldsField.php @@ -70,7 +70,7 @@ protected function inputHtml(ElementInterface $element = null, bool $static = fa foreach ($metafields as $key => $value) { $tableData[] = [ 'key' => Html::tag('code', Html::encode($key)), - 'value' => Html::encode($value), + 'value' => Html::tag('code', json_encode($value)), ]; } From f0d43c8668d874a57bce1acbcba97d07e0b06150 Mon Sep 17 00:00:00 2001 From: August Miller Date: Thu, 19 Feb 2026 16:33:02 -0800 Subject: [PATCH 40/98] Additional info about the bulk op engine --- src/services/BulkOperations.php | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/services/BulkOperations.php b/src/services/BulkOperations.php index 005d9b2..8549a4d 100644 --- a/src/services/BulkOperations.php +++ b/src/services/BulkOperations.php @@ -29,9 +29,23 @@ use yii\db\StaleObjectException; /** - * * BulkOperations service. * + * This service is responsible for creating a local queue of “bulk” synchronization tasks, which are later dispatched to Shopify. The process looks something like this: + * + * 1. Most “bulk” operations are created in response to webhooks, but a user may also request a full synchronization via the Utility or CLI. + * 2. A local record is created to track pending synchronizations + * 3. The plugin checks to see if Shopify is currently processing another bulk operation. If not, we push the query to the API. + * 4. When a bulk query finishes running on Shopify’s infrastructure, they issue a webhook. + * 5. In response to the webhook, we store the URL to the operation’s JSONL results and push a {@see ProcessBulkOperationData} to the Craft queue. That job is responsible for actually updating our local {@see craft\shopify\elements\Product} records. + * 6. After processing a bulk operation, we return to step #3. + * + * The system goes “idle” if there are no operations in our queue, or after adding an operation to the local queue while Shopfiy is still processing a prior one. + * + * Complete, failed, or otherwise “terminal” bulk operations can be deleted from the queue. + * + * @link https://shopify.dev/docs/api/usage/bulk-operations/queries + * * @author Pixel & Tonic, Inc. * @since 6.0.0 */ @@ -128,7 +142,9 @@ public function nextBulkOperation(): bool /** @var BulkOperation $bulkOperation */ $bulkOperation = Craft::createObject(array_merge($result, ['class' => BulkOperation::class])); - // Before trying to create a new bulk op in Shopify, we should check if there is one running + // @todo API version 2026-01 will allow concurrent bulk operations, but 2025-10 and earlier are limited to a single + // https://shopify.dev/docs/api/usage/bulk-operations/queries#limitations + // Before trying to create a new bulk operation in Shopify, we should check if there is one running // This will ensure we don't cause any issue with other processes // @todo This procedure is deprecated in 2025-10, and Shopify suggests moving to the generic bulkOperations() query. From 931042bd56ebf67ca037f18084be9210c5461e06 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Fri, 20 Feb 2026 09:15:55 +0000 Subject: [PATCH 41/98] Fix variant output in sidebar --- src/helpers/Product.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/helpers/Product.php b/src/helpers/Product.php index abd0ba5..d9237a4 100644 --- a/src/helpers/Product.php +++ b/src/helpers/Product.php @@ -15,6 +15,7 @@ use craft\helpers\StringHelper; use craft\i18n\Formatter; use craft\shopify\elements\Product as ProductElement; +use craft\shopify\models\Variant; use craft\shopify\records\ShopifyData; use yii\base\InvalidConfigException; @@ -99,7 +100,7 @@ public static function renderCardHtml(ProductElement $product, array $excludeMet $meta[Craft::t('shopify', 'Total variants')] = Craft::$app->getFormatter()->asInteger(count($variants)); $meta[Craft::t('shopify', 'Variants')] = collect($variants) - ->pluck('title') + ->map(fn(Variant $variant) => Html::encode($variant->title)) ->join(', '); } From 3d5af1a04a02d2d37570d34671786c99b381ea0d Mon Sep 17 00:00:00 2001 From: August Miller Date: Mon, 23 Feb 2026 14:51:34 -0800 Subject: [PATCH 42/98] =?UTF-8?q?Add=20=E2=80=9Ctemplate=20suffix=E2=80=9D?= =?UTF-8?q?=20condition=20rule,=20query=20param,=20etc.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/elements/Product.php | 3 ++ .../conditions/products/ProductCondition.php | 1 + .../products/TemplateSuffixConditionRule.php | 48 +++++++++++++++++++ src/elements/db/ProductQuery.php | 18 +++++++ src/events/DefineGqlFieldsEvent.php | 0 src/helpers/Product.php | 5 ++ src/translations/en/shopify.php | 2 +- 7 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 src/elements/conditions/products/TemplateSuffixConditionRule.php create mode 100644 src/events/DefineGqlFieldsEvent.php diff --git a/src/elements/Product.php b/src/elements/Product.php index 5205560..c5bb853 100644 --- a/src/elements/Product.php +++ b/src/elements/Product.php @@ -825,6 +825,7 @@ protected static function defineTableAttributes(): array 'updatedAt' => Craft::t('shopify', 'Updated At'), 'variants' => Craft::t('shopify', 'Variants'), 'vendor' => Craft::t('shopify', 'Vendor'), + 'templateSuffix' => Craft::t('shopify', 'Template suffix'), 'shopifyEdit' => Craft::t('shopify', 'Shopify Edit'), ]; } @@ -922,6 +923,8 @@ protected function attributeHtml(string $attribute): string })->join(' '); case 'variants': return collect($this->getVariants())->pluck('title')->map(fn($title) => StringHelper::toTitleCase($title))->join(', '); + case 'templateSuffix': + return HtmlHelper::tag('code', $this->templateSuffix); default: { return parent::attributeHtml($attribute); diff --git a/src/elements/conditions/products/ProductCondition.php b/src/elements/conditions/products/ProductCondition.php index e89e723..3a2f42b 100644 --- a/src/elements/conditions/products/ProductCondition.php +++ b/src/elements/conditions/products/ProductCondition.php @@ -39,6 +39,7 @@ protected function selectableConditionRules(): array VendorConditionRule::class, HandleConditionRule::class, TagsConditionRule::class, + TemplateSuffixConditionRule::class, ]); } } diff --git a/src/elements/conditions/products/TemplateSuffixConditionRule.php b/src/elements/conditions/products/TemplateSuffixConditionRule.php new file mode 100644 index 0000000..03138f2 --- /dev/null +++ b/src/elements/conditions/products/TemplateSuffixConditionRule.php @@ -0,0 +1,48 @@ +matchValue($element->templateSuffix); + } + + /** + * @inheritDoc + * @param ProductQuery $query + */ + public function modifyQuery(ElementQueryInterface $query): void + { + /** @var ProductQuery $query */ + $query->templateSuffix($this->paramValue()); + } +} diff --git a/src/elements/db/ProductQuery.php b/src/elements/db/ProductQuery.php index d4ef0ed..84fd97d 100644 --- a/src/elements/db/ProductQuery.php +++ b/src/elements/db/ProductQuery.php @@ -57,6 +57,11 @@ class ProductQuery extends ElementQuery */ public mixed $tags = null; + /** + * @var mixed|null + */ + public mixed $templateSuffix = null; + /** * @var mixed|null */ @@ -262,6 +267,15 @@ public function tags(mixed $value): self return $this; } + /** + * Narrows the query results based on the “template suffix” selected in Shopify. + */ + public function templateSuffix(mixed $value): ProductQuery + { + $this->templateSuffix = $value; + return $this; + } + /** * Narrows the query results based on the Shopify product ID */ @@ -436,6 +450,10 @@ protected function beforePrepare(): bool $this->subQuery->andWhere(Db::parseParam('data.tags', $this->tags)); } + if (isset($this->templateSuffix)) { + $this->subQuery->andWhere(Db::parseParam('data.templateSuffix', $this->templateSuffix)); + } + return parent::beforePrepare(); } } diff --git a/src/events/DefineGqlFieldsEvent.php b/src/events/DefineGqlFieldsEvent.php new file mode 100644 index 0000000..e69de29 diff --git a/src/helpers/Product.php b/src/helpers/Product.php index abd0ba5..1e7f1aa 100644 --- a/src/helpers/Product.php +++ b/src/helpers/Product.php @@ -112,6 +112,11 @@ public static function renderCardHtml(ProductElement $product, array $excludeMet $meta[Craft::t('shopify', 'Shopify ID')] = Html::tag('code', (string)$product->shopifyId); + // Template suffix + if (!empty($product->templateSuffix)) { + $meta[Craft::t('shopify', 'Template suffix')] = Html::tag('code', $product->templateSuffix); + } + $meta[Craft::t('shopify', 'Created at')] = $formatter->asDatetime($product->createdAt, Formatter::FORMAT_WIDTH_SHORT); $meta[Craft::t('shopify', 'Published at')] = $formatter->asDatetime($product->publishedAt, Formatter::FORMAT_WIDTH_SHORT); $meta[Craft::t('shopify', 'Updated at')] = $formatter->asDatetime($product->updatedAt, Formatter::FORMAT_WIDTH_SHORT); diff --git a/src/translations/en/shopify.php b/src/translations/en/shopify.php index b6728a0..e101c54 100644 --- a/src/translations/en/shopify.php +++ b/src/translations/en/shopify.php @@ -83,7 +83,7 @@ 'Sync could not be deleted' => 'Sync could not be deleted', 'Sync deleted' => 'Sync deleted', 'Tags' => 'Tags', - 'Template Suffix' => 'Template Suffix', + 'Template suffix' => 'Template suffix', 'This product has no media.' => 'This product has no media.', 'This product has no meta fields.' => 'This product has no meta fields.', 'This product has no options.' => 'This product has no options.', From 1ce1dae524358bd42554a00f6ad2cef3d70c2698 Mon Sep 17 00:00:00 2001 From: August Miller Date: Mon, 23 Feb 2026 14:52:06 -0800 Subject: [PATCH 43/98] Use constant for `shopifyStatus` initial value --- src/elements/Product.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/elements/Product.php b/src/elements/Product.php index c5bb853..947681f 100644 --- a/src/elements/Product.php +++ b/src/elements/Product.php @@ -152,7 +152,7 @@ public function getDescriptionHtml(): ?string /** * @var string */ - public string $shopifyStatus = 'ACTIVE'; + public string $shopifyStatus = self::SHOPIFY_STATUS_ACTIVE; /** * @var array From b52723295a81f833571d9f6c7d6f6c6c75fe1e1d Mon Sep 17 00:00:00 2001 From: August Miller Date: Mon, 23 Feb 2026 14:52:22 -0800 Subject: [PATCH 44/98] Add `descriptionHtml` to searchable attributes --- src/elements/Product.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/elements/Product.php b/src/elements/Product.php index 947681f..b26f7e6 100644 --- a/src/elements/Product.php +++ b/src/elements/Product.php @@ -226,6 +226,7 @@ public static function searchableAttributes(): array { return array_merge(parent::searchableAttributes(), [ 'bodyHtml', + 'descriptionHtml', 'handle', 'vendor', 'productType', From a44d295a7f9f905e935d9b0b40af45ea41238874 Mon Sep 17 00:00:00 2001 From: August Miller Date: Mon, 23 Feb 2026 14:52:30 -0800 Subject: [PATCH 45/98] Fix casing --- src/elements/Product.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/elements/Product.php b/src/elements/Product.php index b26f7e6..43ccdce 100644 --- a/src/elements/Product.php +++ b/src/elements/Product.php @@ -1023,7 +1023,7 @@ public function attributeLabels(): array $labels['publishedAt'] = Craft::t('shopify', 'Published at'); $labels['tags'] = Craft::t('shopify', 'Tags'); $labels['shopifyStatus'] = Craft::t('shopify', 'Status'); - $labels['templateSuffix'] = Craft::t('shopify', 'Template Suffix'); + $labels['templateSuffix'] = Craft::t('shopify', 'Template suffix'); $labels['updatedAt'] = Craft::t('shopify', 'Updated at'); $labels['variants'] = Craft::t('shopify', 'Variants'); $labels['vendor'] = Craft::t('shopify', 'Vendor'); From 9c385358a0926fdda9a4be580f580026986c011a Mon Sep 17 00:00:00 2001 From: August Miller Date: Mon, 23 Feb 2026 14:52:39 -0800 Subject: [PATCH 46/98] Fix casing --- .../conditions/products/TemplateSuffixConditionRule.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/elements/conditions/products/TemplateSuffixConditionRule.php b/src/elements/conditions/products/TemplateSuffixConditionRule.php index 03138f2..b16bd6d 100644 --- a/src/elements/conditions/products/TemplateSuffixConditionRule.php +++ b/src/elements/conditions/products/TemplateSuffixConditionRule.php @@ -16,7 +16,7 @@ class TemplateSuffixConditionRule extends BaseTextConditionRule implements Eleme */ public function getLabel(): string { - return \Craft::t('shopify', 'Template Suffix'); + return \Craft::t('shopify', 'Template suffix'); } /** From c6a6d01acfb7a7468f0b0613bc6a253edb1a0ab7 Mon Sep 17 00:00:00 2001 From: August Miller Date: Mon, 23 Feb 2026 14:57:20 -0800 Subject: [PATCH 47/98] =?UTF-8?q?Add=20EVENT=5FDEFINE=5FPRODUCT=5FGQL=5FFI?= =?UTF-8?q?ELDS=20to=20allow=20developers=20to=20supplement=20the=20fields?= =?UTF-8?q?=20(i.e.=20`MetaField`s)=20retrieved=20from=20the=20API.=20Of?= =?UTF-8?q?=20course,=20this=20is=20sort=20of=20a=20mess=20to=20parse=20(p?= =?UTF-8?q?rogrammatically)=20given=20the=20number=20of=20`node`=20and=20`?= =?UTF-8?q?edges`=20keys,=20as=20well=20as=20numerically-indexed=20field?= =?UTF-8?q?=20names.=20My=20idea=20was=20that=20this=20could=20be=20trigge?= =?UTF-8?q?red=20from=20other=20places=E2=80=A6=20so=20the=20event=20class?= =?UTF-8?q?=20name=20is=20generic;=20the=20event=20name=20itself=20is=20sp?= =?UTF-8?q?ecific=20to=20products,=20though!?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +++- src/events/DefineGqlFieldsEvent.php | 24 ++++++++++++++++++++++++ src/services/Api.php | 14 +++++++++++++- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5351f76..a22ccb0 100644 --- a/README.md +++ b/README.md @@ -244,7 +244,9 @@ In addition to the standard element attributes like `id`, `title`, and `status`, All of these properties are available when working with a product element [in your templates](#templating). > [!IMPORTANT] -> See the Shopify documentation on the [product resource](https://shopify.dev/docs/api/admin-graphql/latest/objects/Product) for more information about what kinds of values to expect from these properties. +> See the Shopify documentation on the [product resource](https://shopify.dev/docs/api/admin-graphql/2025-10/objects/Product) for more information about what kinds of values to expect from these properties. +> The nature of GraphQL (and API versioning) means that we may not be capturing 100% of the available data. +> To select additional fields, you can intercept the `craft\shopify\services\Api::EVENT_DEFINE_PRODUCT_GQL_FIELDS` [event](https://craftcms.com/docs/5.x/extend/events.html). A complete copy of the Shopify API data used to populate a product element is available under its `data` property. Wherever possible, we have used Shopify’s native property names—but by virtue of fetching products via GraphQL, there may be differences between the structure of this object and the API documentation, especially as it relates to nested objects. Use the following [methods](#methods) to access related or nested data! diff --git a/src/events/DefineGqlFieldsEvent.php b/src/events/DefineGqlFieldsEvent.php index e69de29..39b658a 100644 --- a/src/events/DefineGqlFieldsEvent.php +++ b/src/events/DefineGqlFieldsEvent.php @@ -0,0 +1,24 @@ + + * @since 7.0.0 + */ +class DefineGqlFieldsEvent extends Event +{ + /** + * @var array + */ + public array $fields; +} diff --git a/src/services/Api.php b/src/services/Api.php index ecb5a09..7461811 100644 --- a/src/services/Api.php +++ b/src/services/Api.php @@ -12,6 +12,7 @@ use craft\helpers\ArrayHelper; use craft\helpers\Json; use craft\log\MonologTarget; +use craft\shopify\events\DefineGqlFieldsEvent; use craft\shopify\Plugin; use craft\shopify\records\AccessToken; use craft\shopify\records\ShopifyData; @@ -66,6 +67,12 @@ class Api extends Component */ public const API_ACCESS_TOKEN_ENV_VAR = 'SHOPIFY_API_ACCESS_TOKEN'; + /** + * @event DefineGqlFieldsEvent Triggered while building a GraphQL query for retrieving Product resources from Shopify. + * @since 7.0.0 + */ + public const EVENT_DEFINE_PRODUCT_GQL_FIELDS = 'defineProductGqlFields'; + /** * @var Session|null */ @@ -331,7 +338,12 @@ public function getProductGql(?string $id = null): Query ], ]; - return $this->createQuery('products', $fields, function(QueryBuilder $builder) use ($id) { + $event = new DefineGqlFieldsEvent([ + 'fields' => $fields, + ]); + $this->trigger(self::EVENT_DEFINE_PRODUCT_GQL_FIELDS, $event); + + return $this->createQuery('products', $event->fields, function(QueryBuilder $builder) use ($id) { if ($id) { // Strip Shopify prefix if it exists $id = str_replace('gid://shopify/Product/', '', $id); From a5c6b2d357a7dd0142669e8823ed11c9e745afb9 Mon Sep 17 00:00:00 2001 From: August Miller Date: Mon, 23 Feb 2026 14:57:36 -0800 Subject: [PATCH 48/98] Clarify what kind of ID --- src/services/Api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/Api.php b/src/services/Api.php index 7461811..c6c6f92 100644 --- a/src/services/Api.php +++ b/src/services/Api.php @@ -682,7 +682,7 @@ public function getWebhooks(): Collection } /** - * @param string $id + * @param string $id Shopify webhook subscription GID * @return bool * @throws MissingArgumentException * @throws ShopifyException From a1eaef0f8de9f7f8b65ea126b96f7b71a39d927c Mon Sep 17 00:00:00 2001 From: August Miller Date: Mon, 23 Feb 2026 15:00:18 -0800 Subject: [PATCH 49/98] Already imported --- src/services/Api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/Api.php b/src/services/Api.php index c6c6f92..4b3b28e 100644 --- a/src/services/Api.php +++ b/src/services/Api.php @@ -527,7 +527,7 @@ public function getGqlClient(): Graphql * Returns or initializes a context + session. * * @return Session|null - * @throws \Shopify\Exception\MissingArgumentException + * @throws MissingArgumentException */ public function getSession(): ?Session { From 0c09e0d6eae2ae4d655c5b13cd8c0dc649f4ae4d Mon Sep 17 00:00:00 2001 From: August Miller Date: Mon, 23 Feb 2026 16:33:26 -0800 Subject: [PATCH 50/98] Another readme overhaul, for API compatibility --- README.md | 445 +++++++++++++++++++++++++++--------------------------- 1 file changed, 220 insertions(+), 225 deletions(-) diff --git a/README.md b/README.md index a22ccb0..86f2814 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,8 @@ In this step, we’ll perform the [authorization code grant](https://shopify.dev 1. Press **Authorize** in the dialog. 1. Craft and Shopify will perform the OAuth handshake, and you should land on a confirmation screen in the Craft control panel saying **Your Shopify app has been successfully authorized**. -🎊 Congratulations! Your Craft project can now communicate with the Shopify API. Let’s take it for a spin by importing your store’s products. +🎊 Congratulations! Your Craft project can now communicate with the Shopify API. +Let’s take it for a spin by importing your store’s products. ### Set up Webhooks @@ -166,43 +167,70 @@ The plugin will use your newly-issued access token to perform this operation, so > [!WARNING] > You must add webhooks for every environment you deploy the plugin to; webhooks are tied to the specific, registered URL. > Be aware that Shopify will continue to attempt delivery to your development environment’s subscriptions, which may impact the statistics you see in the Dev Dashboard. +> See [Cleanup](#cleanup) below for help culling unused webhook subscriptions. -> [!NOTE] -> If you need to test synchronization in development, we recommend using [ngrok](https://ngrok.com/) to create a tunnel to your local environment. -> DDEV makes this simple, with [the `ddev share` command](https://ddev.readthedocs.io/en/latest/users/topics/sharing/). +#### Testing Webhooks + +Development environments are not typically exposed to the public internet, which means Shopify won’t be able to deliver webhooks. +To test synchronization in development, we recommend using [ngrok](https://ngrok.com/) to create a tunnel to your local environment. +DDEV makes this simple, with [the `ddev share` command](https://ddev.readthedocs.io/en/latest/users/topics/sharing/). + +> [!TIP] > Use the `SHOPIFY_WEBHOOKS_BASE_URL` environment variable to override your project’s base URL when creating webhooks; this allows you to continue using your regular DDEV site URL for control panel and front-end access, rather than overriding the entire project or site’s base URL. +> > This setting may not work if you have set a custom `cpBaseUrl`! #### Cleanup -Each time you open an `ngrok` tunnel, you get a new public URL. -This means that you may accumulate broken webhook subscriptions over the course of development. -In the control panel, we only display the webhooks relevant to the _current_ environment, or more accurately, when the webhook’s `uri` matches the resolved webhook URL (which can be influenced by the `SHOPIFY_WEBHOOKS_BASE_URL` variable). +Each time you open an `ngrok` tunnel, you get a new public URL, and Shopify will be unable to deliver webhooks. +This means that you may accumulate broken subscriptions over the course of development. +In the control panel, we only display the webhooks relevant to the _current_ environment—or, more accurately, those with a `uri` matching the resolved webhook URL (which can be influenced by the `SHOPIFY_WEBHOOKS_BASE_URL` variable). + +You can delete individual webhooks from the control panel, or by using the [CLI GraphQL playground](#graphql-playground)… +```bash +php craft shopify/api/query 'mutation deleteWebhook { + webhookSubscriptionDelete(id: "gid://shopify/WebhookSubscription/525699895") { + userErrors { + field + message + } + deletedWebhookSubscriptionId + } +}' +``` +…substituting a known subscription GID. +Discover orphaned subscriptions using the [`webhookSubscriptions()`](https://shopify.dev/docs/api/admin-graphql/latest/queries/webhookSubscriptions) query. ## Upgrading -This release (7.x) is primarily concerned with Shopify API compatability, but the [new authentication mechanism](#connect-to-shopify) means that you’ll need to re-establish the +This release (7.x) is primarily concerned with Shopify API compatability, but the [new authentication mechanism](#connect-to-shopify) means that you’ll need to re-establish the connection to Shopify using the authentication scheme [described above](#connect-to-shopify). + +Due to significant shifts in Shopify’s developer ecosystem, many of the [front-end cart management](#front-end-sdks) techniques we have recommended (like the _JS Buy SDK_ and _Buy Button JS_) are no longer viable. > [!TIP] > We strongly recommend reviewing this same section on the [6.x](https://github.com/craftcms/shopify/blob/6.x/README.md#upgrading) branch, as there were a number of breaking changes and deprecations during the upgrade from 5.x. -After the upgrade, you **must**: - -1. Update the webhook version setting to `2025-10` in your app _and_ Craft project -1. Update client credentials in your settings -1. Review the required [access scopes](#create-an-app) -1. Delete and re-create webhooks for each environment (This is essential! Webhooks are registered and delivered with a specific version, and a mismatch will result in errors.) +After the upgrade, you **must** delete and re-create webhooks for each environment. Webhooks are registered and delivered with a specific version, and a mismatch will result in errors. -This ensures that the plugin can properly communicate with the Shopify API. -When you migrate to the Dev Dashboard custom app during the upgrade, you can leave your “legacy custom app” configuration as-is. This will no longer be used. +When you migrate to the Dev Dashboard custom app during the upgrade, you can leave your “legacy custom app” configuration as-is. The plugin will no longer use these credentials, but . ### Credentials At the beginning of 2026, Shopify overhauled how “apps” are created, moving them to the new [Dev Dashboard](https://shopify.dev/docs/apps/build/dev-dashboard). -You should be able to [create a new app](#create-an-app), [install it](#install-in-a-store), and [replace credentials](#connect-to-shopify) without disruption. +You should be able to [create a new app](#create-an-app), and [install it](#install-in-a-store) using the new OAuth mechanism, without disruption to product synchronization. +See the notes above + +### Product Field Layouts + +The product element editor has received a major overhaul. You can now choose exactly where Shopify data is placed, within the [field layout](#custom-fields). + +### Front-End SDKs + +Shopify has retired many of its pre-built client-side frameworks, in favor of directly communicating with the generic [Storefront GraphQL API](#storefront-api-client). +You will need to revise how you query and mutate data, if your front-end currently depends on the JS Buy SDK or Buy Button JS. ## Product Element @@ -210,7 +238,7 @@ Products from your Shopify store are represented in Craft as product [elements]( ### Synchronization -Once the plugin has been configured, you can perform an initial synchronization of all products via the control panel (via **Utilities** → **Shopify Sync**) or the command line: +Once connected to Shopify, you can perform an initial synchronization of all products, from the control panel (via **Utilities** → **Shopify Sync**) or the command line: ```sh php craft shopify/sync/products @@ -220,35 +248,42 @@ This adds a [bulk operation](https://shopify.dev/docs/api/usage/bulk-operations/ Going forward, your products are automatically kept in sync via [webhooks](#set-up-webhooks). You can view a history of synchronization operations by visiting the **Shopify Sync** utility. +> [!WARNING] +> We do out best to capture native Shopify resources that are attached to a product (like variants, media, and options), but cannot dynamically discover relationships with other content via `Metafield`s, or data from third-party apps. + ### Native Attributes -In addition to the standard element attributes like `id`, `title`, and `status`, each Shopify product element contains direct accessors for these canonical Shopify [Product attributes](https://shopify.dev/docs/api/admin-graphql/2025-07/objects/Product): - -| Attribute | Description | Type | -|-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| ---------- | -| `shopifyId` | The unique product [identifier](https://shopify.dev/docs/api/admin-graphql/latest/scalars/ID) in your Shopify store. | `String` | -| `shopifyStatus` | The status of the product in your Shopify store. Values can be `active`, `draft`, or `archived`. | `String` | -| `handle` | The product’s “URL handle” in Shopify, equivalent to a “slug” in Craft. For existing products, this is visible under the **Search engine listing** section of the edit screen. | `String` | -| `productType` | The product type of the product in your Shopify store. | `String` | -| `descriptionHtml` | Product description. Use the `\|raw` filter to output it in Twig—but only if the content is trusted. This was previously called `bodyHtml`. | `String` | -| `tags` | Tags associated with the product in Shopify. | `Array` | -| `templateSuffix` | [Liquid template suffix](https://shopify.dev/themes/architecture/templates#name-structure) used for the product page in Shopify. | `String` | -| `vendor` | Vendor of the product. | `String` | -| `metaFields` | [Metafields](https://shopify.dev/docs/api/admin-graphql/latest/objects/Metafield) associated with the product. | `Array` | -| `images` | Images attached to the product in Shopify. The complete [ProductImage resources](https://shopify.dev/docs/api/admin-graphql/latest/objects/MediaImage) are stored in Craft. | `Array` | -| `options` | [ProductOption](https://shopify.dev/docs/api/admin-graphql/latest/objects/ProductOption) objects, as configured in Shopify. Each option has a `name`, `position`, and an array of in-use `values`. | `Array` | -| `createdAt` | When the product was created in your Shopify store. (This will almost always be different from the element’s native `dateCreated` property.) | `DateTime` | -| `publishedAt` | When the product was published in your Shopify store. | `DateTime` | -| `updatedAt` | When the product was last updated in your Shopify store. (This will almost always be different from the element’s native `dateUpdated` property.) | `DateTime` | +In addition to the standard element attributes like `id`, `title`, and `status`, each Shopify product element contains direct accessors for these canonical Shopify [Product attributes](https://shopify.dev/docs/api/admin-graphql/2025-10/objects/Product): + +| Attribute | Description | Type | +|------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------| +| `shopifyId` | The integer product ID from Shopify. | `Integer` | +| `shopifyGid` | The [unique resource identifier](https://shopify.dev/docs/api/admin-graphql/2025-10/scalars/ID) (“GID”) from Shopify. This should always be the `shopifyId`, prepended with `gid://shopify/Product/`. | `String` | +| `shopifyStatus` | The status of the product in Shopify. Values can be `active`, `draft`, or `archived`. | `String` | +| `handle` | The product’s “URL handle” in Shopify, equivalent to a “slug” in Craft. For existing products, this is visible under the **Search engine listing** section of the edit screen. | `String` | +| `productType` | The product type of the product in your Shopify store. | `String` | +| `descriptionHtml` | Product description. Output with the `\|raw` Twig filter—but only if the content is trusted. This was previously called `bodyHtml`. | `String` | +| `tags` | Tags associated with the product in Shopify. | `Array` | +| `templateSuffix` | [Liquid template suffix](https://shopify.dev/themes/architecture/templates#name-structure) used for the product page in Shopify. | `String` | +| `vendor` | Vendor of the product. | `String` | +| `data` | The raw API response data from Shopify. (See below) | `Array` | +| `metaFields` | [Metafields](https://shopify.dev/docs/api/admin-graphql/2025-10/objects/Metafield) associated with the product. | `Array` | +| `images` | Images attached to the product in Shopify. The complete [ProductImage resource](https://shopify.dev/docs/api/admin-graphql/2025-10/objects/MediaImage) are stored in Craft. | `Array` | +| `options` | [ProductOption](https://shopify.dev/docs/api/admin-graphql/2025-10/objects/ProductOption) objects, as configured in Shopify. Each option has a `name`, `position`, and an array of in-use `values`. | `Array` | +| `defaultVariant` (and `cheapestVariant`) | The first known (or cheapest) variant belonging to the product. This is one of the few ancillary resources that we make available as a model (`craft\shopify\models\Variant`). | `Variant` | +| `createdAt` | When the product was created in your Shopify store. (This will almost always be different from the element’s native `dateCreated` property.) | `DateTime` | +| `publishedAt` | When the product was published in your Shopify store. | `DateTime` | +| `updatedAt` | When the product was last updated in your Shopify store. (This will almost always be different from the element’s native `dateUpdated` property.) | `DateTime` | All of these properties are available when working with a product element [in your templates](#templating). +Yii and Twig also allow you to access some values via magic getters—any [method](#methods) beginning with `get` (like `product.getDefaultVariant()`) can also be treated like a property (`product.defaultVariant`). > [!IMPORTANT] > See the Shopify documentation on the [product resource](https://shopify.dev/docs/api/admin-graphql/2025-10/objects/Product) for more information about what kinds of values to expect from these properties. > The nature of GraphQL (and API versioning) means that we may not be capturing 100% of the available data. > To select additional fields, you can intercept the `craft\shopify\services\Api::EVENT_DEFINE_PRODUCT_GQL_FIELDS` [event](https://craftcms.com/docs/5.x/extend/events.html). -A complete copy of the Shopify API data used to populate a product element is available under its `data` property. Wherever possible, we have used Shopify’s native property names—but by virtue of fetching products via GraphQL, there may be differences between the structure of this object and the API documentation, especially as it relates to nested objects. Use the following [methods](#methods) to access related or nested data! +A complete copy of the requested Shopify API data used to populate a `Product` element is available under its `data` property. Wherever possible, we have used Shopify’s native property names—but by virtue of fetching products via GraphQL, there may be differences between the structure of this object and the API documentation, especially as it relates to nested objects. Use the following [methods](#methods) to access related or nested data! ### Methods @@ -256,7 +291,8 @@ The product element has a few methods you might find useful in your [templates]( #### `Product::getVariants()` -Returns an array of [variants](#variants-and-pricing) belonging to the product. Each variant is an associative array—_not_ an element—but you can use the same dot notation to access their properties: +Returns an array of [variants](#variants-and-pricing) belonging to the product. +Variants are _not_ elements (just regular models), but you can use the same dot notation to access their properties: ```twig {% set variants = product.getVariants() %} @@ -328,9 +364,14 @@ For administrators, you can even link directly to the Shopify admin: ### Custom Fields -Products synchronized from Shopify have a dedicated field layout, which means they support Craft’s full array of [content tools](https://craftcms.com/docs/5.x/system/fields.html). +Products synchronized from Shopify have a dedicated field layout, which means they support Craft’s full array of [content tools](https://craftcms.com/docs/5.x/system/fields.html). In addition, you may place these read-only native fields anywhere in the layout to customize your authoring experience: + +- **Variants:** A static table with variants’ names, SKUs, and prices. +- **Options:** A list of defined options, their options, and whether any variants exist +- **Meta fields:** A static table displaying product meta fields as key-value pairs. +- **Media:** Displays a list of images attached to the product. -The product field layout can be edited by going to **Shopify** → **Settings** → **Products**, and scrolling down to **Field Layout**. +The product field layout can be edited by going to **Shopify** → **Settings** → **Products**. Fields are accessible from any product element, by their handle: @@ -351,7 +392,13 @@ Variants and other nested records do not support custom fields. ### Routing -You can give synchronized products their own on-site URLs. To set up the URI format (and the template that will be loaded when a product URL is requested), go to **Shopify** → **Settings** → **Products**. +You can give synchronized products their own on-site URLs. To set up the URI format (and the template that will be loaded when a product URL is requested), go to **Shopify** → **Settings** → **Products**. A URI format that emulates Shopify’s default would look something like this: + +``` +products/{handle} +``` + +Any [native attribute](#native-attributes), [custom field](#custom-fields) handle, or base element property can be used in this template to construct a URL. If you would prefer your customers to view individual products on Shopify, clear out the **Product URI Format** field on the settings page, and use [`product.shopifyUrl`](#productgetshopifyurl) instead of `product.url` in your templates. @@ -519,7 +566,7 @@ You can still access `product.variants`, `product.images`, and `product.metafiel ### Product Data -Products behave just like any other element, in Twig. Once you’ve loaded a product via a [query](#querying-products) (or have a reference to one on its template), you can output its native [Shopify attributes](#native-attributes) and [custom field](#custom-fields) data. +Products behave just like any other [element](https://craftcms.com/docs/5.x/system/elements.html), in Twig. Once you’ve loaded a product via a [query](#querying-products) (or have a reference to one on its template), you can output its native [Shopify attributes](#native-attributes) and [custom field](#custom-fields) data. > [!NOTE] > Some attributes are stored as JSON, which limits nested properties’s types. As a result, dates may be slightly more difficult to work with. @@ -560,62 +607,62 @@ Products behave just like any other element, in Twig. Once you’ve loaded a pro ### Variants and Pricing Products don’t have a price, despite what the Shopify UI might imply—instead, every product has at least one -[Variant](https://shopify.dev/api/admin-rest/2025-07/resources/product-variant#resource-object). - -You can get an array of variant objects for a product by accessing `product.variants` or calling [`product.getVariants()`](#productgetvariants). The product element also provides convenience methods for getting the [default](#productgetdefaultvariant) and [cheapest](#productgetcheapestvariant) variants, but you can filter them however you like with Craft’s [`collect()`](https://craftcms.com/docs/5.x/reference/twig/functions.html#collect) Twig function. +[Variant](https://shopify.dev/docs/api/admin-graphql/2025-10/objects/ProductVariant). -Unlike products, variants in Craft… +You can get an array (or, more accurately, a [collection](https://craftcms.com/docs/5.x/development/collections.html)) of variant objects for a product by accessing `product.variants` or calling [`product.getVariants()`](#productgetvariants). The product element also provides convenience methods for getting the [default](#productgetdefaultvariant) and [cheapest](#productgetcheapestvariant) variants. -- …are represented (mostly) as [the API](https://shopify.dev/api/admin-rest/2025-07/resources/product-variant#resource-object) returns them; -- …the `metafields` property is accessible in addition to the API’s returned properties; -- …use Shopify’s convention of underscores in property names instead of exposing [camel-cased equivalents](#native-attributes); -- …are plain associative arrays; -- …have no methods of their own; +- Variants are represented by a _model_ (`craft\shopify\models\Variant`), not an element. +- Their native attributes reflect most of what is available via their corresponding [API object](https://shopify.dev/docs/api/admin-graphql/2025-10/objects/ProductVariant); additional fields may be available within their `data` attribute. +- Like products, a `metafields` attribute provides access to additional store-defined data; -Once you have a reference to a variant, you can output its properties: +Once you have a reference to a variant, you can output any of its properties: ```twig {% set defaultVariant = product.getDefaultVariant() %} -{{ defaultVariant.price|currency }} +{{ defaultVariant.price|currency(variant.data.currencyCode) }} ``` > [!NOTE] -> The built-in [`currency`](https://craftcms.com/docs/5.x/reference/twig/filters.html#currency) Twig filter is a great way to format money values. +> The [`currency`](https://craftcms.com/docs/5.x/reference/twig/filters.html#currency) filter is provided by Craft (not the Shopify plugin). +> You must pass a three-digit [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217) code to properly format a currency value. ### Using Options -Options are Shopify’s way of distinguishing variants on multiple axes. +[Options](https://help.shopify.com/en/manual/products/variants) are Shopify’s way of distinguishing variants in multiple dimensions. When you add product options, Shopify typically creates a variant for each combination of their possible values. -If you want to let customers pick from options instead of directly select variants, you will need to resolve which variant a given combination points to. +If you want to let customers pick from _options_ instead of directly select from a list of _variants_, you will need to resolve which variant a given combination of options points to.
Form ```twig
- {# Create a hidden input to send the resolved variant ID to Shopify: #} - {{ hiddenInput('id', null, { - id: 'variant', - data: { - variants: product.variants, - }, - }) }} - - {# Create a dropdown for each set of options: #} - {% for option in product.options %} - - {% endfor %} + {# Create a hidden input to send the resolved variant ID to Shopify: #} + {{ hiddenInput('id', null, { + id: 'variant', + data: { + variants: product.variants | map(v => { + gid: v.shopifyId, + selectedOptions: v.data.selectedOptions, + }), + }, + }) }} + + {# Create a dropdown for each set of options: #} + {% for option in product.options %} + + {% endfor %} - +
``` @@ -625,54 +672,76 @@ If you want to let customers pick from options instead of directly select varian Script -The code below can be added to a [`{% js %}` tag](https://craftcms.com/docs/5.x/reference/twig/tags.html#js), alongside the form code. +The code below can be added to a [`{% js %}` tag](https://craftcms.com/docs/5.x/reference/twig/tags.html#js) or `