commit b1d1c62da0ae83a8c8ee21ffb75b4022d0c3e509
parent c8d2f5903fced36728b5ddbf388b465483e8b7f0
Author: Christian Grothoff <christian@grothoff.org>
Date: Sat, 11 Oct 2025 15:24:42 +0200
adding PriceCategories (WiP)
Diffstat:
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']