mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-24 23:48:13 -05:00
Wired new membership tiers UI to API
refs https://github.com/TryGhost/Team/issues/712 closes https://github.com/TryGhost/Team/issues/717 The product API is updated to support `monthly/yearly_price` on each product instead of using list of stripe prices. This change updates the handling of membership settings to use the updated API instead of `stripe_prices` property.
This commit is contained in:
parent
6165441c30
commit
46e281241e
5 changed files with 130 additions and 200 deletions
|
@ -12,14 +12,10 @@ export default ApplicationAdapter.extend({
|
||||||
return this._super(...arguments);
|
return this._super(...arguments);
|
||||||
},
|
},
|
||||||
|
|
||||||
urlForDeleteRecord(id, modelName, snapshot) {
|
urlForDeleteRecord() {
|
||||||
let url = this._super(...arguments);
|
let url = this._super(...arguments);
|
||||||
let parsedUrl = new URL(url);
|
let parsedUrl = new URL(url);
|
||||||
|
|
||||||
if (snapshot && snapshot.adapterOptions && snapshot.adapterOptions.cancel) {
|
|
||||||
parsedUrl.searchParams.set('cancel', 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsedUrl.toString();
|
return parsedUrl.toString();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -250,19 +250,32 @@ export default Component.extend({
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
updatePortalPlans(monthlyPriceId, yearlyPriceId) {
|
|
||||||
let portalPlans = ['free'];
|
|
||||||
if (monthlyPriceId) {
|
|
||||||
portalPlans.push(monthlyPriceId);
|
|
||||||
}
|
|
||||||
if (yearlyPriceId) {
|
|
||||||
portalPlans.push(yearlyPriceId);
|
|
||||||
}
|
|
||||||
this.settings.set('portalPlans', portalPlans);
|
|
||||||
},
|
|
||||||
|
|
||||||
saveProduct: task(function* () {
|
saveProduct: task(function* () {
|
||||||
|
const products = yield this.store.query('product', {include: 'monthly_price, yearly_price'});
|
||||||
|
this.product = products.firstObject;
|
||||||
|
if (this.product) {
|
||||||
|
const yearlyDiscount = this.calculateDiscount(5, 50);
|
||||||
|
this.product.set('monthlyPrice', {
|
||||||
|
nickname: 'Monthly',
|
||||||
|
amount: 500,
|
||||||
|
active: 1,
|
||||||
|
description: 'Full access',
|
||||||
|
currency: 'usd',
|
||||||
|
interval: 'month',
|
||||||
|
type: 'recurring'
|
||||||
|
});
|
||||||
|
this.product.set('yearlyPrice', {
|
||||||
|
nickname: 'Yearly',
|
||||||
|
amount: 5000,
|
||||||
|
active: 1,
|
||||||
|
currency: 'usd',
|
||||||
|
description: yearlyDiscount > 0 ? `${yearlyDiscount}% discount` : 'Full access',
|
||||||
|
interval: 'year',
|
||||||
|
type: 'recurring'
|
||||||
|
});
|
||||||
|
|
||||||
let pollTimeout = 0;
|
let pollTimeout = 0;
|
||||||
|
/** To allow Stripe config to be ready in backend, we poll the save product request */
|
||||||
while (pollTimeout < RETRY_PRODUCT_SAVE_MAX_POLL) {
|
while (pollTimeout < RETRY_PRODUCT_SAVE_MAX_POLL) {
|
||||||
yield timeout(RETRY_PRODUCT_SAVE_POLL_LENGTH);
|
yield timeout(RETRY_PRODUCT_SAVE_POLL_LENGTH);
|
||||||
|
|
||||||
|
@ -279,6 +292,7 @@ export default Component.extend({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return this.product;
|
return this.product;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
@ -289,41 +303,9 @@ export default Component.extend({
|
||||||
try {
|
try {
|
||||||
let response = yield this.settings.save();
|
let response = yield this.settings.save();
|
||||||
|
|
||||||
const products = yield this.store.query('product', {include: 'stripe_prices'});
|
yield this.saveProduct.perform();
|
||||||
this.product = products.firstObject;
|
this.settings.set('portalPlans', ['free', 'monthly', 'yearly']);
|
||||||
|
|
||||||
if (this.product) {
|
|
||||||
const stripePrices = this.product.stripePrices || [];
|
|
||||||
const yearlyDiscount = this.calculateDiscount(5, 50);
|
|
||||||
stripePrices.push(
|
|
||||||
{
|
|
||||||
nickname: 'Monthly',
|
|
||||||
amount: 500,
|
|
||||||
active: 1,
|
|
||||||
description: 'Full access',
|
|
||||||
currency: 'usd',
|
|
||||||
interval: 'month',
|
|
||||||
type: 'recurring'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nickname: 'Yearly',
|
|
||||||
amount: 5000,
|
|
||||||
active: 1,
|
|
||||||
currency: 'usd',
|
|
||||||
description: yearlyDiscount > 0 ? `${yearlyDiscount}% discount` : 'Full access',
|
|
||||||
interval: 'year',
|
|
||||||
type: 'recurring'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
this.product.set('stripePrices', stripePrices);
|
|
||||||
const updatedProduct = yield this.saveProduct.perform();
|
|
||||||
const monthlyPrice = this.getActivePrice(updatedProduct.stripePrices, 'month', 500, 'usd');
|
|
||||||
const yearlyPrice = this.getActivePrice(updatedProduct.stripePrices, 'year', 5000, 'usd');
|
|
||||||
this.updatePortalPlans(monthlyPrice.id, yearlyPrice.id);
|
|
||||||
this.settings.set('membersMonthlyPriceId', monthlyPrice.id);
|
|
||||||
this.settings.set('membersYearlyPriceId', yearlyPrice.id);
|
|
||||||
response = yield this.settings.save();
|
response = yield this.settings.save();
|
||||||
}
|
|
||||||
|
|
||||||
this.set('membersStripeOpen', false);
|
this.set('membersStripeOpen', false);
|
||||||
this.set('stripeConnectSuccess', true);
|
this.set('stripeConnectSuccess', true);
|
||||||
|
|
|
@ -24,9 +24,11 @@ export default class MembersAccessController extends Controller {
|
||||||
@tracked showLeaveRouteModal = false;
|
@tracked showLeaveRouteModal = false;
|
||||||
@tracked showPortalSettings = false;
|
@tracked showPortalSettings = false;
|
||||||
@tracked showStripeConnect = false;
|
@tracked showStripeConnect = false;
|
||||||
|
@tracked showProductModal = false;
|
||||||
|
|
||||||
@tracked product = null;
|
@tracked product = null;
|
||||||
@tracked stripePrices = [];
|
@tracked products = null;
|
||||||
|
@tracked productModel = null;
|
||||||
@tracked paidSignupRedirect;
|
@tracked paidSignupRedirect;
|
||||||
@tracked freeSignupRedirect;
|
@tracked freeSignupRedirect;
|
||||||
@tracked stripeMonthlyAmount = 5;
|
@tracked stripeMonthlyAmount = 5;
|
||||||
|
@ -59,10 +61,8 @@ export default class MembersAccessController extends Controller {
|
||||||
|
|
||||||
get hasChangedPrices() {
|
get hasChangedPrices() {
|
||||||
if (this.product) {
|
if (this.product) {
|
||||||
this.stripePrices = this.product.get('stripePrices') || [];
|
const monthlyPrice = this.product.get('monthlyPrice');
|
||||||
const activePrices = this.stripePrices.filter(price => !!price.active);
|
const yearlyPrice = this.product.get('yearlyPrice');
|
||||||
const monthlyPrice = this.getPrice(activePrices, 'monthly');
|
|
||||||
const yearlyPrice = this.getPrice(activePrices, 'yearly');
|
|
||||||
|
|
||||||
if (monthlyPrice?.amount && parseInt(this.stripeMonthlyAmount, 10) !== (monthlyPrice.amount / 100)) {
|
if (monthlyPrice?.amount && parseInt(this.stripeMonthlyAmount, 10) !== (monthlyPrice.amount / 100)) {
|
||||||
return true;
|
return true;
|
||||||
|
@ -77,7 +77,7 @@ export default class MembersAccessController extends Controller {
|
||||||
|
|
||||||
@action
|
@action
|
||||||
setup() {
|
setup() {
|
||||||
this.fetchDefaultProduct.perform();
|
this.fetchProducts.perform();
|
||||||
this.updatePortalPreview();
|
this.updatePortalPreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -176,6 +176,23 @@ export default class MembersAccessController extends Controller {
|
||||||
this.showStripeConnect = false;
|
this.showStripeConnect = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async openEditProduct(product) {
|
||||||
|
this.productModel = product;
|
||||||
|
this.showProductModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async openNewProduct() {
|
||||||
|
this.productModel = this.store.createRecord('product');
|
||||||
|
this.showProductModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
closeProductModal() {
|
||||||
|
this.showProductModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
openPortalSettings() {
|
openPortalSettings() {
|
||||||
this.saveSettingsTask.perform();
|
this.saveSettingsTask.perform();
|
||||||
|
@ -207,22 +224,14 @@ export default class MembersAccessController extends Controller {
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
updatePortalPreview({forceRefresh} = {}) {
|
updatePortalPreview({forceRefresh} = {forceRefresh: false}) {
|
||||||
// TODO: can these be worked out from settings in membersUtils?
|
// TODO: can these be worked out from settings in membersUtils?
|
||||||
const monthlyPrice = this.stripeMonthlyAmount * 100;
|
const monthlyPrice = this.stripeMonthlyAmount * 100;
|
||||||
const yearlyPrice = this.stripeYearlyAmount * 100;
|
const yearlyPrice = this.stripeYearlyAmount * 100;
|
||||||
let portalPlans = this.settings.get('portalPlans') || [];
|
let portalPlans = this.settings.get('portalPlans') || [];
|
||||||
const currentMontlyPriceId = this.settings.get('membersMonthlyPriceId');
|
|
||||||
const currentYearlyPriceId = this.settings.get('membersYearlyPriceId');
|
|
||||||
let isMonthlyChecked = false;
|
|
||||||
let isYearlyChecked = false;
|
|
||||||
if (portalPlans.includes(currentMontlyPriceId)) {
|
|
||||||
isMonthlyChecked = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (portalPlans.includes(currentYearlyPriceId)) {
|
let isMonthlyChecked = portalPlans.includes('monthly');
|
||||||
isYearlyChecked = true;
|
let isYearlyChecked = portalPlans.includes('yearly');
|
||||||
}
|
|
||||||
|
|
||||||
const newUrl = new URL(this.membersUtils.getPortalPreviewUrl({
|
const newUrl = new URL(this.membersUtils.getPortalPreviewUrl({
|
||||||
button: false,
|
button: false,
|
||||||
|
@ -272,119 +281,20 @@ export default class MembersAccessController extends Controller {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
confirmProductSave() {
|
||||||
|
return this.fetchProducts.perform();
|
||||||
|
}
|
||||||
|
|
||||||
@task
|
@task
|
||||||
*switchFromNoneTask() {
|
*switchFromNoneTask() {
|
||||||
return yield this.saveSettingsTask.perform({forceRefresh: true});
|
return yield this.saveSettingsTask.perform({forceRefresh: true});
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveProduct() {
|
setupPortalProduct(product) {
|
||||||
const isStripeConnected = this.settings.get('stripeConnectAccountId');
|
if (product) {
|
||||||
if (this.product && isStripeConnected) {
|
const monthlyPrice = product.get('monthlyPrice');
|
||||||
const stripePrices = this.product.stripePrices || [];
|
const yearlyPrice = product.get('yearlyPrice');
|
||||||
const monthlyAmount = this.stripeMonthlyAmount * 100;
|
|
||||||
const yearlyAmount = this.stripeYearlyAmount * 100;
|
|
||||||
const getActivePrice = (prices, type, amount) => {
|
|
||||||
return prices.find((price) => {
|
|
||||||
return (
|
|
||||||
price.active && price.amount === amount && price.type === 'recurring' &&
|
|
||||||
price.interval === type && price.currency.toLowerCase() === this.currency.toLowerCase()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const monthlyPrice = getActivePrice(stripePrices, 'month', monthlyAmount);
|
|
||||||
const yearlyPrice = getActivePrice(stripePrices, 'year', yearlyAmount);
|
|
||||||
|
|
||||||
if (!monthlyPrice) {
|
|
||||||
stripePrices.push(
|
|
||||||
{
|
|
||||||
nickname: 'Monthly',
|
|
||||||
amount: monthlyAmount,
|
|
||||||
active: 1,
|
|
||||||
currency: this.currency,
|
|
||||||
interval: 'month',
|
|
||||||
type: 'recurring'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!yearlyPrice) {
|
|
||||||
stripePrices.push(
|
|
||||||
{
|
|
||||||
nickname: 'Yearly',
|
|
||||||
amount: this.stripeYearlyAmount * 100,
|
|
||||||
active: 1,
|
|
||||||
currency: this.currency,
|
|
||||||
interval: 'year',
|
|
||||||
type: 'recurring'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (monthlyPrice && yearlyPrice) {
|
|
||||||
this.updatePortalPlans(monthlyPrice.id, yearlyPrice.id);
|
|
||||||
this.settings.set('membersMonthlyPriceId', monthlyPrice.id);
|
|
||||||
this.settings.set('membersYearlyPriceId', yearlyPrice.id);
|
|
||||||
return this.product;
|
|
||||||
} else {
|
|
||||||
this.product.set('stripePrices', stripePrices);
|
|
||||||
const savedProduct = await this.product.save();
|
|
||||||
const updatedStripePrices = savedProduct.stripePrices || [];
|
|
||||||
const updatedMonthlyPrice = getActivePrice(updatedStripePrices, 'month', monthlyAmount);
|
|
||||||
const updatedYearlyPrice = getActivePrice(updatedStripePrices, 'year', yearlyAmount);
|
|
||||||
this.updatePortalPlans(updatedMonthlyPrice.id, updatedYearlyPrice.id);
|
|
||||||
this.settings.set('membersMonthlyPriceId', updatedMonthlyPrice.id);
|
|
||||||
this.settings.set('membersYearlyPriceId', updatedYearlyPrice.id);
|
|
||||||
return savedProduct;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePortalPlans(monthlyPriceId, yearlyPriceId) {
|
|
||||||
let portalPlans = this.settings.get('portalPlans') || [];
|
|
||||||
const currentMontlyPriceId = this.settings.get('membersMonthlyPriceId');
|
|
||||||
const currentYearlyPriceId = this.settings.get('membersYearlyPriceId');
|
|
||||||
if (portalPlans.includes(currentMontlyPriceId)) {
|
|
||||||
portalPlans = portalPlans.filter(priceId => priceId !== currentMontlyPriceId);
|
|
||||||
portalPlans.pushObject(monthlyPriceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (portalPlans.includes(currentYearlyPriceId)) {
|
|
||||||
portalPlans = portalPlans.filter(priceId => priceId !== currentYearlyPriceId);
|
|
||||||
portalPlans.pushObject(yearlyPriceId);
|
|
||||||
}
|
|
||||||
this.settings.set('portalPlans', portalPlans);
|
|
||||||
}
|
|
||||||
|
|
||||||
getPrice(prices, type) {
|
|
||||||
const monthlyPriceId = this.settings.get('membersMonthlyPriceId');
|
|
||||||
const yearlyPriceId = this.settings.get('membersYearlyPriceId');
|
|
||||||
|
|
||||||
if (type === 'monthly') {
|
|
||||||
return (
|
|
||||||
prices.find(price => price.id === monthlyPriceId) ||
|
|
||||||
prices.find(price => price.nickname === 'Monthly') ||
|
|
||||||
prices.find(price => price.interval === 'month')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'yearly') {
|
|
||||||
return (
|
|
||||||
prices.find(price => price.id === yearlyPriceId) ||
|
|
||||||
prices.find(price => price.nickname === 'Yearly') ||
|
|
||||||
prices.find(price => price.interval === 'year')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@task({drop: true})
|
|
||||||
*fetchDefaultProduct() {
|
|
||||||
const products = yield this.store.query('product', {include: 'stripe_prices'});
|
|
||||||
this.product = products.firstObject;
|
|
||||||
this.stripePrices = [];
|
|
||||||
if (this.product) {
|
|
||||||
this.stripePrices = this.product.get('stripePrices') || [];
|
|
||||||
const activePrices = this.stripePrices.filter(price => !!price.active);
|
|
||||||
const monthlyPrice = this.getPrice(activePrices, 'monthly');
|
|
||||||
const yearlyPrice = this.getPrice(activePrices, 'yearly');
|
|
||||||
if (monthlyPrice && monthlyPrice.amount) {
|
if (monthlyPrice && monthlyPrice.amount) {
|
||||||
this.stripeMonthlyAmount = (monthlyPrice.amount / 100);
|
this.stripeMonthlyAmount = (monthlyPrice.amount / 100);
|
||||||
this.currency = monthlyPrice.currency;
|
this.currency = monthlyPrice.currency;
|
||||||
|
@ -396,19 +306,27 @@ export default class MembersAccessController extends Controller {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@task({drop: true})
|
||||||
|
*fetchProducts() {
|
||||||
|
this.products = yield this.store.query('product', {include: 'monthly_price,yearly_price'});
|
||||||
|
this.product = this.products.firstObject;
|
||||||
|
this.setupPortalProduct(this.product);
|
||||||
|
}
|
||||||
|
|
||||||
@task({drop: true})
|
@task({drop: true})
|
||||||
*saveSettingsTask(options) {
|
*saveSettingsTask(options) {
|
||||||
yield this.validateStripePlans({updatePortalPreview: false});
|
yield this.validateStripePlans({updatePortalPreview: false});
|
||||||
|
|
||||||
if (this.stripePlanError) {
|
if (this.stripePlanError && !this.config.get('enableDeveloperExperiments')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.settings.get('errors').length !== 0) {
|
if (this.settings.get('errors').length !== 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!this.config.get('enableDeveloperExperiments')) {
|
||||||
yield this.saveProduct();
|
yield this.saveProduct();
|
||||||
|
}
|
||||||
const result = yield this.settings.save();
|
const result = yield this.settings.save();
|
||||||
|
|
||||||
this.updatePortalPreview(options);
|
this.updatePortalPreview(options);
|
||||||
|
@ -416,10 +334,37 @@ export default class MembersAccessController extends Controller {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async saveProduct() {
|
||||||
|
const isStripeConnected = this.settings.get('stripeConnectAccountId');
|
||||||
|
if (this.product && isStripeConnected) {
|
||||||
|
const monthlyAmount = this.stripeMonthlyAmount * 100;
|
||||||
|
const yearlyAmount = this.stripeYearlyAmount * 100;
|
||||||
|
|
||||||
|
this.product.set('monthlyPrice', {
|
||||||
|
nickname: 'Monthly',
|
||||||
|
amount: monthlyAmount,
|
||||||
|
active: true,
|
||||||
|
currency: this.currency,
|
||||||
|
interval: 'month',
|
||||||
|
type: 'recurring'
|
||||||
|
});
|
||||||
|
this.product.set('yearlyPrice', {
|
||||||
|
nickname: 'Yearly',
|
||||||
|
amount: yearlyAmount,
|
||||||
|
active: true,
|
||||||
|
currency: this.currency,
|
||||||
|
interval: 'year',
|
||||||
|
type: 'recurring'
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedProduct = await this.product.save();
|
||||||
|
return savedProduct;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resetPrices() {
|
resetPrices() {
|
||||||
const activePrices = this.stripePrices.filter(price => !!price.active);
|
const monthlyPrice = this.product.get('monthlyPrice');
|
||||||
const monthlyPrice = this.getPrice(activePrices, 'monthly');
|
const yearlyPrice = this.product.get('yearlyPrice');
|
||||||
const yearlyPrice = this.getPrice(activePrices, 'yearly');
|
|
||||||
|
|
||||||
this.stripeMonthlyAmount = monthlyPrice ? (monthlyPrice.amount / 100) : 5;
|
this.stripeMonthlyAmount = monthlyPrice ? (monthlyPrice.amount / 100) : 5;
|
||||||
this.stripeYearlyAmount = yearlyPrice ? (yearlyPrice.amount / 100) : 50;
|
this.stripeYearlyAmount = yearlyPrice ? (yearlyPrice.amount / 100) : 50;
|
||||||
|
|
|
@ -7,5 +7,6 @@ export default Model.extend(ValidationEngine, {
|
||||||
name: attr('string'),
|
name: attr('string'),
|
||||||
description: attr('string'),
|
description: attr('string'),
|
||||||
slug: attr('string'),
|
slug: attr('string'),
|
||||||
stripePrices: attr('stripe-price')
|
monthlyPrice: attr('stripe-price'),
|
||||||
|
yearlyPrice: attr('stripe-price')
|
||||||
});
|
});
|
||||||
|
|
|
@ -3,10 +3,16 @@ import Transform from '@ember-data/serializer/transform';
|
||||||
import {A as emberA, isArray as isEmberArray} from '@ember/array';
|
import {A as emberA, isArray as isEmberArray} from '@ember/array';
|
||||||
|
|
||||||
export default Transform.extend({
|
export default Transform.extend({
|
||||||
deserialize(serialized = []) {
|
deserialize(serialized) {
|
||||||
|
if (serialized === null || serialized === undefined) {
|
||||||
|
return null;
|
||||||
|
} else if (Array.isArray(serialized)) {
|
||||||
const stripePrices = serialized.map(itemDetails => StripePrice.create(itemDetails));
|
const stripePrices = serialized.map(itemDetails => StripePrice.create(itemDetails));
|
||||||
|
|
||||||
return emberA(stripePrices);
|
return emberA(stripePrices);
|
||||||
|
} else {
|
||||||
|
return StripePrice.create(serialized);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
serialize(deserialized) {
|
serialize(deserialized) {
|
||||||
|
@ -15,7 +21,7 @@ export default Transform.extend({
|
||||||
return item;
|
return item;
|
||||||
}).compact();
|
}).compact();
|
||||||
} else {
|
} else {
|
||||||
return [];
|
return deserialized || null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Reference in a new issue