MDL-69166 core_payment: payment gateways can have a surcharge

This commit is contained in:
Shamim Rezaie 2020-06-30 17:37:50 +10:00
parent 11b2d9e9ac
commit e3e83185ed
13 changed files with 87 additions and 22 deletions

View File

@ -23,7 +23,10 @@
*/
$string['callbacknotimplemented'] = 'The callback is not implemented for component {$a}.';
$string['feeincludesurcharge'] = '{$a->fee} (includes {$a->surcharge}% surcharge for using this payment type)';
$string['nogateway'] = 'There is no payment gateway that can be used.';
$string['nogatewayselected'] = 'You first need to select a payment gateway.';
$string['selectpaymenttype'] = 'Select payment type';
$string['supportedcurrencies'] = 'Supported currencies';
$string['surcharge'] = 'Surcharge (percentage)';
$string['surcharge_desc'] = 'The surcharge is an additional percentage charged to users who choose to pay using this payment gateway.';

View File

@ -1,2 +1,2 @@
define ("core_payment/gateways_modal",["exports","core/modal_factory","core/templates","core/str","./repository","./selectors","core/modal_events","core_payment/events","core/toast","core/notification","./modal_gateways"],function(a,b,c,d,e,f,g,h,i,j,k){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.registerEventListeners=a.registerEventListenersBySelector=void 0;b=l(b);c=l(c);f=l(f);g=l(g);h=l(h);j=l(j);k=l(k);var o="undefined"!=typeof window?window:"undefined"!=typeof self?self:"undefined"!=typeof global?global:{};function l(a){return a&&a.__esModule?a:{default:a}}function m(a,b,c,d,e,f,g){try{var h=a[f](g),i=h.value}catch(a){c(a);return}if(h.done){b(i)}else{Promise.resolve(i).then(d,e)}}function n(a){return function(){var b=this,c=arguments;return new Promise(function(d,e){var h=a.apply(b,c);function f(a){m(h,d,e,f,g,"next",a)}function g(a){m(h,d,e,f,g,"throw",a)}f(void 0)})}}a.registerEventListenersBySelector=function registerEventListenersBySelector(a){document.querySelectorAll(a).forEach(function(a){p(a)})};var p=function(a){a.addEventListener("click",function(b){b.preventDefault();q(a,{focusOnClose:b.target})})};a.registerEventListeners=p;var q=function(){var a=n(regeneratorRuntime.mark(function a(l){var m,n,o,p,q,t,u,v,w,x,y,z=arguments;return regeneratorRuntime.wrap(function(a){while(1){switch(a.prev=a.next){case 0:m=1<z.length&&z[1]!==void 0?z[1]:{},n=m.focusOnClose,o=void 0===n?null:n;a.t0=b.default;a.t1=k.default.TYPE;a.next=5;return(0,d.get_string)("selectpaymenttype","core_payment");case 5:a.t2=a.sent;a.next=8;return c.default.render("core_payment/gateways_modal",{});case 8:a.t3=a.sent;a.t4={type:a.t1,title:a.t2,body:a.t3};a.next=12;return a.t0.create.call(a.t0,a.t4);case 12:p=a.sent;(0,i.addToastRegion)(p.getRoot()[0]);p.show();p.getRoot().on(g.default.hidden,function(){p.destroy();try{o.focus()}catch(a){}});p.getRoot().on(h.default.proceed,function(a){var b=p.getRoot()[0],c=(b.querySelector(f.default.values.gateway)||{value:""}).value;if(c){s(c,l.dataset.amount,l.dataset.currency,l.dataset.component,l.dataset.componentid,l.dataset.description,function(a){var b=a.success,c=a.message,d=void 0===c?"":c;p.hide();if(b){j.default.addNotification({message:d,type:"success"});location.reload()}else{j.default.alert("",d)}})}else{(0,d.get_string)("nogatewayselected","core_payment").then(function(a){return(0,i.add)(a)})}a.preventDefault()});q=l.dataset.currency;a.next=20;return(0,e.getGatewaysSupportingCurrency)(q);case 20:t=a.sent;u={gateways:t};a.next=24;return c.default.renderForPromise("core_payment/gateways",u);case 24:v=a.sent;w=v.html;x=v.js;y=p.getRoot()[0];c.default.replaceNodeContents(y.querySelector(f.default.regions.gatewaysContainer),w,x);r(y,parseFloat(l.dataset.amount),l.dataset.currency);case 30:case"end":return a.stop();}}},a)}));return function(){return a.apply(this,arguments)}}(),r=function(){var a=n(regeneratorRuntime.mark(function a(b,d,e){var g,h,i,j,k;return regeneratorRuntime.wrap(function(a){while(1){switch(a.prev=a.next){case 0:a.next=2;return r.locale;case 2:g=a.sent;h=d.toLocaleString(g,{style:"currency",currency:e});a.next=6;return c.default.renderForPromise("core_payment/fee_breakdown",{fee:h});case 6:i=a.sent;j=i.html;k=i.js;c.default.replaceNodeContents(b.querySelector(f.default.regions.costContainer),j,k);case 10:case"end":return a.stop();}}},a)}));return function(){return a.apply(this,arguments)}}();r.locale=(0,d.get_string)("localecldr","langconfig");var s=function(){var a=n(regeneratorRuntime.mark(function a(b,c,d,e,f,g,h){var i;return regeneratorRuntime.wrap(function(a){while(1){switch(a.prev=a.next){case 0:a.next=2;return"function"==typeof o.define&&o.define.amd?new Promise(function(a,c){o.require(["pg_".concat(b,"/gateways_modal")],a,c)}):"undefined"!=typeof module&&module.exports&&"undefined"!=typeof require||"undefined"!=typeof module&&module.component&&o.require&&"component"===o.require.loader?Promise.resolve(require(("pg_".concat(b,"/gateways_modal")))):Promise.resolve(o["pg_".concat(b,"/gateways_modal")]);case 2:i=a.sent;i.process(c,d,e,f,g,h);case 4:case"end":return a.stop();}}},a)}));return function(){return a.apply(this,arguments)}}()});
define ("core_payment/gateways_modal",["exports","core/modal_factory","core/templates","core/str","./repository","./selectors","core/modal_events","core_payment/events","core/toast","core/notification","./modal_gateways"],function(a,b,c,d,e,f,g,h,i,j,k){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.registerEventListeners=a.registerEventListenersBySelector=void 0;b=l(b);c=l(c);f=l(f);g=l(g);h=l(h);j=l(j);k=l(k);var o="undefined"!=typeof window?window:"undefined"!=typeof self?self:"undefined"!=typeof global?global:{};function l(a){return a&&a.__esModule?a:{default:a}}function m(a,b,c,d,e,f,g){try{var h=a[f](g),i=h.value}catch(a){c(a);return}if(h.done){b(i)}else{Promise.resolve(i).then(d,e)}}function n(a){return function(){var b=this,c=arguments;return new Promise(function(d,e){var h=a.apply(b,c);function f(a){m(h,d,e,f,g,"next",a)}function g(a){m(h,d,e,f,g,"throw",a)}f(void 0)})}}a.registerEventListenersBySelector=function registerEventListenersBySelector(a){document.querySelectorAll(a).forEach(function(a){p(a)})};var p=function(a){a.addEventListener("click",function(b){b.preventDefault();q(a,{focusOnClose:b.target})})};a.registerEventListeners=p;var q=function(){var a=n(regeneratorRuntime.mark(function a(l){var m,n,o,p,q,t,u,v,w,x,y,z=arguments;return regeneratorRuntime.wrap(function(a){while(1){switch(a.prev=a.next){case 0:m=1<z.length&&z[1]!==void 0?z[1]:{},n=m.focusOnClose,o=void 0===n?null:n;a.t0=b.default;a.t1=k.default.TYPE;a.next=5;return(0,d.get_string)("selectpaymenttype","core_payment");case 5:a.t2=a.sent;a.next=8;return c.default.render("core_payment/gateways_modal",{});case 8:a.t3=a.sent;a.t4={type:a.t1,title:a.t2,body:a.t3};a.next=12;return a.t0.create.call(a.t0,a.t4);case 12:p=a.sent;q=p.getRoot()[0];(0,i.addToastRegion)(q);p.show();p.getRoot().on(g.default.hidden,function(){p.destroy();try{o.focus()}catch(a){}});p.getRoot().on(h.default.proceed,function(a){var b=(q.querySelector(f.default.values.gateway)||{value:""}).value;if(b){s(b,{value:parseFloat(l.dataset.amount),currency:l.dataset.currency,surcharge:parseInt((q.querySelector(f.default.values.gateway)||{dataset:{surcharge:0}}).dataset.surcharge)},l.dataset.component,l.dataset.componentid,l.dataset.description,function(a){var b=a.success,c=a.message,d=void 0===c?"":c;p.hide();if(b){j.default.addNotification({message:d,type:"success"});location.reload()}else{j.default.alert("",d)}})}else{(0,d.get_string)("nogatewayselected","core_payment").then(function(a){return(0,i.add)(a)})}a.preventDefault()});q.addEventListener("change",function(a){if(a.target.matches(f.default.elements.gateways)){r(q,parseFloat(l.dataset.amount),l.dataset.currency)}});t=l.dataset.currency;a.next=22;return(0,e.getGatewaysSupportingCurrency)(t);case 22:u=a.sent;v={gateways:u};a.next=26;return c.default.renderForPromise("core_payment/gateways",v);case 26:w=a.sent;x=w.html;y=w.js;c.default.replaceNodeContents(q.querySelector(f.default.regions.gatewaysContainer),x,y);a.next=32;return r(q,parseFloat(l.dataset.amount),l.dataset.currency);case 32:case"end":return a.stop();}}},a)}));return function(){return a.apply(this,arguments)}}(),r=function(){var a=n(regeneratorRuntime.mark(function a(b,d,e){var g,h,i,j,k,l;return regeneratorRuntime.wrap(function(a){while(1){switch(a.prev=a.next){case 0:a.next=2;return r.locale;case 2:g=a.sent;h=parseInt((b.querySelector(f.default.values.gateway)||{dataset:{surcharge:0}}).dataset.surcharge);d+=d*h/100;i=d.toLocaleString(g,{style:"currency",currency:e});a.next=8;return c.default.renderForPromise("core_payment/fee_breakdown",{fee:i,surcharge:h});case 8:j=a.sent;k=j.html;l=j.js;c.default.replaceNodeContents(b.querySelector(f.default.regions.costContainer),k,l);case 12:case"end":return a.stop();}}},a)}));return function(){return a.apply(this,arguments)}}();r.locale=(0,d.get_string)("localecldr","langconfig");var s=function(){var a=n(regeneratorRuntime.mark(function a(b,c,d,e,f,g){var h,i,j,k,l;return regeneratorRuntime.wrap(function(a){while(1){switch(a.prev=a.next){case 0:h=c.value,i=c.currency,j=c.surcharge,k=void 0===j?0:j;a.next=3;return"function"==typeof o.define&&o.define.amd?new Promise(function(a,c){o.require(["pg_".concat(b,"/gateways_modal")],a,c)}):"undefined"!=typeof module&&module.exports&&"undefined"!=typeof require||"undefined"!=typeof module&&module.component&&o.require&&"component"===o.require.loader?Promise.resolve(require(("pg_".concat(b,"/gateways_modal")))):Promise.resolve(o["pg_".concat(b,"/gateways_modal")]);case 3:l=a.sent;h+=h*k/100;l.process(h,i,d,e,f,g);case 6:case"end":return a.stop();}}},a)}));return function(){return a.apply(this,arguments)}}()});
//# sourceMappingURL=gateways_modal.min.js.map

File diff suppressed because one or more lines are too long

View File

@ -1,2 +1,2 @@
define ("core_payment/selectors",["exports"],function(a){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.default=void 0;a.default={regions:{gatewaysContainer:"[data-region=\"gateways-container\"]",costContainer:"[data-region=\"fee-breakdown-container\"]"},values:{gateway:"[data-region=\"gateways-container\"] input[type=\"radio\"]:checked"}};return a.default});
define ("core_payment/selectors",["exports"],function(a){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.default=void 0;a.default={elements:{gateways:"[data-region=\"gateways-container\"] input[type=\"radio\"]"},regions:{gatewaysContainer:"[data-region=\"gateways-container\"]",costContainer:"[data-region=\"fee-breakdown-container\"]"},values:{gateway:"[data-region=\"gateways-container\"] input[type=\"radio\"]:checked"}};return a.default});
//# sourceMappingURL=selectors.min.js.map

View File

@ -1 +1 @@
{"version":3,"sources":["../src/selectors.js"],"names":["regions","gatewaysContainer","costContainer","values","gateway"],"mappings":"kJAwBe,CACXA,OAAO,CAAE,CACLC,iBAAiB,CAAE,sCADd,CAELC,aAAa,CAAE,2CAFV,CADE,CAKXC,MAAM,CAAE,CACJC,OAAO,CAAE,oEADL,CALG,C","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Define all of the selectors we will be using on the payment interface.\n *\n * @module core_payment/selectors\n * @package core_payment\n * @copyright 2019 Shamim Rezaie <shamim@moodle.com>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nexport default {\n regions: {\n gatewaysContainer: '[data-region=\"gateways-container\"]',\n costContainer: '[data-region=\"fee-breakdown-container\"]',\n },\n values: {\n gateway: '[data-region=\"gateways-container\"] input[type=\"radio\"]:checked',\n },\n};\n"],"file":"selectors.min.js"}
{"version":3,"sources":["../src/selectors.js"],"names":["elements","gateways","regions","gatewaysContainer","costContainer","values","gateway"],"mappings":"kJAwBe,CACXA,QAAQ,CAAE,CACNC,QAAQ,CAAE,4DADJ,CADC,CAIXC,OAAO,CAAE,CACLC,iBAAiB,CAAE,sCADd,CAELC,aAAa,CAAE,2CAFV,CAJE,CAQXC,MAAM,CAAE,CACJC,OAAO,CAAE,oEADL,CARG,C","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Define all of the selectors we will be using on the payment interface.\n *\n * @module core_payment/selectors\n * @package core_payment\n * @copyright 2019 Shamim Rezaie <shamim@moodle.com>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nexport default {\n elements: {\n gateways: '[data-region=\"gateways-container\"] input[type=\"radio\"]',\n },\n regions: {\n gatewaysContainer: '[data-region=\"gateways-container\"]',\n costContainer: '[data-region=\"fee-breakdown-container\"]',\n },\n values: {\n gateway: '[data-region=\"gateways-container\"] input[type=\"radio\"]:checked',\n },\n};\n"],"file":"selectors.min.js"}

View File

@ -72,7 +72,8 @@ const show = async(rootNode, {
body: await Templates.render('core_payment/gateways_modal', {}),
});
addToastRegion(modal.getRoot()[0]);
const rootElement = modal.getRoot()[0];
addToastRegion(rootElement);
modal.show();
@ -87,14 +88,17 @@ const show = async(rootNode, {
});
modal.getRoot().on(PaymentEvents.proceed, (e) => {
const root = modal.getRoot()[0];
const gateway = (root.querySelector(Selectors.values.gateway) || {value: ''}).value;
const gateway = (rootElement.querySelector(Selectors.values.gateway) || {value: ''}).value;
if (gateway) {
processPayment(
gateway,
rootNode.dataset.amount,
rootNode.dataset.currency,
{
value: parseFloat(rootNode.dataset.amount),
currency: rootNode.dataset.currency,
surcharge: parseInt((rootElement.querySelector(Selectors.values.gateway) || {dataset: {surcharge: 0}})
.dataset.surcharge),
},
rootNode.dataset.component,
rootNode.dataset.componentid,
rootNode.dataset.description,
@ -121,6 +125,13 @@ const show = async(rootNode, {
e.preventDefault();
});
// Re-calculate the cost when gateway is changed.
rootElement.addEventListener('change', e => {
if (e.target.matches(Selectors.elements.gateways)) {
updateCostRegion(rootElement, parseFloat(rootNode.dataset.amount), rootNode.dataset.currency);
}
});
const currency = rootNode.dataset.currency;
const gateways = await getGatewaysSupportingCurrency(currency);
const context = {
@ -128,9 +139,8 @@ const show = async(rootNode, {
};
const {html, js} = await Templates.renderForPromise('core_payment/gateways', context);
const root = modal.getRoot()[0];
Templates.replaceNodeContents(root.querySelector(Selectors.regions.gatewaysContainer), html, js);
updateCostRegion(root, parseFloat(rootNode.dataset.amount), rootNode.dataset.currency);
Templates.replaceNodeContents(rootElement.querySelector(Selectors.regions.gatewaysContainer), html, js);
await updateCostRegion(rootElement, parseFloat(rootNode.dataset.amount), rootNode.dataset.currency);
};
/**
@ -143,9 +153,11 @@ const show = async(rootNode, {
*/
const updateCostRegion = async(root, amount, currency) => {
const locale = await updateCostRegion.locale; // This only takes a bit the first time.
const surcharge = parseInt((root.querySelector(Selectors.values.gateway) || {dataset: {surcharge: 0}}).dataset.surcharge);
amount += amount * surcharge / 100;
const localisedCost = amount.toLocaleString(locale, {style: "currency", currency: currency});
const {html, js} = await Templates.renderForPromise('core_payment/fee_breakdown', {fee: localisedCost});
const {html, js} = await Templates.renderForPromise('core_payment/fee_breakdown', {fee: localisedCost, surcharge});
Templates.replaceNodeContents(root.querySelector(Selectors.regions.costContainer), html, js);
};
updateCostRegion.locale = getString("localecldr", "langconfig");
@ -154,18 +166,21 @@ updateCostRegion.locale = getString("localecldr", "langconfig");
* Process payment using the selected gateway.
*
* @param {string} gateway The gateway to be used for payment
* @param {number} amount Amount of payment
* @param {string} currency The currency in the three-character ISO-4217 format
* @param {Object} amount - Amount of payment
* @param {number} amount.value The numerical part of the amount
* @param {string} amount.currency The currency part of the amount in the three-character ISO-4217 format
* @param {number} amount.surcharge The surcharge percentage that should be added to the amount
* @param {string} component Name of the component that the componentid belongs to
* @param {number} componentid An internal identifier that is used by the component
* @param {string} description Description of the payment
* @param {processPaymentCallback} callback The callback function to call when processing is finished
* @returns {Promise<void>}
*/
const processPayment = async(gateway, amount, currency, component, componentid, description, callback) => {
const processPayment = async(gateway, {value, currency, surcharge = 0}, component, componentid, description, callback) => {
const paymentMethod = await import(`pg_${gateway}/gateways_modal`);
paymentMethod.process(amount, currency, component, componentid, description, callback);
value += value * surcharge / 100;
paymentMethod.process(value, currency, component, componentid, description, callback);
};
/**

View File

@ -23,6 +23,9 @@
*/
export default {
elements: {
gateways: '[data-region="gateways-container"] input[type="radio"]',
},
regions: {
gatewaysContainer: '[data-region="gateways-container"]',
costContainer: '[data-region="fee-breakdown-container"]',

View File

@ -67,6 +67,7 @@ class get_gateways_for_currency extends external_api {
'shortname' => $gateway,
'name' => get_string('gatewayname', 'pg_' . $gateway),
'description' => get_string('gatewaydescription', 'pg_' . $gateway),
'surcharge' => \core_payment\helper::get_gateway_surcharge($gateway),
];
}
@ -84,6 +85,7 @@ class get_gateways_for_currency extends external_api {
'shortname' => new external_value(PARAM_PLUGIN, 'Name of the plugin'),
'name' => new external_value(PARAM_TEXT, 'Human readable name of the gateway'),
'description' => new external_value(PARAM_TEXT, 'description of the gateway'),
'surcharge' => new external_value(PARAM_INT, 'percentage of surcharge when using the gateway'),
])
);
}

View File

@ -76,6 +76,16 @@ class helper {
return $gateways;
}
/**
* Returns the percentage of surcharge that is applied when using a gateway
*
* @param string $gateway Name of the gateway
* @return int
*/
public static function get_gateway_surcharge(string $gateway): int {
return get_config('pg_' . $gateway, 'surcharge') ?: 0;
}
/**
* Returns the attributes to place on a pay button.
*
@ -164,4 +174,16 @@ class helper {
return $id;
}
/**
* This functions adds the settings that are common for all payment gateways.
*
* @param \admin_settingpage $settings The settings object
* @param string $gateway The gateway name prefic with pg_
*/
public static function add_common_gateway_settings(\admin_settingpage $settings, string $gateway): void {
$settings->add(new \admin_setting_configtext($gateway . '/surcharge', get_string('surcharge', 'core_payment'),
get_string('surcharge_desc', 'core_payment'), 0, PARAM_INT));
}
}

View File

@ -77,6 +77,11 @@ class transaction_complete extends external_api {
'currency' => $currency
] = payment_helper::get_cost($component, $componentid);
// Add surcharge if there is any.
if ($config->surcharge) {
$amount += $amount * $config->surcharge / 100;
}
$paypalhelper = new paypal_helper($config->clientid, $config->secret, $sandbox);
$orderdetails = $paypalhelper->get_order_details($orderid);

View File

@ -41,4 +41,6 @@ if ($ADMIN->fulltree) {
];
$settings->add(new admin_setting_configselect('pg_paypal/environment', get_string('environment', 'pg_paypal'),
get_string('environment_desc', 'pg_paypal'), 'live', $options));
\core_payment\helper::add_common_gateway_settings($settings, 'pg_paypal');
}

View File

@ -31,8 +31,19 @@
}}
<div class="core_payment_fee_breakdown">
{{# str }} labelvalue, core, {
"label": {{# quote }}{{# str }} cost {{/ str }}{{/ quote }},
"value": "{{fee}}"
} {{/ str }}
{{#surcharge}}
{{# str }} labelvalue, core, {
"label": {{# quote }}{{# str }} cost {{/ str }}{{/ quote }},
"value": {{# quote }}{{# str }} feeincludesurcharge, core_payment, {
"fee": "{{fee}}",
"surcharge": {{surcharge}}
} {{/ str }}{{/ quote }}
} {{/ str }}
{{/surcharge}}
{{^surcharge}}
{{# str }} labelvalue, core, {
"label": {{# quote }}{{# str }} cost {{/ str }}{{/ quote }},
"value": "{{fee}}"
} {{/ str }}
{{/surcharge}}
</div>

View File

@ -29,6 +29,7 @@
* shortname
* name
* description
* surcharge
* image
Example context (json):
@ -36,11 +37,12 @@
"shortname": "paypal",
"name": "PayPal",
"description": "A description for PayPal.",
"surcharge": "3"
}
}}
<div class="custom-control custom-radio {{shortname}}">
<input class="custom-control-input" type="radio" name="payby" id="id-payby-{{uniqid}}-{{shortname}}" value="{{shortname}}" {{#checked}} checked="checked" {{/checked}} />
<input class="custom-control-input" type="radio" name="payby" id="id-payby-{{uniqid}}-{{shortname}}" data-surcharge="{{surcharge}}" value="{{shortname}}" {{#checked}} checked="checked" {{/checked}} />
<label class="custom-control-label bg-light border p-3 my-3" for="id-payby-{{uniqid}}-{{shortname}}">
<p class="h3">{{name}}</p>
<p class="content mb-2">{{description}}</p>