├── .stylelintignore ├── .npmrc ├── .eslintignore ├── .travis.yml ├── .stylelintrc.js ├── .prettierignore ├── server ├── index.js ├── handlers │ ├── index.js │ ├── client.js │ └── mutations │ │ ├── get-one-time-url.js │ │ └── get-subscription-url.js ├── redis-store.js ├── shopify-theme-templates │ ├── mb-topbar.liquid │ ├── memberbenefits.js │ └── mb-hero.liquid └── server.js ├── .env.example ├── .dependabot └── config.yml ├── .env.demo ├── .eslintrc.js ├── .editorconfig ├── next.config.js ├── components ├── FeedbackToast.js ├── MembershipForm.js └── LockMultiAdder.js ├── LICENSE.md ├── README.md ├── pages ├── _app.js └── index.js ├── .gitignore └── package.json /.stylelintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | @shopify:registry=https://registry.yarnpkg.com 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | coverage 4 | 5 | 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | script: 3 | - npm run build 4 | node_js: 5 | - '10' 6 | -------------------------------------------------------------------------------- /.stylelintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['stylelint-config-shopify/prettier'], 3 | }; 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | node_modules 3 | build 4 | coverage 5 | .sewing-kit 6 | *.svg 7 | app/types/graphql.ts 8 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | require('@babel/register')({ 2 | presets: ['@babel/preset-env'], 3 | ignore: ['node_modules'] 4 | }); 5 | 6 | // Import the rest of our application. 7 | module.exports = require('./server.js'); 8 | -------------------------------------------------------------------------------- /server/handlers/index.js: -------------------------------------------------------------------------------- 1 | import { createClient } from "./client"; 2 | import { getOneTimeUrl } from "./mutations/get-one-time-url"; 3 | import { getSubscriptionUrl } from "./mutations/get-subscription-url"; 4 | 5 | export { createClient, getOneTimeUrl, getSubscriptionUrl }; 6 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | SHOPIFY_API_KEY= 2 | SHOPIFY_API_SECRET= 3 | HOST=https://your-app-domain.com 4 | SHOP=your-shop.myshopify.com 5 | SCOPES=read_script_tags,write_script_tags,read_themes,write_themes,read_price_rules,read_discounts 6 | WEB3_PROVIDER_MAINNET= 7 | WEB3_PROVIDER_POLYGON= 8 | WEB3_PROVIDER_OPTIMISM= 9 | WEB3_PROVIDER_XDAI= 10 | WEB3_PROVIDER_BSC= 11 | -------------------------------------------------------------------------------- /.dependabot/config.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | update_configs: 3 | # Keep package.json up to date as soon as 4 | # new versions are published to the npm registry 5 | - package_manager: "javascript" 6 | directory: "/" 7 | update_schedule: "weekly" 8 | default_reviewers: 9 | - "Shopify/platform-dev-tools-education" 10 | version_requirement_updates: "auto" 11 | -------------------------------------------------------------------------------- /.env.demo: -------------------------------------------------------------------------------- 1 | SHOPIFY_API_KEY= 2 | SHOPIFY_API_SECRET= 3 | SHOP= 4 | SCOPES=read_script_tags,write_script_tags,read_themes,write_themes,read_price_rules,read_discounts 5 | HOST= 6 | WEB3_PROVIDER_MAINNET= 7 | WEB3_PROVIDER_POLYGON= 8 | WEB3_PROVIDER_OPTIMISM= 9 | WEB3_PROVIDER_XDAI= 10 | WEB3_PROVIDER_BSC= 11 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'plugin:shopify/react', 4 | 'plugin:shopify/polaris', 5 | 'plugin:shopify/jest', 6 | 'plugin:shopify/webpack', 7 | ], 8 | rules: { 9 | 'import/no-unresolved': 'off', 10 | }, 11 | overrides: [ 12 | { 13 | files: ['*.test.*'], 14 | rules: { 15 | 'shopify/jsx-no-hardcoded-content': 'off', 16 | }, 17 | }, 18 | ], 19 | }; 20 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | # Markdown syntax specifies that trailing whitespaces can be meaningful, 12 | # so let’s not trim those. e.g. 2 trailing spaces = linebreak (
) 13 | # See https://daringfireball.net/projects/markdown/syntax#p 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /server/handlers/client.js: -------------------------------------------------------------------------------- 1 | import ApolloClient from "apollo-boost"; 2 | 3 | export const createClient = (shop, accessToken) => { 4 | return new ApolloClient({ 5 | uri: `https://${shop}/admin/api/2019-10/graphql.json`, 6 | request: operation => { 7 | operation.setContext({ 8 | headers: { 9 | "X-Shopify-Access-Token": accessToken, 10 | "User-Agent": `shopify-app-node ${ 11 | process.env.npm_package_version 12 | } | Shopify App CLI` 13 | } 14 | }); 15 | } 16 | }); 17 | }; 18 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const { parsed: localEnv } = require("dotenv").config(); 2 | 3 | const webpack = require("webpack"); 4 | const apiKey = JSON.stringify(process.env.SHOPIFY_API_KEY); 5 | 6 | module.exports = { 7 | webpack: (config) => { 8 | const env = { API_KEY: apiKey }; 9 | config.plugins.push(new webpack.DefinePlugin(env)); 10 | 11 | // Add ESM support for .mjs files in webpack 4 12 | config.module.rules.push({ 13 | test: /\.mjs$/, 14 | include: /node_modules/, 15 | type: "javascript/auto", 16 | }); 17 | 18 | return config; 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /components/FeedbackToast.js: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from "react"; 2 | import { Frame, Toast } from "@shopify/polaris"; 3 | 4 | export default function FeedbackToast({ message }) { 5 | const [isToastActive, setIsToastActive] = useState(true); 6 | 7 | const toggleToast = useCallback( 8 | () => setIsToastActive((isToastActive) => !isToastActive), 9 | [] 10 | ); 11 | 12 | const toastMarkup = isToastActive ? ( 13 | 14 | ) : null; 15 | 16 | return ( 17 |
18 | {toastMarkup} 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /server/handlers/mutations/get-one-time-url.js: -------------------------------------------------------------------------------- 1 | import "isomorphic-fetch"; 2 | import { gql } from "apollo-boost"; 3 | 4 | export function ONETIME_CREATE(url) { 5 | return gql` 6 | mutation { 7 | appPurchaseOneTimeCreate( 8 | name: "test" 9 | price: { amount: 10, currencyCode: USD } 10 | returnUrl: "${url}" 11 | test: true 12 | ) { 13 | userErrors { 14 | field 15 | message 16 | } 17 | confirmationUrl 18 | appPurchaseOneTime { 19 | id 20 | } 21 | } 22 | } 23 | `; 24 | } 25 | 26 | export const getOneTimeUrl = async (ctx) => { 27 | const { client } = ctx; 28 | const confirmationUrl = await client 29 | .mutate({ 30 | mutation: ONETIME_CREATE(process.env.HOST), 31 | }) 32 | .then((response) => response.data.appPurchaseOneTimeCreate.confirmationUrl); 33 | return ctx.redirect(confirmationUrl); 34 | }; 35 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /server/handlers/mutations/get-subscription-url.js: -------------------------------------------------------------------------------- 1 | import "isomorphic-fetch"; 2 | import { gql } from "apollo-boost"; 3 | 4 | export function RECURRING_CREATE(url) { 5 | return gql` 6 | mutation { 7 | appSubscriptionCreate( 8 | name: "Super Duper Plan" 9 | returnUrl: "${url}" 10 | test: true 11 | lineItems: [ 12 | { 13 | plan: { 14 | appUsagePricingDetails: { 15 | cappedAmount: { amount: 10, currencyCode: USD } 16 | terms: "$1 for 1000 emails" 17 | } 18 | } 19 | } 20 | { 21 | plan: { 22 | appRecurringPricingDetails: { 23 | price: { amount: 10, currencyCode: USD } 24 | } 25 | } 26 | } 27 | ] 28 | ) { 29 | userErrors { 30 | field 31 | message 32 | } 33 | confirmationUrl 34 | appSubscription { 35 | id 36 | } 37 | } 38 | }`; 39 | } 40 | 41 | export const getSubscriptionUrl = async ctx => { 42 | const { client } = ctx; 43 | const confirmationUrl = await client 44 | .mutate({ 45 | mutation: RECURRING_CREATE(process.env.HOST) 46 | }) 47 | .then(response => response.data.appSubscriptionCreate.confirmationUrl); 48 | 49 | return ctx.redirect(confirmationUrl); 50 | }; 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Unlock Shopify App 2 | 3 | [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE.md) 4 | 5 | This app for Shopify stores allows merchants to offer special memberships to their customers via [Unlock Protocol](https://github.com/unlock-protocol). 6 | 7 | Merchants can [create Unlock Protocol locks](https://app.unlock-protocol.com/dashboard) in the Creator Dashboard. 8 | 9 | Customers may then acquire keys for such locks, turning them into members, automatically unlocking the benefits to them (key ownership is proof of membership). 10 | 11 | Boilerplate based on [Shopify-App-CLI](https://github.com/Shopify/shopify-app-cli): an embedded Shopify app made with Node, [Next.js](https://nextjs.org/), [Shopify-koa-auth](https://github.com/Shopify/quilt/tree/master/packages/koa-shopify-auth), [Polaris](https://github.com/Shopify/polaris-react), and [App Bridge React](https://shopify.dev/tools/app-bridge/react-components). 12 | 13 | ## Installation 14 | 15 | run: 16 | 17 | ```sh 18 | ~/ $ shopify create project APP_NAME 19 | ``` 20 | 21 | **Note:** Shopify merchants may find it easier to use the _free app_ from the Shopify App Store (coming soon). 22 | 23 | ## Requirements 24 | 25 | - If you don’t have one, [create a Shopify partner account](https://partners.shopify.com/signup). 26 | - If you don’t have one, [create a Development store](https://help.shopify.com/en/partners/dashboard/development-stores#create-a-development-store) where you can install and test your app. 27 | - In the Shopify Partner dashboard, [create a new app](https://help.shopify.com/en/api/tools/partner-dashboard/your-apps#create-a-new-app). You’ll need this app’s API credentials during the setup process. 28 | 29 | ## Features 30 | 31 | - A Shopify merchant adds one **Lock** (or more, once supported) to their online store, and assigns a benefit to each. 32 | - Customers can see the possible benefits associated with locks/memberships – for example 10% discount, or free shipping. An example code snippet can be copy-pasted into a template file of the Shopify theme. 33 | - Customers with a fitting **Key** get their benefit applied. At the moment, the only supported benefits of the type _discount code_ are supported. 34 | 35 | ## License 36 | 37 | This respository is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 38 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import ApolloClient from "apollo-boost"; 2 | import { ApolloProvider } from "react-apollo"; 3 | import App from "next/app"; 4 | import { AppProvider } from "@shopify/polaris"; 5 | import { Provider, useAppBridge } from "@shopify/app-bridge-react"; 6 | import { authenticatedFetch } from "@shopify/app-bridge-utils"; 7 | import { Redirect } from "@shopify/app-bridge/actions"; 8 | import "@shopify/polaris/dist/styles.css"; 9 | import translations from "@shopify/polaris/locales/en.json"; 10 | 11 | function userLoggedInFetch(app) { 12 | const fetchFunction = authenticatedFetch(app); 13 | 14 | return async (uri, options) => { 15 | const response = await fetchFunction(uri, options); 16 | 17 | if ( 18 | response.headers.get("X-Shopify-API-Request-Failure-Reauthorize") === "1" 19 | ) { 20 | const authUrlHeader = response.headers.get( 21 | "X-Shopify-API-Request-Failure-Reauthorize-Url" 22 | ); 23 | 24 | const redirect = Redirect.create(app); 25 | redirect.dispatch(Redirect.Action.APP, authUrlHeader || `/auth`); 26 | return null; 27 | } 28 | 29 | return response; 30 | }; 31 | } 32 | 33 | function MyProvider(props) { 34 | const app = useAppBridge(); 35 | 36 | const client = new ApolloClient({ 37 | fetch: userLoggedInFetch(app), 38 | fetchOptions: { 39 | credentials: "include", 40 | }, 41 | }); 42 | 43 | const Component = props.Component; 44 | 45 | return ( 46 | 47 | 48 | 49 | ); 50 | } 51 | 52 | class MyApp extends App { 53 | render() { 54 | const { Component, pageProps, shopOrigin } = this.props; 55 | const hostBuffer = Buffer.from(`${shopOrigin}/admin`, "utf-8"); 56 | const host = hostBuffer.toString("base64"); 57 | return ( 58 | 59 | 67 | 68 | 69 | 70 | ); 71 | } 72 | } 73 | 74 | MyApp.getInitialProps = async ({ ctx }) => { 75 | return { 76 | shopOrigin: ctx.query.shop, 77 | }; 78 | }; 79 | 80 | export default MyApp; 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # Ignore Apple macOS Desktop Services Store 107 | .DS_Store 108 | 109 | # ngrok tunnel file 110 | config/tunnel.pid 111 | 112 | # next build output 113 | .next/ 114 | 115 | # Shopify App CLI config 116 | .shopify-cli.yml 117 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "unlock-shopify-app", 3 | "version": "0.0.1", 4 | "description": "Unlock Protocol integration for Shopify stores", 5 | "scripts": { 6 | "test": "jest", 7 | "dev": "cross-env NODE_ENV=development nodemon ./server/index.js --watch ./server/index.js", 8 | "build": "NEXT_TELEMETRY_DISABLED=1 next build", 9 | "start": "cross-env NODE_ENV=production node ./server/index.js" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/pwagner/unlock-shopify-app.git" 14 | }, 15 | "author": "pwagner", 16 | "license": "MIT", 17 | "bugs": { 18 | "url": "https://github.com/pwagner/unlock-shopify-app/issues" 19 | }, 20 | "dependencies": { 21 | "@babel/core": "7.12.10", 22 | "@babel/polyfill": "^7.6.0", 23 | "@babel/preset-env": "^7.12.11", 24 | "@babel/register": "^7.12.10", 25 | "@shopify/app-bridge-react": "2.0.2", 26 | "@shopify/app-bridge-utils": "2.0.2", 27 | "@shopify/koa-shopify-auth": "^4.1.2", 28 | "@shopify/polaris": "^6.2.0", 29 | "apollo-boost": "^0.4.9", 30 | "cross-env": "^7.0.3", 31 | "dotenv": "^8.2.0", 32 | "ethers": "^5.5.2", 33 | "graphql": "^14.5.8", 34 | "isomorphic-fetch": "^3.0.0", 35 | "koa": "^2.13.1", 36 | "koa-body-parser": "^1.1.2", 37 | "koa-router": "^10.0.0", 38 | "koa-session": "^6.1.0", 39 | "lodash": "^4.17.21", 40 | "next": "^10.0.4", 41 | "next-env": "^1.1.0", 42 | "node-fetch": "^2.6.1", 43 | "react": "^16.10.1", 44 | "react-apollo": "^3.1.3", 45 | "react-dom": "^16.10.1", 46 | "redis": "^3.1.2", 47 | "uid": "^2.0.0", 48 | "web3": "^1.6.1", 49 | "webpack": "^4.44.1" 50 | }, 51 | "devDependencies": { 52 | "@babel/plugin-transform-runtime": "^7.12.10", 53 | "@babel/preset-stage-3": "^7.0.0", 54 | "babel-jest": "26.6.3", 55 | "babel-register": "^6.26.0", 56 | "enzyme": "3.11.0", 57 | "enzyme-adapter-react-16": "1.15.5", 58 | "husky": "^4.3.6", 59 | "jest": "26.6.3", 60 | "lint-staged": "^10.5.3", 61 | "nodemon": "^2.0.0", 62 | "prettier": "2.2.1", 63 | "react-addons-test-utils": "15.6.2", 64 | "react-test-renderer": "16.14.0" 65 | }, 66 | "husky": { 67 | "hooks": { 68 | "pre-commit": "lint-staged" 69 | } 70 | }, 71 | "lint-staged": { 72 | "*.{js,css,json,md}": [ 73 | "prettier --write" 74 | ] 75 | }, 76 | "engines": { 77 | "node": "16.13.0" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /server/redis-store.js: -------------------------------------------------------------------------------- 1 | // Based on https://github.com/Shopify/shopify-node-api/blob/main/docs/usage/customsessions.md#create-a-redisstore-class 2 | // Import the Session type from the library, along with the Node redis package, and `promisify` from Node 3 | import redis from "redis"; 4 | import { promisify } from "util"; 5 | class RedisStore { 6 | constructor(host, port) { 7 | /* 8 | The storeCallback takes in the Session, and sets a stringified version of it on the redis store 9 | This callback is used for BOTH saving new Sessions and updating existing Sessions. 10 | If the session can be stored, return true 11 | Otherwise, return false 12 | */ 13 | this.storeCallback = async (session) => { 14 | try { 15 | // Inside our try, we use the `setAsync` method to save our session. 16 | // This method returns a boolean (true is successful, false if not) 17 | return await this.setAsync(session.id, JSON.stringify(session)); 18 | } catch (err) { 19 | // throw errors, and handle them gracefully in your application 20 | throw new Error(err); 21 | } 22 | }; 23 | /* 24 | The loadCallback takes in the id, and uses the getAsync method to access the session data 25 | If a stored session exists, it's parsed and returned 26 | Otherwise, return undefined 27 | */ 28 | this.loadCallback = async (id) => { 29 | try { 30 | // Inside our try, we use `getAsync` to access the method by id 31 | // If we receive data back, we parse and return it 32 | // If not, we return `undefined` 33 | let reply = await this.getAsync(id); 34 | if (reply) { 35 | return JSON.parse(reply); 36 | } else { 37 | return undefined; 38 | } 39 | } catch (err) { 40 | throw new Error(err); 41 | } 42 | }; 43 | /* 44 | The deleteCallback takes in the id, and uses the redis `del` method to delete it from the store 45 | If the session can be deleted, return true 46 | Otherwise, return false 47 | */ 48 | this.deleteCallback = async (id) => { 49 | try { 50 | // Inside our try, we use the `delAsync` method to delete our session. 51 | // This method returns a boolean (true is successful, false if not) 52 | return await this.delAsync(id); 53 | } catch (err) { 54 | throw new Error(err); 55 | } 56 | }; 57 | // Create a new redis client 58 | this.client = redis.createClient(port, host); 59 | // Use Node's `promisify` to have redis return a promise from the client methods 60 | this.getAsync = promisify(this.client.get).bind(this.client); 61 | this.setAsync = promisify(this.client.set).bind(this.client); 62 | this.delAsync = promisify(this.client.del).bind(this.client); 63 | } 64 | } 65 | // Export the class 66 | export default RedisStore; 67 | -------------------------------------------------------------------------------- /components/MembershipForm.js: -------------------------------------------------------------------------------- 1 | import React, { useState, forwardRef, useCallback } from "react"; 2 | import { 3 | Form, 4 | FormLayout, 5 | Stack, 6 | Button, 7 | Card, 8 | Select, 9 | Checkbox, 10 | Heading, 11 | Icon, 12 | InlineError, 13 | } from "@shopify/polaris"; 14 | import { DeleteMinor } from "@shopify/polaris-icons"; 15 | 16 | import { LockMultiAdder } from "./LockMultiAdder"; 17 | 18 | const MembershipForm = forwardRef( 19 | ( 20 | { 21 | id, 22 | value, 23 | discounts, 24 | index, 25 | onSave, 26 | onDelete, 27 | isLoading, 28 | otherMembershipLockAddresses, 29 | formErrorMessage, 30 | }, 31 | ref 32 | ) => { 33 | return ( 34 | 35 |
36 | {value.lockName} 37 |
38 | 39 | 45 | 46 |
47 |
48 | 49 | 54 | 59 | 60 | 65 | 66 | 71 | 72 | 76 | 77 | 78 | 79 | 82 | 83 | 84 |
85 |
86 | ); 87 | } 88 | ); 89 | 90 | const LockBenefitSelect = ({ name, discounts, defaultValue }) => { 91 | if (!discounts) return; 92 | const [selected, setSelected] = useState(defaultValue); 93 | const handleSelectChange = useCallback((value) => setSelected(value), []); 94 | const options = [{ label: "-- Select Discount --", value: "" }]; 95 | discounts.map((code) => { 96 | options.push({ label: code, value: code }); 97 | }); 98 | 99 | return ( 100 | 113 | 114 | 115 | Unlock Protocol Locks: 116 | {selectedTagMarkup} 117 | 126 | Add 127 | 128 | } 129 | helpText="Enter the smart contract address (Ethereum, BSC, Polygon, Gnosis, Optimism)." 130 | label="Address" 131 | prefix="Address:" 132 | labelHidden 133 | /> 134 | 135 | {isDuplicateAddress && ( 136 | 140 | )} 141 | {!isValidAddress && ( 142 | 146 | )} 147 | {unlockDashboardLinkMarkup} 148 | 149 | ); 150 | }; 151 | 152 | export { LockMultiAdder }; 153 | -------------------------------------------------------------------------------- /server/shopify-theme-templates/mb-topbar.liquid: -------------------------------------------------------------------------------- 1 | {% style %} 2 | 3 | .membership-validity { 4 | font-size: 9px; 5 | text-transform: uppercase; 6 | padding: 3px; 7 | background: lightgrey; 8 | color: white; 9 | font-weight: bold; 10 | border-radius: 5px; 11 | } 12 | 13 | .mb-topbar { 14 | background-color: red; 15 | position: fixed; 16 | top: 0; 17 | width: 100%; 18 | padding: 0.5em; 19 | border-bottom: 1px solid grey; 20 | text-align: center; 21 | z-index: 2; 22 | } 23 | 24 | .mb-topbar-content { 25 | font-size: {{ section.settings.font_size }}px; 26 | font-family: {{ section.settings.font.family }}; 27 | font-style: {{ section.settings.font.style }}; 28 | font-weight: {{ section.settings.font.weight }}; 29 | } 30 | 31 | .mb-topbar-content p, .mb-topbar-content p a { 32 | color: {{ section.settings.custom_color }}; 33 | display: inline-block; 34 | margin: 0.5em 0; 35 | } 36 | 37 | .mb-topbar-content a.btn { 38 | margin-left: 8px; 39 | } 40 | 41 | 42 | 43 | 44 | 45 | 46 | .mb-modal { 47 | position: fixed; 48 | top: 220px; 49 | left: 50%; 50 | transform: translate(-50%, -50%) scale(0); 51 | transition: 200ms ease-in-out; 52 | border: 1px solid black; 53 | border-radius: 10px; 54 | z-index: 2; 55 | background-color: white; 56 | width: 500px; 57 | max-width: 80%; 58 | } 59 | 60 | .mb-modal.active { 61 | transform: translate(-50%, -50%) scale(1); 62 | } 63 | 64 | .mb-modal-header { 65 | padding: 10px 15px; 66 | display: flex; 67 | justify-content: space-between; 68 | align-items: center; 69 | border-bottom: 1px solid black; 70 | } 71 | 72 | .mb-modal-header .title { 73 | font-size: 1.25rem; 74 | font-weight: bold; 75 | } 76 | 77 | .mb-modal-header .close-button { 78 | cursor: pointer; 79 | border: none; 80 | outline: none; 81 | background: none; 82 | font-size: 1.25rem; 83 | font-weight: bold; 84 | } 85 | 86 | .mb-modal-body { 87 | padding: 10px 15px; 88 | } 89 | 90 | #overlay { 91 | position: fixed; 92 | opacity: 0; 93 | transition: 200ms ease-in-out; 94 | top: 0; 95 | left: 0; 96 | right: 0; 97 | bottom: 0; 98 | background-color: rgba(0, 0, 0, .5); 99 | pointer-events: none; 100 | } 101 | 102 | #overlay.active { 103 | opacity: 1; 104 | pointer-events: all; 105 | } 106 | 107 | 108 | .table-memberships { 109 | margin-top: 2em; 110 | } 111 | 112 | #btn-connect, #btn-disconnect { 113 | margin: 1em auto; 114 | display: block; 115 | } 116 | 117 | #selected-account { 118 | font-family: Courier,monospace; 119 | font-size: 12px; 120 | } 121 | 122 | {% endstyle %} 123 | 124 | 125 | {% capture on_click_script %} 126 | window.showMemberBenefitsModal([ 127 | {% for block in section.blocks %} 128 | {% case block.type %} 129 | {% when 'lock' %} 130 | { 131 | name: "{{ block.settings.membership }}", 132 | locks: window.locksByMembershipName["{{ block.settings.membership }}"] 133 | } 134 | {% if forloop.last != true %},{% endif %} 135 | {% endcase %} 136 | {% endfor %} 137 | ], {{ section.settings.enable_open_new_tab }}) 138 | {% endcapture %} 139 |
145 |
146 | {{ section.settings.text }} 147 | 148 | {{ section.settings.button_text }} 149 | 150 |
151 | 152 |
153 | 154 | {% schema %} 155 | { 156 | "name": "MB|Top-bar", 157 | "settings": [ 158 | { 159 | "type": "checkbox", 160 | "id": "enable_hide_unlocked", 161 | "label": "Hidden if unlocked", 162 | "info": "If enabled, the top-bar will not be displayed to members.", 163 | "default": true 164 | }, 165 | { 166 | "type": "checkbox", 167 | "id": "enable_open_new_tab", 168 | "label": "Open in new tab", 169 | "info": "If unchecked, the redirect happens in the same browser tab", 170 | "default": false 171 | }, 172 | { 173 | "type": "richtext", 174 | "id": "text", 175 | "label": "Content", 176 | "default": "

Member Benefits

" 177 | }, 178 | { 179 | "type": "text", 180 | "id": "button_text", 181 | "label": "Button text", 182 | "default": "Unlock" 183 | }, 184 | { 185 | "type": "font_picker", 186 | "id": "font", 187 | "label": "Font", 188 | "default": "helvetica_n4" 189 | }, 190 | { 191 | "type": "range", 192 | "id": "font_size", 193 | "min": 7, 194 | "max": 64, 195 | "step": 1, 196 | "unit": "px", 197 | "label": "Font size", 198 | "default": 14 199 | }, 200 | { 201 | "type": "color", 202 | "id": "custom_color", 203 | "label": "Font color", 204 | "default": "#ffffff" 205 | }, 206 | { 207 | "type": "color", 208 | "id": "custom_background_color", 209 | "label": "Background color", 210 | "default": "#000000" 211 | } 212 | ], 213 | "blocks": [ 214 | { 215 | "type": "lock", 216 | "name": "Membership", 217 | "settings": [ 218 | { 219 | "type": "select", 220 | "id": "membership", 221 | "label": "Membership", 222 | "options": __MEMBERSHIP_SECTION_SETTING_OPTIONS__, 223 | "info": "Select memberships for this section" 224 | } 225 | ] 226 | } 227 | ], 228 | "presets": [ 229 | { 230 | "name": "MB|Top-bar", 231 | "category": { 232 | "en": "Promotional" 233 | }, 234 | "blocks": [ 235 | { 236 | "type": "lock", 237 | "settings": { 238 | "membership": __LOCK_VALUES__ 239 | } 240 | } 241 | ] 242 | } 243 | ] 244 | } 245 | {% endschema %} 246 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Page, 4 | Layout, 5 | TextField, 6 | Form, 7 | FormLayout, 8 | Stack, 9 | Button, 10 | InlineError, 11 | Card, 12 | Link, 13 | Banner, 14 | DisplayText, 15 | } from "@shopify/polaris"; 16 | import { TitleBar, Context } from "@shopify/app-bridge-react"; 17 | import { authenticatedFetch } from "@shopify/app-bridge-utils"; 18 | import { Redirect } from "@shopify/app-bridge/actions"; 19 | import MembershipForm from "../components/MembershipForm"; 20 | import FeedbackToast from "../components/FeedbackToast"; 21 | 22 | class Index extends React.Component { 23 | static contextType = Context; 24 | static fetchWithAuth; // for authenticated requests to Shopify Admin API via app backend 25 | static redirect; 26 | 27 | constructor(props) { 28 | super(props); 29 | this.state = { 30 | memberships: [], 31 | discounts: [], 32 | newMembershipName: "", 33 | newMembershipNameError: false, 34 | isContinueing: false, 35 | isLoading: false, 36 | isAddingMembership: false, 37 | hasLoadedLocks: false, 38 | formErrorMessage: "", 39 | hasSavingFeedback: false, 40 | hasSavingError: false, 41 | hasDeletionFeedback: false, 42 | hasDeletionError: false, 43 | }; 44 | } 45 | 46 | componentDidMount() { 47 | const app = this.context; 48 | this.redirect = Redirect.create(app); 49 | this.fetchWithAuth = authenticatedFetch(app); 50 | this.loadMemberships(); 51 | } 52 | 53 | render() { 54 | return ( 55 | 56 | 57 | 58 | 62 |
63 | Memberships: 64 |
65 | 66 | {!this.state.isAddingMembership && 67 | this.state.hasLoadedLocks && 68 | this.state.memberships.length === 0 ? ( 69 |
70 | Click the button to add your first membership. 71 |
72 | ) : ( 73 | this.state.memberships.map((value, index) => ( 74 | { 85 | if (!lockAddresses || lockAddresses.length < 1) 86 | return acc; 87 | 88 | lockAddresses.map((addr) => acc.push(addr)); 89 | 90 | return acc; 91 | }, 92 | [] 93 | )} 94 | /> 95 | )) 96 | )} 97 | 98 | {this.state.hasSavingFeedback ? ( 99 | 100 | ) : null} 101 | {this.state.hasSavingError ? ( 102 | 103 | ) : null} 104 | {this.state.hasDeletionFeedback ? ( 105 | 106 | ) : null} 107 | {this.state.hasDeletionError ? ( 108 | 109 | ) : null} 110 | 111 | {this.state.hasLoadedLocks ? ( 112 | this.state.isAddingMembership ? ( 113 | 114 |
115 |

New Membership

116 |
117 | 118 | 121 | 122 |
123 |
124 | 125 | {this.state.newMembershipNameError && ( 126 | 130 | )} 131 | 140 | 141 | 152 | 153 | 154 |
155 |
156 | ) : ( 157 |
158 | 165 |
166 | ) 167 | ) : ( 168 | Loading, please wait… 169 | )} 170 |
171 | 172 | {this.state.memberships.length > 0 && ( 173 | 177 | 178 |

179 | Add sections to your theme in your Online Store settings under{" "} 180 | Themes: Customize{" "} 181 |
182 | You'll find the "MB|"-sections on the bottom after clicking on 183 | "see more". 184 |

185 |
186 |
187 | )} 188 |
189 | 190 | 191 |
192 | ); 193 | } 194 | 195 | handleThemeClick = () => { 196 | this.redirect.dispatch(Redirect.Action.ADMIN_PATH, "/themes"); 197 | }; 198 | 199 | handleReset = async () => { 200 | try { 201 | console.log("Reset initiated"); 202 | const resetRes = await this.fetchWithAuth(`/api/reset`, { 203 | method: "GET", 204 | }); 205 | const response = await resetRes.json(); 206 | console.log("Deleted app resources", response); 207 | await this.loadMemberships(); 208 | } catch (err) { 209 | console.log("Error in reset", err); 210 | } 211 | }; 212 | 213 | loadMemberships = async () => { 214 | try { 215 | const lockRes = await this.fetchWithAuth(`/api/memberships`, { 216 | method: "GET", 217 | }); 218 | const response = await lockRes.json(); 219 | if (response.errors || !response.data) { 220 | throw `Error in metafield response: ${JSON.stringify(response)}`; 221 | } 222 | this.setState({ 223 | memberships: response.data.memberships, 224 | discounts: response.data.discounts, 225 | hasLoadedLocks: true, 226 | }); 227 | } catch (err) { 228 | console.log("Error in loadMemberships", err); 229 | } 230 | }; 231 | 232 | handleContinue = async () => { 233 | this.setState({ isContinueing: true }); 234 | try { 235 | const saveRes = await this.fetchWithAuth("/api/addMembership", { 236 | method: "POST", 237 | body: JSON.stringify({ 238 | lockName: this.state.newMembershipName, 239 | }), 240 | }); 241 | const result = await saveRes.json(); 242 | 243 | if (result.status !== "success" || !result.data) { 244 | throw result.errors; 245 | } 246 | 247 | const { metafieldId } = result.data; 248 | this.setState({ 249 | memberships: [ 250 | ...this.state.memberships, 251 | { 252 | metafieldId, 253 | lockName: this.state.newMembershipName, 254 | }, 255 | ], 256 | }); 257 | this.setState({ 258 | newMembershipName: "", 259 | isAddingMembership: false, 260 | isContinueing: false, 261 | }); 262 | } catch (err) { 263 | this.setState({ isContinueing: false }); 264 | console.log("Error in handleContinue:", err); 265 | } 266 | }; 267 | 268 | handleSaveMembership = async (e) => { 269 | this.setState({ 270 | formErrorMessage: "", 271 | isLoading: true, 272 | hasSavingFeedback: false, 273 | hasSavingError: false, 274 | }); 275 | try { 276 | const otherMemberships = this.state.memberships 277 | .filter( 278 | ({ metafieldId }) => 279 | metafieldId != e.target.elements.metafieldId.value 280 | ) 281 | .map(({ lockName, lockAddresses, discountId }) => ({ 282 | lockName, 283 | lockAddresses, 284 | discountId, 285 | })); 286 | const metafieldId = e.target.elements.metafieldId.value; 287 | const lockName = e.target.elements.lockName.value; 288 | const lockAddresses = JSON.parse(e.target.elements.lockAddresses.value); 289 | 290 | if (lockAddresses.length < 1) { 291 | this.setState({ 292 | formErrorMessage: "Please add at least one lock!", 293 | isLoading: false, 294 | }); 295 | return; 296 | } 297 | 298 | const isEnabled = e.target.elements.enabled.checked; 299 | const discountId = e.target.elements.discountId.value; 300 | 301 | if (!discountId) { 302 | this.setState({ 303 | formErrorMessage: "Please select a discount!", 304 | isLoading: false, 305 | }); 306 | 307 | return; 308 | } 309 | 310 | const membershipDetails = { 311 | lockAddresses, 312 | lockName, 313 | isEnabled, 314 | discountId, 315 | metafieldId, 316 | otherMemberships, 317 | }; 318 | 319 | const saveRes = await this.fetchWithAuth("/api/saveMembership", { 320 | method: "POST", 321 | body: JSON.stringify(membershipDetails), 322 | }); 323 | const result = await saveRes.json(); 324 | if (result.status !== "success" || !result.data) { 325 | throw result.errors; 326 | } 327 | // console.log("Saved lock"); 328 | await this.loadMemberships(); 329 | } catch (err) { 330 | console.log("Error trying to save membership:", err); 331 | this.setState({ isLoading: false, hasSavingError: true }); 332 | } 333 | 334 | this.setState({ isLoading: false, hasSavingFeedback: true }); 335 | }; 336 | 337 | handleDeleteMembership = async (name, metafieldId) => { 338 | this.setState({ 339 | hasDeletionFeedback: false, 340 | hasDeletionError: false, 341 | }); 342 | 343 | try { 344 | const saveRes = await this.fetchWithAuth("/api/removeMembership", { 345 | method: "POST", 346 | body: JSON.stringify({ 347 | metafieldId, 348 | }), 349 | }); 350 | const result = await saveRes.json(); 351 | if (!result || result.status !== "success") { 352 | throw result.errors; 353 | } 354 | this.setState({ 355 | memberships: this.state.memberships.filter( 356 | ({ lockName }) => lockName !== name 357 | ), 358 | hasDeletionFeedback: true, 359 | }); 360 | } catch (err) { 361 | console.log("Error trying to delete membership:", err); 362 | this.setState({ 363 | hasDeletionError: true, 364 | }); 365 | } 366 | }; 367 | 368 | validateMembershipName = (name) => { 369 | this.setState({ newMembershipName: name }); 370 | if (name.length > 0) return; 371 | 372 | this.setState({ newMembershipNameError: false }); 373 | }; 374 | 375 | addMembership = () => { 376 | this.setState({ isAddingMembership: true }); 377 | }; 378 | 379 | cancelAddMembership = () => { 380 | this.setState({ isAddingMembership: false }); 381 | }; 382 | } 383 | 384 | export default Index; 385 | -------------------------------------------------------------------------------- /server/shopify-theme-templates/memberbenefits.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | let selectedAccount, 3 | unpkgScriptsLoaded = 0; 4 | 5 | window.locksByMembershipName = __LOCKS_BY_NAME__; 6 | const discountCodesByLockAddresses = __DISCOUNT_CODE_BY_LOCK_ADDRESS__; 7 | 8 | // Helper funciton to load scripts from unpkg 9 | window.load_script = window.load_script || { 10 | scripts: [], 11 | index: -1, 12 | loading: false, 13 | next: function () { 14 | if (window.load_script.loading) return; 15 | 16 | // Load the next queue item 17 | window.load_script.loading = true; 18 | var item = window.load_script.scripts[++window.load_script.index]; 19 | var head = document.getElementsByTagName("head")[0]; 20 | var script = document.createElement("script"); 21 | script.type = "text/javascript"; 22 | script.src = item.src; 23 | // When complete, start next item in queue and resolve this item's promise 24 | script.onload = () => { 25 | window.load_script.loading = false; 26 | if (window.load_script.index < window.load_script.scripts.length - 1) 27 | window.load_script.next(); 28 | item.resolve(); 29 | }; 30 | head.appendChild(script); 31 | }, 32 | }; 33 | 34 | loadScripts = function (src) { 35 | if (src) { 36 | // Check if already added 37 | for (var i = 0; i < window.load_script.scripts.length; i++) { 38 | if (window.load_script.scripts[i].src == src) 39 | return window.load_script.scripts[i].promise; 40 | } 41 | // Add to the queue 42 | var item = { src: src }; 43 | item.promise = new Promise((resolve) => { 44 | item.resolve = resolve; 45 | }); 46 | window.load_script.scripts.push(item); 47 | window.load_script.next(); 48 | } 49 | 50 | // Return the promise of the last queue item 51 | return window.load_script.scripts[window.load_script.scripts.length - 1] 52 | .promise; 53 | }; 54 | 55 | function simulateToggle(elem) { 56 | var evt = new MouseEvent("toggle", { 57 | bubbles: true, 58 | cancelable: true, 59 | view: window, 60 | }); 61 | elem.dispatchEvent(evt); 62 | } 63 | 64 | function simulateClick(elem) { 65 | var evt = new MouseEvent("click", { 66 | bubbles: true, 67 | cancelable: true, 68 | view: window, 69 | }); 70 | elem.dispatchEvent(evt); 71 | } 72 | 73 | function getMembershipDiscountCodeFromCookie() { 74 | const value = "; " + document.cookie; 75 | const parts = value.split("; discount_code="); 76 | if (parts.length == 2) return parts.pop().split(";").shift(); 77 | } 78 | 79 | async function onConnect(displayedMemberships, shouldOpenNewTab) { 80 | console.log("onConnect displayedMemberships", displayedMemberships); 81 | const currentUrl = window.location.href; 82 | const unlockAppUrl = `__UNLOCK_APP_URL__`; 83 | 84 | // Store currentUrl for redirect after request to unlockAppUrl 85 | try { 86 | const state = await requestUnlockStateAndStoreUrl( 87 | currentUrl, 88 | displayedMemberships 89 | ); 90 | if (!state) throw "State missing!"; 91 | 92 | // state is used to identify the user and redirect him to the right URL. 93 | const domain = new URL(unlockAppUrl).host; 94 | const unlockCheckoutUrl = `https://app.unlock-protocol.com/checkout?client_id=${domain}&redirect_uri=${unlockAppUrl}&state=${state}`; 95 | 96 | if (shouldOpenNewTab) { 97 | window.open(unlockCheckoutUrl, "_blank"); 98 | } else { 99 | window.location.href = unlockCheckoutUrl; 100 | } 101 | } catch (err) { 102 | console.log("Error trying to get state and store redirect URL.", err); 103 | } 104 | } 105 | 106 | function requestUnlockStateAndStoreUrl(redirectUri, membershipNames) { 107 | const unlockStateUrl = new URL(`__UNLOCK_STATE_URL__`); 108 | const allLocks = []; 109 | for (name in window.locksByMembershipName) { 110 | allLocks.push(...window.locksByMembershipName[name]); 111 | } 112 | unlockStateUrl.search = new URLSearchParams({ 113 | url: window.location.href, 114 | locks: allLocks, 115 | membershipNames, 116 | }); 117 | 118 | return fetch(unlockStateUrl.toString()) 119 | .then((response) => response.json()) 120 | .then((data) => data.state); 121 | } 122 | 123 | async function onDisconnect() { 124 | // Remove stored discount code 125 | document.cookie = "discount_code=;max-age=0"; 126 | delete window.activeDiscountCode; 127 | delete window.memberBenefitsAddress; 128 | selectedAccount = null; 129 | 130 | // Set the UI back to the initial state 131 | document.querySelector("#prepare").style.display = "block"; 132 | document.querySelector("#connected").style.display = "none"; 133 | document.querySelectorAll(".membership .status").forEach((el) => { 134 | el.textContent = "Not available"; 135 | }); 136 | updateUnlockUIElements("locked"); 137 | } 138 | 139 | async function fetchAccountData(selectedAccount, unlockedLocks) { 140 | console.log("fetchAccountData start", selectedAccount, unlockedLocks); 141 | document.querySelector("#selected-account").textContent = selectedAccount; 142 | 143 | // Check for Unlock keys and update status, if connected. 144 | document.querySelectorAll(".status").forEach((el) => { 145 | console.log("status", el); 146 | const locksClasses = el.dataset.locks.replace(",", " "); 147 | const membershipDiscountCode = el.dataset.discount; 148 | el.innerHTML = ` 149 | 155 |

Inactive

156 |
157 | `; 158 | }); 159 | 160 | // TODO: Show key-purchase URL if no valid keys were found? 161 | /* 162 | setTimeout(() => { 163 | const validMembershipCells = document.getElementsByClassName("membership-validity valid"); 164 | if(validMembershipCells.length > 0) return; 165 | document.getElementById("key-purchase-container").style.display = "block"; 166 | }, 2000); 167 | */ 168 | 169 | document.querySelectorAll(".membership").forEach((membership) => { 170 | console.log("membership", membership); 171 | const knownLocks = membership 172 | .querySelector(".status") 173 | .dataset.locks.split(","); 174 | 175 | knownLocks.map((lockAddress) => { 176 | if (unlockedLocks.indexOf(lockAddress) === -1) return; 177 | 178 | // Found unlocked lock 179 | window.dispatchEvent( 180 | new CustomEvent("memberBenefits.status", { 181 | detail: { 182 | state: "unlocked", 183 | lock: lockAddress, 184 | }, 185 | }) 186 | ); 187 | 188 | // Apply discount, if there is only one possible option 189 | // Delay a second to emphasize activation 190 | setTimeout(() => { 191 | const validMembershipCells = document.getElementsByClassName( 192 | "membership-validity valid" 193 | ); 194 | if (validMembershipCells.length === 1) { 195 | const jellySwitch = document.querySelector(".benefitSwitch"); 196 | jellySwitch.checked = true; 197 | simulateToggle(jellySwitch); 198 | } 199 | }, 1000); 200 | }); 201 | }); 202 | 203 | console.log("prepare and connected"); 204 | 205 | document.querySelector("#prepare").style.display = "none"; 206 | document.querySelector("#connected").style.display = "block"; 207 | } 208 | 209 | // First redirect to unlock, verify address (checking key validity server-side) 210 | // After that actually display modal. 211 | window.showMemberBenefitsModal = async (memberships, shouldOpenNewTab) => { 212 | console.log("shouldOpenNewTab", shouldOpenNewTab, typeof shouldOpenNewTab); 213 | const membershipNames = memberships.map(({ name }) => name); 214 | 215 | if ( 216 | !window.memberBenefitsAddress && 217 | !shouldOpenNewTab // Otherwise browser may block the popup, because a more immediate user-action is required. 218 | ) { 219 | // Immediately redirect to Unlock Protocol before showing modal 220 | onConnect(membershipNames, shouldOpenNewTab); 221 | 222 | return; 223 | } else { 224 | const modal = document.querySelector("#mb-modal"); 225 | openModal(modal); 226 | } 227 | 228 | // Show memberships and current status 229 | const template = document.getElementById("template-memberships"); 230 | const membershipsContainer = document.getElementById("memberships"); 231 | membershipsContainer.innerHTML = ""; 232 | 233 | // Add rows for all memberships and check status 234 | await Promise.all( 235 | memberships.map(async ({ name, locks }) => { 236 | console.log("modal memberships locks", locks); 237 | const clone = template.content.cloneNode(true); 238 | clone.querySelector(".membership-name").textContent = name; 239 | clone.querySelector(".membership-validity").classList.add(...locks); 240 | clone.querySelector(".status").dataset.locks = locks.join(","); 241 | clone.querySelector(".status").dataset.discount = 242 | discountCodesByLockAddresses[locks[0]]; 243 | membershipsContainer.appendChild(clone); 244 | }) 245 | ); 246 | 247 | document 248 | .querySelector("#btn-connect") 249 | .addEventListener("click", function () { 250 | onConnect(membershipNames, shouldOpenNewTab); 251 | }); 252 | document 253 | .querySelector("#btn-disconnect") 254 | .addEventListener("click", onDisconnect); 255 | 256 | await fetchAccountData( 257 | window.memberBenefitsAddress, 258 | window.memberBenefitsUnlocked 259 | ); 260 | }; 261 | 262 | function updateUnlockUIElements(unlockState) { 263 | // Hide all .unlock-content elements 264 | const unlockContentElements = document.querySelectorAll(".unlock-content"); 265 | unlockContentElements.forEach((element) => { 266 | element.style.display = "none"; 267 | }); 268 | if (unlockContentElements.length > 0) { 269 | // Se show only the relevant element (CSS class: locked|unlocked) 270 | document 271 | .querySelectorAll(".unlock-content." + unlockState) 272 | .forEach((element) => { 273 | element.style.display = "block"; 274 | }); 275 | } 276 | 277 | if (unlockState === "unlocked" && window.activeDiscountCode) { 278 | // Hide sections with the according setting after unlocking (and benefit was applied) 279 | document.querySelectorAll(".hidden-after-unlocked").forEach((element) => { 280 | element.style.display = "none"; 281 | }); 282 | document 283 | .querySelectorAll(".displayed-after-unlocked") 284 | .forEach((element) => { 285 | element.style.display = "block"; 286 | }); 287 | } else if (unlockState === "locked") { 288 | // Show sections with the according setting after disconnecting the wallet 289 | document.querySelectorAll(".hidden-after-unlocked").forEach((element) => { 290 | element.style.display = "block"; 291 | }); 292 | document 293 | .querySelectorAll(".displayed-after-unlocked") 294 | .forEach((element) => { 295 | element.style.display = "none"; 296 | }); 297 | } 298 | } 299 | 300 | window.addEventListener("memberBenefits.status", function (event) { 301 | const lockAddress = event.detail.lock.toString(); 302 | 303 | // Find membership row in modal via lock address class 304 | const benefitSwitch = document.getElementsByClassName( 305 | "benefitSwitch " + lockAddress 306 | )[0]; 307 | if (benefitSwitch) { 308 | benefitSwitch.disabled = false; 309 | } 310 | 311 | const membershipValidityCell = document.getElementsByClassName( 312 | "membership-validity " + lockAddress 313 | )[0]; 314 | membershipValidityCell.textContent = "🔓 unlocked"; 315 | membershipValidityCell.style.backgroundColor = "green"; 316 | membershipValidityCell.classList.add("valid"); 317 | 318 | console.log( 319 | "event memberBenefits.status discountCodesByLockAddresses", 320 | discountCodesByLockAddresses, 321 | lockAddress 322 | ); 323 | if ( 324 | window.activeDiscountCode === discountCodesByLockAddresses[lockAddress] 325 | ) { 326 | console.log("Setting checked"); 327 | benefitSwitch.checked = true; 328 | benefitSwitch.querySelector(".rightContent").textContent = " Active"; 329 | } else { 330 | benefitSwitch.querySelector(".rightContent").textContent = " Inactive"; 331 | } 332 | 333 | updateUnlockUIElements(event.detail.state.toString()); 334 | }); 335 | 336 | async function init() { 337 | /* 338 | * Member Benefits Modal (Vanilla JS): 339 | */ 340 | 341 | // Add modal to DOM if necessary 342 | if (!document.getElementById("mb-modal")) { 343 | document.body.insertAdjacentHTML( 344 | "beforeend", 345 | ` 415 |
416 | ` 417 | ); 418 | } 419 | 420 | // openModalButtons = document.querySelectorAll("[data-modal-target]"); 421 | closeModalButtons = document.querySelectorAll("[data-close-button]"); 422 | overlay = document.getElementById("overlay"); 423 | 424 | /* 425 | if (openModalButtons) { 426 | openModalButtons.forEach((button) => { 427 | button.addEventListener("click", () => { 428 | const modal = document.querySelector(button.dataset.modalTarget); 429 | openModal(modal); 430 | }); 431 | }); 432 | }*/ 433 | 434 | if (overlay) { 435 | overlay.addEventListener("click", () => { 436 | const modals = document.querySelectorAll(".mb-modal.active"); 437 | modals.forEach((modal) => { 438 | closeModal(modal); 439 | }); 440 | }); 441 | } 442 | 443 | if (closeModalButtons) { 444 | closeModalButtons.forEach((button) => { 445 | button.addEventListener("click", () => { 446 | const modal = button.closest(".mb-modal"); 447 | closeModal(modal); 448 | }); 449 | }); 450 | } 451 | 452 | openModal = function openModal(modal) { 453 | if (modal == null) return; 454 | document.getElementById("mb-modal").style.display = "block"; 455 | modal.classList.add("active"); 456 | overlay.classList.add("active"); 457 | }; 458 | 459 | closeModal = function closeModal(modal) { 460 | if (modal == null) return; 461 | modal.classList.remove("active"); 462 | overlay.classList.remove("active"); 463 | }; 464 | 465 | // On/Off jelly-switch 466 | 467 | window.captureMembershipChangeEvent = 468 | window.captureMembershipChangeEvent || 469 | function captureMembershipChangeEvent(self, discountCode) { 470 | if (self.checked) { 471 | self.querySelector(".rightContent").style.color = "green"; 472 | self.querySelector(".rightContent").textContent = " Active"; 473 | 474 | // Activate discount code by loading iframe 475 | const iframe = document.createElement("iframe"); 476 | iframe.src = `${window.location.origin}/discount/${discountCode}`; 477 | iframe.width = 1; 478 | iframe.height = 1; 479 | iframe.style.width = "1px"; 480 | iframe.style.height = "1px"; 481 | document.body 482 | .appendChild(iframe) 483 | .classList.add("hidden-after-unlocked"); 484 | window.activeDiscountCode = discountCode; 485 | 486 | // Hide sections with the according setting after unlocking (and benefit was applied) 487 | document 488 | .querySelectorAll(".hidden-after-unlocked") 489 | .forEach((element) => { 490 | element.style.display = "none"; 491 | }); 492 | document 493 | .querySelectorAll(".displayed-after-unlocked") 494 | .forEach((element) => { 495 | element.style.display = "block"; 496 | }); 497 | } else { 498 | self.querySelector(".rightContent").style.color = "red"; 499 | self.querySelector(".rightContent").textContent = " Inactive"; 500 | document.cookie = "discount_code=;max-age=0"; 501 | delete window.activeDiscountCode; 502 | 503 | // Show sections with the according setting after disconnecting the wallet 504 | document 505 | .querySelectorAll(".hidden-after-unlocked") 506 | .forEach((element) => { 507 | element.style.display = "block"; 508 | }); 509 | document 510 | .querySelectorAll(".displayed-after-unlocked") 511 | .forEach((element) => { 512 | element.style.display = "none"; 513 | }); 514 | } 515 | }; 516 | 517 | // Check if the user was redirected back from Unlock (after signing message). 518 | const params = new URLSearchParams(window.location.search); 519 | const address = params.get("_mb_address"); 520 | const locks = params.get("_mb_locks") 521 | ? params.get("_mb_locks").split(",") 522 | : []; 523 | const memberships = params.get("_mb_memberships") 524 | ? params.get("_mb_memberships").split(",") 525 | : []; 526 | console.log("Got address, locks, memberships", address, locks, memberships); 527 | 528 | if (address) { 529 | window.memberBenefitsAddress = address; 530 | window.memberBenefitsUnlocked = locks; 531 | let clickElement; 532 | if (locks.length === 0) { 533 | // User connected wallet but no valid membership was detected 534 | // TODO: Maybe show link to membership purchase URL 535 | 536 | // Use any available modal 537 | clickElement = document.querySelector( 538 | '[onclick*="showMemberBenefitsModal"]' 539 | ); 540 | } else { 541 | // TODO: add signature and verify that locks haven't been changed 542 | 543 | // Show first modal that contains the first lock 544 | let selectorString = '[onclick*="showMemberBenefitsModal"]'; 545 | for (name of memberships) { 546 | selectorString += `[onclick*="${name}"]`; 547 | } 548 | clickElement = document.querySelector(selectorString); 549 | } 550 | 551 | console.log("clickElement", clickElement); 552 | simulateClick(clickElement); 553 | } 554 | 555 | window.activeDiscountCode = 556 | window.activeDiscountCode || getMembershipDiscountCodeFromCookie(); 557 | 558 | if (window.activeDiscountCode) { 559 | updateUnlockUIElements("unlocked"); 560 | } 561 | } // end init() 562 | 563 | ["https://unpkg.com/jelly-switch"].forEach(async (item, index, array) => { 564 | await loadScripts(item); 565 | unpkgScriptsLoaded++; 566 | 567 | if (unpkgScriptsLoaded === array.length) { 568 | init(); 569 | } 570 | }); 571 | })(); 572 | -------------------------------------------------------------------------------- /server/shopify-theme-templates/mb-hero.liquid: -------------------------------------------------------------------------------- 1 | {%- if section.settings.hero_layout == 'full_width' and section.settings.hero_size == 'adapt' -%} 2 | {%- if section.settings.image.aspect_ratio == blank -%} 3 | {%- assign min_aspect_ratio = 2.0 -%} 4 | {%- else -%} 5 | {%- assign min_aspect_ratio = section.settings.image.aspect_ratio -%} 6 | {%- endif -%} 7 | {%- assign wrapper_height = 100 | divided_by: min_aspect_ratio -%} 8 | {%- style -%} 9 | .hero-{{ section.id }} { 10 | height: {{- wrapper_height -}}vw !important; 11 | } 12 | 13 | .hero__inner { 14 | cursor: pointer !important; 15 | } 16 | {%- endstyle -%} 17 | {%- endif -%} 18 | {%- style -%} 19 | 20 | .displayed-after-unlocked { 21 | display: none; 22 | } 23 | 24 | [data-section-type="hero-section"] { 25 | cursor: pointer; 26 | } 27 | 28 | 29 | .mb-modal { 30 | position: fixed; 31 | top: 220px; 32 | left: 50%; 33 | transform: translate(-50%, -50%) scale(0); 34 | transition: 200ms ease-in-out; 35 | border: 1px solid black; 36 | border-radius: 10px; 37 | z-index: 2; 38 | background-color: white; 39 | width: 500px; 40 | max-width: 80%; 41 | } 42 | 43 | .mb-modal.active { 44 | transform: translate(-50%, -50%) scale(1); 45 | } 46 | 47 | .mb-modal-header { 48 | padding: 10px 15px; 49 | display: flex; 50 | justify-content: space-between; 51 | align-items: center; 52 | border-bottom: 1px solid black; 53 | } 54 | 55 | .mb-modal-header .title { 56 | font-size: 1.25rem; 57 | font-weight: bold; 58 | } 59 | 60 | .mb-modal-header .close-button { 61 | cursor: pointer; 62 | border: none; 63 | outline: none; 64 | background: none; 65 | font-size: 1.25rem; 66 | font-weight: bold; 67 | } 68 | 69 | .mb-modal-body { 70 | padding: 10px 15px; 71 | } 72 | 73 | #overlay { 74 | position: fixed; 75 | opacity: 0; 76 | transition: 200ms ease-in-out; 77 | top: 0; 78 | left: 0; 79 | right: 0; 80 | bottom: 0; 81 | background-color: rgba(0, 0, 0, .5); 82 | pointer-events: none; 83 | } 84 | 85 | #overlay.active { 86 | opacity: 1; 87 | pointer-events: all; 88 | } 89 | 90 | 91 | .table-memberships { 92 | margin-top: 2em; 93 | } 94 | 95 | #btn-connect, #btn-disconnect { 96 | margin: 1em auto; 97 | display: block; 98 | } 99 | 100 | #selected-account { 101 | font-family: Courier,monospace; 102 | font-size: 12px; 103 | } 104 | 105 | {% endstyle %} 106 | 107 | 108 |
110 | {%- if section.settings.hero_layout == 'fixed_width' -%} 111 |
112 | {%- endif -%} 113 | {%- if section.settings.hero_layout == 'fixed_width' and section.settings.hero_size == 'adapt' -%} 114 | {%- assign slide_width = 1090 -%} 115 | {%- assign min_aspect_ratio = section.settings.image.aspect_ratio | default: 2.0 -%} 116 |
120 | 121 | {%- assign img_url = section.settings.image | img_url: '1x1' | replace: '_1x1.', '_{width}x.' -%} 122 | 123 | {%- if section.settings.image.width < max_width -%} 124 | {%- assign slide_width = section.settings.image.width -%} 125 | {%- endif -%} 126 |
127 |
128 | {%- if section.settings.title != blank -%} 129 |

{{ section.settings.title | escape }}

130 | {%- endif -%} 131 | {%- if section.settings.text != blank -%} 132 |
{{ section.settings.text }}
133 | {%- endif -%} 134 | {%- if section.settings.button_label != blank -%} 135 | {{ section.settings.button_label | escape }} 136 | {%- endif -%} 137 |
138 |
139 | {%- if section.settings.image != blank -%} 140 | {{ section.settings.image.alt | escape }} 151 | {%- else -%} 152 | 153 | {% capture current %}{% cycle 1, 2 %}{% endcapture %} 154 | {{ 'lifestyle-' | append: current | placeholder_svg_tag: 'placeholder-svg' }} 155 | 156 | {%- endif -%} 157 | 160 |
161 | {%- else -%} 162 | {%- assign hero-image = section.settings.image -%} 163 | 200 | 203 | {%- endif -%} 204 | {%- if section.settings.hero_layout == 'fixed_width' -%} 205 |
206 | {%- endif -%} 207 |
208 | 209 | {%- if section.settings.image-unlocked != blank -%} 210 |
212 | {%- if section.settings.hero_layout == 'fixed_width' -%} 213 |
214 | {%- endif -%} 215 | {%- if section.settings.hero_layout == 'fixed_width' and section.settings.hero_size == 'adapt' -%} 216 | {%- assign slide_width = 1090 -%} 217 | {%- assign min_aspect_ratio = section.settings.image.aspect_ratio | default: 2.0 -%} 218 |
222 | 223 | {%- assign img_url = section.settings.image-unlocked | img_url: '1x1' | replace: '_1x1.', '_{width}x.' -%} 224 | 225 | {%- if section.settings.image.width < max_width -%} 226 | {%- assign slide_width = section.settings.image.width -%} 227 | {%- endif -%} 228 |
229 |
230 | {%- if section.settings.title-unlocked != blank -%} 231 |

{{ section.settings.title-unlocked | escape }}

232 | {%- endif -%} 233 | {%- if section.settings.text-unlocked != blank -%} 234 |
{{ section.settings.text-unlocked }}
235 | {%- endif -%} 236 | {%- if section.settings.button_label-unlocked != blank -%} 237 | {{ section.settings.button_label-unlocked | escape }} 238 | {%- endif -%} 239 |
240 |
241 | {%- if section.settings.image != blank -%} 242 | {{ section.settings.image.alt | escape }} 253 | {%- else -%} 254 | 255 | {% capture current %}{% cycle 1, 2 %}{% endcapture %} 256 | {{ 'lifestyle-' | append: current | placeholder_svg_tag: 'placeholder-svg' }} 257 | 258 | {%- endif -%} 259 | 262 |
263 | {%- else -%} 264 | 265 | {%- assign hero-image = section.settings.image-unlocked -%} 266 | 267 | 302 | 305 | {%- endif -%} 306 | {%- if section.settings.hero_layout == 'fixed_width' -%} 307 |
308 | {%- endif -%} 309 |
310 | {%- endif -%} 311 | 312 | {% schema %} 313 | { 314 | "name": "MB|Hero", 315 | "class": "index-section index-section--flush", 316 | "settings": [ 317 | { 318 | "type": "select", 319 | "id": "membership", 320 | "label": "Membership", 321 | "options": __MEMBERSHIP_SECTION_SETTING_OPTIONS__, 322 | "info": "Select memberships for this section" 323 | }, 324 | { 325 | "type": "text", 326 | "id": "title", 327 | "label": { 328 | "cs": "Nadpis", 329 | "da": "Overskrift", 330 | "de": "Titel", 331 | "en": "Heading", 332 | "es": "Título", 333 | "fi": "Otsake", 334 | "fr": "En-tête", 335 | "hi": "शीर्षक", 336 | "it": "Heading", 337 | "ja": "見出し", 338 | "ko": "제목", 339 | "nb": "Overskrift", 340 | "nl": "Kop", 341 | "pl": "Nagłówek", 342 | "pt-BR": "Título", 343 | "pt-PT": "Título", 344 | "sv": "Rubrik", 345 | "th": "ส่วนหัว", 346 | "tr": "Başlık", 347 | "vi": "Tiêu đề", 348 | "zh-CN": "标题", 349 | "zh-TW": "標題" 350 | }, 351 | "default": { 352 | "en": "Membership" 353 | } 354 | }, 355 | { 356 | "type": "richtext", 357 | "id": "text", 358 | "label": { 359 | "cs": "Text", 360 | "da": "Tekst", 361 | "de": "Text", 362 | "en": "Text", 363 | "es": "Texto", 364 | "fi": "Teksti", 365 | "fr": "Texte", 366 | "hi": "टेक्स्ट", 367 | "it": "Testo", 368 | "ja": "テキスト", 369 | "ko": "텍스트", 370 | "nb": "Tekst", 371 | "nl": "Tekst", 372 | "pl": "Tekst", 373 | "pt-BR": "Texto", 374 | "pt-PT": "Texto", 375 | "sv": "Text", 376 | "th": "ข้อความ", 377 | "tr": "Metin", 378 | "vi": "Văn bản", 379 | "zh-CN": "文本", 380 | "zh-TW": "文字" 381 | }, 382 | "default": { 383 | "en": "

Use overlay text to give your customers insight into your membership offering.
When this is clicked, the wallet can be connected and benefits may be applied.

" 384 | } 385 | }, 386 | { 387 | "type": "text", 388 | "id": "button_label", 389 | "info": "Leave empty to hide button.", 390 | "label": { 391 | "cs": "Text tlačítka", 392 | "da": "Knaptekst", 393 | "de": "Button-Name", 394 | "en": "Button label", 395 | "es": "Etiqueta de botón", 396 | "fi": "Tekstipainike", 397 | "fr": "Texte du bouton", 398 | "it": "Etichetta pulsante", 399 | "ja": "ボタンのラベル", 400 | "ko": "버튼 레이블", 401 | "nb": "Knappetikett", 402 | "nl": "Knoplabel", 403 | "pl": "Przycisk z etykietą", 404 | "pt-BR": "Etiqueta de botão", 405 | "pt-PT": "Etiqueta do botão", 406 | "sv": "Knappetikett", 407 | "th": "ป้ายกำกับปุ่ม", 408 | "tr": "Düğme etiketi", 409 | "vi": "Nhãn nút", 410 | "zh-CN": "按钮标签", 411 | "zh-TW": "按鈕標籤" 412 | }, 413 | "default": "Connect Wallet" 414 | }, 415 | { 416 | "type": "checkbox", 417 | "id": "enable_open_new_tab", 418 | "label": "Open in new tab", 419 | "info": "If unchecked, the redirect happens in the same browser tab", 420 | "default": false 421 | }, 422 | { 423 | "type": "image_picker", 424 | "id": "image", 425 | "label": { 426 | "cs": "Obrázek", 427 | "da": "Billede", 428 | "de": "Foto", 429 | "en": "Image", 430 | "es": "Imagen", 431 | "fi": "Kuva", 432 | "fr": "Image", 433 | "hi": "इमेज", 434 | "it": "Immagine", 435 | "ja": "画像", 436 | "ko": "이미지", 437 | "nb": "Bilde", 438 | "nl": "Afbeelding", 439 | "pl": "Obraz", 440 | "pt-BR": "Imagem", 441 | "pt-PT": "Imagem", 442 | "sv": "Bild", 443 | "th": "รูปภาพ", 444 | "tr": "Görsel", 445 | "vi": "Hình ảnh", 446 | "zh-CN": "图片", 447 | "zh-TW": "圖片" 448 | } 449 | }, 450 | { 451 | "type": "select", 452 | "id": "alignment", 453 | "label": { 454 | "cs": "Zarovnání obrázku", 455 | "da": "Justering af billede", 456 | "de": "Fotoausrichtung", 457 | "en": "Image alignment", 458 | "es": "Alineación de imagen", 459 | "fi": "Kuvan tasaus", 460 | "fr": "Alignement de l'image", 461 | "hi": "इमेज पंक्तिबद्ध करना", 462 | "it": "Allineamento immagine", 463 | "ja": "画像アラインメント", 464 | "ko": "이미지 정렬", 465 | "nb": "Bildejustering", 466 | "nl": "Afbeelding uitlijnen", 467 | "pl": "Wyrównanie obrazu", 468 | "pt-BR": "Alinhamento da imagem", 469 | "pt-PT": "Alinhamento da imagem", 470 | "sv": "Bildjustering", 471 | "th": "การจัดวางรูปภาพ", 472 | "tr": "Görsel hizalaması", 473 | "vi": "Căn chỉnh hình ảnh", 474 | "zh-CN": "图片对齐方式", 475 | "zh-TW": "圖片對齊" 476 | }, 477 | "default": "center", 478 | "options": [ 479 | { 480 | "value": "top", 481 | "label": { 482 | "cs": "Nahoru", 483 | "da": "Top", 484 | "de": "Oben", 485 | "en": "Top", 486 | "es": "Superior", 487 | "fi": "Ylös", 488 | "fr": "Haut", 489 | "hi": "सबसे ऊपर", 490 | "it": "In alto", 491 | "ja": "上", 492 | "ko": "위쪽", 493 | "nb": "Topp", 494 | "nl": "Boven", 495 | "pl": "Do góry", 496 | "pt-BR": "Acima", 497 | "pt-PT": "Acima", 498 | "sv": "Högst upp", 499 | "th": "ด้านบน", 500 | "tr": "Üst", 501 | "vi": "Bên trên", 502 | "zh-CN": "顶部", 503 | "zh-TW": "頂部" 504 | } 505 | }, 506 | { 507 | "value": "center", 508 | "label": { 509 | "cs": "Na střed", 510 | "da": "I midten", 511 | "de": "Mitte", 512 | "en": "Middle", 513 | "es": "Centrada", 514 | "fi": "Keskelle", 515 | "fr": "Milieu", 516 | "hi": "मध्य में", 517 | "it": "Al centro", 518 | "ja": "中央", 519 | "ko": "중간", 520 | "nb": "Midten", 521 | "nl": "Midden", 522 | "pl": "Do środka", 523 | "pt-BR": "Meio", 524 | "pt-PT": "Meio", 525 | "sv": "Mitten", 526 | "th": "ตรงกลาง", 527 | "tr": "Orta", 528 | "vi": "Ở giữa", 529 | "zh-CN": "中间", 530 | "zh-TW": "中央" 531 | } 532 | }, 533 | { 534 | "value": "bottom", 535 | "label": { 536 | "cs": "Dolů", 537 | "da": "Bund", 538 | "de": "Unten", 539 | "en": "Bottom", 540 | "es": "Inferior", 541 | "fi": "Alas", 542 | "fr": "Bas", 543 | "hi": "नीचे का", 544 | "it": "In basso", 545 | "ja": "下", 546 | "ko": "아래쪽", 547 | "nb": "Bunn", 548 | "nl": "Onder", 549 | "pl": "Do dołu", 550 | "pt-BR": "Abaixo", 551 | "pt-PT": "Abaixo", 552 | "sv": "Längst ner", 553 | "th": "ด้านล่าง", 554 | "tr": "Alt", 555 | "vi": "Bên dưới", 556 | "zh-CN": "底部", 557 | "zh-TW": "底部" 558 | } 559 | } 560 | ] 561 | }, 562 | { 563 | "type": "select", 564 | "id": "hero_layout", 565 | "label": { 566 | "cs": "Rozvržení", 567 | "da": "Layout", 568 | "de": "Layout", 569 | "en": "Layout", 570 | "es": "Diseño", 571 | "fi": "Asettelu", 572 | "fr": "Mise en page", 573 | "hi": "लेआउट", 574 | "it": "Layout", 575 | "ja": "レイアウト", 576 | "ko": "레이아웃", 577 | "nb": "Oppsett", 578 | "nl": "Opmaak", 579 | "pl": "Układ", 580 | "pt-BR": "Layout", 581 | "pt-PT": "Esquema", 582 | "sv": "Layout", 583 | "th": "เลย์เอาต์", 584 | "tr": "Düzen", 585 | "vi": "Bố cục", 586 | "zh-CN": "布局", 587 | "zh-TW": "版面配置" 588 | }, 589 | "default": "full_width", 590 | "options": [ 591 | { 592 | "label": { 593 | "cs": "Plná šířka", 594 | "da": "Fuld bredde", 595 | "de": "Volle Breite", 596 | "en": "Full width", 597 | "es": "Ancho completo", 598 | "fi": "Täysi leveys", 599 | "fr": "Pleine largeur", 600 | "hi": "पूर्ण चौड़ाई", 601 | "it": "Intera larghezza", 602 | "ja": "全幅", 603 | "ko": "전체 폭", 604 | "nb": "Full bredde", 605 | "nl": "Volledige breedte", 606 | "pl": "Pełna szerokość", 607 | "pt-BR": "Largura completa", 608 | "pt-PT": "Largura total", 609 | "sv": "Full bredd", 610 | "th": "เต็มความกว้าง", 611 | "tr": "Tam genişlik", 612 | "vi": "Độ rộng đầy đủ", 613 | "zh-CN": "全宽", 614 | "zh-TW": "全寬度" 615 | }, 616 | "value": "full_width" 617 | }, 618 | { 619 | "label": { 620 | "cs": "Pevná šířka", 621 | "da": "Fast bredde", 622 | "de": "Feste Breite", 623 | "en": "Fixed width", 624 | "es": "Ancho fijo", 625 | "fi": "Kiinteä leveys", 626 | "fr": "Largeur fixe", 627 | "hi": "निश्चित चौड़ाई", 628 | "it": "Larghezza fissa", 629 | "ja": "固定幅", 630 | "ko": "고정 폭", 631 | "nb": "Fast bredde", 632 | "nl": "Vaste breedte", 633 | "pl": "Stała szerokość", 634 | "pt-BR": "Largura fixa", 635 | "pt-PT": "Largura fixa", 636 | "sv": "Fast bredd", 637 | "th": "ความกว้างตายตัว", 638 | "tr": "Sabit genişlik", 639 | "vi": "Độ rộng cố định", 640 | "zh-CN": "固定宽度", 641 | "zh-TW": "固定寬度" 642 | }, 643 | "value": "fixed_width" 644 | } 645 | ] 646 | }, 647 | { 648 | "type": "select", 649 | "id": "hero_size", 650 | "label": { 651 | "cs": "Výška sekce", 652 | "da": "Højde på afsnit", 653 | "de": "Bereichs-Höhe", 654 | "en": "Section height", 655 | "es": "Altura de la sección", 656 | "fi": "Osan korkeus", 657 | "fr": "Hauteur de la section", 658 | "hi": "सेक्शन की ऊंचाई", 659 | "it": "Altezza sezione", 660 | "ja": "セクションの高さ", 661 | "ko": "섹션 높이", 662 | "nb": "Høyde på del", 663 | "nl": "Sectiehoogte", 664 | "pl": "Wysokość sekcji", 665 | "pt-BR": "Altura da seção", 666 | "pt-PT": "Altura da secção", 667 | "sv": "Sektionshöjd", 668 | "th": "ความสูงของส่วน", 669 | "tr": "Bölüm yüksekliği", 670 | "vi": "Chiều cao mục", 671 | "zh-CN": "分区高度", 672 | "zh-TW": "區塊高度" 673 | }, 674 | "default": "medium", 675 | "options": [ 676 | { 677 | "label": { 678 | "cs": "Přizpůsobení obrázku", 679 | "da": "Tilpas til billede", 680 | "de": "An Bild anpassen", 681 | "en": "Adapt to image", 682 | "es": "Adaptar a la imagen", 683 | "fi": "Sovita kuvaan", 684 | "fr": "S'adapter à l'image", 685 | "hi": "इमेज के अनुकूल बनाएं", 686 | "it": "Adatta all'immagine", 687 | "ja": "画像に対応", 688 | "ko": "이미지에 맞춤", 689 | "nb": "Tilpass til bilde", 690 | "nl": "Aanpassen aan afbeelding", 691 | "pl": "Dostosuj do obrazu", 692 | "pt-BR": "Adaptar à imagem", 693 | "pt-PT": "Adaptar à imagem", 694 | "sv": "Anpassa till bild", 695 | "th": "ปรับตามรูปภาพ", 696 | "tr": "Görsele uyarla", 697 | "vi": "Điều chỉnh theo hình ảnh", 698 | "zh-CN": "适应图片", 699 | "zh-TW": "配合圖片" 700 | }, 701 | "value": "adapt" 702 | }, 703 | { 704 | "label": { 705 | "cs": "Extra malá", 706 | "da": "Ekstra lille", 707 | "de": "Extra klein", 708 | "en": "Extra Small", 709 | "es": "Extra pequeña", 710 | "fi": "Erikoispieni", 711 | "fr": "Très petite", 712 | "hi": "अतिरिक्त छोटा", 713 | "it": "Molto piccola", 714 | "ja": "極小", 715 | "ko": "특소", 716 | "nb": "Ekstra liten", 717 | "nl": "Extra klein", 718 | "pl": "Bardzo mała", 719 | "pt-BR": "Extra pequeno", 720 | "pt-PT": "Extra pequeno", 721 | "sv": "Extra liten", 722 | "th": "ขนาดเล็กพิเศษ", 723 | "tr": "Çok Küçük", 724 | "vi": "Cực nhỏ", 725 | "zh-CN": "特小", 726 | "zh-TW": "超小型" 727 | }, 728 | "value": "x-small" 729 | }, 730 | { 731 | "label": { 732 | "cs": "Malá", 733 | "da": "Lille", 734 | "de": "Klein", 735 | "en": "Small", 736 | "es": "Pequeña", 737 | "fi": "Pieni", 738 | "fr": "Petite", 739 | "hi": "छोटा", 740 | "it": "Small", 741 | "ja": "小", 742 | "ko": "스몰", 743 | "nb": "Liten", 744 | "nl": "Klein", 745 | "pl": "Mała", 746 | "pt-BR": "Pequeno", 747 | "pt-PT": "Pequeno", 748 | "sv": "Liten", 749 | "th": "เล็ก", 750 | "tr": "Küçük", 751 | "vi": "Nhỏ", 752 | "zh-CN": "小", 753 | "zh-TW": "小型" 754 | }, 755 | "value": "small" 756 | }, 757 | { 758 | "label": { 759 | "cs": "Střední", 760 | "da": "Medium", 761 | "de": "Mitte", 762 | "en": "Medium", 763 | "es": "Mediana", 764 | "fi": "Keskisuuri", 765 | "fr": "Moyenne", 766 | "hi": "मध्यम", 767 | "it": "Medium", 768 | "ja": "中", 769 | "ko": "보통", 770 | "nb": "Middels", 771 | "nl": "Gemiddeld", 772 | "pl": "Średnia", 773 | "pt-BR": "Médio", 774 | "pt-PT": "Médio", 775 | "sv": "Medium", 776 | "th": "ปานกลาง", 777 | "tr": "Orta", 778 | "vi": "Trung bình", 779 | "zh-CN": "中等", 780 | "zh-TW": "中等" 781 | }, 782 | "value": "medium" 783 | }, 784 | { 785 | "label": { 786 | "cs": "Velká", 787 | "da": "Stor", 788 | "de": "Groß", 789 | "en": "Large", 790 | "es": "Grande", 791 | "fi": "Suuri", 792 | "fr": "Grande", 793 | "hi": "बड़ा", 794 | "it": "Large", 795 | "ja": "大", 796 | "ko": "라지", 797 | "nb": "Stor", 798 | "nl": "Groot", 799 | "pl": "Duża", 800 | "pt-BR": "Grande", 801 | "pt-PT": "Grande", 802 | "sv": "Stor", 803 | "th": "ใหญ่", 804 | "tr": "Büyük", 805 | "vi": "Lớn", 806 | "zh-CN": "大", 807 | "zh-TW": "大型" 808 | }, 809 | "value": "large" 810 | }, 811 | { 812 | "label": { 813 | "cs": "Extra velká", 814 | "da": "Ekstra stor", 815 | "de": "Extra groß", 816 | "en": "Extra Large", 817 | "es": "Extra grande", 818 | "fi": "Erikoissuuri", 819 | "fr": "Très grande", 820 | "hi": "अतिरिक्त बड़ा", 821 | "it": "Molto grande", 822 | "ja": "特大", 823 | "ko": "특대", 824 | "nb": "Ekstra stort", 825 | "nl": "Extra groot", 826 | "pl": "Bardzo duża", 827 | "pt-BR": "Extra grande", 828 | "pt-PT": "Extra grande", 829 | "sv": "Extra stor", 830 | "th": "ขนาดใหญ่พิเศษ", 831 | "tr": "Çok Büyük", 832 | "vi": "Cực lớn", 833 | "zh-CN": "特大", 834 | "zh-TW": "超大型" 835 | }, 836 | "value": "x-large" 837 | } 838 | ] 839 | }, 840 | { 841 | "type": "select", 842 | "id": "text_size", 843 | "label": { 844 | "cs": "Velikost textu", 845 | "da": "Tekststørrelse", 846 | "de": "Textgröße", 847 | "en": "Text size", 848 | "es": "Tamaño del texto", 849 | "fi": "Tekstin koko", 850 | "fr": "Taille du texte", 851 | "hi": "टेक्स्ट आकार", 852 | "it": "Dimensione testo", 853 | "ja": "文字サイズ", 854 | "ko": "텍스트 사이즈", 855 | "nb": "Tekststørrelse", 856 | "nl": "Tekengrootte", 857 | "pl": "Rozmiar czcionki", 858 | "pt-BR": "Tamanho do texto", 859 | "pt-PT": "Tamanho do texto", 860 | "sv": "Textstorlek", 861 | "th": "ขนาดตัวอักษร", 862 | "tr": "Metin boyutu", 863 | "vi": "Cỡ văn bản", 864 | "zh-CN": "文本大小", 865 | "zh-TW": "文字尺寸" 866 | }, 867 | "default": "medium", 868 | "options": [ 869 | { 870 | "label": { 871 | "cs": "Střední", 872 | "da": "Medium", 873 | "de": "Mitte", 874 | "en": "Medium", 875 | "es": "Mediano", 876 | "fi": "Keskisuuri", 877 | "fr": "Moyenne", 878 | "hi": "मध्यम", 879 | "it": "Medium", 880 | "ja": "中", 881 | "ko": "보통", 882 | "nb": "Middels", 883 | "nl": "Gemiddeld", 884 | "pl": "Średnia", 885 | "pt-BR": "Médio", 886 | "pt-PT": "Intermédio", 887 | "sv": "Medium", 888 | "th": "ปานกลาง", 889 | "tr": "Orta", 890 | "vi": "Trung bình", 891 | "zh-CN": "中等", 892 | "zh-TW": "中等" 893 | }, 894 | "value": "medium" 895 | }, 896 | { 897 | "label": { 898 | "cs": "Velká", 899 | "da": "Stor", 900 | "de": "Groß", 901 | "en": "Large", 902 | "es": "Grande", 903 | "fi": "Suuri", 904 | "fr": "Grande", 905 | "hi": "बड़ा", 906 | "it": "Large", 907 | "ja": "大", 908 | "ko": "라지", 909 | "nb": "Stor", 910 | "nl": "Groot", 911 | "pl": "Duża", 912 | "pt-BR": "Grande", 913 | "pt-PT": "Grande", 914 | "sv": "Stor", 915 | "th": "ใหญ่", 916 | "tr": "Büyük", 917 | "vi": "Lớn", 918 | "zh-CN": "大", 919 | "zh-TW": "大型" 920 | }, 921 | "value": "large" 922 | } 923 | ] 924 | }, 925 | { 926 | "type": "checkbox", 927 | "id": "enable_hide_unlocked", 928 | "label": "Hidden if unlocked", 929 | "info": "If enabled, the hero will not be displayed to members (unless an alternative image is provided in the next setting).", 930 | "default": true 931 | }, 932 | { 933 | "type": "image_picker", 934 | "id": "image-unlocked", 935 | "label": "Alternative hero image after unlocked", 936 | "info": "If set, this image will be displayed to members after they connected their wallet via Unlock Protocol." 937 | }, 938 | { 939 | "type": "text", 940 | "id": "title-unlocked", 941 | "label": "Heading after unlocked", 942 | "info": "Override the heading displayed to members." 943 | }, 944 | { 945 | "type": "richtext", 946 | "id": "text-unlocked", 947 | "label": "Text after unlocked", 948 | "info":"Override the text displayed to members." 949 | }, 950 | { 951 | "type": "text", 952 | "id": "button_label-unlocked", 953 | "info": "Override the button text.", 954 | "label": "Button text after unlocked" 955 | } 956 | ], 957 | "presets": [ 958 | { 959 | "name": "MB|Hero", 960 | "category": { 961 | "cs": "Obrázek", 962 | "da": "Billede", 963 | "de": "Foto", 964 | "en": "Image", 965 | "es": "Imagen", 966 | "fi": "Kuva", 967 | "fr": "Image", 968 | "it": "Immagine", 969 | "ja": "画像", 970 | "ko": "이미지", 971 | "nb": "Bilde", 972 | "nl": "Afbeelding", 973 | "pl": "Obraz", 974 | "pt-BR": "Imagem", 975 | "pt-PT": "Imagem", 976 | "sv": "Bild", 977 | "th": "รูปภาพ", 978 | "tr": "Görsel", 979 | "vi": "Hình ảnh", 980 | "zh-CN": "图片", 981 | "zh-TW": "圖片" 982 | } 983 | } 984 | ] 985 | } 986 | {% endschema %} 987 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import "@babel/polyfill"; 3 | import dotenv from "dotenv"; 4 | import "isomorphic-fetch"; 5 | import createShopifyAuth, { verifyRequest } from "@shopify/koa-shopify-auth"; 6 | import Shopify from "@shopify/shopify-api"; 7 | import Koa from "koa"; 8 | import next from "next"; 9 | import Router from "koa-router"; 10 | import bodyParser from "koa-body-parser"; 11 | import { uid } from "uid"; 12 | import _ from "lodash"; 13 | import { parse } from "url"; 14 | import { ethers } from "ethers"; 15 | import Web3 from "web3"; 16 | 17 | import RedisStore from "./redis-store"; 18 | 19 | dotenv.config(); 20 | // Configure Redis session storage, so that active shops are persisted 21 | // Heroku RedisToGo sets REDISTOGO_URL env var (https://devcenter.heroku.com/articles/redistogo#using-with-node-js) 22 | let sessionStorage; 23 | if (process.env.REDISTOGO_URL) { 24 | const rtg = parse(process.env.REDISTOGO_URL); 25 | sessionStorage = new RedisStore(rtg.hostname, rtg.port); 26 | sessionStorage.client.auth(rtg.auth.split(":")[1]); 27 | } else { 28 | sessionStorage = new RedisStore( 29 | process.env.REDIS_HOST || "127.0.0.1", 30 | parseInt(process.env.REDIS_PORT) || 6379 31 | ); 32 | } 33 | 34 | // These theme assets (in "assets") get deleted on reset (as opposed to theme sections, e.g. membership hero) 35 | const SCRIPT_ASSET_KEY = "memberbenefits.js"; 36 | const LOCK_METAFIELD_PREFIX = "lock"; 37 | const LOCKDETAILS_METAFIELD_PREFIX = "info"; 38 | const METAFIELD_NAMESPACE = "umb"; 39 | const { 40 | NODE_ENV, 41 | SHOPIFY_API_SECRET, 42 | SHOPIFY_API_KEY, 43 | SCOPES, 44 | HOST, 45 | PORT, 46 | WEB3_PROVIDER_MAINNET, // Infura 47 | WEB3_PROVIDER_POLYGON, // Infura 48 | WEB3_PROVIDER_OPTIMISM, // Infura 49 | WEB3_PROVIDER_XDAI, // Ankr 50 | WEB3_PROVIDER_BSC, // Ankr 51 | } = process.env; 52 | const port = parseInt(PORT, 10) || 8081; 53 | const dev = NODE_ENV !== "production"; 54 | const app = next({ 55 | dev, 56 | }); 57 | const handle = app.getRequestHandler(); 58 | 59 | Shopify.Context.initialize({ 60 | API_KEY: SHOPIFY_API_KEY, 61 | API_SECRET_KEY: SHOPIFY_API_SECRET, 62 | SCOPES: SCOPES.split(","), 63 | HOST_NAME: HOST.replace(/https:\/\//, ""), 64 | API_VERSION: "2021-10", // Shopify.ApiVersion.October21 65 | IS_EMBEDDED_APP: true, 66 | SESSION_STORAGE: new Shopify.Session.CustomSessionStorage( 67 | sessionStorage.storeCallback, 68 | sessionStorage.loadCallback, 69 | sessionStorage.deleteCallback 70 | ), 71 | }); 72 | 73 | // Web3 instances for productive networks (potentially having locks) 74 | const web3ByNetwork = {}; 75 | 76 | if (WEB3_PROVIDER_MAINNET) { 77 | web3ByNetwork.ethereum = new Web3( 78 | new Web3.providers.HttpProvider(WEB3_PROVIDER_MAINNET) 79 | ); 80 | } else { 81 | console.log("Missing WEB3_PROVIDER_MAINNET"); 82 | } 83 | 84 | if (WEB3_PROVIDER_POLYGON) { 85 | web3ByNetwork.polygon = new Web3( 86 | new Web3.providers.HttpProvider(WEB3_PROVIDER_POLYGON) 87 | ); 88 | } else { 89 | console.log("Missing WEB3_PROVIDER_BSC"); 90 | } 91 | 92 | if (WEB3_PROVIDER_OPTIMISM) { 93 | web3ByNetwork.optimism = new Web3( 94 | new Web3.providers.HttpProvider(WEB3_PROVIDER_OPTIMISM) 95 | ); 96 | } else { 97 | console.log("Missing WEB3_PROVIDER_OPTIMISM"); 98 | } 99 | 100 | if (WEB3_PROVIDER_XDAI) { 101 | web3ByNetwork.xdai = new Web3( 102 | new Web3.providers.HttpProvider(WEB3_PROVIDER_XDAI) 103 | ); 104 | } else { 105 | console.log("Missing WEB3_PROVIDER_XDAI"); 106 | } 107 | 108 | if (WEB3_PROVIDER_BSC) { 109 | web3ByNetwork.bsc = new Web3( 110 | new Web3.providers.HttpProvider(WEB3_PROVIDER_BSC) 111 | ); 112 | } else { 113 | console.log("Missing WEB3_PROVIDER_BSC"); 114 | } 115 | 116 | const loadActiveShopsFromStorage = async () => { 117 | const activeShopsFromStorage = await sessionStorage.getAsync( 118 | "ACTIVE_SHOPIFY_SHOPS" 119 | ); 120 | const activeShops = JSON.parse(activeShopsFromStorage); 121 | 122 | return activeShops || {}; 123 | }; 124 | 125 | const getMembershipSettingOptions = (memberships) => 126 | memberships.map(({ lockName }) => ({ 127 | value: lockName, 128 | label: lockName, 129 | })); 130 | 131 | const getLocksByMembershipName = (memberships) => { 132 | const locksByName = {}; 133 | memberships.map(({ lockName, lockAddresses }) => { 134 | if (!locksByName[lockName]) { 135 | locksByName[lockName] = lockAddresses; 136 | } 137 | }); 138 | 139 | return locksByName; 140 | }; 141 | 142 | // Get content of theme section liquid file 143 | const getHeroSectionCode = (lockAddresses, name, otherMemberships) => { 144 | const fileContent = fs.readFileSync( 145 | `${__dirname}/shopify-theme-templates/mb-hero.liquid`, 146 | { encoding: "utf8", flag: "r" } 147 | ); 148 | 149 | const membershipSettingOptions = getMembershipSettingOptions([ 150 | { lockName: name }, 151 | ...otherMemberships, 152 | ]); 153 | const locksByMembershipName = getLocksByMembershipName([ 154 | { lockName: name, lockAddresses }, 155 | ...otherMemberships, 156 | ]); 157 | 158 | const liquidString = fileContent 159 | .replace( 160 | /__MEMBERSHIP_SECTION_SETTING_OPTIONS__/g, 161 | JSON.stringify(membershipSettingOptions) 162 | ) 163 | .replace(/__LOCKS_BY_NAME__/g, JSON.stringify(locksByMembershipName)); 164 | 165 | return liquidString; 166 | }; 167 | 168 | const getTopbarSectionCode = ( 169 | sectionTemplateName, 170 | lockAddresses, 171 | name, 172 | otherMemberships 173 | ) => { 174 | const fileContent = fs.readFileSync( 175 | `${__dirname}/shopify-theme-templates/${sectionTemplateName}`, 176 | { encoding: "utf8", flag: "r" } 177 | ); 178 | 179 | const membershipSettingOptions = getMembershipSettingOptions([ 180 | { lockName: name }, 181 | ...otherMemberships, 182 | ]); 183 | const locksByMembershipName = getLocksByMembershipName([ 184 | { lockName: name, lockAddresses }, 185 | ...otherMemberships, 186 | ]); 187 | 188 | const liquidString = fileContent 189 | .replace( 190 | /__MEMBERSHIP_SECTION_SETTING_OPTIONS__/g, 191 | JSON.stringify(membershipSettingOptions) 192 | ) 193 | .replace(/__LOCKS_BY_NAME__/g, JSON.stringify(locksByMembershipName)) 194 | .replace( 195 | /__LOCK_VALUES__/g, 196 | JSON.stringify(membershipSettingOptions.map(({ value }) => value)[0]) // TODO: pre-select multiple locks? 197 | ); 198 | 199 | return liquidString; 200 | }; 201 | 202 | // Get content for the public JS asset for this member benefit 203 | const getMemberBenefitsJS = ( 204 | discountCode, 205 | lockAddresses, 206 | membershipName, 207 | otherMemberships 208 | ) => { 209 | const locksByMembershipName = getLocksByMembershipName([ 210 | { lockName: membershipName, lockAddresses }, 211 | ...otherMemberships, 212 | ]); 213 | const discountCodesByLockAddresses = {}; 214 | lockAddresses.map((addr) => { 215 | discountCodesByLockAddresses[addr] = discountCode; 216 | }); 217 | otherMemberships.map(({ discountId, lockAddresses }) => { 218 | lockAddresses.map((addr) => { 219 | discountCodesByLockAddresses[addr] = discountId; 220 | }); 221 | }); 222 | const fileContent = fs.readFileSync( 223 | `${__dirname}/shopify-theme-templates/memberbenefits.js`, 224 | { encoding: "utf8", flag: "r" } 225 | ); 226 | const uploadContent = fileContent 227 | .replace( 228 | /__DISCOUNT_CODE_BY_LOCK_ADDRESS__/g, 229 | JSON.stringify(discountCodesByLockAddresses) 230 | ) 231 | .replace(/__LOCKS_BY_NAME__/g, JSON.stringify(locksByMembershipName)) 232 | .replace(/__UNLOCK_APP_URL__/g, `${HOST}${UNLOCK_PATH}`) 233 | .replace(/__UNLOCK_STATE_URL__/g, `${HOST}${UNLOCK_STATE_PATH}`); 234 | // console.log("memberbenefits.js uploadContent", uploadContent); 235 | 236 | return uploadContent; 237 | }; 238 | 239 | const deleteAsset = async (client, key) => { 240 | try { 241 | await client.delete({ 242 | path: `assets`, 243 | query: { "asset[key]": key }, 244 | }); 245 | } catch (err) { 246 | console.log("Error trying to delete assets", err); 247 | } 248 | }; 249 | 250 | const UNLOCK_PATH = "/unlock"; // Unlock Protocol redirect here after verifying the user's address. 251 | const UNLOCK_STATE_PATH = "/api/getUnlockState"; // Unlock Protocol redirect here after verifying the user's address. 252 | const WEBHOOK_PATH_APP_UNINSTALLED = "/webhooks"; 253 | // Mandatory GDPR webhooks: 254 | const WEBHOOK_PATH_CUSTOMERS_REQUEST = "/webhooks/customers-data_request"; 255 | const WEBHOOK_PATH_CUSTOMERS_REDACT = "/webhooks/customers-redact"; 256 | const WEBHOOK_PATH_SHOP_REDACT = "/webhooks/customers-redact"; 257 | 258 | const ACTIVE_SHOPIFY_SHOPS = loadActiveShopsFromStorage(); 259 | 260 | const registerWebhookAppUninstalled = async (shop, accessToken) => { 261 | const response = await Shopify.Webhooks.Registry.register({ 262 | shop, 263 | accessToken, 264 | path: WEBHOOK_PATH_APP_UNINSTALLED, 265 | topic: "APP_UNINSTALLED", 266 | webhookHandler: async (topic, shop, body) => { 267 | delete ACTIVE_SHOPIFY_SHOPS[shop]; 268 | sessionStorage.setAsync( 269 | "ACTIVE_SHOPIFY_SHOPS", 270 | JSON.stringify(ACTIVE_SHOPIFY_SHOPS) 271 | ); 272 | }, 273 | }); 274 | 275 | if (!response.success) { 276 | console.log( 277 | `Failed to register APP_UNINSTALLED webhook: ${response.result}` 278 | ); 279 | } 280 | }; 281 | 282 | app.prepare().then(async () => { 283 | const server = new Koa(); 284 | server.use(bodyParser()); 285 | const router = new Router(); 286 | server.keys = [Shopify.Context.API_SECRET_KEY]; 287 | server.use( 288 | createShopifyAuth({ 289 | async afterAuth(ctx) { 290 | const { shop, accessToken, scope } = ctx.state.shopify; 291 | ACTIVE_SHOPIFY_SHOPS[shop] = scope; 292 | sessionStorage.setAsync( 293 | "ACTIVE_SHOPIFY_SHOPS", 294 | JSON.stringify(ACTIVE_SHOPIFY_SHOPS) 295 | ); 296 | registerWebhookAppUninstalled(shop, accessToken); 297 | 298 | // Redirect to app with shop parameter upon auth 299 | ctx.redirect(`/?shop=${shop}`); 300 | }, 301 | }) 302 | ); 303 | 304 | const handleRequest = async (ctx) => { 305 | await handle(ctx.req, ctx.res); 306 | ctx.respond = false; 307 | ctx.res.statusCode = 200; 308 | }; 309 | 310 | router.get(UNLOCK_STATE_PATH, async (ctx) => { 311 | try { 312 | // Generate state as hash of IP and current timestamp 313 | const state = ethers.utils.id(ctx.request.ip + Date.now()); 314 | 315 | // Store redirectUri in redis using state as key. 316 | const redirectUri = (ctx.query && ctx.query.url) || ""; 317 | const locks = (ctx.query && ctx.query.locks) || ""; 318 | const membershipNames = (ctx.query && ctx.query.membershipNames) || ""; 319 | 320 | sessionStorage.setAsync( 321 | state, 322 | JSON.stringify({ 323 | redirectUri, 324 | locks: locks.split(","), 325 | memberships: membershipNames.split(","), 326 | }), 327 | "EX", 328 | 60 * 5 // Expire in 5 min. 329 | ); 330 | 331 | ctx.set("Access-Control-Allow-Origin", "*"); 332 | ctx.body = { state }; 333 | ctx.res.statusCode = 200; 334 | } catch (error) { 335 | console.log(`Failed to get unlock state: ${error}`); 336 | } 337 | }); 338 | 339 | function checkKeyValidity(web3, lockAddress, selectedAccount) { 340 | const lock = new web3.eth.Contract( 341 | [ 342 | { 343 | constant: true, 344 | inputs: [ 345 | { 346 | name: "_owner", 347 | type: "address", 348 | }, 349 | ], 350 | name: "getHasValidKey", 351 | outputs: [ 352 | { 353 | name: "", 354 | type: "bool", 355 | }, 356 | ], 357 | payable: false, 358 | stateMutability: "view", 359 | type: "function", 360 | }, 361 | ], 362 | lockAddress 363 | ); 364 | 365 | return lock.methods 366 | .getHasValidKey(selectedAccount) 367 | .call() 368 | .then((result) => { 369 | if (result === true) { 370 | console.log("Found valid key", lockAddress); 371 | 372 | return true; 373 | } 374 | 375 | return false; 376 | }) 377 | .catch(async (err) => { 378 | const networkId = await web3.eth.net.getId(); 379 | console.log( 380 | "Could not validate key (or find lock) on network:", 381 | networkId 382 | ); 383 | 384 | if ( 385 | err 386 | .toString() 387 | .indexOf( 388 | `Returned values aren't valid, did it run Out of Gas? You might also see this error if you are not using the correct ABI for the contract you are retrieving data from, requesting data from a block number that does not exist, or querying a node which is not fully synced.` 389 | ) !== -1 390 | ) { 391 | // console.log("Lock probably not on network"); 392 | } 393 | 394 | if ( 395 | err 396 | .toString() 397 | .indexOf(`Invalid JSON RPC response: "Server Internal Error`) !== -1 398 | ) { 399 | console.log("Web3 provider ERROR"); 400 | } 401 | 402 | return false; 403 | }); 404 | } 405 | 406 | router.get(UNLOCK_PATH, async (ctx) => { 407 | console.log("UNLOCK_PATH", UNLOCK_PATH); 408 | try { 409 | // Extract user's address from signed message. 410 | const { state, code } = ctx.query; 411 | const decoded = ethers.utils.base64.decode(code); 412 | const message = JSON.parse(ethers.utils.toUtf8String(decoded)); 413 | const address = ethers.utils.verifyMessage(message.d, message.s); 414 | console.log("Looking up keys of", address); 415 | 416 | // Use state to load URL for redirect back to shop. 417 | const storedString = await sessionStorage.getAsync(state); 418 | const data = JSON.parse(storedString); 419 | const { redirectUri, locks, memberships } = data; 420 | console.log("Checked for memberships", memberships); 421 | const finalUrl = new URL(redirectUri); 422 | 423 | // Validate memberships for recovered address. 424 | const validMemberships = []; 425 | for (let lockAddress of locks) { 426 | // Check if lock address is deployed on a productive network, and if the key is valid 427 | for (let networkName in web3ByNetwork) { 428 | if ( 429 | await checkKeyValidity( 430 | web3ByNetwork[networkName], 431 | lockAddress, 432 | address 433 | ) 434 | ) { 435 | validMemberships.push(lockAddress); 436 | console.log(`Found membership on ${networkName}!`); 437 | } 438 | } 439 | } 440 | 441 | console.log("validMemberships", validMemberships); 442 | 443 | finalUrl.searchParams.set("_mb_address", address); 444 | finalUrl.searchParams.set("_mb_locks", validMemberships); 445 | finalUrl.searchParams.set("_mb_memberships", memberships); 446 | 447 | // Redirect back to shop 448 | ctx.redirect(finalUrl.toString()); 449 | ctx.res.statusCode = 303; 450 | } catch (error) { 451 | console.log(UNLOCK_PATH, `Failed to unlock! ${error}`); 452 | 453 | // TODO: redirect customer back to shop? 454 | // ctx.redirect(finalUrl.toString()); 455 | ctx.res.statusCode = 401; 456 | } 457 | }); 458 | 459 | router.post(WEBHOOK_PATH_APP_UNINSTALLED, async (ctx) => { 460 | try { 461 | await Shopify.Webhooks.Registry.process(ctx.req, ctx.res); 462 | console.log(`Webhook processed, returned status code 200`); 463 | } catch (error) { 464 | console.log(`Failed to process webhook: ${error}`); 465 | } 466 | }); 467 | 468 | router.post(WEBHOOK_PATH_CUSTOMERS_REQUEST, async (ctx) => { 469 | try { 470 | console.log(`Processing WEBHOOK_PATH_CUSTOMERS_REQUEST`); 471 | // We don't store any customer data 472 | ctx.body = {}; 473 | ctx.res.statusCode = 200; 474 | } catch (error) { 475 | console.log( 476 | `Failed to process ${WEBHOOK_PATH_CUSTOMERS_REQUEST}: ${error}` 477 | ); 478 | } 479 | }); 480 | 481 | router.post(WEBHOOK_PATH_CUSTOMERS_REDACT, async (ctx) => { 482 | try { 483 | console.log(`Processing WEBHOOK_PATH_CUSTOMERS_REDACT`); 484 | // We don't store any customer data 485 | ctx.res.statusCode = 200; 486 | } catch (error) { 487 | console.log( 488 | `Failed to process ${WEBHOOK_PATH_CUSTOMERS_REDACT}: ${error}` 489 | ); 490 | } 491 | }); 492 | 493 | router.post(WEBHOOK_PATH_SHOP_REDACT, async (ctx) => { 494 | try { 495 | console.log(`Processing WEBHOOK_PATH_SHOP_REDACT`); 496 | // We don't store any shop data 497 | ctx.res.statusCode = 200; 498 | } catch (error) { 499 | console.log(`Failed to process ${WEBHOOK_PATH_SHOP_REDACT}: ${error}`); 500 | } 501 | }); 502 | 503 | router.post( 504 | "/graphql", 505 | verifyRequest({ returnHeader: true }), 506 | async (ctx, next) => { 507 | await Shopify.Utils.graphqlProxy(ctx.req, ctx.res); 508 | } 509 | ); 510 | 511 | router.get( 512 | "/api/memberships", 513 | verifyRequest({ returnHeader: true }), 514 | async (ctx, next) => { 515 | const discounts = []; 516 | try { 517 | const session = await Shopify.Utils.loadCurrentSession( 518 | ctx.req, 519 | ctx.res 520 | ); 521 | const client = new Shopify.Clients.Rest( 522 | session.shop, 523 | session.accessToken 524 | ); 525 | 526 | const metafieldsRes = await client.get({ 527 | path: "metafields", 528 | }); 529 | 530 | const memberships = metafieldsRes.body.metafields 531 | .filter((i) => i.key.indexOf(LOCK_METAFIELD_PREFIX) === 0) 532 | .map(({ id, value }) => { 533 | // Add details to locks if available 534 | const detailsMetafield = metafieldsRes.body.metafields.find( 535 | (i) => i.key === LOCKDETAILS_METAFIELD_PREFIX + id 536 | ); 537 | if (detailsMetafield) { 538 | const details = JSON.parse(detailsMetafield.value); 539 | 540 | return { 541 | metafieldId: id, 542 | ...details, 543 | }; 544 | } 545 | 546 | return { 547 | metafieldId: id, 548 | lockName: value, 549 | }; 550 | }); 551 | 552 | // Also return all discounts 553 | // First get price-rules (needed to retrieve discount_codes) 554 | const priceRulesRes = await client.get({ 555 | path: "price_rules", 556 | }); 557 | const priceRules = priceRulesRes.body.price_rules; 558 | // console.log('priceRules', priceRules); 559 | 560 | const codes = priceRules 561 | .filter(({ value_type, once_per_customer }) => { 562 | if (["fixed_amount", "percentage"].indexOf(value_type) === -1) 563 | return false; 564 | 565 | // Only general discount codes are supported at the moment. 566 | if (once_per_customer) return false; 567 | 568 | return true; 569 | }) 570 | .map(({ title }) => title); 571 | discounts.push(...codes); 572 | 573 | console.log("api/memberships discounts", discounts); 574 | ctx.body = { 575 | status: "success", 576 | data: { 577 | memberships, 578 | discounts, 579 | }, 580 | }; 581 | } catch (err) { 582 | console.log("api/memberships error", err); 583 | ctx.body = { 584 | status: "error", 585 | errors: "Unknown error occurred", 586 | }; 587 | } 588 | } 589 | ); 590 | 591 | // When adding a new lock, we first save it's address in a Shopify shop metafield. 592 | // The details of the lock are stored in a separate JSON metafield. 593 | router.post( 594 | "/api/addMembership", 595 | verifyRequest({ returnHeader: true }), 596 | async (ctx) => { 597 | try { 598 | const session = await Shopify.Utils.loadCurrentSession( 599 | ctx.req, 600 | ctx.res 601 | ); 602 | const client = new Shopify.Clients.Rest( 603 | session.shop, 604 | session.accessToken 605 | ); 606 | const payload = JSON.parse(ctx.request.body); 607 | if (!payload || !payload.lockName) { 608 | throw "lockName missing in request body"; 609 | } 610 | const { lockName } = payload; 611 | console.log("addMembership got lockName", lockName); 612 | 613 | const lockMetafieldKey = `${LOCK_METAFIELD_PREFIX}${uid( 614 | 30 - LOCK_METAFIELD_PREFIX.length 615 | )}`; 616 | 617 | const metafieldRes = await client.post({ 618 | path: "metafields", 619 | data: { 620 | metafield: { 621 | namespace: METAFIELD_NAMESPACE, 622 | key: lockMetafieldKey, 623 | value: lockName, 624 | type: "single_line_text_field", 625 | }, 626 | }, 627 | type: "application/json", 628 | }); 629 | const { metafield } = metafieldRes.body; 630 | 631 | ctx.body = { 632 | status: "success", 633 | data: { 634 | metafieldId: metafield.id, 635 | }, 636 | }; 637 | } catch (err) { 638 | console.log("Error in addMembership", err); 639 | ctx.body = { 640 | status: "error", 641 | errors: "Unknown error occurred", 642 | }; 643 | } 644 | } 645 | ); 646 | 647 | // Removing lock deletes the address- and JSON-metafield, as well as the script-tag and asset. 648 | router.post( 649 | "/api/removeMembership", 650 | verifyRequest({ returnHeader: true }), 651 | async (ctx) => { 652 | try { 653 | const session = await Shopify.Utils.loadCurrentSession( 654 | ctx.req, 655 | ctx.res 656 | ); 657 | const client = new Shopify.Clients.Rest( 658 | session.shop, 659 | session.accessToken 660 | ); 661 | const payload = JSON.parse(ctx.request.body); 662 | if (!payload || !payload.metafieldId) { 663 | throw "metafieldId missing in request body"; 664 | } 665 | const { metafieldId } = payload; 666 | console.log("removeLock got metafieldId", metafieldId); 667 | const lockDetailsKey = `${LOCKDETAILS_METAFIELD_PREFIX}${metafieldId}`; 668 | const detailsMetafieldRes = await client.get({ 669 | path: "metafields", 670 | query: { key: lockDetailsKey }, 671 | }); 672 | 673 | console.log("About to delete lock address", metafieldId); 674 | 675 | // Delete lock metafield 676 | try { 677 | await client.delete({ 678 | path: `metafields/${metafieldId}`, 679 | }); 680 | } catch (err) { 681 | console.log("Error trying to delete lock metafield", err); 682 | } 683 | 684 | if (detailsMetafieldRes.body.metafields.length > 0) { 685 | const { id, value } = detailsMetafieldRes.body.metafields[0]; 686 | console.log("About to delete details metafield and script tag", id); 687 | 688 | // Delete details metafield 689 | try { 690 | await client.delete({ 691 | path: `metafields/${id}`, 692 | }); 693 | } catch (err) { 694 | console.log("Error trying to delete details metafield", err); 695 | } 696 | 697 | // Delete script tag 698 | const lockDetails = JSON.parse(value); 699 | const { scriptTagId } = lockDetails; 700 | if (scriptTagId) { 701 | try { 702 | await client.delete({ 703 | path: `script_tags/${scriptTagId}`, 704 | }); 705 | } catch (err) { 706 | console.log("Error trying to delete scriptTag", err); 707 | } 708 | } 709 | } 710 | 711 | // Delete script asset 712 | // TODO: Only if this is the last membership to be deleted 713 | /* 714 | await client.delete({ 715 | path: "assets", 716 | query: { "asset[key]": `assets/${SCRIPT_ASSET_KEY}` }, 717 | }); 718 | */ 719 | 720 | // TODO: Update locks in theme sections (e.g. hero, blocks in topbar) 721 | 722 | ctx.body = { 723 | status: "success", 724 | }; 725 | } catch (err) { 726 | console.log("Error in removeLock", err); 727 | ctx.body = { 728 | status: "error", 729 | errors: "Could not remove lock.", 730 | }; 731 | } 732 | } 733 | ); 734 | 735 | // Save the details of a lock in another metafield, which has keys of pattern: 'info' + lockMetafieldId 736 | // The content of this metafield is later exposed to the public via liquid variables in the scriptTag, theme section, or custom code snippets. 737 | router.post( 738 | "/api/saveMembership", 739 | verifyRequest({ returnHeader: true }), 740 | async (ctx) => { 741 | let lockDetails, scriptTagId, lockDetailsMetafieldId, scriptTagSrc; 742 | const session = await Shopify.Utils.loadCurrentSession(ctx.req, ctx.res); 743 | const client = new Shopify.Clients.Rest( 744 | session.shop, 745 | session.accessToken 746 | ); 747 | const payload = JSON.parse(ctx.request.body); 748 | console.log("saveMembership payload", payload); 749 | if (!payload || !payload.lockAddresses) { 750 | throw "lockAddresses missing in request body"; 751 | } 752 | if (!payload || !payload.lockName) { 753 | throw "lockName missing in request body"; 754 | } 755 | const { 756 | metafieldId, 757 | lockAddresses, 758 | lockName, 759 | isEnabled, 760 | discountId, 761 | otherMemberships, 762 | } = payload; 763 | 764 | const lockDetailsKey = `${LOCKDETAILS_METAFIELD_PREFIX}${metafieldId}`; 765 | console.log("lockDetailsKey", lockDetailsKey); 766 | console.log("otherMemberships", otherMemberships); 767 | 768 | // Lock must create theme section assets: 769 | 770 | // 1) Hero 771 | console.log("Creating Hero asset"); 772 | try { 773 | const sectionName = `mb-hero.liquid`; 774 | const assetsRes = await client.put({ 775 | path: "assets", 776 | data: { 777 | asset: { 778 | key: `sections/${sectionName}`, 779 | value: getHeroSectionCode( 780 | lockAddresses, 781 | lockName, 782 | otherMemberships 783 | ), 784 | }, 785 | }, 786 | type: "application/json", 787 | }); 788 | 789 | if (!assetsRes.body.asset) { 790 | console.log("Invalid put assetsRes", sectionName, assetsRes); 791 | throw "Missing asset.public_url"; 792 | } 793 | } catch (err) { 794 | console.log( 795 | "Error trying to save Hero theme section in saveMembership, err json:", 796 | JSON.stringify(err) 797 | ); 798 | ctx.body = { 799 | status: "error", 800 | errors: "Could not create hero theme section for membership.", 801 | }; 802 | 803 | return; 804 | } 805 | 806 | // 2) Topbar 807 | console.log("Creating Topbar asset"); 808 | try { 809 | const sectionName = "mb-topbar.liquid"; 810 | const topBarSectionCode = getTopbarSectionCode( 811 | sectionName, 812 | lockAddresses, 813 | lockName, 814 | otherMemberships 815 | ); 816 | const assetsRes = await client.put({ 817 | path: "assets", 818 | data: { 819 | asset: { 820 | key: `sections/${sectionName}`, 821 | value: topBarSectionCode, 822 | }, 823 | }, 824 | type: "application/json", 825 | }); 826 | if (!assetsRes.body.asset) { 827 | console.log("Invalid put assetsRes", sectionName, assetsRes); 828 | throw "Missing asset.public_url"; 829 | } 830 | } catch (err) { 831 | console.log( 832 | "Error trying to save topbar theme section in saveMembership", 833 | err 834 | ); 835 | ctx.body = { 836 | status: "error", 837 | errors: "Could not create topbar theme section for lock.", 838 | }; 839 | 840 | return; 841 | } 842 | 843 | // TODO: there only needs to be one scriptTag for all memberships 844 | // If the membership is enabled, add its info to script 845 | if (isEnabled) { 846 | console.log("Creating JS asset (for scriptTag)"); 847 | try { 848 | const assetsRes = await client.get({ 849 | path: "assets", 850 | query: { "asset[key]": `assets/${SCRIPT_ASSET_KEY}` }, 851 | }); 852 | // Throws invalid JSON error if it doesn't exist yet 853 | if (!assetsRes.body.asset || !assetsRes.body.asset.public_url) { 854 | console.log("Invalid get assetsRes", assetsRes); 855 | throw "Missing asset.public_url"; 856 | } 857 | console.log("Found existing assetsRes.body.asset"); 858 | } catch (err) { 859 | console.log("Error trying to get theme asset for scriptTag", err); 860 | console.log( 861 | "Presumably missing asset (ambiguous invalid json response), creating asset now" 862 | ); 863 | } 864 | 865 | try { 866 | const assetsRes = await client.put({ 867 | path: "assets", 868 | data: { 869 | asset: { 870 | key: `assets/${SCRIPT_ASSET_KEY}`, 871 | value: getMemberBenefitsJS( 872 | discountId, 873 | lockAddresses, 874 | lockName, 875 | otherMemberships 876 | ), 877 | }, 878 | }, 879 | type: "application/json", 880 | }); 881 | if (!assetsRes.body.asset || !assetsRes.body.asset.public_url) { 882 | console.log("Invalid put assetsRes", assetsRes); 883 | throw "Missing asset.public_url"; 884 | } 885 | console.log("New assetsRes.body.asset", assetsRes.body.asset); 886 | scriptTagSrc = assetsRes.body.asset.public_url; 887 | 888 | const detailsMetafieldRes = await client.get({ 889 | path: "metafields", 890 | query: { key: lockDetailsKey }, 891 | }); 892 | console.log( 893 | "detailsMetafieldRes.body.metafields", 894 | detailsMetafieldRes.body.metafields 895 | ); 896 | 897 | const { metafields } = detailsMetafieldRes.body; 898 | if (metafields.length > 0) { 899 | lockDetails = JSON.parse(metafields[0].value); 900 | lockDetailsMetafieldId = metafields[0].id; 901 | console.log("Lock details", lockDetails); 902 | 903 | // Delete existing scriptTag 904 | if (lockDetails.scriptTagId) { 905 | await client.delete({ 906 | path: `script_tags/${lockDetails.scriptTagId}`, 907 | }); 908 | } 909 | } 910 | } catch (err) { 911 | console.log("Error in saveMembership", err); 912 | ctx.body = { 913 | status: "error", 914 | errors: "Could not save membership.", 915 | }; 916 | 917 | return; 918 | } 919 | 920 | // Create new script tag 921 | const scriptTagRes = await client.post({ 922 | path: "script_tags", 923 | data: { 924 | script_tag: { 925 | event: "onload", 926 | src: scriptTagSrc, 927 | display_scope: "online_store", 928 | }, 929 | }, 930 | type: "application/json", 931 | }); 932 | scriptTagId = scriptTagRes.body.script_tag.id; 933 | } 934 | 935 | // Update or create lock details metafield 936 | await client.post({ 937 | path: "metafields", 938 | data: { 939 | metafield: { 940 | namespace: METAFIELD_NAMESPACE, 941 | key: lockDetailsKey, 942 | value: JSON.stringify({ 943 | lockAddresses, 944 | lockName, 945 | isEnabled, 946 | discountId, 947 | scriptTagId, 948 | }), 949 | type: "json", 950 | }, 951 | }, 952 | type: "application/json", 953 | }); 954 | 955 | ctx.body = { 956 | status: "success", 957 | data: { scriptTagId }, 958 | }; 959 | } 960 | ); 961 | 962 | router.get( 963 | "/api/reset", 964 | verifyRequest({ returnHeader: true }), 965 | async (ctx) => { 966 | try { 967 | const session = await Shopify.Utils.loadCurrentSession( 968 | ctx.req, 969 | ctx.res 970 | ); 971 | const client = new Shopify.Clients.Rest( 972 | session.shop, 973 | session.accessToken 974 | ); 975 | const scriptTagsRes = await client.get({ 976 | path: "script_tags", 977 | }); 978 | const scriptTags = scriptTagsRes.body.script_tags; 979 | console.log("Deleting scriptTags", scriptTags); 980 | scriptTags.map(async ({ id }) => { 981 | try { 982 | await client.delete({ 983 | path: `script_tags/${id}`, 984 | }); 985 | } catch (err) { 986 | console.log("Error trying to delete scriptTag", err); 987 | } 988 | }); 989 | 990 | const metafieldsRes = await client.get({ 991 | path: "metafields", 992 | }); 993 | const { metafields } = metafieldsRes.body; 994 | console.log("Deleting metafields", metafields); 995 | metafields.map(async ({ id }) => { 996 | try { 997 | await client.delete({ 998 | path: `metafields/${id}`, 999 | }); 1000 | } catch (err) { 1001 | console.log("Error trying to delete metafield", err); 1002 | } 1003 | }); 1004 | 1005 | const assetsRes = await client.get({ 1006 | path: "assets", 1007 | }); 1008 | const appAssets = assetsRes.body.assets.filter( 1009 | ({ key }) => 1010 | key.indexOf(`assets/${SCRIPT_ASSET_KEY}`) === 0 || 1011 | key.indexOf("sections/mb-hero") === 0 || 1012 | key.indexOf("sections/mb-topbar") === 0 1013 | ); 1014 | console.log("Deleting assets", appAssets); 1015 | appAssets.map(async ({ key }) => { 1016 | // FIXME: API rate limit of 2 per second 1017 | await deleteAsset(client, key); 1018 | }); 1019 | 1020 | ctx.body = { 1021 | status: "success", 1022 | }; 1023 | } catch (err) { 1024 | console.log("api/reset error", err); 1025 | ctx.body = { 1026 | status: "error", 1027 | errors: "Could not reset", 1028 | }; 1029 | } 1030 | } 1031 | ); 1032 | 1033 | router.get("(/_next/static/.*)", handleRequest); // Static content is clear 1034 | router.get("/_next/webpack-hmr", handleRequest); // Webpack content is clear 1035 | router.get("(.*)", async (ctx) => { 1036 | const shop = ctx.query.shop; 1037 | 1038 | if (Shopify.Context.IS_EMBEDDED_APP && shop) { 1039 | ctx.res.setHeader( 1040 | "Content-Security-Policy", 1041 | `frame-ancestors https://${shop} https://admin.shopify.com;` 1042 | ); 1043 | } else { 1044 | ctx.res.setHeader("Content-Security-Policy", `frame-ancestors 'none';`); 1045 | } 1046 | 1047 | // This shop hasn't been seen yet, go through OAuth to create a session 1048 | if (ACTIVE_SHOPIFY_SHOPS[shop] === undefined) { 1049 | ctx.redirect(`/auth?shop=${shop}`); 1050 | } else { 1051 | await handleRequest(ctx); 1052 | } 1053 | }); 1054 | 1055 | server.use(router.allowedMethods()); 1056 | server.use(router.routes()); 1057 | 1058 | server.listen(port, () => { 1059 | console.log(`> Ready on http://localhost:${port}`); 1060 | }); 1061 | }); 1062 | --------------------------------------------------------------------------------