├── declarations ├── mdx.d.ts ├── @mdx-js │ └── runtime.d.ts ├── @paypal │ └── @paypal │ │ └── checkout-server-sdk.d.ts ├── react-async-script-loader.d.ts └── augmented-next.d.ts ├── src ├── types │ ├── session.ts │ ├── user.ts │ ├── server.ts │ └── resolver.ts ├── constants │ ├── partner.ts │ ├── cookie.ts │ ├── roles.ts │ └── routes.ts ├── services │ ├── discount │ │ ├── types.ts │ │ ├── spec.ts │ │ └── index.ts │ ├── stripe │ │ └── index.ts │ ├── aws │ │ └── s3.js │ ├── slack │ │ ├── index.ts │ │ └── spec.ts │ ├── firebase │ │ ├── admin.js │ │ ├── client.ts │ │ └── course.ts │ ├── ga │ │ └── index.ts │ ├── convertkit │ │ ├── index.ts │ │ └── spec.ts │ ├── paypal │ │ └── index.ts │ ├── revue │ │ ├── index.ts │ │ └── spec.ts │ ├── format │ │ └── index.ts │ └── apollo │ │ └── withApollo.js ├── data │ ├── bundle-keys-types.ts │ ├── course-keys.ts │ ├── course-keys-types.ts │ ├── bundle-legacy.ts │ ├── migration.spec.ts │ ├── migration.ts │ └── bundle-keys.ts ├── context │ └── session.tsx ├── queries │ ├── community.ts │ ├── migrate.ts │ ├── user.ts │ ├── upgrade.ts │ ├── book.ts │ ├── stripe.ts │ ├── paypal.ts │ ├── storefront.ts │ ├── coupon.ts │ ├── session.ts │ ├── partner.ts │ └── course.ts ├── components │ ├── Form │ │ ├── Icon │ │ │ └── index.tsx │ │ ├── StretchedButton │ │ │ └── index.tsx │ │ ├── AtomButton │ │ │ └── index.tsx │ │ └── Item │ │ │ └── index.tsx │ ├── Mdx │ │ ├── Image.tsx │ │ ├── index.tsx │ │ └── Code.tsx │ ├── Loader │ │ └── index.tsx │ ├── Link │ │ └── index.tsx │ ├── Navigation │ │ ├── signOut.ts │ │ └── index.tsx │ ├── Layout │ │ └── index.tsx │ └── Head │ │ └── index.tsx ├── validation │ ├── partner.ts │ └── admin.ts ├── screens │ ├── Checkout │ │ ├── CheckoutWizardPay │ │ │ ├── Adapters │ │ │ │ ├── stripe.tsx │ │ │ │ └── paypal.tsx │ │ │ ├── PaypalCheckout │ │ │ │ ├── spec.tsx │ │ │ │ └── index.tsx │ │ │ ├── FreeCheckout │ │ │ │ ├── index.tsx │ │ │ │ └── spec.tsx │ │ │ └── StripeCheckout │ │ │ │ └── index.tsx │ │ ├── spec.tsx │ │ ├── index.tsx │ │ ├── CheckoutWizard │ │ │ └── index.tsx │ │ └── CheckoutWizardAccount │ │ │ └── index.tsx │ ├── CourseList │ │ └── spec.tsx │ ├── SignIn │ │ ├── spec.tsx │ │ ├── index.tsx │ │ └── SignInForm │ │ │ └── spec.tsx │ ├── SignUp │ │ ├── spec.tsx │ │ ├── index.tsx │ │ └── SignUpForm │ │ │ └── spec.tsx │ ├── Account │ │ ├── spec.tsx │ │ └── index.tsx │ ├── Partner │ │ ├── spec.tsx │ │ ├── Assets │ │ │ └── index.tsx │ │ ├── Payments │ │ │ └── index.tsx │ │ ├── Faq │ │ │ └── index.tsx │ │ ├── Visitors │ │ │ └── index.tsx │ │ ├── GetStarted │ │ │ └── index.tsx │ │ ├── Sales │ │ │ └── index.tsx │ │ └── index.tsx │ ├── CourseItem │ │ ├── spec.tsx │ │ ├── styles.tsx │ │ ├── Curriculum │ │ │ └── index.tsx │ │ ├── Cards │ │ │ ├── ArticleCard │ │ │ │ └── index.tsx │ │ │ └── VideoCard │ │ │ │ └── index.tsx │ │ ├── BookOnline │ │ │ └── index.tsx │ │ ├── Introduction │ │ │ └── index.tsx │ │ ├── Onboarding │ │ │ └── index.tsx │ │ └── BookDownload │ │ │ └── index.tsx │ ├── EmailChange │ │ ├── spec.tsx │ │ ├── index.tsx │ │ └── EmailChangeForm │ │ │ └── spec.tsx │ ├── CommunityJoin │ │ ├── spec.tsx │ │ ├── index.tsx │ │ └── CommunityJoinForm │ │ │ ├── index.tsx │ │ │ └── spec.tsx │ ├── PasswordForgot │ │ ├── spec.tsx │ │ ├── index.tsx │ │ └── PasswordForgotForm │ │ │ ├── index.tsx │ │ │ └── spec.tsx │ ├── PasswordChange │ │ ├── spec.tsx │ │ ├── index.tsx │ │ └── PasswordChangeForm │ │ │ └── spec.tsx │ └── Admin │ │ └── index.tsx ├── api │ ├── middleware │ │ ├── resolver │ │ │ ├── isAuthenticated.ts │ │ │ ├── isAdmin.ts │ │ │ ├── isPartner.ts │ │ │ └── isFreeCourse.ts │ │ └── global │ │ │ ├── sentry.ts │ │ │ └── me.ts │ └── resolvers │ │ ├── migration │ │ └── index.ts │ │ ├── index.ts │ │ ├── upgrade │ │ └── index.ts │ │ ├── user │ │ └── index.ts │ │ ├── book │ │ └── index.ts │ │ ├── coupon │ │ └── index.ts │ │ ├── storefront │ │ └── index.ts │ │ └── stripe │ │ └── index.ts ├── hooks │ ├── useErrorIndicator.ts │ └── useIndicators.ts ├── connectors │ ├── admin.ts │ ├── course.ts │ ├── coupon.ts │ └── partner.ts └── models │ ├── course.ts │ ├── coupon.ts │ ├── partner.ts │ └── index.ts ├── pages ├── admin.tsx ├── sign-in.tsx ├── sign-up.tsx ├── account.tsx ├── checkout.tsx ├── partner.tsx ├── index.tsx ├── email-change.tsx ├── p │ └── [unlocked-course-id].tsx ├── upgrade │ └── [upgradeable-course-id].tsx ├── community-join.tsx ├── password-change.tsx ├── password-forgot.tsx ├── styles.less ├── _document.js ├── api │ ├── graphql.ts │ └── stripe-webhook.ts └── _error.js ├── next-env.d.ts ├── public ├── facebook.png ├── twitter.png ├── favicon │ ├── favicon.ico │ ├── apple-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-96x96.png │ ├── ms-icon-144x144.png │ ├── ms-icon-150x150.png │ ├── ms-icon-310x310.png │ ├── ms-icon-70x70.png │ ├── apple-icon-57x57.png │ ├── apple-icon-60x60.png │ ├── apple-icon-72x72.png │ ├── apple-icon-76x76.png │ ├── android-icon-144x144.png │ ├── android-icon-192x192.png │ ├── android-icon-36x36.png │ ├── android-icon-48x48.png │ ├── android-icon-72x72.png │ ├── android-icon-96x96.png │ ├── apple-icon-114x114.png │ ├── apple-icon-120x120.png │ ├── apple-icon-144x144.png │ ├── apple-icon-152x152.png │ ├── apple-icon-180x180.png │ ├── apple-icon-precomposed.png │ ├── browserconfig.xml │ └── manifest.json └── logo.svg ├── .prettierrc ├── .travis.yml ├── assets └── antd-custom.less ├── webhook.sh ├── .gitignore ├── codegen.yml ├── .github └── FUNDING.yml ├── jest.config.js ├── .babelrc ├── tsconfig.json ├── jest.setup.js └── next.config.js /declarations/mdx.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.mdx'; 2 | -------------------------------------------------------------------------------- /src/types/session.ts: -------------------------------------------------------------------------------- 1 | export type Session = string | null; 2 | -------------------------------------------------------------------------------- /src/constants/partner.ts: -------------------------------------------------------------------------------- 1 | export const PARTNER_PERCENTAGE = 45; 2 | -------------------------------------------------------------------------------- /declarations/@mdx-js/runtime.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@mdx-js/runtime'; 2 | -------------------------------------------------------------------------------- /src/constants/cookie.ts: -------------------------------------------------------------------------------- 1 | export const EXPIRES_IN = 7 * 24 * 60 * 60 * 1000; 2 | -------------------------------------------------------------------------------- /pages/admin.tsx: -------------------------------------------------------------------------------- 1 | import Admin from '@screens/Admin'; 2 | 3 | export default Admin; 4 | -------------------------------------------------------------------------------- /pages/sign-in.tsx: -------------------------------------------------------------------------------- 1 | import SignIn from '@screens/SignIn'; 2 | 3 | export default SignIn; 4 | -------------------------------------------------------------------------------- /pages/sign-up.tsx: -------------------------------------------------------------------------------- 1 | import SignUp from '@screens/SignUp'; 2 | 3 | export default SignUp; 4 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /pages/account.tsx: -------------------------------------------------------------------------------- 1 | import Account from '@screens/Account'; 2 | 3 | export default Account; 4 | -------------------------------------------------------------------------------- /pages/checkout.tsx: -------------------------------------------------------------------------------- 1 | import Checkout from '@screens/Checkout'; 2 | 3 | export default Checkout; 4 | -------------------------------------------------------------------------------- /pages/partner.tsx: -------------------------------------------------------------------------------- 1 | import Partner from '@screens/Partner'; 2 | 3 | export default Partner; 4 | -------------------------------------------------------------------------------- /src/constants/roles.ts: -------------------------------------------------------------------------------- 1 | export const ADMIN = 'admin'; 2 | export const PARTNER = 'partner'; 3 | -------------------------------------------------------------------------------- /src/services/discount/types.ts: -------------------------------------------------------------------------------- 1 | export type Ppp = { 2 | pppConversionFactor: number; 3 | }; 4 | -------------------------------------------------------------------------------- /declarations/@paypal/@paypal/checkout-server-sdk.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@paypal/checkout-server-sdk'; 2 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import CourseList from '@screens/CourseList'; 2 | 3 | export default CourseList; 4 | -------------------------------------------------------------------------------- /src/data/bundle-keys-types.ts: -------------------------------------------------------------------------------- 1 | export type BUNDLE = 'STUDENT' | 'INTERMEDIATE' | 'PROFESSIONAL'; 2 | -------------------------------------------------------------------------------- /pages/email-change.tsx: -------------------------------------------------------------------------------- 1 | import EmailChange from '@screens/EmailChange'; 2 | 3 | export default EmailChange; 4 | -------------------------------------------------------------------------------- /public/facebook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/nextjs-firebase-authentication/HEAD/public/facebook.png -------------------------------------------------------------------------------- /public/twitter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/nextjs-firebase-authentication/HEAD/public/twitter.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "es5", 4 | "singleQuote": true, 5 | "printWidth": 70 6 | } -------------------------------------------------------------------------------- /pages/p/[unlocked-course-id].tsx: -------------------------------------------------------------------------------- 1 | import CourseItem from '@screens/CourseItem'; 2 | 3 | export default CourseItem; 4 | -------------------------------------------------------------------------------- /pages/upgrade/[upgradeable-course-id].tsx: -------------------------------------------------------------------------------- 1 | import Upgrade from '@screens/Upgrade'; 2 | 3 | export default Upgrade; 4 | -------------------------------------------------------------------------------- /pages/community-join.tsx: -------------------------------------------------------------------------------- 1 | import CommunityJoin from '@screens/CommunityJoin'; 2 | 3 | export default CommunityJoin; 4 | -------------------------------------------------------------------------------- /pages/password-change.tsx: -------------------------------------------------------------------------------- 1 | import PasswordChange from '@screens/PasswordChange'; 2 | 3 | export default PasswordChange; 4 | -------------------------------------------------------------------------------- /pages/password-forgot.tsx: -------------------------------------------------------------------------------- 1 | import PasswordForgot from '@screens/PasswordForgot'; 2 | 3 | export default PasswordForgot; 4 | -------------------------------------------------------------------------------- /public/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/nextjs-firebase-authentication/HEAD/public/favicon/favicon.ico -------------------------------------------------------------------------------- /public/favicon/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/nextjs-firebase-authentication/HEAD/public/favicon/apple-icon.png -------------------------------------------------------------------------------- /src/services/stripe/index.ts: -------------------------------------------------------------------------------- 1 | const stripe = require('stripe')(process.env.STRIPE_CLIENT_SECRET); 2 | 3 | export default stripe; 4 | -------------------------------------------------------------------------------- /public/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/nextjs-firebase-authentication/HEAD/public/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/nextjs-firebase-authentication/HEAD/public/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/nextjs-firebase-authentication/HEAD/public/favicon/favicon-96x96.png -------------------------------------------------------------------------------- /public/favicon/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/nextjs-firebase-authentication/HEAD/public/favicon/ms-icon-144x144.png -------------------------------------------------------------------------------- /public/favicon/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/nextjs-firebase-authentication/HEAD/public/favicon/ms-icon-150x150.png -------------------------------------------------------------------------------- /public/favicon/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/nextjs-firebase-authentication/HEAD/public/favicon/ms-icon-310x310.png -------------------------------------------------------------------------------- /public/favicon/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/nextjs-firebase-authentication/HEAD/public/favicon/ms-icon-70x70.png -------------------------------------------------------------------------------- /src/types/user.ts: -------------------------------------------------------------------------------- 1 | import * as firebaseAdminVanilla from 'firebase-admin'; 2 | 3 | export type User = firebaseAdminVanilla.auth.UserRecord; 4 | -------------------------------------------------------------------------------- /public/favicon/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/nextjs-firebase-authentication/HEAD/public/favicon/apple-icon-57x57.png -------------------------------------------------------------------------------- /public/favicon/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/nextjs-firebase-authentication/HEAD/public/favicon/apple-icon-60x60.png -------------------------------------------------------------------------------- /public/favicon/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/nextjs-firebase-authentication/HEAD/public/favicon/apple-icon-72x72.png -------------------------------------------------------------------------------- /public/favicon/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/nextjs-firebase-authentication/HEAD/public/favicon/apple-icon-76x76.png -------------------------------------------------------------------------------- /public/favicon/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/nextjs-firebase-authentication/HEAD/public/favicon/android-icon-144x144.png -------------------------------------------------------------------------------- /public/favicon/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/nextjs-firebase-authentication/HEAD/public/favicon/android-icon-192x192.png -------------------------------------------------------------------------------- /public/favicon/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/nextjs-firebase-authentication/HEAD/public/favicon/android-icon-36x36.png -------------------------------------------------------------------------------- /public/favicon/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/nextjs-firebase-authentication/HEAD/public/favicon/android-icon-48x48.png -------------------------------------------------------------------------------- /public/favicon/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/nextjs-firebase-authentication/HEAD/public/favicon/android-icon-72x72.png -------------------------------------------------------------------------------- /public/favicon/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/nextjs-firebase-authentication/HEAD/public/favicon/android-icon-96x96.png -------------------------------------------------------------------------------- /public/favicon/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/nextjs-firebase-authentication/HEAD/public/favicon/apple-icon-114x114.png -------------------------------------------------------------------------------- /public/favicon/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/nextjs-firebase-authentication/HEAD/public/favicon/apple-icon-120x120.png -------------------------------------------------------------------------------- /public/favicon/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/nextjs-firebase-authentication/HEAD/public/favicon/apple-icon-144x144.png -------------------------------------------------------------------------------- /public/favicon/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/nextjs-firebase-authentication/HEAD/public/favicon/apple-icon-152x152.png -------------------------------------------------------------------------------- /public/favicon/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/nextjs-firebase-authentication/HEAD/public/favicon/apple-icon-180x180.png -------------------------------------------------------------------------------- /src/context/session.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const SessionContext = React.createContext(null); 4 | 5 | export default SessionContext; 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - stable 5 | 6 | install: 7 | - npm install 8 | 9 | script: 10 | - npm run test:ci 11 | -------------------------------------------------------------------------------- /public/favicon/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/nextjs-firebase-authentication/HEAD/public/favicon/apple-icon-precomposed.png -------------------------------------------------------------------------------- /declarations/react-async-script-loader.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-async-script-loader' { 2 | const scriptLoader: any; 3 | export default scriptLoader; 4 | } 5 | -------------------------------------------------------------------------------- /assets/antd-custom.less: -------------------------------------------------------------------------------- 1 | // https://github.com/ant-design/ant-design/blob/master/components/style/themes/default.less 2 | 3 | @primary-color: #823eb7; 4 | 5 | @border-radius-base: 2px; 6 | -------------------------------------------------------------------------------- /src/queries/community.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const COMMUNITY_JOIN = gql` 4 | mutation CommunityJoin($email: String!) { 5 | communityJoin(email: $email) 6 | } 7 | `; 8 | -------------------------------------------------------------------------------- /pages/styles.less: -------------------------------------------------------------------------------- 1 | // https://github.com/ant-design/ant-design/issues/15696#issuecomment-683440468 2 | 3 | @import '~antd/dist/antd.less'; 4 | 5 | @primary-color: #823eb7; 6 | @border-radius-base: 2px; 7 | -------------------------------------------------------------------------------- /src/queries/migrate.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const MIGRATE = gql` 4 | mutation Migrate($migrationType: String!) { 5 | migrate(migrationType: $migrationType) 6 | } 7 | `; 8 | -------------------------------------------------------------------------------- /webhook.sh: -------------------------------------------------------------------------------- 1 | git pull --rebase origin master 2 | yarn install 3 | cd src/data/courses 4 | git pull --rebase origin master 5 | cd .. 6 | cd .. 7 | cd .. 8 | yarn run build 9 | pm2 restart courses.robinwieruch.de -------------------------------------------------------------------------------- /src/components/Form/Icon/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { Icon } from 'antd'; 3 | 4 | const FormIcon = styled(Icon)` 5 | color: rgba(0, 0, 0, 0.25); 6 | `; 7 | 8 | export default FormIcon; 9 | -------------------------------------------------------------------------------- /src/queries/user.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const GET_ME = gql` 4 | query GetMe { 5 | me { 6 | uid 7 | email 8 | username 9 | roles 10 | } 11 | } 12 | `; 13 | -------------------------------------------------------------------------------- /declarations/augmented-next.d.ts: -------------------------------------------------------------------------------- 1 | import 'next'; 2 | import { ApolloClient } from 'apollo-client'; 3 | 4 | declare module 'next' { 5 | export interface NextPageContext { 6 | apolloClient: ApolloClient; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/components/Form/StretchedButton/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { Button } from 'antd'; 3 | 4 | const StretchedButton = styled(Button)` 5 | width: 100%; 6 | `; 7 | 8 | export default StretchedButton; 9 | -------------------------------------------------------------------------------- /src/validation/partner.ts: -------------------------------------------------------------------------------- 1 | import * as ROLES from '@constants/roles'; 2 | import type { User } from '@typeDefs/user'; 3 | 4 | export const hasPartnerRole = (user: User) => 5 | user && user.customClaims && user.customClaims[ROLES.PARTNER]; 6 | -------------------------------------------------------------------------------- /src/components/Form/AtomButton/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { Button } from 'antd'; 3 | 4 | const AtomButton = styled(Button)` 5 | margin: 0; 6 | padding: 0; 7 | `; 8 | 9 | export default AtomButton; 10 | -------------------------------------------------------------------------------- /src/validation/admin.ts: -------------------------------------------------------------------------------- 1 | import * as ROLES from '@constants/roles'; 2 | import type { User } from '@typeDefs/user'; 3 | 4 | export const hasAdminRole = (user: User | null | undefined) => 5 | user && user.customClaims && user.customClaims[ROLES.ADMIN]; 6 | -------------------------------------------------------------------------------- /src/screens/Checkout/CheckoutWizardPay/Adapters/stripe.tsx: -------------------------------------------------------------------------------- 1 | import scriptLoader from 'react-async-script-loader'; 2 | 3 | import StripeCheckout from '../StripeCheckout'; 4 | 5 | export default scriptLoader(`https://js.stripe.com/v3/`)( 6 | StripeCheckout 7 | ); 8 | -------------------------------------------------------------------------------- /src/components/Form/Item/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { Form } from 'antd'; 3 | 4 | const StyledFormItem = styled(Form.Item)` 5 | &:last-of-type { 6 | margin-bottom: 0; 7 | } 8 | `; 9 | 10 | export default StyledFormItem; 11 | -------------------------------------------------------------------------------- /src/components/Mdx/Image.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Image = (props: any) => { 4 | return ( 5 | 9 | ); 10 | }; 11 | 12 | export default Image; 13 | -------------------------------------------------------------------------------- /src/data/course-keys.ts: -------------------------------------------------------------------------------- 1 | export const THE_ROAD_TO_LEARN_REACT = 'THE_ROAD_TO_LEARN_REACT'; 2 | export const TAMING_THE_STATE = 'TAMING_THE_STATE'; 3 | export const THE_ROAD_TO_GRAPHQL = 'THE_ROAD_TO_GRAPHQL'; 4 | export const THE_ROAD_TO_REACT_WITH_FIREBASE = 5 | 'THE_ROAD_TO_REACT_WITH_FIREBASE'; 6 | -------------------------------------------------------------------------------- /public/favicon/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | #ffffff -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # next.js build output 2 | .next 3 | # dotenv environment variables file (build for Zeit Now) 4 | .env 5 | .env.build 6 | # Dependency directories 7 | node_modules/ 8 | # Logs 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | 13 | firebaseServiceAccountKey.json 14 | 15 | src/data/courses -------------------------------------------------------------------------------- /src/services/aws/s3.js: -------------------------------------------------------------------------------- 1 | import AWS from 'aws-sdk'; 2 | 3 | export const bucket = process.env.S3_BUCKET || ''; 4 | 5 | export default new AWS.S3({ 6 | endpoint: new AWS.Endpoint(process.env.S3_ENDPOINT), 7 | accessKeyId: process.env.S3_ACCESS_KEY_ID, 8 | secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, 9 | }); 10 | -------------------------------------------------------------------------------- /src/types/server.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ServerResponse as ServerResponseBase, 3 | ServerRequest as ServerRequestBase, 4 | } from 'microrouter'; 5 | 6 | export interface ServerRequest extends ServerRequestBase { 7 | cookies: { [key: string]: string }; 8 | body: any; 9 | } 10 | 11 | export interface ServerResponse extends ServerResponseBase {} 12 | -------------------------------------------------------------------------------- /src/data/course-keys-types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | THE_ROAD_TO_LEARN_REACT, 3 | TAMING_THE_STATE, 4 | THE_ROAD_TO_GRAPHQL, 5 | THE_ROAD_TO_REACT_WITH_FIREBASE, 6 | } from './course-keys'; 7 | 8 | export type COURSE = 9 | | typeof THE_ROAD_TO_LEARN_REACT 10 | | typeof TAMING_THE_STATE 11 | | typeof THE_ROAD_TO_GRAPHQL 12 | | typeof THE_ROAD_TO_REACT_WITH_FIREBASE; 13 | -------------------------------------------------------------------------------- /codegen.yml: -------------------------------------------------------------------------------- 1 | schema: http://localhost:3000/api/graphql 2 | 3 | generates: 4 | src/generated/client.tsx: 5 | documents: ./src/queries/*.ts 6 | config: 7 | withHOC: false 8 | withComponent: false 9 | withHooks: true 10 | plugins: 11 | - add: /* eslint-disable */ 12 | - typescript 13 | - typescript-operations 14 | - typescript-react-apollo 15 | -------------------------------------------------------------------------------- /src/queries/upgrade.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const GET_UPGRADEABLE_COURSES = gql` 4 | query GetUpgradeableCourses($courseId: String!) { 5 | upgradeableCourses(courseId: $courseId) { 6 | header 7 | courseId 8 | url 9 | bundle { 10 | header 11 | bundleId 12 | price 13 | imageUrl 14 | } 15 | } 16 | } 17 | `; 18 | -------------------------------------------------------------------------------- /src/screens/CourseList/spec.tsx: -------------------------------------------------------------------------------- 1 | import CourseList from '.'; 2 | 3 | describe('CourseList', () => { 4 | const noSession = null; 5 | const session = 'session'; 6 | 7 | it('renders when not authorized', () => { 8 | expect(CourseList.isAuthorized(noSession)).toEqual(true); 9 | }); 10 | 11 | it('renders when authorized', () => { 12 | expect(CourseList.isAuthorized(session)).toEqual(true); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/screens/SignIn/spec.tsx: -------------------------------------------------------------------------------- 1 | import SignInPage from '.'; 2 | 3 | describe('SignInPage', () => { 4 | const noSession = null; 5 | const session = 'session'; 6 | 7 | it('does render when not authorized', () => { 8 | expect(SignInPage.isAuthorized(noSession)).toEqual(true); 9 | }); 10 | 11 | it('renders when authorized', () => { 12 | expect(SignInPage.isAuthorized(session)).toEqual(true); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/screens/SignUp/spec.tsx: -------------------------------------------------------------------------------- 1 | import SignUpPage from '.'; 2 | 3 | describe('SignUpPage', () => { 4 | const noSession = null; 5 | const session = 'session'; 6 | 7 | it('does render when not authorized', () => { 8 | expect(SignUpPage.isAuthorized(noSession)).toEqual(true); 9 | }); 10 | 11 | it('renders when authorized', () => { 12 | expect(SignUpPage.isAuthorized(session)).toEqual(true); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/screens/Account/spec.tsx: -------------------------------------------------------------------------------- 1 | import AccountPage from '.'; 2 | 3 | describe('AccountPage', () => { 4 | const noSession = null; 5 | const session = 'session'; 6 | 7 | it('does not render when not authorized', () => { 8 | expect(AccountPage.isAuthorized(noSession)).toEqual(false); 9 | }); 10 | 11 | it('renders when authorized', () => { 12 | expect(AccountPage.isAuthorized(session)).toEqual(true); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/screens/Checkout/spec.tsx: -------------------------------------------------------------------------------- 1 | import CheckoutPage from '.'; 2 | 3 | describe('CheckoutPage', () => { 4 | const noSession = null; 5 | const session = 'session'; 6 | 7 | it('does render when not authorized', () => { 8 | expect(CheckoutPage.isAuthorized(noSession)).toEqual(true); 9 | }); 10 | 11 | it('renders when authorized', () => { 12 | expect(CheckoutPage.isAuthorized(session)).toEqual(true); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/screens/Partner/spec.tsx: -------------------------------------------------------------------------------- 1 | import PartnerPage from '.'; 2 | 3 | describe('PartnerPage', () => { 4 | const noSession = null; 5 | const session = 'session'; 6 | 7 | it('does not render when not authorized', () => { 8 | expect(PartnerPage.isAuthorized(noSession)).toEqual(false); 9 | }); 10 | 11 | it('renders when authorized', () => { 12 | expect(PartnerPage.isAuthorized(session)).toEqual(true); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/screens/CourseItem/spec.tsx: -------------------------------------------------------------------------------- 1 | import CourseItemPage from '.'; 2 | 3 | describe('CourseItemPage', () => { 4 | const noSession = null; 5 | const session = 'session'; 6 | 7 | it('does not render when not authorized', () => { 8 | expect(CourseItemPage.isAuthorized(noSession)).toEqual(false); 9 | }); 10 | 11 | it('renders when authorized', () => { 12 | expect(CourseItemPage.isAuthorized(session)).toEqual(true); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/services/slack/index.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const SLACK_BASE_URL = 'https://slack.com/api'; 4 | const SLACK_ADMIN_INVITE_PATH = '/users.admin.invite'; 5 | 6 | export const inviteToSlack = async (email: string) => { 7 | if (!process.env.SLACK_TOKEN) return; 8 | 9 | return await axios.get( 10 | `${SLACK_BASE_URL}${SLACK_ADMIN_INVITE_PATH}?email=${email}&token=${process.env.SLACK_TOKEN}&set_active=true` 11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /src/queries/book.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const GET_BOOK = gql` 4 | query GetBook($path: String!, $fileName: String!) { 5 | book(path: $path, fileName: $fileName) { 6 | body 7 | contentType 8 | fileName 9 | } 10 | } 11 | `; 12 | 13 | export const GET_ONLINE_CHAPTER = gql` 14 | query GetOnlineChapter($path: String!) { 15 | onlineChapter(path: $path) { 16 | body 17 | } 18 | } 19 | `; 20 | -------------------------------------------------------------------------------- /src/screens/EmailChange/spec.tsx: -------------------------------------------------------------------------------- 1 | import EmailChangePage from '.'; 2 | 3 | describe('EmailChangePage', () => { 4 | const noSession = null; 5 | const session = 'session'; 6 | 7 | it('does not render when not authorized', () => { 8 | expect(EmailChangePage.isAuthorized(noSession)).toEqual(false); 9 | }); 10 | 11 | it('renders when authorized', () => { 12 | expect(EmailChangePage.isAuthorized(session)).toEqual(true); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/services/firebase/admin.js: -------------------------------------------------------------------------------- 1 | import * as firebaseAdmin from 'firebase-admin'; 2 | 3 | import firebaseServiceAccountKey from '../../../firebaseServiceAccountKey.json'; 4 | 5 | if (!firebaseAdmin.apps.length) { 6 | firebaseAdmin.initializeApp({ 7 | credential: firebaseAdmin.credential.cert( 8 | firebaseServiceAccountKey 9 | ), 10 | databaseURL: process.env.FIREBASE_DATABASE_URL, 11 | }); 12 | } 13 | 14 | export default firebaseAdmin; 15 | -------------------------------------------------------------------------------- /src/api/middleware/resolver/isAuthenticated.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareFn } from 'type-graphql'; 2 | import { ForbiddenError } from 'apollo-server'; 3 | 4 | import type { ResolverContext } from '@typeDefs/resolver'; 5 | 6 | export const isAuthenticated: MiddlewareFn = async ( 7 | { context }, 8 | next 9 | ) => { 10 | if (!context.me) { 11 | throw new ForbiddenError('Not authenticated as user.'); 12 | } 13 | 14 | return next(); 15 | }; 16 | -------------------------------------------------------------------------------- /src/screens/CommunityJoin/spec.tsx: -------------------------------------------------------------------------------- 1 | import CommunityJoinPage from '.'; 2 | 3 | describe('CommunityJoinPage', () => { 4 | const noSession = null; 5 | const session = 'session'; 6 | 7 | it('does not render when not authorized', () => { 8 | expect(CommunityJoinPage.isAuthorized(noSession)).toEqual(false); 9 | }); 10 | 11 | it('renders when authorized', () => { 12 | expect(CommunityJoinPage.isAuthorized(session)).toEqual(true); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/screens/PasswordForgot/spec.tsx: -------------------------------------------------------------------------------- 1 | import PasswordForgotPage from '.'; 2 | 3 | describe('PasswordForgotPage', () => { 4 | const noSession = null; 5 | const session = 'session'; 6 | 7 | it('does render when not authorized', () => { 8 | expect(PasswordForgotPage.isAuthorized(noSession)).toEqual(true); 9 | }); 10 | 11 | it('renders when authorized', () => { 12 | expect(PasswordForgotPage.isAuthorized(session)).toEqual(true); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/screens/PasswordChange/spec.tsx: -------------------------------------------------------------------------------- 1 | import PasswordChangePage from '.'; 2 | 3 | describe('PasswordChangePage', () => { 4 | const noSession = null; 5 | const session = 'session'; 6 | 7 | it('does not render when not authorized', () => { 8 | expect(PasswordChangePage.isAuthorized(noSession)).toEqual(false); 9 | }); 10 | 11 | it('renders when authorized', () => { 12 | expect(PasswordChangePage.isAuthorized(session)).toEqual(true); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/hooks/useErrorIndicator.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { message } from 'antd'; 3 | 4 | export default ({ error }: { error?: { message?: string } }) => { 5 | React.useEffect(() => { 6 | if (error) { 7 | let text = error?.message || 'Something went wrong ...'; 8 | text = text.replace('GraphQL error: ', ''); 9 | 10 | message.error({ 11 | content: text, 12 | duration: 2, 13 | }); 14 | } 15 | }, [error]); 16 | }; 17 | -------------------------------------------------------------------------------- /src/queries/stripe.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const STRIPE_CREATE_ORDER = gql` 4 | mutation StripeCreateOrder( 5 | $imageUrl: String! 6 | $courseId: String! 7 | $bundleId: String! 8 | $coupon: String 9 | $partnerId: String 10 | ) { 11 | stripeCreateOrder( 12 | imageUrl: $imageUrl 13 | courseId: $courseId 14 | bundleId: $bundleId 15 | coupon: $coupon 16 | partnerId: $partnerId 17 | ) { 18 | id 19 | } 20 | } 21 | `; 22 | -------------------------------------------------------------------------------- /src/connectors/admin.ts: -------------------------------------------------------------------------------- 1 | import firebaseAdmin from '@services/firebase/admin'; 2 | 3 | export class AdminConnector { 4 | async getUser(uid: string) { 5 | return await firebaseAdmin.auth().getUser(uid); 6 | } 7 | 8 | async setCustomClaims( 9 | uid: string, 10 | claims: { [key: string]: Boolean } 11 | ) { 12 | const user = await this.getUser(uid); 13 | 14 | return await firebaseAdmin.auth().setCustomUserClaims(uid, { 15 | ...user.customClaims, 16 | ...claims, 17 | }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/data/bundle-legacy.ts: -------------------------------------------------------------------------------- 1 | import { 2 | THE_ROAD_TO_LEARN_REACT, 3 | TAMING_THE_STATE, 4 | THE_ROAD_TO_GRAPHQL, 5 | THE_ROAD_TO_REACT_WITH_FIREBASE, 6 | } from './course-keys'; 7 | 8 | // FROM: TO 9 | 10 | export default { 11 | [THE_ROAD_TO_LEARN_REACT]: { 12 | COMPLETE_COURSE: 'PROFESSIONAL', 13 | }, 14 | [TAMING_THE_STATE]: { 15 | BOOK: 'STUDENT', 16 | CODE: 'PROFESSIONAL', 17 | SCREENCAST: 'PROFESSIONAL', 18 | }, 19 | [THE_ROAD_TO_GRAPHQL]: { 20 | BOOK: 'STUDENT', 21 | }, 22 | [THE_ROAD_TO_REACT_WITH_FIREBASE]: { 23 | BOOK: 'STUDENT', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /src/api/middleware/resolver/isAdmin.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareFn } from 'type-graphql'; 2 | import { ForbiddenError } from 'apollo-server'; 3 | 4 | import type { ResolverContext } from '@typeDefs/resolver'; 5 | import { hasAdminRole } from '@validation/admin'; 6 | 7 | export const isAdmin: MiddlewareFn = async ( 8 | { context }, 9 | next 10 | ) => { 11 | if (!context.me) { 12 | throw new ForbiddenError('Not authenticated as user.'); 13 | } 14 | 15 | if (!hasAdminRole(context.me)) { 16 | throw new ForbiddenError('No admin user.'); 17 | } 18 | 19 | return next(); 20 | }; 21 | -------------------------------------------------------------------------------- /src/api/middleware/resolver/isPartner.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareFn } from 'type-graphql'; 2 | import { ForbiddenError } from 'apollo-server'; 3 | 4 | import type { ResolverContext } from '@typeDefs/resolver'; 5 | import { hasPartnerRole } from '@validation/partner'; 6 | 7 | export const isPartner: MiddlewareFn = async ( 8 | { context }, 9 | next 10 | ) => { 11 | if (!context.me) { 12 | throw new ForbiddenError('Not authenticated as user.'); 13 | } 14 | 15 | if (!hasPartnerRole(context.me)) { 16 | throw new Error('No partner user.'); 17 | } 18 | 19 | return next(); 20 | }; 21 | -------------------------------------------------------------------------------- /src/constants/routes.ts: -------------------------------------------------------------------------------- 1 | export const SIGN_IN = '/sign-in'; 2 | export const SIGN_UP = '/sign-up'; 3 | export const ACCOUNT = '/account'; 4 | export const PASSWORD_CHANGE = '/password-change'; 5 | export const PASSWORD_FORGOT = '/password-forgot'; 6 | export const EMAIL_CHANGE = '/email-change'; 7 | export const PARTNER = '/partner'; 8 | export const COMMUNITY_JOIN = '/community-join'; 9 | 10 | export const INDEX = '/'; 11 | export const UNLOCKED_COURSE_DETAILS = '/p/[unlocked-course-id]'; 12 | 13 | export const COURSE_UPGRADE = '/upgrade/[upgradeable-course-id]'; 14 | 15 | export const CHECKOUT = '/checkout'; 16 | -------------------------------------------------------------------------------- /src/components/Loader/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Loader = styled.div` 4 | border: 8px solid ${({ theme }) => theme.colors.lightGrey}; 5 | border-top: 8px solid ${({ theme }) => theme.colors.primary}; 6 | border-radius: 50%; 7 | width: 40px; 8 | height: 40px; 9 | animation: spin 2s linear infinite; 10 | margin-left: auto; 11 | margin-right: auto; 12 | margin-top: 40px; 13 | 14 | @keyframes spin { 15 | 0% { 16 | transform: rotate(0deg); 17 | } 18 | 100% { 19 | transform: rotate(360deg); 20 | } 21 | } 22 | `; 23 | 24 | export default Loader; 25 | -------------------------------------------------------------------------------- /src/queries/paypal.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const PAYPAL_CREATE_ORDER = gql` 4 | mutation PaypalCreateOrder( 5 | $courseId: String! 6 | $bundleId: String! 7 | $coupon: String 8 | $partnerId: String 9 | ) { 10 | paypalCreateOrder( 11 | courseId: $courseId 12 | bundleId: $bundleId 13 | coupon: $coupon 14 | partnerId: $partnerId 15 | ) { 16 | orderId 17 | } 18 | } 19 | `; 20 | 21 | export const PAYPAL_APPROVE_ORDER = gql` 22 | mutation PaypalApproveOrder($orderId: String!) { 23 | paypalApproveOrder(orderId: $orderId) 24 | } 25 | `; 26 | -------------------------------------------------------------------------------- /src/queries/storefront.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const GET_STOREFRONT_COURSE = gql` 4 | query GetStorefrontCourse($courseId: String!, $bundleId: String!) { 5 | storefrontCourse(courseId: $courseId, bundleId: $bundleId) { 6 | header 7 | courseId 8 | bundle { 9 | header 10 | bundleId 11 | price 12 | imageUrl 13 | } 14 | } 15 | } 16 | `; 17 | 18 | export const GET_STOREFRONT_COURSES = gql` 19 | query GetStorefrontCourses { 20 | storefrontCourses { 21 | header 22 | courseId 23 | url 24 | imageUrl 25 | } 26 | } 27 | `; 28 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: rwieruch 4 | patreon: # rwieruch 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with a single custom sponsorship URL 13 | -------------------------------------------------------------------------------- /src/components/Link/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import NextLink from 'next/link'; 3 | 4 | type LinkProps = { 5 | href: string; 6 | as?: string; 7 | children: React.ReactNode; 8 | }; 9 | 10 | const Link = ({ href, as, children }: LinkProps) => { 11 | const internal = /^\/(?!\/)/.test(href); 12 | 13 | if (internal) { 14 | return ( 15 | 16 | {children} 17 | 18 | ); 19 | } 20 | 21 | return ( 22 | 23 | {children} 24 | 25 | ); 26 | }; 27 | 28 | export default Link; 29 | -------------------------------------------------------------------------------- /src/services/ga/index.ts: -------------------------------------------------------------------------------- 1 | import ReactGA from 'react-ga'; 2 | 3 | export const initGA = () => { 4 | ReactGA.initialize(process.env.GOOGLE_ANALYTICS || ''); 5 | }; 6 | 7 | export const logPageView = () => { 8 | ReactGA.set({ page: window.location.pathname }); 9 | ReactGA.pageview(window.location.pathname); 10 | }; 11 | 12 | export const logEvent = (category = '', action = '') => { 13 | if (category && action) { 14 | ReactGA.event({ category, action }); 15 | } 16 | }; 17 | 18 | export const logException = (description = '', fatal = false) => { 19 | if (description) { 20 | ReactGA.exception({ description, fatal }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /src/services/convertkit/index.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const CONVERTKIT_BASE_URL = 'https://api.convertkit.com/v3'; 4 | const FORMS_PATH = '/forms/'; 5 | 6 | export const inviteToConvertkit = async ( 7 | email: string, 8 | username: string 9 | ) => { 10 | if ( 11 | !process.env.CONVERTKIT_API_KEY || 12 | !process.env.CONVERTKIT_FORM_ID 13 | ) 14 | return; 15 | 16 | return await axios.post( 17 | `${CONVERTKIT_BASE_URL}${FORMS_PATH}${process.env.CONVERTKIT_FORM_ID}/subscribe`, 18 | { 19 | api_key: process.env.CONVERTKIT_API_KEY, 20 | email, 21 | first_name: username, 22 | } 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/types/resolver.ts: -------------------------------------------------------------------------------- 1 | import type { ServerResponse, ServerRequest } from '@typeDefs/server'; 2 | import type { User } from '@typeDefs/user'; 3 | 4 | import { AdminConnector } from '@connectors/admin'; 5 | import { CourseConnector } from '@connectors/course'; 6 | import { PartnerConnector } from '@connectors/partner'; 7 | import { CouponConnector } from '@connectors/coupon'; 8 | 9 | export type ResolverContext = { 10 | res: ServerResponse; 11 | req: ServerRequest; 12 | me?: User; 13 | adminConnector: AdminConnector; 14 | courseConnector: CourseConnector; 15 | partnerConnector: PartnerConnector; 16 | couponConnector: CouponConnector; 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/Mdx/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Typography } from 'antd'; 3 | 4 | import Link from '@components/Link'; 5 | import Code from './Code'; 6 | import Image from './Image'; 7 | 8 | export default { 9 | h1: (props: any) => , 10 | h2: (props: any) => , 11 | h3: (props: any) => , 12 | h4: (props: any) => , 13 | code: (props: any) => , 14 | a: (props: any) => , 15 | img: (props: any) => , 16 | }; 17 | -------------------------------------------------------------------------------- /src/api/resolvers/migration/index.ts: -------------------------------------------------------------------------------- 1 | import { Arg, Resolver, Mutation, UseMiddleware } from 'type-graphql'; 2 | import { isAuthenticated } from '@api/middleware/resolver/isAuthenticated'; 3 | import { isAdmin } from '@api/middleware/resolver/isAdmin'; 4 | 5 | @Resolver() 6 | export default class MigrationResolver { 7 | @Mutation(() => Boolean) 8 | @UseMiddleware(isAuthenticated, isAdmin) 9 | async migrate( 10 | @Arg('migrationType') migrationType: string 11 | ): Promise { 12 | switch (migrationType) { 13 | case 'FOO': 14 | return true; 15 | case 'BAR': 16 | return true; 17 | default: 18 | return false; 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/components/Navigation/signOut.ts: -------------------------------------------------------------------------------- 1 | import Router from 'next/router'; 2 | import cookie from 'js-cookie'; 3 | 4 | import type { ServerRequest, ServerResponse } from '@typeDefs/server'; 5 | import * as ROUTES from '@constants/routes'; 6 | 7 | export default ( 8 | req?: ServerRequest, 9 | res?: ServerResponse, 10 | // TODO: other type than any 11 | apolloClient?: any 12 | ) => { 13 | const isServer = req || res; 14 | 15 | if (apolloClient) apolloClient.resetStore(); 16 | 17 | if (isServer) { 18 | res?.writeHead(302, { Location: ROUTES.SIGN_IN }); 19 | res?.end(); 20 | } else { 21 | Router.push(ROUTES.SIGN_IN); 22 | cookie.remove('session'); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /src/screens/CourseItem/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { Card } from 'antd'; 3 | 4 | export const StyledCard = styled(Card)` 5 | min-width: 200px; 6 | max-width: 300px; 7 | 8 | height: 100%; 9 | 10 | display: flex; 11 | flex-direction: column; 12 | justify-content: space-between; 13 | 14 | .ant-card-head { 15 | flex: 0; 16 | } 17 | 18 | .ant-card-body { 19 | flex: 1; 20 | } 21 | `; 22 | 23 | export const StyledCards = styled.div` 24 | margin: 16px; 25 | 26 | display: grid; 27 | align-items: center; 28 | grid-auto-rows: 1fr; 29 | grid-template-columns: repeat(auto-fit, minmax(200px, 300px)); 30 | grid-gap: 16px; 31 | `; 32 | -------------------------------------------------------------------------------- /src/services/firebase/client.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/app'; 2 | 3 | import 'firebase/auth'; 4 | 5 | if (!firebase.apps.length) { 6 | firebase.initializeApp({ 7 | apiKey: process.env.FIREBASE_API_KEY, 8 | authDomain: process.env.FIREBASE_AUTH_DOMAIN, 9 | databaseURL: process.env.FIREBASE_DATABASE_URL, 10 | projectId: process.env.FIREBASE_PROJECT_ID, 11 | storageBucket: process.env.FIREBASE_STORAGE_BUCKET, 12 | messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID, 13 | appId: process.env.FIREBASE_APP_ID, 14 | }); 15 | } 16 | 17 | // TODO 18 | // As httpOnly cookies are to be used, do not persist any state client side. 19 | firebase.auth().setPersistence(firebase.auth.Auth?.Persistence?.NONE); 20 | 21 | export default firebase; 22 | -------------------------------------------------------------------------------- /src/services/paypal/index.ts: -------------------------------------------------------------------------------- 1 | // https://developer.paypal.com/docs/checkout/reference/server-integration/setup-sdk/ 2 | 3 | import checkoutNodeJssdk from '@paypal/checkout-server-sdk'; 4 | 5 | const environment = () => { 6 | const clientId = process.env.PAYPAL_CLIENT_ID; 7 | const clientSecret = process.env.PAYPAL_CLIENT_SECRET; 8 | 9 | if (process.env.NODE_ENV === 'production') { 10 | return new checkoutNodeJssdk.core.LiveEnvironment( 11 | clientId, 12 | clientSecret 13 | ); 14 | } else { 15 | return new checkoutNodeJssdk.core.SandboxEnvironment( 16 | clientId, 17 | clientSecret 18 | ); 19 | } 20 | }; 21 | 22 | const client = () => { 23 | return new checkoutNodeJssdk.core.PayPalHttpClient(environment()); 24 | }; 25 | 26 | export default client; 27 | -------------------------------------------------------------------------------- /src/queries/coupon.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const GET_DISCOUNTED_PRICE = gql` 4 | query GetDiscountedPrice( 5 | $courseId: String! 6 | $bundleId: String! 7 | $coupon: String! 8 | ) { 9 | discountedPrice( 10 | courseId: $courseId 11 | bundleId: $bundleId 12 | coupon: $coupon 13 | ) { 14 | price 15 | isDiscount 16 | } 17 | } 18 | `; 19 | 20 | export const COUPON_CREATE = gql` 21 | mutation CouponCreate( 22 | $coupon: String! 23 | $discount: Float! 24 | $count: Float! 25 | $courseId: String! 26 | $bundleId: String! 27 | ) { 28 | couponCreate( 29 | coupon: $coupon 30 | discount: $discount 31 | count: $count 32 | courseId: $courseId 33 | bundleId: $bundleId 34 | ) 35 | } 36 | `; 37 | -------------------------------------------------------------------------------- /src/data/migration.spec.ts: -------------------------------------------------------------------------------- 1 | import { THE_ROAD_TO_LEARN_REACT } from './course-keys'; 2 | import { THE_ROAD_TO_LEARN_REACT_BUNDLE_KEYS as BUNDLE_KEYS } from './bundle-keys'; 3 | 4 | import migrate from './migration'; 5 | 6 | describe('migration', () => { 7 | it('migrates the keys', () => { 8 | const allowed = migrate(THE_ROAD_TO_LEARN_REACT); 9 | 10 | const migratedKeys = allowed([ 11 | BUNDLE_KEYS.STUDENT, 12 | BUNDLE_KEYS.INTERMEDIATE, 13 | BUNDLE_KEYS.PROFESSIONAL, 14 | ]); 15 | 16 | expect(migratedKeys.length).toBe(4); 17 | expect(migratedKeys).toContain(BUNDLE_KEYS.STUDENT); 18 | expect(migratedKeys).toContain(BUNDLE_KEYS.INTERMEDIATE); 19 | expect(migratedKeys).toContain(BUNDLE_KEYS.PROFESSIONAL); 20 | expect(migratedKeys).toContain('COMPLETE_COURSE'); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/api/middleware/global/sentry.ts: -------------------------------------------------------------------------------- 1 | import { sentry } from 'graphql-middleware-sentry'; 2 | import * as Sentry from '@sentry/node'; 3 | 4 | import type { ResolverContext } from '@typeDefs/resolver'; 5 | 6 | Sentry.init({ 7 | dsn: process.env.SENTRY_DSN, 8 | }); 9 | 10 | export default sentry({ 11 | sentryInstance: Sentry, 12 | config: { 13 | environment: process.env.NODE_ENV, 14 | }, 15 | forwardErrors: true, 16 | captureReturnedErrors: true, 17 | withScope: (scope, error, context: ResolverContext) => { 18 | scope.setUser({ 19 | id: context.me?.uid, 20 | email: context.me?.email, 21 | }); 22 | 23 | scope.setExtra('body', context.req.body); 24 | scope.setExtra('origin', context.req.headers.origin); 25 | scope.setExtra('user-agent', context.req.headers['user-agent']); 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /src/models/course.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Index, 3 | Entity, 4 | Column, 5 | CreateDateColumn, 6 | PrimaryGeneratedColumn, 7 | } from 'typeorm'; 8 | 9 | import type { COURSE } from '@data/course-keys-types'; 10 | import type { BUNDLE } from '@data/bundle-keys-types'; 11 | 12 | @Entity() 13 | export class Course { 14 | @PrimaryGeneratedColumn('uuid') 15 | id: string; 16 | 17 | @Index() 18 | @Column('varchar') 19 | userId: string; 20 | 21 | @Column('varchar') 22 | courseId: COURSE; 23 | 24 | @Column('varchar') 25 | bundleId: BUNDLE; 26 | 27 | @CreateDateColumn() 28 | createdAt: Date; 29 | 30 | @Column('int') 31 | price: number; 32 | 33 | @Column('varchar') 34 | currency: string; 35 | 36 | @Column('varchar') 37 | paymentType: string; 38 | 39 | @Column('varchar') 40 | coupon: string; 41 | } 42 | -------------------------------------------------------------------------------- /src/screens/CourseItem/Curriculum/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { CurriculumSection } from '@generated/client'; 4 | 5 | import VideoCard from '../Cards/VideoCard'; 6 | import ArticleCard from '../Cards/ArticleCard'; 7 | import { StyledCards } from '../styles'; 8 | 9 | type CurriculumProps = { 10 | curriculumSection: CurriculumSection; 11 | }; 12 | 13 | const Curriculum = ({ curriculumSection }: CurriculumProps) => ( 14 | 15 | {curriculumSection.items.map(item => { 16 | switch (item.kind) { 17 | case 'Article': 18 | return ; 19 | case 'Video': 20 | return ; 21 | default: 22 | return null; 23 | } 24 | })} 25 | 26 | ); 27 | 28 | export default Curriculum; 29 | -------------------------------------------------------------------------------- /src/models/coupon.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Index, 3 | Entity, 4 | Column, 5 | CreateDateColumn, 6 | PrimaryGeneratedColumn, 7 | } from 'typeorm'; 8 | 9 | import type { COURSE } from '@data/course-keys-types'; 10 | import type { BUNDLE } from '@data/bundle-keys-types'; 11 | 12 | @Entity() 13 | export class Coupon { 14 | @PrimaryGeneratedColumn('uuid') 15 | id: string; 16 | 17 | @CreateDateColumn() 18 | createdAt: Date; 19 | 20 | @Index({ unique: true }) 21 | @Column('varchar') 22 | coupon: string; 23 | 24 | @Column('int') 25 | discount: number; 26 | 27 | @Column('int') 28 | count: number; 29 | 30 | @Column('timestamp') 31 | expiresAt: Date; 32 | 33 | @Column({ type: 'varchar', nullable: true }) 34 | courseId: COURSE; 35 | 36 | @Column({ type: 'varchar', nullable: true }) 37 | bundleId: BUNDLE; 38 | } 39 | -------------------------------------------------------------------------------- /src/data/migration.ts: -------------------------------------------------------------------------------- 1 | import invert from 'lodash.invert'; 2 | 3 | import { COURSE } from './course-keys-types'; 4 | import BUNDLE_LEGACY from './bundle-legacy'; 5 | 6 | const applyMigration = ( 7 | courseKey: COURSE, 8 | allowedKeys: string[] 9 | ): string[] => { 10 | const bundleLegacy = BUNDLE_LEGACY[courseKey]; 11 | 12 | const bundleLegacyInverse = invert(bundleLegacy); 13 | 14 | const migratedKeys = allowedKeys.reduce( 15 | (acc: string[], value: string) => { 16 | const legacyAllowed = bundleLegacyInverse[value]; 17 | 18 | if (legacyAllowed) { 19 | acc = acc.concat(legacyAllowed); 20 | } 21 | 22 | return acc.concat(value); 23 | }, 24 | [] 25 | ); 26 | 27 | return migratedKeys; 28 | }; 29 | 30 | export default (courseKey: COURSE) => (roles: string[]) => 31 | applyMigration(courseKey, roles); 32 | -------------------------------------------------------------------------------- /src/models/partner.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Index, 3 | Entity, 4 | Column, 5 | CreateDateColumn, 6 | PrimaryGeneratedColumn, 7 | OneToOne, 8 | JoinColumn, 9 | } from 'typeorm'; 10 | 11 | import { Course } from './course'; 12 | 13 | @Entity() 14 | export class PartnerVisitor { 15 | @PrimaryGeneratedColumn('uuid') 16 | id: string; 17 | 18 | @Index() 19 | @Column('varchar') 20 | partnerId: string; 21 | 22 | @CreateDateColumn() 23 | createdAt: Date; 24 | } 25 | 26 | @Entity() 27 | export class PartnerSale { 28 | @PrimaryGeneratedColumn('uuid') 29 | id: string; 30 | 31 | @Index() 32 | @Column('varchar') 33 | partnerId: string; 34 | 35 | @Index() 36 | @Column('int') 37 | royalty: number; 38 | 39 | @OneToOne(type => Course) 40 | @JoinColumn() 41 | course: Course; 42 | 43 | @CreateDateColumn() 44 | createdAt: Date; 45 | } 46 | -------------------------------------------------------------------------------- /src/screens/Checkout/CheckoutWizardPay/Adapters/paypal.tsx: -------------------------------------------------------------------------------- 1 | // https://developer.paypal.com/docs/checkout/integrate/ 2 | 3 | import React from 'react'; 4 | import scriptLoader from 'react-async-script-loader'; 5 | 6 | import PaypalCheckoutBase, { 7 | PaypalCheckoutProps, 8 | } from '../PaypalCheckout'; 9 | 10 | type PaypalCheckoutAdapterProps = { 11 | isScriptLoaded: boolean; 12 | isScriptLoadSucceed: boolean; 13 | isShow: boolean; 14 | } & PaypalCheckoutProps; 15 | 16 | const PaypalCheckoutAdapter = ({ 17 | isScriptLoaded, 18 | isScriptLoadSucceed, 19 | isShow, 20 | ...props 21 | }: PaypalCheckoutAdapterProps) => 22 | isScriptLoaded && 23 | isScriptLoadSucceed && 24 | isShow && ; 25 | 26 | export default scriptLoader( 27 | `https://www.paypal.com/sdk/js?client-id=${process.env 28 | .PAYPAL_CLIENT_ID || 'sb'}` 29 | )(PaypalCheckoutAdapter); 30 | -------------------------------------------------------------------------------- /src/screens/CourseItem/Cards/ArticleCard/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Icon } from 'antd'; 3 | 4 | import { CurriculumItem } from '@generated/client'; 5 | import Link from '@components/Link'; 6 | 7 | import { StyledCard } from '../../styles'; 8 | 9 | type ArticleCardProps = { 10 | item: CurriculumItem; 11 | }; 12 | 13 | const ArticleCard = ({ item }: ArticleCardProps) => { 14 | let actions = [Read]; 15 | 16 | if (item.secondaryUrl) { 17 | actions = actions.concat( 18 | More 19 | ); 20 | } 21 | 22 | return ( 23 | 26 | {item.label} 27 | 28 | } 29 | actions={actions} 30 | > 31 | {item.description} 32 | 33 | ); 34 | }; 35 | 36 | export default ArticleCard; 37 | -------------------------------------------------------------------------------- /public/favicon/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "App", 3 | "icons": [ 4 | { 5 | "src": "\/android-icon-36x36.png", 6 | "sizes": "36x36", 7 | "type": "image\/png", 8 | "density": "0.75" 9 | }, 10 | { 11 | "src": "\/android-icon-48x48.png", 12 | "sizes": "48x48", 13 | "type": "image\/png", 14 | "density": "1.0" 15 | }, 16 | { 17 | "src": "\/android-icon-72x72.png", 18 | "sizes": "72x72", 19 | "type": "image\/png", 20 | "density": "1.5" 21 | }, 22 | { 23 | "src": "\/android-icon-96x96.png", 24 | "sizes": "96x96", 25 | "type": "image\/png", 26 | "density": "2.0" 27 | }, 28 | { 29 | "src": "\/android-icon-144x144.png", 30 | "sizes": "144x144", 31 | "type": "image\/png", 32 | "density": "3.0" 33 | }, 34 | { 35 | "src": "\/android-icon-192x192.png", 36 | "sizes": "192x192", 37 | "type": "image\/png", 38 | "density": "4.0" 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /src/data/bundle-keys.ts: -------------------------------------------------------------------------------- 1 | export const THE_ROAD_TO_LEARN_REACT_BUNDLE_KEYS = { 2 | STUDENT: 'STUDENT', 3 | INTERMEDIATE: 'INTERMEDIATE', 4 | PROFESSIONAL: 'PROFESSIONAL', 5 | // RETIRED 6 | // COMPLETE_COURSE: 'COMPLETE_COURSE', 7 | }; 8 | 9 | export const TAMING_THE_STATE_BUNDLE_KEYS = { 10 | STUDENT: 'STUDENT', 11 | INTERMEDIATE: 'INTERMEDIATE', 12 | PROFESSIONAL: 'PROFESSIONAL', 13 | // RETIRED 14 | // BOOK: 'BOOK', 15 | // CODE: 'CODE', 16 | // SCREENCAST: 'SCREENCAST', 17 | }; 18 | 19 | export const THE_ROAD_TO_GRAPHQL_BUNDLE_KEYS = { 20 | STUDENT: 'STUDENT', 21 | INTERMEDIATE: 'INTERMEDIATE', 22 | PROFESSIONAL: 'PROFESSIONAL', 23 | // RETIRED 24 | // BOOK: 'BOOK', 25 | }; 26 | 27 | export const THE_ROAD_TO_REACT_WITH_FIREBASE_BUNDLE_KEYS = { 28 | STUDENT: 'STUDENT', 29 | INTERMEDIATE: 'INTERMEDIATE', 30 | PROFESSIONAL: 'PROFESSIONAL', 31 | // RETIRED 32 | // BOOK: 'BOOK', 33 | }; 34 | -------------------------------------------------------------------------------- /src/queries/session.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const SIGN_UP = gql` 4 | mutation SignUp( 5 | $username: String! 6 | $email: String! 7 | $password: String! 8 | ) { 9 | signUp(username: $username, email: $email, password: $password) { 10 | token 11 | } 12 | } 13 | `; 14 | 15 | export const SIGN_IN = gql` 16 | mutation SignIn($email: String!, $password: String!) { 17 | signIn(email: $email, password: $password) { 18 | token 19 | } 20 | } 21 | `; 22 | 23 | export const PASSWORD_CHANGE = gql` 24 | mutation PasswordChange($password: String!) { 25 | passwordChange(password: $password) 26 | } 27 | `; 28 | 29 | export const PASSWORD_FORGOT = gql` 30 | mutation PasswordForgot($email: String!) { 31 | passwordForgot(email: $email) 32 | } 33 | `; 34 | 35 | export const EMAIL_CHANGE = gql` 36 | mutation EmailChange($email: String!) { 37 | emailChange(email: $email) 38 | } 39 | `; 40 | -------------------------------------------------------------------------------- /src/api/resolvers/index.ts: -------------------------------------------------------------------------------- 1 | import { NonEmptyArray } from 'type-graphql'; 2 | 3 | import MigrationResolvers from './migration'; 4 | import SessionResolver from './session'; 5 | import UserResolvers from './user'; 6 | import StorefrontResolvers from './storefront'; 7 | import PaypalResolvers from './paypal'; 8 | import StripeResolvers from './stripe'; 9 | import CourseResolvers from './course'; 10 | import BookResolvers from './book'; 11 | import UpgradeResolvers from './upgrade'; 12 | import CouponResolver from './coupon'; 13 | import PartnerResolver from './partner'; 14 | import CommunityResolvers from './community'; 15 | 16 | export default [ 17 | MigrationResolvers, 18 | SessionResolver, 19 | UserResolvers, 20 | StorefrontResolvers, 21 | PaypalResolvers, 22 | StripeResolvers, 23 | CourseResolvers, 24 | BookResolvers, 25 | UpgradeResolvers, 26 | CouponResolver, 27 | PartnerResolver, 28 | CommunityResolvers, 29 | ] as NonEmptyArray; 30 | -------------------------------------------------------------------------------- /pages/_document.js: -------------------------------------------------------------------------------- 1 | // https://github.com/zeit/next.js/blob/canary/examples/with-styled-components/pages/_document.js 2 | 3 | import Document from 'next/document'; 4 | import { ServerStyleSheet } from 'styled-components'; 5 | 6 | export default class MyDocument extends Document { 7 | static async getInitialProps(ctx) { 8 | const sheet = new ServerStyleSheet(); 9 | const originalRenderPage = ctx.renderPage; 10 | 11 | try { 12 | ctx.renderPage = () => 13 | originalRenderPage({ 14 | enhanceApp: App => props => 15 | sheet.collectStyles(), 16 | }); 17 | 18 | const initialProps = await Document.getInitialProps(ctx); 19 | return { 20 | ...initialProps, 21 | styles: ( 22 | <> 23 | {initialProps.styles} 24 | {sheet.getStyleElement()} 25 | 26 | ), 27 | }; 28 | } finally { 29 | sheet.seal(); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/api/resolvers/upgrade/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Arg, 3 | Ctx, 4 | Resolver, 5 | Query, 6 | UseMiddleware, 7 | } from 'type-graphql'; 8 | 9 | import { StorefrontCourse } from '@api/resolvers/storefront'; 10 | import type { ResolverContext } from '@typeDefs/resolver'; 11 | import { getUpgradeableCourses } from '@services/course'; 12 | import { COURSE } from '@data/course-keys-types'; 13 | import { isAuthenticated } from '@api/middleware/resolver/isAuthenticated'; 14 | 15 | @Resolver() 16 | export default class UpgradeResolver { 17 | @Query(() => [StorefrontCourse]) 18 | @UseMiddleware(isAuthenticated) 19 | async upgradeableCourses( 20 | @Arg('courseId') courseId: string, 21 | @Ctx() ctx: ResolverContext 22 | ) { 23 | const courses = 24 | await ctx.courseConnector.getCoursesByUserIdAndCourseId( 25 | ctx.me!.uid, 26 | courseId as COURSE 27 | ); 28 | 29 | return getUpgradeableCourses(courseId as COURSE, courses); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/screens/EmailChange/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { Card, Layout as AntdLayout } from 'antd'; 4 | 5 | import type { Session } from '@typeDefs/session'; 6 | import Layout from '@components/Layout'; 7 | 8 | import EmailChangeForm from './EmailChangeForm'; 9 | 10 | const StyledContent = styled(AntdLayout.Content)` 11 | display: flex; 12 | justify-content: center; 13 | align-items: center; 14 | 15 | margin-top: 56px; 16 | `; 17 | 18 | const StyledCard = styled(Card)` 19 | min-width: 200px; 20 | max-width: 400px; 21 | `; 22 | 23 | const EmailChangePage = () => { 24 | return ( 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | }; 34 | 35 | EmailChangePage.isAuthorized = (session: Session) => !!session; 36 | 37 | export default EmailChangePage; 38 | -------------------------------------------------------------------------------- /src/screens/CourseItem/BookOnline/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useQuery } from '@apollo/react-hooks'; 3 | import { Skeleton } from 'antd'; 4 | 5 | import { GET_ONLINE_CHAPTER } from '@queries/book'; 6 | import mdxComponents from '@components/Mdx'; 7 | 8 | import useErrorIndicator from '@hooks/useErrorIndicator'; 9 | 10 | import MDX from '@mdx-js/runtime'; 11 | 12 | type BookOnlineProps = { 13 | path: string | null | undefined; 14 | }; 15 | 16 | const BookOnline = ({ path }: BookOnlineProps) => { 17 | if (!path) return null; 18 | 19 | const { loading, error, data } = useQuery(GET_ONLINE_CHAPTER, { 20 | variables: { path }, 21 | }); 22 | 23 | useErrorIndicator({ 24 | error, 25 | }); 26 | 27 | if (loading) return ; 28 | if (!data) return null; 29 | 30 | return ( 31 | 32 | {atob(data.onlineChapter.body)} 33 | 34 | ); 35 | }; 36 | 37 | export default BookOnline; 38 | -------------------------------------------------------------------------------- /src/screens/PasswordForgot/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { Card, Layout as AntdLayout } from 'antd'; 4 | 5 | import type { Session } from '@typeDefs/session'; 6 | import Layout from '@components/Layout'; 7 | 8 | import PasswordForgotForm from './PasswordForgotForm'; 9 | 10 | const StyledContent = styled(AntdLayout.Content)` 11 | display: flex; 12 | justify-content: center; 13 | align-items: center; 14 | 15 | margin-top: 56px; 16 | `; 17 | 18 | const StyledCard = styled(Card)` 19 | min-width: 200px; 20 | max-width: 400px; 21 | `; 22 | 23 | const PasswordForgotPage = () => { 24 | return ( 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | }; 34 | 35 | PasswordForgotPage.isAuthorized = (session: Session) => true; 36 | 37 | export default PasswordForgotPage; 38 | -------------------------------------------------------------------------------- /src/services/firebase/course.ts: -------------------------------------------------------------------------------- 1 | import * as firebaseAdminVanilla from 'firebase-admin'; 2 | 3 | import firebaseAdmin from '@services/firebase/admin'; 4 | 5 | import { COURSE } from '@data/course-keys-types'; 6 | import { BUNDLE } from '@data/bundle-keys-types'; 7 | 8 | export const createCourse = async ({ 9 | uid, 10 | courseId, 11 | bundleId, 12 | amount, 13 | paymentType, 14 | coupon, 15 | }: { 16 | uid?: string; 17 | courseId: COURSE; 18 | bundleId: BUNDLE; 19 | amount: number; 20 | paymentType: string; 21 | coupon: string; 22 | }) => 23 | await firebaseAdmin 24 | .database() 25 | .ref(`users/${uid}/courses`) 26 | .push() 27 | .set({ 28 | courseId: courseId, 29 | packageId: bundleId, 30 | invoice: { 31 | createdAt: 32 | firebaseAdminVanilla.database.ServerValue.TIMESTAMP, 33 | amount, 34 | licensesCount: 1, 35 | currency: 'USD', 36 | paymentType, 37 | coupon, 38 | }, 39 | }); 40 | -------------------------------------------------------------------------------- /src/screens/PasswordChange/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { Card, Layout as AntdLayout } from 'antd'; 4 | 5 | import type { Session } from '@typeDefs/session'; 6 | import Layout from '@components/Layout'; 7 | 8 | import PasswordChangeForm from './PasswordChangeForm'; 9 | 10 | const StyledContent = styled(AntdLayout.Content)` 11 | display: flex; 12 | justify-content: center; 13 | align-items: center; 14 | 15 | margin-top: 56px; 16 | `; 17 | 18 | const StyledCard = styled(Card)` 19 | min-width: 200px; 20 | max-width: 400px; 21 | `; 22 | 23 | const PasswordChangePage = () => { 24 | return ( 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | }; 34 | 35 | PasswordChangePage.isAuthorized = (session: Session) => !!session; 36 | 37 | export default PasswordChangePage; 38 | -------------------------------------------------------------------------------- /src/services/revue/index.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const REVUE_BASE_URL = 'https://www.getrevue.co/api/v2'; 4 | const REVUE_SUBSCRIBERS_PATH = '/subscribers'; 5 | 6 | export const inviteToRevue = async ( 7 | email: string, 8 | username: string 9 | ) => { 10 | if (!process.env.REVUE_TOKEN) return; 11 | 12 | return await axios.post( 13 | `${REVUE_BASE_URL}${REVUE_SUBSCRIBERS_PATH}`, 14 | { 15 | email, 16 | first_name: username, 17 | double_opt_in: false, 18 | }, 19 | { 20 | headers: { 21 | Authorization: `Token token="${process.env.REVUE_TOKEN}"`, 22 | }, 23 | } 24 | ); 25 | }; 26 | 27 | export const removeFromRevue = async (email: string) => { 28 | if (!process.env.REVUE_TOKEN) return; 29 | 30 | await axios.delete(`${REVUE_BASE_URL}${REVUE_SUBSCRIBERS_PATH}`, { 31 | data: { 32 | email, 33 | }, 34 | headers: { 35 | Authorization: `Token token="${process.env.REVUE_TOKEN}"`, 36 | }, 37 | }); 38 | }; 39 | -------------------------------------------------------------------------------- /src/api/middleware/resolver/isFreeCourse.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareFn } from 'type-graphql'; 2 | 3 | import type { ResolverContext } from '@typeDefs/resolver'; 4 | import storefront from '@data/course-storefront'; 5 | import { COURSE } from '@data/course-keys-types'; 6 | import { BUNDLE } from '@data/bundle-keys-types'; 7 | import { priceWithDiscount } from '@services/discount'; 8 | 9 | export const isFreeCourse: MiddlewareFn = async ( 10 | { args, context }, 11 | next 12 | ) => { 13 | const course = storefront[args.courseId as COURSE]; 14 | const bundle = course.bundles[args.bundleId as BUNDLE]; 15 | 16 | const price = await priceWithDiscount( 17 | context.couponConnector, 18 | context.courseConnector 19 | )( 20 | args.courseId as COURSE, 21 | args.bundleId as BUNDLE, 22 | bundle.price, 23 | args.coupon, 24 | context.me?.uid 25 | ); 26 | 27 | if (price !== 0) { 28 | throw new Error('This course is not for free.'); 29 | } 30 | 31 | return next(); 32 | }; 33 | -------------------------------------------------------------------------------- /src/services/format/index.ts: -------------------------------------------------------------------------------- 1 | export const upperSnakeCaseToKebabCase = (string: string) => 2 | string.toLowerCase().replace(/_/g, '-'); 3 | 4 | export const kebabCaseToUpperSnakeCase = (string: string) => 5 | string.toUpperCase().replace(/-/g, '_'); 6 | 7 | export const formatPrice = (price: number) => 8 | (price / 100).toLocaleString('en-US', { 9 | style: 'currency', 10 | currency: 'USD', 11 | }); 12 | 13 | export const formatDateTime = (date: Date) => 14 | new Intl.DateTimeFormat('en-US', { 15 | year: 'numeric', 16 | month: 'numeric', 17 | day: 'numeric', 18 | hour: 'numeric', 19 | minute: 'numeric', 20 | second: 'numeric', 21 | hour12: false, 22 | }).format(date); 23 | 24 | export const formatMonth = (date: Date) => 25 | new Intl.DateTimeFormat('en-US', { 26 | month: 'long', 27 | }).format(date); 28 | 29 | export const formatRouteQuery = ( 30 | queryValue: string | string[] | undefined 31 | ) => (queryValue instanceof Array ? queryValue.join('') : queryValue); 32 | -------------------------------------------------------------------------------- /src/api/middleware/global/me.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationError } from 'apollo-server-micro'; 2 | 3 | import firebaseAdmin from '@services/firebase/admin'; 4 | 5 | import type { ResolverContext } from '@typeDefs/resolver'; 6 | import type { User } from '@typeDefs/user'; 7 | 8 | export default async ( 9 | resolve: Function, 10 | root: any, 11 | args: any, 12 | context: ResolverContext, 13 | info: any 14 | ) => { 15 | const { session } = context.req.cookies; 16 | 17 | if (!session) { 18 | return await resolve(root, args, context, info); 19 | } 20 | 21 | const CHECK_REVOKED = true; 22 | 23 | const me = await firebaseAdmin 24 | .auth() 25 | .verifySessionCookie(session, CHECK_REVOKED) 26 | .then(async (claims) => { 27 | return (await firebaseAdmin.auth().getUser(claims.uid)) as User; 28 | }) 29 | .catch((error) => { 30 | throw new AuthenticationError(error.message); 31 | }); 32 | 33 | context.me = me; 34 | 35 | return await resolve(root, args, context, info); 36 | }; 37 | -------------------------------------------------------------------------------- /src/api/resolvers/user/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ObjectType, 3 | Field, 4 | Ctx, 5 | Resolver, 6 | Query, 7 | UseMiddleware, 8 | } from 'type-graphql'; 9 | 10 | import type { ResolverContext } from '@typeDefs/resolver'; 11 | import { isAuthenticated } from '@api/middleware/resolver/isAuthenticated'; 12 | 13 | @ObjectType() 14 | class User { 15 | @Field() 16 | uid: string; 17 | 18 | @Field() 19 | email: string; 20 | 21 | @Field() 22 | username: string; 23 | 24 | @Field((type) => [String]) 25 | roles: string[]; 26 | } 27 | 28 | @Resolver() 29 | export default class UserResolver { 30 | @Query(() => User) 31 | @UseMiddleware(isAuthenticated) 32 | async me(@Ctx() ctx: ResolverContext): Promise { 33 | const rolesObject = ctx.me!.customClaims || {}; 34 | 35 | const roles = Object.keys(rolesObject).filter( 36 | (key) => rolesObject[key] 37 | ); 38 | 39 | return { 40 | uid: ctx.me!.uid, 41 | email: ctx.me!.email || '', 42 | username: ctx.me!.displayName || '', 43 | roles, 44 | }; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/services/slack/spec.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import { inviteToSlack } from '.'; 4 | 5 | jest.mock('axios'); 6 | 7 | describe('inviteToSlack', () => { 8 | beforeEach(() => { 9 | process.env.SLACK_TOKEN = 'token'; 10 | }); 11 | 12 | afterEach(() => { 13 | delete process.env.SLACK_TOKEN; 14 | }); 15 | 16 | it('signs up for the slack community', async () => { 17 | const get = jest.spyOn(axios, 'get'); 18 | get.mockImplementationOnce(() => Promise.resolve(true)); 19 | 20 | await expect( 21 | inviteToSlack('example@example.com') 22 | ).resolves.toEqual(true); 23 | 24 | expect(axios.get).toHaveBeenCalledWith( 25 | 'https://slack.com/api/users.admin.invite?email=example@example.com&token=token&set_active=true' 26 | ); 27 | }); 28 | 29 | it('does not sign up for the slack community, if no token', async () => { 30 | delete process.env.SLACK_TOKEN; 31 | 32 | const get = jest.spyOn(axios, 'get'); 33 | get.mockImplementationOnce(() => Promise.resolve(true)); 34 | 35 | await inviteToSlack('example@example.com'); 36 | 37 | expect(axios.get).toHaveBeenCalledTimes(0); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/queries/partner.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const PROMOTE_TO_PARTNER = gql` 4 | mutation PromoteToPartner($uid: String!) { 5 | promoteToPartner(uid: $uid) 6 | } 7 | `; 8 | 9 | export const PARTNER_TRACK_VISITOR = gql` 10 | mutation PartnerTrackVisitor($partnerId: String!) { 11 | partnerTrackVisitor(partnerId: $partnerId) 12 | } 13 | `; 14 | 15 | export const PARTNER_VISITORS = gql` 16 | query PartnerVisitors($from: DateTime!, $to: DateTime!) { 17 | partnerVisitors(from: $from, to: $to) { 18 | date 19 | count 20 | } 21 | } 22 | `; 23 | 24 | export const PARTNER_SALES = gql` 25 | query PartnerSales($offset: Float!, $limit: Float!) { 26 | partnerSales(offset: $offset, limit: $limit) { 27 | edges { 28 | id 29 | royalty 30 | price 31 | createdAt 32 | courseId 33 | bundleId 34 | isCoupon 35 | } 36 | pageInfo { 37 | total 38 | } 39 | } 40 | } 41 | `; 42 | 43 | export const PARTNER_PAYMENTS = gql` 44 | query PartnerPayments { 45 | partnerPayments { 46 | createdAt 47 | royalty 48 | } 49 | } 50 | `; 51 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: [''], 3 | moduleFileExtensions: ['js', 'ts', 'tsx', 'json'], 4 | setupFilesAfterEnv: ['/jest.setup.js'], 5 | testPathIgnorePatterns: ['./.next/', './node_modules/'], 6 | moduleNameMapper: { 7 | '\\.(css|less)$': 'identity-obj-proxy', 8 | '^@components(.*)$': '/src/components$1', 9 | '^@api(.*)$': '/src/api$1', 10 | '^@models(.*)$': '/src/models$1', 11 | '^@connectors(.*)$': '/src/connectors$1', 12 | '^@screens(.*)$': '/src/screens$1', 13 | '^@hooks(.*)$': '/src/hooks$1', 14 | '^@services(.*)$': '/src/services$1', 15 | '^@validation(.*)$': '/src/validation$1', 16 | '^@constants(.*)$': '/src/constants$1', 17 | '^@context(.*)$': '/src/context$1', 18 | '^@queries(.*)$': '/src/queries$1', 19 | '^@data(.*)$': '/src/data$1', 20 | '^@typeDefs(.*)$': '/src/types$1', 21 | '^@generated(.*)$': '/src/generated$1', 22 | }, 23 | testEnvironment: 'jsdom', 24 | transform: { 25 | '^.+\\.(ts|tsx)$': 'babel-jest', 26 | }, 27 | clearMocks: true, 28 | }; 29 | -------------------------------------------------------------------------------- /src/screens/CourseItem/Introduction/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styled from 'styled-components'; 4 | import { Modal, Icon } from 'antd'; 5 | 6 | import { IntroductionData } from '@generated/client'; 7 | 8 | import { StyledCards, StyledCard } from '../styles'; 9 | 10 | const StyledModal = styled(Modal)` 11 | min-width: 640px; 12 | min-height: 360px; 13 | 14 | .ant-modal-body { 15 | width: 640px; 16 | height: 360px; 17 | padding: 0; 18 | margin: 0; 19 | } 20 | `; 21 | 22 | type IntroductionCardProps = { 23 | item: IntroductionData; 24 | }; 25 | 26 | const IntroductionCard = ({ item }: IntroductionCardProps) => { 27 | return ( 28 | 31 | {item.label} 32 | 33 | } 34 | > 35 | {item.description} 36 | 37 | ); 38 | }; 39 | 40 | type IntroductionProps = { 41 | introductionData: IntroductionData; 42 | }; 43 | 44 | const Introduction = ({ introductionData }: IntroductionProps) => { 45 | return ( 46 | 47 | 48 | 49 | ); 50 | }; 51 | 52 | export default Introduction; 53 | -------------------------------------------------------------------------------- /src/hooks/useIndicators.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { message } from 'antd'; 3 | 4 | export default ({ 5 | key, 6 | loading, 7 | error, 8 | success, 9 | }: { 10 | key: string; 11 | loading?: boolean; 12 | error?: { message?: string }; 13 | success?: { message?: string }; 14 | }) => { 15 | let destroyMessage = React.useRef(() => {}); 16 | 17 | React.useEffect(() => { 18 | if (loading) { 19 | destroyMessage.current = message.loading({ 20 | content: 'Loading ...', 21 | key: key, 22 | duration: 0, 23 | }); 24 | } 25 | }, [loading]); 26 | 27 | React.useEffect(() => { 28 | if (error) { 29 | let text = error?.message || 'Something went wrong ...'; 30 | text = text.replace('GraphQL error: ', ''); 31 | 32 | destroyMessage.current = message.error({ 33 | content: text, 34 | key: key, 35 | duration: 2, 36 | }); 37 | } 38 | }, [error]); 39 | 40 | const successMessage = () => { 41 | destroyMessage.current = message.success({ 42 | content: success ? success.message : 'Success!', 43 | key: key, 44 | duration: 2, 45 | }); 46 | }; 47 | 48 | return { successMessage, destroyMessage }; 49 | }; 50 | -------------------------------------------------------------------------------- /src/screens/CourseItem/Onboarding/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Icon } from 'antd'; 3 | 4 | import { OnboardingData, OnboardingItem } from '@generated/client'; 5 | import Link from '@components/Link'; 6 | 7 | import { StyledCards, StyledCard } from '../styles'; 8 | 9 | type OnboardingCardProps = { 10 | item: OnboardingItem; 11 | }; 12 | 13 | const OnboardingCard = ({ item }: OnboardingCardProps) => { 14 | let actions = [Hop On]; 15 | 16 | if (item.secondaryUrl) { 17 | actions = actions.concat( 18 | More 19 | ); 20 | } 21 | 22 | return ( 23 | 26 | {item.label} 27 | 28 | } 29 | actions={actions} 30 | > 31 | {item.description} 32 | 33 | ); 34 | }; 35 | 36 | type OnboardingProps = { 37 | onboardingData: OnboardingData; 38 | }; 39 | 40 | const Onboarding = ({ onboardingData }: OnboardingProps) => { 41 | return ( 42 | 43 | {onboardingData.items.map((item, index) => ( 44 | 45 | ))} 46 | 47 | ); 48 | }; 49 | 50 | export default Onboarding; 51 | -------------------------------------------------------------------------------- /src/screens/Partner/Assets/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Skeleton } from 'antd'; 3 | 4 | import { useGetStorefrontCoursesQuery } from '@generated/client'; 5 | import useErrorIndicator from '@hooks/useErrorIndicator'; 6 | import Link from '@components/Link'; 7 | 8 | interface AssetsProps { 9 | isPartner: boolean; 10 | } 11 | 12 | const Assets = ({ isPartner }: AssetsProps) => { 13 | if (!isPartner) { 14 | return null; 15 | } 16 | 17 | const { data, loading, error } = useGetStorefrontCoursesQuery(); 18 | 19 | useErrorIndicator({ 20 | error, 21 | }); 22 | 23 | if (loading) return ; 24 | if (!data) return null; 25 | 26 | return ( 27 | <> 28 |

29 | If not only the referral link but also images are needed on 30 | the partner's side, you can find all the images that can be 31 | used freely over here. 32 |

33 | 34 |
    35 | {data.storefrontCourses.map(storefrontCourse => ( 36 |
  • 37 | 38 | {storefrontCourse.header} 39 | 40 |
  • 41 | ))} 42 |
43 | 44 | ); 45 | }; 46 | 47 | export default Assets; 48 | -------------------------------------------------------------------------------- /src/screens/SignIn/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useRouter } from 'next/router'; 3 | import styled from 'styled-components'; 4 | import { Card, Layout as AntdLayout } from 'antd'; 5 | 6 | import * as ROUTES from '@constants/routes'; 7 | import { Session } from '@typeDefs/session'; 8 | import Layout from '@components/Layout'; 9 | 10 | import SignInForm from './SignInForm'; 11 | 12 | const StyledContent = styled(AntdLayout.Content)` 13 | display: flex; 14 | justify-content: center; 15 | align-items: center; 16 | 17 | margin-top: 56px; 18 | `; 19 | 20 | const StyledCard = styled(Card)` 21 | min-width: 200px; 22 | max-width: 400px; 23 | `; 24 | 25 | const SignInPage = () => { 26 | const router = useRouter(); 27 | 28 | React.useEffect(() => { 29 | router.prefetch(ROUTES.INDEX); 30 | }); 31 | 32 | const handleSuccess = () => { 33 | router.push(ROUTES.INDEX); 34 | }; 35 | 36 | return ( 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | ); 45 | }; 46 | 47 | SignInPage.isAuthorized = (session: Session) => true; 48 | 49 | export default SignInPage; 50 | -------------------------------------------------------------------------------- /src/screens/SignUp/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useRouter } from 'next/router'; 3 | import styled from 'styled-components'; 4 | import { Card, Layout as AntdLayout } from 'antd'; 5 | 6 | import * as ROUTES from '@constants/routes'; 7 | import type { Session } from '@typeDefs/session'; 8 | import Layout from '@components/Layout'; 9 | 10 | import SignUpForm from './SignUpForm'; 11 | 12 | const StyledContent = styled(AntdLayout.Content)` 13 | display: flex; 14 | justify-content: center; 15 | align-items: center; 16 | 17 | margin-top: 56px; 18 | `; 19 | 20 | const StyledCard = styled(Card)` 21 | min-width: 200px; 22 | max-width: 400px; 23 | `; 24 | 25 | const SignUpPage = () => { 26 | const router = useRouter(); 27 | 28 | React.useEffect(() => { 29 | router.prefetch(ROUTES.INDEX); 30 | }); 31 | 32 | const handleSuccess = () => { 33 | router.push(ROUTES.INDEX); 34 | }; 35 | 36 | return ( 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | ); 45 | }; 46 | 47 | SignUpPage.isAuthorized = (session: Session) => true; 48 | 49 | export default SignUpPage; 50 | -------------------------------------------------------------------------------- /src/screens/Checkout/CheckoutWizardPay/PaypalCheckout/spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, fireEvent } from '@testing-library/react'; 2 | import { MockedProvider } from '@apollo/react-testing'; 3 | 4 | import { CourseId, BundleId } from '@generated/client'; 5 | import PaypalCheckout from '.'; 6 | 7 | describe('PaypalCheckout', () => { 8 | const onSuccess = jest.fn(); 9 | const onBack = jest.fn(); 10 | 11 | it('uses the back button', async () => { 12 | (global as any).paypal = { 13 | Buttons: jest.fn(() => ({ 14 | render: jest.fn(), 15 | })), 16 | }; 17 | 18 | const component = render( 19 | 20 | 34 | 35 | ); 36 | 37 | fireEvent.click(component.getByLabelText('back-button')); 38 | 39 | expect(onBack).toHaveBeenCalledTimes(1); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/screens/Checkout/CheckoutWizardPay/FreeCheckout/index.tsx: -------------------------------------------------------------------------------- 1 | // https://stripe.com/docs/payments/checkout/one-time#create-one-time-payments 2 | 3 | import React from 'react'; 4 | import { useMutation } from '@apollo/react-hooks'; 5 | import { Button } from 'antd'; 6 | 7 | import useErrorIndicator from '@hooks/useErrorIndicator'; 8 | import { CREATE_FREE_COURSE } from '@queries/course'; 9 | 10 | export type FreeCheckoutProps = { 11 | courseId: string; 12 | bundleId: string; 13 | coupon: string; 14 | onSuccess: () => void; 15 | }; 16 | 17 | const FreeCheckout = ({ 18 | courseId, 19 | bundleId, 20 | coupon, 21 | onSuccess, 22 | }: FreeCheckoutProps) => { 23 | const [createFreeCourse, { loading, error }] = useMutation( 24 | CREATE_FREE_COURSE 25 | ); 26 | 27 | useErrorIndicator({ 28 | error, 29 | }); 30 | 31 | const handlePay = async () => { 32 | try { 33 | await createFreeCourse({ 34 | variables: { courseId, bundleId, coupon }, 35 | }); 36 | 37 | onSuccess(); 38 | } catch (error) {} 39 | }; 40 | 41 | return ( 42 | 50 | ); 51 | }; 52 | 53 | export default FreeCheckout; 54 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | // https://github.com/vercel/next.js/blob/canary/examples/with-styled-components/.babelrc 2 | 3 | { 4 | "presets": ["next/babel"], 5 | "plugins": [ 6 | [ 7 | "module-resolver", 8 | { 9 | "root": ["./"], 10 | "alias": { 11 | "@components": "./src/components", 12 | "@api": "./src/api", 13 | "@models": "./src/models", 14 | "@connectors": "./src/connectors", 15 | "@screens": "./src/screens", 16 | "@hooks": "./src/hooks", 17 | "@services": "./src/services", 18 | "@validation": "./src/validation", 19 | "@constants": "./src/constants", 20 | "@context": "./src/context", 21 | "@queries": "./src/queries", 22 | "@data": "./src/data", 23 | "@typeDefs": "./src/types", 24 | "@generated": "./src/generated" 25 | } 26 | } 27 | ], 28 | [ 29 | "styled-components", 30 | { 31 | "ssr": true 32 | } 33 | ], 34 | "babel-plugin-transform-typescript-metadata", 35 | [ 36 | "@babel/plugin-proposal-decorators", 37 | { 38 | "legacy": true 39 | } 40 | ], 41 | [ 42 | "@babel/plugin-proposal-class-properties", 43 | { 44 | "loose": true 45 | } 46 | ] 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /src/services/revue/spec.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import { inviteToRevue } from '.'; 4 | 5 | jest.mock('axios'); 6 | 7 | describe('inviteToRevue', () => { 8 | beforeEach(() => { 9 | process.env.REVUE_TOKEN = 'token'; 10 | }); 11 | 12 | afterEach(() => { 13 | delete process.env.REVUE_TOKEN; 14 | }); 15 | 16 | it('signs up for the newsletter', async () => { 17 | const post = jest.spyOn(axios, 'post'); 18 | post.mockImplementationOnce(() => Promise.resolve(true)); 19 | 20 | await expect( 21 | inviteToRevue('example@example.com', 'Example User') 22 | ).resolves.toEqual(true); 23 | 24 | expect(axios.post).toHaveBeenCalledWith( 25 | 'https://www.getrevue.co/api/v2/subscribers', 26 | { 27 | double_opt_in: false, 28 | email: 'example@example.com', 29 | first_name: 'Example User', 30 | }, 31 | { headers: { Authorization: 'Token token="token"' } } 32 | ); 33 | }); 34 | 35 | it('does not sign up for the newsletter, if no token', async () => { 36 | delete process.env.REVUE_TOKEN; 37 | 38 | const post = jest.spyOn(axios, 'post'); 39 | post.mockImplementationOnce(() => Promise.resolve(true)); 40 | 41 | await inviteToRevue('example@example.com', 'Example User'); 42 | 43 | expect(axios.post).toHaveBeenCalledTimes(0); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/screens/Partner/Payments/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Table } from 'antd'; 3 | 4 | import { User, usePartnerPaymentsQuery } from '@generated/client'; 5 | import useErrorIndicator from '@hooks/useErrorIndicator'; 6 | import { formatPrice, formatMonth } from '@services/format'; 7 | 8 | const columns = [ 9 | { 10 | key: 'createdAt', 11 | title: 'Month', 12 | dataIndex: 'createdAt', 13 | }, 14 | { 15 | key: 'royalty', 16 | title: 'Royalty', 17 | dataIndex: 'royalty', 18 | }, 19 | ]; 20 | 21 | interface PaymentsTableProps { 22 | me: User; 23 | isPartner: boolean; 24 | } 25 | 26 | const PaymentsTable = ({ me, isPartner }: PaymentsTableProps) => { 27 | if (!isPartner) { 28 | return null; 29 | } 30 | 31 | const { loading, error, data } = usePartnerPaymentsQuery(); 32 | 33 | useErrorIndicator({ 34 | error, 35 | }); 36 | 37 | const dataSource = (data ? data.partnerPayments : []).map( 38 | partnerPayment => ({ 39 | createdAt: formatMonth(new Date(partnerPayment.createdAt)), 40 | royalty: formatPrice(partnerPayment.royalty), 41 | }) 42 | ); 43 | 44 | return ( 45 | 52 | ); 53 | }; 54 | 55 | export default PaymentsTable; 56 | -------------------------------------------------------------------------------- /src/components/Layout/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { Layout as AntdLayout } from 'antd'; 4 | 5 | const { Header: AntdHeader, Footer: AntdFooter } = AntdLayout; 6 | 7 | import Navigation from '@components/Navigation'; 8 | 9 | const StyledAntdLayout = styled(AntdLayout)` 10 | min-height: calc(100vh - 70px); 11 | 12 | display: flex; 13 | flex-direction: row; 14 | 15 | div { 16 | flex: 1; 17 | } 18 | `; 19 | 20 | const StyledAntdFooter = styled(AntdFooter)` 21 | text-align: center; 22 | `; 23 | 24 | type LayoutProps = { 25 | noFooter?: boolean; 26 | children: React.ReactNode; 27 | }; 28 | 29 | const Footer = () => ( 30 | 31 | Created by{' '} 32 | Robin Wieruch 33 | 34 | ); 35 | 36 | const Layout = ({ noFooter = false, children }: LayoutProps) => ( 37 | 38 | 48 | 49 | 50 | {children} 51 | {!noFooter &&
} 52 | 53 | ); 54 | 55 | export { Footer }; 56 | 57 | export default Layout; 58 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "es6", 8 | "es2017", 9 | "esnext.asynciterable" 10 | ], 11 | "allowJs": true, 12 | "skipLibCheck": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noEmit": true, 16 | "esModuleInterop": true, 17 | "module": "esnext", 18 | "moduleResolution": "node", 19 | "resolveJsonModule": true, 20 | "isolatedModules": true, 21 | "jsx": "preserve", 22 | "emitDecoratorMetadata": true, 23 | "experimentalDecorators": true, 24 | "strictPropertyInitialization": false, 25 | "baseUrl": ".", 26 | "paths": { 27 | "@components/*": ["./src/components/*"], 28 | "@api/*": ["./src/api/*"], 29 | "@models/*": ["./src/models/*"], 30 | "@connectors/*": ["./src/connectors/*"], 31 | "@screens/*": ["./src/screens/*"], 32 | "@hooks/*": ["./src/hooks/*"], 33 | "@services/*": ["./src/services/*"], 34 | "@validation/*": ["./src/validation/*"], 35 | "@constants/*": ["./src/constants/*"], 36 | "@context/*": ["./src/context/*"], 37 | "@queries/*": ["./src/queries/*"], 38 | "@data/*": ["./src/data/*"], 39 | "@typeDefs/*": ["./src/types/*"], 40 | "@generated/*": ["./src/generated/*"] 41 | } 42 | }, 43 | "exclude": ["node_modules"], 44 | "include": ["next-env.d.ts", "**/*.ts", "**/*.spec.ts", "**/*.tsx"] 45 | } 46 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | require('@testing-library/jest-dom'); 2 | 3 | jest.mock('next/router', () => ({ 4 | useRouter: () => ({ 5 | push: jest.fn(), 6 | prefetch: jest.fn(), 7 | }), 8 | })); 9 | 10 | // Firebase Client 11 | 12 | jest.mock('firebase/app', () => ({ 13 | __esModule: true, 14 | default: { 15 | initializeApp: jest.fn(), 16 | apps: [], 17 | auth: jest.fn(() => ({ 18 | setPersistence: jest.fn(), 19 | signInWithEmailAndPassword: jest.fn(() => ({ 20 | user: { 21 | getIdToken: jest.fn(() => '1'), 22 | }, 23 | })), 24 | signOut: jest.fn(), 25 | })), 26 | }, 27 | })); 28 | 29 | jest.mock('firebase/auth'); 30 | 31 | // Firebase Admin 32 | 33 | jest.mock('firebase-admin', () => { 34 | return { 35 | database: { 36 | ServerValue: { 37 | TIMESTAMP: 'TIMESTAMP', 38 | }, 39 | }, 40 | }; 41 | }); 42 | 43 | jest.mock('@services/firebase/admin', () => { 44 | const set = jest.fn(); 45 | const push = jest.fn(() => ({ 46 | set, 47 | })); 48 | const ref = jest.fn(() => ({ 49 | push, 50 | })); 51 | 52 | return { 53 | database: jest.fn(() => ({ 54 | ref, 55 | })), 56 | }; 57 | }); 58 | 59 | // AWS 60 | 61 | jest.mock('@services/aws/s3', () => { 62 | const promise = jest.fn(() => 63 | Promise.resolve({ 64 | ContentType: 'application/json', 65 | Body: 'Body', 66 | }) 67 | ); 68 | 69 | return { 70 | getObject: jest.fn(() => ({ 71 | promise, 72 | })), 73 | }; 74 | }); 75 | -------------------------------------------------------------------------------- /src/connectors/course.ts: -------------------------------------------------------------------------------- 1 | import { Connection, Repository } from 'typeorm'; 2 | 3 | import { COURSE } from '@data/course-keys-types'; 4 | import { BUNDLE } from '@data/bundle-keys-types'; 5 | import { Course } from '@models/course'; 6 | 7 | export class CourseConnector { 8 | courseRepository: Repository; 9 | 10 | constructor(connection: Connection) { 11 | this.courseRepository = 12 | connection?.getRepository('Course'); 13 | } 14 | 15 | async createCourse({ 16 | userId, 17 | courseId, 18 | bundleId, 19 | price, 20 | currency, 21 | paymentType, 22 | coupon, 23 | }: { 24 | userId: string; 25 | courseId: COURSE; 26 | bundleId: BUNDLE; 27 | price: number; 28 | currency: string; 29 | paymentType: string; 30 | coupon: string; 31 | }) { 32 | const course = new Course(); 33 | 34 | course.userId = userId; 35 | course.courseId = courseId; 36 | course.bundleId = bundleId; 37 | course.price = price; 38 | course.currency = currency; 39 | course.paymentType = paymentType; 40 | course.coupon = coupon; 41 | 42 | return await this.courseRepository.save(course); 43 | } 44 | 45 | async getCoursesByUserId(userId: string) { 46 | return await this.courseRepository.find({ 47 | where: { userId }, 48 | }); 49 | } 50 | 51 | async getCoursesByUserIdAndCourseId( 52 | userId: string, 53 | courseId: COURSE 54 | ) { 55 | return await this.courseRepository.find({ 56 | where: { userId, courseId }, 57 | }); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/services/apollo/withApollo.js: -------------------------------------------------------------------------------- 1 | import { ApolloClient } from 'apollo-client'; 2 | import { InMemoryCache } from 'apollo-cache-inmemory'; 3 | import { HttpLink } from 'apollo-link-http'; 4 | import { onError } from 'apollo-link-error'; 5 | 6 | import withApollo from 'next-with-apollo'; 7 | 8 | import signOut from '@components/Navigation/signOut'; 9 | 10 | const httpLink = new HttpLink({ 11 | uri: `${process.env.BASE_URL}/api/graphql`, 12 | credentials: 'same-origin', 13 | }); 14 | 15 | const getErrorLink = (ctx = { req: null, res: null }) => 16 | onError(({ graphQLErrors, networkError }) => { 17 | if (graphQLErrors) { 18 | graphQLErrors.forEach( 19 | ({ message, extensions, locations, path }) => { 20 | console.log('GraphQL error:', message, extensions); 21 | 22 | if (extensions.code === 'UNAUTHENTICATED') { 23 | signOut(ctx.req, ctx.res, ctx.apolloClient); 24 | } 25 | 26 | if (extensions.code === 'FORBIDDEN') { 27 | signOut(ctx.req, ctx.res, ctx.apolloClient); 28 | } 29 | } 30 | ); 31 | } 32 | 33 | if (networkError) { 34 | console.log('Network error', networkError); 35 | 36 | if (networkError.statusCode === 401) { 37 | signOut(ctx.req, ctx.res, ctx.apolloClient); 38 | } 39 | } 40 | }); 41 | 42 | export default withApollo( 43 | ({ ctx, headers, initialState }) => 44 | new ApolloClient({ 45 | link: getErrorLink(ctx).concat(httpLink), 46 | cache: new InMemoryCache().restore(initialState || {}), 47 | }) 48 | ); 49 | -------------------------------------------------------------------------------- /src/screens/CourseItem/Cards/VideoCard/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { Modal, Icon } from 'antd'; 4 | import ReactPlayer from 'react-player'; 5 | 6 | import { CurriculumItem } from '@generated/client'; 7 | import Link from '@components/Link'; 8 | 9 | import { StyledCard } from '../../styles'; 10 | 11 | const StyledModal = styled(Modal)` 12 | min-width: 640px; 13 | min-height: 360px; 14 | 15 | .ant-modal-body { 16 | width: 640px; 17 | height: 360px; 18 | padding: 0; 19 | margin: 0; 20 | } 21 | `; 22 | 23 | type VideoCardProps = { 24 | item: CurriculumItem; 25 | }; 26 | 27 | const VideoCard = ({ item }: VideoCardProps) => { 28 | const [isOpen, setIsOpen] = React.useState(false); 29 | 30 | let actions = [ 31 | <> 32 | setIsOpen(false)} 38 | > 39 | 40 | 41 | 42 | setIsOpen(true)}>Watch 43 | , 44 | ]; 45 | 46 | if (item.secondaryUrl) { 47 | actions = actions.concat( 48 | More 49 | ); 50 | } 51 | 52 | return ( 53 | 56 | {item.label} 57 | 58 | } 59 | actions={actions} 60 | > 61 | {item.description} 62 | 63 | ); 64 | }; 65 | 66 | export default VideoCard; 67 | -------------------------------------------------------------------------------- /src/api/resolvers/book/index.ts: -------------------------------------------------------------------------------- 1 | import s3, { bucket } from '@services/aws/s3'; 2 | 3 | import { 4 | ObjectType, 5 | Field, 6 | Arg, 7 | Resolver, 8 | Query, 9 | UseMiddleware, 10 | } from 'type-graphql'; 11 | import { isAuthenticated } from '@api/middleware/resolver/isAuthenticated'; 12 | 13 | @ObjectType() 14 | class File { 15 | @Field() 16 | fileName: string; 17 | 18 | @Field() 19 | contentType: string | undefined | null; 20 | 21 | @Field() 22 | body: string | undefined | null; 23 | } 24 | 25 | @ObjectType() 26 | class Markdown { 27 | @Field() 28 | body: string; 29 | } 30 | 31 | @Resolver() 32 | export default class BookResolver { 33 | @Query(() => File) 34 | @UseMiddleware(isAuthenticated) 35 | async book( 36 | @Arg('path') path: string, 37 | @Arg('fileName') fileName: string 38 | ): Promise { 39 | const { ContentType, Body } = await s3 40 | .getObject({ 41 | Bucket: bucket, 42 | Key: path, 43 | }) 44 | .promise(); 45 | 46 | return { 47 | fileName, 48 | contentType: ContentType, 49 | body: Body?.toString('base64'), 50 | }; 51 | } 52 | 53 | @Query(() => Markdown) 54 | @UseMiddleware(isAuthenticated) 55 | async onlineChapter(@Arg('path') path: string): Promise { 56 | const { Body } = await s3 57 | .getObject({ 58 | Bucket: bucket, 59 | Key: path, 60 | }) 61 | .promise(); 62 | 63 | if (!Body) { 64 | throw new Error("Chapter couldn't get downloaded."); 65 | } 66 | 67 | return { 68 | body: Body.toString('base64'), 69 | }; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/services/discount/spec.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import { priceWithDiscount } from './'; 4 | 5 | describe('priceWithDiscount', () => { 6 | it('applies successfully the discount if coupon', async () => { 7 | const data = { 8 | data: { 9 | ppp: { 10 | pppConversionFactor: 0.1, 11 | }, 12 | }, 13 | }; 14 | 15 | const get = jest.spyOn(axios, 'get'); 16 | get.mockImplementationOnce(() => Promise.resolve(data)); 17 | 18 | await expect( 19 | priceWithDiscount( 20 | 'THE_ROAD_TO_LEARN_REACT', 21 | 'PROFESSIONAL', 22 | 100, 23 | 'MY_COUPON', 24 | '' 25 | ) 26 | ).resolves.toEqual(10); 27 | }); 28 | 29 | it('applies no discount if no coupon', async () => { 30 | const data = { 31 | data: { 32 | ppp: { 33 | pppConversionFactor: 0.1, 34 | }, 35 | }, 36 | }; 37 | 38 | const get = jest.spyOn(axios, 'get'); 39 | get.mockImplementationOnce(() => Promise.resolve(data)); 40 | 41 | await expect( 42 | priceWithDiscount( 43 | 'THE_ROAD_TO_LEARN_REACT', 44 | 'PROFESSIONAL', 45 | 100, 46 | null, 47 | '' 48 | ) 49 | ).resolves.toEqual(100); 50 | }); 51 | 52 | it('applies no discount if third-party API throws error', async () => { 53 | const get = jest.spyOn(axios, 'get'); 54 | get.mockImplementationOnce(() => 55 | Promise.reject(new Error('error')) 56 | ); 57 | 58 | await expect( 59 | priceWithDiscount( 60 | 'THE_ROAD_TO_LEARN_REACT', 61 | 'PROFESSIONAL', 62 | 100, 63 | 'MY_COUPON', 64 | '' 65 | ) 66 | ).resolves.toEqual(100); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/screens/Checkout/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import styled from 'styled-components'; 4 | import { useRouter } from 'next/router'; 5 | import { Layout as AntdLayout } from 'antd'; 6 | 7 | import { StorefrontCourse } from '@generated/client'; 8 | import { GET_STOREFRONT_COURSE } from '@queries/storefront'; 9 | import { Session } from '@typeDefs/session'; 10 | import * as ROUTES from '@constants/routes'; 11 | import Layout from '@components/Layout'; 12 | 13 | import CheckoutWizard from './CheckoutWizard'; 14 | 15 | const StyledContent = styled(AntdLayout.Content)` 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | 20 | margin-top: 56px; 21 | `; 22 | 23 | interface CheckoutPageProps { 24 | data: { 25 | storefrontCourse: StorefrontCourse; 26 | }; 27 | } 28 | 29 | type NextAuthPage = NextPage & { 30 | isAuthorized: (session: Session) => boolean; 31 | }; 32 | 33 | const CheckoutPage: NextAuthPage = ({ data }) => { 34 | const router = useRouter(); 35 | 36 | React.useEffect(() => { 37 | router.prefetch(ROUTES.INDEX); 38 | }); 39 | 40 | return ( 41 | 42 | 43 | 44 | 45 | 46 | ); 47 | }; 48 | 49 | CheckoutPage.isAuthorized = (session: Session) => true; 50 | 51 | CheckoutPage.getInitialProps = async ctx => { 52 | const { courseId, bundleId } = ctx.query; 53 | 54 | const { data } = await ctx.apolloClient.query({ 55 | query: GET_STOREFRONT_COURSE, 56 | variables: { 57 | courseId, 58 | bundleId, 59 | }, 60 | }); 61 | 62 | return { data }; 63 | }; 64 | 65 | export default CheckoutPage; 66 | -------------------------------------------------------------------------------- /src/services/convertkit/spec.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import { inviteToConvertkit } from '.'; 4 | 5 | jest.mock('axios'); 6 | 7 | describe('inviteToConvertkit', () => { 8 | beforeEach(() => { 9 | process.env.CONVERTKIT_API_KEY = 'apikey'; 10 | process.env.CONVERTKIT_FORM_ID = 'myformid'; 11 | }); 12 | 13 | afterEach(() => { 14 | delete process.env.CONVERTKIT_API_KEY; 15 | delete process.env.CONVERTKIT_FORM_ID; 16 | }); 17 | 18 | it('signs up for the conversion', async () => { 19 | const post = jest.spyOn(axios, 'post'); 20 | post.mockImplementationOnce(() => Promise.resolve(true)); 21 | 22 | await expect( 23 | inviteToConvertkit('example@example.com', 'Example') 24 | ).resolves.toEqual(true); 25 | 26 | expect(axios.post).toHaveBeenCalledWith( 27 | 'https://api.convertkit.com/v3/forms/myformid/subscribe', 28 | { 29 | api_key: 'apikey', 30 | email: 'example@example.com', 31 | first_name: 'Example', 32 | } 33 | ); 34 | }); 35 | 36 | it('does not sign up for the conversion, if no api key', async () => { 37 | delete process.env.CONVERTKIT_API_KEY; 38 | 39 | const post = jest.spyOn(axios, 'post'); 40 | post.mockImplementationOnce(() => Promise.resolve(true)); 41 | 42 | await inviteToConvertkit('example@example.com', 'Example'); 43 | 44 | expect(axios.post).toHaveBeenCalledTimes(0); 45 | }); 46 | 47 | it('does not sign up for the conversion, if no form id', async () => { 48 | delete process.env.CONVERTKIT_FORM_ID; 49 | 50 | const post = jest.spyOn(axios, 'post'); 51 | post.mockImplementationOnce(() => Promise.resolve(true)); 52 | 53 | await inviteToConvertkit('example@example.com', 'Example'); 54 | 55 | expect(axios.post).toHaveBeenCalledTimes(0); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/screens/Partner/Faq/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import { User } from '@generated/client'; 5 | import { PARTNER_PERCENTAGE } from '@constants/partner'; 6 | 7 | const UnstyledList = styled.ul` 8 | margin: 0; 9 | padding: 0; 10 | list-style-type: none; 11 | `; 12 | 13 | interface FaqProps { 14 | me: User; 15 | } 16 | 17 | const Faq = ({ me }: FaqProps) => ( 18 | 19 |
  • 20 | What's the partner program? 21 |

    22 | Partners of this website earn a commission whenever they send 23 | traffic to this website that results in a payment. 24 |

    25 |
  • 26 |
  • 27 | How much does a partner earn? 28 |

    29 | Partners earn {PARTNER_PERCENTAGE}% of the amount of each 30 | succesful payment. 31 |

    32 |
  • 33 |
  • 34 | Can everyone become a partner? 35 |

    36 | The seats for partners are limited. If you have an audience 37 | (e.g. social media) or platform (e.g. blog) for people 38 | interested in the topics taught on this website, you are 39 | welcome to apply for the partner program. 40 |

    41 |
  • 42 |
  • 43 | How do I apply as partner? 44 |

    45 | If the above applies to you, please{' '} 46 | contact me with your 47 | details which should include some words about yourself, your 48 | email {me.email}, estimated traffic, and one or more 49 | URL(s) to your website, blog, newsletter, social media or 50 | anything else related to it. 51 |

    52 |
  • 53 |
    54 | ); 55 | 56 | export default Faq; 57 | -------------------------------------------------------------------------------- /src/screens/CommunityJoin/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import styled from 'styled-components'; 4 | import { Card, Layout as AntdLayout } from 'antd'; 5 | 6 | import { User } from '@generated/client'; 7 | import { GET_ME } from '@queries/user'; 8 | import type { Session } from '@typeDefs/session'; 9 | import Layout from '@components/Layout'; 10 | 11 | import CommunityJoinForm from './CommunityJoinForm'; 12 | 13 | const StyledContent = styled(AntdLayout.Content)` 14 | display: flex; 15 | justify-content: center; 16 | align-items: center; 17 | 18 | margin-top: 56px; 19 | `; 20 | 21 | const StyledCard = styled(Card)` 22 | min-width: 200px; 23 | max-width: 400px; 24 | `; 25 | 26 | interface CommunityJoinPageeProps { 27 | data: { 28 | me: User; 29 | }; 30 | } 31 | 32 | type NextAuthPage = NextPage & { 33 | isAuthorized: (session: Session) => boolean; 34 | }; 35 | 36 | const CommunityJoinPage: NextAuthPage = ({ data }) => { 37 | return ( 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | ); 46 | }; 47 | 48 | CommunityJoinPage.isAuthorized = (session: Session) => !!session; 49 | 50 | CommunityJoinPage.getInitialProps = async (ctx) => { 51 | const isServer = ctx.req || ctx.res; 52 | 53 | const context = isServer 54 | ? { 55 | context: { 56 | headers: { 57 | cookie: ctx?.req?.headers.cookie, 58 | }, 59 | }, 60 | } 61 | : null; 62 | 63 | const { data } = await ctx.apolloClient.query({ 64 | query: GET_ME, 65 | ...(isServer && context), 66 | }); 67 | 68 | return { data }; 69 | }; 70 | 71 | export default CommunityJoinPage; 72 | -------------------------------------------------------------------------------- /src/screens/CourseItem/BookDownload/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useApolloClient } from '@apollo/react-hooks'; 3 | import { Icon } from 'antd'; 4 | import FileSaver from 'file-saver'; 5 | import b64toBlob from 'b64-to-blob'; 6 | 7 | import { 8 | BookDownloadData, 9 | BookDownloadItem, 10 | } from '@generated/client'; 11 | import { GET_BOOK } from '@queries/book'; 12 | 13 | import { StyledCards, StyledCard } from '../styles'; 14 | 15 | type BookDownloadCardProps = { 16 | item: BookDownloadItem; 17 | }; 18 | 19 | const BookDownloadCard = ({ item }: BookDownloadCardProps) => { 20 | const apolloClient = useApolloClient(); 21 | 22 | const [isLoading, setIsLoading] = React.useState(false); 23 | 24 | const onDownload = async () => { 25 | setIsLoading(true); 26 | 27 | const { data } = await apolloClient.query({ 28 | query: GET_BOOK, 29 | variables: { 30 | path: item.url, 31 | fileName: item.fileName || '', 32 | }, 33 | }); 34 | 35 | setIsLoading(false); 36 | 37 | const { fileName, body, contentType } = data.book; 38 | 39 | const blob = b64toBlob(body, contentType); 40 | FileSaver.saveAs(blob, fileName); 41 | }; 42 | 43 | let actions = [ 44 | 45 | {isLoading ? : 'Download'} 46 | , 47 | ]; 48 | 49 | return ( 50 | 53 | {item.label} 54 | 55 | } 56 | actions={actions} 57 | > 58 | {item.description} 59 | 60 | ); 61 | }; 62 | 63 | type BookDownloadProps = { 64 | bookDownloadData: BookDownloadData; 65 | }; 66 | 67 | const BookDownload = ({ bookDownloadData }: BookDownloadProps) => { 68 | return ( 69 | 70 | {bookDownloadData.items.map(item => ( 71 | 72 | ))} 73 | 74 | ); 75 | }; 76 | 77 | export default BookDownload; 78 | -------------------------------------------------------------------------------- /src/screens/Checkout/CheckoutWizardPay/StripeCheckout/index.tsx: -------------------------------------------------------------------------------- 1 | // https://stripe.com/docs/payments/checkout/one-time#create-one-time-payments 2 | 3 | import React from 'react'; 4 | import { Button, message } from 'antd'; 5 | import lf from 'localforage'; 6 | 7 | import { 8 | StorefrontCourse, 9 | useStripeCreateOrderMutation, 10 | } from '@generated/client'; 11 | import useErrorIndicator from '@hooks/useErrorIndicator'; 12 | 13 | export type StripeCheckoutProps = { 14 | storefrontCourse: StorefrontCourse; 15 | coupon: string; 16 | }; 17 | 18 | const StripeCheckout = ({ 19 | storefrontCourse, 20 | coupon, 21 | }: StripeCheckoutProps) => { 22 | const { courseId } = storefrontCourse; 23 | const { bundleId, imageUrl } = storefrontCourse.bundle; 24 | 25 | const [ 26 | stripeCreateOrder, 27 | { loading, error }, 28 | ] = useStripeCreateOrderMutation(); 29 | 30 | useErrorIndicator({ 31 | error, 32 | }); 33 | 34 | const handlePay = async () => { 35 | const partner = JSON.parse(await lf.getItem('partner')); 36 | const partnerId = partner ? partner.partnerId : ''; 37 | 38 | let result; 39 | 40 | try { 41 | result = await stripeCreateOrder({ 42 | variables: { 43 | imageUrl, 44 | courseId, 45 | bundleId, 46 | coupon, 47 | partnerId, 48 | }, 49 | }); 50 | } catch (error) {} 51 | 52 | if (result) { 53 | const stripeResult = await (window as any) 54 | .Stripe(process.env.STRIPE_CLIENT_ID) 55 | .redirectToCheckout({ 56 | sessionId: result?.data?.stripeCreateOrder.id, 57 | }); 58 | 59 | stripeResult.error && 60 | message.error({ 61 | content: stripeResult.error.message, 62 | duration: 2, 63 | }); 64 | } 65 | }; 66 | 67 | return ( 68 | 76 | ); 77 | }; 78 | 79 | export default StripeCheckout; 80 | -------------------------------------------------------------------------------- /src/api/resolvers/coupon/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ObjectType, 3 | Field, 4 | Arg, 5 | Ctx, 6 | Resolver, 7 | Query, 8 | Mutation, 9 | UseMiddleware, 10 | } from 'type-graphql'; 11 | 12 | import type { ResolverContext } from '@typeDefs/resolver'; 13 | import { priceWithDiscount } from '@services/discount'; 14 | import storefront from '@data/course-storefront'; 15 | import { COURSE } from '@data/course-keys-types'; 16 | import { BUNDLE } from '@data/bundle-keys-types'; 17 | import { isAuthenticated } from '@api/middleware/resolver/isAuthenticated'; 18 | import { isAdmin } from '@api/middleware/resolver/isAdmin'; 19 | @ObjectType() 20 | class Discount { 21 | @Field() 22 | price: number; 23 | 24 | @Field() 25 | isDiscount: boolean; 26 | } 27 | 28 | @Resolver() 29 | export default class CouponResolver { 30 | @Query(() => Discount) 31 | @UseMiddleware(isAuthenticated) 32 | async discountedPrice( 33 | @Arg('courseId') courseId: string, 34 | @Arg('bundleId') bundleId: string, 35 | @Arg('coupon') coupon: string, 36 | @Ctx() ctx: ResolverContext 37 | ): Promise { 38 | const course = storefront[courseId as COURSE]; 39 | const bundle = course.bundles[bundleId as BUNDLE]; 40 | 41 | const price = await priceWithDiscount( 42 | ctx.couponConnector, 43 | ctx.courseConnector 44 | )( 45 | courseId as COURSE, 46 | bundleId as BUNDLE, 47 | bundle.price, 48 | coupon, 49 | ctx.me!.uid 50 | ); 51 | 52 | return { 53 | price, 54 | isDiscount: price !== bundle.price, 55 | }; 56 | } 57 | 58 | @Mutation(() => Boolean) 59 | @UseMiddleware(isAuthenticated, isAdmin) 60 | async couponCreate( 61 | @Arg('coupon') coupon: string, 62 | @Arg('discount') discount: number, 63 | @Arg('count') count: number, 64 | @Arg('courseId') courseId: string | undefined | null, 65 | @Arg('bundleId') bundleId: string | undefined | null, 66 | @Ctx() ctx: ResolverContext 67 | ): Promise { 68 | try { 69 | await ctx.couponConnector.createCoupons( 70 | coupon, 71 | discount, 72 | count, 73 | courseId as COURSE, 74 | bundleId as BUNDLE 75 | ); 76 | } catch (error) { 77 | throw new Error(error); 78 | } 79 | 80 | return true; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/queries/course.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const GET_UNLOCKED_COURSES = gql` 4 | query GetCourses { 5 | unlockedCourses { 6 | courseId 7 | header 8 | url 9 | imageUrl 10 | canUpgrade 11 | } 12 | } 13 | `; 14 | 15 | export const GET_UNLOCKED_COURSE = gql` 16 | query GetCourse($courseId: String!) { 17 | unlockedCourse(courseId: $courseId) { 18 | courseId 19 | header 20 | canUpgrade 21 | introduction { 22 | label 23 | data { 24 | label 25 | description 26 | url 27 | } 28 | } 29 | onboarding { 30 | label 31 | data { 32 | items { 33 | label 34 | description 35 | url 36 | secondaryUrl 37 | } 38 | } 39 | } 40 | bookDownload { 41 | label 42 | data { 43 | items { 44 | label 45 | description 46 | url 47 | fileName 48 | } 49 | } 50 | } 51 | bookOnline { 52 | label 53 | data { 54 | chapters { 55 | label 56 | url 57 | sections { 58 | label 59 | url 60 | } 61 | } 62 | } 63 | } 64 | curriculum { 65 | label 66 | data { 67 | sections { 68 | label 69 | items { 70 | kind 71 | label 72 | description 73 | url 74 | secondaryUrl 75 | } 76 | } 77 | } 78 | } 79 | } 80 | } 81 | `; 82 | 83 | export const CREATE_FREE_COURSE = gql` 84 | mutation CreateFreeCourse($courseId: String!, $bundleId: String!, $coupon: String!) { 85 | createFreeCourse(courseId: $courseId, bundleId: $bundleId, coupon: $coupon) 86 | } 87 | `; 88 | 89 | export const CREATE_ADMIN_COURSE = gql` 90 | mutation CreateAdminCourse( 91 | $uid: String! 92 | $courseId: String! 93 | $bundleId: String! 94 | ) { 95 | createAdminCourse( 96 | uid: $uid 97 | courseId: $courseId 98 | bundleId: $bundleId 99 | ) 100 | } 101 | `; 102 | -------------------------------------------------------------------------------- /src/api/resolvers/storefront/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ObjectType, 3 | Field, 4 | Arg, 5 | Resolver, 6 | Query, 7 | } from 'type-graphql'; 8 | 9 | import { COURSE } from '@data/course-keys-types'; 10 | import { BUNDLE } from '@data/bundle-keys-types'; 11 | 12 | import sortBy from 'lodash.sortby'; 13 | 14 | import storefront from '@data/course-storefront'; 15 | 16 | @ObjectType() 17 | export class StorefrontBundle { 18 | @Field() 19 | header: string; 20 | 21 | @Field() 22 | bundleId: string; 23 | 24 | @Field() 25 | price: number; 26 | 27 | @Field() 28 | imageUrl: string; 29 | 30 | @Field(type => [String]) 31 | benefits: string[]; 32 | } 33 | 34 | @ObjectType() 35 | export class StorefrontCourse { 36 | @Field() 37 | header: string; 38 | 39 | @Field() 40 | courseId: string; 41 | 42 | @Field() 43 | url: string; 44 | 45 | @Field() 46 | imageUrl: string; 47 | 48 | @Field() 49 | canUpgrade: boolean; 50 | 51 | @Field() 52 | bundle?: StorefrontBundle; 53 | } 54 | 55 | @Resolver() 56 | export default class StorefrontResolver { 57 | @Query(() => StorefrontCourse) 58 | async storefrontCourse( 59 | @Arg('courseId') courseId: string, 60 | @Arg('bundleId') bundleId: string 61 | ): Promise { 62 | const course = storefront[courseId as COURSE]; 63 | const bundle = course.bundles[bundleId as BUNDLE]; 64 | 65 | return { 66 | ...course, 67 | header: course.header, 68 | courseId: course.courseId, 69 | url: course.url, 70 | imageUrl: course.imageUrl, 71 | canUpgrade: false, 72 | bundle, 73 | }; 74 | } 75 | 76 | @Query(() => [StorefrontCourse]) 77 | async storefrontCourses(): Promise { 78 | return Object.values(storefront).map(storefrontCourse => ({ 79 | courseId: storefrontCourse.courseId, 80 | header: storefrontCourse.header, 81 | url: storefrontCourse.url, 82 | imageUrl: storefrontCourse.imageUrl, 83 | canUpgrade: false, 84 | })); 85 | } 86 | 87 | @Query(() => [StorefrontBundle]) 88 | async storefrontBundles( 89 | @Arg('courseId') courseId: string 90 | ): Promise { 91 | const course = storefront[courseId as COURSE]; 92 | 93 | return sortBy( 94 | Object.values(course.bundles), 95 | (bundle: any) => bundle.weight 96 | ); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/screens/Partner/Visitors/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Skeleton } from 'antd'; 3 | import { ResponsiveCalendar } from '@nivo/calendar'; 4 | 5 | import useErrorIndicator from '@hooks/useErrorIndicator'; 6 | import { 7 | User, 8 | VisitorByDay, 9 | usePartnerVisitorsQuery, 10 | } from '@generated/client'; 11 | 12 | interface VisitorChartProps { 13 | me: User; 14 | isPartner: boolean; 15 | } 16 | 17 | const formatDateForChart = (date: Date) => 18 | date.toISOString().split('T')[0]; 19 | 20 | const VisitorChart = ({ me, isPartner }: VisitorChartProps) => { 21 | if (!isPartner) { 22 | return null; 23 | } 24 | 25 | const startOfLastYearDate = new Date( 26 | new Date().getFullYear() - 1, 27 | 0, 28 | 1, 29 | 12 30 | ); 31 | const endOfYearDate = new Date( 32 | new Date().getFullYear(), 33 | 11, 34 | 31, 35 | 12 36 | ); 37 | 38 | const { data, loading, error } = usePartnerVisitorsQuery({ 39 | variables: { 40 | from: startOfLastYearDate, 41 | to: endOfYearDate, 42 | }, 43 | }); 44 | 45 | useErrorIndicator({ 46 | error, 47 | }); 48 | 49 | if (loading) return ; 50 | if (!data) return null; 51 | 52 | const chartData = data.partnerVisitors.map( 53 | (value: VisitorByDay) => ({ 54 | day: formatDateForChart(new Date(value.date)), 55 | value: value.count, 56 | }) 57 | ); 58 | 59 | return ( 60 |
    66 | 90 |
    91 | ); 92 | }; 93 | 94 | export default VisitorChart; 95 | -------------------------------------------------------------------------------- /src/screens/Checkout/CheckoutWizard/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useRouter } from 'next/router'; 3 | import styled from 'styled-components'; 4 | import { Card, Steps } from 'antd'; 5 | 6 | import { StorefrontCourse } from '@generated/client'; 7 | import SessionContext from '@context/session'; 8 | import * as ROUTES from '@constants/routes'; 9 | 10 | import CheckoutWizardAccount from '../CheckoutWizardAccount'; 11 | import CheckoutWizardPay from '../CheckoutWizardPay'; 12 | 13 | const Container = styled.div` 14 | min-width: 200px; 15 | max-width: 400px; 16 | `; 17 | 18 | const StyledSteps = styled(Steps)` 19 | padding: 16px; 20 | `; 21 | 22 | type CheckoutWizardProps = { 23 | storefrontCourse: StorefrontCourse; 24 | }; 25 | 26 | const CheckoutWizard = ({ 27 | storefrontCourse, 28 | }: CheckoutWizardProps) => { 29 | const router = useRouter(); 30 | 31 | const session = React.useContext(SessionContext); 32 | 33 | const [currentStep, setCurrentStep] = React.useState( 34 | session ? 1 : 0 35 | ); 36 | 37 | const handleNext = () => { 38 | setCurrentStep(1); 39 | }; 40 | 41 | const handleSuccess = () => { 42 | router.push(ROUTES.INDEX); 43 | }; 44 | 45 | return ( 46 | 47 | 48 | 49 | 50 | 51 | 52 | 60 | ) 61 | } 62 | > 63 |
    64 | {currentStep === 0 && ( 65 | 66 | )} 67 | 68 | {currentStep === 1 && !!storefrontCourse && ( 69 | 73 | )} 74 | 75 | {currentStep === 1 && !storefrontCourse && ( 76 | <> 77 | You haven't selected a course yet. Choose a course 78 | first. You can find all the courses in the navigation. 79 | 80 | // TODO 81 | )} 82 |
    83 |
    84 |
    85 | ); 86 | }; 87 | 88 | export default CheckoutWizard; 89 | -------------------------------------------------------------------------------- /pages/api/graphql.ts: -------------------------------------------------------------------------------- 1 | import { ApolloServer } from 'apollo-server-micro'; 2 | import { buildSchema } from 'type-graphql'; 3 | import { applyMiddleware } from 'graphql-middleware'; 4 | 5 | import cors from 'micro-cors'; 6 | 7 | import 'reflect-metadata'; 8 | 9 | import getConnection from '@models/index'; 10 | import { AdminConnector } from '@connectors/admin'; 11 | import { PartnerConnector } from '@connectors/partner'; 12 | import { CourseConnector } from '@connectors/course'; 13 | import { CouponConnector } from '@connectors/coupon'; 14 | import type { ServerRequest, ServerResponse } from '@typeDefs/server'; 15 | import type { ResolverContext } from '@typeDefs/resolver'; 16 | 17 | import resolvers from '@api/resolvers'; 18 | import meMiddleware from '@api/middleware/global/me'; 19 | import sentryMiddleware from '@api/middleware/global/sentry'; 20 | 21 | import firebaseAdmin from '@services/firebase/admin'; 22 | 23 | if (process.env.FIREBASE_ADMIN_UID) { 24 | firebaseAdmin 25 | .auth() 26 | .getUser(process.env.FIREBASE_ADMIN_UID) 27 | .then(user => { 28 | if (process.env.FIREBASE_ADMIN_UID) { 29 | firebaseAdmin 30 | .auth() 31 | .setCustomUserClaims(process.env.FIREBASE_ADMIN_UID, { 32 | ...user.customClaims, 33 | admin: true, 34 | }); 35 | } 36 | }); 37 | } 38 | 39 | const withCors = cors({ 40 | origin: '*', 41 | }); 42 | 43 | export const config = { 44 | api: { 45 | bodyParser: false, 46 | }, 47 | }; 48 | 49 | export default async (req: ServerRequest, res: ServerResponse) => { 50 | const connection = await getConnection(); 51 | 52 | const schema = await buildSchema({ 53 | resolvers, 54 | dateScalarMode: 'isoDate', 55 | }); 56 | 57 | const server = new ApolloServer({ 58 | schema: applyMiddleware(schema, sentryMiddleware, meMiddleware), 59 | 60 | context: async ({ req, res }): Promise => { 61 | const adminConnector = new AdminConnector(); 62 | const partnerConnector = new PartnerConnector(connection!); 63 | const courseConnector = new CourseConnector(connection!); 64 | const couponConnector = new CouponConnector(connection!); 65 | 66 | return { 67 | req, 68 | res, 69 | adminConnector, 70 | courseConnector, 71 | partnerConnector, 72 | couponConnector, 73 | }; 74 | }, 75 | }); 76 | 77 | const handler = withCors( 78 | server.createHandler({ path: '/api/graphql' }) 79 | ); 80 | 81 | return handler(req, res); 82 | }; 83 | -------------------------------------------------------------------------------- /src/screens/Partner/GetStarted/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import { User } from '@generated/client'; 5 | 6 | const UnstyledList = styled.ul` 7 | margin: 0; 8 | padding: 0; 9 | list-style-type: none; 10 | `; 11 | 12 | interface GetStartedProps { 13 | me: User; 14 | isPartner: boolean; 15 | } 16 | 17 | const GetStarted = ({ me, isPartner }: GetStartedProps) => { 18 | const generalPartnerLink = 19 | typeof window !== 'undefined' 20 | ? `${window.location.origin}?partnerId=${me.uid}` 21 | : ''; 22 | 23 | return ( 24 | 25 |
  • 26 | Am I a partner? 27 |

    28 | {isPartner 29 | ? 'Yes.' 30 | : 'Not yet, please check the FAQ if you want to apply.'} 31 |

    32 |
  • 33 | 34 | {isPartner && ( 35 | <> 36 |
  • 37 | What's my partner ID? 38 |

    {me.uid}

    39 |
  • 40 | 41 |
  • 42 | 43 | How do I refer to this website as a partner? 44 | 45 |

    46 | If you want to refer to this website, use{' '} 47 | {generalPartnerLink} as referral link. It's 48 | important that your partner ID is set as{' '} 49 | partnerId query parameter in the URL. 50 |

    51 |
  • 52 | 53 |
  • 54 | How does it work? 55 |

    56 | Every time a user visits this website via your referral 57 | link and happens to buy something, it will be recorded 58 | as a referral Sale on your Sales tab. If the 59 | user allows the usage of the browser's storage, the 60 | referral link will work over multiple browser sessions. 61 |

    62 |
  • 63 |
  • 64 | 65 | How can I verify that my referral link works? 66 | 67 |

    68 | Please verify it on the Visitors tab after 69 | using the referral link yourself. 70 |

    71 |
  • 72 |
  • 73 | When can I expect my payment? 74 |

    The payment will happen at the end of every month.

    75 |
  • 76 | 77 | )} 78 |
    79 | ); 80 | }; 81 | 82 | export default GetStarted; 83 | -------------------------------------------------------------------------------- /src/components/Navigation/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { Menu, Icon } from 'antd'; 4 | 5 | import { useApolloClient } from '@apollo/react-hooks'; 6 | import * as ROUTES from '@constants/routes'; 7 | import SessionContext from '@context/session'; 8 | import Link from '@components/Link'; 9 | 10 | import signOut from './signOut'; 11 | 12 | const Container = styled.nav` 13 | ul { 14 | padding-top: 8px; 15 | padding: bottom: 0px; 16 | padding-left: 30px; 17 | padding-right: 32px; 18 | } 19 | `; 20 | 21 | const Navigation = () => { 22 | const apolloClient = useApolloClient(); 23 | const session = React.useContext(SessionContext); 24 | 25 | return ( 26 | 27 | 28 | 34 | 35 | logo 36 | 37 | 38 | 39 | {!session && ( 40 | 41 | Sign In 42 | 43 | )} 44 | 45 | {session && ( 46 | 47 | 48 | 49 | 50 | Account 51 | 52 | 53 | 54 | 55 | 56 | Join Community 57 | 58 | 59 | 60 | 61 | 62 | Partner Program 63 | 64 | 65 | 68 | signOut(undefined, undefined, apolloClient) 69 | } 70 | > 71 | 72 | Sign Out 73 | 74 | 75 | )} 76 | 77 | 78 | Courses 79 | 80 | 81 | 82 | ); 83 | }; 84 | 85 | export default Navigation; 86 | -------------------------------------------------------------------------------- /pages/_error.js: -------------------------------------------------------------------------------- 1 | // https://github.com/zeit/next.js/blob/canary/examples/with-sentry-simple/pages/_error.js 2 | 3 | import React from 'react'; 4 | import Error from 'next/error'; 5 | import * as Sentry from '@sentry/node'; 6 | 7 | Sentry.init({ 8 | dsn: process.env.SENTRY_DSN, 9 | }); 10 | 11 | const MyError = ({ statusCode, hasGetInitialPropsRun, err }) => { 12 | if (!hasGetInitialPropsRun && err) { 13 | // getInitialProps is not called in case of 14 | // https://github.com/zeit/next.js/issues/8592. As a workaround, we pass 15 | // err via _app.js so it can be captured 16 | Sentry.captureException(err); 17 | } 18 | 19 | return ; 20 | }; 21 | 22 | MyError.getInitialProps = async ({ res, err, asPath }) => { 23 | const errorInitialProps = await Error.getInitialProps({ res, err }); 24 | 25 | // Workaround for https://github.com/zeit/next.js/issues/8592, mark when 26 | // getInitialProps has run 27 | errorInitialProps.hasGetInitialPropsRun = true; 28 | 29 | if (res) { 30 | // Running on the server, the response object is available. 31 | // 32 | // Next.js will pass an err on the server if a page's `getInitialProps` 33 | // threw or returned a Promise that rejected 34 | 35 | if (res.statusCode === 404) { 36 | // Opinionated: do not record an exception in Sentry for 404 37 | return { statusCode: 404 }; 38 | } 39 | 40 | if (err) { 41 | Sentry.captureException(err); 42 | 43 | return errorInitialProps; 44 | } 45 | } else { 46 | // Running on the client (browser). 47 | // 48 | // Next.js will provide an err if: 49 | // 50 | // - a page's `getInitialProps` threw or returned a Promise that rejected 51 | // - an exception was thrown somewhere in the React lifecycle (render, 52 | // componentDidMount, etc) that was caught by Next.js's React Error 53 | // Boundary. Read more about what types of exceptions are caught by Error 54 | // Boundaries: https://reactjs.org/docs/error-boundaries.html 55 | if (err) { 56 | Sentry.captureException(err); 57 | 58 | return errorInitialProps; 59 | } 60 | } 61 | 62 | // If this point is reached, getInitialProps was called without any 63 | // information about what the error might be. This is unexpected and may 64 | // indicate a bug introduced in Next.js, so record it in Sentry 65 | Sentry.captureException( 66 | new Error( 67 | `_error.js getInitialProps missing data at path: ${asPath}` 68 | ) 69 | ); 70 | 71 | return errorInitialProps; 72 | }; 73 | 74 | export default MyError; 75 | -------------------------------------------------------------------------------- /src/screens/Admin/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useRouter } from 'next/router'; 3 | import { useApolloClient } from '@apollo/react-hooks'; 4 | 5 | import type { Session } from '@typeDefs/session'; 6 | import { MIGRATE } from '@queries/migrate'; 7 | import { CREATE_ADMIN_COURSE } from '@queries/course'; 8 | import { PROMOTE_TO_PARTNER } from '@queries/partner'; 9 | import { COUPON_CREATE } from '@queries/coupon'; 10 | import { formatRouteQuery } from '@services/format'; 11 | 12 | const TYPES = { 13 | MIGRATE: 'MIGRATE', 14 | 15 | COURSE_CREATE: 'COURSE_CREATE', 16 | 17 | PROMOTE_TO_PARTNER: 'PROMOTE_TO_PARTNER', 18 | 19 | COUPON_CREATE: 'COUPON_CREATE', 20 | }; 21 | 22 | const AdminPage = () => { 23 | const apolloClient = useApolloClient(); 24 | const { query } = useRouter(); 25 | 26 | React.useEffect(() => { 27 | const type = formatRouteQuery(query.type); 28 | 29 | if (type === TYPES.COURSE_CREATE) { 30 | apolloClient.mutate({ 31 | mutation: CREATE_ADMIN_COURSE, 32 | variables: { 33 | uid: formatRouteQuery(query.uid), 34 | courseId: formatRouteQuery(query.courseId), 35 | bundleId: formatRouteQuery(query.bundleId), 36 | }, 37 | }); 38 | } 39 | 40 | if (type === TYPES.MIGRATE) { 41 | apolloClient.mutate({ 42 | mutation: MIGRATE, 43 | variables: { 44 | migrationType: formatRouteQuery(query.migrationType), 45 | }, 46 | }); 47 | } 48 | 49 | if (type === TYPES.PROMOTE_TO_PARTNER) { 50 | apolloClient.mutate({ 51 | mutation: PROMOTE_TO_PARTNER, 52 | variables: { 53 | uid: formatRouteQuery(query.uid), 54 | }, 55 | }); 56 | } 57 | 58 | if (type === TYPES.PROMOTE_TO_PARTNER) { 59 | apolloClient.mutate({ 60 | mutation: PROMOTE_TO_PARTNER, 61 | variables: { 62 | uid: formatRouteQuery(query.uid), 63 | }, 64 | }); 65 | } 66 | 67 | if (type === TYPES.COUPON_CREATE) { 68 | apolloClient.mutate({ 69 | mutation: COUPON_CREATE, 70 | variables: { 71 | coupon: formatRouteQuery(query.coupon), 72 | discount: Number(formatRouteQuery(query.discount)), // 1 - 100 73 | count: Number(formatRouteQuery(query.count)), 74 | courseId: formatRouteQuery(query.courseId), 75 | bundleId: formatRouteQuery(query.bundleId), 76 | }, 77 | }); 78 | } 79 | }, []); 80 | 81 | return null; 82 | }; 83 | 84 | AdminPage.isAuthorized = (session: Session) => !!session; 85 | 86 | export default AdminPage; 87 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/typeorm/typeorm/issues/5676#issuecomment-797925238 2 | // https://github.com/typeorm/typeorm/issues/6241#issuecomment-643690383 3 | 4 | import { Connection, getConnectionManager } from 'typeorm'; 5 | 6 | import * as CourseEntities from './course'; 7 | import * as PartnerEntities from './partner'; 8 | import * as CouponEntities from './coupon'; 9 | 10 | const options: { [key: string]: any } = { 11 | default: { 12 | type: process.env.DATABASE_TYPE as any, 13 | host: process.env.DATABASE_HOST as any, 14 | port: process.env.DATABASE_PORT as any, 15 | username: process.env.DATABASE_USERNAME as any, 16 | password: process.env.DATABASE_PASSWORD as any, 17 | database: process.env.DATABASE_NAME as any, 18 | entities: [ 19 | ...Object.values(CourseEntities), 20 | ...Object.values(PartnerEntities), 21 | ...Object.values(CouponEntities), 22 | ], 23 | synchronize: true, 24 | logging: false, 25 | ...(process.env.NODE_ENV === 'production' && { 26 | ssl: { 27 | ca: process.env.DATABASE_SSL_CERTIFICATE, 28 | }, 29 | }), 30 | }, 31 | }; 32 | 33 | function entitiesChanged( 34 | prevEntities: any[], 35 | newEntities: any[] 36 | ): boolean { 37 | if (prevEntities.length !== newEntities.length) return true; 38 | 39 | for (let i = 0; i < prevEntities.length; i++) { 40 | if (prevEntities[i] !== newEntities[i]) return true; 41 | } 42 | 43 | return false; 44 | } 45 | 46 | async function updateConnectionEntities( 47 | connection: Connection, 48 | entities: any[] 49 | ) { 50 | // @ts-ignore 51 | if (!entitiesChanged(connection.options.entities, entities)) return; 52 | 53 | // @ts-ignore 54 | connection.options.entities = entities; 55 | 56 | // @ts-ignore 57 | connection.buildMetadatas(); 58 | 59 | if (connection.options.synchronize) { 60 | await connection.synchronize(); 61 | } 62 | } 63 | 64 | export default async function ( 65 | name: string = 'default' 66 | ): Promise { 67 | const connectionManager = getConnectionManager(); 68 | 69 | if (connectionManager.has(name)) { 70 | const connection = connectionManager.get(name); 71 | 72 | if (!connection.isConnected) { 73 | await connection.connect(); 74 | } 75 | 76 | if (process.env.NODE_ENV !== 'production') { 77 | await updateConnectionEntities( 78 | connection, 79 | options[name].entities 80 | ); 81 | } 82 | 83 | return connection; 84 | } 85 | 86 | return await connectionManager 87 | .create({ name, ...options[name] }) 88 | .connect(); 89 | } 90 | -------------------------------------------------------------------------------- /src/screens/PasswordForgot/PasswordForgotForm/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Form, Input } from 'antd'; 3 | import { FormComponentProps } from 'antd/lib/form'; 4 | 5 | import { usePasswordForgotMutation } from '@generated/client'; 6 | import FormItem from '@components/Form/Item'; 7 | import FormStretchedButton from '@components/Form/StretchedButton'; 8 | import useIndicators from '@hooks/useIndicators'; 9 | 10 | interface PasswordForgotFormProps extends FormComponentProps {} 11 | 12 | const PasswordForgotForm = ({ form }: PasswordForgotFormProps) => { 13 | const [ 14 | passwordForgot, 15 | { loading, error }, 16 | ] = usePasswordForgotMutation(); 17 | 18 | const { successMessage } = useIndicators({ 19 | key: 'password-forgot', 20 | error, 21 | success: { message: 'Success! Check your email inbox.' }, 22 | }); 23 | 24 | const handleSubmit = (event: React.FormEvent) => { 25 | form.validateFields(async (error, values) => { 26 | if (error) return; 27 | 28 | try { 29 | await passwordForgot({ 30 | variables: { 31 | email: values.email, 32 | }, 33 | }); 34 | 35 | successMessage(); 36 | 37 | form.resetFields(); 38 | } catch (error) {} 39 | }); 40 | 41 | event.preventDefault(); 42 | }; 43 | 44 | const formItemLayout = { 45 | labelCol: { 46 | xs: { span: 24 }, 47 | sm: { span: 6 }, 48 | }, 49 | wrapperCol: { 50 | xs: { span: 24 }, 51 | sm: { span: 18 }, 52 | }, 53 | }; 54 | 55 | return ( 56 |
    57 | 58 | {form.getFieldDecorator('email', { 59 | rules: [ 60 | { 61 | type: 'email', 62 | message: 'The input is not valid email!', 63 | }, 64 | { 65 | required: true, 66 | message: 'Please input your email!', 67 | }, 68 | ], 69 | validateFirst: true, 70 | validateTrigger: 'onBlur', 71 | })()} 72 | 73 | 74 | 75 | 81 | Reset Password 82 | 83 | 84 | 85 | ); 86 | }; 87 | 88 | export default Form.create({ 89 | name: 'password-forgot', 90 | })(PasswordForgotForm); 91 | -------------------------------------------------------------------------------- /src/screens/CommunityJoin/CommunityJoinForm/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Form, Input } from 'antd'; 3 | import { FormComponentProps } from 'antd/lib/form'; 4 | 5 | import { User, useCommunityJoinMutation } from '@generated/client'; 6 | import FormItem from '@components/Form/Item'; 7 | import FormStretchedButton from '@components/Form/StretchedButton'; 8 | import useIndicators from '@hooks/useIndicators'; 9 | 10 | interface CommunityJoinFormProps extends FormComponentProps { 11 | me: User; 12 | } 13 | 14 | const CommunityJoinForm = ({ form, me }: CommunityJoinFormProps) => { 15 | React.useEffect(() => { 16 | form.setFields({ email: { value: me.email } }); 17 | }, []); 18 | 19 | const [ 20 | communityJoin, 21 | { loading, error }, 22 | ] = useCommunityJoinMutation(); 23 | 24 | const { successMessage } = useIndicators({ 25 | key: 'community-join', 26 | error, 27 | success: { message: 'Success! Check your email inbox.' }, 28 | }); 29 | 30 | const handleSubmit = (event: React.FormEvent) => { 31 | form.validateFields(async (error, values) => { 32 | if (error) return; 33 | 34 | try { 35 | await communityJoin({ 36 | variables: { 37 | email: values.email, 38 | }, 39 | }); 40 | 41 | form.resetFields(); 42 | 43 | successMessage(); 44 | } catch (error) {} 45 | }); 46 | 47 | event.preventDefault(); 48 | }; 49 | 50 | const formItemLayout = { 51 | labelCol: { 52 | xs: { span: 24 }, 53 | sm: { span: 10 }, 54 | }, 55 | wrapperCol: { 56 | xs: { span: 24 }, 57 | sm: { span: 14 }, 58 | }, 59 | }; 60 | 61 | return ( 62 |
    63 | 64 | {form.getFieldDecorator('email', { 65 | rules: [ 66 | { 67 | type: 'email', 68 | message: 'The input is not valid email!', 69 | }, 70 | { 71 | required: true, 72 | message: 'Please input your email!', 73 | }, 74 | ], 75 | validateFirst: true, 76 | validateTrigger: 'onBlur', 77 | })()} 78 | 79 | 80 | 81 | 87 | Join Community 88 | 89 | 90 | 91 | ); 92 | }; 93 | 94 | export default Form.create({ 95 | name: 'community-join', 96 | })(CommunityJoinForm); 97 | -------------------------------------------------------------------------------- /src/api/resolvers/stripe/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ObjectType, 3 | Field, 4 | Arg, 5 | Ctx, 6 | Resolver, 7 | Mutation, 8 | UseMiddleware, 9 | } from 'type-graphql'; 10 | 11 | import { COURSE } from '@data/course-keys-types'; 12 | import { BUNDLE } from '@data/bundle-keys-types'; 13 | import type { ResolverContext } from '@typeDefs/resolver'; 14 | 15 | @ObjectType() 16 | class StripeId { 17 | @Field({ nullable: true }) 18 | id: string | undefined | null; 19 | } 20 | 21 | import { priceWithDiscount } from '@services/discount'; 22 | import stripe from '@services/stripe'; 23 | 24 | import storefront from '@data/course-storefront'; 25 | import { isAuthenticated } from '@api/middleware/resolver/isAuthenticated'; 26 | 27 | // https://stripe.com/docs/payments/checkout/one-time#create-one-time-payments 28 | @Resolver() 29 | export default class StripeResolver { 30 | @Mutation(() => StripeId) 31 | async stripeCreateOrder( 32 | @Arg('imageUrl') imageUrl: string, 33 | @Arg('courseId') courseId: string, 34 | @Arg('bundleId') bundleId: string, 35 | 36 | @Arg('coupon', { nullable: true }) 37 | coupon: string | undefined | null, 38 | 39 | @Arg('partnerId', { nullable: true }) 40 | partnerId: string | undefined | null, 41 | 42 | @Ctx() ctx: ResolverContext 43 | ): Promise { 44 | const course = storefront[courseId as COURSE]; 45 | const bundle = course.bundles[bundleId as BUNDLE]; 46 | 47 | if (!ctx.me) { 48 | return { id: null }; 49 | } 50 | 51 | const price = await priceWithDiscount( 52 | ctx.couponConnector, 53 | ctx.courseConnector 54 | )( 55 | courseId as COURSE, 56 | bundleId as BUNDLE, 57 | bundle.price, 58 | coupon, 59 | ctx.me.uid 60 | ); 61 | 62 | let session; 63 | 64 | try { 65 | session = await stripe.checkout.sessions.create({ 66 | customer_email: ctx.me?.email, 67 | client_reference_id: ctx.me?.uid, 68 | payment_method_types: ['card'], 69 | line_items: [ 70 | { 71 | name: course.header, 72 | description: bundle.header, 73 | images: [imageUrl], 74 | amount: price, 75 | currency: 'usd', 76 | quantity: 1, 77 | }, 78 | ], 79 | metadata: { 80 | courseId, 81 | bundleId, 82 | coupon, 83 | partnerId, 84 | }, 85 | payment_intent_data: { 86 | description: `${courseId} ${bundleId}`, 87 | }, 88 | success_url: process.env.BASE_URL, 89 | cancel_url: `${process.env.BASE_URL}/checkout?courseId=${courseId}&bundleId=${bundleId}`, 90 | }); 91 | } catch (error) { 92 | throw new Error(error); 93 | } 94 | 95 | return { id: session.id }; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/screens/Partner/Sales/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Table } from 'antd'; 3 | 4 | import { User, usePartnerSalesLazyQuery } from '@generated/client'; 5 | import useErrorIndicator from '@hooks/useErrorIndicator'; 6 | import { formatDateTime, formatPrice } from '@services/format'; 7 | 8 | const columns = [ 9 | { 10 | key: 'createdAt', 11 | title: 'Date', 12 | dataIndex: 'createdAt', 13 | }, 14 | { 15 | key: 'courseId', 16 | title: 'Course ID', 17 | dataIndex: 'courseId', 18 | }, 19 | { 20 | key: 'bundleId', 21 | title: 'Bundle ID', 22 | dataIndex: 'bundleId', 23 | }, 24 | { 25 | key: 'price', 26 | title: 'Price', 27 | dataIndex: 'price', 28 | }, 29 | { 30 | key: 'royalty', 31 | title: 'Royalty', 32 | dataIndex: 'royalty', 33 | }, 34 | ]; 35 | 36 | interface SalesTableProps { 37 | me: User; 38 | isPartner: boolean; 39 | } 40 | 41 | const LIMIT = 10; 42 | 43 | const SalesTable = ({ me, isPartner }: SalesTableProps) => { 44 | if (!isPartner) { 45 | return null; 46 | } 47 | 48 | const [ 49 | getPartnerSales, 50 | { loading, error, data }, 51 | ] = usePartnerSalesLazyQuery(); 52 | 53 | React.useEffect(() => { 54 | getPartnerSales({ 55 | variables: { 56 | offset: 0, 57 | limit: LIMIT, 58 | }, 59 | }); 60 | }, []); 61 | 62 | const [pagination, setPagination] = React.useState({ 63 | total: 0, 64 | pageSize: LIMIT, 65 | current: 1, 66 | simple: true, 67 | }); 68 | 69 | React.useEffect(() => { 70 | if (!data) { 71 | return; 72 | } 73 | 74 | setPagination({ 75 | ...pagination, 76 | total: data.partnerSales.pageInfo.total, 77 | }); 78 | }, [data]); 79 | 80 | useErrorIndicator({ 81 | error, 82 | }); 83 | 84 | const handleChange = (newPagination: any) => { 85 | setPagination({ 86 | ...pagination, 87 | current: newPagination.current, 88 | }); 89 | 90 | getPartnerSales({ 91 | variables: { 92 | offset: Math.round(LIMIT * newPagination.current - 1), 93 | limit: LIMIT, 94 | }, 95 | }); 96 | }; 97 | 98 | const dataSource = (data?.partnerSales?.edges || []).map( 99 | partnerSale => ({ 100 | ...partnerSale, 101 | createdAt: formatDateTime(new Date(partnerSale.createdAt)), 102 | price: formatPrice(partnerSale.price), 103 | royalty: formatPrice(partnerSale.royalty), 104 | }) 105 | ); 106 | 107 | return ( 108 |
    116 | ); 117 | }; 118 | 119 | export default SalesTable; 120 | -------------------------------------------------------------------------------- /src/screens/Checkout/CheckoutWizardPay/PaypalCheckout/index.tsx: -------------------------------------------------------------------------------- 1 | // https://developer.paypal.com/docs/checkout/integrate/ 2 | 3 | import React from 'react'; 4 | import lf from 'localforage'; 5 | 6 | import { 7 | StorefrontCourse, 8 | usePaypalCreateOrderMutation, 9 | usePaypalApproveOrderMutation, 10 | } from '@generated/client'; 11 | import useIndicators from '@hooks/useIndicators'; 12 | import FormAtomButton from '@components/Form/AtomButton'; 13 | 14 | export type PaypalCheckoutProps = { 15 | storefrontCourse: StorefrontCourse; 16 | coupon: string; 17 | onSuccess: () => void; 18 | onBack: () => void; 19 | }; 20 | 21 | const PaypalCheckout = ({ 22 | storefrontCourse, 23 | coupon, 24 | onSuccess, 25 | onBack, 26 | }: PaypalCheckoutProps) => { 27 | const { courseId } = storefrontCourse; 28 | const { bundleId } = storefrontCourse.bundle; 29 | 30 | const [ 31 | paypalCreateOrder, 32 | { loading: createOrderLoading, error: createOrderError }, 33 | ] = usePaypalCreateOrderMutation(); 34 | 35 | const [ 36 | paypalApproveOrder, 37 | { loading: approveOrderLoading, error: approveOrderError }, 38 | ] = usePaypalApproveOrderMutation(); 39 | 40 | const { successMessage, destroyMessage } = useIndicators({ 41 | key: 'paypal', 42 | loading: createOrderLoading || approveOrderLoading, 43 | error: createOrderError || approveOrderError, 44 | }); 45 | 46 | React.useEffect(() => { 47 | const createOrder = async () => { 48 | const partner = JSON.parse(await lf.getItem('partner')); 49 | const partnerId = partner ? partner.partnerId : ''; 50 | 51 | try { 52 | const { data } = await paypalCreateOrder({ 53 | variables: { 54 | courseId, 55 | bundleId, 56 | coupon, 57 | partnerId, 58 | }, 59 | }); 60 | 61 | return data?.paypalCreateOrder.orderId; 62 | } catch (error) {} 63 | }; 64 | 65 | const onApprove = async (data: { orderID: string }) => { 66 | try { 67 | await paypalApproveOrder({ 68 | variables: { orderId: data.orderID }, 69 | }); 70 | 71 | successMessage(); 72 | onSuccess(); 73 | } catch (error) {} 74 | }; 75 | 76 | const onCancel = () => { 77 | destroyMessage.current(); 78 | }; 79 | 80 | (window as any).paypal 81 | .Buttons({ 82 | createOrder, 83 | onApprove, 84 | onCancel, 85 | }) 86 | .render('#paypal-button-container'); 87 | }, []); 88 | 89 | return ( 90 | <> 91 |
    92 | 97 | Go back 98 | 99 | 100 | ); 101 | }; 102 | 103 | export default PaypalCheckout; 104 | -------------------------------------------------------------------------------- /pages/api/stripe-webhook.ts: -------------------------------------------------------------------------------- 1 | // https://stripe.com/docs/payments/checkout/fulfillment#webhooks 2 | 3 | import stripe from '@services/stripe'; 4 | 5 | // LEGACY 6 | import { createCourse } from '@services/firebase/course'; 7 | // LEGACY END 8 | 9 | import getConnection from '@models/index'; 10 | import { CourseConnector } from '@connectors/course'; 11 | import { PartnerConnector } from '@connectors/partner'; 12 | import { CouponConnector } from '@connectors/coupon'; 13 | 14 | import { send } from 'micro'; 15 | import getRawBody from 'raw-body'; 16 | 17 | import type { ServerRequest, ServerResponse } from '@typeDefs/server'; 18 | 19 | export default async ( 20 | request: ServerRequest, 21 | response: ServerResponse 22 | ) => { 23 | const rawBody = await getRawBody(request); 24 | 25 | const sig = request.headers['stripe-signature']; 26 | 27 | let event; 28 | 29 | try { 30 | event = stripe.webhooks.constructEvent( 31 | rawBody, 32 | sig, 33 | process.env.STRIPE_WEBHOOK_SECRET 34 | ); 35 | } catch (error) { 36 | send(response, 400, `Webhook Error: ${error.message}`); 37 | } 38 | 39 | if (event.type === 'checkout.session.completed') { 40 | const session = event.data.object; 41 | 42 | const { 43 | metadata, 44 | client_reference_id, 45 | // customer_email, 46 | display_items, 47 | } = session; 48 | 49 | const { courseId, bundleId, coupon, partnerId } = metadata; 50 | 51 | const connection = await getConnection(); 52 | const courseConnector = new CourseConnector(connection!); 53 | const partnerConnector = new PartnerConnector(connection!); 54 | const couponConnector = new CouponConnector(connection!); 55 | 56 | const course = await courseConnector.createCourse({ 57 | userId: client_reference_id, 58 | courseId: courseId, 59 | bundleId: bundleId, 60 | price: display_items[0].amount, 61 | currency: 'USD', 62 | paymentType: 'STRIPE', 63 | coupon: coupon, 64 | }); 65 | 66 | if (coupon) { 67 | await couponConnector.removeCoupon(coupon); 68 | } 69 | 70 | if (partnerId && partnerId !== client_reference_id) { 71 | await partnerConnector.createSale(course, partnerId); 72 | } 73 | 74 | // LEGACY 75 | await createCourse({ 76 | uid: client_reference_id, 77 | courseId: courseId, 78 | bundleId: bundleId, 79 | amount: Number((display_items[0].amount / 100).toFixed(2)), 80 | paymentType: 'STRIPE', 81 | coupon: coupon, 82 | }); 83 | // LEGACY END 84 | } 85 | 86 | // TODO 87 | // accoring to Stripe documentation a response from express looks like: response.json({ received: true }); 88 | // I send the response with Micro now and experience that the Stripe server doesn't react to it. Is there anything wrong? 89 | send(response, 200, { received: true }); 90 | }; 91 | 92 | export const config = { 93 | api: { 94 | bodyParser: false, 95 | }, 96 | }; 97 | -------------------------------------------------------------------------------- /src/services/discount/index.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import { COURSE } from '@data/course-keys-types'; 4 | import { BUNDLE } from '@data/bundle-keys-types'; 5 | import { getUpgradeableCourses } from '@services/course'; 6 | import { Course } from '@models/course'; 7 | 8 | import { CouponConnector } from '@connectors/coupon'; 9 | import { CourseConnector } from '@connectors/course'; 10 | 11 | import { Ppp } from './types'; 12 | 13 | const getPpp = async (countryCodeIsoAlpha2: string) => 14 | await axios.get(`${process.env.COUPON_URL}${countryCodeIsoAlpha2}`); 15 | 16 | const getPppPrice = (price: number, ppp: Ppp) => { 17 | return ppp 18 | ? Number((ppp.pppConversionFactor * price).toFixed(0)) 19 | : price; 20 | }; 21 | 22 | const tryPppDiscount = async (price: number, coupon: string) => { 23 | try { 24 | const { data } = await getPpp(coupon); 25 | 26 | return getPppPrice(price, data.ppp); 27 | } catch (error) { 28 | return price; 29 | } 30 | }; 31 | 32 | const tryUpgradeDiscount = async ( 33 | courseId: COURSE, 34 | bundleId: BUNDLE, 35 | courses: Course[], 36 | price: number, 37 | coupon: string, 38 | uid: string 39 | ) => { 40 | if (!courses) { 41 | return price; 42 | } 43 | 44 | const upgradeableCourses = getUpgradeableCourses(courseId, courses); 45 | 46 | const upgradeableCourse = upgradeableCourses.find( 47 | (course) => course.bundle.bundleId === bundleId 48 | ); 49 | 50 | if (upgradeableCourse?.bundle.bundleId === coupon) { 51 | return upgradeableCourse?.bundle.price; 52 | } 53 | 54 | return price; 55 | }; 56 | 57 | export const priceWithDiscount = 58 | ( 59 | couponConnector: CouponConnector, 60 | courseConnector: CourseConnector 61 | ) => 62 | async ( 63 | courseId: COURSE, 64 | bundleId: BUNDLE, 65 | price: number, 66 | coupon: string | undefined | null, 67 | uid: string | undefined | null 68 | ) => { 69 | if (!coupon || price === 0 || !uid) { 70 | return price; 71 | } 72 | 73 | // Custom Coupon 74 | 75 | let discountedPrice; 76 | 77 | discountedPrice = await couponConnector.redeemCoupon( 78 | coupon, 79 | price, 80 | courseId, 81 | bundleId 82 | ); 83 | 84 | if (discountedPrice !== price) { 85 | return discountedPrice; 86 | } 87 | 88 | // Upgrade 89 | 90 | const courses = 91 | await courseConnector.getCoursesByUserIdAndCourseId( 92 | uid, 93 | courseId 94 | ); 95 | 96 | discountedPrice = await tryUpgradeDiscount( 97 | courseId, 98 | bundleId, 99 | courses, 100 | price, 101 | coupon, 102 | uid 103 | ); 104 | 105 | if (discountedPrice !== price) { 106 | return discountedPrice; 107 | } 108 | 109 | // PPP 110 | 111 | discountedPrice = await tryPppDiscount( 112 | discountedPrice, 113 | coupon.replace(process.env.COUPON_SALT || '', '') 114 | ); 115 | 116 | if (discountedPrice !== price) { 117 | return discountedPrice; 118 | } 119 | 120 | return price; 121 | }; 122 | -------------------------------------------------------------------------------- /src/connectors/coupon.ts: -------------------------------------------------------------------------------- 1 | import { LessThan } from 'typeorm'; 2 | import { Connection, Repository } from 'typeorm'; 3 | 4 | import { Coupon } from '@models/coupon'; 5 | import { COURSE } from '@data/course-keys-types'; 6 | import { BUNDLE } from '@data/bundle-keys-types'; 7 | 8 | export class CouponConnector { 9 | couponRepository: Repository; 10 | 11 | constructor(connection: Connection) { 12 | this.couponRepository = 13 | connection?.getRepository('Coupon'); 14 | } 15 | 16 | async createCoupons( 17 | coupon: string, 18 | discount: number, 19 | count: number, 20 | courseId: COURSE, 21 | bundleId: BUNDLE 22 | ) { 23 | await this.removeExpiredCoupons(); 24 | 25 | const expiresAt = new Date(); 26 | expiresAt.setDate(expiresAt.getDate() + 7); 27 | 28 | const couponEntity = new Coupon(); 29 | 30 | couponEntity.coupon = coupon; 31 | couponEntity.discount = discount; 32 | couponEntity.count = count; 33 | couponEntity.courseId = courseId; 34 | couponEntity.bundleId = bundleId; 35 | couponEntity.expiresAt = expiresAt; 36 | 37 | return await this.couponRepository.save(couponEntity); 38 | } 39 | 40 | async redeemCoupon( 41 | coupon: string, 42 | price: number, 43 | courseId: COURSE, 44 | bundleId: BUNDLE 45 | ) { 46 | const couponEntity = await this.couponRepository.findOne({ 47 | coupon, 48 | }); 49 | 50 | if (!couponEntity) { 51 | return price; 52 | } 53 | 54 | const isNotTightToCourseOrBundle = 55 | (couponEntity.courseId && couponEntity.courseId !== courseId) || 56 | (couponEntity.bundleId && couponEntity.bundleId !== bundleId); 57 | 58 | if (isNotTightToCourseOrBundle) { 59 | return price; 60 | } 61 | 62 | const isExpired = 63 | new Date().getTime() > 64 | new Date(couponEntity.expiresAt).getTime(); 65 | 66 | if (isExpired) { 67 | return price; 68 | } 69 | 70 | const count = couponEntity.count; 71 | 72 | if (count === 0) { 73 | return price; 74 | } 75 | 76 | const discountPrice = Number( 77 | ((price / 100) * (100 - couponEntity.discount)).toFixed(0) 78 | ); 79 | 80 | return discountPrice; 81 | } 82 | 83 | async removeCoupon(coupon: string) { 84 | const couponEntity = await this.couponRepository.findOne({ 85 | coupon, 86 | }); 87 | 88 | if (!couponEntity) { 89 | return; 90 | } 91 | 92 | if (couponEntity.count > 1) { 93 | couponEntity.count = couponEntity.count - 1; 94 | await this.couponRepository.save(couponEntity); 95 | } else { 96 | await this.couponRepository.remove(couponEntity); 97 | } 98 | 99 | return; 100 | } 101 | 102 | async removeExpiredCoupons() { 103 | const coupons = await this.couponRepository.find({ 104 | where: { 105 | expiresAt: LessThan(new Date()), 106 | }, 107 | }); 108 | 109 | for (let i = 0; i < coupons.length; i++) { 110 | await this.couponRepository.remove(coupons[i]); 111 | } 112 | 113 | return; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/screens/PasswordForgot/PasswordForgotForm/spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, fireEvent, wait } from '@testing-library/react'; 2 | import { MockedProvider } from '@apollo/react-testing'; 3 | import { GraphQLError } from 'graphql'; 4 | import { message } from 'antd'; 5 | 6 | import { PASSWORD_FORGOT } from '@queries/session'; 7 | import PasswordForgotForm from '.'; 8 | 9 | describe('PasswordForgotForm', () => { 10 | const email = 'example@example.com'; 11 | 12 | message.error = jest.fn(); 13 | 14 | let mutationCalled: boolean; 15 | 16 | beforeEach(() => { 17 | mutationCalled = false; 18 | }); 19 | 20 | it('resets a password with success', async () => { 21 | const mocks = [ 22 | { 23 | request: { 24 | query: PASSWORD_FORGOT, 25 | variables: { email }, 26 | }, 27 | result: () => { 28 | mutationCalled = true; 29 | return { data: { passwordForgot: null } }; 30 | }, 31 | }, 32 | ]; 33 | 34 | const component = render( 35 | 36 | 37 | 38 | ); 39 | 40 | fireEvent.change( 41 | component.getByLabelText('password-forgot-email'), 42 | { 43 | target: { value: email }, 44 | } 45 | ); 46 | 47 | expect( 48 | component 49 | .getByLabelText('password-forgot-submit') 50 | .classList.contains('ant-btn-loading') 51 | ).toBe(false); 52 | 53 | fireEvent.click( 54 | component.getByLabelText('password-forgot-submit') 55 | ); 56 | 57 | expect( 58 | component 59 | .getByLabelText('password-forgot-submit') 60 | .classList.contains('ant-btn-loading') 61 | ).toBe(true); 62 | 63 | await wait(() => { 64 | expect(mutationCalled).toBe(true); 65 | expect(message.error).toHaveBeenCalledTimes(0); 66 | 67 | expect( 68 | component 69 | .getByLabelText('password-forgot-submit') 70 | .classList.contains('ant-btn-loading') 71 | ).toBe(false); 72 | }); 73 | }); 74 | 75 | it('resets a password with error', async () => { 76 | const mocks = [ 77 | { 78 | request: { 79 | query: PASSWORD_FORGOT, 80 | variables: { email }, 81 | }, 82 | result: () => { 83 | mutationCalled = true; 84 | return { errors: [new GraphQLError('Error!')] }; 85 | }, 86 | }, 87 | ]; 88 | 89 | const component = render( 90 | 91 | 92 | 93 | ); 94 | 95 | fireEvent.change( 96 | component.getByLabelText('password-forgot-email'), 97 | { 98 | target: { value: email }, 99 | } 100 | ); 101 | 102 | fireEvent.click( 103 | component.getByLabelText('password-forgot-submit') 104 | ); 105 | 106 | await wait(() => { 107 | expect(mutationCalled).toBe(true); 108 | expect(message.error).toHaveBeenCalledTimes(1); 109 | }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /src/components/Mdx/Code.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Highlight, { defaultProps } from 'prism-react-renderer'; 3 | import theme from 'prism-react-renderer/themes/nightOwl'; 4 | 5 | const RE = /{(.*?)}/; 6 | 7 | const calculateLinesToHighlight = (meta: string) => { 8 | const lineNumbers = meta 9 | .split(',') 10 | .map(v => v.split('-').map(v => parseInt(v, 10))); 11 | 12 | return (index: number) => { 13 | const lineNumber = index + 1; 14 | return lineNumbers.some(([start, end]) => 15 | end 16 | ? lineNumber >= start && lineNumber <= end 17 | : lineNumber === start 18 | ); 19 | }; 20 | }; 21 | 22 | type CodeProps = { 23 | children: string; 24 | offset: number; 25 | className: string; 26 | }; 27 | 28 | const Code = ({ children, offset, className }: CodeProps) => { 29 | const highlight = className.match(RE); 30 | 31 | let isLineToHighlight = (index: number) => false; 32 | let language = className.replace(/language-/, ''); 33 | 34 | if (highlight) { 35 | language = language.replace(highlight[0], ''); 36 | isLineToHighlight = calculateLinesToHighlight(highlight[1]); 37 | } 38 | 39 | if (language === 'javascript') { 40 | language = 'jsx'; 41 | } 42 | 43 | return ( 44 | 50 | {({ 51 | className, 52 | style, 53 | tokens, 54 | getLineProps, 55 | getTokenProps, 56 | }) => { 57 | const tokensWithoutTrailingLine = tokens.slice(0, -1); 58 | 59 | return ( 60 |
     70 |             {tokensWithoutTrailingLine.map((line, i) => {
     71 |               const lineProps = getLineProps({
     72 |                 line,
     73 |                 key: i,
     74 |               });
     75 |               if (isLineToHighlight(i)) {
     76 |                 lineProps.className = `${lineProps.className} highlight-line`;
     77 |               }
     78 | 
     79 |               //github.com/FormidableLabs/prism-react-renderer/issues/36#issue-439146277
     80 |               if (line.length === 1 && line[0].content === '') {
     81 |                 line[0].content = ' ';
     82 |               }
     83 | 
     84 |               return (
     85 |                 
    86 | {line.map((token, key) => ( 87 | 94 | ))} 95 |
    96 | ); 97 | })} 98 |
    99 | ); 100 | }} 101 |
    102 | ); 103 | }; 104 | 105 | export default Code; 106 | -------------------------------------------------------------------------------- /src/screens/CommunityJoin/CommunityJoinForm/spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, fireEvent, wait } from '@testing-library/react'; 2 | import { MockedProvider } from '@apollo/react-testing'; 3 | import { GraphQLError } from 'graphql'; 4 | import { message } from 'antd'; 5 | 6 | import { COMMUNITY_JOIN } from '@queries/session'; 7 | import CommunityJoinForm from '.'; 8 | 9 | describe('CommunityJoinForm', () => { 10 | const email = 'mynewemail@domain.comw'; 11 | 12 | message.error = jest.fn(); 13 | 14 | let mutationCalled: boolean; 15 | 16 | beforeEach(() => { 17 | mutationCalled = false; 18 | }); 19 | 20 | it('join with email with success', async () => { 21 | const mocks = [ 22 | { 23 | request: { 24 | query: COMMUNITY_JOIN, 25 | variables: { email: 'mynewemail@domain.comw' }, 26 | }, 27 | result: () => { 28 | mutationCalled = true; 29 | return { data: { emailChange: null } }; 30 | }, 31 | }, 32 | ]; 33 | 34 | const component = render( 35 | 36 | 37 | 38 | ); 39 | 40 | fireEvent.change( 41 | component.getByLabelText('community-join-email'), 42 | { 43 | target: { value: email }, 44 | } 45 | ); 46 | 47 | expect( 48 | component 49 | .getByLabelText('community-join-submit') 50 | .classList.contains('ant-btn-loading') 51 | ).toBe(false); 52 | 53 | fireEvent.click( 54 | component.getByLabelText('community-join-submit') 55 | ); 56 | 57 | expect( 58 | component 59 | .getByLabelText('community-join-submit') 60 | .classList.contains('ant-btn-loading') 61 | ).toBe(true); 62 | 63 | await wait(() => { 64 | expect(mutationCalled).toBe(true); 65 | expect(message.error).toHaveBeenCalledTimes(0); 66 | 67 | expect( 68 | component 69 | .getByLabelText('community-join-submit') 70 | .classList.contains('ant-btn-loading') 71 | ).toBe(false); 72 | }); 73 | }); 74 | 75 | it('join community with email with error', async () => { 76 | const mocks = [ 77 | { 78 | request: { 79 | query: COMMUNITY_JOIN, 80 | variables: { email: 'mynewemail@domain.comw' }, 81 | }, 82 | result: () => { 83 | mutationCalled = true; 84 | return { errors: [new GraphQLError('Error!')] }; 85 | }, 86 | }, 87 | ]; 88 | 89 | const component = render( 90 | 91 | 92 | 93 | ); 94 | 95 | fireEvent.change( 96 | component.getByLabelText('community-join-email'), 97 | { 98 | target: { value: email }, 99 | } 100 | ); 101 | 102 | fireEvent.click( 103 | component.getByLabelText('community-join-submit') 104 | ); 105 | 106 | await wait(() => { 107 | expect(mutationCalled).toBe(true); 108 | expect(message.error).toHaveBeenCalledTimes(1); 109 | }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /src/screens/SignIn/SignInForm/spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, fireEvent, wait } from '@testing-library/react'; 2 | import { MockedProvider } from '@apollo/react-testing'; 3 | import { GraphQLError } from 'graphql'; 4 | import { message } from 'antd'; 5 | 6 | import { SIGN_IN } from '@queries/session'; 7 | import SignInForm from '.'; 8 | 9 | describe('SignInForm', () => { 10 | const email = 'example@example.com'; 11 | const password = 'mypassword'; 12 | 13 | const onSuccess = jest.fn(); 14 | 15 | message.error = jest.fn(); 16 | 17 | let mutationCalled: boolean; 18 | 19 | beforeEach(() => { 20 | mutationCalled = false; 21 | }); 22 | 23 | it('signs in with success', async () => { 24 | const mocks = [ 25 | { 26 | request: { 27 | query: SIGN_IN, 28 | variables: { email, password }, 29 | }, 30 | result: () => { 31 | mutationCalled = true; 32 | return { data: { signIn: { token: '1' } } }; 33 | }, 34 | }, 35 | ]; 36 | 37 | const component = render( 38 | 39 | 40 | 41 | ); 42 | 43 | fireEvent.change(component.getByLabelText('sign-in-email'), { 44 | target: { value: email }, 45 | }); 46 | 47 | fireEvent.change(component.getByLabelText('sign-in-password'), { 48 | target: { value: password }, 49 | }); 50 | 51 | expect( 52 | component 53 | .getByLabelText('sign-in-submit') 54 | .classList.contains('ant-btn-loading') 55 | ).toBe(false); 56 | 57 | fireEvent.click(component.getByLabelText('sign-in-submit')); 58 | 59 | expect( 60 | component 61 | .getByLabelText('sign-in-submit') 62 | .classList.contains('ant-btn-loading') 63 | ).toBe(true); 64 | 65 | await wait(() => { 66 | expect(onSuccess).toHaveBeenCalledTimes(1); 67 | expect(message.error).toHaveBeenCalledTimes(0); 68 | expect(mutationCalled).toBe(true); 69 | 70 | expect( 71 | component 72 | .getByLabelText('sign-in-submit') 73 | .classList.contains('ant-btn-loading') 74 | ).toBe(false); 75 | }); 76 | }); 77 | 78 | it('signs in with error', async () => { 79 | const mocks = [ 80 | { 81 | request: { 82 | query: SIGN_IN, 83 | variables: { email, password }, 84 | }, 85 | result: () => { 86 | mutationCalled = true; 87 | return { errors: [new GraphQLError('Error!')] }; 88 | }, 89 | }, 90 | ]; 91 | 92 | const component = render( 93 | 94 | 95 | 96 | ); 97 | 98 | fireEvent.change(component.getByLabelText('sign-in-email'), { 99 | target: { value: email }, 100 | }); 101 | 102 | fireEvent.change(component.getByLabelText('sign-in-password'), { 103 | target: { value: password }, 104 | }); 105 | 106 | fireEvent.click(component.getByLabelText('sign-in-submit')); 107 | 108 | await wait(() => { 109 | expect(onSuccess).toHaveBeenCalledTimes(0); 110 | expect(message.error).toHaveBeenCalledTimes(1); 111 | expect(mutationCalled).toBe(true); 112 | }); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/components/Head/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Head from 'next/head'; 3 | 4 | export default () => ( 5 | 6 | RWieruch 7 | 8 | 9 | 10 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 36 | 41 | 46 | 51 | 56 | 61 | 66 | 71 | 76 | 82 | 88 | 94 | 100 | 101 | 102 | 106 | 107 | 108 | ); 109 | -------------------------------------------------------------------------------- /src/screens/EmailChange/EmailChangeForm/spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, fireEvent, wait } from '@testing-library/react'; 2 | import { MockedProvider } from '@apollo/react-testing'; 3 | import { GraphQLError } from 'graphql'; 4 | import { message } from 'antd'; 5 | 6 | import { EMAIL_CHANGE } from '@queries/session'; 7 | import EmailChangeForm from '.'; 8 | 9 | describe('EmailChangeForm', () => { 10 | const newEmail = 'mynewemail@domain.comw'; 11 | 12 | message.error = jest.fn(); 13 | 14 | let mutationCalled: boolean; 15 | 16 | beforeEach(() => { 17 | mutationCalled = false; 18 | }); 19 | 20 | it('changes a email with success', async () => { 21 | const mocks = [ 22 | { 23 | request: { 24 | query: EMAIL_CHANGE, 25 | variables: { email: 'mynewemail@domain.comw' }, 26 | }, 27 | result: () => { 28 | mutationCalled = true; 29 | return { data: { emailChange: null } }; 30 | }, 31 | }, 32 | ]; 33 | 34 | const component = render( 35 | 36 | 37 | 38 | ); 39 | 40 | fireEvent.change( 41 | component.getByLabelText('email-change-email-new'), 42 | { 43 | target: { value: newEmail }, 44 | } 45 | ); 46 | 47 | fireEvent.change( 48 | component.getByLabelText('email-change-email-confirm'), 49 | { 50 | target: { value: newEmail }, 51 | } 52 | ); 53 | 54 | expect( 55 | component 56 | .getByLabelText('email-change-submit') 57 | .classList.contains('ant-btn-loading') 58 | ).toBe(false); 59 | 60 | fireEvent.click(component.getByLabelText('email-change-submit')); 61 | 62 | expect( 63 | component 64 | .getByLabelText('email-change-submit') 65 | .classList.contains('ant-btn-loading') 66 | ).toBe(true); 67 | 68 | await wait(() => { 69 | expect(mutationCalled).toBe(true); 70 | expect(message.error).toHaveBeenCalledTimes(0); 71 | 72 | expect( 73 | component 74 | .getByLabelText('email-change-submit') 75 | .classList.contains('ant-btn-loading') 76 | ).toBe(false); 77 | }); 78 | }); 79 | 80 | it('changes a email with error', async () => { 81 | const mocks = [ 82 | { 83 | request: { 84 | query: EMAIL_CHANGE, 85 | variables: { email: 'mynewemail@domain.comw' }, 86 | }, 87 | result: () => { 88 | mutationCalled = true; 89 | return { errors: [new GraphQLError('Error!')] }; 90 | }, 91 | }, 92 | ]; 93 | 94 | const component = render( 95 | 96 | 97 | 98 | ); 99 | 100 | fireEvent.change( 101 | component.getByLabelText('email-change-email-new'), 102 | { 103 | target: { value: newEmail }, 104 | } 105 | ); 106 | 107 | fireEvent.change( 108 | component.getByLabelText('email-change-email-confirm'), 109 | { 110 | target: { value: newEmail }, 111 | } 112 | ); 113 | 114 | fireEvent.click(component.getByLabelText('email-change-submit')); 115 | 116 | await wait(() => { 117 | expect(mutationCalled).toBe(true); 118 | expect(message.error).toHaveBeenCalledTimes(1); 119 | }); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /src/screens/Account/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import styled from 'styled-components'; 4 | import { Card, Col, Row, Layout as AntdLayout } from 'antd'; 5 | 6 | import { User } from '@generated/client'; 7 | import { GET_ME } from '@queries/user'; 8 | import { Session } from '@typeDefs/session'; 9 | import * as ROUTES from '@constants/routes'; 10 | import Layout from '@components/Layout'; 11 | import Link from '@components/Link'; 12 | 13 | const StyledContent = styled(AntdLayout.Content)` 14 | margin: calc(56px + 32px) 32px 32px; 15 | `; 16 | 17 | interface AccountPageProps { 18 | data: { 19 | me: User; 20 | }; 21 | } 22 | 23 | type NextAuthPage = NextPage & { 24 | isAuthorized: (session: Session) => boolean; 25 | }; 26 | 27 | const AccountPage: NextAuthPage = ({ data }) => { 28 | return ( 29 | 30 | 31 | 32 |
    33 | 34 |
      35 |
    • 36 | ID: {data?.me?.uid} 37 |
    • 38 |
    • 39 | Username: {data?.me?.username} 40 |
    • 41 |
    • 42 | Email: {data?.me?.email} ( 43 | Change Email 44 | ) 45 |
    • 46 |
    47 |
    48 | 49 | 50 | 51 |
      52 |
    • 53 | 54 | Change Password 55 | 56 |
    • 57 |
    • 58 | 59 | Forgot Password 60 | 61 |
    • 62 |
    • 63 | Change Email 64 |
    • 65 |
    66 |
    67 | 68 | 69 | 70 |
      71 |
    • 72 | Partner Program 73 |
    • 74 |
    75 |
    76 | 77 | 78 | 79 | 84 | 85 | 86 | 87 | 88 | 89 | ); 90 | }; 91 | 92 | AccountPage.isAuthorized = (session: Session) => !!session; 93 | 94 | AccountPage.getInitialProps = async ctx => { 95 | const isServer = ctx.req || ctx.res; 96 | 97 | const context = isServer 98 | ? { 99 | context: { 100 | headers: { 101 | cookie: ctx?.req?.headers.cookie, 102 | }, 103 | }, 104 | } 105 | : null; 106 | 107 | const { data } = await ctx.apolloClient.query({ 108 | query: GET_ME, 109 | ...(isServer && context), 110 | }); 111 | 112 | return { data }; 113 | }; 114 | 115 | export default AccountPage; 116 | -------------------------------------------------------------------------------- /src/screens/Partner/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import styled from 'styled-components'; 4 | import { Card, Layout as AntdLayout, Breadcrumb } from 'antd'; 5 | 6 | import * as ROUTES from '@constants/routes'; 7 | import * as ROLES from '@constants/roles'; 8 | import { User } from '@generated/client'; 9 | import { GET_ME } from '@queries/user'; 10 | import { Session } from '@typeDefs/session'; 11 | import Layout from '@components/Layout'; 12 | import Link from '@components/Link'; 13 | 14 | import Faq from './Faq'; 15 | import GetStarted from './GetStarted'; 16 | import Visitors from './Visitors'; 17 | import Sales from './Sales'; 18 | import Payments from './Payments'; 19 | import Assets from './Assets'; 20 | 21 | const getTabs = (isPartner: boolean) => { 22 | let tabs = [ 23 | { 24 | key: 'tab1', 25 | tab: 'FAQ', 26 | }, 27 | { 28 | key: 'tab2', 29 | tab: 'Get Started', 30 | }, 31 | ]; 32 | 33 | if (isPartner) { 34 | tabs = tabs.concat([ 35 | { 36 | key: 'tab3', 37 | tab: 'Visitors', 38 | }, 39 | { 40 | key: 'tab4', 41 | tab: 'Sales', 42 | }, 43 | { 44 | key: 'tab5', 45 | tab: 'Payments', 46 | }, 47 | { 48 | key: 'tab6', 49 | tab: 'Assets', 50 | }, 51 | ]); 52 | } 53 | 54 | return tabs; 55 | }; 56 | 57 | const getTabsContent = ( 58 | me: User, 59 | isPartner: boolean 60 | ): { [key: string]: React.ReactNode } => ({ 61 | tab1: , 62 | tab2: , 63 | tab3: , 64 | tab4: , 65 | tab5: , 66 | tab6: , 67 | }); 68 | 69 | const StyledContent = styled(AntdLayout.Content)` 70 | margin: calc(56px + 32px) 32px 32px; 71 | `; 72 | 73 | const StyledCard = styled(Card)` 74 | &:not(:first-of-type) { 75 | margin-top: 16px; 76 | } 77 | `; 78 | 79 | interface PartnerPageeProps { 80 | data: { 81 | me: User; 82 | }; 83 | } 84 | 85 | type NextAuthPage = NextPage & { 86 | isAuthorized: (session: Session) => boolean; 87 | }; 88 | 89 | const PartnerPage: NextAuthPage = ({ data }) => { 90 | const [tab, setTab] = React.useState('tab1'); 91 | 92 | const handleTabChange = (key: string) => { 93 | setTab(key); 94 | }; 95 | 96 | const isPartner = data.me.roles.includes(ROLES.PARTNER); 97 | 98 | return ( 99 | 100 | 101 | 102 | 103 | Account 104 | 105 | Partner Program 106 | 107 | 108 | 113 | {getTabsContent(data.me, isPartner)[tab]} 114 | 115 | 116 | 117 | ); 118 | }; 119 | 120 | PartnerPage.isAuthorized = (session: Session) => !!session; 121 | 122 | PartnerPage.getInitialProps = async ctx => { 123 | const isServer = ctx.req || ctx.res; 124 | 125 | const context = isServer 126 | ? { 127 | context: { 128 | headers: { 129 | cookie: ctx?.req?.headers.cookie, 130 | }, 131 | }, 132 | } 133 | : null; 134 | 135 | const { data } = await ctx.apolloClient.query({ 136 | query: GET_ME, 137 | ...(isServer && context), 138 | }); 139 | 140 | return { data }; 141 | }; 142 | 143 | export default PartnerPage; 144 | -------------------------------------------------------------------------------- /src/screens/Checkout/CheckoutWizardPay/FreeCheckout/spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, fireEvent, wait } from '@testing-library/react'; 2 | import { MockedProvider } from '@apollo/react-testing'; 3 | import { GraphQLError } from 'graphql'; 4 | import { message } from 'antd'; 5 | 6 | import FreeCheckoutButton from '.'; 7 | import { CREATE_FREE_COURSE } from '@queries/course'; 8 | 9 | describe('FreeCheckoutButton', () => { 10 | message.error = jest.fn(); 11 | 12 | const onSuccess = jest.fn(); 13 | 14 | let mutationCalled: boolean; 15 | 16 | beforeEach(() => { 17 | mutationCalled = false; 18 | }); 19 | 20 | it('checks out with success', async () => { 21 | const mocks = [ 22 | { 23 | request: { 24 | query: CREATE_FREE_COURSE, 25 | variables: { 26 | courseId: 'THE_ROAD_TO_GRAPHQL', 27 | bundleId: 'STUDENT', 28 | onSuccess, 29 | }, 30 | }, 31 | result: () => { 32 | mutationCalled = true; 33 | return { 34 | data: { createFreeCourse: true }, 35 | }; 36 | }, 37 | }, 38 | ]; 39 | 40 | const component = render( 41 | 42 | 47 | 48 | ); 49 | 50 | expect( 51 | component 52 | .getByLabelText('free-checkout') 53 | .classList.contains('ant-btn-loading') 54 | ).toBe(false); 55 | 56 | fireEvent.click(component.getByLabelText('free-checkout')); 57 | 58 | expect( 59 | component 60 | .getByLabelText('free-checkout') 61 | .classList.contains('ant-btn-loading') 62 | ).toBe(true); 63 | 64 | await wait(() => { 65 | expect(mutationCalled).toBe(true); 66 | expect(onSuccess).toHaveBeenCalledTimes(1); 67 | expect(message.error).toHaveBeenCalledTimes(0); 68 | 69 | expect( 70 | component 71 | .getByLabelText('free-checkout') 72 | .classList.contains('ant-btn-loading') 73 | ).toBe(false); 74 | }); 75 | }); 76 | 77 | it('checks out with error', async () => { 78 | const mocks = [ 79 | { 80 | request: { 81 | query: CREATE_FREE_COURSE, 82 | variables: { 83 | courseId: 'THE_ROAD_TO_GRAPHQL', 84 | bundleId: 'STUDENT', 85 | onSuccess, 86 | }, 87 | }, 88 | result: () => { 89 | mutationCalled = true; 90 | return { errors: [new GraphQLError('Error!')] }; 91 | }, 92 | }, 93 | ]; 94 | 95 | const component = render( 96 | 97 | 102 | 103 | ); 104 | 105 | expect( 106 | component 107 | .getByLabelText('free-checkout') 108 | .classList.contains('ant-btn-loading') 109 | ).toBe(false); 110 | 111 | fireEvent.click(component.getByLabelText('free-checkout')); 112 | 113 | expect( 114 | component 115 | .getByLabelText('free-checkout') 116 | .classList.contains('ant-btn-loading') 117 | ).toBe(true); 118 | 119 | await wait(() => { 120 | expect(mutationCalled).toBe(true); 121 | expect(onSuccess).toHaveBeenCalledTimes(0); 122 | expect(message.error).toHaveBeenCalledTimes(1); 123 | 124 | expect( 125 | component 126 | .getByLabelText('free-checkout') 127 | .classList.contains('ant-btn-loading') 128 | ).toBe(false); 129 | }); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | const withSourceMaps = require('@zeit/next-source-maps')(); 4 | 5 | const withMDX = require('@next/mdx')({ 6 | extension: /\.mdx?$/, 7 | }); 8 | 9 | const withPlugins = require('next-compose-plugins'); 10 | const withLess = require('@zeit/next-less'); 11 | const bundleAnalyzer = require('@next/bundle-analyzer'); 12 | 13 | const nextConfig = { 14 | env: { 15 | BASE_URL: process.env.BASE_URL, 16 | FIREBASE_API_KEY: process.env.FIREBASE_API_KEY, 17 | FIREBASE_AUTH_DOMAIN: process.env.FIREBASE_AUTH_DOMAIN, 18 | FIREBASE_DATABASE_URL: process.env.FIREBASE_DATABASE_URL, 19 | FIREBASE_PROJECT_ID: process.env.FIREBASE_PROJECT_ID, 20 | FIREBASE_STORAGE_BUCKET: process.env.FIREBASE_STORAGE_BUCKET, 21 | FIREBASE_MESSAGING_SENDER_ID: 22 | process.env.FIREBASE_MESSAGING_SENDER_ID, 23 | FIREBASE_APP_ID: process.env.FIREBASE_APP_ID, 24 | PAYPAL_CLIENT_ID: process.env.PAYPAL_CLIENT_ID, 25 | PAYPAL_CLIENT_SECRET: process.env.PAYPAL_CLIENT_SECRET, 26 | STRIPE_CLIENT_ID: process.env.STRIPE_CLIENT_ID, 27 | STRIPE_CLIENT_SECRET: process.env.STRIPE_CLIENT_SECRET, 28 | STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, 29 | COUPON_SALT: process.env.COUPON_SALT, 30 | COUPON_URL: process.env.COUPON_URL, 31 | FIREBASE_ADMIN_UID: process.env.FIREBASE_ADMIN_UID, 32 | DATABASE_TYPE: process.env.DATABASE_TYPE, 33 | DATABASE_HOST: process.env.DATABASE_HOST, 34 | DATABASE_PORT: process.env.DATABASE_PORT, 35 | DATABASE_USERNAME: process.env.DATABASE_USERNAME, 36 | DATABASE_PASSWORD: process.env.DATABASE_PASSWORD, 37 | DATABASE_NAME: process.env.DATABASE_NAME, 38 | DATABASE_SSL_CERTIFICATE: process.env.DATABASE_SSL_CERTIFICATE, 39 | // same as in ... 40 | GOOGLE_ANALYTICS: process.env.GOOGLE_ANALYTICS, 41 | SENTRY_DSN: process.env.SENTRY_DSN, 42 | REVUE_TOKEN: process.env.REVUE_TOKEN, 43 | SLACK_TOKEN: process.env.SLACK_TOKEN, 44 | CONVERTKIT_API_KEY: process.env.CONVERTKIT_API_KEY, 45 | CONVERTKIT_FORM_ID: process.env.CONVERTKIT_FORM_ID, 46 | S3_ENDPOINT: process.env.S3_ENDPOINT, 47 | S3_ACCESS_KEY_ID: process.env.S3_ACCESS_KEY_ID, 48 | S3_SECRET_ACCESS_KEY: process.env.S3_SECRET_ACCESS_KEY, 49 | S3_BUCKET: process.env.S3_BUCKET, 50 | }, 51 | webpack: (config, { isServer }) => { 52 | // Less with Antd 53 | // https://github.com/ant-design/ant-design/issues/15696#issuecomment-683440468 54 | if (isServer) { 55 | const antStyles = /antd\/.*?\/style.*?/; 56 | const origExternals = [...config.externals]; 57 | config.externals = [ 58 | (context, request, callback) => { 59 | if (request.match(antStyles)) return callback(); 60 | if (typeof origExternals[0] === 'function') { 61 | origExternals[0](context, request, callback); 62 | } else { 63 | callback(); 64 | } 65 | }, 66 | ...(typeof origExternals[0] === 'function' 67 | ? [] 68 | : origExternals), 69 | ]; 70 | 71 | config.module.rules.unshift({ 72 | test: antStyles, 73 | use: 'null-loader', 74 | }); 75 | } 76 | 77 | // MDX 78 | if (!isServer) { 79 | config.node = { 80 | fs: 'empty', 81 | }; 82 | } 83 | 84 | // Sentry 85 | if (!isServer) { 86 | config.resolve.alias['@sentry/node'] = '@sentry/browser'; 87 | } 88 | 89 | return config; 90 | }, 91 | }; 92 | 93 | const lessWithAntdConfig = { 94 | lessLoaderOptions: { 95 | javascriptEnabled: true, 96 | }, 97 | }; 98 | 99 | const withBundleAnalyzer = bundleAnalyzer({ 100 | enabled: process.env.ANALYZE === 'true', 101 | }); 102 | 103 | module.exports = withPlugins( 104 | [ 105 | [withLess, lessWithAntdConfig], 106 | [withMDX], 107 | [withSourceMaps], 108 | [withBundleAnalyzer], 109 | ], 110 | nextConfig 111 | ); 112 | -------------------------------------------------------------------------------- /src/screens/Checkout/CheckoutWizardAccount/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import ReactCSSTransitionReplace from 'react-css-transition-replace'; 4 | 5 | import SignInForm from '@screens/SignIn/SignInForm'; 6 | import SignUpForm from '@screens/SignUp/SignUpForm'; 7 | import PasswordForgotForm from '@screens/PasswordForgot/PasswordForgotForm'; 8 | import FormAtomButton from '@components/Form/AtomButton'; 9 | 10 | const StyledPasswordForgotFooter = styled.div` 11 | display: flex; 12 | justify-content: flex-end; 13 | `; 14 | 15 | type PasswordForgotFooterProps = { 16 | onNavigateSignUp: () => void; 17 | onNavigateSignIn: () => void; 18 | }; 19 | 20 | const PasswordForgotFooter = ({ 21 | onNavigateSignUp, 22 | onNavigateSignIn, 23 | }: PasswordForgotFooterProps) => ( 24 | 25 | 26 | Nevermind.  27 | 32 | Sign in 33 | 34 |  or  35 | 40 | sign up 41 | 42 | . 43 | 44 | 45 | ); 46 | 47 | const SELECTIONS = { 48 | SIGN_IN: 'SIGN_IN', 49 | SIGN_UP: 'SIGN_UP', 50 | PASSWORD_FORGOT: 'PASSWORD_FORGOT', 51 | }; 52 | 53 | const FadeWait = styled.div` 54 | .fade-wait-leave { 55 | opacity: 1; 56 | } 57 | 58 | .fade-wait-leave.fade-wait-leave-active { 59 | opacity: 0; 60 | transition: opacity 0.4s ease-in; 61 | } 62 | 63 | .fade-wait-enter { 64 | opacity: 0; 65 | } 66 | 67 | .fade-wait-enter.fade-wait-enter-active { 68 | opacity: 1; 69 | /* Delay the enter animation until the leave completes */ 70 | transition: opacity 0.4s ease-in 0.6s; 71 | } 72 | 73 | .fade-wait-height { 74 | transition: height 0.6s ease-in-out; 75 | } 76 | `; 77 | 78 | type AccountProps = { 79 | onSuccess: () => void; 80 | }; 81 | 82 | const Account = ({ onSuccess }: AccountProps) => { 83 | const [currentSelection, setCurrentSelection] = React.useState( 84 | SELECTIONS.SIGN_IN 85 | ); 86 | 87 | const handleNavigateSignIn = () => { 88 | setCurrentSelection(SELECTIONS.SIGN_IN); 89 | }; 90 | 91 | const handleNavigateSignUp = () => { 92 | setCurrentSelection(SELECTIONS.SIGN_UP); 93 | }; 94 | 95 | const handleNavigatePasswordForgot = () => { 96 | setCurrentSelection(SELECTIONS.PASSWORD_FORGOT); 97 | }; 98 | 99 | return ( 100 | 101 | 106 |
    107 | {currentSelection === SELECTIONS.SIGN_IN && ( 108 | 113 | )} 114 | 115 | {currentSelection === SELECTIONS.SIGN_UP && ( 116 | 120 | )} 121 | 122 | {currentSelection === SELECTIONS.PASSWORD_FORGOT && ( 123 | <> 124 | 125 | 129 | 130 | )} 131 |
    132 |
    133 |
    134 | ); 135 | }; 136 | 137 | export default Account; 138 | -------------------------------------------------------------------------------- /src/screens/PasswordChange/PasswordChangeForm/spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, fireEvent, wait } from '@testing-library/react'; 2 | import { MockedProvider } from '@apollo/react-testing'; 3 | import { GraphQLError } from 'graphql'; 4 | import { message } from 'antd'; 5 | 6 | import { PASSWORD_CHANGE } from '@queries/session'; 7 | import PasswordChangeForm from '.'; 8 | 9 | describe('PasswordChangeForm', () => { 10 | const oldPassword = 'myoldpassword'; 11 | const newPassword = 'mynewpassword'; 12 | 13 | message.error = jest.fn(); 14 | 15 | let mutationCalled: boolean; 16 | 17 | beforeEach(() => { 18 | mutationCalled = false; 19 | }); 20 | 21 | it('changes a password with success', async () => { 22 | const mocks = [ 23 | { 24 | request: { 25 | query: PASSWORD_CHANGE, 26 | variables: { password: 'mynewpassword' }, 27 | }, 28 | result: () => { 29 | mutationCalled = true; 30 | return { data: { passwordChange: null } }; 31 | }, 32 | }, 33 | ]; 34 | 35 | const component = render( 36 | 37 | 38 | 39 | ); 40 | 41 | fireEvent.change( 42 | component.getByLabelText('password-change-password-old'), 43 | { 44 | target: { value: oldPassword }, 45 | } 46 | ); 47 | 48 | fireEvent.change( 49 | component.getByLabelText('password-change-password-new'), 50 | { 51 | target: { value: newPassword }, 52 | } 53 | ); 54 | 55 | fireEvent.change( 56 | component.getByLabelText('password-change-password-confirm'), 57 | { 58 | target: { value: newPassword }, 59 | } 60 | ); 61 | 62 | expect( 63 | component 64 | .getByLabelText('password-change-submit') 65 | .classList.contains('ant-btn-loading') 66 | ).toBe(false); 67 | 68 | fireEvent.click( 69 | component.getByLabelText('password-change-submit') 70 | ); 71 | 72 | expect( 73 | component 74 | .getByLabelText('password-change-submit') 75 | .classList.contains('ant-btn-loading') 76 | ).toBe(true); 77 | 78 | await wait(() => { 79 | expect(mutationCalled).toBe(true); 80 | expect(message.error).toHaveBeenCalledTimes(0); 81 | 82 | expect( 83 | component 84 | .getByLabelText('password-change-submit') 85 | .classList.contains('ant-btn-loading') 86 | ).toBe(false); 87 | }); 88 | }); 89 | 90 | it('changes a password with error', async () => { 91 | const mocks = [ 92 | { 93 | request: { 94 | query: PASSWORD_CHANGE, 95 | variables: { password: 'mynewpassword' }, 96 | }, 97 | result: () => { 98 | mutationCalled = true; 99 | return { errors: [new GraphQLError('Error!')] }; 100 | }, 101 | }, 102 | ]; 103 | 104 | const component = render( 105 | 106 | 107 | 108 | ); 109 | 110 | fireEvent.change( 111 | component.getByLabelText('password-change-password-old'), 112 | { 113 | target: { value: oldPassword }, 114 | } 115 | ); 116 | 117 | fireEvent.change( 118 | component.getByLabelText('password-change-password-new'), 119 | { 120 | target: { value: newPassword }, 121 | } 122 | ); 123 | 124 | fireEvent.change( 125 | component.getByLabelText('password-change-password-confirm'), 126 | { 127 | target: { value: newPassword }, 128 | } 129 | ); 130 | 131 | fireEvent.click( 132 | component.getByLabelText('password-change-submit') 133 | ); 134 | 135 | await wait(() => { 136 | expect(mutationCalled).toBe(true); 137 | expect(message.error).toHaveBeenCalledTimes(1); 138 | }); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /src/screens/SignUp/SignUpForm/spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, fireEvent, wait } from '@testing-library/react'; 2 | import { MockedProvider } from '@apollo/react-testing'; 3 | import { GraphQLError } from 'graphql'; 4 | import { message } from 'antd'; 5 | 6 | import { SIGN_UP } from '@queries/session'; 7 | import SignUpForm from '.'; 8 | 9 | describe('SignUpForm', () => { 10 | const username = 'myusername'; 11 | const email = 'example@example.com'; 12 | const password = 'mypassword'; 13 | 14 | const onSuccess = jest.fn(); 15 | 16 | message.error = jest.fn(); 17 | 18 | let mutationCalled: boolean; 19 | 20 | beforeEach(() => { 21 | mutationCalled = false; 22 | }); 23 | 24 | it('signs up with success', async () => { 25 | const mocks = [ 26 | { 27 | request: { 28 | query: SIGN_UP, 29 | variables: { username, email, password }, 30 | }, 31 | result: () => { 32 | mutationCalled = true; 33 | return { data: { signUp: { token: '1' } } }; 34 | }, 35 | }, 36 | ]; 37 | 38 | const component = render( 39 | 40 | 41 | 42 | ); 43 | 44 | fireEvent.change(component.getByLabelText('sign-up-username'), { 45 | target: { value: username }, 46 | }); 47 | 48 | fireEvent.change(component.getByLabelText('sign-up-email'), { 49 | target: { value: email }, 50 | }); 51 | 52 | fireEvent.change(component.getByLabelText('sign-up-password'), { 53 | target: { value: password }, 54 | }); 55 | 56 | fireEvent.change( 57 | component.getByLabelText('sign-up-password-confirm'), 58 | { 59 | target: { value: password }, 60 | } 61 | ); 62 | 63 | expect( 64 | component 65 | .getByLabelText('sign-up-submit') 66 | .classList.contains('ant-btn-loading') 67 | ).toBe(false); 68 | 69 | fireEvent.click(component.getByLabelText('sign-up-submit')); 70 | 71 | expect( 72 | component 73 | .getByLabelText('sign-up-submit') 74 | .classList.contains('ant-btn-loading') 75 | ).toBe(true); 76 | 77 | await wait(() => { 78 | expect(onSuccess).toHaveBeenCalledTimes(1); 79 | expect(message.error).toHaveBeenCalledTimes(0); 80 | expect(mutationCalled).toBe(true); 81 | 82 | expect( 83 | component 84 | .getByLabelText('sign-up-submit') 85 | .classList.contains('ant-btn-loading') 86 | ).toBe(false); 87 | }); 88 | }); 89 | 90 | it('signs up with error', async () => { 91 | const mocks = [ 92 | { 93 | request: { 94 | query: SIGN_UP, 95 | variables: { username, email, password }, 96 | }, 97 | result: () => { 98 | mutationCalled = true; 99 | return { errors: [new GraphQLError('Error!')] }; 100 | }, 101 | }, 102 | ]; 103 | 104 | const component = render( 105 | 106 | 107 | 108 | ); 109 | 110 | fireEvent.change(component.getByLabelText('sign-up-username'), { 111 | target: { value: username }, 112 | }); 113 | 114 | fireEvent.change(component.getByLabelText('sign-up-email'), { 115 | target: { value: email }, 116 | }); 117 | 118 | fireEvent.change(component.getByLabelText('sign-up-password'), { 119 | target: { value: password }, 120 | }); 121 | 122 | fireEvent.change( 123 | component.getByLabelText('sign-up-password-confirm'), 124 | { 125 | target: { value: password }, 126 | } 127 | ); 128 | 129 | fireEvent.click(component.getByLabelText('sign-up-submit')); 130 | 131 | await wait(() => { 132 | expect(mutationCalled).toBe(true); 133 | expect(onSuccess).toHaveBeenCalledTimes(0); 134 | expect(message.error).toHaveBeenCalledTimes(1); 135 | }); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /src/connectors/partner.ts: -------------------------------------------------------------------------------- 1 | import { Between } from 'typeorm'; 2 | import { Connection, Repository } from 'typeorm'; 3 | 4 | import { VisitorByDay, PartnerPayment } from '@api/resolvers/partner'; 5 | import { PartnerVisitor, PartnerSale } from '@models/partner'; 6 | import { Course } from '@models/course'; 7 | import { PARTNER_PERCENTAGE } from '@constants/partner'; 8 | 9 | const sameDay = (x: Date, y: Date) => { 10 | return ( 11 | x.getFullYear() === y.getFullYear() && 12 | x.getMonth() === y.getMonth() && 13 | x.getDate() === y.getDate() 14 | ); 15 | }; 16 | 17 | const sameMonth = (x: Date, y: Date) => { 18 | return ( 19 | x.getFullYear() === y.getFullYear() && 20 | x.getMonth() === y.getMonth() 21 | ); 22 | }; 23 | 24 | export class PartnerConnector { 25 | partnerVisitorRepository: Repository; 26 | partnerSaleRepository: Repository; 27 | 28 | constructor(connection: Connection) { 29 | this.partnerVisitorRepository = 30 | connection?.getRepository('PartnerVisitor'); 31 | 32 | this.partnerSaleRepository = 33 | connection?.getRepository('PartnerSale'); 34 | } 35 | 36 | async createSale(course: Course, partnerId: string) { 37 | const royalty = Math.round( 38 | (course.price / 100) * PARTNER_PERCENTAGE 39 | ); 40 | 41 | const partnerSale = new PartnerSale(); 42 | 43 | partnerSale.partnerId = partnerId; 44 | partnerSale.royalty = royalty; 45 | partnerSale.course = course; 46 | 47 | return await this.partnerSaleRepository.save(partnerSale); 48 | } 49 | 50 | async getSalesByPartner( 51 | userId: string, 52 | offset: number, 53 | limit: number 54 | ) { 55 | const edges = await this.partnerSaleRepository.find({ 56 | where: { partnerId: userId }, 57 | relations: ['course'], 58 | skip: offset, 59 | take: limit, 60 | }); 61 | 62 | const total = await this.partnerSaleRepository.count({ 63 | partnerId: userId, 64 | }); 65 | 66 | return { edges, total }; 67 | } 68 | 69 | async getPaymentsByPartner(userId: string) { 70 | const partnerSales = await this.partnerSaleRepository.find({ 71 | where: { partnerId: userId }, 72 | }); 73 | 74 | const aggregateByMonth = ( 75 | acc: PartnerPayment[], 76 | dbValue: PartnerSale 77 | ) => { 78 | const prevValue = acc[acc.length - 1]; 79 | 80 | const newEntry = 81 | !acc.length || 82 | !sameMonth(prevValue.createdAt, dbValue.createdAt); 83 | 84 | if (newEntry) { 85 | acc = acc.concat({ 86 | createdAt: dbValue.createdAt, 87 | royalty: dbValue.royalty, 88 | }); 89 | } else { 90 | acc[acc.length - 1].royalty = 91 | prevValue.royalty + dbValue.royalty; 92 | } 93 | 94 | return acc; 95 | }; 96 | 97 | return partnerSales.reduce(aggregateByMonth, []); 98 | } 99 | 100 | async createVisitor(partnerId: string) { 101 | const partnerVisitor = new PartnerVisitor(); 102 | 103 | partnerVisitor.partnerId = partnerId; 104 | 105 | return await this.partnerVisitorRepository.save(partnerVisitor); 106 | } 107 | 108 | async getVisitorsBetween(from: Date, to: Date) { 109 | return await this.partnerVisitorRepository.find({ 110 | createdAt: Between(from, to), 111 | }); 112 | } 113 | 114 | async getVisitorsBetweenAggregatedByDate(from: Date, to: Date) { 115 | const visitors = await this.getVisitorsBetween(from, to); 116 | 117 | const aggregateByDay = ( 118 | acc: VisitorByDay[], 119 | dbValue: PartnerVisitor 120 | ) => { 121 | const prevValue = acc[acc.length - 1]; 122 | 123 | const newEntry = 124 | !acc.length || !sameDay(prevValue.date, dbValue.createdAt); 125 | 126 | if (newEntry) { 127 | acc = acc.concat({ 128 | date: dbValue.createdAt, 129 | count: 1, 130 | }); 131 | } else { 132 | acc[acc.length - 1].count = prevValue.count + 1; 133 | } 134 | 135 | return acc; 136 | }; 137 | 138 | return visitors.reduce(aggregateByDay, []); 139 | } 140 | } 141 | --------------------------------------------------------------------------------