turnstile

Drupal paywall plugin
Log | Files | Refs | README | LICENSE

commit b1d1c62da0ae83a8c8ee21ffb75b4022d0c3e509
parent c8d2f5903fced36728b5ddbf388b465483e8b7f0
Author: Christian Grothoff <christian@grothoff.org>
Date:   Sat, 11 Oct 2025 15:24:42 +0200

adding PriceCategories (WiP)

Diffstat:
MREADME.md | 28+++++++++++++++++++---------
Asrc/Entity/PriceCategory.php | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/Form/PriceCategoryDeleteForm.php | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/Form/PriceCategoryForm.php | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/Form/TurnstileSettingsForm.php | 197+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Asrc/PriceCategoryListBuilder.php | 42++++++++++++++++++++++++++++++++++++++++++
Asrc/TalerMerchantApiService.php | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mturnstile.install | 48++++++++++++++++++++++++++++++++++++++++++++++++
Aturnstile.links.action.yml | 7+++++++
Mturnstile.links.menu.yml | 7+++++++
Mturnstile.module | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mturnstile.permissions.yml | 8++++++--
Mturnstile.routing.yml | 33+++++++++++++++++++++++++++++++++
Mturnstile.services.yml | 8++++++++
14 files changed, 895 insertions(+), 85 deletions(-)

diff --git a/README.md b/README.md @@ -38,8 +38,8 @@ Navigate to `/admin/config/content/Turnstile` to configure: ## TODO -- Actually get basic functionality to work -- Make truncation logic more configurable (?) +- add price *categories* +- Make truncation logic work with tiles / cards - Add support for subsciptions - Add support for discount tokens @@ -50,7 +50,8 @@ The module uses `hook_entity_view_alter()` to intercept node rendering and calls the `hasAccess()` function to: -- 1. Check if the customer already paid, -2a. If not set, truncates the content body -- TODO: adds a link to enable the user to pay! +2a. If not set, truncates the content body, + adds a link to enable the user to pay! 2b. If set, display the original content ## File Structure @@ -61,19 +62,28 @@ turnstile/ │ └── install/ │ └── turnstile.settings.yml ├── src/ +│ ├── Entity/ +│ │ └── PriceCategory.php - Main entity class for PriceCategories │ ├── Form/ +│ │ ├── PriceCategoryForm.php - Add/edit form handler +│ │ ├── PriceCategoryDeleteForm.php - Delete confirmation form │ │ └── TurnstileSettingsForm.php +│ ├── PriceCategoryListBuilder.php - Admin list page builder +│ ├── TalerMerchantApiService.php - API service for merchant backend interaction │ └── Service/ │ └── Turnstile.php -├── turnstile.info.yml -├── turnstile.install -├── turnstile.module -├── turnstile.permissions.yml -├── turnstile.routing.yml -├── turnstile.services.yml +├── turnstile.info.yml - Module metadata and dependencies +├── turnstile.install - Install/uninstall hooks +├── turnstile.module - Hook implementations and debug functions +├── turnstile.permissions.yml - Permission definitions +├── turnstile.routing.yml - Route definitions for pages +├── turnstile.services.yml - Service container definitions +├── turnstile.links.menu.yml - Menu link to Structure menu +├── turnstile.links.action.yml - Action link for adding price categories └── README.md ``` + ## Requirements - Drupal 9 or 10 diff --git a/src/Entity/PriceCategory.php b/src/Entity/PriceCategory.php @@ -0,0 +1,125 @@ +<?php + +/** + * @file + * Price category structure for the turnstile module. + */ + +namespace Drupal\turnstile\Entity; + +use Drupal\Core\Config\Entity\ConfigEntityBase; + +/** + * Defines the Price Category entity. + * + * @ConfigEntityType( + * id = "price_category", + * label = @Translation("Price Category"), + * handlers = { + * "list_builder" = "Drupal\turnstile\PriceCategoryListBuilder", + * "form" = { + * "add" = "Drupal\turnstile\Form\PriceCategoryForm", + * "edit" = "Drupal\turnstile\Form\PriceCategoryForm", + * "delete" = "Drupal\turnstile\Form\PriceCategoryDeleteForm" + * } + * }, + * config_prefix = "price_category", + * admin_permission = "administer price categories", + * entity_keys = { + * "id" = "id", + * "label" = "label" + * }, + * links = { + * "collection" = "/admin/structure/price-categories", + * "edit-form" = "/admin/structure/price-categories/{price_category}/edit", + * "delete-form" = "/admin/structure/price-categories/{price_category}/delete" + * }, + * config_export = { + * "id", + * "label", + * "description", + * "prices" + * } + * ) + */ +class PriceCategory extends ConfigEntityBase { + + /** + * The price category ID. + * + * @var string + */ + protected $id; + + /** + * The price category label. + * + * @var string + */ + protected $label; + + /** + * The price category description. + * + * @var string + */ + protected $description; + + /** + * The prices array. + * + * Structure: ['subscription_id' => ['currency_code' => 'price']] + * + * @var array + */ + protected $prices = []; + + /** + * Gets the description. + * + * @return string + * The description. + */ + public function getDescription() { + return $this->description; + } + + /** + * Gets all prices. + * + * @return array + * The prices array. + */ + public function getPrices() { + return $this->prices ?: []; + } + + /** + * Gets the price for a specific subscription and currency. + * + * @param string $subscription_id + * The subscription ID. + * @param string $currency_code + * The currency code. + * + * @return string|null + * The price or NULL if not set. + */ + public function getPrice($subscription_id, $currency_code) { + return $this->prices[$subscription_id][$currency_code] ?? NULL; + } + + /** + * Sets the prices array. + * + * @param array $prices + * The prices array. + * + * @return $this + */ + public function setPrices(array $prices) { + $this->prices = $prices; + return $this; + } + +} +\ No newline at end of file diff --git a/src/Form/PriceCategoryDeleteForm.php b/src/Form/PriceCategoryDeleteForm.php @@ -0,0 +1,57 @@ +<?php + +/** + * @file + * Location: src/Form/PriceCategoryDeleteForm.php + * + * Confirmation form for deleting a price category. + */ + +namespace Drupal\turnstile\Form; + +use Drupal\Core\Entity\EntityConfirmFormBase; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Url; + +/** + * Builds the form to delete a price category. + */ +class PriceCategoryDeleteForm extends EntityConfirmFormBase { + + /** + * {@inheritdoc} + */ + public function getQuestion() { + return $this->t('Are you sure you want to delete the price category %name?', [ + '%name' => $this->entity->label(), + ]); + } + + /** + * {@inheritdoc} + */ + public function getCancelUrl() { + return new Url('entity.price_category.collection'); + } + + /** + * {@inheritdoc} + */ + public function getConfirmText() { + return $this->t('Delete'); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $this->entity->delete(); + + $this->messenger()->addStatus($this->t('Price category %label has been deleted.', [ + '%label' => $this->entity->label(), + ])); + + $form_state->setRedirectUrl($this->getCancelUrl()); + } + +} +\ No newline at end of file diff --git a/src/Form/PriceCategoryForm.php b/src/Form/PriceCategoryForm.php @@ -0,0 +1,158 @@ +<?php + +/** + * @file + * Location: src/Form/PriceCategoryForm.php + * + * Form handler for price category add and edit forms. + */ + +namespace Drupal\turnstile\Form; + +use Drupal\Core\Entity\EntityForm; +use Drupal\Core\Form\FormStateInterface; +use Drupal\turnstile\TalerMerchantApiService; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Form handler for the price category add and edit forms. + */ +class PriceCategoryForm extends EntityForm { + + /** + * The Turnstile API service. + * + * @var \Drupal\turnstile\TalerMerchantApiService + */ + protected $apiService; + + /** + * Constructs a PriceCategoryForm object. + * + * @param \Drupal\turnstile\TalerMerchantApiService $api_service + * The API service. + */ + public function __construct(TalerMerchantApiService $api_service) { + $this->apiService = $api_service; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('turnstile.api_service') + ); + } + + /** + * {@inheritdoc} + */ + public function form(array $form, FormStateInterface $form_state) { + $form = parent::form($form, $form_state); + $price_category = $this->entity; + + $form['label'] = [ + '#type' => 'textfield', + '#title' => $this->t('Name'), + '#maxlength' => 255, + '#default_value' => $price_category->label(), + '#description' => $this->t('The name of the price category.'), + '#required' => TRUE, + ]; + + $form['id'] = [ + '#type' => 'machine_name', + '#default_value' => $price_category->id(), + '#machine_name' => [ + 'exists' => '\Drupal\turnstile\Entity\PriceCategory::load', + ], + '#disabled' => !$price_category->isNew(), + ]; + + $form['description'] = [ + '#type' => 'textarea', + '#title' => $this->t('Description'), + '#default_value' => $price_category->getDescription(), + '#description' => $this->t('A description of this price category.'), + ]; + + // Get subscriptions and currencies from API. + $subscriptions = $this->apiService->getSubscriptions(); + $currencies = $this->apiService->getCurrencies(); + + if (empty($subscriptions) || empty($currencies)) { + $this->messenger()->addWarning($this->t('Unable to load subscriptions or currencies from API. Please check your configuration.')); + } + + $form['prices'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Prices'), + '#tree' => TRUE, + ]; + + $existing_prices = $price_category->getPrices(); + + foreach ($subscriptions as $subscription) { + $subscription_id = $subscription['id'] ?? $subscription['name']; + $subscription_label = $subscription['label'] ?? $subscription['name']; + + $form['prices'][$subscription_id] = [ + '#type' => 'details', + '#title' => $subscription_label, + '#open' => FALSE, + ]; + + foreach ($currencies as $currency) { + $currency_code = $currency['code'] ?? $currency['name']; + $currency_label = $currency['label'] ?? $currency['code']; + + $form['prices'][$subscription_id][$currency_code] = [ + '#type' => 'textfield', + '#title' => $currency_label, + '#default_value' => $existing_prices[$subscription_id][$currency_code] ?? '', + '#size' => 20, + '#description' => $this->t('Leave empty for no price.'), + ]; + } + } + + return $form; + } + + /** + * {@inheritdoc} + */ + public function save(array $form, FormStateInterface $form_state) { + $price_category = $this->entity; + + // Filter out empty prices. + $prices = $form_state->getValue('prices'); + $filtered_prices = []; + + foreach ($prices as $subscription_id => $currencies) { + foreach ($currencies as $currency_code => $price) { + if ($price !== '') { + $filtered_prices[$subscription_id][$currency_code] = $price; + } + } + } + + $price_category->setPrices($filtered_prices); + $status = $price_category->save(); + + if ($status === SAVED_NEW) { + $this->messenger()->addStatus($this->t('Created the %label price category.', [ + '%label' => $price_category->label(), + ])); + } + else { + $this->messenger()->addStatus($this->t('Updated the %label price category.', [ + '%label' => $price_category->label(), + ])); + } + + $form_state->setRedirectUrl($price_category->toUrl('collection')); + } + +} +\ No newline at end of file diff --git a/src/Form/TurnstileSettingsForm.php b/src/Form/TurnstileSettingsForm.php @@ -106,8 +106,6 @@ class TurnstileSettingsForm extends ConfigFormBase { '#default_value' => $config->get('grant_access_on_error') ?: '', ]; - // FIXME: add options for subscription and discount token families + prices here! - return parent::buildForm($form, $form_state); } @@ -316,6 +314,24 @@ class TurnstileSettingsForm extends ConfigFormBase { $field_storage->save(); } +if (FALSE) { + // FIXME: code duplication with turnstile.install! + $field_storage = FieldStorageConfig::loadByName('node', 'field_turnstile_price_category'); + if (!$field_storage) { + $field_storage = FieldStorageConfig::create([ + 'field_name' => 'field_turnstile_price_category', + 'entity_type' => 'node', + 'type' => 'entity_reference', + 'module' => 'core', // FIXME: should this be system? + 'cardinality' => 1, + 'settings' => [ + 'max_length' => 255, + ], + ]); + $field_storage->save(); + } +} + foreach ($bundles as $bundle) { // Verify content type exists. if (!$this->entityTypeManager->getStorage('node_type')->load($bundle)) { @@ -332,46 +348,104 @@ class TurnstileSettingsForm extends ConfigFormBase { $existing_field->set('constraints', $constraints); $existing_field->save(); } - continue; } + else + { + // Create field configuration. + $field_config = FieldConfig::create([ + 'field_storage' => $field_storage, + 'bundle' => $bundle, + 'label' => 'Price', + 'description' => 'Price for accessing this content (e.g., "EUR:5" or "USD:5,CHF:3.50" to support payment in multiple currencies)', + 'required' => FALSE, + ]); + // This adds the PriceFormatConstraintValidator to the field. + $field_config->setConstraints(['TalerPriceListFormat' => []]); + $field_config->save(); + + // Add to form display. + $form_display = EntityFormDisplay::load('node.' . $bundle . '.default'); + if ($form_display) { + $form_display->setComponent('field_price', [ + 'type' => 'string_textfield', + 'weight' => 10, + 'settings' => [ + 'size' => 60, + 'placeholder' => 'e.g., EUR:1 or USD:5,CHF:3.50', + ], + ]); + $form_display->save(); + } - // Create field configuration. - $field_config = FieldConfig::create([ - 'field_storage' => $field_storage, - 'bundle' => $bundle, - 'label' => 'Price', - 'description' => 'Price for accessing this content (e.g., "EUR:5" or "USD:5,CHF:3.50" to support payment in multiple currencies)', - 'required' => FALSE, - ]); - // This adds the PriceFormatConstraintValidator to the field. - $field_config->setConstraints(['TalerPriceListFormat' => []]); - $field_config->save(); + // Add to view display. + $view_display = EntityViewDisplay::load('node.' . $bundle . '.default'); + if ($view_display) { + $view_display->setComponent('field_price', [ + 'type' => 'string', + 'weight' => 10, + 'label' => 'above', + ]); + $view_display->save(); + } + } /* end field_price did not exist */ - // Add to form display. - $form_display = EntityFormDisplay::load('node.' . $bundle . '.default'); - if ($form_display) { - $form_display->setComponent('field_price', [ - 'type' => 'string_textfield', - 'weight' => 10, + +if (FALSE) { + // Check if field already exists for this bundle. + $existing_field = FieldConfig::loadByName('node', $bundle, 'field_turnstile_price_category'); + if ($existing_field) { + // FIXME: initialize constraints on field? + } + else + { + // Create field configuration. + $field_config = FieldConfig::create([ + 'field_name' => 'field_turnstile_price_category', + 'entity_type' => 'node', + 'bundle' => $bundle, + 'label' => t('Price Category'), + 'description' => t('Select a price category for this content.'), + 'required' => FALSE, 'settings' => [ - 'size' => 60, - 'placeholder' => 'e.g., EUR:1 or USD:5,CHF:3.50', + 'handler' => 'default', + 'handler_settings' => [ + 'target_bundles' => NULL, + 'sort' => [ + 'field' => 'label', + 'direction' => 'asc', + ], + 'auto_create' => FALSE, + ], ], ]); - $form_display->save(); - } + // FIXME: constraints? + $field_config->save(); + + // Add to form display. + $form_display = EntityFormDisplay::load('node.' . $bundle . '.default'); + if ($form_display) { + $form_display->setComponent('field_turnstile_price_category', [ + 'type' => 'string_textfield', // FIXME: wrong type! + 'weight' => 10, + ]); + $form_display->save(); + } + + // Add to view display. + $view_display = EntityViewDisplay::load('node.' . $bundle . '.default'); + if ($view_display) { + $view_display->setComponent('field_turnstile_price_category', [ + 'type' => 'entity_reference', + 'weight' => 10, + 'label' => 'above', + ]); + $view_display->save(); + } + } // end field_turnstile_price_category did not exist +} + + } // for each bundle - // Add to view display. - $view_display = EntityViewDisplay::load('node.' . $bundle . '.default'); - if ($view_display) { - $view_display->setComponent('field_price', [ - 'type' => 'string', - 'weight' => 10, - 'label' => 'above', - ]); - $view_display->save(); - } - } } /** @@ -386,11 +460,20 @@ class TurnstileSettingsForm extends ConfigFormBase { if ($field_config) { $field_config->delete(); } +if (FALSE) { + $field_config = FieldConfig::loadByName('node', $bundle, 'field_turnstile_price_category'); + if ($field_config) { + $field_config->delete(); + } +} // Remove from form display. $form_display = EntityFormDisplay::load('node.' . $bundle . '.default'); if ($form_display) { $form_display->removeComponent('field_price'); +if (FALSE) { + $form_display->removeComponent('field_turnstile_price_category'); +} $form_display->save(); } @@ -398,6 +481,9 @@ class TurnstileSettingsForm extends ConfigFormBase { $view_display = EntityViewDisplay::load('node.' . $bundle . '.default'); if ($view_display) { $view_display->removeComponent('field_price'); +if (FALSE) { + $view_display->removeComponent('field_turnstile_price_category'); +} $view_display->save(); } } @@ -411,20 +497,35 @@ class TurnstileSettingsForm extends ConfigFormBase { */ protected function cleanupFieldStorage() { $field_storage = FieldStorageConfig::loadByName('node', 'field_price'); - if (!$field_storage) { - return; + if ($field_storage) { + + // Get all field configs that use this storage. + $field_configs = $this->entityTypeManager + ->getStorage('field_config') + ->loadByProperties([ + 'field_storage' => $field_storage, + ]); + + // If no field configs exist, delete the storage. + if (empty($field_configs)) { + $field_storage->delete(); + } } - // Get all field configs that use this storage. - $field_configs = $this->entityTypeManager - ->getStorage('field_config') - ->loadByProperties([ - 'field_storage' => $field_storage, - ]); + $field_storage = FieldStorageConfig::loadByName('node', 'field_turnstile_price_category'); + if ($field_storage) { - // If no field configs exist, delete the storage. - if (empty($field_configs)) { - $field_storage->delete(); + // Get all field configs that use this storage. + $field_configs = $this->entityTypeManager + ->getStorage('field_config') + ->loadByProperties([ + 'field_storage' => $field_storage, + ]); + + // If no field configs exist, delete the storage. + if (empty($field_configs)) { + $field_storage->delete(); + } } } @@ -448,7 +549,7 @@ class TurnstileSettingsForm extends ConfigFormBase { } if (!empty($added_labels)) { $this->messenger()->addStatus( - $this->t('Price field added to: @types', [ + $this->t('Price and price category fields added to: @types', [ '@types' => implode(', ', $added_labels), ]) ); @@ -464,7 +565,7 @@ class TurnstileSettingsForm extends ConfigFormBase { } if (!empty($removed_labels)) { $this->messenger()->addStatus( - $this->t('Price field removed from: @types', [ + $this->t('Price and price category fields removed from: @types', [ '@types' => implode(', ', $removed_labels), ]) ); diff --git a/src/PriceCategoryListBuilder.php b/src/PriceCategoryListBuilder.php @@ -0,0 +1,41 @@ +<?php + +/** + * @file + * Location: src/PriceCategoryListBuilder.php + * + * List builder for price categories. + */ + +namespace Drupal\turnstile; + +use Drupal\Core\Config\Entity\ConfigEntityListBuilder; +use Drupal\Core\Entity\EntityInterface; + +/** + * Provides a listing of price categories. + */ +class PriceCategoryListBuilder extends ConfigEntityListBuilder { + + /** + * {@inheritdoc} + */ + public function buildHeader() { + $header['label'] = $this->t('Name'); + $header['id'] = $this->t('Machine name'); + $header['description'] = $this->t('Description'); + return $header + parent::buildHeader(); + } + + /** + * {@inheritdoc} + */ + public function buildRow(EntityInterface $entity) { + /** @var \Drupal\turnstile\Entity\PriceCategory $entity */ + $row['label'] = $entity->label(); + $row['id'] = $entity->id(); + $row['description'] = $entity->getDescription(); + return $row + parent::buildRow($entity); + } + +} +\ No newline at end of file diff --git a/src/TalerMerchantApiService.php b/src/TalerMerchantApiService.php @@ -0,0 +1,86 @@ +<?php + +/** + * @file + * Location: src/TalerMerchantApiService.php + * + * + * Service for interacting with the Turnstile API. + * FIXME: rename to merchant API service? + */ + +namespace Drupal\turnstile; + +use Drupal\Core\Http\ClientFactory; +use Psr\Log\LoggerInterface; + +/** + * Service for fetching subscriptions and currencies from external API. + */ +class TalerMerchantApiService { + + /** + * The HTTP client factory. + * + * @var \Drupal\Core\Http\ClientFactory + */ + protected $httpClientFactory; + + /** + * The logger. + * + * @var \Psr\Log\LoggerInterface + */ + protected $logger; + + /** + * Constructs a TalerMerchantApiService object. + * + * @param \Drupal\Core\Http\ClientFactory $http_client_factory + * The HTTP client factory. + * @param \Psr\Log\LoggerInterface $logger + * The logger. + */ + public function __construct(ClientFactory $http_client_factory, LoggerInterface $logger) { + $this->httpClientFactory = $http_client_factory; + $this->logger = $logger; + } + + /** + * Gets the list of available subscriptions. + * + * @return array + * Array of subscriptions. + */ + public function getSubscriptions() { + return [ + ["id" => "none", + "name" => "none", + "label" => "No Subscription" ], + [ "id" => "monthly", + "name" => "monthly", + "label" => "Monthly" ], + ["id" => "yearly", + "name" => "yearly", + "label" => "Yearly"] + ]; + } + + /** + * Gets the list of available currencies. + * + * @return array + * Array of currencies. + */ + public function getCurrencies() { + return [ + [ "code" => "USD", + "name" => "USD", + "label" => "US Dollar" ], + [ "code" => "EUR", + "name" => "EUR", + "label" => "Euro"], + ]; + } + +} +\ No newline at end of file diff --git a/turnstile.install b/turnstile.install @@ -14,6 +14,20 @@ use Drupal\Core\Entity\Entity\EntityViewDisplay; * Implements hook_install(). */ function turnstile_install() { + // Create the price category field storage. + $field_storage = FieldStorageConfig::create([ + 'field_name' => 'field_turnstile_price_category', + 'entity_type' => 'node', + 'type' => 'entity_reference', + 'module' => 'core', // FIXME: should this be system? + 'cardinality' => 1, + 'settings' => [ + 'max_length' => 255, + ], + ]); + $field_storage->save(); + + // FIXME: code duplication with TurnstileSettingsForm.php! // Create the price field storage. $field_storage = FieldStorageConfig::create([ 'field_name' => 'field_price', @@ -42,6 +56,27 @@ function turnstile_install() { ]); $field_config->save(); + $field_config = FieldConfig::create([ + 'field_name' => 'field_turnstile_price_category', + 'entity_type' => 'node', + 'bundle' => $bundle, + 'label' => t('Price Category'), + 'description' => t('Select a price category for this content.'), + 'required' => FALSE, + 'settings' => [ + 'handler' => 'default', + 'handler_settings' => [ + 'target_bundles' => NULL, + 'sort' => [ + 'field' => 'label', + 'direction' => 'asc', + ], + 'auto_create' => FALSE, + ], + ], + ]); + $field_config->save(); + // Add to form display. $form_display = EntityFormDisplay::load('node.' . $bundle . '.default'); if ($form_display) { @@ -49,6 +84,10 @@ function turnstile_install() { 'type' => 'string_textfield', 'weight' => 10, ]); + $form_display->setComponent('field_turnstile_price_category', [ + 'type' => 'string_textfield', // FIXME: wrong type! + 'weight' => 10, + ]); $form_display->save(); } @@ -60,6 +99,11 @@ function turnstile_install() { 'weight' => 10, 'label' => 'above', ]); + $view_display->setComponent('field_turnstile_price_category', [ + 'type' => 'entity_reference', + 'weight' => 10, + 'label' => 'above', + ]); $view_display->save(); } } @@ -71,6 +115,10 @@ function turnstile_install() { */ function turnstile_uninstall() { // Remove field configurations. + $field_storage = FieldStorageConfig::loadByName('node', 'field_turnstile_price_category'); + if ($field_storage) { + $field_storage->delete(); + } $field_storage = FieldStorageConfig::loadByName('node', 'field_price'); if ($field_storage) { $field_storage->delete(); diff --git a/turnstile.links.action.yml b/turnstile.links.action.yml @@ -0,0 +1,7 @@ +# Action links for adding new price categories. + +entity.price_category.add_form: + route_name: entity.price_category.add_form + title: 'Add price category' + appears_on: + - entity.price_category.collection diff --git a/turnstile.links.menu.yml b/turnstile.links.menu.yml @@ -4,3 +4,10 @@ turnstile.settings: parent: system.admin_config_system route_name: turnstile.settings weight: 99 + +turnstile.price_category.collection: + title: 'Price Categories' + route_name: entity.price_category.collection + description: 'Manage price categories for content.' + parent: system.admin_structure + weight: 10 diff --git a/turnstile.module b/turnstile.module @@ -25,6 +25,8 @@ enum TalerErrorCode: int { /** * Implements hook_entity_bundle_field_info_alter(). + * Adds the TalerPriceListFormat constraint to 'field_price' fields. + * Probably can be removed entirely once we have only price categories. */ function turnstile_entity_bundle_field_info_alter(&$fields, EntityTypeInterface $entity_type, $bundle) { $log_verbose = FALSE; @@ -49,18 +51,61 @@ function turnstile_entity_bundle_field_info_alter(&$fields, EntityTypeInterface return; } - if (! isset($fields['field_price'])) { + if (isset($fields['field_price'])) { + // Use the ID as defined in the annotation of the constraint definition + $fields['field_price']->addConstraint('TalerPriceListFormat', []); + } else { \Drupal::logger('turnstile')->debug('Node lacks field_price, strange. Not adding constraint.'); - return; } - // Use the ID as defined in the annotation of the constraint definition - $fields['field_price']->addConstraint('TalerPriceListFormat', []); } +/** + * Implements hook_form_FORM_ID_alter() for node forms. Adds a + * description for the Turnstile fields. + * FIXME: is there a better way to do this? + */ +function turnstile_form_node_form_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state, $form_id) { + $node = $form_state->getFormObject()->getEntity(); + $config = \Drupal::config('turnstile.settings'); + $enabled_types = $config->get('enabled_content_types') ?: []; + + // Only show price field on enabled content types. + if (in_array($node->bundle(), $enabled_types)) { + if (isset($form['field_price'])) { + $form['field_price']['#group'] = 'meta'; + $form['field_price']['widget'][0]['value']['#description'] = t('Set a price to enable paywall protection for this content.'); + } + if (isset($form['field_turnstile_price_category'])) { + $form['field_turnstile_price_category']['#group'] = 'meta'; + $form['field_turnstile_price_category']['widget'][0]['value']['#description'] = t('Set a price category to enable paywall protection for this content.'); + + // Load all price categories for the description. + $price_categories = \Drupal::entityTypeManager() + ->getStorage('turnstile_price_category') + ->loadMultiple(); + + $category_list = []; + foreach ($price_categories as $category) { + $category_list[] = $category->label() . ': ' . $category->getDescription(); + } + + $description = t('Select a price category to enable paywall protection for this content.'); + if (!empty($category_list)) { + $description .= '<br><br><strong>' . t('Available categories:') . '</strong><ul><li>' + . implode('</li><li>', $category_list) . '</li></ul>'; + } + + $form['field_turnstile_price_category']['widget']['#description'] = $description; + + } + } +} + /** - * Implements hook_entity_view_alter(). + * Implements hook_entity_view_alter(). Transforms the body of an entity to + * show the Turnstile dialog instead of the full body. */ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display) { $log_verbose = FALSE; @@ -150,14 +195,17 @@ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, Ent // No valid payment found, need to create order or show payment option. $order_info = turnstile_create_or_get_order($node); if (! $order_info) { - // Fallback: just return body, not good to have unhappy readers. $config = \Drupal::config('turnstile.settings'); $grant_access_on_error = $config->get('grant_access_on_error') ?: true; if ($grant_access_on_error) { + // Fallback: just return body, not good to have unhappy readers. return; } - // FIXME: transform body into error message! - // 'Error: failed to setup order with payment backend' + // Transform body into error message: + $build = []; + $build['error_message'] = [ + '#markup' => '<div class="error-message">Failed to create order with payment backend.</div>', + ]; return; } @@ -223,6 +271,13 @@ function _turnstile_transform_body_recursive(array &$build, NodeInterface $node, '#format' => $format, '#cache' => ['contexts' => ['url.path']], // Optional caching metadata ]; + + // FIXME: Here we probably need to attach + // some JS library to make long-polling for + // the order status work in the future. + // Something like: + // $build['#attached']['library'][] = 'turnstile/paymentcheck_js'; + // might work. } } else { @@ -292,24 +347,6 @@ function turnstile_create_or_get_order(NodeInterface $node) { /** - * Implements hook_form_FORM_ID_alter() for node forms. - */ -function turnstile_form_node_form_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state, $form_id) { - $node = $form_state->getFormObject()->getEntity(); - $config = \Drupal::config('turnstile.settings'); - $enabled_types = $config->get('enabled_content_types') ?: []; - - // Only show price field on enabled content types. - if (in_array($node->bundle(), $enabled_types)) { - if (isset($form['field_price'])) { - $form['field_price']['#group'] = 'meta'; - $form['field_price']['widget'][0]['value']['#description'] = t('Set a price to enable paywall protection for this content.'); - } - } -} - - -/** * Check order status with Taler backend. * * @param string $order_id @@ -567,3 +604,85 @@ function turnstile_theme() { ], ]; } + + + +/** + * Debugs a price category by outputting all its settings. + * + * @param string $price_category_id + * The price category identifier. + * + * @return string + * Formatted output of the price category settings. + */ +function debug_price_category($price_category_id) { + $output = []; + + // Load the price category. + $price_category = \Drupal::entityTypeManager() + ->getStorage('turnstile_price_category') + ->load($price_category_id); + + if (!$price_category) { + return "Price category '$price_category_id' not found.\n"; + } + + // Get API service for subscription and currency labels. + $api_service = \Drupal::service('turnstile.api_service'); + $subscriptions = $api_service->getSubscriptions(); + $currencies = $api_service->getCurrencies(); + + // Create lookup arrays for labels. + $subscription_labels = []; + foreach ($subscriptions as $sub) { + $id = $sub['id'] ?? $sub['name']; + $subscription_labels[$id] = $sub['label'] ?? $sub['name']; + } + + $currency_labels = []; + foreach ($currencies as $curr) { + $code = $curr['code'] ?? $curr['name']; + $currency_labels[$code] = $curr['label'] ?? $curr['code']; + } + + // Build output. + $output[] = "=== Price Category: {$price_category->label()} ==="; + $output[] = "ID: {$price_category->id()}"; + $output[] = "Description: {$price_category->getDescription()}"; + $output[] = ""; + $output[] = "Prices:"; + $output[] = str_repeat('-', 60); + + $prices = $price_category->getPrices(); + + if (empty($prices)) { + $output[] = " No prices configured."; + } + else { + foreach ($prices as $subscription_id => $currency_prices) { + $sub_label = $subscription_labels[$subscription_id] ?? $subscription_id; + $output[] = ""; + $output[] = " Subscription: $sub_label"; + + if (empty($currency_prices)) { + $output[] = " No prices set for this subscription."; + } + else { + foreach ($currency_prices as $currency_code => $price) { + $curr_label = $currency_labels[$currency_code] ?? $currency_code; + $output[] = " $curr_label ($currency_code): $price"; + } + } + } + } + + $output[] = str_repeat('=', 60); + + return implode("\n", $output) . "\n"; +} + +/** + * Usage example for drush: + * drush php-eval "echo debug_price_category('premium');" + */ +\ No newline at end of file diff --git a/turnstile.permissions.yml b/turnstile.permissions.yml @@ -1,4 +1,9 @@ administer turnstile: title: 'Administer Turnstile' description: 'Configure Turnstile settings and manage payment options.' - restrict access: true -\ No newline at end of file + restrict access: true + +administer price categories: + title: 'Administer price categories' + description: 'Create, edit, and delete price categories.' + restrict access: true diff --git a/turnstile.routing.yml b/turnstile.routing.yml @@ -8,6 +8,39 @@ turnstile.settings: options: _admin_route: TRUE +# Routes for price categories. +entity.price_category.collection: + path: '/admin/structure/price-categories' + defaults: + _entity_list: 'price_category' + _title: 'Price Categories' + requirements: + _permission: 'administer price categories' + +entity.price_category.add_form: + path: '/admin/structure/price-categories/add' + defaults: + _entity_form: 'price_category.add' + _title: 'Add price category' + requirements: + _permission: 'administer price categories' + +entity.price_category.edit_form: + path: '/admin/structure/price-categories/{price_category}/edit' + defaults: + _entity_form: 'price_category.edit' + _title: 'Edit price category' + requirements: + _permission: 'administer price categories' + +entity.price_category.delete_form: + path: '/admin/structure/price-categories/{price_category}/delete' + defaults: + _entity_form: 'price_category.delete' + _title: 'Delete price category' + requirements: + _permission: 'administer price categories' + turnstile.debughook: path: '/turndebug' defaults: diff --git a/turnstile.services.yml b/turnstile.services.yml @@ -2,3 +2,11 @@ services: turnstile.turnstile: class: Drupal\turnstile\Service\Turnstile arguments: ['@config.factory', '@http_client'] + + turnstile.api_service: + class: Drupal\turnstile\TalerMerchantApiService + arguments: ['@http_client_factory', '@logger.channel.turnstile'] + + logger.channel.turnstile: + parent: logger.channel_base + arguments: ['turnstile']