mirror of
https://github.com/overleaf/overleaf.git
synced 2024-12-01 15:11:30 -05:00
Merge pull request #10372 from overleaf/ii-adjust-plans-prices
Adjust plans prices and automate the process of generating the prices lists GitOrigin-RevId: 06be1f9a26837ed9bb7eca598cd88b6288244338
This commit is contained in:
parent
81e2265e72
commit
c9f8080769
6 changed files with 433 additions and 0 deletions
3
services/web/scripts/plan-prices/.gitignore
vendored
Normal file
3
services/web/scripts/plan-prices/.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules
|
||||||
|
output
|
||||||
|
*.xlsx
|
15
services/web/scripts/plan-prices/README.md
Normal file
15
services/web/scripts/plan-prices/README.md
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
A nodejs tool for reading plans prices from an Excel file and creating JSON objects.
|
||||||
|
|
||||||
|
Run `npm install` in order to install the `xlsx` dependency.
|
||||||
|
|
||||||
|
The scripts will put the output results into the `output` folder.
|
||||||
|
|
||||||
|
### Create group plans
|
||||||
|
|
||||||
|
_Command_ `node groups.js fileName sheetName` - generates group plans prices. To be used for `/services/web/app/templates/plans/groups.json`
|
||||||
|
|
||||||
|
### Create localized plan pricing
|
||||||
|
|
||||||
|
_Command_ `node plans.js fileName sheetName` - generates two json files:
|
||||||
|
- `localizedPlanPricing.json` for `/services/web/config/settings.overrides.saas.js`
|
||||||
|
- `plans.json` for `/services/web/frontend/js/main/plans.js`
|
68
services/web/scripts/plan-prices/groups.js
Normal file
68
services/web/scripts/plan-prices/groups.js
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
// Creates data for groups.json
|
||||||
|
|
||||||
|
const xlsx = require('xlsx')
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const [fileName, sheetName] = process.argv.slice(2)
|
||||||
|
|
||||||
|
// Pick the xlsx file
|
||||||
|
const filePath = path.resolve(__dirname, fileName)
|
||||||
|
const file = xlsx.readFile(filePath)
|
||||||
|
|
||||||
|
if (!file.SheetNames.includes(sheetName)) {
|
||||||
|
throw new Error('Sheet not found!')
|
||||||
|
}
|
||||||
|
|
||||||
|
const workSheet = Object.values(file.Sheets)[file.SheetNames.indexOf(sheetName)]
|
||||||
|
// Convert to JSON
|
||||||
|
const workSheetJSON = xlsx.utils.sheet_to_json(workSheet)
|
||||||
|
|
||||||
|
const groupPlans = workSheetJSON.filter(data =>
|
||||||
|
data.plan_code.startsWith('group')
|
||||||
|
)
|
||||||
|
|
||||||
|
const currencies = [
|
||||||
|
'AUD',
|
||||||
|
'CAD',
|
||||||
|
'CHF',
|
||||||
|
'DKK',
|
||||||
|
'EUR',
|
||||||
|
'GBP',
|
||||||
|
'NOK',
|
||||||
|
'NZD',
|
||||||
|
'SEK',
|
||||||
|
'SGD',
|
||||||
|
'USD',
|
||||||
|
]
|
||||||
|
const sizes = ['2', '3', '4', '5', '10', '20', '50']
|
||||||
|
|
||||||
|
const result = {}
|
||||||
|
for (const type1 of ['educational', 'enterprise']) {
|
||||||
|
result[type1] = {}
|
||||||
|
for (const type2 of ['professional', 'collaborator']) {
|
||||||
|
result[type1][type2] = {}
|
||||||
|
for (const currency of currencies) {
|
||||||
|
result[type1][type2][currency] = {}
|
||||||
|
for (const size of sizes) {
|
||||||
|
const planCode = `group_${type2}_${size}_${type1}`
|
||||||
|
const plan = groupPlans.find(data => data.plan_code === planCode)
|
||||||
|
|
||||||
|
if (!plan) throw new Error(`Missing plan: ${planCode}`)
|
||||||
|
|
||||||
|
result[type1][type2][currency][size] = {
|
||||||
|
price_in_cents: plan[currency] * 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = JSON.stringify(result, null, 2)
|
||||||
|
const dir = './output'
|
||||||
|
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir)
|
||||||
|
}
|
||||||
|
fs.writeFileSync(`${dir}/groups.json`, output)
|
||||||
|
|
||||||
|
console.log('Completed!')
|
191
services/web/scripts/plan-prices/package-lock.json
generated
Normal file
191
services/web/scripts/plan-prices/package-lock.json
generated
Normal file
|
@ -0,0 +1,191 @@
|
||||||
|
{
|
||||||
|
"name": "prices",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 2,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "prices",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/adler-32": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cfb": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"crc-32": "~1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/codepage": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/crc-32": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"crc32": "bin/crc32.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/frac": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ssf": {
|
||||||
|
"version": "0.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||||
|
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"frac": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wmf": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/word": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xlsx": {
|
||||||
|
"version": "0.18.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||||
|
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"cfb": "~1.2.1",
|
||||||
|
"codepage": "~1.15.0",
|
||||||
|
"crc-32": "~1.2.1",
|
||||||
|
"ssf": "~0.11.2",
|
||||||
|
"wmf": "~1.0.1",
|
||||||
|
"word": "~0.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xlsx": "bin/xlsx.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"cfb": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"crc-32": "~1.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"codepage": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"crc-32": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"frac": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"ssf": {
|
||||||
|
"version": "0.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||||
|
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"frac": "~1.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"wmf": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"word": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"xlsx": {
|
||||||
|
"version": "0.18.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||||
|
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"cfb": "~1.2.1",
|
||||||
|
"codepage": "~1.15.0",
|
||||||
|
"crc-32": "~1.2.1",
|
||||||
|
"ssf": "~0.11.2",
|
||||||
|
"wmf": "~1.0.1",
|
||||||
|
"word": "~0.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
14
services/web/scripts/plan-prices/package.json
Normal file
14
services/web/scripts/plan-prices/package.json
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"name": "prices",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
|
}
|
||||||
|
}
|
142
services/web/scripts/plan-prices/plans.js
Normal file
142
services/web/scripts/plan-prices/plans.js
Normal file
|
@ -0,0 +1,142 @@
|
||||||
|
// Creates data for localizedPlanPricing object in settings.overrides.saas.js
|
||||||
|
// and plans object in main/plans.js
|
||||||
|
|
||||||
|
const xlsx = require('xlsx')
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const [fileName, sheetName] = process.argv.slice(2)
|
||||||
|
|
||||||
|
// Pick the xlsx file
|
||||||
|
const filePath = path.resolve(__dirname, fileName)
|
||||||
|
const file = xlsx.readFile(filePath)
|
||||||
|
|
||||||
|
if (!file.SheetNames.includes(sheetName)) {
|
||||||
|
throw new Error('Sheet not found!')
|
||||||
|
}
|
||||||
|
|
||||||
|
const workSheet = Object.values(file.Sheets)[file.SheetNames.indexOf(sheetName)]
|
||||||
|
// Convert to JSON
|
||||||
|
const workSheetJSON = xlsx.utils.sheet_to_json(workSheet)
|
||||||
|
|
||||||
|
// Mapping of [output_keys]:[actual_keys]
|
||||||
|
const plansMap = {
|
||||||
|
student: 'student',
|
||||||
|
personal: 'paid-personal',
|
||||||
|
collaborator: 'collaborator',
|
||||||
|
professional: 'professional',
|
||||||
|
}
|
||||||
|
|
||||||
|
const currencies = {
|
||||||
|
USD: {
|
||||||
|
symbol: '$',
|
||||||
|
placement: 'before',
|
||||||
|
},
|
||||||
|
EUR: {
|
||||||
|
symbol: '€',
|
||||||
|
placement: 'before',
|
||||||
|
},
|
||||||
|
GBP: {
|
||||||
|
symbol: '£',
|
||||||
|
placement: 'before',
|
||||||
|
},
|
||||||
|
SEK: {
|
||||||
|
symbol: ' kr',
|
||||||
|
placement: 'after',
|
||||||
|
},
|
||||||
|
CAD: {
|
||||||
|
symbol: '$',
|
||||||
|
placement: 'before',
|
||||||
|
},
|
||||||
|
NOK: {
|
||||||
|
symbol: ' kr',
|
||||||
|
placement: 'after',
|
||||||
|
},
|
||||||
|
DKK: {
|
||||||
|
symbol: ' kr',
|
||||||
|
placement: 'after',
|
||||||
|
},
|
||||||
|
AUD: {
|
||||||
|
symbol: '$',
|
||||||
|
placement: 'before',
|
||||||
|
},
|
||||||
|
NZD: {
|
||||||
|
symbol: '$',
|
||||||
|
placement: 'before',
|
||||||
|
},
|
||||||
|
CHF: {
|
||||||
|
symbol: 'Fr ',
|
||||||
|
placement: 'before',
|
||||||
|
},
|
||||||
|
SGD: {
|
||||||
|
symbol: '$',
|
||||||
|
placement: 'before',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildCurrencyValue = (amount, currency) => {
|
||||||
|
return currency.placement === 'before'
|
||||||
|
? `${currency.symbol}${amount}`
|
||||||
|
: `${amount}${currency.symbol}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// localizedPlanPricing object for settings.overrides.saas.js
|
||||||
|
let localizedPlanPricing = {}
|
||||||
|
// plans object for main/plans.js
|
||||||
|
let plans = {}
|
||||||
|
|
||||||
|
for (const [currency, currencyDetails] of Object.entries(currencies)) {
|
||||||
|
localizedPlanPricing[currency] = {
|
||||||
|
symbol: currencyDetails.symbol.trim(),
|
||||||
|
free: {
|
||||||
|
monthly: buildCurrencyValue(0, currencyDetails),
|
||||||
|
annual: buildCurrencyValue(0, currencyDetails),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plans[currency] = {
|
||||||
|
symbol: currencyDetails.symbol.trim(),
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [outputKey, actualKey] of Object.entries(plansMap)) {
|
||||||
|
const monthlyPlan = workSheetJSON.find(data => data.plan_code === actualKey)
|
||||||
|
|
||||||
|
if (!monthlyPlan) throw new Error(`Missing plan: ${actualKey}`)
|
||||||
|
|
||||||
|
const actualKeyAnnual = `${actualKey}-annual`
|
||||||
|
const annualPlan = workSheetJSON.find(
|
||||||
|
data => data.plan_code === actualKeyAnnual
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!annualPlan) throw new Error(`Missing plan: ${actualKeyAnnual}`)
|
||||||
|
|
||||||
|
const monthly = buildCurrencyValue(monthlyPlan[currency], currencyDetails)
|
||||||
|
const monthlyTimesTwelve = buildCurrencyValue(
|
||||||
|
monthlyPlan[currency] * 12,
|
||||||
|
currencyDetails
|
||||||
|
)
|
||||||
|
const annual = buildCurrencyValue(annualPlan[currency], currencyDetails)
|
||||||
|
|
||||||
|
localizedPlanPricing[currency] = {
|
||||||
|
...localizedPlanPricing[currency],
|
||||||
|
[outputKey]: { monthly, monthlyTimesTwelve, annual },
|
||||||
|
}
|
||||||
|
plans[currency] = {
|
||||||
|
...plans[currency],
|
||||||
|
[outputKey]: { monthly, annual },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// removes quotes from object keys
|
||||||
|
const format = obj => JSON.stringify(obj, null, 2).replace(/"([^"]+)":/g, '$1:')
|
||||||
|
const dir = './output'
|
||||||
|
|
||||||
|
localizedPlanPricing = format(localizedPlanPricing)
|
||||||
|
plans = format(plans)
|
||||||
|
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir)
|
||||||
|
}
|
||||||
|
fs.writeFileSync(`${dir}/localizedPlanPricing.json`, localizedPlanPricing)
|
||||||
|
fs.writeFileSync(`${dir}/plans.json`, plans)
|
||||||
|
|
||||||
|
console.log('Completed!')
|
Loading…
Reference in a new issue