├── src ├── react-app-env.d.ts ├── index.css ├── constants.ts ├── index.tsx ├── AlertBar.tsx ├── VariantCard.tsx ├── i18n.ts ├── PrivacyPolicy.tsx ├── Subscription.tsx ├── api.ts ├── App.tsx └── Order.tsx ├── public ├── favicon.ico └── index.html ├── tsconfig.json ├── README.md ├── package.json ├── LICENSE └── .gitignore /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | display: block; 3 | margin: 0; 4 | } 5 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mthli/lemontree/master/public/favicon.ico -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | // Get Google API client ID. 2 | // https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid 3 | export const GOOGLE_OAUTH_CLIENT_ID = '1098077489169-lbdsrj1l2hk3f0ot3j0o33q89iaj1cdr.apps.googleusercontent.com' 4 | 5 | // Copied from Lemon Squeezy dashboard. 6 | export const STORE_ID = '37298' 7 | export const PRODUCT_ID = '100616' 8 | export const ORDER_VARIANT_ID = '109679' 9 | export const SUBSCRIPTION_VARIANT_ID = '109680' 10 | 11 | // Python Server base URL. 12 | export const SERVER_BASE_URL = 'https://lemon.mthli.com' 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Lemon Tree 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lemontree 2 | 3 | A [lemonsqueepy](https://github.com/mthli/lemonsqueepy) example 🍋 4 | 5 | The test scenarios are: 6 | 7 | - [x] Check order is available or not 8 | - [x] Check subscription is available or not 9 | - [ ] Check license is available or not (not implemented in this example, but the API supports) 10 | - [x] Activate license 11 | 12 | ## Privacy Policy 13 | 14 | This App use these non-sensitive scopes of your Google Account: 15 | 16 | - `/auth/userinfo.email` See your primary Google Account email address 17 | - `/auth/userinfo.profile` See your personal info, including any personal info you've made publicly available 18 | 19 | ## License 20 | 21 | ``` 22 | BSD 3-Clause License 23 | 24 | Copyright (c) 2023, Matthew Lee 25 | ``` 26 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import { RouterProvider, createBrowserRouter } from 'react-router-dom' 4 | 5 | import { GoogleOAuthProvider } from '@react-oauth/google' 6 | import { GOOGLE_OAUTH_CLIENT_ID } from './constants' 7 | 8 | import App from './App' 9 | import PrivacyPolicy from './PrivacyPolicy' 10 | import './index.css' 11 | 12 | const router = createBrowserRouter([ 13 | { 14 | path: '/', 15 | element: , 16 | }, 17 | { 18 | path: '/privacy-policy', 19 | element: 20 | }, 21 | ]) 22 | 23 | const root = ReactDOM.createRoot( 24 | document.getElementById('root') as HTMLElement 25 | ) 26 | 27 | root.render( 28 | 29 | 30 | 31 | 32 | 33 | ) 34 | -------------------------------------------------------------------------------- /src/AlertBar.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from 'react'; 2 | 3 | import MuiAlert, { AlertColor, AlertProps } from '@mui/material/Alert' 4 | import Snackbar from '@mui/material/Snackbar' 5 | 6 | const Alert = forwardRef(function Alert(props, ref) { 7 | return 8 | }) 9 | 10 | const AlertBar = ({ 11 | severity = 'info', 12 | message, 13 | open, 14 | onClose, 15 | }: { 16 | severity: AlertColor, 17 | message: string, 18 | open: boolean, 19 | onClose: () => void, 20 | }) => { 21 | return ( 22 | 29 | 30 | {message} 31 | 32 | 33 | ) 34 | } 35 | 36 | export default AlertBar 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lemontree", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@emotion/react": "^11.11.1", 7 | "@emotion/styled": "^11.11.0", 8 | "@mui/material": "^5.14.3", 9 | "@react-oauth/google": "^0.11.1", 10 | "@types/node": "^16.18.39", 11 | "@types/react": "^18.2.18", 12 | "@types/react-dom": "^18.2.7", 13 | "github-markdown-css": "^5.2.0", 14 | "i18next": "^23.4.1", 15 | "i18next-browser-languagedetector": "^7.1.0", 16 | "localforage": "^1.10.0", 17 | "match-sorter": "^6.3.1", 18 | "react": "^18.2.0", 19 | "react-dom": "^18.2.0", 20 | "react-i18next": "^13.0.3", 21 | "react-markdown": "^8.0.7", 22 | "react-router-dom": "^6.15.0", 23 | "react-scripts": "5.0.1", 24 | "sort-by": "^1.2.0", 25 | "swr": "^2.2.0", 26 | "typescript": "^4.9.5", 27 | "usehooks-ts": "^2.9.1" 28 | }, 29 | "scripts": { 30 | "start": "react-scripts start", 31 | "build": "react-scripts build" 32 | }, 33 | "eslintConfig": { 34 | "extends": [ 35 | "react-app" 36 | ] 37 | }, 38 | "browserslist": { 39 | "production": [ 40 | ">0.2%", 41 | "not dead", 42 | "not op_mini all" 43 | ], 44 | "development": [ 45 | "last 1 chrome version", 46 | "last 1 firefox version", 47 | "last 1 safari version" 48 | ] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023, Matthew Lee 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /src/VariantCard.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@mui/material/Button' 2 | import Card from '@mui/material/Card' 3 | import CardContent from '@mui/material/CardContent' 4 | import Link from '@mui/material/Link' 5 | import Typography from '@mui/material/Typography' 6 | 7 | import { Trans, useTranslation } from 'react-i18next' 8 | import './i18n' 9 | 10 | const VariantCard = ({ 11 | name, 12 | price, 13 | desc1, 14 | desc2, 15 | desc3Key, 16 | checkoutText, 17 | checkoutUrl, 18 | anonymous, 19 | }: { 20 | name: string, 21 | price: string, 22 | desc1: string, 23 | desc2: string, 24 | desc3Key: string, 25 | checkoutText: string, 26 | checkoutUrl: string, 27 | anonymous: boolean, 28 | }) => { 29 | const { t } = useTranslation() 30 | return ( 31 | 32 | 33 | 39 | {name} 40 | 41 | 42 | {price} 43 | 49 | {t('usd').toString()} 50 | 51 | 52 | 57 | {desc1} 58 | 59 | 60 | {desc2} 61 | 62 | 63 | 64 | link 65 | 66 | 67 | 75 | 76 | 77 | ) 78 | } 79 | 80 | export default VariantCard 81 | -------------------------------------------------------------------------------- /src/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18n from 'i18next' 2 | 3 | import LanguageDetector from 'i18next-browser-languagedetector' 4 | import { initReactI18next } from 'react-i18next' 5 | 6 | const resources = { 7 | en: { 8 | translation: { 9 | 'subtitle': 'A <0>lemonsqueepy example 🍋', 10 | 'not_signed_in': 'Please Sign in with Google first 👀', 11 | 'has_signed_in': 'Sign in successful ✅', 12 | 'privacy_policy': 'Privacy Policy', 13 | 'thanks': 'Thanks for your support 🖤', 14 | 'variant': 'Variant', 15 | 'usd': 'USD', 16 | 17 | 'check': 'Check', 18 | 'available': 'Available', 19 | 'unavailable': 'Unavailable', 20 | 21 | '30_days_validity': '• 30 days validity 🌖', 22 | '32_activation_tests': '• 32 activation tests 🚀', 23 | 'invoices_and_receipts': '• <0>Invoices and receipts 🧾', 24 | 'buy_license': 'Buy License', 25 | 'license': 'License', 26 | 'activate': 'Activate', 27 | 28 | '1_day_free_trial': '• 1 day free trial 💎', 29 | 'renew_after_30_days': '• Renew after 30 days 🙌', 30 | 'manage_subscription': '• <0>Manage subscription 💳', 31 | 'subscribe': 'Subscribe', 32 | }, 33 | }, 34 | zh: { 35 | translation: { 36 | 'subtitle': '一个 <0>lemonsqueepy 示例 🍋', 37 | 'not_signed_in': '请先使用 Google 帐号登录 👀', 38 | 'has_signed_in': '已登录 ✅', 39 | 'privacy_policy': '隐私政策', 40 | 'thanks': '感谢您的资瓷 🖤', 41 | 'variant': '产品变体', 42 | 'usd': '美元', 43 | 44 | 'check': '校验', 45 | 'available': '可以使用', 46 | 'unavailable': '不可使用', 47 | 48 | '30_days_validity': '• 30 天有效期 🌖', 49 | '32_activation_tests': '• 32 次激活测试 🚀', 50 | 'invoices_and_receipts': '• <0>提供发票和收据 🧾', 51 | 'buy_license': '购买证书', 52 | 'activate': '激活', 53 | 'license': '证书', 54 | 55 | '1_day_free_trial': '• 免费试用 1 天 💎', 56 | 'renew_after_30_days': '• 30 天后自动续费 🙌', 57 | 'manage_subscription': '• <0>管理订阅 💳', 58 | 'subscribe': '我要订阅', 59 | }, 60 | }, 61 | } 62 | 63 | i18n 64 | .use(LanguageDetector) 65 | .use(initReactI18next) 66 | .init({ 67 | resources, 68 | fallbackLng: 'en', 69 | 70 | interpolation: { 71 | escapeValue: false, 72 | }, 73 | }) 74 | 75 | export default i18n 76 | -------------------------------------------------------------------------------- /src/PrivacyPolicy.tsx: -------------------------------------------------------------------------------- 1 | import Box from '@mui/material/Box' 2 | import Container from '@mui/material/Container' 3 | import ReactMarkdown from 'react-markdown' 4 | 5 | import 'github-markdown-css' 6 | 7 | const TEXT = ` 8 | # Privacy Policy 9 | 10 | At Sign in with Google, we prioritize the privacy and security of our users. 11 | 12 | This Privacy Policy outlines how we collect, use, and protect your personal information when you use our sign-in service. 13 | 14 | ## 1. Information We Collect 15 | 16 | When you choose to Sign in with Google, we will collect: 17 | 18 | - Your name. 19 | - Your email address. 20 | - Your profile picture (if available). 21 | 22 | ## 2. How We Use Your Information 23 | 24 | We use the information collected during the sign-in process to: 25 | 26 | - Authenticate your identity and provide secure access to our services. 27 | - Check your order, subscription or license available. 28 | 29 | ## 3. Information Sharing 30 | 31 | We do not sell, trade, or rent your personal information to third parties. 32 | 33 | ## 4. Data Security 34 | 35 | We take the security of your personal information seriously and have implemented appropriate measures to protect it from unauthorized access, alteration, disclosure, or destruction. 36 | 37 | However, please note that no method of transmission over the internet or electronic storage is 100% secure, and we can't guarantee 100% security. 38 | 39 | ## 5. Data Retention 40 | 41 | We retain your personal information for as long as necessary to fulfill the purposes outlined in this Privacy Policy, unless a longer retention period is required or permitted by law. 42 | 43 | ## 6. Your Rights 44 | 45 | You have the right to access, update, and delete your personal information. 46 | 47 | If you wish to exercise these rights or have any questions or concerns about our privacy practices, please contact us using the information below. 48 | 49 | ## 7. Changes to this Privacy Policy 50 | 51 | We may update this Privacy Policy from time to time to reflect changes in our practices or legal obligations. 52 | 53 | We encourage you to review this Privacy Policy periodically for any updates. 54 | 55 | ## 8. Contact Us 56 | 57 | If you have any questions or concerns about this Privacy Policy or our privacy practices, please contact us at 👀 58 | ` 59 | 60 | const PrivacyPolicy = () => { 61 | return ( 62 | 73 | 74 | 75 | {TEXT} 76 | 77 | 78 | 79 | ) 80 | } 81 | 82 | export default PrivacyPolicy 83 | -------------------------------------------------------------------------------- /src/Subscription.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | import { AlertColor } from '@mui/material/Alert' 4 | import Box from '@mui/material/Box' 5 | import Button from '@mui/material/Button' 6 | 7 | import AlertBar from './AlertBar' 8 | import VariantCard from './VariantCard' 9 | 10 | import { useTranslation } from 'react-i18next' 11 | import './i18n' 12 | 13 | import { useCheckSubscription } from './api' 14 | import { STORE_ID, PRODUCT_ID, SUBSCRIPTION_VARIANT_ID } from './constants' 15 | 16 | const Subscription = ({ 17 | userId = '', 18 | userToken = '', 19 | email = '', 20 | width, 21 | marginTop, 22 | }: { 23 | userId?: string, 24 | userToken?: string, 25 | email?: string, 26 | width?: string, 27 | marginTop?: string, 28 | }) => { 29 | const { t } = useTranslation() 30 | const [check, setCheck] = useState(0) 31 | const [alertOpen, setAlertOpen] = useState(false) 32 | 33 | const { data, error, isLoading } = useCheckSubscription( 34 | check, 35 | userToken, 36 | STORE_ID, 37 | PRODUCT_ID, 38 | SUBSCRIPTION_VARIANT_ID, 39 | ) 40 | 41 | const { available = false } = data || {} 42 | const alertSeverity: AlertColor = error ? 'error' : (available ? 'success' : 'warning') 43 | const alertMessage = t(available ? 'available' : 'unavailable').toString() 44 | 45 | // Must pass custom `user_id` for making it easy to identify the user in our server side. 46 | // https://docs.lemonsqueezy.com/help/checkout/passing-custom-data#passing-custom-data-in-checkout-links 47 | const checkoutUrl = 'https://mthli.lemonsqueezy.com/checkout/buy/fce1d1a0-1e52-4c68-a75f-4aa4a114631b' 48 | + '?media=0&discount=0' // set checkout page style. 49 | + `&checkout[custom][user_id]=${userId}` // required. 50 | + `&checkout[email]=${email}` // optional; pre-filling. 51 | 52 | return ( 53 | <> 54 | 55 | 65 | 77 | 78 | { 79 | data && !isLoading && 80 | setAlertOpen(false)} 85 | /> 86 | } 87 | 88 | ) 89 | } 90 | 91 | export default Subscription 92 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 132 | 133 | # dependencies 134 | /node_modules 135 | /.pnp 136 | .pnp.js 137 | 138 | # testing 139 | /coverage 140 | 141 | # production 142 | /build 143 | 144 | # misc 145 | .DS_Store 146 | .env.local 147 | .env.development.local 148 | .env.test.local 149 | .env.production.local 150 | 151 | npm-debug.log* 152 | yarn-debug.log* 153 | yarn-error.log* 154 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | 3 | import { SERVER_BASE_URL } from './constants' 4 | 5 | export class RequestError extends Error { 6 | code: number; // HTTP Status Code. 7 | 8 | constructor(code: number, name: string, message: string) { 9 | super() 10 | this.code = code 11 | this.name = name 12 | this.message = message 13 | } 14 | } 15 | 16 | export const useGoogleOAuth = ( 17 | credential: string, 18 | userToken: string = '', 19 | verifyExp: boolean = false, 20 | ) => { 21 | return useSWR( 22 | [`${SERVER_BASE_URL}/api/user/oauth/google`, credential, verifyExp], 23 | async ([url, credential]) => { 24 | const res = await fetch(url, { 25 | method: 'POST', 26 | headers: { 'Content-Type': 'application/json' }, 27 | body: JSON.stringify({ 28 | 'credential': credential, 29 | 'user_token': userToken, 30 | 'verify_exp': verifyExp, 31 | }) 32 | }) 33 | 34 | const body = await res.json() 35 | if (!res.ok) throw new RequestError(body['code'], body['name'], body['message']) 36 | return body 37 | }, 38 | { 39 | errorRetryCount: 0, 40 | revalidateOnFocus: false, 41 | }) 42 | } 43 | 44 | const useCheckVariant = ( 45 | check: number, 46 | apiUrl: string, 47 | userToken: string, 48 | storeId: string, 49 | productId: string, 50 | variantId: string, 51 | testMode: boolean = false, 52 | ) => { 53 | const params = new URLSearchParams({ 54 | 'user_token': userToken, 55 | 'store_id': storeId, 56 | 'product_id': productId, 57 | 'variant_id': variantId, 58 | 'test_mode': testMode ? 'true' : 'false', 59 | }).toString() 60 | 61 | return useSWR( 62 | check ? [check, `${apiUrl}?${params}`] : null, 63 | async ([_check, url]) => { 64 | const res = await fetch(url) 65 | const body = await res.json() 66 | if (!res.ok) throw new RequestError(body['code'], body['name'], body['message']) 67 | return body 68 | }, 69 | { 70 | errorRetryCount: 0, 71 | revalidateOnFocus: false, 72 | }) 73 | } 74 | 75 | export const useCheckOrder = ( 76 | check: number, 77 | userToken: string, 78 | storeId: string, 79 | productId: string, 80 | variantId: string, 81 | testMode: boolean = false, 82 | ) => { 83 | const apiUrl = `${SERVER_BASE_URL}/api/orders/check` 84 | return useCheckVariant(check, apiUrl, userToken, storeId, productId, variantId, testMode) 85 | } 86 | 87 | export const useCheckSubscription = ( 88 | check: number, 89 | userToken: string, 90 | storeId: string, 91 | productId: string, 92 | variantId: string, 93 | testMode: boolean = false, 94 | ) => { 95 | const apiUrl = `${SERVER_BASE_URL}/api/subscriptions/check` 96 | return useCheckVariant(check, apiUrl, userToken, storeId, productId, variantId, testMode) 97 | } 98 | 99 | export const useActivateLicense = ( 100 | activate: number, 101 | licenseKey: string, 102 | instanceName: string, 103 | ) => { 104 | return useSWR( 105 | activate ? [activate, `${SERVER_BASE_URL}/api/licenses/activate`, licenseKey, instanceName] : null, 106 | async ([_activate, url, licenseKey, instanceName]) => { 107 | const res = await fetch(url, { 108 | method: 'POST', 109 | headers: { 'Content-Type': 'application/json' }, 110 | body: JSON.stringify({ 111 | 'license_key': licenseKey, 112 | 'instance_name': instanceName, 113 | }) 114 | }) 115 | 116 | const body = await res.json() 117 | if (!res.ok) throw new RequestError(body['code'], body['name'], body['message']) 118 | return body 119 | }, 120 | { 121 | errorRetryCount: 0, 122 | revalidateOnFocus: false, 123 | }) 124 | } 125 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useSessionStorage } from 'usehooks-ts' 2 | 3 | import Box from '@mui/material/Box' 4 | import Container from '@mui/material/Container' 5 | import Link from '@mui/material/Link' 6 | import Typography from '@mui/material/Typography' 7 | 8 | import { GoogleLogin } from '@react-oauth/google' 9 | import { useGoogleOAuth } from './api' 10 | 11 | import { Trans, useTranslation } from 'react-i18next' 12 | import './i18n' 13 | 14 | import Order from './Order' 15 | import Subscription from './Subscription' 16 | 17 | const WIDTH = '336px' 18 | 19 | const App = () => { 20 | const { t } = useTranslation() 21 | 22 | // Persist the state with session storage so that it remains after a page refresh. 23 | const [credential, setCredential] = useSessionStorage('google-login-credential', '') 24 | 25 | // If the credential has expired, the `error.code` is 401. 26 | // 27 | // But in this example we don't need to check the credential has expired, 28 | // because we only use the "email" field in out server side. 29 | const { data: user = {}, /* error */ } = useGoogleOAuth(credential, '', false) 30 | const { id: userId = '', token: userToken = '', email = '' } = user 31 | 32 | return ( 33 | 44 | 45 | Lemon Tree 46 | 47 | 48 | link 49 | 50 | 51 | 52 | {t(!userId ? 'not_signed_in' : 'has_signed_in').toString()} 53 | 54 | 55 | 56 | {t('privacy_policy').toString()} 57 | 58 |  🔗 59 | 60 | 61 | 62 | { 66 | setCredential(c) 67 | 68 | // https://github.com/vercel/next.js/discussions/51135 69 | // https://github.com/MomenSherif/react-oauth/issues/289 70 | // 71 | // FIXME (Matthew Lee) 72 | // We have to refresh again after success, because of 73 | // 'Cross-Origin-Opener-Policy policy would block the window.postMessage call' for now. 74 | window.location.reload() 75 | }} 76 | onError={() => { 77 | // DO NOTHING. 78 | }} 79 | /> 80 | 81 | 89 | 96 | 103 | 104 | 118 | {t('thanks').toString()} 119 | 120 | 121 | ); 122 | } 123 | 124 | export default App 125 | -------------------------------------------------------------------------------- /src/Order.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | import { AlertColor } from '@mui/material/Alert' 4 | import Box from '@mui/material/Box' 5 | import Button from '@mui/material/Button' 6 | import OutlinedInput from '@mui/material/OutlinedInput' 7 | 8 | import AlertBar from './AlertBar' 9 | import VariantCard from './VariantCard' 10 | 11 | import { useTranslation } from 'react-i18next' 12 | import './i18n' 13 | 14 | import { useCheckOrder, useActivateLicense } from './api' 15 | import { STORE_ID, PRODUCT_ID, ORDER_VARIANT_ID } from './constants' 16 | 17 | const Order = ({ 18 | userId = '', 19 | userToken = '', 20 | email = '', 21 | width, 22 | marginTop, 23 | }: { 24 | userId?: string, 25 | userToken?: string, 26 | email?: string, 27 | width?: string, 28 | marginTop?: string, 29 | }) => { 30 | const { t } = useTranslation() 31 | const [check, setCheck] = useState(0) 32 | const [activate, setActivate] = useState(0) 33 | const [licenseKey, setLicenseKey] = useState('') 34 | const [alertOpen, setAlertOpen] = useState(false) 35 | 36 | const { 37 | data: orderData, 38 | error: orderError, 39 | isLoading: isLoadingOrder, 40 | } = useCheckOrder( 41 | check, 42 | userToken, 43 | STORE_ID, 44 | PRODUCT_ID, 45 | ORDER_VARIANT_ID, 46 | ) 47 | 48 | const { 49 | data: licenseData, 50 | error: licenseError, 51 | isLoading: isLoadingLicense, 52 | } = useActivateLicense( 53 | activate, 54 | licenseKey, 55 | `instance_name_${activate}`, // just for example. 56 | ) 57 | 58 | // Simply show alert in this example. 59 | const { available: orderAvailable = false } = orderData || {} 60 | const { available: licenseAvailable = false } = licenseData || {} 61 | const alertSeverity: AlertColor = orderError || licenseError ? 'error' : (orderAvailable || licenseAvailable ? 'success' : 'warning') 62 | const alertMessage = t(orderAvailable || licenseAvailable ? 'available' : 'unavailable').toString() 63 | 64 | // Must pass custom `user_id` for making it easy to identify the user in our server side. 65 | // https://docs.lemonsqueezy.com/help/checkout/passing-custom-data#passing-custom-data-in-checkout-links 66 | const checkoutUrl = 'https://mthli.lemonsqueezy.com/checkout/buy/40500aae-2138-4345-a3f2-86695c5debec' 67 | + '?media=0&discount=0' // set checkout page style. 68 | + `&checkout[custom][user_id]=${userId}` // required. 69 | + `&checkout[email]=${email}` // optional; pre-filling. 70 | 71 | return ( 72 | <> 73 | 74 | 84 | 96 | 103 | setLicenseKey(value.trim())} 109 | /> 110 | 122 | 123 | 124 | { 125 | (orderData || licenseData) && !isLoadingOrder && !isLoadingLicense && 126 | setAlertOpen(false)} 131 | /> 132 | } 133 | 134 | ) 135 | } 136 | 137 | export default Order 138 | --------------------------------------------------------------------------------