diff --git a/src/Plugin.php b/src/Plugin.php index 1309802..b353430 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -13,11 +13,15 @@ use Craft; use craft\base\Model; use craft\base\Plugin as BasePlugin; +use craft\commerce\services\Gateways; +use craft\commerce\services\ProductTypes; +use craft\events\DeleteSiteEvent; use craft\events\RegisterComponentTypesEvent; use craft\events\RegisterUrlRulesEvent; use craft\helpers\UrlHelper; use craft\services\Elements; use craft\services\Fields; +use craft\services\Sites; use craft\services\Utilities; use craft\shopify\elements\Product; use craft\shopify\fields\Products as ProductsField; @@ -25,7 +29,7 @@ use craft\shopify\models\Settings; use craft\shopify\services\Api; use craft\shopify\services\Products; -use craft\shopify\services\Store; +use craft\shopify\services\Stores; use craft\shopify\utilities\Sync; use craft\shopify\web\twig\CraftVariableBehavior; use craft\web\twig\variables\CraftVariable; @@ -51,7 +55,7 @@ class Plugin extends BasePlugin /** * @var string */ - public string $schemaVersion = '4.0.4'; // For some reason the 2.2+ version of the plugin was at 4.0 schema version + public string $schemaVersion = '4.1.0'; // For some reason the 2.2+ version of the plugin was at 4.0 schema version /** * @inheritdoc @@ -72,11 +76,30 @@ public static function config(): array 'components' => [ 'api' => ['class' => Api::class], 'products' => ['class' => Products::class], - 'store' => ['class' => Store::class], + 'store' => ['class' => Stores::class], ], ]; } + /** + * Register Shopify’s project config event listeners + */ + private function _registerProjectConfigEventListeners(): void + { + $projectConfigService = Craft::$app->getProjectConfig(); + + $storesService = $this->getStores(); + $projectConfigService->onAdd(Stores::CONFIG_STORES_KEY . '.{uid}', [$storesService, 'handleChangedProductType']) + ->onUpdate(Stores::CONFIG_STORES_KEY . '.{uid}', [$storesService, 'handleChangedProductType']) + ->onRemove(Stores::CONFIG_STORES_KEY . '.{uid}', [$storesService, 'handleDeletedProductType']); + + Event::on(Sites::class, Sites::EVENT_AFTER_DELETE_SITE, function(DeleteSiteEvent $event) use ($productTypeService) { + if (!Craft::$app->getProjectConfig()->getIsApplyingExternalChanges()) { + $productTypeService->pruneDeletedSite($event); + } + }); + } + /** * @inheritdoc */ @@ -104,6 +127,7 @@ public function init() $this->_registerUtilityTypes(); $this->_registerFieldTypes(); $this->_registerVariables(); + $this->_registerProjectConfigEventListeners(); if (!$request->getIsConsoleRequest()) { if ($request->getIsCpRequest()) { @@ -146,11 +170,11 @@ public function getProducts(): Products /** * Returns the API service * - * @return Store The Store service + * @return Stores The Store service * @throws InvalidConfigException * @since 3.0 */ - public function getStore(): Store + public function getStore(): Stores { return $this->get('store'); } diff --git a/src/controllers/SettingsController.php b/src/controllers/SettingsController.php index b893807..8b253f9 100644 --- a/src/controllers/SettingsController.php +++ b/src/controllers/SettingsController.php @@ -68,7 +68,9 @@ public function actionSaveSettings(): ?Response $fieldLayout = Craft::$app->getFields()->assembleLayoutFromPost(); $fieldLayout->type = Product::class; - Craft::$app->fields->saveLayout($fieldLayout); + if(Craft::$app->fields->saveLayout($fieldLayout)){ + + }; $pluginSettings->setProductFieldLayout($fieldLayout); diff --git a/src/controllers/StoresController.php b/src/controllers/StoresController.php new file mode 100644 index 0000000..254f9de --- /dev/null +++ b/src/controllers/StoresController.php @@ -0,0 +1,96 @@ + + * @since 3.0 + */ +class StoresController extends Controller +{ + + public function actionIndex(){ + $stores = Plugin::getInstance()->getStores()->getAllStores(); + } + + /** + * Display a form to allow an administrator to update plugin settings. + * + * @return Response + */ + public function actionIndex(?Settings $settings = null): Response + { + if ($settings == null) { + $settings = Plugin::getInstance()->getSettings(); + } + + $tabs = [ + 'apiConnection' => [ + 'label' => Craft::t('shopify', 'API Connection'), + 'url' => '#api', + ], + 'products' => [ + 'label' => Craft::t('shopify', 'Products'), + 'url' => '#products', + ], + ]; + + return $this->renderTemplate('shopify/settings/index', compact('settings', 'tabs')); + } + + /** + * Save the settings. + * + * @return ?Response + */ + public function actionSaveSettings(): ?Response + { + $settings = Craft::$app->getRequest()->getParam('settings'); + $plugin = Plugin::getInstance(); + /** @var Settings $pluginSettings */ + $pluginSettings = $plugin->getSettings(); + + // Remove from editable table namespace + $settings['uriFormat'] = $settings['routing']['uriFormat']; + $settings['template'] = $settings['routing']['template']; + unset($settings['routing']); + + $settingsSuccess = Craft::$app->getPlugins()->savePluginSettings($plugin, $settings); + + $fieldLayout = Craft::$app->getFields()->assembleLayoutFromPost(); + $fieldLayout->type = Product::class; + if(Craft::$app->fields->saveLayout($fieldLayout)){ + + }; + + $pluginSettings->setProductFieldLayout($fieldLayout); + + if (!$settingsSuccess) { + return $this->asModelFailure( + $pluginSettings, + Craft::t('shopify', 'Couldn’t save settings.'), + 'settings', + ); + } + + return $this->asModelSuccess( + $pluginSettings, + Craft::t('shopify', 'Settings saved.'), + 'settings', + ); + } +} diff --git a/src/db/Table.php b/src/db/Table.php index ad3ee90..6a43f98 100644 --- a/src/db/Table.php +++ b/src/db/Table.php @@ -17,4 +17,5 @@ abstract class Table { public const PRODUCTDATA = '{{%shopify_productdata}}'; public const PRODUCTS = '{{%shopify_products}}'; + public const STORES = '{{%shopify_stores}}'; } diff --git a/src/events/StoreEvent.php b/src/events/StoreEvent.php new file mode 100644 index 0000000..85bdb1b --- /dev/null +++ b/src/events/StoreEvent.php @@ -0,0 +1,30 @@ + + * @since 3.1 + */ +class StoreEvent extends Event +{ + /** + * @var Store The store + */ + public Store $store; + + /** + * @var bool Whether the store is brand new. + */ + public bool $isNew = false; +} diff --git a/src/migrations/Install.php b/src/migrations/Install.php index 155cb35..9fc9fe7 100644 --- a/src/migrations/Install.php +++ b/src/migrations/Install.php @@ -35,6 +35,7 @@ public function createTables(): void $this->archiveTableIfExists(Table::PRODUCTS); $this->createTable(Table::PRODUCTS, [ 'id' => $this->integer()->notNull(), + 'storeId' => $this->string()->notNull(), 'shopifyId' => $this->string(), 'dateCreated' => $this->dateTime()->notNull(), 'dateUpdated' => $this->dateTime()->notNull(), @@ -66,6 +67,22 @@ public function createTables(): void 'uid' => $this->string(), 'PRIMARY KEY(shopifyId)', ]); + + $this->archiveTableIfExists(Table::STORES); + $this->createTable(Table::STORES, [ + 'id' => $this->primaryKey(), + 'name' => $this->string(), + 'hostName' => $this->string(), + 'apiKey' => $this->string(), + 'apiSecretKey' => $this->string(), + 'accessToken' => $this->string(), + 'uriFormat' => $this->string(), + 'template' => $this->string(), + 'productFieldLayoutId' => $this->integer(), + 'dateCreated' => $this->dateTime()->notNull(), + 'dateUpdated' => $this->dateTime()->notNull(), + 'uid' => $this->string(), + ]); } /** @@ -74,6 +91,8 @@ public function createTables(): void public function createIndexes(): void { $this->createIndex(null, Table::PRODUCTDATA, ['shopifyId'], true); + $this->createIndex(null, Table::STORES, ['id'], true); + $this->createIndex(null, Table::STORES, ['productFieldLayoutId'], true); } /** @@ -83,6 +102,8 @@ public function addForeignKeys(): void { $this->addForeignKey(null, Table::PRODUCTS, ['shopifyId'], Table::PRODUCTDATA, ['shopifyId'], 'CASCADE', 'CASCADE'); $this->addForeignKey(null, Table::PRODUCTS, ['id'], CraftTable::ELEMENTS, ['id'], 'CASCADE', 'CASCADE'); + $this->addForeignKey(null, Table::PRODUCTS, ['storeId'], Table::STORES, ['id'], 'CASCADE', 'CASCADE'); + $this->addForeignKey(null, Table::STORES, ['productFieldLayoutId'], '{{%fieldlayouts}}', ['id'], 'SET NULL'); } /** diff --git a/src/migrations/m221118_073752_multi_store.php b/src/migrations/m221118_073752_multi_store.php new file mode 100644 index 0000000..b7c3071 --- /dev/null +++ b/src/migrations/m221118_073752_multi_store.php @@ -0,0 +1,85 @@ +getProjectConfig(); + $schemaVersion = $projectConfig->get('plugins.shopify.schemaVersion', true); + + // create store table + if (!$this->db->tableExists('{{%shopify_stores}}')) { + $this->createTable(Table::STORES, [ + 'id' => $this->primaryKey(), + 'name' => $this->string(), + 'hostName' => $this->string(), + 'apiKey' => $this->string(), + 'apiSecretKey' => $this->string(), + 'accessToken' => $this->string(), + 'uriFormat' => $this->string(), + 'template' => $this->string(), + 'productFieldLayoutId' => $this->integer(), + 'dateCreated' => $this->dateTime()->notNull(), + 'dateUpdated' => $this->dateTime()->notNull(), + 'uid' => $this->string(), + ]); + } + + $currentFieldLayout = Craft::$app->getFields()->getLayoutByType(Product::class); + + $this->insert('{{%shopify_stores', [ + 'name' => 'Default Store', + 'hostName' => Craft::$app->getProjectConfig()->get('plugins.shopify.settings.hostName', true), + 'accessToken' => Craft::$app->getProjectConfig()->get('plugin.shopify.settings.accessToken', true), + 'apiKey' => Craft::$app->getProjectConfig()->get('plugin.shopify.settings.apiKey', true), + 'apiSecretKey' => Craft::$app->getProjectConfig()->get('plugin.shopify.settings.apiSecretKey', true), + 'template' => Craft::$app->getProjectConfig()->get('plugin.shopify.settings.template', true), + 'uriFormat' => Craft::$app->getProjectConfig()->get('plugin.shopify.settings.uriFormat', true), + 'productFieldLayoutId' => $currentFieldLayout?->id, + ]); + + $migratableSettings = []; + // If this is the first time running this migration + if (version_compare($schemaVersion, '4.1.0', '<')) { + $migratableSettings = $projectConfig->get('plugins.shopify.settings'); + if ($migratableSettings) { + $projectConfig->set('plugins.shopify.settings', []); + $projectConfig->set('plugins.shopify.stores', [ + 'default' => $migratableSettings, + ]); + } + + }else{ + $migratableSettings = $projectConfig->get('shopify.settings'); + } + + if (!$this->db->columnExists('{{%shopify_products}}', 'storeId')) { + $this->addColumn('{{%shopify_products}}', 'storeId', $this->string()->after('id')); + } + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + echo "m221118_073752_multi_store cannot be reverted.\n"; + return false; + } +} diff --git a/src/models/Store.php b/src/models/Store.php new file mode 100644 index 0000000..8bd742d --- /dev/null +++ b/src/models/Store.php @@ -0,0 +1,218 @@ + + * @since 3.0 + * + * @mixin FieldLayoutBehavior + * @property-read array $config + * @property-read FieldLayout $productFieldLayout + */ +class Store extends Model +{ + /** + * @var string + */ + public string $name = ''; + + /** + * @var string + */ + public string $apiKey = ''; + + /** + * @var string + */ + public string $apiSecretKey = ''; + + /** + * @var string + */ + public string $accessToken = ''; + + /** + * @var string + */ + public string $hostName = ''; + + /** + * @var string + */ + public string $uriFormat = ''; + + /** + * @var string + */ + public string $template = ''; + + /** + * @var int|null Field layout ID + */ + public ?int $productFieldLayoutId = null; + + /** + * @var FieldLayout|null Field layout + */ + private ?FieldLayout $_productFieldLayout = null; + + /** + * @var string|null UID + */ + public ?string $uid = null; + + /** + * @inheritdoc + */ + protected function defineBehaviors(): array + { + $behaviors = parent::behaviors(); + + $behaviors['productFieldLayout'] = [ + 'class' => FieldLayoutBehavior::class, + 'elementType' => Product::class, + 'idAttribute' => 'productFieldLayoutId', + ]; + + return $behaviors; + } + + /** + * @inheritdoc + */ + public function rules(): array + { + return [ + [['apiSecretKey', 'apiKey', 'accessToken', 'hostName'], 'required'], + ['productFieldLayout', 'validateProductFieldLayout'], + ]; + } + + /** + * Validate the field layout to make sure no fields with reserved words are used. + * + * @since 4.1 + */ + public function validateProductFieldLayout(): void + { + /** @var FieldLayoutBehavior $behavior */ + $behavior = $this->getBehavior('productFieldLayout'); + $productFieldLayout = $behavior->getFieldLayout(); + + $productFieldLayout->reservedFieldHandles = [ + 'shopifyId', + 'bodyHtml', + 'createdAt', + 'handle', + 'images', + 'options', + 'productType', + 'publishedAt', + 'publishedScope', + 'tags', + 'shopifyStatus', + 'templateSuffix', + 'updatedAt', + 'variants', + 'vendor', + 'metaFields', + ]; + + if (!$productFieldLayout->validate()) { + $this->addModelErrors($productFieldLayout, 'productFieldLayout'); + } + } + + /** + * Returns the field layout config for this email. + * + * @since 3.1 + */ + public function getConfig(): array + { + $config = [ + 'name' => $this->name, + 'hostName' => $this->hostName, + 'apiKey' => $this->apiKey, + 'apiSecretKey' => $this->apiSecretKey, + 'accessToken' => $this->accessToken, + 'uriFormat' => $this->uriFormat, + 'template' => $this->template, + ]; + + $fieldLayout = $this->getProductFieldLayout(); + + if ($fieldLayoutConfig = $fieldLayout->getConfig()) { + $config['fieldLayouts'] = [ + $fieldLayout->uid => $fieldLayoutConfig, + ]; + } + + return $config; + } + + /** + * Returns the product's field layout. + * + * @return FieldLayout + * @throws InvalidConfigException if the configured field layout ID is invalid + */ + public function getProductFieldLayout(): FieldLayout + { + /** @var FieldLayoutBehavior $behavior */ + $behavior = $this->getBehavior('productFieldLayout'); + $this->_productFieldLayout = $behavior->getFieldLayout(); + + return $this->_productFieldLayout; + } + + + /** + * @inheritdoc + */ + public function attributeLabels(): array + { + return [ + 'apiKey' => Craft::t('shopify', 'Shopify API Key'), + 'apiSecretKey' => Craft::t('shopify', 'Shopify API Secret Key'), + 'accessToken' => Craft::t('shopify', 'Shopify Access Token'), + 'hostName' => Craft::t('shopify', 'Shopify Host Name'), + 'uriFormat' => Craft::t('shopify', 'Product URI format'), + 'template' => Craft::t('shopify', 'Product Template'), + ]; + } + + /** + * @param mixed $fieldLayout + * @return void + */ + public function setProductFieldLayout(mixed $fieldLayout): void + { + $this->_productFieldLayout = $fieldLayout; + } + + /** + * @return string + */ + public function getWebhookUrl(): string + { + return UrlHelper::actionUrl('shopify/webhook/handle'); + } +} diff --git a/src/records/Store.php b/src/records/Store.php new file mode 100644 index 0000000..c01b31e --- /dev/null +++ b/src/records/Store.php @@ -0,0 +1,34 @@ + + * @since 3.0 + * + * @property int $id + * @property int $shopifyId + * + */ +class Store extends ActiveRecord +{ + /** + * @inheritdoc + */ + public static function tableName(): string + { + return Table::STORES; + } +} diff --git a/src/services/Store.php b/src/services/Store.php deleted file mode 100644 index 39b3161..0000000 --- a/src/services/Store.php +++ /dev/null @@ -1,38 +0,0 @@ - - * @since 3.0 - */ -class Store extends Component -{ - /** - * Creates a URL to the external Shopify store - * - * @param string $path - * @param array $params - * @throws InvalidConfigException when no hostname is set up. - * @return string - */ - public function getUrl(string $path = '', array $params = []): string - { - $settings = Plugin::getInstance()->getSettings(); - $host = App::parseEnv($settings->hostName); - - if (!$host) { - throw new InvalidConfigException('Shopify URLs cannot be generated without a hostname configured.'); - } - - return UrlHelper::url("https://{$host}/{$path}", $params); - } -} diff --git a/src/services/Stores.php b/src/services/Stores.php new file mode 100644 index 0000000..9a63968 --- /dev/null +++ b/src/services/Stores.php @@ -0,0 +1,148 @@ + + * @since 3.0 + */ +class Stores extends Component +{ + public const EVENT_BEFORE_SAVE_STORE = 'beforeSaveStore'; + + public const CONFIG_STORES_KEY = 'shopify.stores'; + + /** + * @return Collection + */ + public function getAllStores(): Collection + { + + } + + /** + * @param Store $store + * @param bool $runValidation + * @return void + */ + public function saveStore(Store $store, bool $runValidation = true): bool + { + $isNewStore = !(bool)$store->id; + + // Fire a 'beforeSaveStore' event + if ($this->hasEventHandlers(self::EVENT_BEFORE_SAVE_STORE)) { + $this->trigger(self::EVENT_BEFORE_SAVE_STORE, new StoreEvent([ + 'store' => $store, + 'isNew' => $isNewStore, + ])); + } + + if ($runValidation && !$store->validate()) { + Craft::info('Store not saved due to validation error(s).', __METHOD__); + return false; + } + + if ($isNewStore) { + $store->uid = StringHelper::UUID(); + } + + $configPath = self::CONFIG_STORES_KEY . '.' . $store->uid; + $configData = $store->getConfig(); + Craft::$app->getProjectConfig()->set($configPath, $configData); + + if ($isNewStore) { + $store->id = Db::idByUid(Table::STORES, $store->uid); + } + + return true; + + } + + /** + * Handle stores status change. + * + * @throws Throwable if reasons + */ + public function handleChangedStore(ConfigEvent $event): void + { + $emailUid = $event->tokenMatches[0]; + $data = $event->newValue; + + $pdfUid = $data['pdf'] ?? null; + if ($pdfUid) { + Craft::$app->getProjectConfig()->processConfigChanges(Pdfs::CONFIG_PDFS_KEY . '.' . $pdfUid); + } + + $transaction = Craft::$app->getDb()->beginTransaction(); + try { + $emailRecord = $this->_getEmailRecord($emailUid); + $isNewEmail = $emailRecord->getIsNewRecord(); + + $emailRecord->name = $data['name']; + $emailRecord->subject = $data['subject']; + $emailRecord->recipientType = $data['recipientType']; + $emailRecord->to = $data['to']; + $emailRecord->bcc = $data['bcc']; + $emailRecord->cc = $data['cc'] ?? null; + $emailRecord->replyTo = $data['replyTo'] ?? null; + $emailRecord->enabled = $data['enabled']; + $emailRecord->templatePath = $data['templatePath']; + $emailRecord->plainTextTemplatePath = $data['plainTextTemplatePath'] ?? null; + $emailRecord->uid = $emailUid; + $emailRecord->pdfId = $pdfUid ? Db::idByUid(\craft\commerce\db\Table::PDFS, $pdfUid) : null; + $emailRecord->language = $data['language'] ?? EmailRecord::LOCALE_ORDER_LANGUAGE; + + $emailRecord->save(false); + + $transaction->commit(); + } catch (Throwable $e) { + $transaction->rollBack(); + throw $e; + } + + // Fire a 'afterSaveEmail' event + if ($this->hasEventHandlers(self::EVENT_AFTER_SAVE_EMAIL)) { + $this->trigger(self::EVENT_AFTER_SAVE_EMAIL, new EmailEvent([ + 'email' => $this->getEmailById($emailRecord->id), + 'isNew' => $isNewEmail, + ])); + } + } + + private function _createStoresQuery() + { + return (new Query()) + ->select([ + 'stores.id', + 'stores.name', + 'stores.hostName', + 'stores.apiKey', + 'stores.apiSecretKey', + 'stores.accessToken', + 'stores.uriFormat', + 'stores.template', + ]) + ->from(['stores' => Table::STORES]) + ->orderBy(['name' => SORT_ASC]); + } +}